Compare commits

...

386 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
219 changed files with 68550 additions and 31751 deletions
+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 }}"
+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;
},
},
});
+26 -16
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,7 +52,7 @@ 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.get("label").contains("Title").type("Test Batch");
cy.get("label").contains("Start Date").type("2030-10-01");
@@ -65,7 +65,7 @@ describe("Batch Creation", () => {
cy.get("label")
.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."
);
@@ -74,7 +74,7 @@ describe("Batch Creation", () => {
.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");
@@ -87,7 +87,20 @@ describe("Batch Creation", () => {
});
});
cy.button("Save").click();
cy.get("label").contains("Published").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;
@@ -105,11 +118,7 @@ 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/${batchName}'`).within(() => {
@@ -154,6 +163,7 @@ describe("Batch Creation", () => {
cy.get("button:visible").contains("Dashboard").click();
/* Add student to batch */
cy.closeOnboardingModal();
cy.get("button").contains("Enroll").click();
cy.get('div[role="dialog"]')
.first()
+35 -16
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")
@@ -54,7 +61,7 @@ describe("Course Creation", () => {
});
cy.button("Save").last().click();
cy.closeOnboardingModal();
// Edit Course Details
cy.wait(500);
cy.get("label")
@@ -74,10 +81,10 @@ describe("Course Creation", () => {
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(() => {
@@ -86,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."
@@ -99,7 +104,7 @@ describe("Course Creation", () => {
cy.button("Save").click();
// View Course
cy.wait(1000);
cy.wait(500);
cy.visit("/lms/courses");
cy.closeOnboardingModal();
@@ -133,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");
@@ -148,7 +153,7 @@ describe("Course Creation", () => {
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."
);
@@ -158,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."
);
@@ -166,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
+2
View File
@@ -8,6 +8,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
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']
@@ -78,6 +79,7 @@ declare module 'vue' {
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 -2
View File
@@ -31,7 +31,7 @@
"dayjs": "1.11.10",
"dompurify": "3.2.6",
"feather-icons": "4.28.0",
"frappe-ui": "^0.1.264",
"frappe-ui": "^0.1.276",
"highlight.js": "11.11.1",
"lucide-vue-next": "0.383.0",
"markdown-it": "14.0.0",
@@ -49,7 +49,7 @@
"vuedraggable": "4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "5.0.3",
"@vitejs/plugin-vue": "5.0.3",
"autoprefixer": "10.4.2",
"postcss": "8.4.5",
"tailwindcss": "^3.4.15",
+4 -3
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,7 +62,7 @@
</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 { getLmsRoute } from '@/utils/basePath'
+38 -32
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">
@@ -17,7 +17,7 @@
</div>
</div>
<div class="text-ink-gray-9 font-semibold mb-5">
{{ __('Assignment Question') }}
{{ __('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>
@@ -43,7 +43,7 @@
{{ submissionResource.doc?.status }}
</Badge>
<Button
v-if="canModifyAssignment"
v-if="canModifyAssignment || canGradeSubmission"
variant="solid"
@click="submitAssignment()"
>
@@ -106,7 +106,7 @@
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>
@@ -117,7 +117,7 @@
<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>
@@ -281,7 +281,6 @@ const submissionResource = createDocumentResource({
watch(submissionResource, () => {
if (!submissionResource.doc) return
console.log(submissionResource.doc)
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
@@ -301,7 +300,7 @@ const submitAssignment = () => {
}
}
const addNewSubmission = () => {
const prepareSubmissionDoc = () => {
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentID,
@@ -312,24 +311,31 @@ const addNewSubmission = () => {
} else {
doc.assignment_attachment = attachment.value
}
return doc
}
const addNewSubmission = () => {
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'))
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()
}
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()
@@ -373,15 +379,17 @@ const saveSubmission = (file) => {
}
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],
})
}
}
@@ -405,7 +413,7 @@ const getType = () => {
const removeSubmission = () => {
isDirty.value = true
submissionResource.doc.assignment_attachment = ''
attachment.value = null
}
const canGradeSubmission = computed(() => {
@@ -419,9 +427,7 @@ const canGradeSubmission = computed(() => {
})
const canModifyAssignment = computed(() => {
if (canGradeSubmission.value) {
return true
} else if (props.submissionName == 'new') {
if (props.submissionName == 'new') {
return true
} else if (
submissionResource.doc?.owner == user.data?.name &&
+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" />
@@ -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>
@@ -9,7 +9,11 @@
nullable
v-slot="{ open: isComboboxOpen }"
>
<Popover class="w-full" v-model:show="showOptions">
<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">
@@ -61,7 +65,7 @@
placeholder="Search"
/>
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
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" />
@@ -92,7 +96,8 @@
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
'flex items-center rounded px-2.5 text-base py-1.5',
optionLines(option).secondary ? '' : 'h-7',
{ 'bg-surface-gray-2': active },
]"
>
@@ -104,16 +109,20 @@
name="item-label"
v-bind="{ active, selected, option }"
>
<div class="flex flex-col gap-1 p-1">
<div
class="flex flex-col px-1"
:class="
optionLines(option).secondary ? 'gap-0.5' : ''
"
>
<div class="text-base font-medium text-ink-gray-8">
{{
option.value == option.label && option.description
? option.description
: option.label
}}
{{ optionLines(option).primary }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
<div
v-if="optionLines(option).secondary"
class="text-sm text-ink-gray-5"
>
{{ optionLines(option).secondary }}
</div>
</div>
</slot>
@@ -245,6 +254,17 @@ function filterOptions(options) {
})
}
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 }
}
function displayValue(option) {
if (typeof option === 'string') {
let allOptions = groups.value.flatMap((group) => group.items)
@@ -288,9 +308,9 @@ const inputClasses = computed(() => {
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'
@@ -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"
+74 -18
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,11 +105,25 @@ 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),
@@ -105,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) => {
@@ -140,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) => {
@@ -153,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()
@@ -178,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)"
/>
+16 -5
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) }}
@@ -45,7 +46,7 @@
<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 {
@@ -96,14 +96,14 @@
</div>
<div class="flex items-center text-ink-gray-9">
<BookOpen class="h-4 w-4 stroke-1.5" />
<span class="ml-2">
<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) }}
{{
course.data.enrollments > 1
@@ -117,7 +117,7 @@
class="flex items-center text-ink-gray-9"
>
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
<span class="ml-2">
<span class="ms-2">
{{ course.data.rating }} {{ __('average rating') }}
</span>
</div>
@@ -126,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>
@@ -135,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] &&
+3 -3
View File
@@ -3,7 +3,7 @@
<Button
v-if="membership && !hasReviewed.data"
@click="openReviewModal()"
class="float-right"
class="float-end"
>
{{ __('Write a Review') }}
</Button>
@@ -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"
+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 -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,
+2 -2
View File
@@ -1,7 +1,7 @@
<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 are Instructor Notes?') }}
</span>
@@ -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>
+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>
@@ -48,7 +48,7 @@
</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,7 +72,7 @@
<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 { sanitizeHTML } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
@@ -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)
}
@@ -51,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">
@@ -64,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"
@@ -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>
@@ -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') }}
@@ -66,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,7 +2,7 @@
<Dialog
v-model="show"
:options="{
size: '4xl',
size: '5xl',
}"
>
<template #body>
@@ -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
@@ -186,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>
+4 -3
View File
@@ -72,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>
@@ -104,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>
@@ -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>
@@ -18,7 +18,7 @@
<!-- <FormControl
v-model="searchText"
:placeholder="__('Search by Member')"
class="mt-2 mr-5 w-[25%]"
class="mt-2 me-5 w-[25%]"
/> -->
</div>
<div
@@ -50,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"
@@ -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'
+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 }}
+366 -95
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) {
@@ -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 {
@@ -545,7 +775,7 @@ const checkAnswer = () => {
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) {
@@ -571,12 +801,13 @@ const addToLocalStorage = () => {
question_name: currentQuestion.value,
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 {
@@ -586,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
}
@@ -605,7 +833,6 @@ const resetQuestion = () => {
const submitQuiz = () => {
if (!quiz.data.show_answers) {
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
else checkAnswer()
setTimeout(() => {
createSubmission()
}, 500)
@@ -639,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()
@@ -669,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 [
{
@@ -697,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 }">
@@ -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'
+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 }">
@@ -10,7 +10,7 @@
{{ __(description) }}
</div>
</div>
<div class="space-x-2">
<div class="flex items-center gap-x-2">
<Badge
v-if="isDirty"
:label="__('Not Saved')"
@@ -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">
@@ -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>
+56 -58
View File
@@ -9,18 +9,50 @@
{{ __(description) }}
</div>
</div>
<div class="flex item-center space-x-2">
<Button @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"
@@ -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, () => {
@@ -20,10 +20,13 @@
>
<template #body-content>
<div class="mb-4">
<FormControl
<Switch
size="sm"
v-model="account.enabled"
:label="__('Enabled')"
type="checkbox"
:description="
__('Activate this Google Meet account for scheduling meetings.')
"
/>
</div>
<div class="grid grid-cols-2 gap-5">
@@ -51,7 +54,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'
@@ -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="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
@@ -18,7 +18,7 @@
</Button>
</div>
</div>
<div v-if="googleMeetAccounts.data?.length" class="overflow-y-scroll">
<div v-if="googleMeetAccounts.data?.length" class="overflow-y-auto">
<ListView
:columns="columns"
:rows="googleMeetAccounts.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 }">
+17 -80
View File
@@ -9,8 +9,8 @@
{{ __(description) }}
</div>
</div>
<div class="flex item-center space-x-2">
<Button @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 max-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>
@@ -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 }">
@@ -10,7 +10,7 @@
{{ __(description) }}
</div>
</div>
<div class="space-x-2">
<div class="flex items-center gap-x-2">
<Badge
v-if="data.isDirty"
:label="__('Not Saved')"
@@ -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
@@ -64,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'"
@@ -91,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>
+44 -11
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 }
: {}),
@@ -331,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.',
},
],
},
@@ -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 &&
@@ -85,7 +85,7 @@
/>
</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">
@@ -109,7 +109,7 @@
</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">
@@ -140,7 +140,7 @@
</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">
@@ -175,7 +175,7 @@
</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'
@@ -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'
@@ -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="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 }">
+65 -20
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">
<ChevronsRight
<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>
@@ -159,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
@@ -182,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')">
@@ -199,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="
@@ -207,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()"
/>
@@ -216,6 +227,7 @@
</div>
</div>
<HelpModal
data-testid="onboarding-help-modal"
v-if="showOnboarding && showHelpModal"
v-model="showHelpModal"
v-model:articles="articles"
@@ -267,10 +279,11 @@ import {
CircleAlert,
ChevronRight,
ChevronsRight,
Plus,
CircleHelp,
FolderTree,
FileText,
Phone,
Plus,
User,
UserPlus,
Users,
@@ -313,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,
@@ -660,6 +674,7 @@ watch(settingsStore.settings, () => {
const updateSidebarLinks = () => {
sidebarLinks.value = getSidebarLinks()
updateSidebarLinksVisibility()
updateUnreadCount()
}
const redirectToWebsite = () => {
@@ -678,6 +693,36 @@ const profileIsComplete = computed(() => {
)
})
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')
})
@@ -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" />
@@ -68,6 +68,7 @@ import { sessionStore } from '@/stores/session'
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'
@@ -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 [
{
+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>
@@ -5,12 +5,12 @@
</template>
<template #body>
<div
class="absolute left-1/2 mt-3 max-w-sm -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 max-w-sm -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 space-x-2">
<div class="flex items-center gap-x-2">
<div class="flex-1">
<TextInput
type="text"
@@ -64,27 +64,27 @@
</template>
</Dropdown>
</div>
<div class="flex items-center mb-3">
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
<span class="ms-2">
{{ dayjs(evl.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-3">
<div class="flex items-center mb-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
<span class="ms-2">
{{ formatTime(evl.start_time) }}
</span>
</div>
<div class="flex items-center">
<GraduationCap class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
<span class="ms-2">
{{ evl.evaluator_name }}
</span>
</div>
<div
v-if="evl.google_meet_link"
class="flex items-center justify-between space-x-2 mt-4"
class="flex items-center justify-between gap-x-2 mt-4"
>
<Button @click="openEvalCall(evl)" class="w-full">
<template #prefix>
+41 -7
View File
@@ -8,7 +8,7 @@
)
}}
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
<div v-for="(quiz, index) in quizzes" class="ps-3 mt-1">
<span>
{{ index + 1 }}. <span class="font-semibold"> {{ quiz.quiz }} </span>
</span>
@@ -36,7 +36,7 @@
@click="playVideo"
>
<div
class="rounded-full p-4 pl-4.5"
class="rounded-full p-4 ps-4.5"
style="
background: radial-gradient(
circle,
@@ -49,7 +49,7 @@
</div>
</div>
<div
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
class="flex items-center gap-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 start-0 end-0 mx-auto rounded-md"
:class="{
'invisible group-hover:visible': playing,
}"
@@ -76,7 +76,7 @@
class="duration-slider h-1"
/>
<!-- QUIZ MARKERS -->
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
<div class="absolute top-0 start-0 w-full h-full pointer-events-none">
<div
v-for="(quiz, index) in quizzes"
:key="index"
@@ -89,6 +89,11 @@
<span class="text-sm font-medium">
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
</span>
<Dropdown :options="dropdownOptions">
<Button>{{ playbackSpeedLabel }}</Button>
</Dropdown>
<Button
variant="ghost"
@click="toggleMute"
@@ -151,9 +156,9 @@
</Dialog>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { ref, onMounted, computed, watch, onBeforeUnmount } from 'vue'
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button, Dialog } from 'frappe-ui'
import { Button, Dialog, Dropdown } from 'frappe-ui'
import { formatSeconds, formatTimestamp } from '@/utils'
import { useSettings } from '@/stores/settings'
import Play from '@/components/Icons/Play.vue'
@@ -173,6 +178,16 @@ const currentQuiz = ref(null)
const nextQuiz = ref({})
const { settings } = useSettings()
// Speed control states
const playbackSpeed = ref(1)
const playbackSpeedLabel = ref('1x')
const playbackSpeeds = [
{ label: '0.5x', value: 0.5 },
{ label: '1x', value: 1 },
{ label: '1.5x', value: 1.5 },
{ label: '2x', value: 2 },
]
const props = defineProps({
file: {
type: String,
@@ -199,6 +214,9 @@ const props = defineProps({
onMounted(() => {
updateCurrentTime()
updateNextQuiz()
if (videoRef.value) {
videoRef.value.playbackRate = 1
}
})
const updateCurrentTime = () => {
@@ -318,9 +336,25 @@ const toggleFullscreen = () => {
const getQuizMarkerStyle = (time) => {
const percentage = ((time - 5) / Math.ceil(duration.value)) * 100
return {
left: `${percentage}%`,
insetInlineStart: `${percentage}%`,
}
}
const setPlaybackSpeed = (speed, label) => {
playbackSpeed.value = speed
playbackSpeedLabel.value = label
if (videoRef.value) {
videoRef.value.playbackRate = speed
}
}
const dropdownOptions = computed(() =>
playbackSpeeds.map((speed) => ({
label: speed.label,
active: playbackSpeed.value === speed.value,
onClick: () => setPlaybackSpeed(speed.value, speed.label),
}))
)
</script>
<style scoped>
@@ -26,7 +26,7 @@
rowKey="name"
>
<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 submissionColumns" />
</ListHeader>
+114 -33
View File
@@ -20,18 +20,17 @@
</Button>
</header>
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
<div class="flex items-center justify-between mb-5">
<div v-if="assignmentCount" class="text-lg font-semibold text-ink-gray-9">
{{ __('{0} Assignments').format(assignmentCount) }}
<div class="py-5">
<div
class="flex flex-col md:flex-row md:items-center space-y-4 md:space-y-0 justify-between mb-5 mx-5"
>
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('{0} Assignments').format(assignments.data?.length) }}
</div>
<div
v-if="assignments.data?.length || assignmentCount > 0"
class="grid grid-cols-2 gap-5"
>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="titleFilter"
:placeholder="__('Search by title')"
:placeholder="__('Search by Title')"
/>
<FormControl
v-model="typeFilter"
@@ -48,23 +47,77 @@
row-key="name"
:options="{
showTooltip: false,
selectable: false,
selectable: true,
onRowClick: (row) => {
if (readOnlyMode) return
assignmentID = row.name
showAssignmentForm = true
},
}"
class="h-[71vh] lg:h-[79vh] px-5"
>
<ListHeader
class="mb-2 grid items-center rounded bg-surface-white border-b rounded-none p-2"
>
<ListHeaderItem :item="item" v-for="item in assignmentColumns">
<template #prefix="{ item }">
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
v-for="row in assignments.data"
:row="row"
class="hover:bg-surface-gray-2"
>
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'show_answers'">
<FormControl
type="checkbox"
v-model="row[column.key]"
:disabled="true"
/>
</div>
<div
v-else-if="column.key == 'modified'"
class="text-sm text-ink-gray-5"
>
{{ row[column.key] }}
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner class="bottom-50">
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteAssignment(selections, unselectAll)"
>
<FeatherIcon name="trash-2" class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
<EmptyState v-else type="Assignments" />
<div
v-if="assignments.data && assignments.hasNextPage"
class="flex justify-center my-5"
>
<Button @click="assignments.next()">
<div v-else class="h-[53vh]">
<EmptyState type="Assignments" />
</div>
<div class="flex items-center justify-end gap-x-3 pt-3 border-t px-5">
<Button v-if="assignments.hasNextPage" @click="assignments.next()">
{{ __('Load More') }}
</Button>
<div v-if="assignments.hasNextPage" class="h-8 border-s"></div>
<div class="text-ink-gray-5">
{{ assignments.data?.length }} {{ __('of') }}
{{ totalAssignments.data }}
</div>
</div>
</div>
<AssignmentForm
@@ -79,8 +132,17 @@ import {
Button,
call,
createListResource,
createResource,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
FeatherIcon,
toast,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
@@ -96,7 +158,6 @@ const titleFilter = ref('')
const typeFilter = ref('')
const showAssignmentForm = ref(false)
const assignmentID = ref('new')
const assignmentCount = ref(0)
const { brand } = sessionStore()
const router = useRouter()
const route = useRoute()
@@ -110,7 +171,6 @@ onMounted(() => {
assignmentID.value = 'new'
showAssignmentForm.value = true
}
getAssignmentCount()
titleFilter.value = router.currentRoute.value.query.title
typeFilter.value = router.currentRoute.value.query.type
})
@@ -123,6 +183,10 @@ watch([titleFilter, typeFilter], () => {
},
})
reloadAssignments()
totalAssignments.update({
filters: assignmentFilter.value,
})
totalAssignments.reload()
})
const reloadAssignments = () => {
@@ -137,7 +201,7 @@ const assignmentFilter = computed(() => {
if (titleFilter.value) {
filters.title = ['like', `%${titleFilter.value}%`]
}
if (typeFilter.value) {
if (typeFilter.value && typeFilter.value.trim() !== '') {
filters.type = typeFilter.value
}
return filters
@@ -145,51 +209,60 @@ const assignmentFilter = computed(() => {
const assignments = createListResource({
doctype: 'LMS Assignment',
fields: ['name', 'title', 'type', 'creation', 'question', 'course'],
fields: ['name', 'title', 'type', 'modified', 'question', 'course'],
orderBy: 'modified desc',
cache: ['assignments'],
transform(data) {
return data.map((row) => {
return {
...row,
creation: dayjs(row.creation).fromNow(),
modified: dayjs(row.modified).format('DD MMM YYYY'),
}
})
},
})
const totalAssignments = createResource({
url: 'frappe.client.get_count',
params: {
doctype: 'LMS Assignment',
filters: assignmentFilter.value,
},
auto: true,
cache: ['assignments_count', user.data?.name],
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
})
const assignmentColumns = computed(() => {
return [
{
label: __('Title'),
key: 'title',
width: 2,
width: 1,
icon: 'file-text',
},
{
label: __('Type'),
key: 'type',
width: 1,
align: 'left',
icon: 'tag',
},
{
label: __('Created'),
key: 'creation',
label: __('Updated On'),
key: 'modified',
width: 1,
align: 'right',
icon: 'clock',
},
]
})
const getAssignmentCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Assignment',
}).then((data) => {
assignmentCount.value = data
})
}
const assignmentTypes = computed(() => {
let types = ['', 'Document', 'Image', 'PDF', 'URL', 'Text']
let types = [' ', 'Document', 'Image', 'PDF', 'URL', 'Text']
return types.map((type) => {
return {
label: __(type),
@@ -198,6 +271,14 @@ const assignmentTypes = computed(() => {
})
})
const deleteAssignment = (selections, unselectAll) => {
Array.from(selections).forEach(async (assignmentName) => {
await assignments.delete.submit(assignmentName)
})
unselectAll()
toast.success(__('Assignments deleted successfully'))
}
const breadcrumbs = computed(() => [
{
label: __('Assignments'),
+1 -1
View File
@@ -4,7 +4,7 @@
class="sticky top-0 z-10 border-b flex items-center justify-between bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div v-if="tabIndex == 5 && isAdmin" class="flex items-center space-x-2">
<div v-if="tabIndex == 5 && isAdmin" class="flex items-center gap-x-2">
<Badge v-if="childRef?.isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
+82 -51
View File
@@ -9,10 +9,11 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-5">
<FormControl
<Switch
size="sm"
v-model="batchDetail.doc.published"
type="checkbox"
:label="__('Published')"
:description="__('Make the batch visible to all users.')"
/>
<FormControl
v-model="batchDetail.doc.title"
@@ -43,10 +44,13 @@
/>
</div>
<div class="space-y-5">
<FormControl
<Switch
size="sm"
v-model="batchDetail.doc.allow_self_enrollment"
type="checkbox"
:label="__('Allow Self Enrollment')"
:description="
__('Allow users to enroll in this batch on their own.')
"
/>
<FormControl
v-model="batchDetail.doc.start_time"
@@ -72,10 +76,11 @@
/>
<Link
v-model="batchDetail.doc.category"
doctype="LMS Category"
:label="__('Category')"
v-model="batchDetail.doc.category"
:onCreate="(value, close) => openSettings('Categories', close)"
:inlineCreate="true"
:onCreate="createCategory"
/>
</div>
</div>
@@ -87,10 +92,11 @@
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 items-start">
<div class="flex flex-col space-y-5">
<FormControl
<Switch
size="sm"
v-model="batchDetail.doc.evaluation"
type="checkbox"
:label="__('Evaluation')"
:description="__('Enable evaluations for batch participants.')"
/>
<FormControl
v-if="batchDetail.doc.evaluation"
@@ -101,10 +107,11 @@
/>
</div>
<div>
<FormControl
<Switch
size="sm"
v-model="batchDetail.doc.certification"
type="checkbox"
:label="__('Certification')"
:description="__('Issue certificates to batch participants.')"
/>
</div>
</div>
@@ -114,11 +121,12 @@
<div class="grid grid-cols-2 gap-5">
<MultiSelect
v-model="instructors"
doctype="Course Evaluator"
doctype="User"
:label="__('Instructors')"
:required="true"
:onCreate="(close) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }"
:onCreate="() => (showMemberModal = true)"
url="lms.lms.api.search_users_by_role"
:searchParams="{ roles: JSON.stringify(['Batch Evaluator']) }"
/>
<FormControl
v-model="batchDetail.doc.description"
@@ -155,12 +163,14 @@
class="mb-4"
/>
<Link
ref="emailTemplateLinkRef"
doctype="Email Template"
:label="__('Enrollment Confirmation Email Template')"
v-model="batchDetail.doc.confirmation_email_template"
:onCreate="
(value, close) => {
openSettings('Email Templates', close)
if (close) close()
showEmailTemplateModal = true
}
"
/>
@@ -214,10 +224,11 @@
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Pricing') }}
</div>
<FormControl
<Switch
size="sm"
v-model="batchDetail.doc.paid_batch"
type="checkbox"
:label="__('Paid Batch')"
:description="__('Charge a fee for batch enrollment.')"
/>
<div
v-if="batchDetail.doc.paid_batch"
@@ -264,7 +275,7 @@
</div>
</div>
</div>
<div class="border-l min-w-0">
<div class="border-s min-w-0">
<div class="border-b p-4">
<BatchCourses :batch="batch" />
</div>
@@ -274,6 +285,17 @@
</div>
</div>
</div>
<NewMemberModal
v-model="showMemberModal"
:defaultRoles="['batch_evaluator']"
@created="onInstructorCreated"
/>
<EmailTemplateModal
v-model="showEmailTemplateModal"
v-model:emailTemplates="emailTemplates"
templateID="new"
@created="onEmailTemplateCreated"
/>
</template>
<script setup>
import {
@@ -290,37 +312,65 @@ import {
} from 'vue'
import {
FormControl,
Switch,
TextEditor,
createDocumentResource,
toast,
call,
createListResource,
} from 'frappe-ui'
import {
escapeHTML,
createLMSCategory,
getMetaInfo,
openSettings,
sanitizeHTML,
updateMetaInfo,
} from '@/utils'
import { useRouter } from 'vue-router'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { sessionStore } from '@/stores/session'
import { useTelemetry } from 'frappe-ui/frappe'
import Uploader from '@/components/Controls/Uploader.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue'
import BatchCourses from '@/pages/Batches/components/BatchCourses.vue'
import Assessments from '@/pages/Batches/components/Assessments.vue'
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
const router = useRouter()
const user = inject('$user')
const { brand } = sessionStore()
const { updateOnboardingStep } = useOnboarding('learning')
const instructors = ref([])
const app = getCurrentInstance()
const { capture } = useTelemetry()
const { $dialog } = app.appContext.config.globalProperties
const isDirty = ref(false)
const originalDoc = ref(null)
const showMemberModal = ref(false)
const showEmailTemplateModal = ref(false)
const emailTemplateLinkRef = ref(null)
const emailTemplates = createListResource({
doctype: 'Email Template',
fields: ['name', 'subject', 'use_html', 'response', 'response_html'],
auto: true,
orderBy: 'modified desc',
cache: 'email-templates',
})
const onEmailTemplateCreated = (name) => {
batchDetail.doc.confirmation_email_template = name
emailTemplateLinkRef.value?.reload()
}
const createCategory = (name, done) => {
createLMSCategory(name).then((categoryName) => {
if (!categoryName) return
batchDetail.doc.category = categoryName
done()
})
}
const onInstructorCreated = (user) => {
instructors.value = [...instructors.value, user.name]
}
const meta = reactive({
description: '',
@@ -364,9 +414,16 @@ watch(
() => batchDetail.doc,
() => {
if (!batchDetail.doc) return
getMetaInfo('batches', batchDetail.doc?.name, meta)
if (originalDoc.value) {
isDirty.value =
JSON.stringify(batchDetail.doc) !== JSON.stringify(originalDoc.value)
}
updateBatchData()
}
getMetaInfo('batches', batchDetail.doc?.name, meta)
},
{ deep: true }
)
const updateBatchData = () => {
@@ -400,22 +457,7 @@ const formatTime = (timeStr) => {
return `${hours}:${minutes}`
}
const validateFields = () => {
batchDetail.doc.description = sanitizeHTML(batchDetail.doc.description)
batchDetail.doc.batch_details = sanitizeHTML(batchDetail.doc.batch_details)
Object.keys(batchDetail.doc).forEach((key) => {
if (
!['description', 'batch_details'].includes(key) &&
typeof batchDetail.doc[key] === 'string'
) {
batchDetail.doc[key] = escapeHTML(batchDetail.doc[key])
}
})
}
const submitBatch = () => {
validateFields()
updateBatch()
}
@@ -444,17 +486,6 @@ const updateBatch = () => {
)
}
watch(
() => batchDetail.doc,
() => {
if (originalDoc.value) {
isDirty.value =
JSON.stringify(batchDetail.doc) !== JSON.stringify(originalDoc.value)
}
},
{ deep: true }
)
const deleteBatch = () => {
$dialog({
title: __('Confirm your action to delete'),
+1 -1
View File
@@ -10,7 +10,7 @@
</div>
<div class="flex avatar-group overlap">
<div
class="h-6 mr-1"
class="h-6 me-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
+11 -8
View File
@@ -34,7 +34,7 @@
<template #suffix>
<ChevronDown
:class="[
'w-4 h-4 stroke-1.5 ml-1 transform transition-transform',
'w-4 h-4 stroke-1.5 ms-1 transform transition-transform',
open ? 'rotate-180' : '',
]"
/>
@@ -51,7 +51,7 @@
{{ __('All Batches') }}
</div>
<div
class="flex flex-col space-y-3 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
class="flex flex-col space-y-3 lg:space-y-0 lg:flex-row lg:items-center lg:gap-x-4"
>
<TabButtons
v-if="user.data"
@@ -78,12 +78,14 @@
</div>
</div>
<FormControl
v-model="certification"
:label="__('Certification')"
type="checkbox"
@change="updateBatches()"
/>
<Tooltip :text="__('Only show batches that offer a certificate')">
<FormControl
type="checkbox"
v-model="certification"
:label="__('Certification')"
@change="updateBatches()"
/>
</Tooltip>
</div>
</div>
<div
@@ -122,6 +124,7 @@ import {
Dropdown,
FormControl,
Select,
Tooltip,
TabButtons,
usePageMeta,
} from 'frappe-ui'
@@ -26,11 +26,11 @@
<div class="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-5 items-start">
<div class="border rounded-lg py-3 px-4 order-2 lg:order-1">
<div class="flex items-center justify-between space-x-2 mb-3">
<div class="flex items-center justify-between gap-x-2 mb-3">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Students') }}
</div>
<div class="flex items-center space-x-2">
<div class="flex items-center gap-x-2">
<FormControl
v-model="searchFilter"
:placeholder="__('Search by name')"
@@ -62,7 +62,7 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-white border-b rounded-none p-2"
class="mb-2 grid items-center gap-x-4 rounded bg-surface-white border-b rounded-none p-2"
>
<ListHeaderItem
:item="item"
@@ -91,7 +91,7 @@
<!-- <ProgressBar
v-else-if="column.key == 'progress'"
:progress="Math.ceil(row[column.key])"
class="!mx-0 !mr-4"
class="!mx-0 !me-4"
/> -->
</template>
<div v-if="column.key == 'creation'">
@@ -174,7 +174,6 @@ import {
AxisChart,
createResource,
createListResource,
dayjs,
FormControl,
ListView,
ListHeader,
@@ -185,7 +184,8 @@ import {
Avatar,
Button,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import type dayjsType from 'dayjs'
import { formatAmount } from '@/utils'
import { Plus } from 'lucide-vue-next'
import BatchFeedback from '@/pages/Batches/components/BatchFeedback.vue'
@@ -193,6 +193,7 @@ import BatchStudentProgress from '@/pages/Batches/components/BatchStudentProgres
import NumberChartGraph from '@/components/NumberChartGraph.vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
const dayjs = inject<typeof dayjsType>('$dayjs')!
const searchFilter = ref<string | null>(null)
const showEnrollmentModal = ref<boolean>(false)
const showProgressModal = ref<boolean>(false)
@@ -9,7 +9,7 @@
<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">
<div class="ms-2 text-ink-gray-7">
{{ comm.sender_full_name }}
</div>
</div>
@@ -24,7 +24,7 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
class="mb-2 grid items-center gap-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
</ListHeaderItem>
@@ -39,8 +39,8 @@
class="text-sm text-ink-gray-7"
/>
<div class="flex items-center text-sm text-ink-gray-7">
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-ink-gray-7" />
<span>
<Clock class="h-4 w-4 stroke-1.5 me-2 text-ink-gray-7" />
<span dir="ltr">
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
</span>
</div>
@@ -48,7 +48,7 @@
v-if="batch.timezone"
class="flex items-center text-sm text-ink-gray-7"
>
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-ink-gray-5" />
<Globe class="h-4 w-4 stroke-1.5 me-2 text-ink-gray-5" />
<span>
{{ batch.timezone }}
</span>
@@ -59,7 +59,7 @@
class="flex avatar-group overlap mt-4"
>
<div
class="h-6 mr-1"
class="h-6 me-1"
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
>
<UserAvatar
@@ -108,6 +108,6 @@ const props = defineProps({
}
.avatar-group.overlap .avatar + .avatar {
margin-left: calc(-8px);
margin-inline-start: calc(-8px);
}
</style>
@@ -27,7 +27,7 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
class="mb-2 grid items-center gap-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
</ListHeaderItem>
@@ -35,7 +35,7 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
class="mb-2 grid items-center gap-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
</ListHeader>
<ListRows>
@@ -65,7 +65,7 @@
<Assessments :batch="batch.data.name" />
</div>
</div>
<div class="border-l h-[88vh] divide-y">
<div class="border-s h-[88vh] divide-y">
<div v-if="batch.data?.evaluation" class="p-4 mb-5">
<UpcomingEvaluations
:batch="batch.data.name"
@@ -183,9 +183,3 @@ const isAdmin = computed(() => {
return user.data?.is_moderator || user.data?.is_evaluator
})
</script>
<style>
.feedback-list > button > div {
align-items: start;
padding: 0.15rem 0;
}
</style>

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