Compare commits

...

568 Commits

Author SHA1 Message Date
Raizaaa 0fa4dd7121 Merge pull request #2354 from raizasafeel/chore/dependency
chore: add payments in pyproject for auto install
2026-05-06 08:40:54 +05:30
Raizaaa c2a4ef692f chore: add payments in pyproject for auto install 2026-05-05 14:46:31 +05:30
Raizaaa 6211c507ff Merge pull request #2351 from raizasafeel/chore/translate
chore: add hindi translation
2026-05-03 20:08:22 +05:30
Raizaaa ffbe3d6e69 chore: add hindi translation 2026-05-03 19:53:37 +05:30
Raizaaa 1b13f20ff8 Merge pull request #2347 from frappe/pot_develop_2026-05-01
chore: update POT file
2026-05-03 19:49:32 +05:30
Raizaaa ee9c460f8d Merge pull request #2344 from raizasafeel/security
fix: sanitize lesson blocks from server and client
2026-05-01 21:58:46 +05:30
frappe-pr-bot 6e57e246e9 chore: update POT file 2026-05-01 16:20:17 +00:00
Jannat Patel 5e18ba3c35 Merge pull request #2346 from frappe/readme-payments-app
docs: updated readme to include instructions for payments app
2026-05-01 18:25:18 +05:30
Jannat Patel 3bb7f51295 docs: updated readme to include instructions for payments app 2026-05-01 18:18:01 +05:30
Raizaaa ad037e5f90 fix(lesson): sanitize lesson client side 2026-05-01 02:10:57 +05:30
Raizaaa 07ca95caa8 fix(lesson): sanitize lesson server side 2026-05-01 01:34:05 +05:30
Raizaaa 47b5b603c7 fix(lesson): sanitize lesson server side 2026-05-01 01:15:50 +05:30
Raizaaa eff914c73b Merge pull request #2342 from raizasafeel/fix/patches
fix: sidebar_for_certified_members patch
2026-04-30 15:49:50 +05:30
Raizaaa e3e19c6252 fix: sidebar_for_certified_members patch 2026-04-30 15:40:27 +05:30
Raizaaa e702853b2f Merge pull request #2339 from raizasafeel/chore-deps
chore(deps): update build env deps to frappe >=14, node>=22, apt deps for lxml
2026-04-30 12:58:35 +05:30
Raizaaa 1e7da9b40c chore(deps): update build env deps 2026-04-30 12:49:27 +05:30
Raizaaa ad1f516d66 Merge pull request #2338 from raizasafeel/fix/notification
fix: update notification count on sidebar after they are read
2026-04-30 00:23:47 +05:30
Raizaaa 08794c2424 Merge pull request #2336 from raizasafeel/fix/patches
fix(patches): stale imports breaking bench migrate
2026-04-30 00:10:12 +05:30
Raizaaa 8cff1cf916 fix: render notification count even after sidebar rebuilds 2026-04-29 23:53:38 +05:30
Raizaaa 7fecbe5799 fix: update notification count on sidebar after they are read 2026-04-29 23:51:32 +05:30
Raizaaa 7c46d8bbfc Merge pull request #2337 from raizasafeel/fix/ui-test
test: fix onboarding modal close + cleanup
2026-04-29 23:48:29 +05:30
Raizaaa e25b7c67b9 test: refactor batch and course creation ui tests 2026-04-29 23:38:23 +05:30
Raizaaa 65f4cde5b8 test: fix closeOnboardingModal to reliably close the modal 2026-04-29 21:45:18 +05:30
Raizaaa 56eb556929 fix(patches): stale imports breaking bench migrate 2026-04-29 15:47:45 +05:30
Raizaaa 371b7663e9 Merge pull request #2330 from frappe/pot_develop_2026-04-24
chore: update POT file
2026-04-27 12:30:18 +05:30
frappe-pr-bot aea1059473 chore: update POT file 2026-04-24 16:20:14 +00:00
Raizaaa bb5e277e9e Merge pull request #2325 from raizasafeel/fix/loading-state
fix: show job count on jobs listing for guest users
2026-04-23 00:16:38 +05:30
Raizaaa e4e6f047a4 Merge pull request #2321 from raizasafeel/chapter-progression
feat: on chapter completion continue learning takes you to next chapter
2026-04-23 00:12:13 +05:30
Raizaaa 23996da0bc Merge pull request #2322 from raizasafeel/fix/text-editor
fix: render styles properly in text editor
2026-04-23 00:11:51 +05:30
Raizaaa dc6e3265c0 fix: tab navigation to closed jobs 2026-04-23 00:07:14 +05:30
Raizaaa e3e4b9a648 Merge branch 'frappe:develop' into fix/loading-state 2026-04-23 00:04:39 +05:30
Raizaaa 79d5da75b3 fix: show job count on jobs listing for guest users 2026-04-22 21:55:40 +05:30
Raizaaa 80b802a76b fix: render styles properly in text editor for batchform and courseform 2026-04-21 03:52:42 +05:30
Raizaaa c082f1d30d feat: on chapter completion continue learning takes you to next chapter 2026-04-21 01:55:42 +05:30
Raizaaa 8db8fd489e Merge pull request #2319 from frappe/pot_develop_2026-04-17
chore: update POT file
2026-04-18 17:23:57 +03:00
frappe-pr-bot d94d08f7c3 chore: update POT file 2026-04-17 16:20:30 +00:00
Raizaaa 0fe8d3ac74 Merge pull request #2315 from raizasafeel/fix/types
fix: allow None for optional args in get_order_summary
2026-04-15 10:16:11 +03:00
Raizaaa 193374df47 Merge branch 'frappe:develop' into fix/types 2026-04-15 10:08:09 +03:00
raizasafeel d55951525f fix: allow None for optional args in get_order_summary 2026-04-15 12:37:36 +05:30
Raizaaa 70a9ee1bd7 Merge pull request #2314 from raizasafeel/fix/types
fix: allow None for optional args in get_payment_link
2026-04-15 09:46:35 +03:00
raizasafeel 2878f0232c fix: allow None for optional args in get_payment_link 2026-04-15 12:08:24 +05:30
Raizaaa 189cae3490 Merge pull request #2311 from raizasafeel/fix
fix: loading state on batch details, student enrollment by admin
2026-04-14 14:57:05 +03:00
Raizaaa 2b1df68e78 Merge branch 'frappe:develop' into fix/misc 2026-04-14 14:41:09 +03:00
raizasafeel 46c24b1166 fix: admin roles can enroll students in course/batch successfully 2026-04-14 17:08:13 +05:30
raizasafeel bc139767a8 feat: add empty state to batch student progress detail 2026-04-14 17:07:52 +05:30
Raizaaa 0838a9d325 Merge pull request #2310 from raizasafeel/feat/rtl
feat: rtl support
2026-04-14 13:00:51 +03:00
raizasafeel aed4c6915d refactor: swap remaining physical tailwind classes for rtl-safe equivalents 2026-04-14 11:29:28 +05:30
raizasafeel 07e9b62467 chore: bump frappe-ui to v.0.1.276 for rtl support 2026-04-13 18:10:41 +05:30
raizasafeel 5de889c71b fix: render dayjs import properly 2026-04-13 17:45:53 +05:30
raizasafeel 4278361559 fix: prevent breaking of batch time in rtl 2026-04-13 17:28:17 +05:30
raizasafeel f891f72e20 feat: rtl support for dates 2026-04-13 17:28:04 +05:30
raizasafeel cb5d19e523 Merge remote-tracking branch 'upstream/develop' into feat/rtl 2026-04-13 10:45:10 +05:30
raizasafeel 8b3930169d feat: add rtl support to icons, css styles 2026-04-13 10:10:53 +05:30
raizasafeel 3e05dcedeb feat(lesson): add rtl support 2026-04-13 09:49:05 +05:30
raizasafeel aec9556cf0 feat: sidebar toggle icon adjusts to text direction 2026-04-12 20:55:00 +05:30
raizasafeel c5236b2e50 refactor(components): swap tailwindcss classes with ones with rtl support 2026-04-12 20:54:52 +05:30
raizasafeel fa94e5f96a refactor(pages): swap tailwindcss classes with ones with rtl support 2026-04-12 20:52:22 +05:30
Jannat Patel 88e86e6cfb fix: page length for jobs and certification listing 2026-04-12 11:03:20 +05:30
Jannat Patel 04fe73531f Merge pull request #2308 from pateljannat/misc-issues
refactor: jobs and certified participants view
2026-04-11 22:06:26 +05:30
Jannat Patel dc4f188648 fix: certified participants page height 2026-04-11 20:12:47 +05:30
Jannat Patel 411b400d04 fix: quiz scroll issue in lessons 2026-04-11 19:54:57 +05:30
Jannat Patel ec5e45e6c6 fix: mobile view of lists 2026-04-11 19:50:59 +05:30
Jannat Patel f5b1feade6 Merge pull request #2304 from frappe/pot_develop_2026-04-10
chore: update POT file
2026-04-11 19:50:27 +05:30
frappe-pr-bot 19cb56cb21 chore: update POT file 2026-04-10 16:17:34 +00:00
Jannat Patel af08e6842a fix: list pagination 2026-04-10 19:54:59 +05:30
Jannat Patel c7ccb2d1c5 refactor: certified participants list 2026-04-10 18:06:30 +05:30
Jannat Patel 2ebb6ca745 refactor: job applications list 2026-04-10 16:44:42 +05:30
Jannat Patel 1f040c4561 refactor: jobs list and form ui 2026-04-10 15:26:50 +05:30
Jannat Patel ee85af09ed fix: course import with proper evaluator data 2026-04-10 13:09:20 +05:30
raizasafeel 453862a653 chore(deps): bump frappe-ui to v.0.1.275 for rtl support 2026-04-10 12:57:59 +05:30
raizasafeel ff98df6acd feat: detect language and change direction 2026-04-10 09:57:50 +05:30
Jannat Patel 75ed9c5ec2 fix: removed body content from course export request 2026-04-09 20:29:00 +05:30
Jannat Patel 0f7a2d1975 fix: changed course export request to GET 2026-04-09 20:20:53 +05:30
Raizaaa a4f8497988 Merge pull request #2299 from raizasafeel/security
fix: prevent path traversal in scorm file
2026-04-09 08:55:09 +03:00
Jannat Patel 79d82647ef Merge pull request #2298 from pateljannat/issues-220
fix: misc issues
2026-04-08 17:40:15 +05:30
Raizaaa 4e003a2490 Merge branch 'frappe:develop' into security 2026-04-08 13:10:01 +03:00
Jannat Patel 0d394646d9 fix: removed prefix from assignment and exercise list count 2026-04-08 12:17:10 +05:30
Jannat Patel 071e8dc529 fix: lesson progress after assignment submission 2026-04-08 12:15:23 +05:30
Jannat Patel def3e3d372 fix: mark for review should only be allowed when showing answers is disabled 2026-04-08 11:02:42 +05:30
Jannat Patel 97228e4655 Merge pull request #2296 from pateljannat/issues-219
fix: improved list for assignments and programming exercises
2026-04-07 18:13:25 +05:30
Jannat Patel a507ab425c fix: improved list for assignments and programming exercises 2026-04-07 17:54:03 +05:30
raizasafeel e1b425ed5b fix: prevent path traversal in scorm file 2026-04-06 22:50:37 +05:30
Raizaaa e4ad66c226 Merge pull request #2294 from raizasafeel/security
fix: prevent xss in meta data
2026-04-06 22:45:22 +05:30
Raizaaa 9003e92d6c Merge pull request #2292 from raizasafeel/feat/zero-amount-checkout
fix: allow zero amount checkout with coupons
2026-04-06 20:45:16 +05:30
raizasafeel f244a6c9ff fix: prevent xss in meta data 2026-04-06 20:41:38 +05:30
Jannat Patel 226ed85636 Merge pull request #2293 from pateljannat/issues-218
fix: misc issues
2026-04-06 18:55:12 +05:30
Jannat Patel 717f9000f2 fix: variable names for certificate template 2026-04-06 18:20:24 +05:30
Jannat Patel 0d8898576f fix: learning workspace sidebar 2026-04-06 18:09:10 +05:30
Jannat Patel ab1bed8f30 fix: course card gradient toggle when theme changed 2026-04-06 17:06:44 +05:30
Jannat Patel 93161b8278 fix: sidebar links in mobile 2026-04-06 16:19:52 +05:30
Raizaaa 090f26f58f Merge branch 'frappe:develop' into feat/zero-amount-checkout 2026-04-06 12:12:55 +05:30
raizasafeel 1d04f4fd91 fix: allow zero amount checkout with coupons 2026-04-06 11:58:05 +05:30
Jannat Patel abda48eaad Merge pull request #2278 from iamrubeng/patch-1
ci(build): add payments repository to APPS_JSON
2026-04-06 11:02:09 +05:30
Jannat Patel ad6f24dd7c Merge pull request #2290 from frappe/pot_develop_2026-04-03
chore: update POT file
2026-04-06 10:50:00 +05:30
Raizaaa 2fe39ee2ba Merge pull request #2291 from raizasafeel/fix/ui-teardown
revert: change switches into checkbox
2026-04-06 09:48:41 +05:30
raizasafeel 221ac4fad9 revert: change switches into checkbox 2026-04-06 09:03:53 +05:30
Rubén Gómez 831f119398 build(init): add payments dependency 2026-04-03 19:25:15 +02:00
frappe-pr-bot 540c676206 chore: update POT file 2026-04-03 16:11:34 +00:00
Jannat Patel 90d4f32c47 Merge pull request #2286 from pateljannat/course-package-import
feat: course package import and export
2026-04-03 13:28:15 +05:30
Jannat Patel 7fe8d6c500 fix: asset export path 2026-04-03 13:18:11 +05:30
Jannat Patel 7c1869853f ci: codecov rules 2026-04-03 12:38:34 +05:30
Jannat Patel 3ece2fc3ec fix: assessments replace logic 2026-04-03 12:22:04 +05:30
Jannat Patel f9f17ef8ac fix: roles before user is saved 2026-04-03 11:52:00 +05:30
Jannat Patel a263ca9330 Merge pull request #2288 from Rl0007/fix/configurable-max-signups
fix: take max signups from Frappe settings for custom LMS signup
2026-04-03 11:24:57 +05:30
Jannat Patel ab96e354cc fix: cleanup and more tests 2026-04-03 11:22:40 +05:30
Rahul Agrawal 3d37461a73 fix: Semgrep 2026-04-03 08:25:41 +05:30
Rahul Agrawal b1c68ad4f3 fix: take max signups from Frappe settings for custom LMS signup 2026-04-03 08:16:39 +05:30
Jannat Patel 6338a5911f fix: misc issues 2026-04-02 20:08:08 +05:30
Jannat Patel 6ebaf0e28b test: corrected zip path 2026-04-02 19:24:07 +05:30
Jannat Patel 55f01dc313 test: course package export and import 2026-04-02 18:53:50 +05:30
Jannat Patel c0df21c076 fix: save export zip as private 2026-04-02 17:10:40 +05:30
Jannat Patel 564d10feb6 fix: cleanup of import functionality 2026-04-02 16:48:33 +05:30
Jannat Patel e1e2c08493 fix: import modal ui 2026-04-02 13:52:07 +05:30
Jannat Patel cd85c5c57f fix: import package as private 2026-04-02 13:23:38 +05:30
Jannat Patel 03e5bae0aa chore: resolved conflicts 2026-04-02 12:41:38 +05:30
Jannat Patel a4a0a76ad7 feat: import course zip 2026-04-02 12:40:08 +05:30
Raizaaa 1d2b3b0996 Merge pull request #2282 from raizasafeel/security
fix: prevent unauthorised enrollments in paid courses
2026-04-01 14:32:17 +05:30
raizasafeel c3e3337de4 fix(enrollment): prevent unauthorised enrollments in paid courses 2026-04-01 13:46:10 +05:30
Rubén Gómez 94a80603b0 ci(build): update Frappe branch to version-16 in workflow 2026-04-01 08:57:14 +02:00
Leo Daniel A 42abc678a2 fix(notifications): single-line mobile header and improve empty state (#2250)
* fix(notifications): single-line mobile header and improve empty state

* fix(style): improve the notification content for mobile view and dark theme
2026-03-31 13:01:53 +05:30
Rubén Gómez Soto 78a9eac356 ci(build): add payments repository to APPS_JSON 2026-03-31 08:53:26 +02:00
Jannat Patel 8100c67a00 Merge pull request #2277 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-31 11:22:57 +05:30
MochaMind 1c43e6f857 chore: Esperanto translations 2026-03-30 22:17:02 +05:30
MochaMind 46c0e86723 chore: Portuguese, Brazilian translations 2026-03-30 22:17:01 +05:30
MochaMind 21c63722f9 chore: Serbian (Latin) translations 2026-03-30 22:16:59 +05:30
MochaMind f736c896ed chore: Norwegian Bokmal translations 2026-03-30 22:16:57 +05:30
MochaMind 0a732da414 chore: Bosnian translations 2026-03-30 22:16:55 +05:30
MochaMind dc0a4ca45c chore: Burmese translations 2026-03-30 22:16:53 +05:30
MochaMind 399c893028 chore: Croatian translations 2026-03-30 22:16:52 +05:30
MochaMind ff96120cd4 chore: Thai translations 2026-03-30 22:16:50 +05:30
MochaMind c87c21ce7c chore: Persian translations 2026-03-30 22:16:48 +05:30
MochaMind 2a3a5bc875 chore: Indonesian translations 2026-03-30 22:16:47 +05:30
MochaMind d8faa43820 chore: Vietnamese translations 2026-03-30 22:16:46 +05:30
MochaMind 37a63c5771 chore: Chinese Simplified translations 2026-03-30 22:16:44 +05:30
MochaMind 42c88235af chore: Turkish translations 2026-03-30 22:16:43 +05:30
MochaMind eb4f348a4c chore: Swedish translations 2026-03-30 22:16:41 +05:30
MochaMind b35eda205d chore: Serbian (Cyrillic) translations 2026-03-30 22:16:39 +05:30
MochaMind d26c704389 chore: Slovenian translations 2026-03-30 22:16:37 +05:30
MochaMind e6dba195ce chore: Russian translations 2026-03-30 22:16:36 +05:30
MochaMind ed657138dc chore: Portuguese translations 2026-03-30 22:16:34 +05:30
MochaMind 47148353fb chore: Polish translations 2026-03-30 22:16:32 +05:30
MochaMind 9aa8e72dc4 chore: Dutch translations 2026-03-30 22:16:31 +05:30
MochaMind b580b38a04 chore: Italian translations 2026-03-30 22:16:29 +05:30
MochaMind c95662a96c chore: Hungarian translations 2026-03-30 22:16:27 +05:30
MochaMind 15de77c8a6 chore: German translations 2026-03-30 22:16:26 +05:30
MochaMind b80f6bcb1a chore: Danish translations 2026-03-30 22:16:24 +05:30
MochaMind 5845308344 chore: Czech translations 2026-03-30 22:16:23 +05:30
MochaMind b660c81a56 chore: Arabic translations 2026-03-30 22:16:21 +05:30
MochaMind a02cc1e213 chore: Spanish translations 2026-03-30 22:16:20 +05:30
MochaMind 755a69420c chore: French translations 2026-03-30 22:16:18 +05:30
Raizaaa 36c8c291f1 Merge pull request #2274 from raizasafeel/security
fix: prevent path transversals in lms
2026-03-30 15:05:00 +05:30
raizasafeel bb1b1f6adc fix: prevent path transversals in scorm upload 2026-03-30 14:51:16 +05:30
Jannat Patel 0e9abf91a1 Merge pull request #2271 from LeoDanielA01/fix/elevators-route
fix: evaluator navigation by syncing modal visibility with route change
2026-03-30 11:04:59 +05:30
Jannat Patel 0fd5d6b2b0 Merge pull request #2270 from LeoDanielA01/fix/settings-header-text-contrast
fix(style): improve header text contrast in settings pages dark mode
2026-03-30 11:02:20 +05:30
Jannat Patel e926fde159 Merge pull request #2269 from LeoDanielA01/fix/payment-gateway-header-color
fix(style): improve payment gateway header text visibility in dark mode
2026-03-30 10:57:46 +05:30
Jannat Patel 81e8ff5bff Merge pull request #2265 from Owaishk08/fix/sidebar-overflow
fix: remove horizontal scrollbar and duplicate overflow div in sidebar
2026-03-30 10:57:00 +05:30
Jannat Patel c0f5ceacfb Merge pull request #2266 from frappe/pot_develop_2026-03-27
chore: update POT file
2026-03-30 10:51:40 +05:30
LEO DANIEL A 79441ae09c fix: evaluator navigation by syncing modal visibility with route change 2026-03-28 16:43:30 +05:30
LEO DANIEL A 3509da679c fix(style): improve header text contrast in settings pages dark mode 2026-03-28 15:22:23 +05:30
LEO DANIEL A edf3a80d8d fix(style): improve payment gateway header text visibility in dark mode 2026-03-28 15:07:02 +05:30
frappe-pr-bot 0f7322b67d chore: update POT file 2026-03-27 16:17:18 +00:00
Owais Khan c510f28a01 fix: remove horizontal scrollbar and duplicate overflow div in sidebar 2026-03-27 14:43:30 +05:30
Jannat Patel 839c8eca6e Merge pull request #2264 from pateljannat/issues-217
fix: better sanitization of form fields
2026-03-27 12:15:52 +05:30
Jannat Patel ba01c1e803 Merge pull request #2262 from NuriaAmoros/fix/overflow-settings-dialog
fix: add overflow-y-auto to settings dialog content panel (#2079)
2026-03-27 11:53:18 +05:30
Jannat Patel 5efcaab95a fix: better sanitization of form fields 2026-03-27 11:26:04 +05:30
Nuria Amorós 73122d1faa fix: change overflow-y-scroll to overflow-y-auto in settings list components (#2079) 2026-03-26 15:20:46 +01:00
Jannat Patel 26623ecc25 Merge pull request #2260 from pateljannat/issues-216
fix: misc ui improvements
2026-03-26 16:27:24 +05:30
Jannat Patel 79c732e357 Merge pull request #2255 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-26 15:59:37 +05:30
Jannat Patel 5b50701b3b fix: ui improvements in batch feedback and evaluator slots 2026-03-26 15:59:02 +05:30
Jannat Patel 71c13d634c fix: enhancements in quiz 2026-03-26 15:58:20 +05:30
Jannat Patel 029d76cf59 fix: demo data roles and email account message 2026-03-26 15:57:36 +05:30
MochaMind 72bc9f9630 chore: Serbian (Latin) translations 2026-03-25 20:57:27 +05:30
MochaMind ce719f8159 chore: Serbian (Cyrillic) translations 2026-03-25 20:57:13 +05:30
MochaMind b62edef938 chore: Spanish translations 2026-03-25 20:57:00 +05:30
Jannat Patel aaa866e3ff feat: export course zip 2026-03-25 16:29:56 +05:30
Jannat Patel 15e9e95129 Merge branch 'main-hotfix' into develop 2026-03-25 11:05:30 +05:30
Hussain Nagaria 924e118d92 Merge pull request #2246 from frappe/misc/fixes
fix: misc issues in batch
2026-03-25 10:33:34 +05:30
Hussain Nagaria e3a70a04a3 Merge branch 'develop' into misc/fixes 2026-03-25 10:23:55 +05:30
Raizaaa 0ec9ad0b26 Merge pull request #2239 from raizasafeel/fix/ui-teardown
style(courseform): render tags inside input to match multiselect
2026-03-25 00:09:47 +05:30
Jannat Patel b4cd463fef Merge pull request #2252 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-24 20:52:48 +05:30
MochaMind c4a64c26cd chore: Vietnamese translations 2026-03-24 20:05:34 +05:30
Raizaaa d2d36c75c0 Merge branch 'frappe:develop' into fix/ui-teardown 2026-03-24 16:58:26 +05:30
raizasafeel c2287a5d08 style(courseform): render tags inside input to match multiselect 2026-03-24 16:16:35 +05:30
Raizaaa bcd55984a9 Merge pull request #2247 from raizasafeel/fix/course-progress
fix: course progress updated for scorm and video end event
2026-03-24 16:10:03 +05:30
Hussain Nagaria ecc01825c0 fix: move to correct line 2026-03-24 15:33:58 +05:30
raizasafeel 400c950bb7 fix: progress updated on video completion 2026-03-24 15:33:53 +05:30
Hussain Nagaria 681923e3f7 chore: ignore semgrep on batch details whitelisted method 2026-03-24 15:29:15 +05:30
raizasafeel 89505eac7d fix(scorm): save_progress no longer impended by race condition 2026-03-24 15:27:08 +05:30
Hussain Nagaria e7d2594142 feat: show preview video in batch 2026-03-24 15:19:51 +05:30
Hussain Nagaria 0486842bc8 fix: show error if upload fails 2026-03-24 14:51:09 +05:30
raizasafeel 99397ad1f4 feat(scorm): show completion in frontend 2026-03-24 12:58:44 +05:30
Jannat Patel 847719ab77 Merge pull request #2244 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-23 20:07:34 +05:30
MochaMind 7d51da78c9 chore: Esperanto translations 2026-03-23 19:47:15 +05:30
MochaMind 5f9b93280a chore: Portuguese, Brazilian translations 2026-03-23 19:47:13 +05:30
MochaMind 5614c72472 chore: Serbian (Latin) translations 2026-03-23 19:47:11 +05:30
MochaMind 2d3ba826cf chore: Norwegian Bokmal translations 2026-03-23 19:47:09 +05:30
MochaMind 6ca69ecda9 chore: Bosnian translations 2026-03-23 19:47:08 +05:30
MochaMind 8ff9cde1e3 chore: Burmese translations 2026-03-23 19:47:06 +05:30
MochaMind 9fa73ecca2 chore: Croatian translations 2026-03-23 19:47:04 +05:30
MochaMind fdc019c106 chore: Thai translations 2026-03-23 19:47:03 +05:30
MochaMind f4eff5d088 chore: Persian translations 2026-03-23 19:47:01 +05:30
MochaMind ea18d07baf chore: Indonesian translations 2026-03-23 19:46:59 +05:30
MochaMind ffd7d0e466 chore: Vietnamese translations 2026-03-23 19:46:57 +05:30
MochaMind 080dbdf9cd chore: Chinese Simplified translations 2026-03-23 19:46:55 +05:30
MochaMind 9533ba3b76 chore: Turkish translations 2026-03-23 19:46:54 +05:30
MochaMind 17ebc7ae4f chore: Swedish translations 2026-03-23 19:46:52 +05:30
MochaMind b2616817e5 chore: Serbian (Cyrillic) translations 2026-03-23 19:46:50 +05:30
MochaMind 32fe61b965 chore: Slovenian translations 2026-03-23 19:46:48 +05:30
MochaMind 4e92c700bb chore: Russian translations 2026-03-23 19:46:47 +05:30
MochaMind f1e5ce4499 chore: Portuguese translations 2026-03-23 19:46:45 +05:30
MochaMind 410f06b2a2 chore: Polish translations 2026-03-23 19:46:44 +05:30
MochaMind 4b701e5638 chore: Dutch translations 2026-03-23 19:46:42 +05:30
MochaMind 8919f8933a chore: Italian translations 2026-03-23 19:46:40 +05:30
MochaMind 3617dd04e9 chore: Hungarian translations 2026-03-23 19:46:39 +05:30
MochaMind 1496add6e4 chore: German translations 2026-03-23 19:46:37 +05:30
MochaMind b8a0105d85 chore: Danish translations 2026-03-23 19:46:36 +05:30
MochaMind 3f57a18b3f chore: Czech translations 2026-03-23 19:46:34 +05:30
MochaMind c65e38fd1b chore: Arabic translations 2026-03-23 19:46:32 +05:30
MochaMind 8737b29475 chore: Spanish translations 2026-03-23 19:46:30 +05:30
MochaMind c25a8896ce chore: French translations 2026-03-23 19:46:29 +05:30
Nuria Amorós 4317c2297c fix: add overflow-y-auto to settings dialog content panel (#2079) 2026-03-23 11:37:26 +01:00
Jannat Patel 6e852cb86f Merge pull request #2238 from LeoDanielA01/fix/job-detail-application-data-loading
fix(JobDetail): application count and button now load properly on pag…
2026-03-23 11:09:32 +05:30
Jannat Patel 2f468ea0ec Merge pull request #2237 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-23 11:06:31 +05:30
Jannat Patel 9bb9177d36 Merge pull request #2235 from frappe/pot_develop_2026-03-20
chore: update POT file
2026-03-23 11:05:49 +05:30
LEO DANIEL A f92b7faf0f fix(JobDetail): application count and button now load properly on page open 2026-03-22 22:15:58 +05:30
MochaMind e94fd2949d chore: Hungarian translations 2026-03-22 19:48:39 +05:30
MochaMind e1ac29d79f chore: Arabic translations 2026-03-22 19:48:34 +05:30
frappe-pr-bot f559fa1b32 chore: update POT file 2026-03-20 16:13:38 +00:00
Jannat Patel 46ee36a6dc Merge pull request #2233 from pateljannat/issues-215
fix: misc issues
2026-03-20 20:20:52 +05:30
Jannat Patel ca1b5da8e5 fix: improved role description in profile 2026-03-19 18:19:07 +05:30
Jannat Patel 189fc08cdb revert: certification batch filter to a checkbox 2026-03-19 18:01:09 +05:30
Jannat Patel 71b96d836a revert: certification course filter to a checkbox 2026-03-19 17:58:31 +05:30
Leo Daniel A f2807d3e38 fix: LiveCodeSession reference error and UI loading state (#2218)
* fix: LiveCodeSession reference error and UI loading state

* fix: remove trailing slash from livecode_url in LMS Settings and add patch to update existing records

* fix: remove reload function in patch file and update live_code_url default value by administrator

---------

Co-authored-by: Jannat Patel <31363128+pateljannat@users.noreply.github.com>
2026-03-19 17:55:24 +05:30
Jannat Patel 9b8721fa87 ci: run ui tests parallely (#2232)
* ci: run ui tests parallely

* ci: run ui tests parallely

* ci: run ui tests parallely
2026-03-19 17:20:48 +05:30
Jannat Patel a6abef224c Merge pull request #2230 from frappe/mergify/bp/main-hotfix/pr-2229
fix: misc issues (backport #2229)
2026-03-19 16:18:31 +05:30
Jannat Patel 1e646e35a2 Merge pull request #2228 from raizasafeel/fix/ui-teardown
revert(course): use checkbox with tooltip for certification filter
2026-03-19 16:18:22 +05:30
Jannat Patel 6f0c695856 fix: misc ui issues
(cherry picked from commit 8f4bd7afaf)
2026-03-19 10:23:19 +00:00
Jannat Patel 9d71915b7d fix: certification should be visible by default in sidebar
(cherry picked from commit 0d39f1cce1)
2026-03-19 10:23:19 +00:00
Jannat Patel 29faf4d3b8 fix: events permission to moderator and evaluator
(cherry picked from commit e18d27e9de)
2026-03-19 10:23:19 +00:00
Jannat Patel 7730b58a02 Merge pull request #2229 from pateljannat/issues-213
fix: misc issues
2026-03-19 15:52:49 +05:30
Jannat Patel 8f4bd7afaf fix: misc ui issues 2026-03-19 15:38:31 +05:30
Jannat Patel 0d39f1cce1 fix: certification should be visible by default in sidebar 2026-03-19 15:37:51 +05:30
Jannat Patel e18d27e9de fix: events permission to moderator and evaluator 2026-03-19 15:37:18 +05:30
Raizaaa 52e44bee12 Merge branch 'frappe:develop' into fix/ui-teardown 2026-03-19 13:35:24 +05:30
raizasafeel 5c03754888 revert(course): use checkbox with tooltip for certification filter 2026-03-19 13:34:52 +05:30
Jannat Patel b34a23ec48 Merge pull request #2226 from frappe/develop
chore: merge `develop` into `main-hotfix`
2026-03-19 11:36:12 +05:30
Jannat Patel 5511576a65 Merge pull request #2220 from LeoDanielA01/fix/programming-submissions-filter
fix: reload and persist status filters in programming submissions
2026-03-18 11:36:58 +05:30
Jannat Patel 30632e9b3a Merge pull request #2217 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-18 11:18:31 +05:30
Jannat Patel ee6ee469f4 Merge pull request #2223 from frappe/mergify/bp/main-hotfix/pr-2205
fix: assignment issues (backport #2205)
2026-03-18 11:12:02 +05:30
Jannat Patel f424fe8bbb fix: assignment issues
(cherry picked from commit 77bdc29b3e)
2026-03-18 05:34:20 +00:00
LEO DANIEL A 515ff5662b fix: reload and persist status filters in programming submissions 2026-03-18 00:39:03 +05:30
MochaMind 0cc68cced9 chore: Serbian (Latin) translations 2026-03-17 18:40:47 +05:30
MochaMind bd321dbab4 chore: Serbian (Cyrillic) translations 2026-03-17 18:40:34 +05:30
Jannat Patel 9b01dfaa14 Merge pull request #2216 from frappe/mergify/bp/main-hotfix/pr-2215
chore: updated demo data with latest explainer videos (backport #2215)
2026-03-17 15:15:42 +05:30
Jannat Patel 7bfbbc5926 chore: updated demo data with latest explainer videos
(cherry picked from commit b70b69eb63)
2026-03-17 09:32:06 +00:00
Jannat Patel 8d49252418 Merge pull request #2215 from pateljannat/issues-212
chore: updated demo data with latest explainer videos
2026-03-17 15:01:47 +05:30
Jannat Patel b70b69eb63 chore: updated demo data with latest explainer videos 2026-03-17 14:52:00 +05:30
Raizaaa 8da726a280 Merge pull request #2214 from raizasafeel/fix/ui-teardown
feat: change checkbox to switches, controls styling
2026-03-17 13:51:42 +05:30
Raizaaa 7f95a3eb60 Merge branch 'frappe:develop' into fix/ui-teardown 2026-03-17 13:25:31 +05:30
raizasafeel 7e0bea60ee fix: prevent toast pop up on profiles page mount 2026-03-17 13:09:30 +05:30
raizasafeel 74862c131d feat: replace preview video placeholder with description 2026-03-17 12:48:16 +05:30
raizasafeel f2f042e0fa feat: rename disable self enrollment toggle to self enrollment 2026-03-17 12:48:12 +05:30
raizasafeel b8dab3e54a feat: replace checkboxes with switches 2026-03-17 12:21:58 +05:30
raizasafeel 186cd90d42 style: matched to frappe-ui style for input 2026-03-17 11:48:42 +05:30
Jannat Patel a7598233a7 Merge pull request #2199 from LeoDanielA01/fix/sidebar-scrollview
fix: improve sidebar scrolling with overflow
2026-03-17 09:06:47 +05:30
Jannat Patel 83b6a02e0f Merge pull request #2208 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-17 09:04:55 +05:30
Raizaaa 755b0af9d0 Merge pull request #2211 from raizasafeel/fix/ui-teardown
refactor(multiselect): selected values are shown in input
2026-03-17 03:41:59 +05:30
raizasafeel d821ec56aa refactor(multiselect): selected values are shown in input 2026-03-17 03:32:38 +05:30
Raizaaa e8c9510511 Merge pull request #2210 from raizasafeel/fix/ui-teardown
feat(control): better ux, inline create functionality for link
2026-03-17 03:28:15 +05:30
raizasafeel 674512444e fix: refactor components for create modal 2026-03-17 03:22:20 +05:30
raizasafeel 29d11a42df feat(control): better ux, inline create functionality for link 2026-03-17 02:20:15 +05:30
Raizaaa 613ee475b7 Merge pull request #2209 from raizasafeel/fix/ui-teardown
feat: add member modal, refactor control filters
2026-03-17 02:13:10 +05:30
raizasafeel 5a0bbae746 test: update batch creation cypress test for new member modal 2026-03-17 02:06:08 +05:30
raizasafeel 0c0820a826 feat: update batch, course form to use new member modal 2026-03-17 01:59:40 +05:30
raizasafeel dd8a0d4238 feat: add member modal, evaluator add refactor 2026-03-17 01:55:57 +05:30
raizasafeel 5d8090c0a0 fix(controls): link and multiselect filter handling 2026-03-17 00:16:13 +05:30
Raizaaa 5cc8ef227e Merge pull request #2207 from raizasafeel/fix/ui-teardown
feat(lesson): add support for html (heading, block code, list)
2026-03-16 21:34:47 +05:30
MochaMind d0261d178d chore: Esperanto translations 2026-03-16 18:38:56 +05:30
MochaMind 33635408f5 chore: Serbian (Latin) translations 2026-03-16 18:38:52 +05:30
MochaMind 2fc68d12db chore: Norwegian Bokmal translations 2026-03-16 18:38:49 +05:30
MochaMind 791601f573 chore: Bosnian translations 2026-03-16 18:38:45 +05:30
MochaMind 328804c50e chore: Burmese translations 2026-03-16 18:38:44 +05:30
MochaMind dfc138fa00 chore: Croatian translations 2026-03-16 18:38:41 +05:30
MochaMind c5e0dee764 chore: Thai translations 2026-03-16 18:38:38 +05:30
MochaMind d3c1890ba1 chore: Persian translations 2026-03-16 18:38:36 +05:30
MochaMind 2d840f3c0c chore: Indonesian translations 2026-03-16 18:38:32 +05:30
MochaMind da3cd25880 chore: Vietnamese translations 2026-03-16 18:38:28 +05:30
MochaMind b987fa0e27 chore: Chinese Simplified translations 2026-03-16 18:38:24 +05:30
MochaMind dff5359b08 chore: Turkish translations 2026-03-16 18:38:18 +05:30
MochaMind 6d05a39b74 chore: Swedish translations 2026-03-16 18:38:15 +05:30
MochaMind d6b79b19bc chore: Serbian (Cyrillic) translations 2026-03-16 18:38:13 +05:30
MochaMind a93571d1e1 chore: Slovenian translations 2026-03-16 18:38:12 +05:30
MochaMind 9ace1381c6 chore: Russian translations 2026-03-16 18:38:09 +05:30
MochaMind 4d6aec0bca chore: Portuguese translations 2026-03-16 18:38:06 +05:30
MochaMind d6f2720927 chore: Polish translations 2026-03-16 18:38:04 +05:30
MochaMind c5bd65ab23 chore: Dutch translations 2026-03-16 18:38:03 +05:30
MochaMind cbabe5bce1 chore: Italian translations 2026-03-16 18:38:01 +05:30
MochaMind f718f0aa61 chore: Hungarian translations 2026-03-16 18:37:59 +05:30
MochaMind 76776dbc2f chore: German translations 2026-03-16 18:37:57 +05:30
MochaMind 49bd5e6766 chore: Danish translations 2026-03-16 18:37:53 +05:30
MochaMind cfefb2101e chore: Czech translations 2026-03-16 18:37:52 +05:30
MochaMind 857c7c6a55 chore: Arabic translations 2026-03-16 18:37:49 +05:30
MochaMind b46d5a1f9c chore: Spanish translations 2026-03-16 18:37:47 +05:30
MochaMind e8d8a6feb5 chore: French translations 2026-03-16 18:37:45 +05:30
MochaMind 8c68584fc2 chore: Portuguese, Brazilian translations 2026-03-16 18:37:41 +05:30
raizasafeel e2550cca31 feat(lesson): add support for html (heading, block code, list) 2026-03-16 17:38:05 +05:30
Leo Daniel A 6646a83378 fix: hide evaluators search when list is empty (#2197)
* fix: hide evaluator search when list is empty

* fix(lms): keep search bar visible in Evaluators settings during search
2026-03-16 16:51:00 +05:30
Jannat Patel 1ff071a147 Merge pull request #2206 from pateljannat/mark-for-review
feat: mark questions for review in quiz
2026-03-16 16:44:08 +05:30
Jannat Patel 4684411d09 feat: mark questions for review in quiz 2026-03-16 16:21:21 +05:30
Jannat Patel d6714e6123 Merge pull request #2205 from pateljannat/issues-211
fix: assignment issues
2026-03-16 15:39:34 +05:30
Jannat Patel 77bdc29b3e fix: assignment issues 2026-03-16 15:20:03 +05:30
Jannat Patel 952da4d240 Merge pull request #2193 from harshpwctech/bunnystream-new-player
chore: Added new Embed URL for BunnyStream Player
2026-03-16 12:54:20 +05:30
Jannat Patel 51cf663eb7 Merge pull request #2203 from frappe/mergify/bp/main-hotfix/pr-2202
feat: Frappe appointment booking for trial sites (backport #2202)
2026-03-16 12:52:59 +05:30
Jannat Patel b8ec83c25a feat: Frappe appointment booking for trial sites
(cherry picked from commit fde1c106c5)
2026-03-16 06:59:21 +00:00
Jannat Patel 0d096257c9 Merge pull request #2202 from pateljannat/issues-210
feat: Frappe appointment booking for trial sites
2026-03-16 12:28:57 +05:30
Jannat Patel 86faf86183 Merge pull request #2080 from jagadish-7/fix/video-speed
feat: added speed controls
2026-03-16 12:28:17 +05:30
Jannat Patel c33247e347 Merge pull request #2194 from pateljannat/issues-209
fix: sidebar scroll issue
2026-03-16 12:15:15 +05:30
Jannat Patel a47125d0d1 Merge pull request #2198 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-16 12:13:46 +05:30
Jannat Patel 9bda76f5f5 Merge pull request #2195 from frappe/pot_develop_2026-03-13
chore: update POT file
2026-03-16 12:13:35 +05:30
Jannat Patel fde1c106c5 feat: Frappe appointment booking for trial sites 2026-03-16 12:11:37 +05:30
CA Harsh Agrawal 53f98b2788 chore: Added new Embed URL for BunnyStream Player 2026-03-14 18:28:44 +05:30
LEO DANIEL A 6a467ea8e2 fix(ui): add scroll to sidebar 2026-03-14 14:11:34 +05:30
MochaMind a8575b7ff0 chore: Portuguese, Brazilian translations 2026-03-14 07:13:01 +05:30
frappe-pr-bot 707bbed8d7 chore: update POT file 2026-03-13 16:10:33 +00:00
jagadish madavalkar f26eec09c4 fix: dynamic speed options 2026-03-13 21:31:31 +05:30
jagadish madavalkar 0a056e101f fix: used frappe dropdown
Signed-off-by: jagadish madavalkar <jagadish.me07@gmail.com>
2026-03-13 21:31:31 +05:30
jagadish madavalkar bac875baed feat: added speed controls 2026-03-13 21:31:31 +05:30
Jannat Patel 496f1c0acd Merge pull request #2191 from frappe/mergify/bp/main-hotfix/pr-2190
chore: frappe dependency change (backport #2190)
2026-03-13 19:16:33 +05:30
CA Harsh Agrawal 6085471053 chore: Added new Embed URL for BunnyStream Player 2026-03-13 19:00:31 +05:30
Jannat Patel a72aa1366b fix: sidebar scroll issue 2026-03-13 18:39:21 +05:30
Jannat Patel 83b003a303 chore: frappe dependency change
(cherry picked from commit 7d08a76cff)
2026-03-13 13:05:56 +00:00
Jannat Patel 62685b93e2 Merge pull request #2190 from pateljannat/issues-208
chore: frappe dependency change
2026-03-13 18:35:31 +05:30
Jannat Patel 82e5af1dee Merge pull request #2189 from LeoDanielA01/fix/quizzes-dark-mode-text
style: fix text color for dark mode in Quizzes
2026-03-13 18:35:19 +05:30
Jannat Patel 7d08a76cff chore: frappe dependency change 2026-03-13 18:27:58 +05:30
Jannat Patel 61b3bd651d Merge pull request #2188 from pateljannat/quiz-navigation
feat: navigate between questions in quiz
2026-03-13 18:19:00 +05:30
LEO DANIEL A cd17b7dcfb style: fix text color for dark mode in Quizzes 2026-03-13 18:12:29 +05:30
Jannat Patel b6a82c5850 feat: submit the page if the user reloads or closes the quiz window 2026-03-13 17:39:55 +05:30
Jannat Patel 747da123aa test: quiz submission 2026-03-13 11:57:55 +05:30
Raizaaa 7cc2f0c52c Merge pull request #2186 from raizasafeel/fix/ui-teardown
fix: evaluator role synched between doctype and role
2026-03-12 17:11:59 +05:30
Jannat Patel 2f66dd8046 Merge pull request #2187 from frappe/develop
chore: merge `develop` into `main-hotfix`
2026-03-12 17:09:12 +05:30
raizasafeel 8458985c28 test: test sync between evaluator doc and role 2026-03-12 17:05:33 +05:30
raizasafeel 6a6b4e0139 fix: add patch to sync batch evaluator role with course evaluator doctype 2026-03-12 17:05:33 +05:30
raizasafeel ba394926c5 fix: synched evaluator across roles and 'course evaluator' doctype 2026-03-12 17:05:26 +05:30
Jannat Patel e29c9354fd Merge branch 'main-hotfix' into develop 2026-03-12 16:59:01 +05:30
Jannat Patel 429d38f771 feat: navigate between questions in quiz 2026-03-12 16:50:02 +05:30
Raizaaa b8283860a7 Merge pull request #2185 from raizasafeel/fix/api
fix(security): prevent stored XSS decoding in _lms.py
2026-03-12 13:15:35 +05:30
Raizaaa 456e1db6c8 Merge branch 'frappe:develop' into fix/api 2026-03-12 13:03:19 +05:30
Jannat Patel 40aae3a2ed Merge pull request #2184 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-12 11:12:28 +05:30
MochaMind 4f27b9b763 chore: Portuguese, Brazilian translations 2026-03-12 06:03:39 +05:30
Raizaaa be4934862e Merge branch 'frappe:develop' into fix/api 2026-03-12 00:38:51 +05:30
raizasafeel efda159191 fix: prevent stored XSS decoding in _lms.py 2026-03-12 00:37:29 +05:30
Jannat Patel a664296fe5 Merge pull request #2182 from pateljannat/issues-207
feat: payment reminder setting
2026-03-11 14:23:11 +05:30
Jannat Patel 189de76a42 Merge pull request #2180 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-11 14:22:52 +05:30
Jannat Patel 1661389b07 test: fixed course image issue 2026-03-11 12:53:01 +05:30
Jannat Patel e90a730a29 test: delete course from cypress test after all operations are complete 2026-03-11 12:34:05 +05:30
Jannat Patel 9820db329e fix: course deletion issues 2026-03-11 11:50:05 +05:30
MochaMind d572f54e3b chore: Esperanto translations 2026-03-11 05:55:18 +05:30
MochaMind 97405d4ad8 chore: Serbian (Latin) translations 2026-03-11 05:55:17 +05:30
MochaMind 6beae3496f chore: Norwegian Bokmal translations 2026-03-11 05:55:15 +05:30
MochaMind e295424d1d chore: Bosnian translations 2026-03-11 05:55:14 +05:30
MochaMind 5e96911834 chore: Burmese translations 2026-03-11 05:55:12 +05:30
MochaMind fdd8c083e8 chore: Croatian translations 2026-03-11 05:55:11 +05:30
MochaMind 45298a6f85 chore: Thai translations 2026-03-11 05:55:09 +05:30
MochaMind 00c4d5b878 chore: Persian translations 2026-03-11 05:55:08 +05:30
MochaMind 7343691bb1 chore: Indonesian translations 2026-03-11 05:55:06 +05:30
MochaMind 9aaff97f06 chore: Portuguese, Brazilian translations 2026-03-11 05:55:05 +05:30
MochaMind 226b0fb5d1 chore: Vietnamese translations 2026-03-11 05:55:03 +05:30
MochaMind 549a3281ec chore: Chinese Simplified translations 2026-03-11 05:55:02 +05:30
MochaMind 27f516e383 chore: Turkish translations 2026-03-11 05:55:00 +05:30
MochaMind 62d748b6b3 chore: Swedish translations 2026-03-11 05:54:59 +05:30
MochaMind bef52063c9 chore: Serbian (Cyrillic) translations 2026-03-11 05:54:57 +05:30
MochaMind b0ae913b33 chore: Slovenian translations 2026-03-11 05:54:56 +05:30
MochaMind 57b5240c5c chore: Russian translations 2026-03-11 05:54:55 +05:30
MochaMind 193f014627 chore: Portuguese translations 2026-03-11 05:54:53 +05:30
MochaMind bd005c82c2 chore: Polish translations 2026-03-11 05:54:52 +05:30
MochaMind 0f516a452b chore: Dutch translations 2026-03-11 05:54:50 +05:30
MochaMind 9ebf895733 chore: Italian translations 2026-03-11 05:54:49 +05:30
MochaMind 554e111329 chore: Hungarian translations 2026-03-11 05:54:47 +05:30
MochaMind 2f5010fbe2 chore: German translations 2026-03-11 05:54:46 +05:30
MochaMind e1710eb59e chore: Danish translations 2026-03-11 05:54:44 +05:30
MochaMind d072c6259b chore: Czech translations 2026-03-11 05:54:43 +05:30
MochaMind 80de3ad5e1 chore: Arabic translations 2026-03-11 05:54:42 +05:30
MochaMind db7c8499b4 chore: Spanish translations 2026-03-11 05:54:40 +05:30
MochaMind 005acc2815 chore: French translations 2026-03-11 05:54:39 +05:30
Jannat Patel d68a362115 feat: settings for payment reminders 2026-03-10 18:44:28 +05:30
Jannat Patel c583ad72d1 fix: send payment reminders for incomplete batch payments only 2026-03-10 18:15:36 +05:30
Jannat Patel ef574047fe Merge pull request #2174 from frappe/develop
chore: merge `develop` into `main-hotfix`
2026-03-09 17:26:18 +05:30
Jannat Patel a7eaaeda95 Merge pull request #2159 from pateljannat/demo-data
feat: demo data
2026-03-09 16:47:08 +05:30
Jannat Patel 82d9ea7efc fix: delete demo quiz and users when deleting demo data 2026-03-09 16:37:21 +05:30
Jannat Patel a6da65ab99 fix: dont capture progress of demo course for analytics 2026-03-09 16:22:21 +05:30
Jannat Patel ad76fac579 Merge pull request #2172 from frappe/pot_develop_2026-03-09
chore: update POT file
2026-03-09 14:10:41 +05:30
frappe-pr-bot 0fb4e0bc41 chore: update POT file 2026-03-09 08:28:54 +00:00
Jannat Patel 68d69d5ccd Merge pull request #2171 from pateljannat/issues-206
fix: misc issues
2026-03-09 12:20:28 +05:30
Jannat Patel f11059524f fix: lms dynamic path fetching 2026-03-09 11:54:16 +05:30
Jannat Patel 735a3f4b00 fix: misc issues 2026-03-09 11:34:28 +05:30
Jannat Patel 0a587e5598 Merge pull request #2169 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-09 10:51:24 +05:30
Jannat Patel cb273685a7 test: click the div of the test course and not the first card 2026-03-09 10:50:46 +05:30
MochaMind daacbc7faf chore: Russian translations 2026-03-07 06:03:52 +05:30
Jannat Patel 711d89b603 fix: exclude demo course count before showing persona 2026-03-06 17:51:40 +05:30
Jannat Patel 3889893b2f Merge branch 'develop' of https://github.com/frappe/lms into demo-data 2026-03-06 17:44:53 +05:30
Jannat Patel 7cfae5401e Merge pull request #2168 from raizasafeel/fix/persona
fix(persona): redirection route and skip button
2026-03-06 17:44:42 +05:30
Jannat Patel 71f3aca623 Merge pull request #2167 from raizasafeel/fix/payment
fix: use backend field metadata for billing and transaction forms
2026-03-06 17:42:39 +05:30
Jannat Patel 8c54d77740 test(ui): find course with the test title instead of checking the first course card 2026-03-06 17:22:12 +05:30
Jannat Patel 2e0f8e91af fix: pluck name when creating instructor for demo 2026-03-06 17:08:42 +05:30
Jannat Patel 7c20b9c728 ci: get payments app in ui tests 2026-03-06 17:00:03 +05:30
Jannat Patel 2941c4724f ci(revert): don't get payments app 2026-03-06 12:10:34 +05:30
Jannat Patel dcdffc0aac ci: get payments app before installing lms 2026-03-05 21:51:59 +05:30
Jannat Patel 607103e40e feat: demo review and course progress 2026-03-05 21:41:34 +05:30
raizasafeel 8d3485742b revert: 'refactor(persona): made persona labels/options smaller and simpler' 2026-03-05 15:27:02 +05:30
raizasafeel f24b0fd22b fix(persona): skip/continue button redirects to Home page 2026-03-05 15:20:59 +05:30
Raizaaa 3731826ffd Merge branch 'frappe:develop' into fix/persona 2026-03-05 13:49:12 +05:30
raizasafeel 865634ce82 fix(persona): skip button now successfully redirects to courses 2026-03-05 13:45:16 +05:30
raizasafeel 9923b702e0 refactor(persona): made persona labels/options smaller and simpler 2026-03-05 13:37:54 +05:30
raizasafeel 49f4c878d6 fix(persona): moved to homepage from courses page 2026-03-05 13:30:22 +05:30
Jannat Patel 69e2d628d9 Merge pull request #2165 from frappe/develop
merge `develop` into `main-hotifx`
2026-03-05 10:30:31 +05:30
Raizaaa 112cc3ac9d Merge branch 'frappe:develop' into fix/payment 2026-03-05 02:07:41 +05:30
raizasafeel 4a5f16e1bc fix(settings): transaction button now redirects successfully to batch/course 2026-03-05 02:05:21 +05:30
raizasafeel a893c405d1 fix: replace hardcoded meta fields with validation from backend 2026-03-05 02:05:00 +05:30
raizasafeel 5683fd5d7a fix: add get_field_meta function to get doctype field metadata 2026-03-05 02:03:24 +05:30
Jannat Patel f1014e7452 Merge branch 'develop' of https://github.com/frappe/lms into demo-data 2026-03-04 20:53:22 +05:30
Jannat Patel 82f0bb40ef Merge branch 'main-hotfix' into develop 2026-03-04 20:49:45 +05:30
Jannat Patel 71ff6e01d6 Merge pull request #2164 from pateljannat/issues-205
fix: misc issues
2026-03-04 20:43:56 +05:30
Jannat Patel 701814060d fix: delete event after deleting the live class 2026-03-04 20:35:51 +05:30
Jannat Patel 292b48fbac fix: do not link live class to event as event is already linked to live class 2026-03-04 18:08:55 +05:30
Jannat Patel 2e3baff401 refactor: live class controllers for better code reusability 2026-03-04 16:59:32 +05:30
Jannat Patel 7e26bb277f fix: misc ui issues 2026-03-04 16:58:53 +05:30
Jannat Patel 22fb96a00f Merge pull request #2163 from pateljannat/issues-204
fix: assignment conditions for save button visibility
2026-03-04 11:09:37 +05:30
Jannat Patel 8752f8038a Merge pull request #2156 from raizasafeel/fix/payment-gateway
fix(payment gateway): add delete functionality and field details
2026-03-04 11:01:38 +05:30
Jannat Patel c5bb852227 Merge pull request #2122 from ColoredCow/feature/google-meet
feat: Google Meet integration for Live Classes
2026-03-04 11:00:13 +05:30
Jannat Patel 8d8452f8a3 fix: assignment conditions for save button visibility 2026-03-04 10:44:39 +05:30
Jannat Patel c77fdf55b3 Merge branch 'main' into main-hotfix 2026-03-04 10:22:29 +05:30
Jannat Patel c509da8497 Merge pull request #2160 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-04 10:21:32 +05:30
MochaMind d5bc012c21 chore: Swedish translations 2026-03-04 00:17:20 +05:30
MochaMind 610ec89670 chore: Spanish translations 2026-03-04 00:17:06 +05:30
Vaibhav Rathore a29e1a58a4 fix: resolve server test failures for Google Meet integration
- Mock get_google_calendar_object in live class tests to prevent real
  Google API calls in CI (no OAuth tokens available)
- Fix on_trash to clear event link before deleting Event to avoid
  LinkExistsError
- Fix test query to avoid PostgreSQL-incompatible integer filter
- Add Google Settings setup to Google Meet Settings integration tests
2026-03-03 17:34:34 +05:30
Vaibhav Rathore f0d35ec1d1 fix: enable Google API in test setup to fix CI server tests
Google Calendar validation requires Google API to be enabled in Google
Settings. Without this, all LMS Live Class tests fail in CI with
"Enable Google API in Google Settings" error.
2026-03-03 15:56:34 +05:30
Vaibhav Rathore 3f116a37c2 fix: remove unnecessary "Meet link generating" block from LiveClass 2026-03-03 15:56:34 +05:30
Vaibhav Rathore 1086d2219b fix: add console.error to error handlers in GoogleMeetAccountModal 2026-03-03 15:56:34 +05:30
Vaibhav Rathore c5998f95ee refactor: move GoogleMeetAccountModal to Settings folder
Move GoogleMeetAccountModal.vue from Modals to Settings directory
to colocate it with GoogleMeetSettings.vue. Update import path.
2026-03-03 15:56:33 +05:30
Vaibhav Rathore 507938425c refactor: move conferencing fields to separate section and translate labels
Move Conferencing Provider, Zoom Account, and Google Meet Account
fields into their own "Conferencing" section. Wrap option labels
with __() for i18n translation.
2026-03-03 15:56:33 +05:30
Vaibhav Rathore cd9a6831a7 fix: add type annotations and role validation to create_google_meet_live_class
Add type hints to match create_live_class signature. Add
frappe.only_for role check to prevent unauthorized access.
2026-03-03 15:56:33 +05:30
Vaibhav Rathore 2fab297745 fix: add patch to set conferencing provider for existing Zoom records
Sets conferencing_provider to "Zoom" for all existing LMS Batch and
LMS Live Class records that have a zoom_account linked.
2026-03-03 15:56:33 +05:30
Vaibhav Rathore 4925c5bc45 refactor: extract shared helpers to reduce code duplication in lms_live_class
Extract _get_participants() and _build_event_description() to eliminate
duplicated participant-gathering and description-building logic across
Zoom and Google Meet code paths.
2026-03-03 15:56:33 +05:30
Vaibhav Rathore f5551603a5 fix: resolve ruff-format and prettier linting errors 2026-03-03 15:56:33 +05:30
Vaibhav Rathore 1eb13c9378 chore: remove unused component declaration from components.d.ts 2026-03-03 15:56:33 +05:30
Frappe PR Bot 2c2e8ca112 chore(release): Bumped to Version 2.45.2 2026-03-03 07:19:36 +00:00
Jannat Patel 4771ebbcfd fix: enrollment error during course progress 2026-03-03 12:48:51 +05:30
Jannat Patel 2a2e937876 feat: added course card image for demo course 2026-03-03 12:46:40 +05:30
Jannat Patel 08fbcc963d Merge pull request #2158 from frappe/mergify/bp/main-hotfix/pr-2157
fix: pricing section issue in course form (backport #2157)
2026-03-02 18:32:05 +05:30
Jannat Patel 54e9396fdb fix: pricing section issue in course form
(cherry picked from commit a0d6b2b6b6)
2026-03-02 12:54:01 +00:00
Jannat Patel 2b124de4cb Merge pull request #2157 from pateljannat/issues-203
fix: pricing section issue in course form
2026-03-02 18:23:48 +05:30
Jannat Patel a0d6b2b6b6 fix: pricing section issue in course form 2026-03-02 18:06:44 +05:30
Jannat Patel 93f019a0d0 feat: demo data 2026-03-02 18:02:25 +05:30
Jannat Patel 5180875ab5 Merge pull request #2155 from pateljannat/issues-202
fix: misc issues
2026-03-02 13:43:38 +05:30
Jannat Patel 40d83aca36 fix: course and batch description formatting issue 2026-03-02 13:37:28 +05:30
Jannat Patel d3a4c211db fix: do not accept json as input for certificate request event 2026-03-02 13:29:06 +05:30
raizasafeel 1223ca8f29 fix(payment gateway): default values not pre-filling in new form 2026-03-02 13:21:04 +05:30
Jannat Patel 9af9a7d87f fix: desk redirection and desk sidebar 2026-03-02 13:18:13 +05:30
raizasafeel 5ae5634753 fix(payment gateway): add missing removeAccount function 2026-03-02 13:02:06 +05:30
Jannat Patel f63a4a44a2 fix: support youtube watch links as preview links 2026-03-02 13:01:59 +05:30
raizasafeel b95a308f7a fix(payment gateway): include reqd, options, default, and description in fields 2026-03-02 12:10:31 +05:30
Jannat Patel e8fcd2fa0a Merge pull request #2154 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-02 11:09:28 +05:30
Jannat Patel 5c4385aefd Merge pull request #2146 from raizasafeel/chore/upgrade-frappe-ui-0.1.264
chore: upgrade frappe-ui to v0.1.264
2026-03-02 11:09:18 +05:30
MochaMind 3359d4511c chore: Russian translations 2026-03-02 00:03:52 +05:30
raizasafeel 5520f7f083 chore: upgrade frappe-ui to v0.1.264 2026-02-27 17:47:46 +05:30
Jannat Patel 94f0f79404 Merge pull request #2144 from Owaishk08/fix/profile-nudge-collapsed-sidebar
fix: show profile nudge as icon when sidebar is collapsed
2026-02-27 16:21:40 +05:30
Owais Khan 8d03b25331 fix: show profile nudge as icon when sidebar is collapsed 2026-02-27 16:00:11 +05:30
Owais Khan f54b63a2a7 fix: show profile nudge as icon when sidebar is collapsed 2026-02-27 12:56:36 +05:30
Jannat Patel 2dd2c78b88 Merge pull request #2143 from pateljannat/issues-201
fix: misc issues
2026-02-27 12:44:01 +05:30
Jannat Patel 361d1c0fd6 fix: misc issues 2026-02-27 12:31:38 +05:30
Jannat Patel 5c0faa39b7 Merge pull request #2141 from pateljannat/issues-200
fix: if certificate is linked to a batch then don't validate course enrollment
2026-02-27 11:06:56 +05:30
Jannat Patel 78c6bfea83 fix: if certificate is linked to a batch then don't validate course enrollment 2026-02-27 10:56:06 +05:30
Jannat Patel f3eb000c23 Merge pull request #2140 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-27 10:53:50 +05:30
MochaMind aa638e9992 chore: Swedish translations 2026-02-26 23:41:48 +05:30
Vaibhav Rathore 8ea178fcad refactor: remove manual attendance feature for Google Meet classes
Manual attendance marking is not required for Google Meet live classes.
This removes the ManualAttendance modal, the mark_manual_attendance API
endpoint, and associated tests.
2026-02-26 18:38:55 +05:30
Jannat Patel fa3be115d7 Merge pull request #2138 from pateljannat/nudge-profile-completion
feat: nudge students to complete their profile
2026-02-26 17:14:22 +05:30
Jannat Patel 975f06d956 feat: nudge students to complete their profile 2026-02-26 16:59:08 +05:30
Jannat Patel 6b24a23e70 Merge pull request #2137 from pateljannat/issues-199
fix: misc issues
2026-02-26 16:10:12 +05:30
Jannat Patel 87e588cd1f fix: misc permission issues 2026-02-26 15:48:21 +05:30
Jannat Patel 3462d2f251 fix: misc ui issues 2026-02-26 15:06:30 +05:30
Jannat Patel 92e956a9a2 Merge pull request #2136 from pateljannat/issues-198
fix: batch admin conditions
2026-02-26 13:40:09 +05:30
Jannat Patel 0e65c2cf76 fix: batch admin conditions 2026-02-26 13:39:40 +05:30
Jannat Patel 0adda28674 Merge pull request #2135 from pateljannat/issues-197
fix: assignment upload issue
2026-02-26 12:12:45 +05:30
Jannat Patel 69f90fb809 fix: assignment upload issue 2026-02-26 11:31:32 +05:30
Jannat Patel 23cde1761b Merge pull request #2132 from pateljannat/issues-196
fix: student home page issue when not enrolled in any batch
2026-02-25 18:22:11 +05:30
Jannat Patel e8354e9781 fix: student home page issue when not enrolled in any batch 2026-02-25 18:14:45 +05:30
Vaibhav Rathore 08b6a9d091 fix: use str() instead of get_time_str() for time field comparison
get_time_str() expects a timedelta but batch start_time is a
datetime.time object, causing AttributeError on batch details page.
2026-02-25 17:05:38 +05:30
Vaibhav Rathore 4be47af3ef fix: skip conferencing provider validation on batch creation
Frappe sets Select field default to the first option ("Zoom") even
when the field isn't in the creation form. Skip validation for new
batches since conferencing is configured after creation.
2026-02-25 16:59:23 +05:30
Jannat Patel 49e989f39e Merge pull request #2131 from pateljannat/issues-195
fix: verify quiz answers on server side
2026-02-25 16:57:05 +05:30
Vaibhav Rathore 898a872232 fix: make conferencing provider optional during batch creation
The conferencing_provider Select field defaulted to "Zoom" (first option)
when not explicitly set, causing batch creation to fail with
"Please select a Zoom account for this batch" since no zoom_account
is provided at creation time.
2026-02-25 16:56:02 +05:30
Jannat Patel cb01e17aa7 fix: verify quiz answers on server side 2026-02-25 16:42:55 +05:30
Jannat Patel d7c5ff7098 Merge pull request #2129 from pateljannat/issues-194
fix: sanitize data before creating new course or batch
2026-02-25 14:01:15 +05:30
Jannat Patel 62b5715b98 Merge pull request #2127 from frappe/mergify/bp/main-hotfix/pr-2126
fix: permission issue during quiz submission (backport #2126)
2026-02-25 13:08:14 +05:30
Jannat Patel af611b1603 fix: sanitize data before creating new course or batch 2026-02-25 13:03:47 +05:30
Jannat Patel e63d83beb5 fix: permission issue during quiz submission
(cherry picked from commit af5bce9e34)
2026-02-25 07:21:40 +00:00
Jannat Patel 8fa5c899ff Merge pull request #2126 from pateljannat/issues-193
fix: permission issue during quiz submission
2026-02-25 12:44:28 +05:30
Jannat Patel af5bce9e34 fix: permission issue during quiz submission 2026-02-25 12:37:09 +05:30
Jannat Patel 1ea8705552 Merge pull request #2125 from frappe/develop
chore: merge `develop` into `main-hotfix`
2026-02-25 11:24:11 +05:30
Jannat Patel 61193b71f4 Merge branch 'main-hotfix' into develop 2026-02-25 11:09:48 +05:30
Jannat Patel 26301c26e9 Merge pull request #2121 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-25 10:37:32 +05:30
Vaibhav Rathore 559338da59 fix: include conferencing_provider and google_meet_account in batch details API 2026-02-24 23:55:33 +05:30
Vaibhav Rathore 9a7c77c57b feat: Google Meet integration for Live Classes
Add Google Meet as an alternative conferencing provider for Live Classes
in Frappe LMS, alongside the existing Zoom integration. Leverages
Frappe's built-in Google Calendar sync to generate Meet links.

Changes:
- New DocType: LMS Google Meet Settings (account_name, member, calendar)
- Schema changes to LMS Batch (conferencing_provider, google_meet_account)
- Schema changes to LMS Live Class (conferencing_provider, google_meet_account)
- Participant calendar invites via Google Calendar API
- Event update/reschedule sync (on_update hook)
- Event cancellation/deletion sync (on_trash hook)
- Async Meet link handling with user-facing fallback message
- Frontend empty link guard ("Meet link generating...")
- Batch validation for conferencing provider configuration
- Manual attendance marking for Google Meet classes
- Admin UI for managing Google Meet accounts in LMS Settings
- Unit and integration tests

Upstream Issue: frappe/lms#2027
2026-02-24 23:25:27 +05:30
MochaMind 63321fe2c8 chore: Persian translations 2026-02-24 22:48:48 +05:30
MochaMind 68848fc642 chore: Spanish translations 2026-02-24 22:48:29 +05:30
Jannat Patel aa7ec019bc Merge pull request #2120 from pateljannat/issues-192
fix: misc issues
2026-02-24 18:22:57 +05:30
Jannat Patel eb33155db2 fix: enqueue progress calculation after validating enrollments 2026-02-24 18:13:02 +05:30
Jannat Patel 3088b14d83 fix: recalculate course progress when lesson is inserted or deleted 2026-02-24 17:40:58 +05:30
Jannat Patel bf89f3ba2f fix: show system timezone in certificate request 2026-02-24 13:01:29 +05:30
Jannat Patel 2198adf902 fix: sidebar settings issue if guest access was not allowed 2026-02-24 12:36:46 +05:30
Jannat Patel c5145c6c24 Merge pull request #2119 from pateljannat/issues-191
fix: misc issues
2026-02-24 12:17:41 +05:30
Jannat Patel 499bcd5281 chore: resolved conflicts 2026-02-24 12:05:44 +05:30
Jannat Patel dc4bbdaa55 Merge pull request #2116 from raizasafeel/fix/codesandbox-embed
fix(lesson): render codesandbox
2026-02-24 12:02:52 +05:30
Jannat Patel bf19ebd3a8 fix: assignment submission issue 2026-02-24 12:02:10 +05:30
Jannat Patel 5a6a7ff646 Merge pull request #2117 from pateljannat/issues-189
chore: capture more events for analytics
2026-02-23 16:41:23 +05:30
Jannat Patel b3c8fbd833 chore: capture more events for analytics 2026-02-23 16:31:58 +05:30
Jannat Patel f828c76a0f Merge pull request #2115 from pateljannat/issues-188
fix: misc issues
2026-02-23 15:13:02 +05:30
raizasafeel 2634a4e316 fix(lesson): render codesandbox properly 2026-02-23 15:12:08 +05:30
Jannat Patel fb0517caa0 fix: show only instructor tab for admins on home page 2026-02-23 14:54:54 +05:30
Jannat Patel 90151be166 fix: updated app name in workspace and desktop 2026-02-23 12:35:13 +05:30
Jannat Patel b77c4867e1 Merge pull request #2109 from pateljannat/issues-186
fix: check permission of session user during batch enrollment
2026-02-23 11:36:49 +05:30
Jannat Patel c1260edb00 fix: check permission of session user during batch enrollment 2026-02-23 11:28:58 +05:30
Jannat Patel 41d5ef5fd5 Merge pull request #2108 from pateljannat/issues-185
fix: misc permission issues
2026-02-23 11:22:52 +05:30
Jannat Patel 14937fd4fc fix: check ptype for permission if not admin 2026-02-23 11:06:34 +05:30
Jannat Patel 58826fe30f fix: removed badge page reference from the router 2026-02-23 10:41:13 +05:30
Jannat Patel 0da9eec0af Merge pull request #2105 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-23 10:29:32 +05:30
Jannat Patel bb47fd5ba9 fix: misc permission issues 2026-02-23 10:29:13 +05:30
MochaMind db49cb2d64 chore: Thai translations 2026-02-21 22:13:22 +05:30
MochaMind 58732148e2 chore: Vietnamese translations 2026-02-21 22:13:18 +05:30
MochaMind 08eb7ef17b chore: Dutch translations 2026-02-21 22:13:10 +05:30
MochaMind 8bda7edb7b chore: Spanish translations 2026-02-21 22:13:03 +05:30
Jannat Patel 49d596216d fix: zoom account link issue 2026-02-21 12:58:19 +05:30
Jannat Patel faa9c94970 fix: zoom account link issue 2026-02-21 12:28:55 +05:30
Jannat Patel c596d1e215 fix: misc permission issues 2026-02-21 12:25:47 +05:30
Jannat Patel 235958e432 Merge pull request #2103 from raizasafeel/fix/lesson-body-filter
fix(security): remove ignore_xss_filter to enable HTML sanitization
2026-02-20 14:20:07 +05:30
raizasafeel 7f85dbccec fix: added timestamps for bench migrate 2026-02-20 13:52:13 +05:30
Jannat Patel 5b69ddf9b5 Merge pull request #2099 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-20 13:39:16 +05:30
Jannat Patel dfb7152aa3 fix: video watch time permission and other issues 2026-02-20 13:08:27 +05:30
Raizaaa 2a311bfb6f Merge pull request #2104 from raizasafeel/fix/lms-enrollment
fix: duplicate enrollment validation on update
2026-02-20 12:21:45 +05:30
raizasafeel 0e90627144 fix: duplicate enrollment validation on update 2026-02-20 12:09:08 +05:30
Raizaaa aac1692058 Merge branch 'frappe:develop' into fix/lesson-body-filter 2026-02-20 11:58:57 +05:30
raizasafeel d58d362c72 fix(security): remove ignore_xss_filter to enable HTML sanitization 2026-02-20 11:54:17 +05:30
MochaMind e7f2386d14 chore: Thai translations 2026-02-19 21:23:50 +05:30
MochaMind 79a50d2454 chore: Portuguese, Brazilian translations 2026-02-19 21:23:45 +05:30
MochaMind 936f82c477 chore: Spanish translations 2026-02-19 21:23:24 +05:30
Jannat Patel 133037698c fix: remove read permission on lms settings for lms student 2026-02-19 16:30:13 +05:30
Jannat Patel 07c58251a1 fix: lms certificate request will allow students to read only if they are owner 2026-02-19 16:27:35 +05:30
Jannat Patel c88d36df1e fix: permission issues on badges 2026-02-19 15:59:03 +05:30
Jannat Patel 08373dc2ab fix: refactored job form and permissions 2026-02-19 15:58:44 +05:30
Jannat Patel 44ca59c64a fix: return profile details only if the profile is of an LMS user 2026-02-19 12:51:30 +05:30
Jannat Patel c961923fa0 fix: verify enrollment and admin access before returing batch assessment data 2026-02-19 12:43:50 +05:30
Jannat Patel 72cee75474 fix: only allow lms roles to be modified by moderator 2026-02-19 12:39:55 +05:30
Jannat Patel cb3af6fa63 fix: sanitised badge assignment api 2026-02-19 12:24:47 +05:30
Jannat Patel 0ff14a959d Merge pull request #2096 from pateljannat/issues-184
fix: course form issues
2026-02-19 11:58:09 +05:30
Jannat Patel 35adf49015 fix: course form overflow issue 2026-02-19 11:47:17 +05:30
Jannat Patel e5f0d55ff0 fix: course form permission issue 2026-02-19 11:46:56 +05:30
Jannat Patel ba395fe982 Merge pull request #2081 from pateljannat/batch-dashboard-update
Batch dashboard update
2026-02-18 15:45:01 +05:30
Jannat Patel 8ab6776fa9 fix: redirect from batch/details to batch 2026-02-18 15:36:55 +05:30
Jannat Patel 24bfe69985 chore: resolved conflicts 2026-02-18 11:16:00 +05:30
Jannat Patel 6f86b822bf fix: mobile view for batch dashboard 2026-02-18 10:58:49 +05:30
Jannat Patel af273a9a1c refactor: batch student progress 2026-02-17 19:38:19 +05:30
Jannat Patel 44b7a210ce chore: resolved conflicts 2026-02-17 15:04:31 +05:30
Jannat Patel 641d729bd1 refactor: student batch dashboard 2026-02-16 12:17:13 +05:30
Jannat Patel 944fd6d013 refactor: new batch quick entry modal 2026-02-12 19:52:46 +05:30
Jannat Patel c0298f0a70 Merge branch 'develop' of https://github.com/frappe/lms into batch-dashboard-update 2026-02-12 11:09:19 +05:30
Jannat Patel 7ef8aad2c8 fix: dirty state of batch form 2026-02-12 10:33:21 +05:30
Jannat Patel f59eecd34e fix: circular dependency issues 2026-02-11 19:24:50 +05:30
Jannat Patel eab929da47 refactor: batch form 2026-02-11 18:42:42 +05:30
Jannat Patel e9f0b12550 feat: batch page new look 2026-02-10 19:53:26 +05:30
294 changed files with 75985 additions and 36317 deletions
+1
View File
@@ -11,6 +11,7 @@ cd ./frappe-bench || exit
bench -v setup requirements
echo "Setting Up LMS App..."
bench get-app "https://github.com/frappe/payments"
bench get-app lms "${GITHUB_WORKSPACE}"
echo "Setting Up Sites & Database..."
+3 -3
View File
@@ -38,9 +38,9 @@ jobs:
- name: Set Branch
run: |
export APPS_JSON='[{"url": "https://github.com/frappe/lms","branch": "main"}]'
export APPS_JSON='[{"url": "https://github.com/frappe/payments","branch": "version-15"},{"url": "https://github.com/frappe/lms","branch": "main"}]'
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
echo "FRAPPE_BRANCH=version-16" >> $GITHUB_ENV
- name: Set Image Tag
run: |
@@ -61,4 +61,4 @@ jobs:
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
build-args: |
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
+4
View File
@@ -8,6 +8,7 @@ on:
pull_request: {}
jobs:
tests:
name: Server Tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -61,6 +62,9 @@ jobs:
mkdir -p ~/bench-cache
(cd && tar czf ~/bench-cache/bench.tgz frappe-bench)
fi
- name: add payments app to bench
working-directory: /home/runner/frappe-bench
run: bench get-app https://github.com/frappe/payments
- name: add lms app to bench
working-directory: /home/runner/frappe-bench
run: bench get-app lms $GITHUB_WORKSPACE
+13 -4
View File
@@ -1,4 +1,4 @@
name: UI
name: UI Tests
on:
pull_request:
@@ -16,14 +16,14 @@ permissions:
jobs:
test:
name: UI Tests (Cypress) - ${{ matrix.containers }}
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'frappe' }}
timeout-minutes: 60
strategy:
fail-fast: false
name: UI Tests (Cypress)
matrix:
containers: [1, 2]
services:
mariadb:
@@ -115,6 +115,15 @@ jobs:
env:
CYPRESS_BASE_URL: http://lms.test:8000
CYPRESS_RECORD_KEY: 095366ec-7b9f-41bd-aeec-03bb76d627fe
SPLIT: ${{ strategy.job-total }}
SPLIT_INDEX: ${{ strategy.job-index }}
- name: Upload Cypress screenshots if tests fail
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: cypress-screenshots-${{ matrix.containers }}
path: cypress/screenshots
- name: Stop server and wait for coverage file
run: |
+13 -5
View File
@@ -152,11 +152,19 @@ You need Docker, docker-compose and git setup on your machine. Refer [Docker doc
To setup the repository locally follow the steps mentioned below:
1. Install bench and setup a `frappe-bench` directory by following the [Installation Steps](https://frappeframework.com/docs/user/en/installation)
1. Start the server by running `bench start`
1. In a separate terminal window, create a new site by running `bench new-site learning.test`
1. Map your site to localhost with the command `bench --site learning.test add-to-hosts`
1. Get the Learning app. Run `bench get-app https://github.com/frappe/lms`
1. Run `bench --site learning.test install-app lms`.
1. Start the server by running
```sh
$ bench start
```
1. In a separate terminal window, run the following commands.
```sh
$ bench new-site learning.test
$ bench --site learning.test add-to-hosts
$ bench get-app https://github.com/frappe/payments
$ bench get-app https://github.com/frappe/lms
$ bench --site learning.test install-app lms
```
1. Now open the URL `http://learning.test:8000/lms` in your browser, you should see the app running
## Learn and connect
+7
View File
@@ -1,2 +1,9 @@
coverage:
status:
project:
default:
target: auto
threshold: 1%
ignore:
- "**/test_helper.py"
+8
View File
@@ -1,4 +1,5 @@
import { defineConfig } from "cypress";
import cypressSplit from "cypress-split";
export default defineConfig({
projectId: "vandxn",
@@ -14,5 +15,12 @@ export default defineConfig({
},
e2e: {
baseUrl: "http://pertest:8000",
setupNodeEvents(on, config) {
// Splitting tests only works when Cypress Cloud is not orchestrating parallel runs.
if (process.env.CYPRESS_CLOUD_PARALLEL !== "1") {
cypressSplit(on, config);
}
return config;
},
},
});
+35 -28
View File
@@ -7,7 +7,7 @@ describe("Batch Creation", () => {
// Open Settings
cy.get("span").contains("Learning").click();
cy.get("span").contains("Settings").click();
cy.contains('[role="menuitem"]', "Settings").click();
// Add a new member
cy.get("[data-dismissable-layer]")
@@ -27,24 +27,24 @@ describe("Batch Creation", () => {
cy.get("input[placeholder='Jane']").type(randomName);
cy.get("button").contains("Add").click();
// Open Settings
cy.get("span").contains("Learning").click();
cy.get("span").contains("Settings").click();
// Add evaluator
// Switch to Evaluators tab
cy.get("[data-dismissable-layer]")
.find("span")
.contains(/^Evaluators$/)
.click();
// Click "New" dropdown and select "New Evaluator"
cy.get("[data-dismissable-layer]")
.find("button")
.contains("New")
.click();
const randomEvaluator = `evaluator${dateNow}@example.com`;
cy.contains('[role="menuitem"]', "New Evaluator").click();
const randomEvaluator = `evaluator${dateNow}@example.com`;
cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
cy.get("input[placeholder='Jane']").type("Evaluator");
cy.get("button").contains("Add").click();
cy.wait(500);
cy.get("div").contains(randomEvaluator).should("be.visible").click();
cy.visit("/lms/batches");
@@ -52,33 +52,29 @@ describe("Batch Creation", () => {
// Create a batch
cy.get("button").contains("Create").click();
cy.get("span").contains("New Batch").click();
cy.contains('[role="menuitem"]', "New Batch").click();
cy.wait(500);
cy.url().should("include", "/batches/new/edit");
cy.get("label").contains("Title").type("Test Batch");
cy.get("label").contains("Start Date").type("2030-10-01");
cy.get("label").contains("End Date").type("2030-10-31");
cy.get("label").contains("Start Time").type("10:00");
cy.get("label").contains("End Time").type("11:00");
cy.get("label").contains("Timezone").type("IST");
cy.get("label").contains("Seat Count").type("10");
cy.get("label").contains("Published").click();
cy.get("label")
.contains("Short Description")
.contains("Description")
.type("Test Batch Short Description to test the UI");
cy.get("div[contenteditable=true").invoke(
cy.get("div.ProseMirror").invoke(
"text",
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
/* Instructor */
cy.get("label")
.contains("Instructors")
.parent()
.within(() => {
cy.get("input").click().type("evaluator");
cy.get("input").click().clear().type(randomEvaluator);
cy.get("input")
.invoke("attr", "aria-controls")
.as("instructor_list_id");
@@ -90,13 +86,27 @@ describe("Batch Creation", () => {
cy.get("[id^=headlessui-combobox-option-").first().click();
});
});
cy.button("Save").click();
cy.wait(1000);
// going to batch settings and publishing the batch
cy.url().should("include", "#settings");
cy.closeOnboardingModal();
cy.contains("label", "Published")
.invoke("attr", "for")
.then((id) => {
cy.get(`#${id}`)
.scrollIntoView()
.should("be.visible")
.click({ force: true });
cy.get(`#${id}`).should("have.attr", "aria-checked", "true");
});
cy.button("Save").click();
cy.wait(1000);
let batchName;
cy.url().then((url) => {
console.log(url);
batchName = url.split("/").pop();
batchName = url.split("/").pop().split("#")[0];
cy.wrap(batchName).as("batchName");
});
cy.wait(500);
@@ -108,14 +118,10 @@ describe("Batch Creation", () => {
cy.url().should("include", "/lms/batches");
cy.get('[id^="headlessui-radiogroup-v-"]')
.find("span")
.contains("Upcoming")
.should("be.visible")
.click();
cy.contains('[role="radio"]', "Upcoming").should("be.visible").click();
cy.get("@batchName").then((batchName) => {
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
cy.get(`a[href='/lms/batches/${batchName}'`).within(() => {
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
@@ -132,7 +138,7 @@ describe("Batch Creation", () => {
"be.visible"
);
});
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
cy.get(`a[href='/lms/batches/${batchName}'`).click();
});
cy.get("div").contains("Test Batch").should("be.visible");
@@ -154,14 +160,15 @@ describe("Batch Creation", () => {
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
)
.should("be.visible");
cy.get("button:visible").contains("Manage Batch").click();
cy.get("button:visible").contains("Dashboard").click();
/* Add student to batch */
cy.get("button").contains("Students").click();
cy.get("button").contains("Add").click();
cy.closeOnboardingModal();
cy.get("button").contains("Enroll").click();
cy.get('div[role="dialog"]')
.first()
.find("input[id^='headlessui-combobox-input-v-']")
.find("div[label='Student']")
.find("div")
.first()
.click();
cy.get("input[placeholder='Search']").type(randomEmail);
@@ -169,7 +176,7 @@ describe("Batch Creation", () => {
cy.get("button").contains("Submit").click();
// Verify Seat Count
cy.get("span").contains("Details").click();
cy.get("button:visible").contains("Overview").click();
cy.contains("div:visible", "9 Seats Left").should("be.visible");
});
});
+50 -33
View File
@@ -9,22 +9,29 @@ describe("Course Creation", () => {
// Create a course
cy.get("button").contains("Create").click();
cy.get("span").contains("New Course").click();
cy.contains('[role="menuitem"]', "New Course").click();
cy.wait(500);
cy.get("label").contains("Title").type("Test Course");
cy.get("label")
.contains("Short Introduction")
.type("Test Course Short Introduction to test the UI");
cy.get("div[contenteditable=true").invoke(
cy.get("div.ProseMirror").invoke(
"text",
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
cy.fixture("profile.png", "base64").then((fileContent) => {
cy.contains("Course Image")
.should("exist")
.parent()
.find('input[type="file"]')
.attachFile("profile.png", { force: true });
/* cy.fixture("profile.png", "base64").then((fileContent) => {
expect(fileContent).to.exist;
cy.get("div")
.contains("Course Image")
.siblings("div")
.parent()
.children('input[type="file"]')
.attachFile({
fileContent,
@@ -32,7 +39,7 @@ describe("Course Creation", () => {
mimeType: "image/png",
encoding: "base64",
});
});
}); */
/* Instructor */
cy.get("label")
@@ -53,8 +60,8 @@ describe("Course Creation", () => {
});
});
cy.button("Create").last().click();
cy.button("Save").last().click();
cy.closeOnboardingModal();
// Edit Course Details
cy.wait(500);
cy.get("label")
@@ -65,22 +72,19 @@ describe("Course Creation", () => {
.contains("Category")
.parent()
.within(() => {
cy.get("input").click();
cy.get("button").click();
});
cy.get("[id^=headlessui-combobox-option-")
.should("be.visible")
.first()
.click();
cy.get("div").contains("Business").click();
cy.get("label").contains("Published").click();
cy.get("label").contains("Published On").type("2021-01-01");
cy.button("Save").click();
// Add Chapter
cy.wait(1000);
cy.wait(500);
cy.button("Add").click();
cy.wait(1000);
cy.wait(500);
cy.get("[data-dismissable-layer]")
.should("be.visible")
.within(() => {
@@ -89,12 +93,10 @@ describe("Course Creation", () => {
});
// Add Lesson
cy.wait(1000);
cy.wait(500);
cy.button("Add Lesson").click();
cy.wait(1000);
cy.wait(500);
cy.url().should("include", "/learn/1-1/edit");
cy.wait(1000);
cy.get("label").contains("Title").type("Test Lesson");
cy.get("#content .ce-block").type(
"{enter}This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
@@ -102,21 +104,23 @@ describe("Course Creation", () => {
cy.button("Save").click();
// View Course
cy.wait(1000);
cy.wait(500);
cy.visit("/lms/courses");
cy.closeOnboardingModal();
cy.url().should("include", "/lms/courses");
cy.get(".grid a:first").within(() => {
cy.get("div").contains("Test Course");
cy.get("div").contains(
"Test Course Short Introduction to test the UI"
);
cy.get(".bg-cover")
.invoke("css", "background-image")
.should("include", "/files/profile");
});
cy.get(".grid a:first").click();
cy.get("div")
.contains("Test Course")
.closest("a")
.within(() => {
cy.get("div").contains(
"Test Course Short Introduction to test the UI"
);
cy.get(".bg-cover")
.invoke("css", "background-image")
.should("include", "/files/profile");
});
cy.get("div").contains("Test Course").closest("a").click();
cy.url().should("include", "/lms/courses/test-course");
cy.get("div").contains("Test Course");
cy.get("div").contains("Test Course Short Introduction to test the UI");
@@ -134,7 +138,7 @@ describe("Course Creation", () => {
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
cy.get("div").contains("Test Lesson").click();
});
cy.wait(3000);
cy.wait(500);
// View Lesson
cy.url().should("include", "/learn/1-1");
@@ -145,12 +149,11 @@ describe("Course Creation", () => {
);
// Add Discussion
cy.get("span").contains("Community").click();
cy.button("New Question").click();
cy.wait(500);
cy.get("[data-dismissable-layer]").within(() => {
cy.get("label").contains("Title").type("Test Discussion");
cy.get("div[contenteditable=true]").invoke(
cy.get("div.ProseMirror").invoke(
"text",
"This is a test discussion. This will check if the UI is working properly."
);
@@ -160,7 +163,7 @@ describe("Course Creation", () => {
// View Discussion
cy.wait(500);
cy.get("div").contains("Test Discussion").click();
cy.get("div[contenteditable=true").invoke(
cy.get("div.ProseMirror").invoke(
"text",
"This is a test comment. This will check if the UI is working properly."
);
@@ -168,5 +171,19 @@ describe("Course Creation", () => {
cy.get("div").contains(
"This is a test comment. This will check if the UI is working properly."
);
// Delete Course
cy.get("div").contains("Test Course").click();
cy.get("button").contains("Settings").click();
cy.get("header").within(() => {
cy.get("svg.lucide.lucide-ellipsis-icon").click();
});
cy.get("div[role=menu]").within(() => {
cy.contains('[role="menuitem"]', "Delete").click();
});
cy.get("span").contains("Delete").click();
cy.wait(500);
cy.url().should("include", "/lms/courses");
cy.get("div").contains("Test Course").should("not.exist");
});
});
+12 -8
View File
@@ -72,15 +72,19 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
Cypress.Commands.add("closeOnboardingModal", () => {
cy.wait(500);
const modalSelector = '[data-testid="onboarding-help-modal"]';
cy.get("body").then(($body) => {
// Check if any element with class including 'z-50' exists
if ($body.find('[class*="z-50"]').length > 0) {
cy.get('[class*="z-50"]')
.find('button:has(svg[class*="feather-x"])')
.realClick();
cy.wait(1000);
} else {
cy.log("Onboarding modal not found, skipping close.");
if (!$body.find(modalSelector).length) {
cy.log("Onboarding modal not present, skipping close.");
return;
}
// Skip onboarding steps if the button exists, otherwise just close the modal.
if ($body.find(`${modalSelector} button:contains("Skip all")`).length) {
cy.get(modalSelector).contains("button", "Skip all").click();
}
cy.get(modalSelector).find("button:has(svg.feather-x)").click();
cy.get(modalSelector).should("not.exist");
});
});
+2
View File
@@ -24,6 +24,7 @@ bench set-redis-socketio-host redis://redis:6379
sed -i '/redis/d' ./Procfile
sed -i '/watch/d' ./Procfile
bench get-app payments
bench get-app lms
bench new-site lms.localhost \
@@ -32,6 +33,7 @@ bench new-site lms.localhost \
--admin-password admin \
--no-mariadb-socket
bench --site lms.localhost install-app payments
bench --site lms.localhost install-app lms
bench --site lms.localhost set-config developer_mode 1
bench --site lms.localhost clear-cache
+4 -13
View File
@@ -8,14 +8,11 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AdminBatchDashboard: typeof import('./src/components/AdminBatchDashboard.vue')['default']
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
AddEvaluatorModal: typeof import('./src/components/Modals/AddEvaluatorModal.vue')['default']
Apps: typeof import('./src/components/Sidebar/Apps.vue')['default']
AppSidebar: typeof import('./src/components/Sidebar/AppSidebar.vue')['default']
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
Assessments: typeof import('./src/components/Assessments.vue')['default']
Assignment: typeof import('./src/components/Assignment.vue')['default']
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
@@ -24,16 +21,8 @@ declare module 'vue' {
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
BatchDashboard: typeof import('./src/components/BatchDashboard.vue')['default']
BatchFeedback: typeof import('./src/components/BatchFeedback.vue')['default']
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
@@ -72,6 +61,8 @@ declare module 'vue' {
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
GoogleMeetAccountModal: typeof import('./src/components/Settings/GoogleMeetAccountModal.vue')['default']
GoogleMeetSettings: typeof import('./src/components/Settings/GoogleMeetSettings.vue')['default']
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
InlineLessonMenu: typeof import('./src/components/Notes/InlineLessonMenu.vue')['default']
@@ -82,13 +73,13 @@ declare module 'vue' {
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default']
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
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']
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']
NewMemberModal: typeof import('./src/components/Modals/NewMemberModal.vue')['default']
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
+15 -15
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="{{ boot.lang }}" dir="{{ boot.text_direction }}">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="{{ favicon }}" />
@@ -201,26 +201,26 @@
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ title }}</title>
<meta name="title" content="{{ meta.title }}" />
<meta name="image" content="{{ meta.image }}" />
<meta name="description" content="{{ meta.description }}" />
<meta name="keywords" content="{{ meta.keywords }}" />
<meta property="og:title" content="{{ meta.title }}" />
<meta property="og:image" content="{{ meta.image }}" />
<meta property="og:description" content="{{ meta.description }}" />
<meta name="twitter:title" content="{{ meta.title }}" />
<meta name="twitter:image" content="{{ meta.image }}" />
<meta name="twitter:description" content="{{ meta.description }}" />
<title>{{ title | e }}</title>
<meta name="title" content="{{ meta.title | e }}" />
<meta name="image" content="{{ meta.image | e }}" />
<meta name="description" content="{{ meta.description | e }}" />
<meta name="keywords" content="{{ meta.keywords | e }}" />
<meta property="og:title" content="{{ meta.title | e }}" />
<meta property="og:image" content="{{ meta.image | e }}" />
<meta property="og:description" content="{{ meta.description | e }}" />
<meta name="twitter:title" content="{{ meta.title | e }}" />
<meta name="twitter:image" content="{{ meta.image | e }}" />
<meta name="twitter:description" content="{{ meta.description | e }}" />
</head>
<body class="sm:overscroll-y-none no-scrollbar">
<div id="app">
<div id="seo-content">
<h1>{{ meta.title }}</h1>
<h1>{{ meta.title | e }}</h1>
<p>
{{ meta.description }}
{{ meta.description | e }}
</p>
<a href="{{ meta.link }}">Know More</a>
<a href="{{ meta.link | e }}">Know More</a>
</div>
</div>
<script>
+2 -4
View File
@@ -27,13 +27,11 @@
"@editorjs/table": "2.4.2",
"@vueuse/core": "^14.1.0",
"ace-builds": "1.36.2",
"apexcharts": "4.3.0",
"chart.js": "4.4.1",
"codemirror": "6.0.1",
"dayjs": "1.11.10",
"dompurify": "3.2.6",
"feather-icons": "4.28.0",
"frappe-ui": "^0.1.261",
"frappe-ui": "^0.1.276",
"highlight.js": "11.11.1",
"lucide-vue-next": "0.383.0",
"markdown-it": "14.0.0",
@@ -57,6 +55,6 @@
"tailwindcss": "^3.4.15",
"unplugin-auto-import": "^20.3.0",
"vite": "5.0.11",
"vite-plugin-pwa": "0.15.0"
"vite-plugin-pwa": "^1.2.0"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<FrappeUIProvider>
<Layout class="isolate text-base">
<Layout class="isolate text-p-base">
<router-view />
</Layout>
<InstallPrompt v-if="isMobile && !settings.data?.disable_pwa" />
@@ -1,118 +0,0 @@
<template>
<div v-if="batch?.data" class="">
<div class="w-full flex items-center justify-between pb-4">
<div class="font-medium text-ink-gray-7">
{{ __('Statistics') }}
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
<NumberChart
class="border rounded-md"
:config="{ title: __('Students'), value: studentCount.data || 0 }"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Certified'),
value: certificationCount.data || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Courses'),
value: batch?.data?.courses?.length || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{ title: __('Assessments'), value: assessmentCount.data || 0 }"
/>
</div>
<AxisChart
v-if="showProgressChart"
class="border rounded-lg p-3 min-h-[300px]"
:config="{
data: filteredChartData,
title: __('Batch Summary'),
subtitle: __('Progress of students in courses and assessments'),
xAxis: {
key: 'task',
title: 'Tasks',
type: 'category',
},
yAxis: {
title: __('Number of Students'),
echartOptions: {
minInterval: 1,
},
},
swapXY: true,
series: [
{
name: 'value',
type: 'bar',
},
],
}"
/>
</div>
</template>
<script setup lang="ts">
import { AxisChart, createResource, NumberChart } from 'frappe-ui'
import { computed } from 'vue'
const props = defineProps<{
batch: { [key: string]: any } | null
}>()
const studentCount = createResource({
url: 'frappe.client.get_count',
cache: ['batch_student_count', props.batch?.data?.name],
params: {
doctype: 'LMS Batch Enrollment',
filters: { batch: props.batch?.data?.name },
},
auto: true,
})
const assessmentCount = createResource({
url: 'lms.lms.utils.get_batch_assessment_count',
cache: ['batch_assessment_count', props.batch?.data?.name],
params: {
batch: props.batch?.data?.name,
},
auto: true,
})
const chartData = createResource({
url: 'lms.lms.utils.get_batch_chart_data',
cache: ['batch_chart_data', props.batch?.data?.name],
params: { batch: props.batch?.data?.name },
auto: true,
})
const certificationCount = createResource({
url: 'frappe.client.get_count',
cache: ['batch_certificate_count', props.batch?.data?.name],
params: {
doctype: 'LMS Certificate',
filters: { batch_name: props.batch?.data?.name },
},
auto: true,
})
const filteredChartData = computed(() =>
(chartData.data || []).filter((item: { value: number }) => item.value > 0)
)
const showProgressChart = computed(
() =>
studentCount.data &&
(props.batch?.data?.courses?.length || assessmentCount.data)
)
</script>
-53
View File
@@ -1,53 +0,0 @@
<template>
<div v-if="communications.data?.length">
<div v-for="comm in communications.data">
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<Avatar :label="comm.sender_full_name" size="lg" />
<div class="ml-2 text-ink-gray-7">
{{ comm.sender_full_name }}
</div>
</div>
<div class="text-sm">
{{ timeAgo(comm.communication_date) }}
</div>
</div>
<div
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
v-html="comm.content"
></div>
</div>
</div>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No announcements') }}
</div>
</template>
<script setup>
import { createResource, Avatar } from 'frappe-ui'
import { timeAgo } from '@/utils'
const props = defineProps({
batch: {
type: String,
required: true,
},
})
const communications = createResource({
url: 'lms.lms.api.get_announcements',
makeParams(value) {
return {
batch: props.batch,
}
},
auto: true,
cache: ['announcement', props.batch],
})
</script>
<style>
.prose-sm p {
margin: 0 0 0.5rem;
}
</style>
+5 -4
View File
@@ -49,8 +49,9 @@
:label="__('Select an Assignment')"
:onCreate="(value, close) => redirectToForm()"
/>
<FormControl
type="checkbox"
<Switch
size="sm"
:description="__('Only show assignments from the current course')"
:label="__('Filter assignments by course')"
v-model="filterAssignmentsByCourse"
/>
@@ -61,11 +62,11 @@
</Dialog>
</template>
<script setup>
import { Dialog, FormControl } from 'frappe-ui'
import { Dialog, Switch } from 'frappe-ui'
import { nextTick, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Link } from 'frappe-ui/frappe'
import { getLmsRoute } from '@/utils/basePath'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const quiz = ref(null)
+115 -132
View File
@@ -5,7 +5,7 @@
:class="{ 'border rounded-lg overflow-auto': !showTitle }"
>
<div
class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]"
class="border-e p-5 overflow-y-auto h-[calc(100vh-3.2rem)]"
:class="{ 'h-full': !showTitle }"
>
<div v-if="showTitle" class="text-lg font-semibold mb-5 text-ink-gray-9">
@@ -16,8 +16,8 @@
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
</div>
</div>
<div class="text-sm text-ink-gray-7 font-medium mb-2">
{{ __('Question') }}:
<div class="text-ink-gray-9 font-semibold mb-5">
{{ __('Assignment') }}: {{ assignment.data.title }}
</div>
<div
v-html="assignment.data.question"
@@ -31,7 +31,7 @@
<div class="font-semibold text-ink-gray-9">
{{ __('Submission') }}
</div>
<div class="flex items-center space-x-2">
<div class="flex items-center gap-x-2">
<Badge v-if="isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
@@ -42,7 +42,11 @@
>
{{ submissionResource.doc?.status }}
</Badge>
<Button variant="solid" @click="submitAssignment()">
<Button
v-if="canModifyAssignment || canGradeSubmission"
variant="solid"
@click="submitAssignment()"
>
{{ __('Save') }}
</Button>
</div>
@@ -73,12 +77,15 @@
}}
</div>
<FileUploader
v-if="!submissionResource.doc?.assignment_attachment"
v-if="!attachment"
:fileTypes="getType()"
:uploadArgs="{
private: true,
}"
:validateFile="validateFile"
:validateFile="
(file) =>
validateFile(file, true, assignment.data.type.toLowerCase())
"
@success="(file) => saveSubmission(file)"
>
<template #default="{ uploading, progress, openFileSelector }">
@@ -94,27 +101,23 @@
<div v-else>
<div class="flex items-center text-ink-gray-7">
<a
:href="submissionResource.doc.assignment_attachment"
:href="attachment"
target="_blank"
class="cursor-pointer !no-underline text-sm leading-5"
>
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<div class="border rounded-md p-2 me-2">
<FileText class="h-5 w-5 stroke-1.5" />
</div>
<span>
{{
submissionResource.doc.assignment_attachment
.split('/')
.pop()
}}
{{ attachment.split('/').pop() }}
</span>
</div>
</a>
<X
v-if="canModifyAssignment"
@click="removeSubmission()"
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ms-4"
/>
</div>
</div>
@@ -138,6 +141,7 @@
@change="(val) => (answer = val)"
:editable="true"
:fixedMenu="true"
:readonly="!canModifyAssignment"
:uploadArgs="{
private: true,
}"
@@ -150,7 +154,7 @@
user.data?.name == submissionResource.doc?.owner &&
submissionResource.doc?.comments
"
class="mt-8 p-3 border rounded-lg"
class="mt-8 p-3 border rounded-lg bg-surface-gray-2"
>
<div class="text-ink-gray-5 mb-4">
{{ __('Comments by Evaluator') }}
@@ -213,8 +217,10 @@ import {
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { validateFile } from '@/utils'
const answer = ref(null)
const attachment = ref(null)
const comments = ref(null)
const router = useRouter()
const user = inject('$user')
@@ -264,118 +270,104 @@ const assignment = createResource({
},
})
const newSubmission = createResource({
url: 'frappe.client.insert',
makeParams(values) {
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentID,
member: user.data?.name,
}
if (!showUploader()) {
doc.answer = answer.value
}
return {
doc: doc,
}
},
})
const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission',
name: props.submissionName,
auto: false,
onError(err) {
toast.error(err.messages?.[0] || err)
},
auto: false,
cache: [user.data?.name, props.assignmentID],
})
watch(submissionResource, () => {
if (submissionResource.doc) {
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
if (submissionResource.doc.comments) {
comments.value = submissionResource.doc.comments
}
if (submissionResource.isDirty) {
isDirty.value = true
} else if (
showUploader() &&
!submissionResource.doc.assignment_attachment
) {
isDirty.value = true
} else if (!showUploader() && !answer.value) {
isDirty.value = true
} else {
isDirty.value = false
}
if (!submissionResource.doc) return
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
if (submissionResource.doc.assignment_attachment) {
attachment.value = submissionResource.doc.assignment_attachment
}
if (submissionResource.doc.comments) {
comments.value = submissionResource.doc.comments
}
})
watch(
() => submissionResource.doc,
() => {
if (
props.submissionName == 'new' &&
submissionResource.doc?.assignment_attachment
) {
isDirty.value = true
}
}
)
const submitAssignment = () => {
if (props.submissionName != 'new') {
let evaluator =
submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name
: null
submissionResource.setValue.submit(
{
...submissionResource.doc,
evaluator: evaluator,
comments: comments.value,
answer: answer.value,
},
{
onSuccess(data) {
isDirty.value = false
toast.success(__('Changes saved successfully'))
},
}
)
updateSubmission()
} else {
addNewSubmission()
}
}
const prepareSubmissionDoc = () => {
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentID,
member: user.data?.name,
}
if (!showUploader()) {
doc.answer = answer.value
} else {
doc.assignment_attachment = attachment.value
}
return doc
}
const addNewSubmission = () => {
newSubmission.submit(
{},
let doc = prepareSubmissionDoc()
if (!doc.assignment_attachment && !doc.answer) {
toast.error(
__('Please provide an answer or upload a file before submitting.')
)
return
}
call('frappe.client.insert', {
doc: doc,
})
.then((data) => {
toast.success(__('Assignment submitted successfully'))
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
query: { fromLesson: router.currentRoute.value.query.fromLesson },
})
markLessonProgress()
isDirty.value = false
submissionResource.name = data.name
submissionResource.reload()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const updateSubmission = () => {
let evaluator =
submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name
: null
submissionResource.setValue.submit(
{
...submissionResource.doc,
evaluator: evaluator,
comments: comments.value,
answer: answer.value,
assignment_attachment: attachment.value,
},
{
onSuccess(data) {
toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
query: { fromLesson: router.currentRoute.value.query.fromLesson },
})
} else {
markLessonProgress()
router.go()
}
submissionResource.name = data.name
submissionResource.reload()
isDirty.value = false
toast.success(__('Changes saved successfully'))
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
@@ -383,19 +375,21 @@ const addNewSubmission = () => {
const saveSubmission = (file) => {
isDirty.value = true
submissionResource.doc.assignment_attachment = file.file_url
attachment.value = file.file_url
}
const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') {
let courseName = router.currentRoute.value.params.courseName
let chapterNumber = router.currentRoute.value.params.chapterNumber
let lessonNumber = router.currentRoute.value.params.lessonNumber
let pathname = window.location.pathname.split('/')
if (!pathname.includes('courses'))
pathname = window.parent.location.pathname.split('/')
if (pathname[2] != 'courses') return
let lessonIndex = pathname.pop().split('-')
if (lessonIndex.length == 2) {
call('lms.lms.api.mark_lesson_progress', {
course: courseName,
chapter_number: chapterNumber,
lesson_number: lessonNumber,
course: pathname[3],
chapter_number: lessonIndex[0],
lesson_number: lessonIndex[1],
})
}
}
@@ -417,24 +411,9 @@ const getType = () => {
}
}
const validateFile = (file) => {
let type = assignment.data?.type
let extension = file.name.split('.').pop().toLowerCase()
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
} else if (
type == 'Document' &&
!['doc', 'docx', 'xml'].includes(extension)
) {
return 'Only document file is allowed.'
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
return 'Only PDF file is allowed.'
}
}
const removeSubmission = () => {
isDirty.value = true
submissionResource.doc.assignment_attachment = ''
attachment.value = null
}
const canGradeSubmission = computed(() => {
@@ -448,11 +427,15 @@ const canGradeSubmission = computed(() => {
})
const canModifyAssignment = computed(() => {
return (
!submissionResource.doc ||
(submissionResource.doc?.owner == user.data?.name &&
submissionResource.doc?.status == 'Not Graded')
)
if (props.submissionName == 'new') {
return true
} else if (
submissionResource.doc?.owner == user.data?.name &&
submissionResource.doc?.status == 'Not Graded'
) {
return true
}
return false
})
const submissionStatusOptions = computed(() => {
+1 -1
View File
@@ -6,7 +6,7 @@
<audio @ended="handleAudioEnd" controlsList="nodownload" class="mb-4">
<source :src="encodeURI(file)" type="audio/mp3" />
</audio>
<div class="flex items-center space-x-2 shadow rounded-lg p-1 w-1/2">
<div class="flex items-center gap-x-2 shadow rounded-lg p-1 w-1/2">
<Button variant="ghost" @click="togglePlay">
<template #icon>
<Play v-if="!isPlaying" class="w-4 h-4 text-ink-gray-9" />
@@ -1,26 +0,0 @@
<template>
<div class="space-y-10">
<UpcomingEvaluations
:batch="batch.data.name"
:endDate="batch.data.evaluation_end_date"
:courses="batch.data.courses"
/>
<Assessments :batch="batch.data.name" />
<!-- <StudentHeatmap /> -->
</div>
</template>
<script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/components/Assessments.vue'
const props = defineProps({
batch: {
type: Object,
default: null,
},
isStudent: {
type: Boolean,
default: false,
},
})
</script>
-227
View File
@@ -1,227 +0,0 @@
<template>
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<Badge
v-if="batch.data.seat_count && batch.data.seats_left > 0"
variant="subtle"
theme="green"
size="md"
:class="
batch.data.amount || batch.data.courses.length
? 'float-right'
: 'w-fit mb-4'
"
:label="
batch.data.seats_left +
' ' +
(batch.data.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
"
/>
<Badge
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
variant="subtle"
theme="red"
size="md"
class="float-right"
:label="__('Sold Out')"
/>
<div
v-if="batch.data.amount"
class="text-lg font-semibold mb-3 text-ink-gray-9"
>
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</div>
<div
v-if="batch.data.courses.length"
class="flex items-center mb-3 text-ink-gray-7"
>
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</div>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
class="mb-3"
/>
<div class="flex items-center mb-3 text-ink-gray-7">
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }}
</span>
</div>
<div v-if="batch.data.timezone" class="flex items-center text-ink-gray-7">
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ batch.data.timezone }}
</span>
</div>
<div v-if="!readOnlyMode">
<router-link
v-if="canAccessBatch"
:to="{
name: 'Batch',
params: {
batchName: batch.data.name,
},
}"
>
<Button variant="solid" class="w-full mt-4">
<template #prefix>
<LogIn v-if="isStudent" class="size-4 stroke-1.5" />
<Settings v-else class="size-4 stroke-1.5" />
</template>
<span>
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
</span>
</Button>
</router-link>
<router-link
:to="{
name: 'Billing',
params: {
type: 'batch',
name: batch.data.name,
},
}"
v-else-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Register Now') }}
</span>
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Enroll Now') }}
</Button>
<router-link
v-if="canEditBatch"
:to="{
name: 'BatchForm',
params: {
batchName: batch.data.name,
},
}"
>
<Button class="w-full mt-2">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div>
</div>
</template>
<script setup>
import { inject, computed } from 'vue'
import { Badge, Button, createResource, toast } from 'frappe-ui'
import {
BookOpen,
Clock,
CreditCard,
Globe,
GraduationCap,
LogIn,
Pencil,
Settings,
} from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const user = inject('$user')
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
type: Object,
default: null,
},
})
const enroll = createResource({
url: 'lms.lms.utils.enroll_in_batch',
makeParams(values) {
return {
batch: props.batch.data.name,
}
},
})
const enrollInBatch = () => {
if (!user.data) {
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
}
enroll.submit(
{},
{
onSuccess(data) {
toast.success(__('You have been enrolled in this batch'))
router.push({
name: 'Batch',
params: {
batchName: props.batch.data.name,
},
})
},
}
)
}
const isStudent = computed(() => {
return user.data
? props.batch.data?.students?.includes(user.data?.name)
: false
})
const isModerator = computed(() => {
return user.data?.is_moderator
})
const isEvaluator = computed(() => {
return user.data?.is_evaluator
})
const isInstructor = computed(() => {
return (
props.batch.data?.instructors?.filter(
(instructor) => instructor.name === user.data?.name
).length > 0
)
})
const canAccessBatch = computed(() => {
if (!user.data) {
return false
}
return isModerator.value || isStudent.value || isEvaluator.value
})
const canEditBatch = computed(() => {
return isModerator.value || isInstructor.value
})
</script>
-226
View File
@@ -1,226 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-ink-gray-9 font-medium">
{{ studentCount.data ?? 0 }} {{ __('Students') }}
</div>
<Button v-if="!readOnlyMode" @click="openStudentModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="students.data?.length">
<ListView
class="max-h-[75vh]"
:columns="studentColumns"
:rows="students.data"
row-key="name"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem
:item="item"
v-for="item in studentColumns"
:title="item.label"
>
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in students.data"
class="group cursor-pointer hover:bg-surface-gray-2 rounded"
@click="openStudentProgressModal(row)"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'full_name'">
<Avatar
class="flex items-center"
:image="row['user_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div
v-if="column.key == 'progress'"
class="flex items-center space-x-4 w-full"
>
<ProgressBar :progress="row[column.key]" size="sm" />
<div class="text-xs">{{ row[column.key] }}%</div>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeStudents(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
<div class="mt-4 flex justify-center" v-if="students.hasNextPage">
<Button @click="students.next()">
{{ __('Load More') }}
</Button>
</div>
</ListView>
</div>
<div v-else-if="!students.loading" class="text-sm italic text-ink-gray-5">
{{ __('There are no students in this batch.') }}
</div>
</div>
<StudentModal
:batch="props.batch.data.name"
v-model="showStudentModal"
v-model:reloadStudents="students"
v-model:batchModal="props.batch"
/>
<BatchStudentProgress
:student="selectedStudent"
v-model="showStudentProgressModal"
/>
</template>
<script setup>
import {
Avatar,
Button,
createListResource,
createResource,
FeatherIcon,
ListHeader,
ListHeaderItem,
ListSelectBanner,
ListRow,
ListRows,
ListView,
ListRowItem,
toast,
} from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next'
import { ref } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
const showStudentModal = ref(false)
const showStudentProgressModal = ref(false)
const selectedStudent = ref(null)
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
type: Object,
default: null,
},
})
const studentCount = createResource({
url: 'frappe.client.get_count',
cache: ['batch_student_count', props.batch?.data?.name],
params: {
doctype: 'LMS Batch Enrollment',
filters: { batch: props.batch?.data?.name },
},
auto: true,
})
const students = createListResource({
doctype: 'LMS Batch Enrollment',
url: 'lms.lms.utils.get_batch_students',
cache: ['batch_students', props.batch?.data?.name],
pageLength: 50,
filters: {
batch: props.batch?.data?.name,
},
auto: true,
})
const studentColumns = [
{
label: 'Full Name',
key: 'full_name',
width: '25rem',
icon: 'user',
},
{
label: 'Progress',
key: 'progress',
width: '15rem',
icon: 'activity',
},
{
label: 'Last Active',
key: 'last_active',
width: '10rem',
align: 'center',
icon: 'clock',
},
]
const openStudentModal = () => {
showStudentModal.value = true
}
const openStudentProgressModal = (row) => {
showStudentProgressModal.value = true
selectedStudent.value = row
}
const deleteStudents = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Batch Enrollment',
documents: values.students,
}
},
})
const removeStudents = (selections, unselectAll) => {
deleteStudents.submit(
{
students: Array.from(selections),
},
{
onSuccess(data) {
students.reload()
studentCount.reload()
props.batch.reload()
toast.success(__('Students deleted successfully'))
unselectAll()
},
}
)
}
</script>
@@ -2,13 +2,13 @@
<Dialog v-model="show" :options="{ size: '2xl' }">
<template #body>
<div class="text-base">
<div class="flex items-center space-x-2 pl-4.5 border-b">
<div class="flex items-center gap-x-2 ps-4.5 border-b">
<Search class="size-4 text-ink-gray-4" />
<input
ref="inputRef"
type="text"
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"
class="w-full border-none bg-transparent py-3 !ps-2 pe-4.5 text-base text-ink-gray-7 placeholder-ink-gray-4 focus:ring-0"
@input="onInput"
v-model="query"
autocomplete="off"
@@ -32,9 +32,9 @@
</div>
<div
class="flex items-center space-x-5 w-full border-t py-2 text-sm text-ink-gray-7 px-4.5"
class="flex items-center gap-x-5 w-full border-t py-2 text-sm text-ink-gray-7 px-4.5"
>
<div class="flex items-center space-x-2">
<div class="flex items-center gap-x-2">
<MoveUp
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
/>
@@ -45,7 +45,7 @@
{{ __('to navigate') }}
</span>
</div>
<div class="flex items-center space-x-2">
<div class="flex items-center gap-x-2">
<CornerDownLeft
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
/>
@@ -53,7 +53,7 @@
{{ __('to select') }}
</span>
</div>
<div class="flex items-center space-x-2">
<div class="flex items-center gap-x-2">
<span class="bg-surface-gray-2 p-1 rounded-sm"> esc </span>
<span>
{{ __('to close') }}
@@ -10,7 +10,7 @@
:class="{ 'bg-surface-gray-2': item.isActive }"
@click="emit('navigateTo', item.route)"
>
<div class="flex items-center space-x-3">
<div class="flex items-center gap-x-3">
<component
v-if="item.icon"
:is="item.icon"
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="flex items-center text-ink-gray-7">
<Calendar class="h-4 w-4 stroke-1.5 mr-2" />
<Calendar class="h-4 w-4 stroke-1.5 me-2" />
<span>
{{ getFormattedDateRange(props.startDate, props.endDate) }}
</span>
+1 -1
View File
@@ -28,7 +28,7 @@
</div>
</template>
<template #actions="{ close }">
<div class="pb-5 float-right">
<div class="pb-5 float-end">
<Button variant="solid" @click="sendMail(close)">
{{ __('Send') }}
</Button>
+220 -137
View File
@@ -1,95 +1,151 @@
<template>
<div>
<!-- Label -->
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
{{ __(label) }}
<span class="text-ink-red-3" v-if="attrs.required">*</span>
</div>
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
<div class="relative w-full">
<ComboboxInput
class="form-input w-full"
:class="inputClasses"
type="text"
:value="selectedValue"
autocomplete="off"
@click="onFocus"
/>
<ComboboxButton ref="trigger" class="hidden" />
<!-- Dropdown -->
<ComboboxOptions
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal py-1 text-base border-2 border-outline-gray-modals shadow-lg"
>
<input
ref="search"
v-model="query"
class="form-input w-[98%] rounded-tl-lg rounded-tr-lg mb-1 mx-1"
type="text"
placeholder="Search"
autocomplete="off"
/>
<!-- Options -->
<div class="my-1 max-h-[12rem] overflow-y-auto px-1.5">
<template v-for="group in groups" :key="group.key">
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
<Combobox
v-model="selectedValue"
nullable
v-slot="{ open: isComboboxOpen }"
>
<Popover
class="w-full"
v-model:show="showOptions"
:matchTargetWidth="true"
>
<template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full">
<button
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="
() => {
showOptions = !showOptions
togglePopover()
}
"
:disabled="attrs.readonly"
>
{{ group.group }}
</div>
<ComboboxOption
v-for="option in group.items"
:key="option.value"
:value="option.value"
v-slot="{ active }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base cursor-pointer',
{ 'bg-surface-gray-2': active },
]"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{
option.value === option.label
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</template>
<div class="flex items-center w-[90%]">
<slot name="prefix" />
<span
class="block truncate text-base leading-5"
v-if="selectedValue"
>
{{ displayValue(selectedValue) }}
</span>
<span class="text-base leading-5 text-ink-gray-4" v-else>
{{ placeholder || '' }}
</span>
</div>
<ChevronDown class="h-4 w-4 stroke-1.5" />
</button>
</div>
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen" class="">
<div
v-if="groups.length === 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
>
{{ __('No results found') }}
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="
(e) => {
query = e.target.value
}
"
:value="query"
autocomplete="off"
placeholder="Search"
/>
<button
class="absolute end-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
>
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
</button>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<div
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
>
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
>
{{ group.group }}
</div>
<ComboboxOption
as="template"
v-for="option in group.items"
:key="option.value"
:value="option"
v-slot="{ active, selected }"
>
<li
:class="[
'flex items-center rounded px-2.5 text-base py-1.5',
optionLines(option).secondary ? '' : 'h-7',
{ 'bg-surface-gray-2': active },
]"
>
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
>
<div
class="flex flex-col px-1"
:class="
optionLines(option).secondary ? 'gap-0.5' : ''
"
>
<div class="text-base font-medium text-ink-gray-8">
{{ optionLines(option).primary }}
</div>
<div
v-if="optionLines(option).secondary"
class="text-sm text-ink-gray-5"
>
{{ optionLines(option).secondary }}
</div>
</div>
</slot>
</li>
</ComboboxOption>
</div>
<li
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
>
{{ __('No results found') }}
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{ value: search?.el._value, close }"
></slot>
</div>
</div>
</div>
<!-- Footer -->
<div
v-if="slots.footer"
class="border-t border-outline-gray-modals p-1.5 pb-0.5"
>
<slot
name="footer"
v-bind="{
value: selectedValue,
close,
}"
/>
</div>
</ComboboxOptions>
</div>
</template>
</Popover>
</Combobox>
</div>
</template>
@@ -100,15 +156,15 @@ import {
ComboboxInput,
ComboboxOptions,
ComboboxOption,
ComboboxButton,
} from '@headlessui/vue'
import { Popover } from 'frappe-ui'
import { ChevronDown, X } from 'lucide-vue-next'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
import { watchDebounced } from '@vueuse/core'
const props = defineProps({
modelValue: {
type: [String, Object],
default: null,
type: String,
default: '',
},
options: {
type: Array,
@@ -139,97 +195,122 @@ const props = defineProps({
default: true,
},
})
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
const trigger = ref(null)
const query = ref('')
const showOptions = ref(false)
const search = ref(null)
const attrs = useAttrs()
const slots = useSlots()
const selectedValue = ref(props.modelValue)
const query = ref('')
const valuePropPassed = computed(() => 'value' in attrs)
watch(selectedValue, (val) => {
query.value = ''
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
const selectedValue = computed({
get() {
return valuePropPassed.value ? attrs.value : props.modelValue
},
set(val) {
query.value = ''
if (val) {
showOptions.value = false
}
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
},
})
function clearValue() {
emit('update:modelValue', null)
function close() {
showOptions.value = false
}
const groups = computed(() => {
if (!props.options?.length) return []
if (!props.options || props.options.length == 0) return []
const normalized = props.options[0]?.group
let groups = props.options[0]?.group
? props.options
: [{ group: '', items: props.options }]
return normalized
.map((group, i) => ({
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: props.filterable ? filterOptions(group.items) : group.items,
}))
return groups
.map((group, i) => {
return {
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: props.filterable ? filterOptions(group.items) : group.items,
}
})
.filter((group) => group.items.length > 0)
})
function filterOptions(options) {
if (!query.value) return options
const q = query.value.toLowerCase()
return options.filter((option) =>
[option.label, option.value]
.filter(Boolean)
.some((text) => text.toString().toLowerCase().includes(q))
)
}
watchDebounced(
query,
(val) => {
emit('update:query', val)
},
{ debounce: 300 }
)
const onFocus = () => {
trigger.value?.$el.click()
nextTick(() => {
search.value?.focus()
if (!query.value) {
return options
}
return options.filter((option) => {
let searchTexts = [option.label, option.value]
return searchTexts.some((text) =>
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
)
})
}
const close = () => {
selectedValue.value = null
trigger.value?.$el.click()
function optionLines(option) {
const primary = option.label
let secondary = null
if (option.description && option.description !== primary) {
secondary = option.description
} else if (option.value && option.value !== primary) {
secondary = option.value
}
return { primary, secondary }
}
const textColor = computed(() =>
props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
)
function displayValue(option) {
if (typeof option === 'string') {
let allOptions = groups.value.flatMap((group) => group.items)
let selectedOption = allOptions.find((o) => o.value === option)
return selectedOption?.label || option
}
return option?.label
}
watch(query, (q) => {
emit('update:query', q)
})
watch(showOptions, (val) => {
if (val) {
nextTick(() => {
search.value.el.focus()
})
}
})
const textColor = computed(() => {
return props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
})
const inputClasses = computed(() => {
const sizeClasses = {
let sizeClasses = {
sm: 'text-base rounded h-7',
md: 'text-base rounded h-8',
lg: 'text-lg rounded-md h-10',
xl: 'text-xl rounded-md h-10',
}[props.size]
const paddingClasses = {
let paddingClasses = {
sm: 'py-1.5 px-2',
md: 'py-1.5 px-2.5',
lg: 'py-1.5 px-3',
xl: 'py-1.5 px-3',
}[props.size]
const variant = props.disabled ? 'disabled' : props.variant
const variantClasses = {
let variant = props.disabled ? 'disabled' : props.variant
let variantClasses = {
subtle:
'border border-outline-gray-modals bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
'border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3',
outline:
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3',
disabled: [
'border bg-surface-menu-bar placeholder-ink-gray-3',
props.variant === 'outline'
@@ -246,4 +327,6 @@ const inputClasses = computed(() => {
'transition-colors w-full',
]
})
defineExpose({ query })
</script>
@@ -6,7 +6,7 @@
<div class="overflow-visible border border-outline-gray-modals rounded-md">
<div class="overflow-x-auto">
<div
class="grid items-center space-x-4 p-2 border-b border-outline-gray-modals"
class="grid items-center gap-x-4 p-2 border-b border-outline-gray-modals"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<div
@@ -21,7 +21,7 @@
<div
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="grid items-center space-x-4 p-2"
class="grid items-center gap-x-4 p-2"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<template v-for="key in Object.keys(row)" :key="key">
@@ -47,7 +47,7 @@
<div
v-if="menuOpenIndex === rowIndex"
ref="menuRef"
class="absolute right-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
class="absolute end-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
:class="
rowIndex == (rows?.length ?? 0) - 1
? 'bottom-full mb-1'
@@ -56,7 +56,7 @@
>
<button
@click="deleteRow(rowIndex)"
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
class="flex items-center gap-x-2 w-full text-start px-3 py-2 text-sm text-ink-red-3"
>
<Trash2 class="size-4 stroke-1.5" />
<span>
@@ -8,7 +8,7 @@
<template #target="{ togglePopover }">
<button
@click="openPopover(togglePopover)"
class="flex w-full items-center space-x-2 focus:outline-none bg-surface-gray-2 rounded h-7 py-1.5 px-2 hover:bg-surface-gray-3 focus:bg-surface-white border border-gray-100 hover:border-outline-gray-modals focus:border-outline-gray-4"
class="flex w-full items-center gap-x-2 focus:outline-none bg-surface-gray-2 rounded h-7 py-1.5 px-2 hover:bg-surface-gray-3 focus:bg-surface-white border border-gray-100 hover:border-outline-gray-modals focus:border-outline-gray-4"
>
<component
v-if="selectedIcon"
+76 -19
View File
@@ -30,28 +30,48 @@
</template>
<template #footer="{ value, close }">
<div v-if="attrs.onCreate">
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(value, close)"
<div v-if="creating" class="flex items-center gap-1">
<button
class="p-1 rounded hover:bg-surface-gray-3 text-ink-gray-5"
@click="creating = false"
:aria-label="__('Cancel')"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
<ArrowLeft class="size-4 stroke-1.5" />
</button>
<FormControl
v-model="newItemName"
class="flex-1 min-w-0"
size="sm"
:placeholder="__(props.inlineCreatePlaceholder)"
/>
<Button
variant="solid"
size="sm"
:disabled="!newItemName.trim()"
@click="submitCreate"
:aria-label="__('Create')"
>
{{ __('Create') }}
</Button>
</div>
<div>
<div v-else class="flex justify-between">
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Clear')"
@click="() => clearValue(close)"
:aria-label="__('Clear')"
>
{{ __('Clear') }}
</Button>
<Button
v-if="props.onCreate"
variant="ghost"
@click="handleCreate(close)"
:aria-label="__('Create New')"
>
<template #prefix>
<X class="h-4 w-4 stroke-1.5" />
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('Create New') }}
</Button>
</div>
</template>
@@ -63,8 +83,8 @@
<script setup>
import Autocomplete from '@/components/Controls/Autocomplete.vue'
import { watchDebounced } from '@vueuse/core'
import { createResource, Button } from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next'
import { createResource, Button, FormControl } from 'frappe-ui'
import { Plus, ArrowLeft } from 'lucide-vue-next'
import { useAttrs, computed, ref } from 'vue'
import { useSettings } from '@/stores/settings'
@@ -85,17 +105,32 @@ const props = defineProps({
type: String,
default: '',
},
inlineCreate: {
type: Boolean,
default: false,
},
inlineCreatePlaceholder: {
type: String,
default: 'Enter...',
},
onCreate: {
type: Function,
default: null,
},
})
const emit = defineEmits(['update:modelValue', 'change'])
const attrs = useAttrs()
const valuePropPassed = computed(() => 'value' in attrs)
const creating = ref(false)
const newItemName = ref('')
const value = computed({
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
set: (val) => {
return (
val && emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
val?.value &&
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val.value)
)
},
})
@@ -104,6 +139,26 @@ const autocomplete = ref(null)
const text = ref('')
const settingsStore = useSettings()
function handleCreate(close) {
if (props.inlineCreate) {
creating.value = true
return
}
if (props.onCreate) {
props.onCreate(null, close)
}
}
function submitCreate() {
if (!newItemName.value.trim() || !props.onCreate) return
const value = newItemName.value.trim()
props.onCreate(value, () => {
creating.value = false
newItemName.value = ''
reload()
})
}
watchDebounced(
() => autocomplete.value?.query,
(val) => {
@@ -139,7 +194,7 @@ const options = createResource({
params: {
txt: text.value,
doctype: props.doctype,
filters: props.filters,
filters: JSON.stringify(props.filters),
},
transform: (data) => {
return data.map((option) => {
@@ -152,12 +207,12 @@ const options = createResource({
},
})
const reload = (val) => {
const reload = (val = '') => {
options.update({
params: {
txt: val,
doctype: props.doctype,
filters: props.filters,
filters: JSON.stringify(props.filters),
},
})
options.reload()
@@ -177,4 +232,6 @@ const labelClasses = computed(() => {
'text-ink-gray-5',
]
})
defineExpose({ reload })
</script>
@@ -6,18 +6,34 @@
</label>
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
<div class="relative w-full">
<ComboboxInput
ref="search"
class="form-input w-full focus-visible:!ring-0"
type="text"
@change="
(e) => {
query = e.target.value
}
"
autocomplete="off"
@focus="onFocus"
/>
<div
class="flex flex-wrap items-center gap-1.5 w-full rounded-lg border border-[--surface-gray-2] bg-surface-gray-2 px-2 py-1.5 cursor-text transition-colors focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3"
@click="focusInput"
>
<button
v-for="value in values"
:key="value"
type="button"
class="inline-flex items-center gap-1 bg-surface-white border border-outline-gray-2 text-ink-gray-7 ps-2 pe-1.5 py-0.5 rounded text-base leading-5"
@click.stop="removeValue(value)"
>
<span>{{ value }}</span>
<X class="size-3.5 stroke-1.5 shrink-0" />
</button>
<ComboboxInput
ref="search"
class="flex-1 min-w-[4rem] border-none outline-none bg-transparent p-0 text-base focus:ring-0"
type="text"
:placeholder="!values?.length ? __('Search...') : ''"
@change="
(e) => {
query = e.target.value
}
"
autocomplete="off"
@focus="onFocus"
/>
</div>
<ComboboxButton ref="trigger" class="hidden" />
<ComboboxOptions
v-show="open"
@@ -26,7 +42,7 @@
>
<div
class="flex-1 my-1 overflow-y-auto px-1.5"
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
:class="options.length ? 'min-h-[6rem]' : 'min-h-[1rem]'"
>
<template v-if="options.length">
<ComboboxOption
@@ -80,21 +96,6 @@
</ComboboxOptions>
</div>
</Combobox>
<!-- Selected values -->
<div v-if="values?.length" class="grid grid-cols-2 gap-2 mt-1">
<div
v-for="value in values"
:key="value"
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
>
<span>{{ value }}</span>
<X
class="size-4 stroke-1.5 cursor-pointer"
@click="removeValue(value)"
/>
</div>
</div>
</div>
</template>
@@ -106,7 +107,7 @@ import {
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import { createResource, Button } from 'frappe-ui'
import { createResource, Button, toast } from 'frappe-ui'
import { ref, computed, useAttrs, watch } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { X, Plus } from 'lucide-vue-next'
@@ -115,7 +116,9 @@ const props = defineProps({
label: String,
size: { type: String, default: 'sm' },
doctype: { type: String, required: true },
filters: { type: Object, default: () => ({}) },
filters: { type: [Object, Array], default: () => ({}) },
url: { type: String, default: 'frappe.desk.search.search_link' },
searchParams: { type: Object, default: () => ({}) },
validate: Function,
errorMessage: {
type: Function,
@@ -124,22 +127,18 @@ const props = defineProps({
required: Boolean,
})
const values = defineModel()
const values = defineModel({ default: () => [] })
const attrs = useAttrs()
const trigger = ref(null)
const query = ref('')
const text = ref('')
const selectedValue = ref(null)
const error = ref(null)
const emit = defineEmits(['update:modelValue'])
watch(selectedValue, (val) => {
if (!val?.value) return
query.value = ''
addValue(val.value)
selectedValue.value = null
emit('update:modelValue', values.value)
})
watchDebounced(
@@ -153,14 +152,27 @@ watchDebounced(
{ debounce: 300, immediate: true }
)
const filterOptions = createResource({
url: 'frappe.desk.search.search_link',
method: 'POST',
auto: true,
params: {
txt: text.value,
doctype: props.doctype,
// Refetch when filters or searchParams change
watch(
() => [props.filters, props.searchParams],
() => {
reload(text.value)
},
{ deep: true }
)
function getParams(txt) {
return {
txt,
doctype: props.doctype,
filters: JSON.stringify(props.filters),
...props.searchParams,
}
}
const filterOptions = createResource({
url: props.url,
method: 'POST',
})
const options = computed(() => {
@@ -170,10 +182,7 @@ const options = computed(() => {
function reload(val) {
filterOptions.update({
params: {
txt: val,
doctype: props.doctype,
},
params: getParams(val),
})
filterOptions.reload()
}
@@ -186,34 +195,30 @@ function onFocus() {
}
function addValue(value) {
error.value = null
if (!value) return
const splitValues = value.split(',')
let newValues = [...(values.value || [])]
splitValues.forEach((val) => {
val = val.trim()
if (!val) return
if (values.value?.includes(val)) return
if (newValues.includes(val)) return
if (props.validate && !props.validate(val)) {
error.value = props.errorMessage(val)
toast.error(props.errorMessage(val))
return
}
if (!values.value) values.value = [val]
else values.value.push(val)
newValues.push(val)
})
values.value = newValues
}
function removeValue(value) {
let indexToRemove = values.value.indexOf(value)
if (indexToRemove > -1) {
values.value.splice(indexToRemove, 1)
}
emit('update:modelValue', values.value)
values.value = values.value.filter((v) => v !== value)
}
const labelClasses = computed(() => [
+1 -1
View File
@@ -10,7 +10,7 @@
@mouseleave="hoveredRating = 0"
>
<Star
class="fill-gray-400 text-gray-50 stroke-1 mr-1 cursor-pointer"
class="fill-gray-400 text-gray-50 stroke-1 me-1 cursor-pointer"
:class="iconClasses(index)"
@click="markRating(index)"
/>
+17 -6
View File
@@ -9,6 +9,7 @@
:fileTypes="[fileType]"
:validateFile="(file: File) => validateFile(file, true, type)"
@success="(file: File) => saveFile(file)"
@failure="onUploadFailure"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center">
@@ -18,9 +19,9 @@
class="size-5 stroke-1 text-ink-gray-7"
/>
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
<div class="ms-4">
<Button @click="openFileSelector" :loading="uploading">
{{ uploading ? `${__('Uploading')} ${progress}%` : __('Upload') }}
</Button>
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
{{ __(description) }}
@@ -38,14 +39,14 @@
'border object-cover',
shape === 'circle'
? 'w-20 h-20 rounded-full'
: 'w-44 h-auto min-h-20 rounded-md',
: 'w-44 h-auto min-h-20 max-h-32 rounded-md',
]"
/>
<video v-else controls class="border rounded-md w-44 h-auto">
<source :src="modelValue" />
{{ __('Your browser does not support the video tag.') }}
</video>
<div class="ml-4">
<div class="ms-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
@@ -62,7 +63,7 @@
</template>
<script setup lang="ts">
import { validateFile } from '@/utils'
import { Button, FileUploader } from 'frappe-ui'
import { Button, FileUploader, toast } from 'frappe-ui'
import { Image, Video } from 'lucide-vue-next'
import { computed } from 'vue'
@@ -100,4 +101,14 @@ const saveFile = (file: any) => {
const removeImage = () => {
emit('update:modelValue', '')
}
const onUploadFailure = (error: any) => {
let message = __('Error Uploading File')
if (error?._server_messages) {
message = JSON.parse(JSON.parse(error._server_messages)[0]).message
} else if (error?.exc) {
message = JSON.parse(error.exc)[0].split('\n').slice(-2, -1)[0]
}
toast.error(message)
}
</script>
+16 -14
View File
@@ -1,7 +1,7 @@
<template>
<div
v-if="course.title"
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9"
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9 bg-surface-cards"
style="min-height: 350px"
>
<div
@@ -10,7 +10,7 @@
course.image
? { backgroundImage: `url('${encodeURI(course.image)}')` }
: {
backgroundImage: getGradientColor(),
backgroundImage: gradientColor,
backgroundBlendMode: 'screen',
}
"
@@ -18,7 +18,7 @@
<!-- <div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
<div
v-if="course.featured"
class="flex items-center space-x-1 text-xs text-ink-amber-3 bg-surface-white border border-outline-amber-1 px-2 py-0.5 rounded-md mr-1 mb-1"
class="flex items-center gap-x-1 text-xs text-ink-amber-3 bg-surface-white border border-outline-amber-1 px-2 py-0.5 rounded-md me-1 mb-1"
>
<Star class="size-3 stroke-2" />
<span>
@@ -28,7 +28,7 @@
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="text-xs border bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 mr-1"
class="text-xs border bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 me-1"
>
{{ tag }}
</div>
@@ -52,7 +52,7 @@
<div v-if="course.lessons">
<Tooltip :text="__('Lessons')">
<span class="flex items-center">
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
<BookOpen class="h-4 w-4 stroke-1.5 me-1" />
{{ course.lessons }}
</span>
</Tooltip>
@@ -61,7 +61,7 @@
<div v-if="course.enrollments">
<Tooltip :text="__('Enrolled Students')">
<span class="flex items-center">
<Users class="h-4 w-4 stroke-1.5 mr-1" />
<Users class="h-4 w-4 stroke-1.5 me-1" />
{{ formatAmount(course.enrollments) }}
</span>
</Tooltip>
@@ -70,7 +70,7 @@
<div v-if="course.rating">
<Tooltip :text="__('Average Rating')">
<span class="flex items-center">
<Star class="h-4 w-4 stroke-1.5 mr-1" />
<Star class="h-4 w-4 stroke-1.5 me-1" />
{{ course.rating }}
</span>
</Tooltip>
@@ -105,7 +105,7 @@
<div class="flex items-center justify-between mt-auto">
<div class="flex avatar-group overlap">
<div
class="h-6 mr-1"
class="h-6 me-1"
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
>
<UserAvatar
@@ -116,7 +116,7 @@
<CourseInstructors :instructors="course.instructors" />
</div>
<div class="flex items-center space-x-2">
<div class="flex items-center gap-x-2">
<div v-if="course.paid_course" class="font-semibold">
{{ course.price }}
</div>
@@ -137,6 +137,8 @@ import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { Tooltip } from 'frappe-ui'
import { formatAmount } from '@/utils'
import { theme } from '@/utils/theme'
import { computed, watch } from 'vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import ProgressBar from '@/components/ProgressBar.vue'
@@ -151,12 +153,12 @@ const props = defineProps({
},
})
const getGradientColor = () => {
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
const gradientColor = computed(() => {
let themeMode = theme.value === 'dark' ? 'darkMode' : 'lightMode'
let color = props.course.card_gradient?.toLowerCase() || 'blue'
let colorMap = colors[theme][color]
let colorMap = colors[themeMode][color]
return `linear-gradient(to top right, black, ${colorMap[400]})`
}
})
</script>
<style>
.course-card-pills {
@@ -182,7 +184,7 @@ const getGradientColor = () => {
}
.avatar-group.overlap .avatar + .avatar {
margin-left: calc(-8px);
margin-inline-start: calc(-8px);
}
.short-introduction {
+18 -16
View File
@@ -10,7 +10,7 @@
{{ course.data.price }}
</div>
<div v-if="!readOnlyMode">
<div v-if="course.data.membership" class="space-y-2">
<div v-if="course.data.membership" class="space-y-2 mb-8">
<router-link
:to="{
name: 'Lesson',
@@ -46,7 +46,7 @@
},
}"
>
<Button variant="solid" size="md" class="w-full">
<Button variant="solid" size="md" class="w-full mb-8">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
@@ -67,7 +67,7 @@
v-else-if="!isAdmin"
@click="enrollStudent()"
variant="solid"
class="w-full"
class="w-full mb-8"
size="md"
>
<template #prefix>
@@ -90,24 +90,26 @@
{{ __('Get Certificate') }}
</Button>
</div>
<div class="space-y-4">
<div
class="font-medium text-ink-gray-9"
:class="{ 'mt-8': !readOnlyMode }"
>
<div class="space-y-3">
<div class="font-medium text-ink-gray-9">
{{ __('This course has:') }}
</div>
<div class="flex items-center text-ink-gray-9">
<BookOpen class="h-4 w-4 stroke-1.5" />
<span class="ml-2">
{{ course.data.lessons }} {{ __('Lessons') }}
<span class="ms-2">
{{ course.data.lessons }}
{{ course.data.lessons > 1 ? __('lessons') : __('lesson') }}
</span>
</div>
<div class="flex items-center text-ink-gray-9">
<Users class="h-4 w-4 stroke-1.5" />
<span class="ml-2">
<span class="ms-2">
{{ formatAmount(course.data.enrollments) }}
{{ __('Enrolled Students') }}
{{
course.data.enrollments > 1
? __('enrolled students')
: __('enrolled student')
}}
</span>
</div>
<div
@@ -115,8 +117,8 @@
class="flex items-center text-ink-gray-9"
>
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
<span class="ml-2">
{{ course.data.rating }} {{ __('Rating') }}
<span class="ms-2">
{{ course.data.rating }} {{ __('average rating') }}
</span>
</div>
<div
@@ -124,7 +126,7 @@
class="flex items-center font-semibold text-ink-gray-9"
>
<GraduationCap class="h-4 w-4 stroke-2" />
<span class="ml-2">
<span class="ms-2">
{{ __('Certificate of Completion') }}
</span>
</div>
@@ -133,7 +135,7 @@
class="flex items-center font-semibold text-ink-gray-9"
>
<GraduationCap class="h-4 w-4 stroke-2" />
<span class="ml-2">
<span class="ms-2">
{{ __('Paid Certificate after Evaluation') }}
</span>
</div>
+25 -16
View File
@@ -2,7 +2,7 @@
<div class="">
<div
v-if="title && (outline.data?.length || allowEdit)"
class="flex items-center justify-between space-x-2 mb-4 px-2"
class="flex items-center justify-between gap-x-2 mb-4 px-2"
:class="{
'sticky top-0 z-10 bg-surface-white border-b px-3 py-2.5 sm:px-5':
allowEdit,
@@ -46,20 +46,20 @@
>
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
'duration-200': !open,
'rotate-90': open,
'rtl:rotate-180': !open,
hidden: chapter.is_scorm_package,
open: index == 1,
}"
class="h-4 w-4 text-ink-gray-9 stroke-1"
class="h-4 w-4 text-ink-gray-9 stroke-1 transform duration-200"
/>
<div
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
class="text-base text-start text-ink-gray-9 font-medium leading-5 ms-2"
@click="redirectToChapter(chapter)"
>
{{ chapter.title }}
</div>
<div class="flex ml-auto space-x-4">
<div class="flex ms-auto gap-x-4">
<Tooltip :text="__('Edit Chapter')" placement="bottom">
<FilePenLine
v-if="allowEdit"
@@ -75,6 +75,12 @@
/>
</Tooltip>
</div>
<Check
v-if="
chapter.is_scorm_package && isScormChapterComplete(chapter)
"
class="h-4 w-4 text-green-700"
/>
</DisclosureButton>
<DisclosurePanel v-if="!chapter.is_scorm_package">
<Draggable
@@ -88,7 +94,7 @@
>
<template #item="{ element: lesson }">
<div
class="outline-lesson pl-8 py-2 pr-4 text-ink-gray-9"
class="outline-lesson ps-8 py-2 pe-4 text-ink-gray-9"
:class="
isActiveLesson(lesson.number) ? 'bg-surface-gray-3' : ''
"
@@ -106,23 +112,23 @@
<div class="flex items-center text-sm leading-5 group">
<MonitorPlay
v-if="lesson.icon === 'icon-youtube'"
class="h-4 w-4 stroke-1 mr-2"
class="h-4 w-4 stroke-1 me-2"
/>
<HelpCircle
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 stroke-1 mr-2"
class="h-4 w-4 stroke-1 me-2"
/>
<NotebookPen
v-else-if="lesson.icon === 'icon-assignment'"
class="h-4 w-4 stroke-1 mr-2"
class="h-4 w-4 stroke-1 me-2"
/>
<SquareCode
v-else-if="lesson.icon === 'icon-code'"
class="h-4 w-4 stroke-1 mr-2"
class="h-4 w-4 stroke-1 me-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
class="h-4 w-4 text-ink-gray-9 stroke-1 me-2"
/>
{{ lesson.title }}
<Trash2
@@ -130,18 +136,18 @@
@click.prevent="
trashLesson(lesson.name, chapter.name)
"
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
class="h-4 w-4 text-ink-red-3 ms-auto invisible group-hover:visible"
/>
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-700 ml-2"
class="h-4 w-4 text-green-700 ms-2"
/>
</div>
</router-link>
</div>
</template>
</Draggable>
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<div v-if="allowEdit" class="flex mt-2 mb-4 ps-8">
<router-link
v-if="!chapter.is_scorm_package"
:to="{
@@ -189,7 +195,6 @@ import {
Plus,
SquareCode,
Trash2,
Notebook,
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue'
@@ -402,6 +407,10 @@ const redirectToChapter = (chapter) => {
})
}
const isScormChapterComplete = (chapter) => {
return chapter.lessons?.length && chapter.lessons.every((l) => l.is_complete)
}
const isActiveLesson = (lessonNumber) => {
return (
route.params.chapterNumber == lessonNumber.split('-')[0] &&
+7 -7
View File
@@ -3,7 +3,7 @@
<Button
v-if="membership && !hasReviewed.data"
@click="openReviewModal()"
class="float-right"
class="float-end"
>
{{ __('Write a Review') }}
</Button>
@@ -12,7 +12,7 @@
</div>
<div class="grid gap-8 mt-10">
<div v-for="(review, index) in reviews.data">
<div class="flex items-center">
<div class="flex">
<router-link
:to="{
name: 'Profile',
@@ -28,14 +28,14 @@
params: { username: review.owner_details.username },
}"
>
<span class="text-lg font-medium mr-4 text-ink-gray-7">
<span class="text-lg font-medium me-4 text-ink-gray-7">
{{ review.owner_details.full_name }}
</span>
</router-link>
<span class="text-ink-gray-7">
{{ review.creation }}
</span>
<div class="flex mt-2 space-x-1">
<div class="flex mt-2 gap-x-1">
<Star
v-for="index in 5"
class="size-4 text-transparent rounded-sm"
@@ -46,11 +46,11 @@
"
/>
</div>
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
{{ review.review }}
</div>
</div>
</div>
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
{{ review.review }}
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="flex h-screen w-screen">
<div class="h-full border-r bg-surface-menu-bar">
<div class="h-full border-e bg-surface-menu-bar">
<AppSidebar />
</div>
<div class="flex-1 flex flex-col h-full overflow-auto bg-surface-white">
@@ -6,7 +6,7 @@
<ChevronLeft class="w-5 h-5 stroke-1.5 text-ink-gray-7" />
</template>
</Button>
<span class="text-lg font-semibold ml-2 text-ink-gray-9">
<span class="text-lg font-semibold ms-2 text-ink-gray-9">
{{ topic.title }}
</span>
</div>
@@ -18,11 +18,11 @@
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center text-ink-gray-5">
<UserAvatar :user="reply.user" class="mr-2" />
<UserAvatar :user="reply.user" class="me-2" />
<span>
{{ reply.user.full_name }}
</span>
<span class="text-sm ml-2">
<span class="text-sm ms-2">
{{ timeAgo(reply.creation) }}
</span>
</div>
+4 -4
View File
@@ -2,7 +2,7 @@
<div>
<Button
v-if="!singleThread && !readOnlyMode"
class="float-right"
class="float-end"
@click="openTopicModal()"
>
<template #prefix>
@@ -21,7 +21,7 @@
class="flex items-center cursor-pointer py-5 w-full"
:class="{ 'border-b': index + 1 != topics.data.length }"
>
<UserAvatar :user="topic.user" size="2xl" class="mr-4" />
<UserAvatar :user="topic.user" size="2xl" class="me-4" />
<div>
<div class="text-lg font-semibold mb-1 text-ink-gray-7">
{{ topic.title }}
@@ -30,7 +30,7 @@
<span>
{{ topic.user.full_name }}
</span>
<span class="text-sm ml-3">
<span class="text-sm ms-3">
{{ timeAgo(topic.creation) }}
</span>
</div>
@@ -51,7 +51,7 @@
v-else
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
>
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 mr-2" />
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 me-2" />
<div class="mt-2">
<div v-if="emptyStateTitle" class="font-medium mb-2">
{{ __(emptyStateTitle) }}
+1 -3
View File
@@ -4,9 +4,7 @@
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
{{ __('No {0}').format(type?.toLowerCase()) }}
</div>
<div
class="leading-5 text-base w-full md:w-2/5 text-base text-center text-ink-gray-7"
>
<div class="text-p-base w-full md:w-2/5 text-center text-ink-gray-7">
{{
__(
'There are no {0} currently. Keep an eye out, fresh learning experiences are on the way!'
+1 -1
View File
@@ -34,7 +34,7 @@
<span class="inline-flex items-baseline">
<FeatherIcon
name="x"
class="ml-auto h-4 w-4 text-gray-700"
class="ms-auto h-4 w-4 text-gray-700"
@click="iosInstallMessage = false"
/>
</span>
+4 -4
View File
@@ -2,7 +2,7 @@
<div
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3"
>
<div class="flex space-x-4 mb-4">
<div class="flex gap-x-4 mb-4">
<div class="flex flex-col space-y-2 flex-1 break-all">
<div class="text-lg font-semibold text-ink-gray-9">
{{ job.company_name }}
@@ -10,7 +10,7 @@
<span class="font-medium text-ink-gray-7 leading-5">
{{ job.job_title }}
</span>
<div class="flex items-center space-x-1 text-sm text-ink-gray-7">
<div class="flex items-center gap-x-1 text-sm text-ink-gray-7">
<MapPin class="size-3" />
<span>
{{ job.location }}{{ job.country ? `, ${job.country}` : '' }}
@@ -18,7 +18,7 @@
</div>
<div
v-if="job.applicants"
class="flex items-center space-x-1 text-sm text-ink-gray-7"
class="flex items-center gap-x-1 text-sm text-ink-gray-7"
>
<User class="size-3" />
<span>
@@ -29,7 +29,7 @@
</div>
<!-- <img :src="job.company_logo" alt="Company Logo" class="size-8 rounded-full object-contain bg-white" /> -->
</div>
<div class="space-x-2 mt-auto">
<div class="flex gap-x-2 items-center mt-auto">
<Badge>
{{ job.type }}
</Badge>
+4 -1
View File
@@ -57,7 +57,7 @@
>
</iframe>
</div>
<div v-else v-html="markdown.render(block)"></div>
<div v-else v-html="renderSafe(block)"></div>
</div>
<div v-if="quizId">
<Quiz :quiz="quizId" />
@@ -66,6 +66,7 @@
<script setup>
import Quiz from '@/components/QuizBlock.vue'
import MarkdownIt from 'markdown-it'
import DOMPurify from 'dompurify'
import { useScreenSize } from '@/utils/composables'
const screenSize = useScreenSize()
@@ -75,6 +76,8 @@ const markdown = new MarkdownIt({
linkify: true,
})
const renderSafe = (block) => DOMPurify.sanitize(markdown.render(block))
const props = defineProps({
content: {
type: String,
+4 -4
View File
@@ -1,15 +1,15 @@
<template>
<div class="space-y-5 text-ink-gray-9">
<div class="space-y-2">
<div class="flex items-center text-sm font-medium space-x-2">
<div class="flex items-center text-sm font-medium gap-x-2">
<span>
{{ __('What does include in preview mean?') }}
{{ __('What are Instructor Notes?') }}
</span>
</div>
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
{{
__(
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
'Instructor Notes are private notes that only instructors can see. They can be used to provide additional context or guidance for the lesson.'
)
}}
</div>
@@ -17,7 +17,7 @@
<div class="space-y-2" v-for="(item, key) in contentMap" :key="key">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
class="flex items-center text-sm font-medium gap-x-2 cursor-pointer"
@click="openHelpDialog(key)"
>
<span>
-221
View File
@@ -1,221 +0,0 @@
<template>
<div
v-if="hasPermission() && !props.zoomAccount"
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3 text-xs"
>
<AlertCircle class="size-4 stroke-1.5" />
<span>
{{ __('Please add a zoom account to the batch to create live classes.') }}
</span>
</div>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Live Class') }}
</div>
<Button v-if="canCreateClass()" @click="openLiveClassModal">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
<span>
{{ __('Add') }}
</span>
</Button>
</div>
<div
v-if="liveClasses.data?.length"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
>
<div
v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
:class="{
'cursor-pointer': hasPermission() && cls.attendees > 0,
}"
@click="
() => {
openAttendanceModal(cls)
}
"
>
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
{{ cls.title }}
</div>
<div class="short-introduction">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
<div v-else class="text-sm italic text-ink-gray-5 mt-2">
{{ __('No live classes scheduled') }}
</div>
<LiveClassModal
:batch="props.batch"
:zoomAccount="props.zoomAccount"
v-model="showLiveClassModal"
v-model:reloadLiveClasses="liveClasses"
/>
<LiveClassAttendance
v-if="showAttendance"
v-model="showAttendance"
:live_class="attendanceFor"
/>
</template>
<script setup>
import { createListResource, Button, Tooltip } from 'frappe-ui'
import {
Plus,
Clock,
Calendar,
Video,
Monitor,
Info,
AlertCircle,
} from 'lucide-vue-next'
import { inject, ref } from 'vue'
import { formatTime } from '@/utils/'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
const user = inject('$user')
const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode
const showAttendance = ref(false)
const attendanceFor = ref(null)
const props = defineProps({
batch: {
type: String,
required: true,
},
zoomAccount: String,
})
const liveClasses = createListResource({
doctype: 'LMS Live Class',
filters: {
batch_name: props.batch,
},
fields: [
'title',
'description',
'time',
'date',
'duration',
'attendees',
'start_url',
'join_url',
'owner',
],
orderBy: 'date',
auto: true,
})
const openLiveClassModal = () => {
showLiveClassModal.value = true
}
const canCreateClass = () => {
if (readOnlyMode) return false
if (!props.zoomAccount) return false
return hasPermission()
}
const hasPermission = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
const canAccessClass = (cls) => {
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
if (hasClassEnded(cls)) return false
return true
}
const getClassStart = (cls) => {
return new Date(`${cls.date}T${cls.time}`)
}
const getClassEnd = (cls) => {
const classStart = getClassStart(cls)
return new Date(classStart.getTime() + cls.duration * 60000)
}
const hasClassEnded = (cls) => {
const classEnd = getClassEnd(cls)
const now = new Date()
return now > classEnd
}
const openAttendanceModal = (cls) => {
if (!hasPermission()) return
if (cls.attendees <= 0) return
showAttendance.value = true
attendanceFor.value = cls
}
</script>
<style>
.short-introduction {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin: 0.25rem 0 1.5rem;
line-height: 1.5;
}
</style>
+54 -61
View File
@@ -7,14 +7,14 @@
<div class="relative z-20">
<!-- Dropdown menu -->
<div
class="fixed bottom-16 right-2 w-[80%] rounded-md bg-surface-white text-base p-5 space-y-4 shadow-md"
class="fixed bottom-16 end-2 w-[80%] rounded-md bg-surface-white text-base p-5 space-y-4 shadow-md"
v-if="showMenu"
ref="menu"
>
<div
v-for="link in otherLinks"
:key="link.label"
class="flex items-center space-x-2 cursor-pointer"
class="flex items-center gap-x-2 cursor-pointer"
@click="handleClick(link)"
>
<component
@@ -28,7 +28,7 @@
<!-- Fixed menu -->
<div
v-if="sidebarSettings.data"
class="fixed bottom-0 left-0 w-full flex items-center justify-around border-t border-outline-gray-2 bg-surface-white standalone:pb-4 z-10"
class="fixed bottom-0 start-0 w-full flex items-center justify-around border-t border-outline-gray-2 bg-surface-white standalone:pb-4 z-10"
>
<button
v-for="tab in sidebarLinks"
@@ -57,7 +57,7 @@
import { getSidebarLinks } from '@/utils'
import { useRouter } from 'vue-router'
import { call } from 'frappe-ui'
import { watch, ref, onMounted } from 'vue'
import { ref, watch } from 'vue'
import { sessionStore } from '@/stores/session'
import { useSettings } from '@/stores/settings'
import { usersStore } from '@/stores/user'
@@ -68,26 +68,13 @@ let { isLoggedIn } = sessionStore()
const { sidebarSettings } = useSettings()
const router = useRouter()
let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks())
const sidebarLinks = ref([])
const otherLinks = ref([])
const showMenu = ref(false)
const menu = ref(null)
const isModerator = ref(false)
const isInstructor = ref(false)
onMounted(() => {
sidebarSettings.reload(
{},
{
onSuccess(data) {
destructureSidebarLinks()
filterLinksToShow(data)
addOtherLinks()
},
}
)
})
const handleOutsideClick = (e) => {
if (menu.value && !menu.value.contains(e.target)) {
showMenu.value = false
@@ -126,65 +113,57 @@ const filterLinksToShow = (data) => {
const addOtherLinks = () => {
if (user) {
otherLinks.value.push({
label: 'Notifications',
icon: 'Bell',
to: 'Notifications',
})
otherLinks.value.push({
label: 'Profile',
icon: 'UserRound',
})
otherLinks.value.push({
label: 'Log out',
icon: 'LogOut',
})
addLink('Notifications', 'Bell', 'Notifications')
addLink('Profile', 'UserRound')
addLink('Log out', 'LogOut')
} else {
otherLinks.value.push({
label: 'Log in',
icon: 'LogIn',
})
addLink('Log in', 'LogIn')
}
}
watch(userResource, () => {
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addPrograms()
if (isModerator.value || isInstructor.value) {
addProgrammingExercises()
addQuizzes()
addAssignments()
const addLink = (label, icon, to = '') => {
if (otherLinks.value.some((link) => link.label === label)) return
otherLinks.value.push({
label: label,
icon: icon,
to: to,
})
}
const updateSidebarLinks = () => {
sidebarLinks.value = getSidebarLinks(true)
destructureSidebarLinks()
sidebarSettings.reload(
{},
{
onSuccess: async (data) => {
filterLinksToShow(data)
await addPrograms()
if (isModerator.value || isInstructor.value) {
addQuizzes()
addAssignments()
addProgrammingExercises()
}
addOtherLinks()
},
}
}
})
)
}
const addQuizzes = () => {
otherLinks.value.push({
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
})
addLink('Quizzes', 'CircleHelp', 'Quizzes')
}
const addAssignments = () => {
otherLinks.value.push({
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
})
addLink('Assignments', 'Pencil', 'Assignments')
}
const addProgrammingExercises = () => {
otherLinks.value.push({
label: 'Programming Exercises',
icon: 'Code',
to: 'ProgrammingExercises',
})
addLink('Programming Exercises', 'Code', 'ProgrammingExercises')
}
const addPrograms = async () => {
if (sidebarLinks.value.some((link) => link.label === 'Programs')) return
let canAddProgram = await checkIfCanAddProgram()
if (!canAddProgram) return
let activeFor = ['Programs', 'ProgramDetail']
@@ -198,7 +177,21 @@ const addPrograms = async () => {
})
}
watch(
userResource,
async () => {
await userResource.promise
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
}
updateSidebarLinks()
},
{ immediate: true }
)
const checkIfCanAddProgram = async () => {
if (!userResource.data) return false
if (isModerator.value || isInstructor.value) {
return true
}
@@ -0,0 +1,65 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add Existing User as Evaluator'),
size: 'md',
actions: [
{
label: __('Add'),
variant: 'solid',
loading: submitting,
onClick: ({ close }: any) => addEvaluator(close),
},
],
}"
>
<template #body-content>
<Link doctype="User" v-model="selectedUser" :label="__('Select User')" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, toast } from 'frappe-ui'
import { ref, watch } from 'vue'
import { cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const show = defineModel<boolean>({ default: false })
const selectedUser = ref('')
const submitting = ref(false)
const emit = defineEmits<{
added: []
}>()
watch(show, (isOpen) => {
if (isOpen) {
selectedUser.value = ''
}
})
const addEvaluator = async (close?: () => void) => {
if (!selectedUser.value?.trim()) {
toast.error(__('Please select a user'))
return
}
submitting.value = true
try {
await call('lms.lms.api.save_role', {
user: selectedUser.value,
role: 'Batch Evaluator',
value: 1,
})
toast.success(__('Evaluator added successfully'))
emit('added')
close?.()
} catch (err: any) {
toast.error(cleanError(err.messages?.[0]) || __('Unable to add evaluator'))
} finally {
submitting.value = false
}
}
</script>
@@ -20,11 +20,15 @@
:options="assessmentTypes"
v-model="assessmentType"
:label="__('Type')"
placeholder=" "
@update:modelValue="() => (assessment = null)"
/>
<Link
v-if="assessmentType"
v-model="assessment"
:doctype="assessmentType"
:label="__('Assessment')"
placeholder=" "
:onCreate="
(value, close) => {
close()
@@ -49,9 +53,9 @@
</template>
<script setup>
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const assessmentType = ref(null)
@@ -14,7 +14,7 @@
: __('Edit Assignment')
}}
</div>
<div class="space-y-4 max-h-[75vh] overflow-y-auto">
<div class="space-y-4 max-h-[75vh] overflow-y-auto p-1">
<FormControl
v-model="assignment.title"
:label="__('Title')"
@@ -43,12 +43,12 @@
@change="(val) => (assignment.question = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem] max-h-[18rem] overflow-y-auto"
/>
</div>
</div>
<div class="flex justify-end space-x-2 mt-5">
<div class="flex justify-end gap-x-2 mt-5">
<router-link
:to="{
name: 'AssignmentSubmissionList',
@@ -72,8 +72,8 @@
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { computed, reactive, watch } from 'vue'
import { escapeHTML, sanitizeHTML } from '@/utils'
import { Link } from 'frappe-ui/frappe'
import { sanitizeHTML } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const assignments = defineModel<Assignments>('assignments')
@@ -133,7 +133,7 @@ watch(show, (newVal) => {
})
const validateFields = () => {
assignment.title = escapeHTML(assignment.title.trim())
assignment.title = sanitizeHTML(assignment.title.trim())
assignment.question = sanitizeHTML(assignment.question)
}
@@ -2,8 +2,8 @@
<Dialog
v-model="show"
:options="{
title: __('Add Course'),
size: 'sm',
title: __('Add a course to the batch'),
size: 'lg',
actions: [
{
label: __('Submit'),
@@ -41,7 +41,7 @@
</Dialog>
</template>
<script setup>
import { Dialog, createResource, toast } from 'frappe-ui'
import { Dialog, toast } from 'frappe-ui'
import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { useOnboarding } from 'frappe-ui/frappe'
@@ -63,37 +63,28 @@ const props = defineProps({
},
})
const createBatchCourse = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Batch Course',
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'courses',
course: course.value,
evaluator: evaluator.value,
},
}
},
})
const addCourse = (close) => {
createBatchCourse.submit(
{},
courses.value.insert.submit(
{
course: course.value,
evaluator: evaluator.value,
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'courses',
},
{
onSuccess() {
if (user.data?.is_system_manager)
updateOnboardingStep('add_batch_course')
close()
courses.value.reload()
course.value = null
evaluator.value = null
toast.success(__('Course added to batch successfully'))
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.log(err)
},
}
)
@@ -1,146 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-10 text-base">
<div class="flex items-center space-x-2">
<Avatar :image="student.user_image" size="3xl" />
<div class="space-y-1">
<div class="flex items-center space-x-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ student.full_name }}
</div>
<Badge
v-if="
Object.keys(student.assessments).length ||
Object.keys(student.courses).length
"
:theme="student.progress === 100 ? 'green' : 'red'"
>
{{ student.progress }}% {{ __('Complete') }}
</Badge>
</div>
<div class="text-sm text-ink-gray-7">
{{ student.email }}
</div>
</div>
</div>
<div class="space-y-8">
<!-- Assessments -->
<div
v-if="Object.keys(student.assessments).length"
class="space-y-2 text-sm"
>
<div
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
>
<span class="flex-1">
{{ __('Assessment') }}
</span>
<span>
{{ __('Percentage/Status') }}
</span>
</div>
<router-link
v-for="assessment in Object.keys(student.assessments)"
class="flex items-center text-ink-gray-7 font-medium"
:to="{
name:
student.assessments[assessment].type == 'LMS Assignment'
? 'AssignmentSubmission'
: '',
params:
student.assessments[assessment].type == 'LMS Assignment'
? {
assignmentID:
student.assessments[assessment].assessment,
submissionName:
student.assessments[assessment].submission,
}
: {},
}"
>
<span class="flex-1">
{{ assessment }}
</span>
<span v-if="isAssignment(student.assessments[assessment].status)">
<Badge
:theme="
getStatusTheme(student.assessments[assessment].status)
"
>
{{ student.assessments[assessment].status }}
</Badge>
</span>
<span v-else>
{{ student.assessments[assessment].status }}
</span>
</router-link>
</div>
<!-- Courses -->
<div
v-if="Object.keys(student.courses).length"
class="space-y-2 text-sm"
>
<div
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
>
<span class="flex-1">
{{ __('Courses') }}
</span>
<span>
{{ __('Progress') }}
</span>
</div>
<div
v-for="course in Object.keys(student.courses)"
class="flex items-center text-ink-gray-7 font-medium"
>
<span class="flex-1">
{{ course }}
</span>
<span>
{{ Math.floor(student.courses[course]) }}
</span>
</div>
</div>
</div>
<!-- Heatmap -->
<StudentHeatmap :member="student.email" :days="120" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { Avatar, Badge, Dialog } from 'frappe-ui'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const show = defineModel()
const props = defineProps({
student: {
type: Object,
default: null,
},
})
const isAssignment = (value) => {
return isNaN(value)
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
return 'green'
} else if (status == 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script>
@@ -16,7 +16,12 @@
>
<template #body-content>
<div class="space-y-4 text-base">
<FormControl label="Title" v-model="chapter.title" :required="true" />
<FormControl
label="Title"
v-model="chapter.title"
:required="true"
autocomplete="off"
/>
<Switch
size="sm"
:label="__('SCORM Package')"
@@ -46,7 +51,7 @@
</FileUploader>
<div v-else class="">
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<div class="border rounded-md p-2 me-2">
<FileText class="h-5 w-5 stroke-1.5 text-ink-gray-7" />
</div>
<div class="flex flex-col">
@@ -59,7 +64,7 @@
</div>
<X
@click="() => (chapter.scorm_package = null)"
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ms-4"
/>
</div>
</div>
@@ -5,12 +5,12 @@
</template>
<template #body>
<div
class="absolute left-1/2 mt-3 w-96 max-w-lg -translate-x-1/2 transform rounded-lg bg-surface-white px-4 sm:px-0 lg:max-w-3xl"
class="absolute start-1/2 mt-3 w-96 max-w-lg -translate-x-1/2 transform rounded-lg bg-surface-white px-4 sm:px-0 lg:max-w-3xl"
>
<div
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
>
<div class="flex items-center justify-center space-x-2">
<div class="flex items-center justify-center gap-x-2">
<TextInput
type="text"
placeholder="search by keyword"
+22 -2
View File
@@ -10,11 +10,11 @@
<div class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ __('Edit Profile') }}
</div>
<div class="space-x-2">
<div class="flex items-center gap-x-2">
<Badge v-if="isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<div class="pb-5 float-right">
<div class="pb-5 float-end">
<Button variant="solid" @click="saveProfile()">
{{ __('Save') }}
</Button>
@@ -37,10 +37,12 @@
<FormControl
v-model="profile.first_name"
:label="__('First Name')"
:required="true"
/>
<FormControl
v-model="profile.last_name"
:label="__('Last Name')"
:required="true"
/>
<FormControl v-model="profile.headline" :label="__('Headline')" />
@@ -141,7 +143,25 @@ const updateProfile = createResource({
},
})
const validateMandatoryFields = () => {
let missingFields = []
if (!profile.first_name) missingFields.push(__('First Name'))
if (!profile.last_name) missingFields.push(__('Last Name'))
if (!profile.image) missingFields.push(__('Profile Image'))
if (missingFields.length) {
toast.error(
__('Please fill the mandatory fields: {0}').format(
missingFields.join(', ')
)
)
console.error('Missing mandatory fields:', missingFields)
}
return missingFields.length
}
const saveProfile = () => {
let missingMandatoryFields = validateMandatoryFields()
if (missingMandatoryFields) return
profile.bio = sanitizeHTML(profile.bio)
updateProfile.submit(
{},
@@ -34,10 +34,11 @@
:required="true"
:placeholder="__('Your enrollment in {{ batch_name }} is confirmed')"
/>
<FormControl
<Switch
size="sm"
:description="__('Use HTML content for the email response')"
:label="__('Use HTML')"
v-model="template.use_html"
type="checkbox"
/>
<FormControl
v-if="template.use_html"
@@ -75,7 +76,7 @@
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { call, Dialog, FormControl, TextEditor, toast, Switch } from 'frappe-ui'
import { reactive, watch } from 'vue'
import { cleanError } from '@/utils'
@@ -88,6 +89,7 @@ const props = defineProps({
const show = defineModel()
const emailTemplates = defineModel('emailTemplates')
const emit = defineEmits(['created'])
const template = reactive({
name: '',
subject: '',
@@ -113,6 +115,7 @@ const createNewTemplate = (close) => {
{
onSuccess() {
emailTemplates.value.reload()
emit('created', template.name)
refreshForm(close)
toast.success(__('Email Template created successfully'))
},
@@ -27,7 +27,7 @@
</div>
<div class="space-y-5">
<div v-for="row in slots.data" class="space-y-2">
<div class="flex items-center text-ink-gray-7 space-x-2">
<div class="flex items-center text-ink-gray-7 gap-x-2">
<Calendar class="size-3" />
<div class="text-ink-gray-9">
{{ dayjs(row.date).format('DD MMMM YYYY') }}
@@ -55,6 +55,9 @@
</div>
</div>
</div>
<div v-else-if="!evaluation.course" class="text-ink-gray-7">
{{ __('Please select a course to view available slots.') }}
</div>
<div v-else class="text-ink-red-3">
{{ __('No slots available for the selected course.') }}
</div>
@@ -63,18 +66,12 @@
</Dialog>
</template>
<script setup>
import {
call,
createResource,
dayjs,
Dialog,
FormControl,
toast,
} from 'frappe-ui'
import { call, createResource, Dialog, FormControl, toast } from 'frappe-ui'
import { ref, watch, inject } from 'vue'
import { Calendar } from 'lucide-vue-next'
import { formatTime } from '@/utils/'
const dayjs = inject('$dayjs')
const user = inject('$user')
const show = defineModel()
const evaluations = defineModel('reloadEvals')
+14 -10
View File
@@ -14,7 +14,7 @@
<div class="flex flex-col space-y-4 text-sm text-ink-gray-8">
<Tooltip :text="__('Email ID')">
<div class="flex items-center space-x-2 w-fit">
<div class="flex items-center gap-x-2 w-fit">
<User class="h-4 w-4 stroke-1.5" />
<span>
{{ event.member }}
@@ -23,7 +23,7 @@
</Tooltip>
<Tooltip :text="__('Course')">
<div
class="flex space-x-2 w-fit cursor-pointer"
class="flex gap-x-2 w-fit cursor-pointer"
@click="openLink('course', event.course)"
>
<BookOpen class="h-4 w-4 stroke-1.5" />
@@ -34,7 +34,7 @@
</Tooltip>
<Tooltip v-if="event.batch_title" :text="__('Batch')">
<div
class="flex space-x-2 w-fit cursor-pointer"
class="flex gap-x-2 w-fit cursor-pointer"
@click="openLink('batch', event.batch_name)"
>
<Users class="h-4 w-4 stroke-1.5" />
@@ -44,7 +44,7 @@
</div>
</Tooltip>
<Tooltip :text="__('Date')">
<div class="flex items-center space-x-2 w-fit">
<div class="flex items-center gap-x-2 w-fit">
<Calendar class="h-4 w-4 stroke-1.5" />
<span>
{{ dayjs(event.date).format('DD MMM YYYY') }}
@@ -52,7 +52,7 @@
</div>
</Tooltip>
<Tooltip :text="__('Time')">
<div class="flex items-center space-x-2 w-fit">
<div class="flex items-center gap-x-2 w-fit">
<Clock class="h-4 w-4 stroke-1.5" />
<span>
{{ formatTime(event.start_time) }} -
@@ -61,7 +61,7 @@
</div>
</Tooltip>
</div>
<div class="flex items-center space-x-2 mt-auto">
<div class="flex items-center gap-x-2 mt-auto">
<Button
v-if="certificate.name"
@click="openCertificate(certificate)"
@@ -86,7 +86,7 @@
</Button>
</div>
</div>
<Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-l w-1/2">
<Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-s w-1/2">
<template #tab-panel="{ tab }">
<div
v-if="tab.label == 'Evaluation'"
@@ -122,10 +122,13 @@
</Button>
</div>
<div v-else class="flex flex-col space-y-4 p-5">
<FormControl
type="checkbox"
<Switch
size="sm"
v-model="certificate.published"
:label="__('Published')"
:description="
__('Make this certificate visible to the participant.')
"
:disabled="!userIsEvaluator()"
/>
<Link
@@ -169,6 +172,7 @@ import {
Button,
FormControl,
createResource,
Switch,
Tabs,
Tooltip,
Textarea,
@@ -243,7 +247,7 @@ const evaluationResource = createResource({
member: props.event.member,
course: props.event.course,
batch_name: props.event.batch_name,
date: props.event.date,
date_value: props.event.date,
start_time: props.event.start_time,
end_time: props.event.end_time,
status: evaluation.status,
@@ -2,12 +2,12 @@
<Dialog
v-model="show"
:options="{
size: '4xl',
size: '5xl',
}"
>
<template #body>
<div class="p-5 min-h-[300px]">
<div class="text-lg font-semibold mb-4">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Training Feedback') }}
</div>
<ListView
@@ -19,10 +19,17 @@
rowHeight: 'h-16',
selectable: false,
}"
class="border rounded-lg py-2 px-3"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
></ListHeader>
class="mb-2 grid items-center rounded bg-surface-white border-b rounded-none !px-0"
>
<ListHeaderItem :item="item" v-for="item in feedbackColumns">
<template #prefix="{ item }">
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
:row="row"
@@ -41,7 +48,7 @@
class="flex"
:image="row['member_image']"
:label="item"
size="sm"
size="xl"
/>
</div>
</template>
@@ -63,9 +70,11 @@
<script setup lang="ts">
import {
Dialog,
ListView,
Avatar,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
@@ -89,27 +98,43 @@ const feedbackColumns = computed(() => {
label: 'Member',
key: 'member_name',
width: '10rem',
align: 'left',
icon: 'user',
},
{
label: 'Feedback',
key: 'feedback',
width: '15rem',
align: 'left',
icon: 'message-square',
},
{
label: 'Content',
key: 'content',
width: '9rem',
width: '10rem',
align: 'center',
icon: 'book',
},
{
label: 'Instructors',
key: 'instructors',
width: '9rem',
width: '10rem',
align: 'center',
icon: 'users',
},
{
label: 'Value',
key: 'value',
width: '9rem',
width: '10rem',
align: 'center',
icon: 'dollar-sign',
},
]
})
</script>
<style>
.feedback-list > button > div {
padding: 0.2rem 0;
margin-bottom: 0.2rem;
}
</style>
@@ -51,7 +51,7 @@
</FileUploader>
</div>
<div v-else class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<div class="border rounded-md p-2 me-2">
<FileText class="h-5 w-5 stroke-1.5 text-ink-gray-7" />
</div>
<div class="flex flex-col">
@@ -31,7 +31,7 @@
@click="redirectToProfile(participant.member_username)"
class="grid grid-cols-2 items-center w-full text-base w-fit py-2"
>
<div class="flex items-center space-x-2">
<div class="flex items-center gap-x-2">
<Avatar
:image="participant.member_image"
:label="participant.member_name"
@@ -47,7 +47,7 @@
</div>
</div>
<div class="grid grid-cols-3 gap-20 text-right">
<div class="grid grid-cols-3 gap-20 text-end">
<div>
{{ dayjs(participant.joined_at).format('HH:mm a') }}
</div>
@@ -29,14 +29,12 @@
:label="__('Date')"
:required="true"
/>
<Tooltip :text="__('Duration of the live class in minutes')">
<FormControl
type="number"
v-model="liveClass.duration"
:label="__('Duration')"
:required="true"
/>
</Tooltip>
<FormControl
type="number"
v-model="liveClass.duration"
:label="__('Duration (in minutes)')"
:required="true"
/>
</div>
<div class="space-y-4">
<Tooltip
@@ -67,6 +65,7 @@
/>
</div>
<FormControl
v-if="props.conferencingProvider === 'Zoom'"
v-model="liveClass.auto_recording"
type="select"
:options="getRecordingOptions()"
@@ -84,16 +83,10 @@
</Dialog>
</template>
<script setup>
import {
Dialog,
createResource,
Tooltip,
FormControl,
Autocomplete,
toast,
} from 'frappe-ui'
import { Dialog, createResource, Tooltip, FormControl, toast } from 'frappe-ui'
import { reactive, inject, onMounted } from 'vue'
import { getTimezones, getUserTimezone } from '@/utils/'
import Autocomplete from '@/components/Controls/Autocomplete.vue'
const liveClasses = defineModel('reloadLiveClasses')
const show = defineModel()
@@ -105,10 +98,9 @@ const props = defineProps({
type: String,
required: true,
},
zoomAccount: {
type: String,
required: true,
},
zoomAccount: String,
googleMeetAccount: String,
conferencingProvider: String,
})
let liveClass = reactive({
@@ -165,8 +157,23 @@ const createLiveClass = createResource({
},
})
const createGoogleMeetLiveClass = createResource({
url: 'lms.lms.doctype.lms_batch.lms_batch.create_google_meet_live_class',
makeParams(values) {
return {
batch_name: values.batch,
google_meet_account: props.googleMeetAccount,
...values,
}
},
})
const submitLiveClass = (close) => {
return createLiveClass.submit(liveClass, {
const resource =
props.conferencingProvider === 'Google Meet'
? createGoogleMeetLiveClass
: createLiveClass
return resource.submit(liveClass, {
validate() {
validateFormFields()
},
@@ -177,6 +184,7 @@ const submitLiveClass = (close) => {
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
})
}
@@ -0,0 +1,173 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add New Member'),
size: 'lg',
actions: [
{
label: __('Add'),
variant: 'solid',
loading: submitting,
onClick: ({ close }: any) => addMember(close),
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
v-model="member.email"
:label="__('Email')"
placeholder="jane@doe.com"
type="email"
:required="true"
@keyup.enter="addMember()"
/>
<div class="flex items-center gap-3">
<FormControl
v-model="member.first_name"
:label="__('First Name')"
placeholder="Jane"
type="text"
class="w-full"
/>
<FormControl
v-model="member.last_name"
:label="__('Last Name')"
placeholder="Doe"
type="text"
class="w-full"
/>
</div>
<div class="flex flex-col gap-2">
<div class="text-sm text-ink-gray-5">
{{ __('Roles') }}
</div>
<div class="grid md:grid-cols-2 gap-x-6 gap-y-3">
<Switch
size="sm"
:label="__('Student')"
v-model="roles.lms_student"
/>
<Switch
size="sm"
:label="__('Course Creator')"
v-model="roles.course_creator"
/>
<Switch
size="sm"
:label="__('Evaluator')"
v-model="roles.batch_evaluator"
/>
<Switch
size="sm"
:label="__('Moderator')"
v-model="roles.moderator"
/>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, toast, Switch } from 'frappe-ui'
import { reactive, ref, watch } from 'vue'
import { cleanError } from '@/utils'
const show = defineModel<boolean>({ default: false })
const submitting = ref(false)
const props = defineProps<{
defaultRoles?: string[]
}>()
const emit = defineEmits<{
created: [user: any]
}>()
const ROLE_MAP: Record<string, string> = {
moderator: 'Moderator',
course_creator: 'Course Creator',
batch_evaluator: 'Batch Evaluator',
lms_student: 'LMS Student',
}
const member = reactive({
email: '',
first_name: '',
last_name: '',
})
const roles = reactive({
moderator: false,
course_creator: false,
batch_evaluator: false,
lms_student: false,
})
const resetForm = () => {
member.email = ''
member.first_name = ''
member.last_name = ''
applyDefaultRoles()
}
const applyDefaultRoles = () => {
roles.moderator = props.defaultRoles?.includes('moderator') ?? false
roles.course_creator = props.defaultRoles?.includes('course_creator') ?? false
roles.batch_evaluator =
props.defaultRoles?.includes('batch_evaluator') ?? false
roles.lms_student = props.defaultRoles?.includes('lms_student') ?? false
}
watch(show, (isOpen) => {
if (isOpen) {
resetForm()
}
})
const assignRoles = async (userEmail: string) => {
const selectedRoles = Object.entries(roles).filter(([_, checked]) => checked)
for (const [key, _] of selectedRoles) {
await call('lms.lms.api.save_role', {
user: userEmail,
role: ROLE_MAP[key],
value: 1,
})
}
}
const addMember = async (close?: () => void) => {
if (!member.email?.trim()) {
toast.error(__('Email is required'))
return
}
submitting.value = true
try {
const user = await call('frappe.client.insert', {
doc: {
doctype: 'User',
email: member.email.trim(),
first_name: member.first_name.trim() || undefined,
last_name: member.last_name.trim() || undefined,
},
})
await assignRoles(user.name)
toast.success(__('Member added successfully'))
emit('created', user)
resetForm()
close?.()
} catch (err: any) {
toast.error(cleanError(err.messages?.[0]) || __('Unable to add member'))
} finally {
submitting.value = false
}
}
</script>
+13 -15
View File
@@ -2,7 +2,7 @@
<Dialog
v-model="show"
:options="{
size: '5xl',
size: '3xl',
}"
>
<template #body>
@@ -10,17 +10,14 @@
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
{{ __(props.title) }}
</div>
<div
<Switch
v-if="!editMode"
class="flex items-center text-xs text-ink-gray-7 space-x-5"
>
<Switch
size="sm"
:label="__('Choose an existing question')"
v-model="chooseFromExisting"
class="!p-0"
/>
</div>
size="sm"
:label="__('Choose an existing question')"
:description="__('Select from questions you have already created')"
v-model="chooseFromExisting"
class="!p-0"
/>
<div v-if="!chooseFromExisting || editMode">
<div>
<label class="block text-xs text-ink-gray-5 mb-1">
@@ -75,10 +72,11 @@
:label="__('Explanation')"
v-model="question[`explanation_${n}`]"
/>
<FormControl
<Switch
size="sm"
:label="__('Correct Answer')"
:description="__('Mark this option as a correct answer.')"
v-model="question[`is_correct_${n}`]"
type="checkbox"
/>
</div>
</div>
@@ -107,7 +105,7 @@
type="number"
/>
</div>
<div class="flex items-center justify-end space-x-2 mt-5">
<div class="flex items-center justify-end gap-x-2 mt-5">
<Button variant="solid" @click="submitQuestion()">
{{ __('Save') }}
</Button>
@@ -164,7 +162,7 @@ populateFields()
const props = defineProps({
title: {
type: String,
default: __('Add a new question'),
default: __('Add new question'),
},
questionDetail: {
type: [Object, null],
@@ -44,14 +44,14 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
class="h-4 w-4 stroke-1.5 ms-4"
/>
</template>
</ListHeaderItem>
+24 -22
View File
@@ -3,7 +3,7 @@
v-model="show"
:options="{
title: __('Enroll a Student'),
size: 'sm',
size: 'lg',
actions: [
{
label: 'Submit',
@@ -51,8 +51,6 @@ import { useOnboarding } from 'frappe-ui/frappe'
import { openSettings } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const students = defineModel('reloadStudents')
const batchModal = defineModel('batchModal')
const student = ref(null)
const payment = ref(null)
const user = inject('$user')
@@ -61,33 +59,37 @@ const show = defineModel()
const props = defineProps({
batch: {
type: String,
type: Object,
default: null,
},
students: {
type: Object,
default: null,
},
})
const addStudent = (close) => {
call('frappe.client.insert', {
doc: {
doctype: 'LMS Batch Enrollment',
batch: props.batch,
props.students.insert.submit(
{
member: student.value,
payment: payment.value,
batch: props.batch.data?.name,
},
})
.then(() => {
if (user.data?.is_system_manager)
updateOnboardingStep('add_batch_student')
{
onSuccess() {
if (user.data?.is_system_manager)
updateOnboardingStep('add_batch_student')
students.value.reload()
batchModal.value.reload()
student.value = null
payment.value = null
close()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
student.value = null
payment.value = null
props.batch.reload()
close()
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
}
</script>
@@ -3,7 +3,7 @@
v-model="show"
:options="{
size: '4xl',
title: __('Video Statistics for {0}').format(lessonTitle),
title: __('Video Statistics'),
}"
>
<template #body-content>
@@ -18,20 +18,25 @@
<!-- <FormControl
v-model="searchText"
:placeholder="__('Search by Member')"
class="mt-2 mr-5 w-[25%]"
class="mt-2 me-5 w-[25%]"
/> -->
</div>
<div v-if="currentTab" class="mt-4">
<div
v-if="currentTab"
:class="{
'mt-5': tabs.length > 1,
}"
>
<div class="grid grid-cols-[55%,40%] gap-5">
<div
class="space-y-5 border rounded-md p-2 pt-4 h-[70vh] overflow-y-auto"
class="space-y-5 border rounded-md p-2 pt-4 max-h-[70vh] overflow-y-auto"
>
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
<div class="px-4">
{{ __('Member') }}
</div>
<div class="text-center">
{{ __('Watch Time') }}
{{ __('Watch Time (mins)') }}
</div>
</div>
<div
@@ -45,7 +50,7 @@
}"
>
<div class="grid grid-cols-[70%,30%] items-center">
<div class="flex items-center space-x-2">
<div class="flex items-center gap-x-2">
<Avatar
:image="row.member_image"
:label="row.member_name"
@@ -68,15 +73,16 @@
</div>
</div>
<div class="space-y-5">
<NumberChart
class="border rounded-md"
:config="{
title: __('Average Watch Time'),
value: averageWatchTime,
}"
<NumberChartGraph
:title="__('Average Watch Time (mins)')"
:value="averageWatchTime"
/>
<div v-if="isPlyrSource">
<div class="video-player" :src="currentTab"></div>
<div
class="video-player"
:data-plyr-provider="provider"
:src="currentTab"
></div>
</div>
<VideoBlock v-else :file="currentTab" />
</div>
@@ -101,6 +107,7 @@ import {
import { computed, ref, watch } from 'vue'
import { enablePlyr, formatTimestamp } from '@/utils'
import VideoBlock from '@/components/VideoBlock.vue'
import NumberChartGraph from '@/components/NumberChartGraph.vue'
const show = defineModel<boolean | undefined>()
const currentTab = ref<string>('')
@@ -171,7 +178,7 @@ watch(show, () => {
const statisticsData = computed(() => {
const grouped = <Record<string, any[]>>{}
statistics.data.forEach((item: { source: string }) => {
statistics.data?.forEach((item: { source: string }) => {
if (!grouped[item.source]) {
grouped[item.source] = []
}
@@ -18,10 +18,13 @@
>
<template #body-content>
<div class="mb-4">
<FormControl
<Switch
size="sm"
v-model="account.enabled"
:label="__('Enabled')"
type="checkbox"
:description="
__('Activate this Zoom account for scheduling meetings.')
"
/>
</div>
<div class="grid grid-cols-2 gap-5">
@@ -61,7 +64,7 @@
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, toast } from 'frappe-ui'
import { call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
import { inject, reactive, watch } from 'vue'
import { User } from '@/components/Settings/types'
import { openSettings, cleanError } from '@/utils'
@@ -109,16 +112,14 @@ const account = reactive({
client_secret: '',
})
const props = defineProps({
accountID: {
type: String,
default: 'new',
},
})
const props = defineProps<{
accountID: string | null
}>()
watch(
() => props.accountID,
(val) => {
console.log(props.accountID)
if (val === 'new') {
account.name = ''
account.enabled = false
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
<div class="border-b px-5 py-3 font-medium text-ink-gray-9">
<span
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:me-2"
></span>
{{ __(title) }}
</div>
@@ -4,7 +4,7 @@
:style="{
display: top > 0 ? 'block' : 'none',
top: top + 'px',
left: left + 'px',
insetInlineStart: left + 'px',
}"
>
<div class="space-y-2 py-2">
@@ -14,7 +14,7 @@
<div class="">
<div
v-for="color in colors"
class="flex items-center space-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
class="flex items-center gap-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
@click="saveHighLight(color)"
>
<span
@@ -32,7 +32,7 @@
<div class="border-t">
<div
@click="addToNotes()"
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
class="flex items-center gap-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
>
<NotepadText class="size-3 stroke-1.5" />
<span>
@@ -42,7 +42,7 @@
<div
v-if="highlightExists()"
@click="deleteHighlight"
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
class="flex items-center gap-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
>
<Trash2 class="size-3 stroke-1.5" />
<span>
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="text-ink-gray-5">
{{ __(title) }}
</div>
<div class="flex items-center space-x-2">
<div class="flex items-center gap-x-2">
<slot name="prefix" />
<div class="font-semibold text-ink-gray-9 text-2xl">
{{ value }}
+369 -101
View File
@@ -1,59 +1,75 @@
<template>
<div v-if="quiz.data">
<div
class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 leading-5"
class="bg-surface-blue-2 text-ink-blue-3 space-y-2 p-3 mb-4 rounded-lg leading-5"
>
<div v-if="inVideo">
{{ __('You will have to complete the quiz to continue the video') }}
</div>
<div class="leading-5">
{{
__('This quiz consists of {0} questions.').format(questions.length)
}}
</div>
<div v-if="quiz.data?.duration" class="leading-5">
<div class="font-medium">
{{
__(
'Please ensure that you complete all the questions in {0} minutes.'
).format(quiz.data.duration)
}}
</div>
<div v-if="quiz.data?.duration" class="leading-5">
{{
__(
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
)
}}
</div>
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
{{
__(
'You will have to get {0}% correct answers in order to pass the quiz.'
).format(quiz.data.passing_percentage)
}}
</div>
<div v-if="quiz.data.max_attempts" class="leading-5">
{{
__('You can attempt this quiz {0}.').format(
quiz.data.max_attempts == 1
? '1 time'
: `${quiz.data.max_attempts} times`
)
}}
</div>
<div v-if="quiz.data.enable_negative_marking" class="leading-5">
{{
__(
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
).format(
quiz.data.marks_to_cut,
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
'Please read the following instructions carefully before starting the quiz'
)
}}
</div>
<ol class="list-decimal list-inside space-y-2">
<li v-if="inVideo">
{{ __('You will have to complete the quiz to continue the video') }}
</li>
<li>
{{
__(
'Do not refresh the page or close this window. If you do, the quiz will be submitted automatically.'
)
}}
</li>
<li>
{{
__('This quiz consists of {0} questions.').format(questions.length)
}}
</li>
<li v-if="quiz.data?.duration">
{{
__(
'Please ensure that you complete all the questions in {0} minutes.'
).format(quiz.data.duration)
}}
</li>
<li v-if="quiz.data?.duration">
{{
__(
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
)
}}
</li>
<li v-if="quiz.data.passing_percentage">
{{
__(
'You will have to get {0}% correct answers in order to pass the quiz.'
).format(quiz.data.passing_percentage)
}}
</li>
<li v-if="quiz.data.max_attempts">
{{
__('You can attempt this quiz {0}.').format(
quiz.data.max_attempts == 1
? '1 time'
: `${quiz.data.max_attempts} times`
)
}}
</li>
<li v-if="quiz.data.enable_negative_marking">
{{
__(
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
).format(
quiz.data.marks_to_cut,
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
)
}}
</li>
</ol>
</div>
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
<div v-if="quiz.data.duration" class="flex flex-col gap-x-1 my-4 px-2">
<div class="mb-2">
<span class="text-ink-gray-9"> {{ __('Time') }}: </span>
<span class="font-semibold text-ink-gray-9">
@@ -68,7 +84,7 @@
<div class="font-semibold text-lg text-ink-gray-9">
{{ quiz.data.title }}
</div>
<div class="flex items-center justify-center space-x-2 mt-4">
<div class="flex items-center justify-center gap-x-2 mt-4">
<Button
v-if="
!quiz.data.max_attempts ||
@@ -104,16 +120,12 @@
<div v-for="(question, qtidx) in questions">
<div
v-if="qtidx == activeQuestion - 1 && questionDetails.data"
class="border rounded-md p-5"
class="border rounded-lg p-5"
>
<div class="flex justify-between">
<div class="text-sm text-ink-gray-5">
<span class="mr-2">
{{ __('Question {0}').format(activeQuestion) }}:
</span>
<span>
{{ getInstructions(questionDetails.data) }}
</span>
{{ __('Question {0}').format(activeQuestion) }} -
{{ getInstructions(questionDetails.data) }}
</div>
<div class="text-ink-gray-9 text-sm font-semibold item-left">
{{ question.marks }}
@@ -135,6 +147,7 @@
:name="encodeURIComponent(questionDetails.data.question)"
class="w-3.5 h-3.5 text-ink-gray-9 focus:ring-outline-gray-modals"
@change="markAnswer(index)"
:checked="selectedOptions[index - 1]"
/>
<input
@@ -143,6 +156,7 @@
:name="encodeURIComponent(questionDetails.data.question)"
class="w-3.5 h-3.5 text-ink-gray-9 rounded-sm focus:ring-outline-gray-modals"
@change="markAnswer(index)"
:checked="selectedOptions[index - 1]"
/>
<div
v-else-if="quiz.data.show_answers"
@@ -165,7 +179,7 @@
</div>
</div>
<span
class="ml-2 text-ink-gray-9"
class="ms-2 text-ink-gray-9"
v-html="questionDetails.data[`option_${index}`]"
>
</span>
@@ -188,12 +202,12 @@
<div v-if="showAnswers.length">
<Badge v-if="showAnswers[0]" :label="__('Correct')" theme="green">
<template #prefix>
<CheckCircle class="w-4 h-4 text-ink-green-2 mr-1" />
<CheckCircle class="w-4 h-4 text-ink-green-2 me-1" />
</template>
</Badge>
<Badge v-else theme="red" :label="__('Incorrect')">
<template #prefix>
<XCircle class="w-4 h-4 text-ink-red-3 mr-1" />
<XCircle class="w-4 h-4 text-ink-red-3 me-1" />
</template>
</Badge>
</div>
@@ -208,14 +222,56 @@
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<div class="flex items-center justify-between mt-4">
<div class="text-sm text-ink-gray-5">
{{
__('Question {0} of {1}').format(
activeQuestion,
questions.length
)
}}
<div class="flex items-center justify-between mt-8">
<Checkbox
v-if="!quiz.data.show_answers"
:label="__('Mark for review')"
:model-value="reviewQuestions.includes(activeQuestion) ? 1 : 0"
@change="markForReview($event, activeQuestion)"
/>
<div
v-if="!quiz.data.show_answers"
class="flex items-center gap-x-2"
>
<Button
@click="switchQuestion(activeQuestion - 1)"
:disabled="activeQuestion == 1"
class="rounded-full"
>
<template #icon>
<ChevronLeft class="size-4 stroke-1.5" />
</template>
</Button>
<span
v-for="item in paginationWindow"
:key="item"
class="w-6 h-6 rounded-full flex items-center justify-center text-sm"
:class="{
'cursor-pointer': item !== '...',
'bg-surface-gray-4 border border-outline-gray-5 font-medium':
activeQuestion == item,
'text-ink-gray-5': item === '...',
'bg-surface-blue-3 text-ink-white':
attemptedQuestions.includes(item) && activeQuestion != item,
'bg-surface-gray-3 text-ink-gray-6':
activeQuestion != item &&
item !== '...' &&
!attemptedQuestions.includes(item),
}"
@click="item !== '...' && switchQuestion(item)"
>
{{ item }}
</span>
<Button
@click="switchQuestion(activeQuestion + 1)"
:disabled="activeQuestion == questions.length"
class="rounded-full"
>
<template #icon>
<ChevronRight class="size-4 stroke-1.5" />
</template>
</Button>
</div>
<Button
v-if="
@@ -223,6 +279,7 @@
!showAnswers.length &&
questionDetails.data.type != 'Open Ended'
"
class="ms-auto"
@click="checkAnswer()"
>
<span>
@@ -230,14 +287,22 @@
</span>
</Button>
<Button
v-else-if="activeQuestion != questions.length"
v-else-if="
activeQuestion != questions.length && quiz.data.show_answers
"
@click="nextQuestion()"
class="ms-auto"
>
<span>
{{ __('Next') }}
</span>
</Button>
<Button v-else @click="submitQuiz()">
<Button
variant="solid"
v-else
@click="handleSubmitClick()"
class="ms-auto"
>
<span>
{{ __('Submit') }}
</span>
@@ -245,8 +310,22 @@
</div>
</div>
</div>
<div v-if="reviewQuestions.length" class="border rounded-lg p-4 mt-4">
<div class="font-semibold">
{{ __('Questions marked for review') }}
</div>
<div class="flex items-center gap-x-2 mt-2">
<div
v-for="index in reviewQuestions"
@click="switchQuestion(index)"
class="w-6 h-6 rounded-full flex items-center justify-center text-sm cursor-pointer bg-surface-gray-3"
>
{{ index }}
</div>
</div>
</div>
</div>
<div v-else class="border rounded-md p-20 text-center space-y-2">
<div v-else class="border rounded-lg p-20 text-center space-y-2">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Quiz Summary') }}
</div>
@@ -271,7 +350,7 @@
)
}}
</div>
<div class="space-x-2">
<div class="flex gap-x-2">
<Button
@click="resetQuiz()"
class="mt-2"
@@ -310,30 +389,96 @@
</ListView>
</div>
</div>
<Dialog
v-model="showSubmissionConfirmation"
:options="{
title: __('Are you sure you want to submit the quiz?'),
actions: [
{
size: 'sm',
label: __('Submit'),
variant: 'solid',
onClick() {
submitQuiz()
showSubmissionConfirmation = false
},
},
],
}"
>
<template #body-content>
<div class="border border-outline-gray-modals rounded-lg text-base">
<div class="divide-y divide-outline-gray-modals">
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
<div class="p-2">
{{ __('Total Questions') }}
</div>
<div class="p-2">
{{ questions.length }}
</div>
</div>
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
<div class="p-2">
{{ __('Attempted Questions') }}
</div>
<div class="p-2">
{{ attemptedQuestions.length }}
</div>
</div>
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
<div class="p-2">
{{ __('Unattempted Questions') }}
</div>
<div class="p-2">
{{ questions.length - attemptedQuestions.length }}
</div>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import {
Badge,
Button,
call,
Checkbox,
createResource,
Dialog,
ListView,
TextEditor,
FormControl,
toast,
} from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import {
computed,
inject,
onMounted,
onUnmounted,
reactive,
ref,
watch,
} from 'vue'
import {
CheckCircle,
ChevronLeft,
ChevronRight,
XCircle,
MinusCircle,
} from 'lucide-vue-next'
import { timeAgo } from '@/utils'
import { useRouter } from 'vue-router'
import ProgressBar from '@/components/ProgressBar.vue'
const user = inject('$user')
const activeQuestion = ref(0)
const currentQuestion = ref('')
const selectedOptions = reactive([0, 0, 0, 0])
const selectedOptions = ref([0, 0, 0, 0])
const showAnswers = reactive([])
let questions = reactive([])
const attemptedQuestions = ref([])
const reviewQuestions = ref([])
const showSubmissionConfirmation = ref(false)
const possibleAnswer = ref(null)
const timer = ref(0)
let timerInterval = null
@@ -353,6 +498,40 @@ const props = defineProps({
},
})
onMounted(() => {
window.addEventListener('pagehide', handlePageHide)
window.addEventListener('beforeunload', handleBeforeUnload)
})
onUnmounted(() => {
window.removeEventListener('pagehide', handlePageHide)
window.removeEventListener('beforeunload', handleBeforeUnload)
})
const handlePageHide = () => {
if (activeQuestion.value > 0 && !quizSubmission.data) {
const params = new URLSearchParams({
quiz: quiz.data.name,
results: localStorage.getItem(quiz.data.title),
})
navigator.sendBeacon(
'/api/method/lms.lms.doctype.lms_quiz.lms_quiz.submit_quiz?' +
params.toString()
)
}
}
const handleBeforeUnload = (event) => {
if (activeQuestion.value > 0 && !quizSubmission.data) {
if (attemptedQuestions.value.length) {
switchQuestion(activeQuestion.value)
}
event.preventDefault()
event.returnValue = ''
}
}
const quiz = createResource({
url: 'frappe.client.get',
makeParams(values) {
@@ -465,7 +644,7 @@ watch(
)
const quizSubmission = createResource({
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
url: 'lms.lms.doctype.lms_quiz.lms_quiz.submit_quiz',
makeParams(values) {
return {
quiz: quiz.data.name,
@@ -486,10 +665,58 @@ const questionDetails = createResource({
watch(activeQuestion, (value) => {
if (value > 0) {
currentQuestion.value = quiz.data.questions[value - 1].question
questionDetails.reload()
questionDetails.reload(
{},
{
onSuccess() {
if (!quiz.data.show_answers) {
loadSavedAnswers()
}
},
}
)
}
})
const switchQuestion = (questionNumber) => {
let answers = getAnswers()
if (answers.length) {
if (!attemptedQuestions.value.includes(activeQuestion.value)) {
attemptedQuestions.value.push(activeQuestion.value)
}
addToLocalStorage()
resetQuestion()
}
if (questionNumber < 1 || questionNumber > questions.length) return
activeQuestion.value = questionNumber
}
const loadSavedAnswers = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
if (quizData) {
let localQuestion = quizData.find(
(q) => q.question_name == currentQuestion.value
)
if (localQuestion) {
let localAnswers = localQuestion.answer
if (localAnswers.length) {
if (questionDetails.data.type == 'Choices') {
localAnswers.forEach((answer) => {
for (let i = 1; i <= 4; i++) {
if (questionDetails.data[`option_${i}`] == answer) {
selectedOptions.value[i - 1] = 1
}
}
})
} else {
possibleAnswer.value = localAnswers[0]
}
}
}
}
}
watch(
() => props.quizName,
(newName) => {
@@ -507,17 +734,20 @@ const startQuiz = () => {
const markAnswer = (index) => {
if (!questionDetails.data.multiple)
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
selectedOptions[index - 1] = selectedOptions[index - 1] ? 0 : 1
selectedOptions.value.splice(
0,
selectedOptions.value.length,
...[0, 0, 0, 0]
)
selectedOptions.value[index - 1] = selectedOptions.value[index - 1] ? 0 : 1
}
const getAnswers = () => {
let answers = []
const type = questionDetails.data.type
if (type == 'Choices') {
selectedOptions.forEach((value, index) => {
if (selectedOptions[index])
selectedOptions.value.forEach((value, index) => {
if (selectedOptions.value[index])
answers.push(questionDetails.data[`option_${index + 1}`])
})
} else {
@@ -538,14 +768,14 @@ const checkAnswer = () => {
url: 'lms.lms.doctype.lms_quiz.lms_quiz.check_answer',
params: {
question: currentQuestion.value,
type: questionDetails.data.type,
question_type: questionDetails.data.type,
answers: JSON.stringify(answers),
},
auto: true,
onSuccess(data) {
let type = questionDetails.data.type
if (type == 'Choices') {
selectedOptions.forEach((option, index) => {
selectedOptions.value.forEach((option, index) => {
if (option) {
showAnswers[index] = option && data[index]
} else if (data[index] == 2) {
@@ -569,17 +799,15 @@ const addToLocalStorage = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
let questionData = {
question_name: currentQuestion.value,
answer: getAnswers().join(),
is_correct: showAnswers.filter((answer) => {
return answer != undefined
}),
answer: getAnswers(),
}
if (quizData) {
let existingQuestion = quizData.find(
(q) => q.question_name == questionData.question_name
)
if (!existingQuestion) {
if (existingQuestion) {
existingQuestion.answer = questionData.answer
} else {
quizData.push(questionData)
}
} else {
@@ -589,18 +817,15 @@ const addToLocalStorage = () => {
}
const nextQuestion = () => {
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
checkAnswer()
} else {
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion()
}
if (!quiz.data.show_answers) return
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion()
}
const resetQuestion = () => {
if (activeQuestion.value == quiz.data.questions.length) return
activeQuestion.value = activeQuestion.value + 1
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
selectedOptions.value.splice(0, selectedOptions.value.length, ...[0, 0, 0, 0])
showAnswers.length = 0
possibleAnswer.value = null
}
@@ -608,7 +833,6 @@ const resetQuestion = () => {
const submitQuiz = () => {
if (!quiz.data.show_answers) {
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
else checkAnswer()
setTimeout(() => {
createSubmission()
}, 500)
@@ -642,8 +866,10 @@ const createSubmission = () => {
const resetQuiz = () => {
activeQuestion.value = 0
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
selectedOptions.value.splice(0, selectedOptions.value.length, ...[0, 0, 0, 0])
showAnswers.length = 0
possibleAnswer.value = null
attemptedQuestions.value = []
quizSubmission.reset()
populateQuestions()
setupTimer()
@@ -672,6 +898,53 @@ const markLessonProgress = () => {
}
}
const handleSubmitClick = () => {
if (!quiz.data.show_answers) {
if (attemptedQuestions.value.length) {
switchQuestion(activeQuestion.value)
}
showSubmissionConfirmation.value = true
} else {
submitQuiz()
}
}
const paginationWindow = computed(() => {
const total = questions.length
const current = activeQuestion.value
const pages = []
const size = 5
let start = Math.floor((current - 1) / size) * size + 1
let end = Math.min(start + size - 1, total)
if (start > 1) {
pages.push('...')
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < total) {
pages.push('...')
}
return pages
})
const markForReview = (event, questionNumber) => {
if (event.target.checked) {
if (!reviewQuestions.value.includes(questionNumber)) {
reviewQuestions.value.push(questionNumber)
}
} else {
reviewQuestions.value = reviewQuestions.value.filter(
(num) => num !== questionNumber
)
}
}
const getSubmissionColumns = () => {
return [
{
@@ -700,8 +973,3 @@ const getSubmissionColumns = () => {
]
}
</script>
<style>
p {
line-height: 1.5rem;
}
</style>
@@ -1,7 +1,7 @@
<template>
<div class="text-base">
<div class="flex items-center justify-between space-x-2 mb-5">
<div class="flex items-center space-x-2">
<div class="flex items-center justify-between gap-x-2 mb-5">
<div class="flex items-center gap-x-2">
<ChevronLeft
class="size-5 stroke-1.5 text-ink-gray-5 cursor-pointer"
@click="
@@ -34,7 +34,7 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
+7 -10
View File
@@ -9,11 +9,7 @@
<template #body-content>
<div class="grid grid-cols-2 gap-x-5">
<div class="space-y-4">
<FormControl
v-model="badge.enabled"
:label="__('Enabled')"
type="checkbox"
/>
<Switch size="sm" v-model="badge.enabled" :label="__('Enabled')" />
<FormControl
v-model="badge.title"
:label="__('Title')"
@@ -41,10 +37,11 @@
</div>
<div class="space-y-4">
<FormControl
<Switch
size="sm"
v-model="badge.grant_only_once"
:label="__('Grant Only Once')"
type="checkbox"
:description="__('Each user can only receive this badge one time.')"
/>
<FormControl
v-model="badge.event"
@@ -73,7 +70,7 @@
</div>
</template>
<template #actions="{ close }">
<div class="pb-5 float-right">
<div class="pb-5 float-end">
<Button variant="solid" @click="saveBadge(close)">
{{ __('Save') }}
</Button>
@@ -82,7 +79,7 @@
</Dialog>
</template>
<script setup lang="ts">
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
import { Button, call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { cleanError } from '@/utils'
import type { Badges, Badge } from '@/components/Settings/types'
@@ -206,7 +203,7 @@ const referenceDoctypeOptions = computed(() => {
})
const eventOptions = computed(() => {
let options = ['New', 'Value Change', 'Auto Assign']
let options = ['New', 'Value Change', 'Manual Assignment']
return options.map((event) => ({ label: __(event), value: event }))
})
+2 -2
View File
@@ -21,7 +21,7 @@
{{ __('New') }}
</Button>
</div>
<div v-if="badges.data?.length" class="overflow-y-scroll">
<div v-if="badges.data?.length" class="overflow-y-auto">
<ListView
:columns="columns"
:rows="badges.data"
@@ -32,7 +32,7 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns" :key="item.key">
<template #prefix="{ item }">
@@ -1,11 +1,16 @@
<template>
<div class="flex flex-col h-full">
<div class="flex flex-col h-full text-p-base">
<div>
<div class="flex items-center justify-between">
<div class="font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
<div class="space-y-2">
<div class="font-semibold text-xl text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="space-x-2">
<div class="flex items-center gap-x-2">
<Badge
v-if="isDirty"
:label="__('Not Saved')"
@@ -21,9 +26,6 @@
</Button>
</div>
</div>
<div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div>
</div>
<div class="overflow-y-auto">
<SettingFields :sections="sections" :data="branding.data" />
@@ -9,9 +9,9 @@
{{ __(description) }}
</div>
</div>
<div class="flex items-center space-x-5">
<div class="flex items-center gap-x-5">
<div
class="flex items-center space-x-1 text-ink-amber-3 border border-outline-amber-1 bg-surface-amber-1 rounded-lg px-2 py-1"
class="flex items-center gap-x-1 text-ink-amber-3 border border-outline-amber-1 bg-surface-amber-1 rounded-lg px-2 py-1"
v-if="saving"
>
<LoadingIndicator class="size-2" />
@@ -29,10 +29,7 @@
</div>
</div>
<div
v-if="showForm"
class="flex items-center justify-between my-4 space-x-2"
>
<div v-if="showForm" class="flex items-center justify-between my-4 gap-x-2">
<FormControl
ref="categoryInput"
v-model="category"
@@ -44,7 +41,7 @@
</Button>
</div>
<div class="overflow-y-scroll">
<div class="overflow-y-auto">
<div class="divide-y divide-outline-gray-modals space-y-2">
<div
v-for="(cat, index) in categories.data"
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col text-base h-full">
<div class="flex items-center space-x-2 mb-8 -ml-1.5">
<div class="flex items-center gap-x-2 mb-8 -ms-1.5">
<ChevronLeft
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="emit('updateStep', 'list')"
@@ -11,10 +11,11 @@
</div>
<div class="space-y-4 overflow-y-auto">
<div>
<FormControl
<Switch
size="sm"
v-model="data.enabled"
:label="__('Enabled')"
type="checkbox"
:description="__('Allow this coupon to be used for discounts.')"
/>
</div>
<div class="grid grid-cols-2 gap-4">
@@ -73,7 +74,7 @@
<CouponItems ref="couponItems" :data="data" :coupons="coupons" />
</div>
</div>
<div class="mt-auto space-x-2 ml-auto">
<div class="mt-auto flex gap-x-2 items-center ms-auto">
<Button variant="solid" @click="saveCoupon()">
{{ __('Save') }}
</Button>
@@ -81,7 +82,7 @@
</div>
</template>
<script setup lang="ts">
import { Button, FormControl, toast } from 'frappe-ui'
import { Button, FormControl, toast, Switch } from 'frappe-ui'
import { ref } from 'vue'
import { ChevronLeft } from 'lucide-vue-next'
import type { Coupon, Coupons } from './types'
@@ -1,7 +1,7 @@
<template>
<div>
<div class="relative overflow-x-auto border rounded-md">
<table class="w-full text-sm text-left text-ink-gray-5">
<table class="w-full text-sm text-start text-ink-gray-5">
<thead class="text-xs text-ink-gray-7 uppercase bg-surface-gray-2">
<tr>
<td scope="col" class="px-6 py-2">
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
@@ -17,7 +17,7 @@
</Button>
</div>
<div v-if="coupons.data?.length" class="overflow-y-scroll">
<div v-if="coupons.data?.length" class="overflow-y-auto">
<ListView
:columns="columns"
:rows="coupons.data"
@@ -31,7 +31,7 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
>
</ListHeader>
<ListRows>
@@ -9,7 +9,7 @@
{{ __(description) }}
</div> -->
</div>
<div class="flex items-center space-x-5">
<div class="flex items-center gap-x-5">
<Button @click="openTemplateForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
@@ -18,7 +18,7 @@
</Button>
</div>
</div>
<div v-if="emailTemplates.data?.length" class="overflow-y-scroll">
<div v-if="emailTemplates.data?.length" class="overflow-y-auto">
<ListView
:columns="columns"
:rows="emailTemplates.data"
@@ -31,14 +31,14 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
class="h-4 w-4 stroke-1.5 ms-4"
/>
</template>
</ListHeaderItem>
+58 -60
View File
@@ -2,25 +2,57 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="flex item-center space-x-2">
<Button variant="solid" @click="() => (showForm = !showForm)">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
<div class="flex item-center gap-x-2">
<Dropdown
placement="right"
side="bottom"
:options="[
{
label: __('New Evaluator'),
icon: 'user-plus',
onClick() {
showNewEvaluator = true
},
},
{
label: __('Existing User'),
icon: 'user-check',
onClick() {
showExistingUser = true
},
},
]"
>
<template v-slot="{ open }">
<Button variant="solid">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('New') }}
<template #suffix>
<ChevronDown
:class="[
'w-4 h-4 stroke-1.5 ms-1 transform transition-transform',
open ? 'rotate-180' : '',
]"
/>
</template>
</Button>
</template>
{{ __('New') }}
</Button>
</Dropdown>
</div>
</div>
<div class="mt-8 pb-5">
<FormControl
v-if="evaluators.data?.length > 0 || search"
v-model="search"
:placeholder="__('Search')"
type="text"
@@ -31,7 +63,7 @@
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
</template>
</FormControl>
<div class="overflow-auto h-[60vh]">
<div class="overflow-auto max-h-[60vh]">
<div class="divide-y divide-outline-gray-modals">
<div
v-for="evaluator in evaluators.data"
@@ -40,7 +72,7 @@
>
<div class="flex items-center justify-between group py-3">
<div
class="flex items-center space-x-3"
class="flex items-center gap-x-3"
@click="openProfile(evaluator.username)"
>
<Avatar
@@ -70,11 +102,8 @@
</div>
</div>
</div>
<div
v-if="evaluators.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="evaluators.reload()">
<div v-if="evaluators.hasNextPage" class="flex justify-center mt-4">
<Button @click="evaluators.next()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
@@ -84,33 +113,12 @@
</div>
</div>
</div>
<Dialog
v-model="showForm"
:options="{
size: 'xl',
title: __('Add Evaluator'),
actions: [{
label: __('Add'),
variant: 'solid',
onClick({ close }: any) {
addEvaluator(close)
},
}]
}"
>
<template #body-content>
<div v-if="showForm" class="flex items-center">
<FormControl
v-model="email"
:label="__('Email')"
placeholder="jane@doe.com"
type="email"
class="w-full"
@keydown.enter="addEvaluator"
/>
</div>
</template>
</Dialog>
<AddEvaluatorModal v-model="showExistingUser" @added="evaluators.reload()" />
<NewMemberModal
v-model="showNewEvaluator"
:defaultRoles="['batch_evaluator']"
@created="onMemberCreated"
/>
</template>
<script setup lang="ts">
import {
@@ -118,18 +126,20 @@ import {
Button,
call,
createListResource,
Dialog,
Dropdown,
FormControl,
toast,
} from 'frappe-ui'
import { ref, watch } from 'vue'
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
import { Plus, Search, Trash2, RefreshCw, ChevronDown } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
import AddEvaluatorModal from '@/components/Modals/AddEvaluatorModal.vue'
const show = defineModel('show')
const search = ref('')
const showForm = ref(false)
const email = ref('')
const show = defineModel('show')
const showExistingUser = ref(false)
const showNewEvaluator = ref(false)
const router = useRouter()
const props = defineProps({
@@ -150,20 +160,8 @@ const evaluators = createListResource({
orderBy: 'creation desc',
})
const addEvaluator = (close: () => void) => {
call('lms.lms.api.add_an_evaluator', {
email: email.value,
})
.then(() => {
email.value = ''
evaluators.reload()
toast.success(__('Evaluator added successfully'))
close()
})
.catch((error: any) => {
toast.error(__(error.messages[0] || error.messages))
console.error('Error adding evaluator:', error)
})
const onMemberCreated = () => {
evaluators.reload()
}
watch(search, () => {
@@ -0,0 +1,200 @@
<template>
<Dialog
v-model="show"
:options="{
title:
accountID === 'new'
? __('New Google Meet Account')
: __('Edit Google Meet Account'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveAccount(close)
},
},
],
}"
>
<template #body-content>
<div class="mb-4">
<Switch
size="sm"
v-model="account.enabled"
:label="__('Enabled')"
:description="
__('Activate this Google Meet account for scheduling meetings.')
"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="account.name"
:label="__('Account Name')"
type="text"
:required="true"
/>
<Link
v-model="account.member"
:label="__('Member')"
doctype="Course Evaluator"
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
:required="true"
/>
<Link
v-model="account.google_calendar"
:label="__('Google Calendar')"
doctype="Google Calendar"
:required="true"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
import { inject, reactive, watch } from 'vue'
import { User } from '@/components/Settings/types'
import { openSettings, cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
import { useTelemetry } from 'frappe-ui/frappe'
interface GoogleMeetAccount {
name: string
account_name: string
enabled: boolean
member: string
google_calendar: string
}
interface GoogleMeetAccounts {
data: GoogleMeetAccount[]
reload: () => void
insert: {
submit: (
data: GoogleMeetAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
setValue: {
submit: (
data: GoogleMeetAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
}
const show = defineModel('show')
const user = inject<User | null>('$user')
const googleMeetAccounts = defineModel<GoogleMeetAccounts>('googleMeetAccounts')
const { capture } = useTelemetry()
const account = reactive({
name: '',
enabled: false,
member: user?.data?.name || '',
google_calendar: '',
})
const props = defineProps({
accountID: {
type: String,
default: 'new',
},
})
watch(
() => props.accountID,
(val) => {
if (val === 'new') {
account.name = ''
account.enabled = false
account.member = user?.data?.name || ''
account.google_calendar = ''
} else if (val && val !== 'new') {
const acc = googleMeetAccounts.value?.data.find((acc) => acc.name === val)
if (acc) {
account.name = acc.name
account.enabled = acc.enabled || false
account.member = acc.member
account.google_calendar = acc.google_calendar
}
}
}
)
const saveAccount = (close: () => void) => {
if (props.accountID == 'new') {
createAccount(close)
} else {
updateAccount(close)
}
}
const createAccount = (close: () => void) => {
googleMeetAccounts.value?.insert.submit(
{
account_name: account.name,
...account,
},
{
onSuccess() {
capture('google_meet_account_linked')
googleMeetAccounts.value?.reload()
close()
toast.success(__('Google Meet Account created successfully'))
},
onError(err) {
console.error(err)
close()
toast.error(
cleanError(err.messages[0]) ||
__('Error creating Google Meet Account')
)
},
}
)
}
const updateAccount = async (close: () => void) => {
if (props.accountID != account.name) {
await renameDoc()
}
setValue(close)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'LMS Google Meet Settings',
old_name: props.accountID,
new_name: account.name,
})
}
const setValue = (close: () => void) => {
googleMeetAccounts.value?.setValue.submit(
{
...account,
name: account.name,
account_name: props.accountID,
},
{
onSuccess() {
googleMeetAccounts.value?.reload()
close()
toast.success(__('Google Meet Account updated successfully'))
},
onError(err: any) {
console.error(err)
close()
toast.error(
cleanError(err.messages[0]) ||
__('Error updating Google Meet Account')
)
},
}
)
}
</script>
@@ -0,0 +1,202 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="flex items-center gap-x-5">
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div v-if="googleMeetAccounts.data?.length" class="overflow-y-auto">
<ListView
:columns="columns"
:rows="googleMeetAccounts.data"
row-key="name"
:options="{
showTooltip: false,
onRowClick: (row) => {
openForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in googleMeetAccounts.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="green">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAccount(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
<GoogleMeetAccountModal
v-model="showForm"
v-model:googleMeetAccounts="googleMeetAccounts"
:accountID="currentAccount"
/>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
Badge,
call,
createListResource,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import { User } from '@/components/Settings/types'
import GoogleMeetAccountModal from '@/components/Settings/GoogleMeetAccountModal.vue'
const user = inject<User | null>('$user')
const showForm = ref(false)
const currentAccount = ref<string | null>(null)
const props = defineProps({
label: String,
description: String,
})
const googleMeetAccounts = createListResource({
doctype: 'LMS Google Meet Settings',
fields: [
'name',
'enabled',
'member',
'member_name',
'member_image',
'google_calendar',
],
cache: ['googleMeetAccounts'],
})
onMounted(() => {
fetchGoogleMeetAccounts()
})
const fetchGoogleMeetAccounts = () => {
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
if (!user?.data?.is_moderator) {
googleMeetAccounts.update({
filters: {
member: user.data.name,
},
})
}
googleMeetAccounts.reload()
}
const openForm = (accountID: string) => {
currentAccount.value = accountID
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'LMS Google Meet Settings',
documents: Array.from(selections),
})
.then(() => {
googleMeetAccounts.reload()
toast.success(__('Google Meet Account deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting Google Meet Account')
)
})
}
const columns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
icon: 'user',
},
{
label: __('Account Name'),
key: 'name',
icon: 'video',
},
{
label: __('Status'),
key: 'enabled',
align: 'center',
icon: 'check-square',
},
]
})
</script>
+18 -81
View File
@@ -2,15 +2,15 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="flex item-center space-x-2">
<Button variant="solid" @click="() => (showForm = !showForm)">
<div class="flex item-center gap-x-2">
<Button @click="showNewMember = true">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
@@ -31,7 +31,7 @@
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
</template>
</FormControl>
<div class="overflow-y-scroll h-[60vh]">
<div class="overflow-y-auto max-h-[60vh]">
<ul class="divide-y divide-outline-gray-modals">
<li
v-for="member in memberList"
@@ -39,7 +39,7 @@
>
<div
@click="openProfile(member.username)"
class="flex items-center space-x-3 col-span-2"
class="flex items-center gap-x-3 col-span-2"
>
<Avatar
:image="member.user_image"
@@ -58,7 +58,7 @@
</div>
</div>
<div
class="flex items-center text-ink-gray-9 space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
class="flex items-center text-ink-gray-9 gap-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
v-if="member.role && member.role !== 'LMS Student'"
>
<Shield class="size-4 stroke-1.5" />
@@ -82,56 +82,16 @@
</div>
</div>
</div>
<Dialog
v-model="showForm"
:options="{
title: __('Add a new member'),
size: 'lg',
actions: [{
label: __('Add'),
variant: 'solid',
onClick({ close }: any) {
addMember(close)
}
}]
}"
>
<template #body-content>
<div class="flex items-center space-x-2">
<FormControl
v-model="member.email"
:label="__('Email')"
placeholder="jane@doe.com"
type="email"
class="w-full"
/>
<FormControl
v-model="member.first_name"
:label="__('First Name')"
placeholder="Jane"
type="text"
class="w-full"
/>
</div>
</template>
</Dialog>
<NewMemberModal v-model="showNewMember" @created="onMemberCreated" />
</template>
<script setup lang="ts">
import {
Avatar,
Button,
call,
createResource,
Dialog,
FormControl,
toast,
} from 'frappe-ui'
import { Avatar, Button, createResource, FormControl } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue'
import { ref, watch, inject } from 'vue'
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import type { User } from '@/components/Settings/types'
import { useTelemetry } from 'frappe-ui/frappe'
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
type Member = {
username: string
@@ -147,16 +107,11 @@ const search = ref('')
const start = ref(0)
const memberList = ref<Member[]>([])
const hasNextPage = ref(false)
const showForm = ref(false)
const showNewMember = ref(false)
const user = inject<User | null>('$user')
const { updateOnboardingStep } = useOnboarding('learning')
const { capture } = useTelemetry()
const member = reactive({
email: '',
first_name: '',
})
const props = defineProps({
label: {
type: String,
@@ -194,30 +149,12 @@ const openProfile = (username: string) => {
})
}
const addMember = (close: () => void) => {
call('frappe.client.insert', {
doc: {
doctype: 'User',
first_name: member.first_name,
email: member.email,
},
})
.then((data: Member) => {
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
capture('user_added')
show.value = false
router.push({
name: 'ProfileRoles',
params: {
username: data.username,
},
})
close()
})
.catch((err: any) => {
console.error(err)
toast.error(__(err.messages?.[0] || err))
})
const onMemberCreated = (data: any) => {
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
capture('user_added')
memberList.value = []
start.value = 0
members.reload()
}
watch(search, () => {
@@ -6,7 +6,7 @@
}"
>
<template #body-header>
<div class="text-lg font-semibold">
<div class="text-lg font-semibold text-ink-gray-9">
{{
gatewayID === 'new'
? __('New Payment Gateway')
@@ -36,7 +36,7 @@
</div>
</template>
<template #actions="{ close }">
<div class="pb-5 float-right">
<div class="pb-5 float-end">
<Button variant="solid" @click="saveSettings(close)">
{{ __('Save') }}
</Button>
@@ -131,7 +131,7 @@ watch(newGateway, () => {
let fields = gatewayFields.data || []
arrangeFields(fields)
newGatewayFields.value = makeSections(fields)
prepareGatewayData()
prepareGatewayData(fields)
})
})
@@ -209,13 +209,11 @@ const allGatewayOptions = computed(() => {
return options.map((gateway: string) => ({ label: gateway, value: gateway }))
})
const prepareGatewayData = () => {
const prepareGatewayData = (fields: any[]) => {
newGatewayData.value = {}
if (newGatewayFields.value.length) {
newGatewayFields.value.forEach((field: any) => {
newGatewayData.value[field.fieldname] = field.default || ''
})
}
fields.forEach((field: any) => {
newGatewayData.value[field.name] = field.default || ''
})
}
const makeSections = (fields: any[]) => {
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
@@ -17,7 +17,7 @@
</Button>
</div>
<div v-if="paymentGateways.data?.length" class="overflow-y-scroll">
<div v-if="paymentGateways.data?.length" class="overflow-y-auto">
<ListView
:columns="columns"
:rows="paymentGateways.data"
@@ -30,7 +30,7 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
@@ -88,6 +88,7 @@
import {
Badge,
Button,
call,
createListResource,
FeatherIcon,
ListView,
@@ -97,10 +98,12 @@ import {
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import PaymentGatewayDetails from '@/components/Settings/PaymentGatewayDetails.vue'
import { cleanError } from '@/utils'
const showForm = ref(false)
const currentGateway = ref(null)
@@ -128,6 +131,23 @@ const openForm = (gatewayID) => {
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'Payment Gateway',
documents: Array.from(selections),
})
.then(() => {
paymentGateways.reload()
toast.success(__('Payment gateways deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting payment gateways')
)
})
}
const columns = computed(() => {
return [
{
@@ -2,23 +2,25 @@
<div class="flex flex-col h-full text-base overflow-y-hidden">
<div class="">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold leading-none text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="flex items-center gap-x-2">
<Badge
v-if="data.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
@@ -6,7 +6,7 @@
</div>
<div
:class="{
'flex justify-between space-x-8 w-full': section.columns.length > 1,
'flex justify-between gap-x-8 w-full': section.columns.length > 1,
}"
>
<div
@@ -20,6 +20,7 @@
:doctype="field.doctype"
:label="__(field.label)"
:description="__(field.description)"
:required="field.reqd"
/>
<div v-else-if="field.type == 'Code'">
@@ -63,7 +64,7 @@
</template>
</FileUploader>
<div v-else>
<div class="flex items-center text-sm space-x-2">
<div class="flex items-center text-sm gap-x-2">
<div
class="flex items-center justify-center rounded border border-outline-gray-modals bg-surface-gray-2"
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
@@ -90,7 +91,7 @@
</div>
<X
@click="data[field.name] = null"
class="border text-ink-gray-7 border-outline-gray-modals rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
class="border text-ink-gray-7 border-outline-gray-modals rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ms-4"
/>
</div>
</div>
@@ -115,6 +116,7 @@
:rows="field.rows"
:options="field.options"
:description="field.description"
:required="field.reqd"
placeholder=""
/>
</div>
+111 -39
View File
@@ -31,7 +31,7 @@
<div
v-if="activeTab && data.doc"
:key="activeTab.label"
class="flex flex-1 flex-col p-8 bg-surface-modal overflow-x-auto"
class="flex flex-1 flex-col p-8 bg-surface-modal overflow-x-auto overflow-y-auto"
>
<component
v-if="activeTab.template"
@@ -42,8 +42,8 @@
...(activeTab.label == 'Branding'
? { sections: activeTab.sections }
: {}),
...(activeTab.label == 'Evaluators' ||
activeTab.label == 'Members' ||
...(activeTab.label == 'Members' ||
activeTab.label == 'Evaluators' ||
activeTab.label == 'Transactions'
? { 'onUpdate:show': (val) => (show = val), show }
: {}),
@@ -76,6 +76,7 @@ import PaymentGateways from '@/components/Settings/PaymentGateways.vue'
import Coupons from '@/components/Settings/Coupons/Coupons.vue'
import Transactions from '@/components/Settings/Transactions/Transactions.vue'
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
import GoogleMeetSettings from '@/components/Settings/GoogleMeetSettings.vue'
import Badges from '@/components/Settings/Badges.vue'
const show = defineModel()
@@ -219,6 +220,25 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Jobs',
columns: [
{
fields: [
{
label: 'Allow Job Posting',
name: 'allow_job_posting',
type: 'checkbox',
description:
'If enabled, users can post job openings on the job board. Else only admins can post jobs.',
},
],
},
{
fields: [],
},
],
},
{
label: '',
columns: [
@@ -249,34 +269,6 @@ const tabsStructure = computed(() => {
},
],
},
],
},
{
label: 'Lists',
hideLabel: false,
items: [
{
label: 'Members',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'UserRoundPlus',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: '',
icon: 'UserCheck',
description:
'Add new evaluators or check the slots existing evaluators',
template: markRaw(Evaluators),
},
{
label: 'Zoom Accounts',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Badges',
description:
@@ -298,6 +290,27 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Users',
hideLabel: false,
items: [
{
label: 'Members',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'User',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: '',
icon: 'UserCircle2',
description:
'Add new evaluators or check the slots of existing evaluators',
template: markRaw(Evaluators),
},
],
},
{
label: 'Payment',
hideLabel: false,
@@ -318,29 +331,62 @@ const tabsStructure = computed(() => {
doctype: 'Currency',
},
{
label: 'Payment Gateway',
name: 'payment_gateway',
type: 'Link',
doctype: 'Payment Gateway',
label: 'Show USD equivalent amount',
name: 'show_usd_equivalent',
type: 'checkbox',
description:
'If enabled, it shows the USD equivalent amount for all transactions based on the current exchange rate.',
},
{
label: 'Apply rounding on equivalent',
name: 'apply_rounding',
type: 'checkbox',
description:
'If enabled, it applies rounding on the USD equivalent amount.',
},
],
},
{
fields: [
{
label: 'Payment Gateway',
name: 'payment_gateway',
type: 'Link',
doctype: 'Payment Gateway',
},
{
label: 'Apply GST for India',
name: 'apply_gst',
type: 'checkbox',
description:
'If enabled, GST will be applied to the price for students from India.',
},
],
},
],
},
{
label: 'Payment Reminders',
columns: [
{
fields: [
{
label: 'Show USD equivalent amount',
name: 'show_usd_equivalent',
label: 'Send payment reminders for batch',
name: 'send_payment_reminders_for_batch',
type: 'checkbox',
description:
'If enabled, it sends payment reminders to students who left the payment incomplete for a batch.',
},
],
},
{
fields: [
{
label: 'Apply rounding on equivalent',
name: 'apply_rounding',
label: 'Send payment reminders for course',
name: 'send_payment_reminders_for_course',
type: 'checkbox',
description:
'If enabled, it sends payment reminders to students who left the payment incomplete for a course.',
},
],
},
@@ -368,6 +414,26 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Conferencing',
hideLabel: false,
items: [
{
label: 'Zoom',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Google Meet',
description:
'Manage Google Meet accounts to conduct live classes from batches',
icon: 'Presentation',
template: markRaw(GoogleMeetSettings),
},
],
},
{
label: 'Customize',
hideLabel: false,
@@ -375,6 +441,8 @@ const tabsStructure = computed(() => {
{
label: 'Branding',
icon: 'Blocks',
description:
'Customize the brand name and logo to make the application your own',
template: markRaw(BrandSettings),
sections: [
{
@@ -463,6 +531,8 @@ const tabsStructure = computed(() => {
{
label: 'Signup',
icon: 'LogIn',
description:
'Manage the settings related to user signup and registration',
sections: [
{
columns: [
@@ -498,6 +568,8 @@ const tabsStructure = computed(() => {
{
label: 'SEO',
icon: 'Search',
description:
'Manage the SEO settings to improve your website ranking on search engines',
sections: [
{
columns: [
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col h-full text-base">
<div class="flex items-center justify-between mb-10 -ml-1.5">
<div class="flex items-center space-x-2">
<div class="flex items-center justify-between mb-10 -ms-1.5">
<div class="flex items-center gap-x-2">
<ChevronLeft
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="emit('updateStep', 'list')"
@@ -10,7 +10,7 @@
{{ __('Transaction Details') }}
</div>
</div>
<div class="space-x-2">
<div class="flex items-center gap-x-2">
<Button
v-if="
transactionData?.payment_for_document_type &&
@@ -55,17 +55,18 @@
:label="__('Member')"
doctype="User"
v-model="transactionData.member"
:required="true"
:required="!!fieldMeta.member?.reqd"
/>
<FormControl
:label="__('Billing Name')"
v-model="transactionData.billing_name"
:required="true"
:required="!!fieldMeta.billing_name?.reqd"
/>
<Link
:label="__('Source')"
v-model="transactionData.source"
doctype="LMS Source"
:required="!!fieldMeta.source?.reqd"
/>
<FormControl
type="select"
@@ -73,16 +74,18 @@
:label="__('Payment For Document Type')"
v-model="transactionData.payment_for_document_type"
doctype="DocType"
:required="!!fieldMeta.payment_for_document_type?.reqd"
/>
<Link
v-if="transactionData.payment_for_document_type"
:label="__('Payment For Document')"
v-model="transactionData.payment_for_document"
:doctype="transactionData.payment_for_document_type"
:required="!!fieldMeta.payment_for_document?.reqd"
/>
</div>
<div class="font-semibold mt-10">
<div class="font-semibold mt-10 text-ink-gray-9">
{{ __('Payment Details') }}
</div>
<div class="grid grid-cols-3 gap-5 mt-5">
@@ -90,22 +93,23 @@
:label="__('Currency')"
v-model="transactionData.currency"
doctype="Currency"
:required="true"
:required="!!fieldMeta.currency?.reqd"
/>
<FormControl
:label="__('Amount')"
v-model="transactionData.amount"
:required="true"
:required="!!fieldMeta.amount?.reqd"
/>
<FormControl
v-if="transactionData.amount_with_gst"
:label="__('Amount with GST')"
v-model="transactionData.amount_with_gst"
:required="!!fieldMeta.amount_with_gst?.reqd"
/>
</div>
<div v-if="transactionData.coupon">
<div class="font-semibold mt-10">
<div class="font-semibold mt-10 text-ink-gray-9">
{{ __('Coupon Details') }}
</div>
<div class="grid grid-cols-3 gap-5 mt-5">
@@ -113,26 +117,30 @@
v-if="transactionData.coupon"
:label="__('Coupon Code')"
v-model="transactionData.coupon"
:required="!!fieldMeta.coupon?.reqd"
/>
<FormControl
v-if="transactionData.coupon"
:label="__('Coupon Code')"
v-model="transactionData.coupon_code"
:required="!!fieldMeta.coupon_code?.reqd"
/>
<FormControl
v-if="transactionData.coupon"
:label="__('Discount Amount')"
v-model="transactionData.discount_amount"
:required="!!fieldMeta.discount_amount?.reqd"
/>
<FormControl
v-if="transactionData.coupon"
:label="__('Original Amount')"
v-model="transactionData.original_amount"
:required="!!fieldMeta.original_amount?.reqd"
/>
</div>
</div>
<div class="font-semibold mt-10">
<div class="font-semibold mt-10 text-ink-gray-9">
{{ __('Billing Details') }}
</div>
<div class="grid grid-cols-3 gap-5 mt-5">
@@ -140,24 +148,34 @@
:label="__('Address')"
v-model="transactionData.address"
doctype="Address"
:required="true"
:required="!!fieldMeta.address?.reqd"
/>
<FormControl
:label="__('GSTIN')"
v-model="transactionData.gstin"
:required="!!fieldMeta.gstin?.reqd"
/>
<FormControl
:label="__('PAN')"
v-model="transactionData.pan"
:required="!!fieldMeta.pan?.reqd"
/>
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
<FormControl :label="__('PAN')" v-model="transactionData.pan" />
<FormControl
:label="__('Payment ID')"
v-model="transactionData.payment_id"
:required="!!fieldMeta.payment_id?.reqd"
/>
<FormControl
:label="__('Order ID')"
v-model="transactionData.order_id"
:required="!!fieldMeta.order_id?.reqd"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Button, FormControl, toast } from 'frappe-ui'
import { Button, FormControl, Switch, toast } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { computed, ref, watch } from 'vue'
import { ChevronLeft } from 'lucide-vue-next'
@@ -171,6 +189,10 @@ const show = defineModel('show')
const props = defineProps<{
transactions: any
data: any
fieldMeta: Record<
string,
{ reqd?: number; default?: string; description?: string }
>
}>()
const saveTransaction = () => {
@@ -211,48 +233,49 @@ const updateTransaction = () => {
}
const openDetails = () => {
if (props.data) {
const docType = props.data.payment_for_document_type
const docName = props.data.payment_for_document
if (docType && docName) {
router.push({
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
params: {
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
},
})
}
const docType = transactionData.value?.payment_for_document_type
const docName = transactionData.value?.payment_for_document
if (docType && docName) {
router.push({
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
params: {
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
},
})
show.value = false
}
}
const emptyTransactionData = {
const getDefault = (fieldname: string) =>
props.fieldMeta[fieldname]?.default || null
const getEmptyTransactionData = () => ({
payment_received: false,
payment_for_certificate: false,
member: null,
billing_name: null,
source: null,
payment_for_document_type: null,
payment_for_document: null,
member: getDefault('member'),
billing_name: getDefault('billing_name'),
source: getDefault('source'),
payment_for_document_type: getDefault('payment_for_document_type'),
payment_for_document: getDefault('payment_for_document'),
member_consent: false,
currency: null,
amount: null,
amount_with_gst: null,
coupon: null,
coupon_code: null,
discount_amount: null,
original_amount: null,
order_id: null,
payment_id: null,
gstin: null,
pan: null,
address: null,
}
currency: getDefault('currency'),
amount: getDefault('amount'),
amount_with_gst: getDefault('amount_with_gst'),
coupon: getDefault('coupon'),
coupon_code: getDefault('coupon_code'),
discount_amount: getDefault('discount_amount'),
original_amount: getDefault('original_amount'),
order_id: getDefault('order_id'),
payment_id: getDefault('payment_id'),
gstin: getDefault('gstin'),
pan: getDefault('pan'),
address: getDefault('address'),
})
watch(
() => props.data,
(newVal) => {
transactionData.value = newVal ? { ...newVal } : emptyTransactionData
transactionData.value = newVal ? { ...newVal } : getEmptyTransactionData()
},
{ immediate: true }
)
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
@@ -17,7 +17,7 @@
</Button>
</div>
<div class="flex items-center space-x-5 mb-4">
<div class="flex items-center gap-x-5 mb-4">
<FormControl
v-model="billingName"
:placeholder="__('Filter by Billing Name')"
@@ -39,21 +39,21 @@
/>
</div>
<div v-if="transactions.data?.length" class="overflow-y-scroll">
<div v-if="transactions.data?.length" class="overflow-y-auto">
<ListView
:columns="columns"
:rows="transactions.data"
row-key="name"
:options="{
showTooltip: false,
selectable: false,
onRowClick: (row: { [key: string]: any }) => {
openForm(row)
},
}"
showTooltip: false,
selectable: false,
onRowClick: (row: { [key: string]: any }) => {
openForm(row)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
@@ -116,6 +116,7 @@ import {
ListRow,
ListRowItem,
FormControl,
Switch,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
@@ -3,6 +3,7 @@
v-if="step == 'new'"
:transactions="transactions"
:data="data"
:fieldMeta="fieldMeta.data || {}"
v-model:show="show"
@updateStep="updateStep"
/>
@@ -17,13 +18,14 @@
v-else-if="step == 'details'"
:transactions="transactions"
:data="data"
:fieldMeta="fieldMeta.data || {}"
v-model:show="show"
@updateStep="updateStep"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { createListResource } from 'frappe-ui'
import { createListResource, createResource } from 'frappe-ui'
import TransactionList from '@/components/Settings/Transactions/TransactionList.vue'
import TransactionDetails from '@/components/Settings/Transactions/TransactionDetails.vue'
@@ -45,6 +47,11 @@ const updateStep = (newStep: 'list' | 'new' | 'edit', newData: any) => {
}
}
const fieldMeta = createResource({
url: 'lms.lms.api.get_payment_field_meta',
auto: true,
})
const transactions = createListResource({
doctype: 'LMS Payment',
fields: [
@@ -6,10 +6,10 @@
{{ label }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
{{ __(description || '') }}
</div>
</div>
<div class="flex items-center space-x-5">
<div class="flex items-center gap-x-5">
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
@@ -18,7 +18,7 @@
</Button>
</div>
</div>
<div v-if="zoomAccounts.data?.length" class="overflow-y-scroll">
<div v-if="zoomAccounts.data?.length" class="overflow-y-auto">
<ListView
:columns="columns"
:rows="zoomAccounts.data"
@@ -31,7 +31,7 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
@@ -90,6 +90,7 @@
</div>
</div>
<ZoomAccountModal
v-if="showForm"
v-model="showForm"
v-model:zoomAccounts="zoomAccounts"
:accountID="currentAccount"
@@ -100,7 +101,6 @@ import {
Avatar,
Button,
Badge,
call,
createListResource,
FeatherIcon,
ListView,
@@ -112,20 +112,18 @@ import {
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import { User } from '@/components/Settings/types'
import ZoomAccountModal from '@/components/Modals/ZoomAccountModal.vue'
const user = inject<User | null>('$user')
const showForm = ref(false)
const currentAccount = ref<string | null>(null)
const props = defineProps({
label: String,
description: String,
})
const props = defineProps<{
label: string
description?: string
}>()
const zoomAccounts = createListResource({
doctype: 'LMS Zoom Settings',
@@ -147,15 +145,6 @@ onMounted(() => {
})
const fetchZoomAccounts = () => {
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
if (!user?.data?.is_moderator) {
zoomAccounts.update({
filters: {
member: user.data.name,
},
})
}
zoomAccounts.reload()
}
@@ -164,21 +153,20 @@ const openForm = (accountID: string) => {
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'LMS Zoom Settings',
documents: Array.from(selections),
const removeAccount = (selections: Set<string>, unselectAll: () => void) => {
Array.from(selections).forEach((accountID) => {
zoomAccounts.delete.submit(accountID, {
onSuccess() {
toast.success(__('Zoom account deleted successfully'))
fetchZoomAccounts()
unselectAll()
},
onError(err: any) {
toast.error(cleanError(err.messages[0] || err))
console.error(err)
},
})
})
.then(() => {
zoomAccounts.reload()
toast.success(__('Email Templates deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting email templates')
)
})
}
const columns = computed(() => {
+129 -19
View File
@@ -1,10 +1,10 @@
<template>
<div
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out border-r bg-surface-menu-bar"
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out border-e bg-surface-menu-bar overflow-x-hidden"
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
>
<div
class="flex flex-col overflow-hidden"
class="flex flex-col overflow-y-auto"
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
>
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
@@ -31,21 +31,24 @@
class="mt-4"
>
<div
class="flex items-center justify-between pr-2 cursor-pointer"
:class="sidebarStore.isSidebarCollapsed ? 'pl-3' : 'pl-4'"
class="flex items-center justify-between pe-2 cursor-pointer"
:class="sidebarStore.isSidebarCollapsed ? 'ps-3' : 'ps-4'"
@click="toggleWebPages"
>
<div
v-if="!sidebarStore.isSidebarCollapsed"
class="flex items-center text-sm text-ink-gray-5 my-1"
class="flex items-center text-ink-gray-5 my-1"
>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<ChevronRight
class="h-4 w-4 stroke-1.5 text-ink-gray-9 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': !sidebarStore.isWebpagesCollapsed }"
:class="{
'rotate-90': sidebarStore.isWebpagesCollapsed,
'rtl:rotate-180': !sidebarStore.isWebpagesCollapsed,
}"
/>
</span>
<span class="ml-2">
<span class="ms-2">
{{ __('More') }}
</span>
</div>
@@ -90,6 +93,56 @@
)
}}
</div>
<div
v-if="
isStudent && !profileIsComplete && !sidebarStore.isSidebarCollapsed
"
class="flex flex-col gap-3 text-ink-gray-9 py-2.5 px-3 bg-surface-white shadow-sm rounded-md"
>
<div class="flex flex-col text-p-sm gap-1">
<div class="inline-flex gap-1">
<User class="h-4 my-0.5 shrink-0" />
<div class="font-medium">
{{ __('Complete your profile') }}
</div>
</div>
<div class="text-ink-gray-7 leading-5">
{{ __('Highlight what makes you unique and show your skills.') }}
</div>
</div>
<router-link
:to="{
name: 'Profile',
params: {
username: userResource.data?.username,
},
}"
>
<Button :label="__('My Profile')" class="w-full">
<template #prefix>
<ChevronsRight class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
</template>
</Button>
</router-link>
</div>
<Tooltip
v-if="
isStudent && !profileIsComplete && sidebarStore.isSidebarCollapsed
"
:text="__('Complete your profile')"
>
<router-link
:to="{
name: 'Profile',
params: {
username: userResource.data?.username,
},
}"
class="flex items-center justify-center"
>
<User class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer" />
</router-link>
</Tooltip>
<TrialBanner
v-if="
userResource.data?.is_system_manager && userResource.data?.is_fc_site
@@ -109,12 +162,8 @@
"
>
<div
class="flex items-center flex-1"
:class="
sidebarStore.isSidebarCollapsed
? 'flex-col space-y-3'
: 'flex-row space-x-3'
"
class="flex items-center flex-1 gap-3"
:class="sidebarStore.isSidebarCollapsed ? 'flex-col' : 'flex-row'"
>
<Tooltip v-if="readOnlyMode && sidebarStore.isSidebarCollapsed">
<CircleAlert
@@ -132,10 +181,13 @@
</div>
</template>
</Tooltip>
<Tooltip :text="__('Powered by Learning')">
<Zap
<Tooltip
v-if="showAppointmentIcon"
:text="__('Book a free onboarding session with the Frappe team')"
>
<Phone
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="redirectToWebsite()"
@click="redirectToAppointmentScreen()"
/>
</Tooltip>
<Tooltip v-if="showOnboarding" :text="__('Help')">
@@ -149,6 +201,12 @@
"
/>
</Tooltip>
<Tooltip :text="__('Powered by Frappe Learning')">
<Zap
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="redirectToWebsite()"
/>
</Tooltip>
</div>
<Tooltip
:text="
@@ -157,8 +215,11 @@
>
<CollapseSidebar
class="size-4 text-ink-gray-7 duration-300 stroke-1.5 ease-in-out cursor-pointer"
:class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
:style="{
transform:
isRtl !== sidebarStore.isSidebarCollapsed
? 'rotateY(180deg)'
: '',
}"
@click="toggleSidebar()"
/>
@@ -166,6 +227,7 @@
</div>
</div>
<HelpModal
data-testid="onboarding-help-modal"
v-if="showOnboarding && showHelpModal"
v-model="showHelpModal"
v-model:articles="articles"
@@ -210,15 +272,19 @@ import {
markRaw,
h,
onUnmounted,
computed,
} from 'vue'
import {
BookOpen,
CircleAlert,
ChevronRight,
Plus,
ChevronsRight,
CircleHelp,
FolderTree,
FileText,
Phone,
Plus,
User,
UserPlus,
Users,
BookText,
@@ -260,6 +326,7 @@ const router = useRouter()
let onboardingDetails
let isOnboardingStepsCompleted = false
const readOnlyMode = window.read_only_mode
const isRtl = document.documentElement.dir === 'rtl'
const iconProps = {
strokeWidth: 1.5,
width: 16,
@@ -607,12 +674,55 @@ watch(settingsStore.settings, () => {
const updateSidebarLinks = () => {
sidebarLinks.value = getSidebarLinks()
updateSidebarLinksVisibility()
updateUnreadCount()
}
const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
const isStudent = computed(() => {
return userResource.data?.is_student
})
const profileIsComplete = computed(() => {
return (
userResource.data?.user_image &&
userResource.data?.headline &&
userResource.data?.bio
)
})
const showAppointmentIcon = computed(() => {
let isTrialPlan = userResource.data?.site_info?.plan?.is_trial_plan
let trialEndDate = calculateTrialEndDays(
userResource.data?.site_info?.trial_end_date
)
return (
userResource.data?.is_system_manager &&
userResource.data?.is_fc_site &&
isTrialPlan &&
trialEndDate > 0
)
})
const calculateTrialEndDays = (trialEndDate) => {
if (!trialEndDate) return 0
trialEndDate = new Date(trialEndDate)
const today = new Date()
const diffTime = trialEndDate - today
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return diffDays
}
const redirectToAppointmentScreen = () => {
window.open(
'https://calendar.google.com/calendar/u/0/appointments/schedules/AcZssZ0c7Z3XIpW1WgbeIuktSaoX6qudoYuSdRbIlJty5TW7p4IZaOk5viHQGwTNi6HpNVqzOZOTHcle',
'_blank'
)
}
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
+1 -1
View File
@@ -48,7 +48,7 @@ const apps = createResource({
name: 'frappe',
logo: '/assets/lms/images/desk.png',
title: __('Desk'),
route: '/desk/lms',
route: '/desk/learning',
},
]
data.map((app) => {
@@ -25,18 +25,18 @@
class="flex-shrink-0 text-sm duration-300 ease-in-out"
:class="
isCollapsed
? 'ml-0 w-0 overflow-hidden opacity-0'
: 'ml-2 w-auto opacity-100'
? 'ms-0 w-0 overflow-hidden opacity-0'
: 'ms-2 w-auto opacity-100'
"
>
{{ __(link.label) }}
</span>
<span
v-if="link.count && !isCollapsed"
class="!ml-auto block text-xs text-ink-gray-5"
class="!ms-auto block text-xs text-ink-gray-5"
:class="
isCollapsed && link.count > 9
? 'absolute top-[2px] right-0 bg-surface-white'
? 'absolute top-[2px] end-0 bg-surface-white'
: ''
"
>
@@ -44,7 +44,7 @@
</span>
<div
v-if="showControls && !isCollapsed"
class="flex items-center space-x-2 !ml-auto block text-xs text-ink-gray-5 group-hover:visible invisible"
class="flex items-center gap-x-2 !ms-auto block text-xs text-ink-gray-5 group-hover:visible invisible"
>
<component
:is="icons['Edit']"
@@ -19,11 +19,11 @@
/>
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
<div
class="flex flex-1 flex-col text-left duration-300 ease-in-out"
class="flex flex-1 flex-col text-start duration-300 ease-in-out"
:class="
isCollapsed
? 'opacity-0 ml-0 w-0 overflow-hidden'
: 'opacity-100 ml-2 w-auto'
? 'opacity-0 ms-0 w-0 overflow-hidden'
: 'opacity-100 ms-2 w-auto'
"
>
<div class="text-base font-medium text-ink-gray-9 leading-none">
@@ -47,8 +47,8 @@
class="duration-300 ease-in-out"
:class="
isCollapsed
? 'opacity-0 ml-0 w-0 overflow-hidden'
: 'opacity-100 ml-2 w-auto'
? 'opacity-0 ms-0 w-0 overflow-hidden'
: 'opacity-100 ms-2 w-auto'
"
>
<ChevronDown class="h-4 w-4 text-ink-gray-7" />
@@ -65,9 +65,10 @@
<script setup>
import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui'
import { call, Dropdown, toast } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '@/utils'
import { applyTheme, toggleTheme, theme } from '@/utils/theme'
import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref, onMounted, computed } from 'vue'
@@ -85,7 +86,7 @@ import {
User,
Settings,
Sun,
Zap,
Trash2,
} from 'lucide-vue-next'
const router = useRouter()
@@ -94,7 +95,6 @@ let { userResource } = usersStore()
const settingsStore = useSettings()
let { isLoggedIn } = sessionStore()
const showSettingsModal = ref(false)
const theme = ref('light')
const frappeCloudBaseEndpoint = 'https://frappecloud.com'
const $dialog = createDialog
@@ -106,9 +106,8 @@ const props = defineProps({
})
onMounted(() => {
theme.value = localStorage.getItem('theme') || 'light'
if (['light', 'dark'].includes(theme.value)) {
document.documentElement.setAttribute('data-theme', theme.value)
applyTheme(theme.value)
}
})
@@ -119,13 +118,6 @@ watch(
}
)
const toggleTheme = () => {
const currentTheme = document.documentElement.getAttribute('data-theme')
theme.value = currentTheme === 'dark' ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', theme.value)
localStorage.setItem('theme', theme.value)
}
const userDropdownOptions = computed(() => {
return [
{
@@ -175,6 +167,19 @@ const userDropdownOptions = computed(() => {
return userResource.data?.is_moderator
},
},
{
label: 'Clear Demo Data',
icon: Trash2,
onClick: () => {
clearDemoDataConfirmation()
},
condition: () => {
return (
userResource.data?.is_moderator &&
settingsStore.settings.data?.demo_data_present
)
},
},
{
icon: FrappeCloudIcon,
label: 'Login to Frappe Cloud',
@@ -234,4 +239,36 @@ const loginToFrappeCloud = () => {
let redirect_to = '/dashboard/sites/' + userResource.data.sitename
window.open(`${frappeCloudBaseEndpoint}${redirect_to}`, '_blank')
}
const clearDemoDataConfirmation = () => {
$dialog({
title: __('Confirm clearing demo data?'),
message: __(
'Are you sure you want to clear the demo data? This would delete the course "A guide to Frappe Learning" along with all its associated data. This action cannot be undone.'
),
actions: [
{
label: __('Confirm'),
theme: 'red',
variant: 'solid',
onClick(close) {
clearDemoData()
close()
},
},
],
})
}
const clearDemoData = () => {
call('lms.lms.api.clear_demo_data')
.then(() => {
window.location.href = '/lms'
toast.success(__('Demo data cleared successfully'))
})
.catch((error) => {
toast.error(__(error.message || 'Error clearing demo data'))
console.error('Error clearing demo data:', error)
})
}
</script>
+2 -2
View File
@@ -7,11 +7,11 @@
{{ tags }}
<div
v-for="tag in tags?.split(', ')"
class="flex items-center bg-surface-gray-2 p-2 rounded-md mr-2"
class="flex items-center bg-surface-gray-2 p-2 rounded-md me-2"
>
{{ tag }}
<X
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
class="stroke-1.5 w-3 h-3 ms-2 cursor-pointer"
@click="removeTag(tag)"
/>
</div>

Some files were not shown because too many files have changed in this diff Show More