From c7915e2c3d0d10bf4f5b6bbd5f1ede836b17a48b Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Mon, 17 Nov 2025 10:10:26 +0530 Subject: [PATCH 1/8] feat: launch command palette --- frontend/components.d.ts | 1 + frontend/src/components/AppSidebar.vue | 49 +++++++++++++------ .../CommandPalette/CommandPalette.vue | 10 ++++ frontend/src/stores/settings.js | 2 + frontend/src/utils/index.js | 5 ++ 5 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/CommandPalette/CommandPalette.vue diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 374bf990..fd58e52a 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -42,6 +42,7 @@ declare module 'vue' { CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default'] CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default'] ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default'] + CommandPalette: typeof import('./src/components/CommandPalette/CommandPalette.vue')['default'] ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default'] CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default'] CouponItems: typeof import('./src/components/Settings/Coupons/CouponItems.vue')['default'] diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 9334c63e..31bd5e0b 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -173,6 +173,7 @@ :currentStep="currentStep" /> + diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index 539a292f..e8b4ba70 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -5,6 +5,7 @@ import { sessionStore } from './session' export const useSettings = defineStore('settings', () => { const isSettingsOpen = ref(false) + const isCommandPaletteOpen = ref(false) const activeTab = ref(null) const allowGuestAccess = createResource({ @@ -50,6 +51,7 @@ export const useSettings = defineStore('settings', () => { return { isSettingsOpen, + isCommandPaletteOpen, activeTab, allowGuestAccess, preventSkippingVideos, diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index fb24c9f7..13cb4e7e 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -403,6 +403,11 @@ export function getUserTimezone() { export function getSidebarLinks() { return [ + { + label: 'Search', + icon: 'Search', + to: 'Search', + }, { label: 'Courses', icon: 'BookOpen', From eab43a66cfdef3b0e8a631ac3c52931f585efef8 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Tue, 25 Nov 2025 19:44:31 +0530 Subject: [PATCH 2/8] feat: improved search results in command palette --- frappe-ui | 2 +- frontend/components.d.ts | 4 + .../CommandPalette/CommandPalette.vue | 249 +++++++++++++++++- .../CommandPalette/CommandPaletteGroup.vue | 44 ++++ frontend/src/pages/Search/Search.vue | 11 + frontend/src/router.js | 5 + lms/command_palette.py | 29 ++ lms/hooks.py | 8 + lms/lms/doctype/lms_course/lms_course.json | 6 +- lms/sqlite.py | 122 +++++++++ pyproject.toml | 2 +- 11 files changed, 474 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/CommandPalette/CommandPaletteGroup.vue create mode 100644 frontend/src/pages/Search/Search.vue create mode 100644 lms/command_palette.py create mode 100644 lms/sqlite.py diff --git a/frappe-ui b/frappe-ui index 8d5956c0..204333c9 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit 8d5956c0c675a13d32d3ef8035389128fa4735d5 +Subproject commit 204333c9256f21fca5f5c50acd66cee11aeca4f3 diff --git a/frontend/components.d.ts b/frontend/components.d.ts index fd58e52a..b52f8116 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -43,6 +43,7 @@ declare module 'vue' { CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default'] ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default'] CommandPalette: typeof import('./src/components/CommandPalette/CommandPalette.vue')['default'] + CommandPaletteGroup: typeof import('./src/components/CommandPalette/CommandPaletteGroup.vue')['default'] ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default'] CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default'] CouponItems: typeof import('./src/components/Settings/Coupons/CouponItems.vue')['default'] @@ -85,6 +86,9 @@ declare module 'vue' { LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default'] LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default'] LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default'] + LucideArrowDown: typeof import('~icons/lucide/arrow-down')['default'] + LucideArrowUp: typeof import('~icons/lucide/arrow-up')['default'] + LucideCornerDownLeft: typeof import('~icons/lucide/corner-down-left')['default'] Members: typeof import('./src/components/Settings/Members.vue')['default'] MobileLayout: typeof import('./src/components/MobileLayout.vue')['default'] MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default'] diff --git a/frontend/src/components/CommandPalette/CommandPalette.vue b/frontend/src/components/CommandPalette/CommandPalette.vue index 25bc3d3e..c4fdf036 100644 --- a/frontend/src/components/CommandPalette/CommandPalette.vue +++ b/frontend/src/components/CommandPalette/CommandPalette.vue @@ -1,10 +1,253 @@ + diff --git a/frontend/src/components/CommandPalette/CommandPaletteGroup.vue b/frontend/src/components/CommandPalette/CommandPaletteGroup.vue new file mode 100644 index 00000000..cf34b593 --- /dev/null +++ b/frontend/src/components/CommandPalette/CommandPaletteGroup.vue @@ -0,0 +1,44 @@ + + diff --git a/frontend/src/pages/Search/Search.vue b/frontend/src/pages/Search/Search.vue new file mode 100644 index 00000000..bc577e7b --- /dev/null +++ b/frontend/src/pages/Search/Search.vue @@ -0,0 +1,11 @@ + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 9b0234f8..c9fba46e 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -243,6 +243,11 @@ const routes = [ ), props: true, }, + { + path: '/search', + name: 'Search', + component: () => import('@/pages/Search/Search.vue'), + }, ] let router = createRouter({ diff --git a/lms/command_palette.py b/lms/command_palette.py new file mode 100644 index 00000000..077c9697 --- /dev/null +++ b/lms/command_palette.py @@ -0,0 +1,29 @@ +import frappe + + +@frappe.whitelist() +def search_sqlite(query: str): + from lms.sqlite import LearningSearch, LearningSearchIndexMissingError + + search = LearningSearch() + + try: + result = search.search(query) + except LearningSearchIndexMissingError: + return [] + + groups = {} + print(result) + for r in result["results"]: + doctype = r["doctype"] + + if doctype == "LMS Course": + groups.setdefault("Courses", []).append(r) + elif doctype == "LMS Batch": + groups.setdefault("Batches", []).append(r) + + out = [] + for key in groups: + out.append({"title": key, "items": groups[key]}) + + return out diff --git a/lms/hooks.py b/lms/hooks.py index 038eee9d..361aa3b2 100644 --- a/lms/hooks.py +++ b/lms/hooks.py @@ -64,6 +64,9 @@ after_install = "lms.install.after_install" after_sync = "lms.install.after_sync" before_uninstall = "lms.install.before_uninstall" setup_wizard_requires = "assets/lms/js/setup_wizard.js" +after_migrate = [ + "lms.sqlite.build_index_in_background", +] # Desk Notifications # ------------------ @@ -112,6 +115,9 @@ doc_events = { # Scheduled Tasks # --------------- scheduler_events = { + "all": [ + "lms.sqlite.build_index_in_background", + ], "hourly": [ "lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals", "lms.lms.api.update_course_statistics", @@ -251,3 +257,5 @@ add_to_apps_screen = [ "has_permission": "lms.lms.api.check_app_permission", } ] + +sqlite_search = ["lms.sqlite.LearningSearch"] diff --git a/lms/lms/doctype/lms_course/lms_course.json b/lms/lms/doctype/lms_course/lms_course.json index 20fc0482..b30ed240 100644 --- a/lms/lms/doctype/lms_course/lms_course.json +++ b/lms/lms/doctype/lms_course/lms_course.json @@ -76,6 +76,8 @@ "default": "0", "fieldname": "published", "fieldtype": "Check", + "in_list_view": 1, + "in_standard_filter": 1, "label": "Published" }, { @@ -152,8 +154,6 @@ "fieldname": "status", "fieldtype": "Select", "hidden": 1, - "in_list_view": 1, - "in_standard_filter": 1, "label": "Status", "options": "In Progress\nUnder Review\nApproved", "read_only": 1 @@ -313,7 +313,7 @@ } ], "make_attachments_public": 1, - "modified": "2025-10-13 15:08:11.734204", + "modified": "2025-11-25 11:35:17.924569", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Course", diff --git a/lms/sqlite.py b/lms/sqlite.py new file mode 100644 index 00000000..f8f30e03 --- /dev/null +++ b/lms/sqlite.py @@ -0,0 +1,122 @@ +from contextlib import suppress + +import frappe +from frappe.search.sqlite_search import SQLiteSearch, SQLiteSearchIndexMissingError +from frappe.utils import update_progress_bar +from redis.exceptions import ResponseError + + +class LearningSearch(SQLiteSearch): + INDEX_NAME = "learning.db" + + INDEX_SCHEMA = { + "metadata_fields": ["category", "owner", "published"], + "tokenizer": "unicode61 remove_diacritics 2 tokenchars '-_'", + } + + INDEXABLE_DOCTYPES = { + "LMS Course": { + "fields": [ + "name", + "title", + {"content": "description"}, + "short_introduction", + "published", + "category", + "owner", + {"modified": "published_on"}, + ], + }, + "LMS Batch": { + "fields": [ + "name", + "title", + "description", + {"content": "batch_details"}, + "published", + "category", + "owner", + {"modified": "start_date"}, + ], + }, + } + + DOCTYPE_FIELDS = { + "LMS Course": [ + "name", + "title", + "description", + "short_introduction", + "category", + "creation", + "modified", + "owner", + ], + "LMS Batch": [ + "name", + "title", + "description", + "batch_details", + "category", + "creation", + "modified", + "owner", + ], + } + + def can_create_course(self, roles): + return "Course Creator" in roles or "Moderator" in roles + + def can_create_batch(self, roles): + return "Batch Evaluator" in roles or "Moderator" in roles + + def get_records(self, doctype): + records = [] + roles = frappe.get_roles() + filters = {} + + if doctype == "LMS Course": + if not self.can_create_course(roles): + filters = {"published": 1} + + if doctype == "LMS Batch": + if not self.can_create_batch(roles): + filters = {"published": 1} + + records = frappe.db.get_all(doctype, filters=filters, fields=self.DOCTYPE_FIELDS[doctype]) + for record in records: + record["doctype"] = doctype + + return records + + def build_index(self): + try: + super().build_index() + except Exception as e: + frappe.throw(e) + + def get_search_filters(self): + roles = frappe.get_roles() + if not (self.can_create_course(roles) and self.can_create_batch(roles)): + return {"published": 1} + return {} + + +class LearningSearchIndexMissingError(SQLiteSearchIndexMissingError): + pass + + +def build_index(): + search = LearningSearch() + search.build_index() + + +def build_index_in_background(): + if not frappe.cache().get_value("learning_search_indexing_in_progress"): + frappe.enqueue(build_index, queue="long") + + +def build_index_if_not_exists(): + search = LearningSearch() + if not search.index_exists(): + build_index() diff --git a/pyproject.toml b/pyproject.toml index fc16f51d..7d7f4193 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ # core dependencies "websocket_client~=1.6.4", "markdown~=3.5.1", - "beautifulsoup4~=4.12.2", + "beautifulsoup4~=4.13.4", "lxml~=4.9.3", "cairocffi==1.5.1", "razorpay~=1.4.1", From 7e3c5beaea56ee62b60cd49bc0317c8a182ea7ab Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Wed, 10 Dec 2025 11:17:21 +0530 Subject: [PATCH 3/8] fix: filter search records based on roles --- frontend/components.d.ts | 2 + .../CommandPalette/CommandPalette.vue | 21 +++++++-- .../CommandPalette/CommandPaletteGroup.vue | 5 +- .../src/components/Sidebar/AppSidebar.vue | 7 ++- frontend/src/components/Sidebar/Apps.vue | 2 +- frontend/vite.config.js | 2 +- lms/command_palette.py | 43 +++++++++++++++-- lms/sqlite.py | 46 ++++++------------- 8 files changed, 81 insertions(+), 47 deletions(-) diff --git a/frontend/components.d.ts b/frontend/components.d.ts index f7cc0a5d..95951283 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -90,6 +90,8 @@ declare module 'vue' { LucideArrowDown: typeof import('~icons/lucide/arrow-down')['default'] LucideArrowUp: typeof import('~icons/lucide/arrow-up')['default'] LucideCornerDownLeft: typeof import('~icons/lucide/corner-down-left')['default'] + LucideSearch: typeof import('~icons/lucide/search')['default'] + LucideX: typeof import('~icons/lucide/x')['default'] Members: typeof import('./src/components/Settings/Members.vue')['default'] MobileLayout: typeof import('./src/components/MobileLayout.vue')['default'] MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default'] diff --git a/frontend/src/components/CommandPalette/CommandPalette.vue b/frontend/src/components/CommandPalette/CommandPalette.vue index c4fdf036..27e6eb4b 100644 --- a/frontend/src/components/CommandPalette/CommandPalette.vue +++ b/frontend/src/components/CommandPalette/CommandPalette.vue @@ -18,11 +18,17 @@
- +
- +
@@ -61,7 +67,8 @@ From 820ea7e2a4f1ab7d4c7a88ce087f8fda4398e5a0 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Thu, 11 Dec 2025 14:56:28 +0530 Subject: [PATCH 5/8] feat: search page functionality --- .../CommandPalette/CommandPalette.vue | 2 +- .../src/components/Sidebar/AppSidebar.vue | 9 +- frontend/src/pages/Search/Search.vue | 107 ++++++++++++------ lms/command_palette.py | 21 +++- 4 files changed, 98 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/CommandPalette/CommandPalette.vue b/frontend/src/components/CommandPalette/CommandPalette.vue index 27e6eb4b..6fd6a02d 100644 --- a/frontend/src/components/CommandPalette/CommandPalette.vue +++ b/frontend/src/components/CommandPalette/CommandPalette.vue @@ -215,7 +215,7 @@ const shortcutForEnter = () => { const navigateTo = (route: string) => { show.value = false query.value = '' - router.push({ name: route.name, params: route.params, query: route.query }) + router.replace({ name: route.name, params: route.params, query: route.query }) } const jumpToOptions = ref([ diff --git a/frontend/src/components/Sidebar/AppSidebar.vue b/frontend/src/components/Sidebar/AppSidebar.vue index 39607382..b51e72a5 100644 --- a/frontend/src/components/Sidebar/AppSidebar.vue +++ b/frontend/src/components/Sidebar/AppSidebar.vue @@ -240,7 +240,14 @@ const showPageModal = ref(false) const isModerator = ref(false) const isInstructor = ref(false) const pageToEdit = ref(null) -const { settings, sidebarSettings, activeTab, isSettingsOpen } = useSettings() +const { + settings, + sidebarSettings, + activeTab, + isSettingsOpen, + isCommandPaletteOpen, +} = useSettings() +const settingsStore = useSettings() const showOnboarding = ref(false) const showIntermediateModal = ref(false) const currentStep = ref({}) diff --git a/frontend/src/pages/Search/Search.vue b/frontend/src/pages/Search/Search.vue index d04c947b..c0d9a3ff 100644 --- a/frontend/src/pages/Search/Search.vue +++ b/frontend/src/pages/Search/Search.vue @@ -4,7 +4,7 @@ > -
+
-
+
{{ searchResults.length }} {{ searchResults.length === 1 ? __('match') : __('matches') }}
+
+ {{ __('Press enter to search') }} +
+
+ {{ __('No results found') }} +
-
+
- - - -
-
-
-
- {{ result.doctype == 'LMS Course' ? 'Course' : 'Batch' }} -
-
- {{ - dayjs(result.published_on || result.start_date).format( - 'DD MMM YYYY' - ) - }} +
+ + + +
+
+
+
+ {{ result.doctype == 'LMS Course' ? 'Course' : 'Batch' }} +
+
+ {{ + dayjs(result.published_on || result.start_date).format( + 'DD MMM YYYY' + ) + }} +
+
-
@@ -85,23 +103,23 @@ import { Tooltip, usePageMeta, } from 'frappe-ui' -import { inject, onMounted, ref } from 'vue' +import { inject, onMounted, ref, watch } from 'vue' import { Search, X } from 'lucide-vue-next' import { sessionStore } from '@/stores/session' -import { useRouter } from 'vue-router' +import { useRouter, useRoute } from 'vue-router' const query = ref('') const searchInput = ref(null) -const newSearch = ref(false) const searchResults = ref>([]) const { brand } = sessionStore() const router = useRouter() +const route = useRoute() +const queryChanged = ref(false) const dayjs = inject('$dayjs') onMounted(() => { if (router.currentRoute.value.query.q) { query.value = router.currentRoute.value.query.q as string - searchInput.value.el.focus() submit() } }) @@ -130,12 +148,12 @@ const search = createResource({ const generateSearchResults = () => { searchResults.value = [] if (search.data) { + queryChanged.value = false search.data.forEach((group: any) => { group.items.forEach((item: any) => { searchResults.value.push(item) }) }) - // sort Search results by item.score descending searchResults.value.sort((a, b) => b.score - a.score) } } @@ -158,9 +176,28 @@ const navigate = (result: any) => { } } +watch(query, () => { + if (query.value && query.value != search.params?.query) { + queryChanged.value = true + } else if (!query.value) { + queryChanged.value = false + searchResults.value = [] + } +}) + +watch( + () => route.query.q, + (newQ) => { + if (newQ && newQ !== query.value) { + query.value = newQ as string + submit() + } + } +) + const clearSearch = () => { query.value = '' - searchInput.value?.focus() + updateQuery('') } usePageMeta(() => { diff --git a/lms/command_palette.py b/lms/command_palette.py index 8b7b4a87..fed94b3e 100644 --- a/lms/command_palette.py +++ b/lms/command_palette.py @@ -23,10 +23,10 @@ def prepare_search_results(result): for r in result["results"]: doctype = r["doctype"] if doctype == "LMS Course" and can_access_course(r, roles): - r["author_info"] = get_author_info(r.get("author")) + r["instructors_info"] = get_instructor_info(doctype, r) groups.setdefault("Courses", []).append(r) elif doctype == "LMS Batch" and can_access_batch(r, roles): - r["author_info"] = get_author_info(r.get("author")) + r["instructors_info"] = get_instructor_info(doctype, r) groups.setdefault("Batches", []).append(r) out = [] @@ -60,5 +60,18 @@ def can_create_batch(roles): return "Batch Evaluator" in roles or "Moderator" in roles -def get_author_info(owner): - return frappe.db.get_value("User", owner, ["full_name", "user_image", "username", "email"], as_dict=True) +def get_instructor_info(doctype, record): + instructors = frappe.get_all( + "Course Instructor", filters={"parenttype": doctype, "parent": record.get("name")}, pluck="instructor" + ) + + instructor = record.get("author") + if len(instructors): + instructor = instructors[0] + + return frappe.db.get_value( + "User", + instructor, + ["full_name", "email", "user_image", "username"], + as_dict=True, + ) From 819318de37000848206fd2ce24f25ccea6302b5d Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Fri, 12 Dec 2025 12:30:16 +0530 Subject: [PATCH 6/8] feat: broke down sidebar into categories --- .../src/components/Sidebar/AppSidebar.vue | 167 +++------------ frontend/src/stores/settings.js | 9 +- frontend/src/utils/index.js | 194 +++++++++++++++--- lms/lms/doctype/lms_course/lms_course.json | 4 +- 4 files changed, 203 insertions(+), 171 deletions(-) diff --git a/frontend/src/components/Sidebar/AppSidebar.vue b/frontend/src/components/Sidebar/AppSidebar.vue index b51e72a5..cdc8c94e 100644 --- a/frontend/src/components/Sidebar/AppSidebar.vue +++ b/frontend/src/components/Sidebar/AppSidebar.vue @@ -9,11 +9,21 @@ >
-
- +
+
+ {{ __(link.label) }} +
+
{ - addNotifications() - setSidebarLinks() setUpOnboarding() addKeyboardShortcut() socket.on('publish_lms_notifications', (data) => { @@ -278,9 +281,14 @@ const setSidebarLinks = () => { onSuccess(data) { Object.keys(data).forEach((key) => { if (!parseInt(data[key])) { - sidebarLinks.value = sidebarLinks.value.filter( + sidebarLinks.value.forEach((link) => { + link.items = link.items.filter( + (item) => item.label.toLowerCase().split(' ').join('_') !== key + ) + }) + /* sidebarLinks.value = sidebarLinks.value?.items.filter( (link) => link.label.toLowerCase().split(' ').join('_') !== key - ) + ) */ } }) }, @@ -319,85 +327,16 @@ const unreadNotifications = createResource({ }, onSuccess(data) { unreadCount.value = data - sidebarLinks.value = sidebarLinks.value.map((link) => { + /* sidebarLinks.value = sidebarLinks.value.map((link) => { if (link.label === 'Notifications') { link.count = data } return link - }) + }) */ }, auto: user ? true : false, }) -const addNotifications = () => { - if (user) { - sidebarLinks.value.push({ - label: 'Notifications', - icon: 'Bell', - to: 'Notifications', - activeFor: ['Notifications'], - count: unreadCount.value, - }) - } -} - -const addQuizzes = () => { - if (!isInstructor.value && !isModerator.value) return - - const quizzesLinkExists = sidebarLinks.value.some( - (link) => link.label === 'Quizzes' - ) - if (quizzesLinkExists) return - - sidebarLinks.value.splice(4, 0, { - label: 'Quizzes', - icon: 'CircleHelp', - to: 'Quizzes', - activeFor: ['Quizzes', 'QuizForm', 'QuizSubmissionList', 'QuizSubmission'], - }) -} - -const addAssignments = () => { - if (!isInstructor.value && !isModerator.value) return - - const assignmentsLinkExists = sidebarLinks.value.some( - (link) => link.label === 'Assignments' - ) - if (assignmentsLinkExists) return - - sidebarLinks.value.splice(5, 0, { - label: 'Assignments', - icon: 'Pencil', - to: 'Assignments', - activeFor: [ - 'Assignments', - 'AssignmentForm', - 'AssignmentSubmissionList', - 'AssignmentSubmission', - ], - }) -} - -const addProgrammingExercises = () => { - if (!isInstructor.value && !isModerator.value) return - const programmingExercisesLinkExists = sidebarLinks.value.some( - (link) => link.label === 'Programming Exercises' - ) - if (programmingExercisesLinkExists) return - - sidebarLinks.value.splice(3, 0, { - label: 'Programming Exercises', - icon: 'Code', - to: 'ProgrammingExercises', - activeFor: [ - 'ProgrammingExercises', - 'ProgrammingExerciseForm', - 'ProgrammingExerciseSubmissions', - 'ProgrammingExerciseSubmission', - ], - }) -} - const addPrograms = async () => { const programsLinkExists = sidebarLinks.value.some( (link) => link.label === 'Programs' @@ -417,45 +356,6 @@ const addPrograms = async () => { }) } -const addContactUsDetails = () => { - if (!settings?.data?.contact_us_email && !settings?.data?.contact_us_url) - return - - const contactUsLinkExists = sidebarLinks.value.some( - (link) => link.label === 'Contact Us' - ) - if (contactUsLinkExists) return - - sidebarLinks.value.push({ - label: 'Contact Us', - icon: settings.data?.contact_us_url ? 'Headset' : 'Mail', - to: settings.data?.contact_us_url - ? settings.data?.contact_us_url - : settings.data?.contact_us_email, - }) -} - -const checkIfCanAddProgram = async () => { - if (isModerator.value || isInstructor.value) { - return true - } - const programs = await call('lms.lms.utils.get_programs') - return programs.enrolled.length > 0 || programs.published.length > 0 -} - -const addHome = () => { - const homeLinkExists = sidebarLinks.value.some( - (link) => link.label === 'Home' - ) - if (homeLinkExists) return - sidebarLinks.value.unshift({ - label: 'Home', - icon: 'Home', - to: 'Home', - activeFor: ['Home'], - }) -} - const openPageModal = (link) => { showPageModal.value = true pageToEdit.value = link @@ -700,16 +600,15 @@ const setUpOnboarding = () => { } } -watch(userResource, () => { - addContactUsDetails() +watch(userResource, async () => { + await userResource.promise + sidebarLinks.value = getSidebarLinks() + setSidebarLinks() if (userResource.data) { isModerator.value = userResource.data.is_moderator isInstructor.value = userResource.data.is_instructor - addHome() - addPrograms() - addProgrammingExercises() - addQuizzes() - addAssignments() + await programs.promise + sidebarLinks.value = getSidebarLinks() setUpOnboarding() } }) diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index f1689f0d..b0cf32f0 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -1,7 +1,6 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { createResource } from 'frappe-ui' -import { sessionStore } from './session' export const useSettings = defineStore('settings', () => { const isSettingsOpen = ref(false) @@ -20,10 +19,16 @@ export const useSettings = defineStore('settings', () => { auto: false, }) + const programs = createResource({ + url: 'lms.lms.utils.get_programs', + auto: false, + }) + return { + activeTab, isSettingsOpen, isCommandPaletteOpen, - activeTab, + programs, settings, sidebarSettings, } diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 6b46946c..d18bc98a 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -403,51 +403,177 @@ export function getUserTimezone() { } export function getSidebarLinks() { + let links = getSidebarItems() + + links.forEach((link) => { + link.items = link.items.filter((item) => { + return item.condition ? item.condition() : true + }) + }) + + links = links.filter((link) => { + return link.items.length > 0 + }) + + return links +} + +const getSidebarItems = () => { + const { userResource } = usersStore() + const { settings } = useSettings() + return [ { - label: 'Search', - icon: 'Search', - to: 'Search', - }, - { - label: 'Courses', - icon: 'BookOpen', - to: 'Courses', - activeFor: [ - 'Courses', - 'CourseDetail', - 'Lesson', - 'CourseForm', - 'LessonForm', + label: 'General', + hideLabel: true, + items: [ + { + label: 'Home', + icon: 'Home', + to: 'Home', + condition: () => { + return userResource?.data + }, + }, + { + label: 'Search', + icon: 'Search', + to: 'Search', + condition: () => { + return userResource?.data + }, + }, + { + label: 'Notifications', + icon: 'Bell', + to: 'Notifications', + condition: () => { + return userResource?.data + }, + }, ], }, { - label: 'Batches', - icon: 'Users', - to: 'Batches', - activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'], + label: 'Learning', + hideLabel: true, + items: [ + { + label: 'Courses', + icon: 'BookOpen', + to: 'Courses', + activeFor: [ + 'Courses', + 'CourseDetail', + 'Lesson', + 'CourseForm', + 'LessonForm', + ], + }, + { + label: 'Programs', + icon: 'Route', + to: 'Programs', + activeFor: ['Programs', 'ProgramDetail'], + await: true, + condition: () => { + return checkIfCanAddProgram() + }, + }, + { + label: 'Batches', + icon: 'Users', + to: 'Batches', + activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'], + }, + { + label: 'Certifications', + icon: 'GraduationCap', + to: 'CertifiedParticipants', + activeFor: ['CertifiedParticipants'], + }, + { + label: 'Jobs', + icon: 'Briefcase', + to: 'Jobs', + activeFor: ['Jobs', 'JobDetail'], + }, + { + label: 'Statistics', + icon: 'TrendingUp', + to: 'Statistics', + activeFor: ['Statistics'], + }, + { + label: 'Contact Us', + icon: settings.data?.contact_us_url ? 'Headset' : 'Mail', + to: settings.data?.contact_us_url + ? settings.data?.contact_us_url + : settings.data?.contact_us_email, + condition: () => { + return ( + settings?.data?.contact_us_email || + settings?.data?.contact_us_url + ) + }, + }, + ], }, { - label: 'Certifications', - icon: 'GraduationCap', - to: 'CertifiedParticipants', - activeFor: ['CertifiedParticipants'], - }, - { - label: 'Jobs', - icon: 'Briefcase', - to: 'Jobs', - activeFor: ['Jobs', 'JobDetail'], - }, - { - label: 'Statistics', - icon: 'TrendingUp', - to: 'Statistics', - activeFor: ['Statistics'], + label: 'Assessments', + hideLabel: true, + items: [ + { + label: 'Quizzes', + icon: 'CircleHelp', + to: 'Quizzes', + condition: () => { + return isAdmin() + }, + }, + { + label: 'Assignments', + icon: 'Pencil', + to: 'Assignments', + condition: () => { + return isAdmin() + }, + }, + { + label: 'Programming Exercises', + icon: 'Code', + to: 'ProgrammingExercises', + condition: () => { + return isAdmin() + }, + }, + ], }, ] } +const isAdmin = () => { + const { userResource } = usersStore() + return ( + userResource?.data?.is_instructor || + userResource?.data?.is_moderator || + userResource.data?.is_evaluator + ) +} + +const checkIfCanAddProgram = () => { + const { userResource } = usersStore() + const { programs } = useSettings() + if (!userResource.data) return false + if (userResource?.data?.is_moderator || userResource?.data?.is_instructor) { + return true + } + console.log('programs.data', programs.data) + return ( + programs.data?.enrolled.length > 0 || + programs.data?.published.length > 0 + ) +} + export function getFormattedDateRange( startDate, endDate, diff --git a/lms/lms/doctype/lms_course/lms_course.json b/lms/lms/doctype/lms_course/lms_course.json index b30ed240..f4b03ffa 100644 --- a/lms/lms/doctype/lms_course/lms_course.json +++ b/lms/lms/doctype/lms_course/lms_course.json @@ -313,7 +313,7 @@ } ], "make_attachments_public": 1, - "modified": "2025-11-25 11:35:17.924569", + "modified": "2025-12-11 17:21:05.231761", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Course", @@ -336,6 +336,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -348,6 +349,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, From f49bb98b926975a3bb0e97fa819bc8e89d400957 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Fri, 12 Dec 2025 16:25:45 +0530 Subject: [PATCH 7/8] fix: sidebar improvements --- .../CommandPalette/CommandPalette.vue | 14 ++++-- .../src/components/Sidebar/AppSidebar.vue | 43 ++++++------------- frontend/src/utils/index.js | 1 - 3 files changed, 22 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/CommandPalette/CommandPalette.vue b/frontend/src/components/CommandPalette/CommandPalette.vue index 6fd6a02d..a32cbb00 100644 --- a/frontend/src/components/CommandPalette/CommandPalette.vue +++ b/frontend/src/components/CommandPalette/CommandPalette.vue @@ -10,7 +10,6 @@ placeholder="Search" class="w-full border-none bg-transparent py-3 !pl-2 pr-4.5 text-base text-ink-gray-7 placeholder-ink-gray-4 focus:ring-0" @input="onInput" - @keydown="onKeyDown" v-model="query" autocomplete="off" /> @@ -108,7 +107,7 @@ const onInput = () => { const generateSearchResults = () => { search.data?.forEach((type: any) => { - let result = {} + let result: { title: string; items: any[] } = { title: '', items: [] } result.title = type.title type.items.forEach((item: any) => { let paramName = item.doctype === 'LMS Course' ? 'courseName' : 'batchName' @@ -126,7 +125,10 @@ const generateSearchResults = () => { } const appendSearchPage = () => { - let searchPage = {} + let searchPage: { title: string; items: Array } = { + title: '', + items: [], + } searchPage.title = __('Jump to') searchPage.items = [ { @@ -212,7 +214,11 @@ const shortcutForEnter = () => { } } -const navigateTo = (route: string) => { +const navigateTo = (route: { + name: string + params?: Record + query?: Record +}) => { show.value = false query.value = '' router.replace({ name: route.name, params: route.params, query: route.query }) diff --git a/frontend/src/components/Sidebar/AppSidebar.vue b/frontend/src/components/Sidebar/AppSidebar.vue index cdc8c94e..fbb95344 100644 --- a/frontend/src/components/Sidebar/AppSidebar.vue +++ b/frontend/src/components/Sidebar/AppSidebar.vue @@ -250,8 +250,7 @@ const showPageModal = ref(false) const isModerator = ref(false) const isInstructor = ref(false) const pageToEdit = ref(null) -const { settings, sidebarSettings, activeTab, isSettingsOpen, programs } = - useSettings() +const { sidebarSettings, activeTab, isSettingsOpen, programs } = useSettings() const settingsStore = useSettings() const showOnboarding = ref(false) const showIntermediateModal = ref(false) @@ -286,9 +285,6 @@ const setSidebarLinks = () => { (item) => item.label.toLowerCase().split(' ').join('_') !== key ) }) - /* sidebarLinks.value = sidebarLinks.value?.items.filter( - (link) => link.label.toLowerCase().split(' ').join('_') !== key - ) */ } }) }, @@ -327,32 +323,18 @@ const unreadNotifications = createResource({ }, onSuccess(data) { unreadCount.value = data - /* sidebarLinks.value = sidebarLinks.value.map((link) => { - if (link.label === 'Notifications') { - link.count = data - } - return link - }) */ + updateUnreadCount() }, auto: user ? true : false, }) -const addPrograms = async () => { - const programsLinkExists = sidebarLinks.value.some( - (link) => link.label === 'Programs' - ) - if (programsLinkExists) return - - let canAddProgram = await checkIfCanAddProgram() - if (!canAddProgram) return - let activeFor = ['Programs', 'ProgramDetail'] - let index = 2 - - sidebarLinks.value.splice(index, 0, { - label: 'Programs', - icon: 'Route', - to: 'Programs', - activeFor: activeFor, +const updateUnreadCount = () => { + sidebarLinks.value?.forEach((link) => { + link.items.forEach((item) => { + if (item.label === 'Notifications') { + item.count = unreadCount.value || 0 + } + }) }) } @@ -602,15 +584,14 @@ const setUpOnboarding = () => { watch(userResource, async () => { await userResource.promise - sidebarLinks.value = getSidebarLinks() - setSidebarLinks() if (userResource.data) { isModerator.value = userResource.data.is_moderator isInstructor.value = userResource.data.is_instructor - await programs.promise - sidebarLinks.value = getSidebarLinks() + await programs.reload() setUpOnboarding() } + sidebarLinks.value = getSidebarLinks() + setSidebarLinks() }) const redirectToWebsite = () => { diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index d18bc98a..b7b1d817 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -567,7 +567,6 @@ const checkIfCanAddProgram = () => { if (userResource?.data?.is_moderator || userResource?.data?.is_instructor) { return true } - console.log('programs.data', programs.data) return ( programs.data?.enrolled.length > 0 || programs.data?.published.length > 0 From 1bc610bd76f5482d72848ad63bcdcae91f77c95c Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Fri, 12 Dec 2025 19:14:25 +0530 Subject: [PATCH 8/8] feat: search jobs from command palette --- frontend/src/components/CourseCard.vue | 2 +- frontend/src/pages/Search/Search.vue | 39 ++++++++++++++++++++------ lms/command_palette.py | 13 +++++++-- lms/sqlite.py | 33 +++++++++++++++++++++- 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/CourseCard.vue b/frontend/src/components/CourseCard.vue index 34f2094e..1d49ffb3 100644 --- a/frontend/src/components/CourseCard.vue +++ b/frontend/src/components/CourseCard.vue @@ -156,7 +156,7 @@ const getGradientColor = () => { localStorage.getItem('theme') == 'light' ? 'lightMode' : 'darkMode' let color = props.course.card_gradient?.toLowerCase() || 'blue' let colorMap = colors[theme][color] - return `linear-gradient(to top right, black, ${colorMap[400]})` + return `linear-gradient(to top right, black, ${colorMap[200]})` /* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */ /* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */ /* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */ diff --git a/frontend/src/pages/Search/Search.vue b/frontend/src/pages/Search/Search.vue index c0d9a3ff..5dbede05 100644 --- a/frontend/src/pages/Search/Search.vue +++ b/frontend/src/pages/Search/Search.vue @@ -61,10 +61,10 @@ 'border-b': index !== searchResults.length - 1, }" > - + @@ -72,16 +72,20 @@
- {{ result.doctype == 'LMS Course' ? 'Course' : 'Batch' }} + {{ getDocTypeTitle(result.doctype) }}
{{ - dayjs(result.published_on || result.start_date).format( - 'DD MMM YYYY' - ) + dayjs( + result.published_on || + result.start_date || + result.creation + ).format('DD MMM YYYY') }}
@@ -173,6 +177,13 @@ const navigate = (result: any) => { batchName: result.name, }, }) + } else if (result.doctype == 'Job Opportunity') { + router.push({ + name: 'JobDetail', + params: { + job: result.name, + }, + }) } } @@ -195,6 +206,18 @@ watch( } ) +const getDocTypeTitle = (doctype: string) => { + if (doctype === 'LMS Course') { + return __('Course') + } else if (doctype === 'LMS Batch') { + return __('Batch') + } else if (doctype === 'Job Opportunity') { + return __('Job') + } else { + return doctype + } +} + const clearSearch = () => { query.value = '' updateQuery('') diff --git a/lms/command_palette.py b/lms/command_palette.py index fed94b3e..0a182cee 100644 --- a/lms/command_palette.py +++ b/lms/command_palette.py @@ -23,11 +23,14 @@ def prepare_search_results(result): for r in result["results"]: doctype = r["doctype"] if doctype == "LMS Course" and can_access_course(r, roles): - r["instructors_info"] = get_instructor_info(doctype, r) + r["author_info"] = get_instructor_info(doctype, r) groups.setdefault("Courses", []).append(r) elif doctype == "LMS Batch" and can_access_batch(r, roles): - r["instructors_info"] = get_instructor_info(doctype, r) + r["author_info"] = get_instructor_info(doctype, r) groups.setdefault("Batches", []).append(r) + elif doctype == "Job Opportunity" and can_access_job(r, roles): + r["author_info"] = get_instructor_info(doctype, r) + groups.setdefault("Job Opportunities", []).append(r) out = [] for key in groups: @@ -52,6 +55,12 @@ def can_access_batch(batch, roles): return False +def can_access_job(job, roles): + if "Moderator" in roles: + return True + return job.get("status") == "Open" + + def can_create_course(roles): return "Course Creator" in roles or "Moderator" in roles diff --git a/lms/sqlite.py b/lms/sqlite.py index 448907a6..36ad7a4a 100644 --- a/lms/sqlite.py +++ b/lms/sqlite.py @@ -9,7 +9,15 @@ class LearningSearch(SQLiteSearch): INDEX_NAME = "learning.db" INDEX_SCHEMA = { - "metadata_fields": ["category", "owner", "published", "published_on", "start_date"], + "metadata_fields": [ + "owner", + "published", + "published_on", + "start_date", + "status", + "company_name", + "creation", + ], "tokenizer": "unicode61 remove_diacritics 2 tokenchars '-_'", } @@ -38,6 +46,20 @@ class LearningSearch(SQLiteSearch): {"modified": "start_date"}, ], }, + "Job Opportunity": { + "fields": [ + "name", + {"title": "job_title"}, + {"content": "description"}, + "owner", + "location", + "country", + "company_name", + "status", + "creation", + {"modified": "creation"}, + ], + }, } DOCTYPE_FIELDS = { @@ -61,6 +83,15 @@ class LearningSearch(SQLiteSearch): "modified", "owner", ], + "Job Opportunity": [ + "name", + "job_title", + "company_name", + "description", + "creation", + "modified", + "owner", + ], } def build_index(self):