Compare commits

...

618 Commits

Author SHA1 Message Date
Youssef
597648db08 i18n: update fr.po and template and sync PO files 2025-08-15 21:00:57 -04:00
Youssef
16afb24276 i18n: add missing __() calls to complete French translation 2025-08-15 19:51:10 -04:00
stanig2106
cf5cc19fd5 i18n setup 2025-08-13 22:33:25 +02:00
Youssef
80f093958a i18n: progress on french translations 2025-08-13 11:02:58 -04:00
Youssef
57df376207 i18n: clean fr.csv and add __() translations in Statistics.vue 2025-08-10 16:46:00 -04:00
Youssef
a147aca24f i18m: auto import FR Translations from lms/translations/fr.csv 2025-08-10 12:28:50 -04:00
Youssef
1660f69930 i18n: populate fr.csv with French Translatations 2025-08-10 10:34:08 -04:00
Youssef
718657f493 i18n: wrap UI strings with __() (components/pages/modals) 2025-08-09 18:56:47 -04:00
Youssef
9a77b716a1 i18n : wrap core IU stringg with __() (nav, button, tabs, breadcrumbs ) 2025-08-09 17:43:45 -04:00
Youssef
6e3c624b91 i18n-> add initial fr.csv for French Translations 2025-08-09 15:57:25 -04:00
Jannat Patel
2271eb270e Merge pull request #1667 from harshpwctech/develop
refactor: Announcement mail being sent to students in BCC
2025-08-01 17:32:41 +05:30
CA Harsh Agrawal
7e5b2e4e79 refactor: Announcement mail being sent to students in BCC 2025-08-01 17:02:48 +05:30
Jannat Patel
36076068ec fix: text padding on card gradient 2025-07-30 12:30:12 +05:30
Frappe PR Bot
c868354b5b chore(release): Bumped to Version 2.33.0 2025-07-30 06:14:36 +00:00
Jannat Patel
db91f0b2a0 Merge pull request #1663 from pateljannat/issues-125
fix: show video statistics watch time in minutes
2025-07-30 11:43:01 +05:30
Jannat Patel
d7e83bb78e fix: show video statistics watch time in minutes 2025-07-30 11:30:17 +05:30
Jannat Patel
feb2a39e05 Merge pull request #1661 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-30 10:53:15 +05:30
Jannat Patel
a6cf910d05 chore: Esperanto translations 2025-07-30 04:23:56 +05:30
Jannat Patel
b891b44ac6 chore: Spanish translations 2025-07-30 04:23:54 +05:30
Jannat Patel
026a3ebb81 chore: Serbian (Latin) translations 2025-07-30 04:23:53 +05:30
Jannat Patel
71ba246011 chore: Bosnian translations 2025-07-30 04:23:51 +05:30
Jannat Patel
a391204fa6 chore: Croatian translations 2025-07-30 04:23:49 +05:30
Jannat Patel
9c773399a8 chore: Thai translations 2025-07-30 04:23:48 +05:30
Jannat Patel
528b85352a chore: Persian translations 2025-07-30 04:23:47 +05:30
Jannat Patel
249c369c14 chore: Indonesian translations 2025-07-30 04:23:45 +05:30
Jannat Patel
9803fc1031 chore: Portuguese, Brazilian translations 2025-07-30 04:23:44 +05:30
Jannat Patel
299fde1c98 chore: Vietnamese translations 2025-07-30 04:23:43 +05:30
Jannat Patel
7f55734fbb chore: Chinese Simplified translations 2025-07-30 04:23:41 +05:30
Jannat Patel
efe230865a chore: Turkish translations 2025-07-30 04:23:40 +05:30
Jannat Patel
6e52e684c8 chore: Swedish translations 2025-07-30 04:23:38 +05:30
Jannat Patel
99d880297a chore: Serbian (Cyrillic) translations 2025-07-30 04:23:36 +05:30
Jannat Patel
dec706ae72 chore: Russian translations 2025-07-30 04:23:35 +05:30
Jannat Patel
2e60f0a0c2 chore: Portuguese translations 2025-07-30 04:23:34 +05:30
Jannat Patel
ef612f86e5 chore: Polish translations 2025-07-30 04:23:32 +05:30
Jannat Patel
9c16e03ea7 chore: Dutch translations 2025-07-30 04:23:31 +05:30
Jannat Patel
7780c0310e chore: Italian translations 2025-07-30 04:23:29 +05:30
Jannat Patel
b0a23c0d1a chore: Hungarian translations 2025-07-30 04:23:27 +05:30
Jannat Patel
05c85cea08 chore: German translations 2025-07-30 04:23:26 +05:30
Jannat Patel
1ffae0a1de chore: Czech translations 2025-07-30 04:23:24 +05:30
Jannat Patel
15cbccd15f chore: Arabic translations 2025-07-30 04:23:23 +05:30
Jannat Patel
266b2f2ac8 chore: French translations 2025-07-30 04:23:21 +05:30
Jannat Patel
26f9fb4199 Merge pull request #1658 from frappe/pot_develop_2025-07-25
chore: update POT file
2025-07-29 12:05:37 +05:30
frappe-pr-bot
67887fb6ef chore: update POT file 2025-07-25 16:04:39 +00:00
Jannat Patel
3d102e39ff Merge pull request #1657 from pateljannat/course-card-gradient
feat: course card gradient
2025-07-25 18:56:50 +05:30
Jannat Patel
ddd9089130 fix: color swatch input style 2025-07-25 18:31:46 +05:30
Jannat Patel
d8ce88ab57 fix: color swatch input style 2025-07-25 18:30:58 +05:30
Jannat Patel
01794a47c6 feat: set a random color is no color or image is present 2025-07-25 17:46:50 +05:30
Jannat Patel
17626dbbdb feat: course card gradient 2025-07-25 17:29:48 +05:30
Jannat Patel
e5bd86658d Merge pull request #1655 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-24 10:41:13 +05:30
Jannat Patel
e911dc1353 chore: Thai translations 2025-07-24 02:45:56 +05:30
Jannat Patel
27e3e5aa6a chore: Indonesian translations 2025-07-24 02:45:53 +05:30
Jannat Patel
5b65525bf1 chore: Portuguese translations 2025-07-24 02:45:46 +05:30
Jannat Patel
277804f8b1 chore: Hungarian translations 2025-07-24 02:45:42 +05:30
Jannat Patel
4c77802e3c Merge pull request #1653 from pateljannat/issues-124
fix: progress timer in lessons
2025-07-23 11:32:51 +05:30
Jannat Patel
aacfea6ea5 fix: progress timer in lessons 2025-07-23 11:31:41 +05:30
Frappe PR Bot
6d55040e43 chore(release): Bumped to Version 2.32.2 2025-07-23 05:31:05 +00:00
Jannat Patel
290f785a47 Merge pull request #1651 from pateljannat/issues-123
fix: vimeo video embed with plyr
2025-07-23 11:00:03 +05:30
Jannat Patel
39ef187f6b fix: vimeo video embed with plyr 2025-07-23 10:44:53 +05:30
Frappe PR Bot
a7a475e763 chore(release): Bumped to Version 2.32.1 2025-07-22 13:31:38 +00:00
Jannat Patel
6eb380ea38 Merge pull request #1648 from pateljannat/issues-122
fix: play embed videos on Lesson Form
2025-07-22 14:20:42 +05:30
Jannat Patel
4d150cb323 fix: play embed videos on Lesson Form 2025-07-22 14:11:29 +05:30
Jannat Patel
09d6d99b14 Merge pull request #1647 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-22 10:24:07 +05:30
Jannat Patel
5e7fd8baff chore: Esperanto translations 2025-07-22 02:06:41 +05:30
Jannat Patel
52c159e2e8 chore: French translations 2025-07-22 02:06:40 +05:30
Jannat Patel
67e8feb879 chore: Serbian (Latin) translations 2025-07-22 02:06:38 +05:30
Jannat Patel
a5b61d5244 chore: Bosnian translations 2025-07-22 02:06:37 +05:30
Jannat Patel
decc3a16ed chore: Croatian translations 2025-07-22 02:06:35 +05:30
Jannat Patel
7f39e9f0cc chore: Thai translations 2025-07-22 02:06:34 +05:30
Jannat Patel
95afa1a6ad chore: Persian translations 2025-07-22 02:06:32 +05:30
Jannat Patel
0d0bb5f9e2 chore: Portuguese, Brazilian translations 2025-07-22 02:06:31 +05:30
Jannat Patel
3dd5ce5035 chore: Vietnamese translations 2025-07-22 02:06:29 +05:30
Jannat Patel
549e56d551 chore: Chinese Simplified translations 2025-07-22 02:06:28 +05:30
Jannat Patel
50b6215d1e chore: Turkish translations 2025-07-22 02:06:27 +05:30
Jannat Patel
ff69bfdce7 chore: Swedish translations 2025-07-22 02:06:25 +05:30
Jannat Patel
c04cc8ec0f chore: Serbian (Cyrillic) translations 2025-07-22 02:06:24 +05:30
Jannat Patel
f324de2254 chore: Russian translations 2025-07-22 02:06:22 +05:30
Jannat Patel
40af4e6f34 chore: Portuguese translations 2025-07-22 02:06:21 +05:30
Jannat Patel
5d9b66b5cb chore: Polish translations 2025-07-22 02:06:20 +05:30
Jannat Patel
d2a8277c13 chore: Dutch translations 2025-07-22 02:06:18 +05:30
Jannat Patel
ada85fc0f3 chore: Italian translations 2025-07-22 02:06:17 +05:30
Jannat Patel
505345eff7 chore: Hungarian translations 2025-07-22 02:06:15 +05:30
Jannat Patel
2911ade880 chore: German translations 2025-07-22 02:06:14 +05:30
Jannat Patel
8980dc8f9c chore: Czech translations 2025-07-22 02:06:12 +05:30
Jannat Patel
d94a1c47c0 chore: Arabic translations 2025-07-22 02:06:11 +05:30
Jannat Patel
99c3e5182d chore: Spanish translations 2025-07-22 02:06:09 +05:30
Jannat Patel
70e39fee40 Merge pull request #1646 from frappe/pot_develop_2025-07-18
chore: update POT file
2025-07-21 19:30:03 +05:30
frappe-pr-bot
26d6bec8a0 chore: update POT file 2025-07-18 16:05:11 +00:00
Jannat Patel
c9ac1a1402 Merge pull request #1645 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-18 10:38:18 +05:30
Jannat Patel
6949c1092c chore: Persian translations 2025-07-18 01:39:51 +05:30
Jannat Patel
aae8a54481 Merge pull request #1644 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-17 10:26:39 +05:30
Jannat Patel
e1d93bf670 chore: Serbian (Latin) translations 2025-07-17 01:24:47 +05:30
Jannat Patel
fea0533cb1 chore: Chinese Simplified translations 2025-07-17 01:24:40 +05:30
Jannat Patel
5cd991f02a chore: Swedish translations 2025-07-17 01:24:37 +05:30
Jannat Patel
50a8a605d5 chore: Serbian (Cyrillic) translations 2025-07-17 01:24:36 +05:30
Jannat Patel
9ce7d8f5d6 Merge pull request #1641 from pateljannat/issues-121
chore: upgraded frappe-ui
2025-07-15 15:39:48 +05:30
Jannat Patel
eae2587e4c chore: upgraded frappe-ui 2025-07-15 15:08:32 +05:30
Jannat Patel
323097f201 Merge pull request #1639 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-15 09:44:54 +05:30
Jannat Patel
014499888a chore: Esperanto translations 2025-07-15 01:33:53 +05:30
Jannat Patel
5662de21ae chore: Serbian (Latin) translations 2025-07-15 01:33:52 +05:30
Jannat Patel
17c2eba455 chore: Bosnian translations 2025-07-15 01:33:50 +05:30
Jannat Patel
1f2c986e8f chore: Croatian translations 2025-07-15 01:33:49 +05:30
Jannat Patel
12040b5f6d chore: Thai translations 2025-07-15 01:33:47 +05:30
Jannat Patel
20a985848f chore: Portuguese, Brazilian translations 2025-07-15 01:33:46 +05:30
Jannat Patel
c06c6169e5 chore: Vietnamese translations 2025-07-15 01:33:44 +05:30
Jannat Patel
917aeb79ef chore: Turkish translations 2025-07-15 01:33:43 +05:30
Jannat Patel
c4f36a39fe chore: Swedish translations 2025-07-15 01:33:42 +05:30
Jannat Patel
befedc30ad chore: Serbian (Cyrillic) translations 2025-07-15 01:33:40 +05:30
Jannat Patel
d3bc67daa2 chore: Russian translations 2025-07-15 01:33:39 +05:30
Jannat Patel
5d7e211367 chore: Polish translations 2025-07-15 01:33:37 +05:30
Jannat Patel
fa9daa01ec chore: Dutch translations 2025-07-15 01:33:36 +05:30
Jannat Patel
0ed9dc63b8 chore: Italian translations 2025-07-15 01:33:34 +05:30
Jannat Patel
5dd6b33eb2 chore: Hungarian translations 2025-07-15 01:33:33 +05:30
Jannat Patel
1210b823c7 chore: German translations 2025-07-15 01:33:32 +05:30
Jannat Patel
04240b4b3d chore: Czech translations 2025-07-15 01:33:30 +05:30
Jannat Patel
787f592a1a chore: Arabic translations 2025-07-15 01:33:29 +05:30
Jannat Patel
e7363fbd40 chore: Spanish translations 2025-07-15 01:33:27 +05:30
Jannat Patel
e2762825e5 chore: French translations 2025-07-15 01:33:25 +05:30
Jannat Patel
bbbca70c71 chore: Chinese Simplified translations 2025-07-15 01:33:24 +05:30
Jannat Patel
8dde423866 chore: Persian translations 2025-07-15 01:33:23 +05:30
Jannat Patel
fc4c1c2b7e chore: Portuguese translations 2025-07-15 01:33:21 +05:30
Jannat Patel
bf02e2de3f Merge pull request #1637 from pateljannat/issues-120
fix: increase pageLength for evaluation schedule
2025-07-14 16:57:09 +05:30
Jannat Patel
a26ba4dc6e fix: increase pageLength for evaluation schedule 2025-07-14 16:33:11 +05:30
Frappe PR Bot
f187cc9314 chore(release): Bumped to Version 2.32.0 2025-07-14 06:37:43 +00:00
Jannat Patel
c15c6374f9 Merge pull request #1635 from pateljannat/issues-119
fix: quiz progress issue
2025-07-14 11:51:51 +05:30
Jannat Patel
acec382dfe fix: quiz progress issue 2025-07-14 11:40:55 +05:30
Jannat Patel
fbc078c6b6 Merge pull request #1632 from frappe/pot_develop_2025-07-11
chore: update POT file
2025-07-14 09:37:09 +05:30
frappe-pr-bot
170b20185a chore: update POT file 2025-07-11 16:04:39 +00:00
Jannat Patel
3e8489c13b Merge pull request #1631 from pateljannat/issues-118
fix: misc issues
2025-07-11 11:14:36 +05:30
Jannat Patel
18dfc4c23e test: changed CTA labels 2025-07-11 11:06:29 +05:30
Jannat Patel
e6bae3dc77 fix: changed CTA labels on lists to Create 2025-07-11 10:44:04 +05:30
Jannat Patel
6f9f27c030 fix: delete batch and pass fields prop to brand settings 2025-07-10 22:21:26 +05:30
Jannat Patel
874bef74c7 Merge pull request #1623 from JoeBrar/feature/reorder-chapters
feat: added chapter re-ordering functionality for courses
2025-07-10 12:20:04 +05:30
Jannat Patel
ad483e0916 Merge pull request #1630 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-10 12:19:49 +05:30
Jannat Patel
5b4bbaec20 chore: Portuguese translations 2025-07-10 01:07:19 +05:30
Jannat Patel
b8ae0db0bd Merge pull request #1627 from pateljannat/badge-management
feat: badge management from settings
2025-07-09 10:46:58 +05:30
Jannat Patel
f2c18fad52 fix: misc improvements to badge flow 2025-07-08 19:41:08 +05:30
Jannat Patel
9716655b94 Merge pull request #1626 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-08 14:16:27 +05:30
Jannat Patel
efb317191c feat: badge assignment from settings 2025-07-08 14:12:46 +05:30
Jannat Patel
a47b5db40c chore: Serbian (Cyrillic) translations 2025-07-08 01:10:50 +05:30
Jannat Patel
ec94796b9c chore: Italian translations 2025-07-08 01:10:48 +05:30
Jannat Patel
e3e0cd61a2 chore: Vietnamese translations 2025-07-08 01:10:47 +05:30
Jannat Patel
a438473279 chore: Dutch translations 2025-07-08 01:10:46 +05:30
Jannat Patel
12b5b8b509 chore: Czech translations 2025-07-08 01:10:44 +05:30
Jannat Patel
22442b47a8 chore: Esperanto translations 2025-07-08 01:10:43 +05:30
Jannat Patel
30c8b7d64f chore: Chinese Simplified translations 2025-07-08 01:10:41 +05:30
Jannat Patel
b643575c4f chore: Serbian (Latin) translations 2025-07-08 01:10:40 +05:30
Jannat Patel
7dd7124fac chore: Bosnian translations 2025-07-08 01:10:38 +05:30
Jannat Patel
4b1eebf5bb chore: Croatian translations 2025-07-08 01:10:37 +05:30
Jannat Patel
3257943926 chore: Thai translations 2025-07-08 01:10:36 +05:30
Jannat Patel
24246c83e0 chore: Persian translations 2025-07-08 01:10:34 +05:30
Jannat Patel
a26787f478 chore: Portuguese, Brazilian translations 2025-07-08 01:10:33 +05:30
Jannat Patel
ec3b88f890 chore: Turkish translations 2025-07-08 01:10:31 +05:30
Jannat Patel
7f5f1dad92 chore: Swedish translations 2025-07-08 01:10:30 +05:30
Jannat Patel
b6db128214 chore: Russian translations 2025-07-08 01:10:29 +05:30
Jannat Patel
8831635db2 chore: Portuguese translations 2025-07-08 01:10:28 +05:30
Jannat Patel
e19198b720 chore: Polish translations 2025-07-08 01:10:26 +05:30
Jannat Patel
f618d9dc1a chore: Hungarian translations 2025-07-08 01:10:25 +05:30
Jannat Patel
66a667a0a3 chore: German translations 2025-07-08 01:10:23 +05:30
Jannat Patel
8a4c67f712 chore: Arabic translations 2025-07-08 01:10:22 +05:30
Jannat Patel
fa6ef2e989 chore: Spanish translations 2025-07-08 01:10:21 +05:30
Jannat Patel
7450b99197 chore: French translations 2025-07-08 01:10:19 +05:30
Jannat Patel
023fd272b1 feat: badge list and form 2025-07-07 16:44:48 +05:30
Jannat Patel
84067cb027 Merge pull request #1621 from JoeBrar/fix/image-upload
fix: Changed image extension validation to MIME type validation
2025-07-07 13:01:18 +05:30
Jannat Patel
3087ef70e7 Merge pull request #1619 from JoeBrar/fix/lms-issues
fix: ensure tags wrap correctly to prevent overflow and breaking of layout
2025-07-07 12:59:26 +05:30
Jannat Patel
387385bb1c Merge pull request #1624 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-07 11:13:27 +05:30
Jannat Patel
6766d0d08c Merge pull request #1622 from frappe/pot_develop_2025-07-04
chore: update POT file
2025-07-07 11:13:03 +05:30
Jannat Patel
371d890793 chore: Persian translations 2025-07-07 01:14:35 +05:30
Joedeep Singh
57046c1b38 feat: added chapter re-ordering functionality for courses 2025-07-06 17:02:27 +00:00
frappe-pr-bot
2a64144e94 chore: update POT file 2025-07-04 16:04:41 +00:00
Joedeep Singh
9b0320ccf1 chore: fixed the linter issues 2025-07-04 14:02:13 +00:00
Joedeep Singh
23f209131e Merge branch 'develop' into fix/image-upload 2025-07-04 18:57:03 +05:30
Joedeep Singh
d71f1c7f9a refactor: updated the validateFile function in utils and reused it 2025-07-04 13:22:25 +00:00
Jannat Patel
d21ea2c854 Merge pull request #1618 from pateljannat/member-list-refactor
fix: improved members and evaluators list and form
2025-07-04 12:33:44 +05:30
Jannat Patel
cd7f3ba820 Merge pull request #1620 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-04 12:28:37 +05:30
Jannat Patel
e057d3ed9a fix: improved evaluators list 2025-07-04 12:26:57 +05:30
Joedeep Singh
5f04607a44 fix: Changed image extension validation to MIME type validation 2025-07-03 20:41:23 +00:00
Jannat Patel
9440d13a08 chore: Serbian (Cyrillic) translations 2025-07-04 01:23:16 +05:30
Jannat Patel
85c4f1654e chore: Serbian (Latin) translations 2025-07-04 01:23:15 +05:30
Joedeep Singh
eed339cc64 fix: ensure tags wrap correctly to prevent overflow and breaking of layout 2025-07-03 18:25:04 +00:00
Jannat Patel
3d1a23576a fix: repositioned the search bar on members list 2025-07-03 19:40:09 +05:30
Jannat Patel
ed0e2e4bb5 fix: issue with roles on profile page 2025-07-03 19:26:07 +05:30
Jannat Patel
954d0a0637 fix: improved members list and form 2025-07-03 18:19:29 +05:30
Jannat Patel
f2c8788602 Merge pull request #1613 from Grumbled0rf/patch-1
Update README.md
2025-07-03 13:21:33 +05:30
Jannat Patel
49c63da27c Merge pull request #1614 from mahsem/patch-state
fix: state_translatability
2025-07-03 13:21:06 +05:30
Jannat Patel
24496d1856 Merge pull request #1617 from pateljannat/course-progress-summary
feat: course progress summary report
2025-07-03 13:20:41 +05:30
Jannat Patel
991ebe09a2 chore: linters 2025-07-03 13:09:45 +05:30
Jannat Patel
85da4f6d85 feat: course progress summary report 2025-07-03 13:02:57 +05:30
Jannat Patel
5f065db991 Merge pull request #1616 from pateljannat/issues-117
fix: when logo is updated from brand settings, update the login logo too
2025-07-02 16:56:20 +05:30
Manoj Prabhkaran D
ffb40586d7 Merge branch 'develop' into patch-1 2025-07-02 15:10:59 +04:00
Jannat Patel
fcfd87fd50 fix: when logo is updated from brand settings, update the login logo too 2025-07-02 16:16:49 +05:30
Jannat Patel
eb5b12aa7b Merge pull request #1615 from pateljannat/course-list-fetch
fix: page length issue on course list
2025-07-02 15:41:23 +05:30
Manoj Prabhkaran D
f6e2438744 docs(readme): add DNS configuration note to avoid 404 error during self-hosting 2025-07-02 13:43:10 +04:00
Jannat Patel
e3c7dc695d fix: page length issue on batch list 2025-07-02 15:01:33 +05:30
Jannat Patel
82d2025e6c fix: page length issue on course list 2025-07-02 15:00:53 +05:30
mahsem
91b82d78b8 fix: state _translatability 2025-07-02 11:03:33 +02:00
Jannat Patel
b97e792893 Merge pull request #1609 from pateljannat/track-video-watch-duration
feat: video watch time tracking
2025-07-02 10:53:56 +05:30
Jannat Patel
13ac5ec7dc fix: sidebar settings for programming exercises 2025-07-02 10:47:03 +05:30
Jannat Patel
199f880936 Merge pull request #1612 from addeeandra/fix-sidebar-settings-programming-exercise
feat: sidebar settings to toggle "Programming Exercise" menu
2025-07-02 10:33:47 +05:30
Jannat Patel
ed86c207ba Merge pull request #1610 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-07-01 19:18:51 +05:30
Jannat Patel
b4cf290f4d fix: allow backward seek but prevent forward seek 2025-07-01 19:16:13 +05:30
Jannat Patel
e526a6fd64 fix: moved sirebar settings to settings store 2025-07-01 17:38:15 +05:30
Jannat Patel
94cbbf169a feat: prevent skipping videos 2025-07-01 17:27:43 +05:30
Jannat Patel
2837ed16a7 feat: track watch time for youtube and vimeo 2025-07-01 16:55:55 +05:30
Aditya Chandra
68961deb6b revert: api.py 2025-07-01 12:52:52 +07:00
Aditya Chandra
ec54bfee98 fix: programming exercise's sidebar settings 2025-07-01 12:39:50 +07:00
Jannat Patel
385e97b76a chore: Serbian (Cyrillic) translations 2025-07-01 00:39:53 +05:30
Jannat Patel
cbd916877f chore: Italian translations 2025-07-01 00:39:51 +05:30
Jannat Patel
38586034cd chore: Vietnamese translations 2025-07-01 00:39:50 +05:30
Jannat Patel
62b3ba2bff chore: Dutch translations 2025-07-01 00:39:49 +05:30
Jannat Patel
dd470b61b5 chore: Czech translations 2025-07-01 00:39:47 +05:30
Jannat Patel
4fa92d2327 chore: Esperanto translations 2025-07-01 00:39:46 +05:30
Jannat Patel
6f6c2db66d chore: Chinese Simplified translations 2025-07-01 00:39:44 +05:30
Jannat Patel
e6348cfa20 chore: Serbian (Latin) translations 2025-07-01 00:39:43 +05:30
Jannat Patel
a006d1000a chore: Bosnian translations 2025-07-01 00:39:42 +05:30
Jannat Patel
4a575e642f chore: Croatian translations 2025-07-01 00:39:40 +05:30
Jannat Patel
93525bc577 chore: Thai translations 2025-07-01 00:39:39 +05:30
Jannat Patel
2cf0e9a723 chore: Persian translations 2025-07-01 00:39:36 +05:30
Jannat Patel
c32164bfea chore: Portuguese, Brazilian translations 2025-07-01 00:39:35 +05:30
Jannat Patel
714b0924e7 chore: Turkish translations 2025-07-01 00:39:34 +05:30
Jannat Patel
43079790a8 chore: Swedish translations 2025-07-01 00:39:33 +05:30
Jannat Patel
d03e61b625 chore: Russian translations 2025-07-01 00:39:31 +05:30
Jannat Patel
2d760112a3 chore: Portuguese translations 2025-07-01 00:39:30 +05:30
Jannat Patel
f46507ec72 chore: Polish translations 2025-07-01 00:39:28 +05:30
Jannat Patel
e9e10bdc93 chore: Hungarian translations 2025-07-01 00:39:27 +05:30
Jannat Patel
0386967a32 chore: German translations 2025-07-01 00:39:25 +05:30
Jannat Patel
4900fc8b88 chore: Arabic translations 2025-07-01 00:39:24 +05:30
Jannat Patel
99294b5643 chore: Spanish translations 2025-07-01 00:39:22 +05:30
Jannat Patel
eb12bcb83c chore: French translations 2025-07-01 00:39:21 +05:30
Jannat Patel
22a2e57642 fix: fetch stats when lesson is loaded 2025-06-30 20:00:13 +05:30
Jannat Patel
5eaae06ceb feat: video watch time tracking 2025-06-30 19:56:07 +05:30
Jannat Patel
ce7fc35349 Merge pull request #1606 from pateljannat/sidebar-toggle-issue
chore: upgraded frappe ui
2025-06-30 11:57:06 +05:30
Jannat Patel
8d4b5c83ae chore: upgraded frappe ui 2025-06-30 11:49:22 +05:30
Jannat Patel
cbd3c56ca0 Merge pull request #1605 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-30 11:42:20 +05:30
Jannat Patel
be6dad1424 Merge pull request #1603 from frappe/pot_develop_2025-06-27
chore: update POT file
2025-06-30 11:42:07 +05:30
Jannat Patel
298452fa7b Merge pull request #1602 from pateljannat/negative-marking-in-quiz
feat: negative marking in quiz
2025-06-30 11:41:53 +05:30
Jannat Patel
4abbd7c35c chore: Bosnian translations 2025-06-30 00:27:08 +05:30
Jannat Patel
c2f51c51ab chore: Croatian translations 2025-06-30 00:27:07 +05:30
Jannat Patel
255cff6664 chore: Turkish translations 2025-06-30 00:27:04 +05:30
Jannat Patel
8a9578bb0a chore: German translations 2025-06-30 00:26:59 +05:30
Jannat Patel
8831f6cecc chore: Arabic translations 2025-06-30 00:26:58 +05:30
Jannat Patel
f3daa7e48b chore: Spanish translations 2025-06-30 00:26:56 +05:30
Jannat Patel
6163597958 chore: French translations 2025-06-30 00:26:55 +05:30
frappe-pr-bot
f9e1222065 chore: update POT file 2025-06-27 16:04:48 +00:00
Jannat Patel
7d85de7c6c fix: set default marks to cut as 1 2025-06-27 20:00:57 +05:30
Jannat Patel
cf452c2300 feat: negative marking in quiz 2025-06-27 19:58:35 +05:30
Jannat Patel
72bd1d548d Merge pull request #1600 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-27 16:51:01 +05:30
Jannat Patel
4556f4dee6 chore: Thai translations 2025-06-26 23:43:56 +05:30
Jannat Patel
3dfbd3165a chore: German translations 2025-06-26 23:43:54 +05:30
Jannat Patel
02b8e02131 Merge pull request #1599 from pateljannat/issues-116
fix: misc issues
2025-06-26 16:53:06 +05:30
Jannat Patel
087ded9f9e chore: removed console 2025-06-26 16:37:05 +05:30
Jannat Patel
21f122ee82 fix: misc issues 2025-06-26 16:35:03 +05:30
Jannat Patel
d60a7e8c94 Merge pull request #1593 from pateljannat/programming-exercises
feat: programming exercises
2025-06-26 13:05:22 +05:30
Jannat Patel
b8981c249f feat: livecode settings 2025-06-25 19:57:07 +05:30
Jannat Patel
e71275a0dc feat: javascript exercises 2025-06-25 12:15:27 +05:30
Jannat Patel
4fb0db7a1e fix: test case with no input issue 2025-06-24 12:22:02 +05:30
Jannat Patel
1e9beedc77 Merge pull request #1591 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-24 09:50:23 +05:30
Jannat Patel
4a4a0653ef chore: Serbian (Cyrillic) translations 2025-06-23 22:25:04 +05:30
Jannat Patel
c80a900277 chore: Serbian (Latin) translations 2025-06-23 22:25:02 +05:30
Jannat Patel
6fb0394d96 Merge pull request #1588 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-23 18:55:28 +05:30
Jannat Patel
a6a7712039 chore: Serbian (Latin) translations 2025-06-22 22:14:14 +05:30
Jannat Patel
dd0687ba29 chore: Serbian (Cyrillic) translations 2025-06-21 22:08:07 +05:30
Jannat Patel
9cb87a5333 chore: Italian translations 2025-06-21 22:08:06 +05:30
Jannat Patel
8ec93d84a0 chore: Vietnamese translations 2025-06-21 22:08:04 +05:30
Jannat Patel
1d38715db9 chore: Dutch translations 2025-06-21 22:08:03 +05:30
Jannat Patel
6225c4eb35 chore: Czech translations 2025-06-21 22:08:02 +05:30
Jannat Patel
e58ce2fbe6 chore: Esperanto translations 2025-06-21 22:08:00 +05:30
Jannat Patel
8881d62e78 chore: Chinese Simplified translations 2025-06-21 22:07:59 +05:30
Jannat Patel
effb2a1265 chore: Serbian (Latin) translations 2025-06-21 22:07:58 +05:30
Jannat Patel
ab387473b5 chore: Bosnian translations 2025-06-21 22:07:56 +05:30
Jannat Patel
3cf6079b70 chore: Croatian translations 2025-06-21 22:07:55 +05:30
Jannat Patel
53c655bb53 chore: Thai translations 2025-06-21 22:07:54 +05:30
Jannat Patel
87952463c2 chore: Persian translations 2025-06-21 22:07:52 +05:30
Jannat Patel
3a8a63a49a chore: Portuguese, Brazilian translations 2025-06-21 22:07:51 +05:30
Jannat Patel
debe115044 chore: Turkish translations 2025-06-21 22:07:50 +05:30
Jannat Patel
554d2808fd chore: Swedish translations 2025-06-21 22:07:49 +05:30
Jannat Patel
12b2c89a25 chore: Russian translations 2025-06-21 22:07:47 +05:30
Jannat Patel
a66fc3a07e chore: Portuguese translations 2025-06-21 22:07:46 +05:30
Jannat Patel
7b3705cab0 chore: Polish translations 2025-06-21 22:07:45 +05:30
Jannat Patel
8e99e5f5e8 chore: Hungarian translations 2025-06-21 22:07:43 +05:30
Jannat Patel
c5ba5370bb chore: German translations 2025-06-21 22:07:42 +05:30
Jannat Patel
464dec9810 chore: Arabic translations 2025-06-21 22:07:41 +05:30
Jannat Patel
c2e2ec8803 chore: Spanish translations 2025-06-21 22:07:39 +05:30
Jannat Patel
37378e2360 chore: French translations 2025-06-21 22:07:38 +05:30
Jannat Patel
678385d90c Merge pull request #1586 from frappe/pot_develop_2025-06-20
chore: update POT file
2025-06-20 22:25:10 +05:30
frappe-pr-bot
4c461f087f chore: update POT file 2025-06-20 16:04:51 +00:00
Jannat Patel
88a2b69980 feat: exercise form and submission list 2025-06-20 19:59:10 +05:30
Jannat Patel
1f57792da7 Merge pull request #1582 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-19 22:30:20 +05:30
Jannat Patel
9bb4c45a23 feat: programming exercise submission 2025-06-19 14:47:52 +05:30
Jannat Patel
75fd19f491 chore: Serbian (Cyrillic) translations 2025-06-18 21:10:25 +05:30
Jannat Patel
0ac16bdeb7 chore: Italian translations 2025-06-18 21:10:23 +05:30
Jannat Patel
223ee41e10 Merge pull request #1578 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-18 12:15:27 +05:30
Jannat Patel
c126ded82e feat: allow Jammu and Kashmir as state 2025-06-18 11:07:58 +05:30
Jannat Patel
0edf78b7fd feat: programming exercises 2025-06-18 11:06:44 +05:30
Jannat Patel
5af3580987 Merge pull request #1580 from pateljannat/lesson-editor-improvements
fix: lesson editor fixes
2025-06-17 17:04:14 +05:30
Jannat Patel
343cb6f97a fix: removed unused import 2025-06-17 16:46:36 +05:30
Jannat Patel
023c8ac13e fix: lesson editor fixes 2025-06-17 16:38:42 +05:30
Jannat Patel
c385eed795 chore: Vietnamese translations 2025-06-16 20:59:31 +05:30
Jannat Patel
ee5fdd789f chore: Dutch translations 2025-06-16 20:59:29 +05:30
Jannat Patel
df1e400f4e chore: Czech translations 2025-06-16 20:59:28 +05:30
Jannat Patel
6c9c298478 chore: Esperanto translations 2025-06-16 20:59:27 +05:30
Jannat Patel
7106ee150d chore: Chinese Simplified translations 2025-06-16 20:59:26 +05:30
Jannat Patel
03e2287f80 chore: Serbian (Latin) translations 2025-06-16 20:59:24 +05:30
Jannat Patel
2edcd41e24 chore: Bosnian translations 2025-06-16 20:59:23 +05:30
Jannat Patel
0fe043bd99 chore: Croatian translations 2025-06-16 20:59:21 +05:30
Jannat Patel
6686f5240d chore: Thai translations 2025-06-16 20:59:20 +05:30
Jannat Patel
2936facf0f chore: Persian translations 2025-06-16 20:59:18 +05:30
Jannat Patel
cc208f2c43 chore: Portuguese, Brazilian translations 2025-06-16 20:59:17 +05:30
Jannat Patel
9a0fc231e5 chore: Turkish translations 2025-06-16 20:59:16 +05:30
Jannat Patel
bfc0ae62ec chore: Swedish translations 2025-06-16 20:59:14 +05:30
Jannat Patel
5e7d8d97f2 chore: Russian translations 2025-06-16 20:59:13 +05:30
Jannat Patel
70ceb16ed6 chore: Portuguese translations 2025-06-16 20:59:11 +05:30
Jannat Patel
f162fa639f chore: Polish translations 2025-06-16 20:59:10 +05:30
Jannat Patel
f000c72546 chore: Hungarian translations 2025-06-16 20:59:08 +05:30
Jannat Patel
32c01f931c chore: German translations 2025-06-16 20:59:07 +05:30
Jannat Patel
d0121e2b9d chore: Arabic translations 2025-06-16 20:59:06 +05:30
Jannat Patel
1caab8ce1d chore: Spanish translations 2025-06-16 20:59:04 +05:30
Jannat Patel
878be435a1 chore: French translations 2025-06-16 20:59:02 +05:30
Frappe PR Bot
6a68ae989e chore(release): Bumped to Version 2.31.0 2025-06-16 11:49:38 +00:00
Jannat Patel
00993da781 Merge pull request #1577 from pateljannat/issues-115
fix: misc issues
2025-06-16 17:09:33 +05:30
Jannat Patel
e9ef67e402 chore: regenerated yarn lock 2025-06-16 16:55:31 +05:30
Jannat Patel
83ebfececf feat: edit related courses from frontend 2025-06-16 15:13:30 +05:30
Jannat Patel
ec8bf6251f Merge branch 'develop' of https://github.com/frappe/lms into issues-115 2025-06-16 13:07:26 +05:30
Jannat Patel
1b2874b3a5 Merge pull request #1565 from OsafAliSayed/related_courses
Feat: Related courses
2025-06-16 13:07:18 +05:30
Jannat Patel
0ac1053a71 Merge pull request #1575 from frappe/pot_develop_2025-06-13
chore: update POT file
2025-06-16 12:55:02 +05:30
Jannat Patel
224d270952 Merge pull request #1572 from harshpwctech/develop
feat: Embedding for Bunny Stream
2025-06-16 12:54:49 +05:30
Jannat Patel
c6137545cd ci: verify yarn lock file 2025-06-16 12:53:53 +05:30
Jannat Patel
335417f9f4 fix: persona form role issue 2025-06-16 12:44:29 +05:30
Jannat Patel
cb797223ed fix: time markers on video slider for quiz 2025-06-16 12:07:39 +05:30
Jannat Patel
3a2a0313ac fix: show an intermediate dialog informing users of the quiz if its in between video 2025-06-16 11:22:29 +05:30
Jannat Patel
e221a5a73a Merge branch 'develop' of https://github.com/frappe/lms into issues-115 2025-06-16 10:47:47 +05:30
frappe-pr-bot
2b7aaf095f chore: update POT file 2025-06-13 16:04:26 +00:00
Jannat Patel
6f01e7b8d8 fix: job count 2025-06-13 20:33:51 +05:30
Jannat Patel
d594419200 feat: show live class joining and leaving time in attendance list 2025-06-12 23:18:35 +05:30
Jannat Patel
bf50e3f898 Merge pull request #1571 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-11 20:05:43 +05:30
safe user
d434f1781f feat: Embedding for Bunny Stream 2025-06-11 06:53:26 +00:00
safe user
3f311a45ef feat: Embedding for Bunny Stream 2025-06-11 06:42:29 +00:00
Jannat Patel
9293b7796e chore: Serbian (Latin) translations 2025-06-11 03:33:13 +05:30
OsafAliSayed
b1e7883526 fix(relatedCourses): remove loading component 2025-06-10 18:03:43 +00:00
Frappe PR Bot
7fcf6a253d chore(release): Bumped to Version 2.30.0 2025-06-10 10:24:40 +00:00
Jannat Patel
be8d985d15 fix: removed duplicate import 2025-06-10 15:34:19 +05:30
Jannat Patel
974c90dddc Merge branch 'main' into develop 2025-06-10 15:29:39 +05:30
Jannat Patel
4811d395d2 Merge pull request #1568 from pateljannat/issues-114
fix: misc evaluation issues
2025-06-10 11:06:45 +05:30
Jannat Patel
132423d577 Merge pull request #1567 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-10 10:58:22 +05:30
Jannat Patel
10829e2f00 fix: misc evaluation issues 2025-06-10 10:58:03 +05:30
Jannat Patel
47b908c964 chore: Esperanto translations 2025-06-10 03:35:55 +05:30
Jannat Patel
0f8e471d5d chore: Chinese Simplified translations 2025-06-10 03:35:54 +05:30
Jannat Patel
2537119250 chore: Serbian (Latin) translations 2025-06-10 03:35:52 +05:30
Jannat Patel
977066d114 chore: Bosnian translations 2025-06-10 03:35:51 +05:30
Jannat Patel
46e956dc74 chore: Croatian translations 2025-06-10 03:35:50 +05:30
Jannat Patel
7afdd8d44f chore: Thai translations 2025-06-10 03:35:48 +05:30
Jannat Patel
6daf204b4f chore: Persian translations 2025-06-10 03:35:47 +05:30
Jannat Patel
2f4a550a4a chore: Portuguese, Brazilian translations 2025-06-10 03:35:46 +05:30
Jannat Patel
fe214f6b41 chore: Turkish translations 2025-06-10 03:35:44 +05:30
Jannat Patel
ca7de81888 chore: Swedish translations 2025-06-10 03:35:43 +05:30
Jannat Patel
17ce20355a chore: Russian translations 2025-06-10 03:35:42 +05:30
Jannat Patel
34981b4765 chore: Portuguese translations 2025-06-10 03:35:41 +05:30
Jannat Patel
21151a2e09 chore: Polish translations 2025-06-10 03:35:39 +05:30
Jannat Patel
1abb7f5b8c chore: Hungarian translations 2025-06-10 03:35:38 +05:30
Jannat Patel
05998549a4 chore: German translations 2025-06-10 03:35:37 +05:30
Jannat Patel
96283a3629 chore: Arabic translations 2025-06-10 03:35:35 +05:30
Jannat Patel
2bfc7abe9c chore: Spanish translations 2025-06-10 03:35:34 +05:30
Jannat Patel
4f389eca8d chore: French translations 2025-06-10 03:35:33 +05:30
Jannat Patel
1789479955 Merge pull request #1564 from frappe/pot_develop_2025-06-06
chore: update POT file
2025-06-09 19:44:09 +05:30
OsafAliSayed
212800155b style(linter): apply linting fixes 2025-06-09 06:13:21 +00:00
OsafAliSayed
c241bf2104 feat(related-courses): add related courses component 2025-06-09 06:13:21 +00:00
OsafAliSayed
bda61f32f3 feat(related-courses): add related courses frontend 2025-06-09 06:11:40 +00:00
frappe-pr-bot
59316dbaf9 chore: update POT file 2025-06-06 16:04:34 +00:00
Jannat Patel
b726073a5b Merge pull request #1562 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-06 10:52:56 +05:30
Jannat Patel
adf897c812 chore: French translations 2025-06-06 03:03:37 +05:30
Jannat Patel
1fc4c2442c Merge pull request #1561 from pateljannat/evaluator-link-in-batch
fix batch instructor should be linked to evaluator
2025-06-05 15:06:46 +05:30
Jannat Patel
414643ee90 test: link evaluator as batch instructor 2025-06-05 14:46:09 +05:30
Jannat Patel
1a1cbd6ea1 fix: batch instructor should be linked to evaluator 2025-06-05 12:58:12 +05:30
Jannat Patel
9ae809a62f Merge pull request #1559 from pateljannat/issues-113
fix: dont allow enrollment is self learning is disabled from api
2025-06-05 12:53:42 +05:30
Jannat Patel
eb9b1c905d Merge pull request #1558 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-05 10:46:03 +05:30
Jannat Patel
fe9a8f49c1 fix: dont allow enrollment is self learning is disabled from api 2025-06-05 10:43:25 +05:30
Jannat Patel
f912c8fce3 chore: French translations 2025-06-05 02:22:54 +05:30
Jannat Patel
1d1ca43c35 chore: Serbian (Latin) translations 2025-06-04 01:58:16 +05:30
Jannat Patel
bce45f44e4 Merge pull request #1557 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-03 13:23:54 +05:30
Jannat Patel
07583fb563 fix: show error in toast when scheduling evaluations 2025-06-03 12:47:57 +05:30
Jannat Patel
775aa23992 chore: Esperanto translations 2025-06-03 02:00:23 +05:30
Jannat Patel
05ed6b7e73 chore: Chinese Simplified translations 2025-06-03 02:00:22 +05:30
Jannat Patel
d602694ea7 chore: Serbian (Latin) translations 2025-06-03 02:00:20 +05:30
Jannat Patel
18d71bc0d4 chore: Bosnian translations 2025-06-03 02:00:19 +05:30
Jannat Patel
3fa68643ba chore: Croatian translations 2025-06-03 02:00:18 +05:30
Jannat Patel
8904525c36 chore: Thai translations 2025-06-03 02:00:16 +05:30
Jannat Patel
3ce09a98f3 chore: Persian translations 2025-06-03 02:00:15 +05:30
Jannat Patel
b833768e71 chore: Portuguese, Brazilian translations 2025-06-03 02:00:14 +05:30
Jannat Patel
b9a6afd993 chore: Turkish translations 2025-06-03 02:00:12 +05:30
Jannat Patel
b5a81ea927 chore: Swedish translations 2025-06-03 02:00:11 +05:30
Jannat Patel
750e92cdde chore: Russian translations 2025-06-03 02:00:09 +05:30
Jannat Patel
da45f4c011 chore: Portuguese translations 2025-06-03 02:00:08 +05:30
Jannat Patel
544bb5c11c chore: Polish translations 2025-06-03 02:00:07 +05:30
Jannat Patel
1fc6f62f70 chore: Hungarian translations 2025-06-03 02:00:05 +05:30
Jannat Patel
8751ad27ec chore: German translations 2025-06-03 02:00:04 +05:30
Jannat Patel
159d3d5b87 chore: Arabic translations 2025-06-03 02:00:03 +05:30
Jannat Patel
34d6d99d8c chore: Spanish translations 2025-06-03 02:00:01 +05:30
Jannat Patel
6c46931b1a chore: French translations 2025-06-03 02:00:00 +05:30
Jannat Patel
2c3e2d9d08 Merge pull request #1554 from pateljannat/quiz-in-video
feat: show quiz in between videos
2025-06-02 19:35:55 +05:30
Jannat Patel
7be1562fa4 fix: simplified timestamp label 2025-06-02 19:18:27 +05:30
Jannat Patel
294389e7c7 Merge branch 'develop' of https://github.com/frappe/lms into quiz-in-video 2025-06-02 19:16:27 +05:30
Jannat Patel
2c8ce133f7 fix: quiz and time validation before linking to video 2025-06-02 19:12:13 +05:30
Ankush Menat
4f1d4d90d0 fix: remove invasive configs (#1555) 2025-06-02 19:04:55 +05:30
Jannat Patel
7b7484332b feat: quiz in videos 2025-06-02 18:18:13 +05:30
Jannat Patel
50e94b85aa chore: resolved conflicts 2025-06-02 12:24:16 +05:30
Jannat Patel
9b820594ef Merge pull request #1553 from pateljannat/batch-test
test: batch creation flow
2025-06-02 12:22:58 +05:30
Jannat Patel
ddcd45d56d test: don't add course to batch 2025-06-02 12:15:34 +05:30
Jannat Patel
c4a4c16516 test: batch creation flow 2025-06-02 10:48:54 +05:30
Jannat Patel
5ae9ad0762 Merge pull request #1552 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-06-02 10:38:49 +05:30
Jannat Patel
405f7d498e Merge pull request #1548 from frappe/pot_develop_2025-05-30
chore: update POT file
2025-06-02 10:38:39 +05:30
Jannat Patel
bcd6a5b1e7 chore: Persian translations 2025-06-02 01:08:03 +05:30
Jannat Patel
e5e5ac994c Merge pull request #1550 from pateljannat/issues-112
fix: misc issues
2025-05-31 12:49:16 +05:30
Jannat Patel
e1f8d6ec49 fix: count of jobs and certified members 2025-05-31 12:41:58 +05:30
Jannat Patel
6f50242f5a fix: misc issues 2025-05-31 11:52:25 +05:30
frappe-pr-bot
036f7ece05 chore: update POT file 2025-05-30 16:04:24 +00:00
Jannat Patel
622a2ff072 feat: display quiz when time is reached 2025-05-30 18:55:26 +05:30
Jannat Patel
60334ca04a feat: show quiz in between videos 2025-05-30 13:00:00 +05:30
Jannat Patel
ade47b4e83 Merge pull request #1547 from pateljannat/seo-in-forms
feat: seo tags and keywords for courses and batches
2025-05-30 10:19:29 +05:30
Jannat Patel
d7e550dfea feat: seo tags and keywords for courses and batches 2025-05-29 20:13:35 +05:30
Jannat Patel
c3cc0b9bf7 Merge pull request #1546 from pateljannat/issues-111
fix: misc issues
2025-05-29 16:29:57 +05:30
Jannat Patel
5ad89189c1 fix: changed certified members count based on filters 2025-05-29 16:09:51 +05:30
Jannat Patel
f1bbd4eb13 fix: settings ui cleanup 2025-05-29 16:09:14 +05:30
Jannat Patel
fba89dfacb feat: show unpushlished courses to admins on frontend 2025-05-29 12:50:25 +05:30
Jannat Patel
b93ed41215 fix: course and chapter permissions to moderators 2025-05-29 12:49:30 +05:30
Jannat Patel
13ff6a7304 chore: record sessions while creating courses and lessons 2025-05-29 12:48:58 +05:30
Jannat Patel
ad97405e55 Merge pull request #1544 from Rl0007/fix/edit-profile-escape-html
fix: Edit profile escape html
2025-05-28 20:20:53 +05:30
Rahul Agrawal
376e231d7b chore: remove unwanted line profile.bio = profile.bio 2025-05-28 16:00:14 +05:30
Rahul Agrawal
e16d76f6dd fix: remove escapeHtml from edit profile bio on save 2025-05-28 15:44:54 +05:30
Jannat Patel
ffd0fd92fc Merge pull request #1542 from pateljannat/zoom-refactor
feat: multiple zoom accounts and zoom attendance
2025-05-28 12:06:21 +05:30
Jannat Patel
933613d730 fix: jobs list header issue 2025-05-28 11:22:03 +05:30
Jannat Patel
9b0673bf92 feat: zoom attendance 2025-05-27 23:01:04 +05:30
Jannat Patel
7cba22aa28 Merge pull request #1539 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-27 12:02:06 +05:30
Jannat Patel
af05b614a9 feat: delete zoom accounts from settings 2025-05-27 11:40:22 +05:30
Jannat Patel
c0fa219a8b chore: Esperanto translations 2025-05-26 23:41:15 +05:30
Jannat Patel
4e3a47b0f4 chore: Chinese Simplified translations 2025-05-26 23:41:14 +05:30
Jannat Patel
161276b58a chore: Serbian (Latin) translations 2025-05-26 23:41:13 +05:30
Jannat Patel
47713019a5 chore: Bosnian translations 2025-05-26 23:41:11 +05:30
Jannat Patel
010632a21d chore: Croatian translations 2025-05-26 23:41:10 +05:30
Jannat Patel
e77fe550af chore: Thai translations 2025-05-26 23:41:09 +05:30
Jannat Patel
0a4233da14 chore: Persian translations 2025-05-26 23:41:08 +05:30
Jannat Patel
56fb70ab1e chore: Portuguese, Brazilian translations 2025-05-26 23:41:06 +05:30
Jannat Patel
4a1f2bc01d chore: Turkish translations 2025-05-26 23:41:05 +05:30
Jannat Patel
20292fbf16 chore: Swedish translations 2025-05-26 23:41:04 +05:30
Jannat Patel
1290cf8991 chore: Russian translations 2025-05-26 23:41:02 +05:30
Jannat Patel
b8b8af7cf1 chore: Portuguese translations 2025-05-26 23:41:01 +05:30
Jannat Patel
75f4f452d3 chore: Polish translations 2025-05-26 23:41:00 +05:30
Jannat Patel
9de492384f chore: Hungarian translations 2025-05-26 23:40:58 +05:30
Jannat Patel
14c4e161f2 chore: German translations 2025-05-26 23:40:57 +05:30
Jannat Patel
c55efbc0ba chore: Arabic translations 2025-05-26 23:40:56 +05:30
Jannat Patel
f0610222d9 chore: Spanish translations 2025-05-26 23:40:55 +05:30
Jannat Patel
302ee4a50f chore: French translations 2025-05-26 23:40:53 +05:30
Jannat Patel
2170819159 chore: telemetry fixes 2025-05-26 22:02:46 +05:30
Jannat Patel
0d1fac321a feat: zoom settings on frontend 2025-05-26 21:35:13 +05:30
Jannat Patel
dbbc1756dd Merge branch 'develop' of https://github.com/frappe/lms into zoom-refactor 2025-05-26 21:27:18 +05:30
Jannat Patel
d5b882d3f8 feat: multiple zoom accounts 2025-05-26 18:08:17 +05:30
Frappe PR Bot
3025ea9a7b chore(release): Bumped to Version 2.29.0 2025-05-26 10:05:36 +00:00
Jannat Patel
5dba4d1384 Merge pull request #1537 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-26 15:33:03 +05:30
Jannat Patel
e4f1e7b093 Merge pull request #1536 from pateljannat/telemetry-fixes
chore: fix posthog init condition
2025-05-26 15:21:34 +05:30
Jannat Patel
d0a0597087 chore: removed unused imports 2025-05-26 15:08:41 +05:30
Jannat Patel
c9ccf9a1b5 chore: fix posthog init condition 2025-05-26 15:02:51 +05:30
Jannat Patel
69107d4441 Merge pull request #1535 from pateljannat/refactor-batch-charts
refactor: use frappe-ui for batch progress charts
2025-05-26 12:43:15 +05:30
Jannat Patel
e25afc1ef7 chore: fixed formating 2025-05-26 12:32:34 +05:30
Jannat Patel
9babfd150e fix: course count on batch dashboard 2025-05-26 12:25:16 +05:30
Jannat Patel
532dbbea4a fix: restricted minimum chart interval to 1 2025-05-26 11:34:52 +05:30
Jannat Patel
0d284d05d9 Merge pull request #1534 from pateljannat/issues-110
fix: misc issues
2025-05-26 11:22:16 +05:30
Jannat Patel
28fccae3ac Merge pull request #1532 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-26 11:05:01 +05:30
Jannat Patel
3a4a6da69c Merge pull request #1531 from frappe/pot_develop_2025-05-23
chore: update POT file
2025-05-26 11:04:50 +05:30
Jannat Patel
4ea07a95e7 fix: show batch CTA's on mobile 2025-05-26 11:04:00 +05:30
Jannat Patel
80ceb49358 fix: login menu now works on all browsers and devices 2025-05-26 10:58:17 +05:30
Jannat Patel
589337116a fix: added dependencies for onboarding steps 2025-05-26 09:59:05 +05:30
Jannat Patel
cb50067223 chore: Chinese Simplified translations 2025-05-24 23:16:12 +05:30
Jannat Patel
4d63266d88 chore: Serbian (Latin) translations 2025-05-24 23:16:11 +05:30
Jannat Patel
90dd33ce21 chore: Serbian (Latin) translations 2025-05-23 23:13:03 +05:30
frappe-pr-bot
763b849ddf chore: update POT file 2025-05-23 16:04:27 +00:00
Jannat Patel
9c76c54283 Merge pull request #1528 from pateljannat/email-template-list
feat: email template in settings
2025-05-23 15:35:11 +05:30
Md Hussain Nagaria
5cb17b3a36 Merge pull request #1529 from frappe/misc-fixes 2025-05-23 11:01:25 +02:00
Hussain Nagaria
2f7b5d1cbb fix: use unavailabilityMessage if set 2025-05-23 14:28:47 +05:30
Hussain Nagaria
4fe14eb2e9 fix: early return cleanup 2025-05-23 14:26:22 +05:30
Jannat Patel
eb089f2b58 fix: return payment fields data after transform 2025-05-23 14:25:29 +05:30
Hussain Nagaria
4f0ac98eea fix: toast used but not imported 2025-05-23 14:02:04 +05:30
Hussain Nagaria
af19940fa1 fix: some code semantics 2025-05-23 14:00:11 +05:30
Jannat Patel
5635d2a325 feat: email template update and deletion 2025-05-23 13:28:18 +05:30
Jannat Patel
5e2de35693 refactor: layout of payment fields 2025-05-22 21:39:49 +05:30
Jannat Patel
ef7180f23f Merge pull request #1523 from pateljannat/issues-109
refactor: misc enhancements
2025-05-22 12:12:57 +05:30
Jannat Patel
f939973d4f fix: don't validate number of students if seat count is 0 in batch 2025-05-22 12:03:07 +05:30
Jannat Patel
63f327733e refactor: category list in settings 2025-05-22 11:54:54 +05:30
Jannat Patel
c1fb807fe4 fix: show onboarding banner when redirected from other pages 2025-05-21 17:52:24 +05:30
Jannat Patel
b7ddf44267 test: close onboaring popover before creating course 2025-05-21 17:35:48 +05:30
Jannat Patel
6d4c72ea5e fix: rating input style on course details page 2025-05-21 16:28:04 +05:30
Jannat Patel
3db11b9372 refactor: moved batch feedback to sidebar 2025-05-21 16:08:49 +05:30
Jannat Patel
b8714f4abe refactor: batch progress chart will now use frappe-ui components 2025-05-21 13:13:42 +05:30
Jannat Patel
7ccbe74bbe chore: fixed conflicts 2025-05-20 19:09:19 +05:30
Jannat Patel
ea3ae3516b Merge pull request #1513 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-20 19:05:38 +05:30
Jannat Patel
d33af3ca52 chore: Esperanto translations 2025-05-19 21:33:54 +05:30
Jannat Patel
291c3fa908 chore: Serbian (Latin) translations 2025-05-19 21:33:53 +05:30
Jannat Patel
a51fa58122 chore: Bosnian translations 2025-05-19 21:33:52 +05:30
Jannat Patel
65a3967abd chore: Croatian translations 2025-05-19 21:33:50 +05:30
Jannat Patel
e1e5c94a43 chore: Thai translations 2025-05-19 21:33:49 +05:30
Jannat Patel
f15127eceb chore: Chinese Simplified translations 2025-05-19 21:33:47 +05:30
Jannat Patel
071a238b71 chore: Persian translations 2025-05-19 21:33:46 +05:30
Jannat Patel
050b052156 chore: Portuguese, Brazilian translations 2025-05-19 21:33:44 +05:30
Jannat Patel
8f65cca776 chore: Turkish translations 2025-05-19 21:33:43 +05:30
Jannat Patel
66624a8c47 chore: Swedish translations 2025-05-19 21:33:41 +05:30
Jannat Patel
c8b9a415e6 chore: Russian translations 2025-05-19 21:33:40 +05:30
Jannat Patel
a1dcb4c203 chore: Portuguese translations 2025-05-19 21:33:39 +05:30
Jannat Patel
d4edc3e622 chore: Polish translations 2025-05-19 21:33:37 +05:30
Jannat Patel
e2b8c3ee0e chore: Hungarian translations 2025-05-19 21:33:35 +05:30
Jannat Patel
c37816e90d chore: German translations 2025-05-19 21:33:34 +05:30
Jannat Patel
a35cfcdca7 chore: Arabic translations 2025-05-19 21:33:32 +05:30
Jannat Patel
d381646226 chore: Spanish translations 2025-05-19 21:33:31 +05:30
Jannat Patel
285e7afec2 chore: French translations 2025-05-19 21:33:30 +05:30
Jannat Patel
df7d678c32 Merge pull request #1510 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-19 14:24:21 +05:30
Jannat Patel
f36f7e58de Merge pull request #1511 from frappe/pot_develop_2025-05-16
chore: update POT file
2025-05-19 14:24:10 +05:30
Jannat Patel
0e16c834d8 chore: Serbian (Latin) translations 2025-05-18 21:37:27 +05:30
frappe-pr-bot
31a3256128 chore: update POT file 2025-05-16 16:04:20 +00:00
Jannat Patel
aa8f70da28 chore: Swedish translations 2025-05-16 21:09:45 +05:30
Frappe PR Bot
f375ffb8f8 chore(release): Bumped to Version 2.28.1 2025-05-16 06:36:08 +00:00
Jannat Patel
de240e40a5 Merge pull request #1507 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-16 12:01:07 +05:30
Jannat Patel
7d30aea07f Merge pull request #1509 from pateljannat/issues-108
fix: misc issues
2025-05-16 11:55:31 +05:30
Jannat Patel
04a7361d0d fix: verify if score_out_of is not 0 before calculating percentage 2025-05-16 11:40:03 +05:30
Jannat Patel
7b19618eca fix: basic cleanup of quiz submission form 2025-05-16 11:25:43 +05:30
Jannat Patel
bd9600cc08 fix: list index error on quiz submission 2025-05-16 11:07:47 +05:30
Jannat Patel
32172bc791 chore: fixed redis issue faced during docker setup 2025-05-16 09:53:45 +05:30
Jannat Patel
c92f57fb07 Merge pull request #1508 from pateljannat/issues-107
fix: ask for role in persona form
2025-05-15 21:51:05 +05:30
Jannat Patel
8fbdea7f36 fix: ask for role in persona form 2025-05-15 19:57:43 +05:30
Jannat Patel
df15da5145 Merge branch 'develop' of https://github.com/frappe/lms into develop 2025-05-15 09:40:09 +05:30
Jannat Patel
846fe53c0f fix: show persona form after course count has been fetched 2025-05-15 09:38:22 +05:30
Jannat Patel
3bbdc828d9 Merge pull request #1506 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-14 17:48:42 +05:30
Jannat Patel
c454c3f0f2 Merge pull request #1505 from pateljannat/issues-106
fix: plyr will now work on all videos of a lesson
2025-05-14 17:33:14 +05:30
Jannat Patel
77b1a546e8 fix: plyr will now work on all videos of a lesson 2025-05-14 17:12:20 +05:30
Jannat Patel
7c7f063204 Merge pull request #1502 from pateljannat/issues-105
fix: misc fixes
2025-05-14 14:10:16 +05:30
Jannat Patel
0a0fcb305c fix: settings modal size 2025-05-14 13:19:03 +05:30
Jannat Patel
da8028784d chore: changed cypress config to esm 2025-05-14 11:52:48 +05:30
Jannat Patel
48edd888a6 chore: changed file to esm 2025-05-14 11:30:11 +05:30
Jannat Patel
da4f134095 fix: misc ui issues 2025-05-13 20:04:39 +05:30
Jannat Patel
0a71620046 fix: misc ui issues 2025-05-13 20:04:06 +05:30
Jannat Patel
1b5a762578 Merge pull request #1500 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-13 14:10:58 +05:30
Jannat Patel
d9d031ed2b refactor: new toast api 2025-05-13 14:08:04 +05:30
Jannat Patel
403e56b4ef fix: misc batch issues 2025-05-13 11:46:00 +05:30
Jannat Patel
499b06e300 chore: Esperanto translations 2025-05-12 20:56:10 +05:30
Jannat Patel
cb69540bdd chore: Chinese Simplified translations 2025-05-12 20:56:08 +05:30
Jannat Patel
1f27fa419a chore: Serbian (Latin) translations 2025-05-12 20:56:06 +05:30
Jannat Patel
a561b2bd91 chore: Bosnian translations 2025-05-12 20:56:05 +05:30
Jannat Patel
eeec85d1de chore: Croatian translations 2025-05-12 20:56:04 +05:30
Jannat Patel
e01484f854 chore: Thai translations 2025-05-12 20:56:02 +05:30
Jannat Patel
fb996ded88 chore: Persian translations 2025-05-12 20:56:01 +05:30
Jannat Patel
a11bfca15a chore: Portuguese, Brazilian translations 2025-05-12 20:55:59 +05:30
Jannat Patel
6262e1c9e6 chore: Turkish translations 2025-05-12 20:55:57 +05:30
Jannat Patel
4e318af7cc chore: Swedish translations 2025-05-12 20:55:55 +05:30
Jannat Patel
d587b7867e chore: Russian translations 2025-05-12 20:55:54 +05:30
Jannat Patel
bd03ead9c3 chore: Portuguese translations 2025-05-12 20:55:53 +05:30
Jannat Patel
c1685b7128 chore: Polish translations 2025-05-12 20:55:51 +05:30
Jannat Patel
7625e79574 chore: Hungarian translations 2025-05-12 20:55:50 +05:30
Jannat Patel
c5bf7875b9 chore: German translations 2025-05-12 20:55:48 +05:30
Jannat Patel
da026293bc chore: Arabic translations 2025-05-12 20:55:46 +05:30
Jannat Patel
86e5677574 chore: Spanish translations 2025-05-12 20:55:45 +05:30
Jannat Patel
a48636604f chore: French translations 2025-05-12 20:55:43 +05:30
Jannat Patel
e6945ac076 fix: all empty states now come from a common component 2025-05-12 17:46:23 +05:30
Jannat Patel
9107d76522 fix: batch form cleanup 2025-05-12 15:04:35 +05:30
Jannat Patel
52b925b306 fix: course form cleanup 2025-05-12 13:07:06 +05:30
Jannat Patel
49d3dc0aa0 Merge pull request #1498 from frappe/pot_develop_2025-05-09
chore: update POT file
2025-05-12 10:53:20 +05:30
Jannat Patel
0d41a1ae70 refactor: use frappe-ui for batch progress charts 2025-05-12 10:37:26 +05:30
frappe-pr-bot
49e22d790a chore: update POT file 2025-05-09 16:04:15 +00:00
Jannat Patel
12e5eedd6b Merge pull request #1494 from pateljannat/issues-104
fix: misc issues
2025-05-08 16:01:14 +05:30
Jannat Patel
159b651871 fix: dark mode for upcoming evaluations 2025-05-08 15:29:04 +05:30
Jannat Patel
080be7a885 fix: tooltips for number cards on statistics page 2025-05-08 15:10:51 +05:30
Jannat Patel
e526627eb9 fix: only show published certificate on the statistics page 2025-05-08 15:05:07 +05:30
Jannat Patel
67fc37c76c fix: ui of job details 2025-05-08 14:53:34 +05:30
Jannat Patel
b2b92aea31 chore: merged upstream 2025-05-07 22:04:53 +05:30
Jannat Patel
e0680d9612 chore: merged upstream 2025-05-07 22:03:57 +05:30
Jannat Patel
d54ac37403 Merge pull request #1491 from pateljannat/issues-103
fix: only assign lms roles to admin
2025-05-07 21:49:38 +05:30
Jannat Patel
eedb3d3dd8 fix: only assign lms roles to admin 2025-05-07 21:40:43 +05:30
Jannat Patel
d286df649e Merge pull request #1490 from frappe/develop
chore: merge 'develop' into 'main'
2025-05-07 12:56:56 +05:30
Jannat Patel
e0cbc247b2 Merge pull request #1485 from pateljannat/release-conflicts
chore: merge to 'main'
2025-05-06 13:10:08 +05:30
Jannat Patel
a2c8a82559 chore: merged conflicts 2025-05-06 12:59:22 +05:30
Jannat Patel
8b91323705 Merge pull request #1457 from pateljannat/vimeo
fix: allow fullscreen on vimeo
2025-04-21 17:32:12 +05:30
Jannat Patel
89fdbf5660 test: find the course image label and attach course image to its sibling input 2025-04-21 17:10:33 +05:30
Jannat Patel
7ed5dfdb8f fix: allow fullscreen on video and adjust video height on mobile devices 2025-04-21 16:34:34 +05:30
Jannat Patel
824c65eb38 Merge pull request #1440 from frappe/develop
chore: merge 'develop' into 'main'
2025-04-16 18:55:21 +05:30
Jannat Patel
e43eeeba4a Merge pull request #1423 from frappe/develop
chore: merge 'develop' into 'main'
2025-04-10 16:01:54 +05:30
Jannat Patel
9e2c7cc145 Merge pull request #1417 from frappe/develop
chore: merge 'develop' into 'main'
2025-04-09 15:17:25 +05:30
Jannat Patel
989598b9cd Merge pull request #1398 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-27 09:44:00 +05:30
Jannat Patel
6a41942de6 Merge pull request #1384 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-20 13:04:16 +05:30
Jannat Patel
d263072aca Merge pull request #1373 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-12 11:10:48 +05:30
Jannat Patel
78c8467bf6 Merge pull request #1361 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-05 18:33:33 +05:30
Jannat Patel
084908bd04 Merge pull request #1352 from frappe/develop
chore: merge 'develop' into 'main'
2025-03-03 15:38:08 +05:30
Jannat Patel
039a775ce4 Merge pull request #1340 from frappe/develop
chore: merge 'develop' into 'main'
2025-02-26 10:18:26 +05:30
Jannat Patel
dd9e80f067 Merge pull request #1326 from frappe/develop
chore: merge 'develop' into 'main'
2025-02-19 11:06:43 +05:30
Jannat Patel
a3a2af948e Merge pull request #1303 from frappe/develop
chore: merge 'develop' into 'main'
2025-02-14 18:07:10 +05:30
Jannat Patel
0bedf3ea59 Merge pull request #1264 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-22 12:59:34 +05:30
Jannat Patel
1775ac4803 Merge pull request #1247 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-15 11:24:49 +05:30
Jannat Patel
ae1a615863 Merge pull request #1237 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-09 11:01:37 +05:30
Jannat Patel
a6ef1b8902 Merge pull request #1220 from frappe/develop
chore: merge 'develop' into 'main'
2025-01-02 20:24:58 +05:30
Jannat Patel
94d17b81d4 Merge pull request #1197 from frappe/develop
chore: merge 'develop' into 'main'
2024-12-18 20:30:52 +05:30
Jannat Patel
44a63d9cec Merge pull request #1180 from frappe/develop
chore: merge 'develop' into 'main'
2024-12-13 12:27:07 +05:30
Jannat Patel
e2b4b5a57e Merge pull request #1164 from frappe/develop
chore: merge 'develop' into 'main'
2024-12-06 11:05:49 +05:30
Jannat Patel
ec30aa323e Merge pull request #1155 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-29 17:05:30 +05:30
Jannat Patel
95e9087c6e Merge pull request #1151 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-25 15:06:00 +05:30
Jannat Patel
db38099557 Merge pull request #1143 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-20 13:30:06 +05:30
Jannat Patel
164d5cdec9 Merge pull request #1130 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-13 11:53:57 +05:30
Jannat Patel
c6b1076092 Merge pull request #1099 from frappe/develop
chore: merge 'develop' into 'main'
2024-11-07 09:51:07 +05:30
Jannat Patel
6aebe856da Merge pull request #1087 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-31 12:15:12 +05:30
Jannat Patel
4737551918 Merge pull request #1075 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-23 13:00:37 +05:30
Jannat Patel
c2cb79f700 Merge pull request #1067 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-17 12:33:35 +05:30
Jannat Patel
d7c05984be Merge pull request #1048 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-09 22:11:23 +05:30
Jannat Patel
55429e2f03 Merge pull request #1036 from frappe/develop
chore: merge 'develop' into 'main'
2024-10-02 12:30:46 +05:30
Jannat Patel
25ffe8b0e4 Merge pull request #1029 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-25 11:47:14 +05:30
Jannat Patel
303a9d1110 Merge pull request #1020 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-18 10:21:11 +05:30
Jannat Patel
de8c907c51 Merge pull request #1013 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-11 11:09:55 +05:30
Jannat Patel
0fd1cabd60 Merge pull request #1003 from frappe/develop
chore: merge 'develop' into 'main'
2024-09-04 10:36:05 +05:30
Jannat Patel
8dd480735c Merge pull request #996 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-28 11:24:59 +05:30
Jannat Patel
676f1a1f0e Merge pull request #984 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-21 10:48:23 +05:30
Jannat Patel
ce75422126 Merge pull request #966 from frappe/develop
chore: merge 'develop' into 'main'
2024-08-14 11:24:10 +05:30
Jannat Patel
3a097d6b15 Merge pull request #956 from frappe/develop
chore: Merge develop into main
2024-08-06 11:27:00 +05:30
Jannat Patel
9de1bf1020 Merge pull request #954 from frappe/develop
chore: Merge develop into main
2024-08-05 14:47:45 +05:30
Jannat Patel
93e5cf1c25 Merge pull request #952 from frappe/develop
chore: Merge develop to main
2024-08-05 12:22:05 +05:30
Jannat Patel
6e2376570b Merge pull request #949 from frappe/develop
chore: Merge develop to main
2024-08-01 17:16:22 +05:30
Jannat Patel
b20c4bf197 Merge pull request #948 from frappe/develop
chore: Merge develop to main
2024-07-31 16:33:43 +05:30
Jannat Patel
6ae1d92033 Merge pull request #925 from frappe/develop
chore: merge `develop` into `main`
2024-07-11 09:11:50 +05:30
259 changed files with 94514 additions and 24933 deletions

View File

@@ -5,7 +5,7 @@ echo "Setting Up System Dependencies..."
sudo apt update
sudo apt remove mysql-server mysql-client
sudo apt-get install libcups2-dev redis-server mariadb-client
sudo apt-get install libcups2-dev redis-server mariadb-client libmariadb-dev
install_wkhtmltopdf() {
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb

View File

@@ -1,7 +1,7 @@
name: Create weekly release
on:
schedule:
- cron: '30 4 15 * *'
- cron: '30 3 * * 3'
workflow_dispatch:
jobs:

View File

@@ -105,7 +105,7 @@ jobs:
- name: cypress pre-requisites
run: |
cd ~/frappe-bench/apps/lms
yarn add cypress@^10 --no-lockfile
yarn add cypress@^10 --no-lockfile -W
- name: UI Tests
run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="HtmlUnknownBooleanAttribute" enabled="true" level="INFORMATION" enabled_by_default="true" editorAttributes="INFORMATION_ATTRIBUTES" />
</profile>
</component>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.11 (env)" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_24" project-jdk-name="Python 3.11 (env)" project-jdk-type="Python SDK" />
</project>

9
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/frontend/frontend.iml" filepath="$PROJECT_DIR$/frontend/frontend.iml" />
<module fileurl="file://$PROJECT_DIR$/lms/lms.iml" filepath="$PROJECT_DIR$/lms/lms.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

265
.idea/workspace.xml generated Normal file
View File

@@ -0,0 +1,265 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8ceac89e-548b-4898-a7d0-cd9692210a67" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/frontend/vite.config.js" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/vite.config.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/ar.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/ar.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/bs.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/bs.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/cs.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/cs.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/de.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/de.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/eo.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/eo.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/es.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/es.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/fa.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/fa.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/fr.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/fr.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/hr.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/hr.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/hu.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/hu.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/id.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/id.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/it.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/it.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/main.pot" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/main.pot" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/nl.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/nl.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/pl.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/pl.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/pt.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/pt.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/pt_BR.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/pt_BR.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/ru.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/ru.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/sr.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/sr.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/sr_CS.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/sr_CS.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/sv.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/sv.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/th.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/th.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/tr.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/tr.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/vi.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/vi.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/lms/locale/zh.po" beforeDir="false" afterPath="$PROJECT_DIR$/lms/locale/zh.po" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="ComposerSettings">
<execution />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="GitHubPullRequestSearchHistory">{
&quot;lastFilter&quot;: {
&quot;state&quot;: &quot;OPEN&quot;,
&quot;assignee&quot;: &quot;stanig2106&quot;
}
}</component>
<component name="GithubPullRequestsUISettings">{
&quot;selectedUrlAndAccountId&quot;: {
&quot;url&quot;: &quot;https://github.com/eduvia-app/lms&quot;,
&quot;accountId&quot;: &quot;07629746-0347-4709-8dfa-2d0f080158cf&quot;
}
}</component>
<component name="KubernetesApiPersistence">{}</component>
<component name="KubernetesApiProvider">{
&quot;isMigrated&quot;: true
}</component>
<component name="MacroExpansionManager">
<option name="directoryName" value="zkk5YL6F" />
</component>
<component name="PhpWorkspaceProjectConfiguration" interpreter_name="/opt/homebrew/Cellar/php/8.3.7/bin/php" />
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 3
}</component>
<component name="ProjectId" id="31EuJyp79zrs7MLT76Y4wgzYgGe" />
<component name="ProjectViewState">
<option name="showLibraryContents" value="true" />
<option name="showMembers" value="true" />
<option name="showVisibilityIcons" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.go.formatter.settings.were.checked": "true",
"RunOnceActivity.go.migrated.go.modules.settings": "true",
"dart.analysis.tool.window.visible": "false",
"git-widget-placeholder": "eduvia/prod",
"go.import.settings.migrated": "true",
"junie.onboarding.icon.badge.shown": "true",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "yarn",
"org.rust.cargo.project.model.impl.CargoExternalSystemProjectAware.subscribe.first.balloon": "",
"org.rust.first.attach.projects": "true",
"project.structure.last.edited": "Modules",
"project.structure.proportion": "0.15",
"project.structure.side.proportion": "0.2",
"settings.editor.selected.configurable": "application.passwordSafe",
"to.speed.mode.migration.done": "true",
"ts.external.directory.path": "/Users/stani/Documents/campus-prive/frappe-bench/apps/lms/frontend/node_modules/typescript/lib",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RubyModuleManagerSettings">
<option name="blackListedRootsPaths">
<list>
<option value="$PROJECT_DIR$/lms" />
</list>
</option>
</component>
<component name="RunManager">
<configuration default="true" type="GoApplicationRunConfiguration" factoryName="Go Application">
<module name="lms" />
<working_directory value="$PROJECT_DIR$" />
<go_parameters value="-i" />
<kind />
<directory value="$PROJECT_DIR$" />
<filePath value="$PROJECT_DIR$" />
<method v="2" />
</configuration>
<configuration default="true" type="GoTestRunConfiguration" factoryName="Go Test">
<module name="lms" />
<working_directory value="$PROJECT_DIR$" />
<go_parameters value="-i" />
<kind />
<directory value="$PROJECT_DIR$" />
<filePath value="$PROJECT_DIR$" />
<framework value="gotest" />
<method v="2" />
</configuration>
<configuration default="true" type="JetRunConfigurationType">
<module name="lms" />
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<configuration default="true" type="KotlinStandaloneScriptRunConfigurationType">
<option name="filePath" />
<method v="2" />
</configuration>
<configuration default="true" type="PythonConfigurationType" factoryName="Python">
<module name="lms" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<configuration default="true" type="Python.FlaskServer">
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<module name="" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="launchJavascriptDebuger" value="false" />
<method v="2" />
</configuration>
<configuration default="true" type="Tox" factoryName="Tox">
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<method v="2" />
</configuration>
<configuration default="true" type="tests" factoryName="Autodetect">
<module name="lms" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="_new_additionalArguments" value="&quot;&quot;" />
<option name="_new_target" value="&quot;&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
<configuration default="true" type="tests" factoryName="Doctests">
<module name="lms" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="" />
<option name="CLASS_NAME" value="" />
<option name="METHOD_NAME" value="" />
<option name="FOLDER_NAME" value="" />
<option name="TEST_TYPE" value="TEST_SCRIPT" />
<option name="PATTERN" value="" />
<option name="USE_PATTERN" value="false" />
<method v="2" />
</configuration>
</component>
<component name="RustProjectSettings">
<option name="toolchainHomeDirectory" value="$USER_HOME$/.cargo/bin" />
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-b26f3e71634d-JavaScript-IU-251.26094.121" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="8ceac89e-548b-4898-a7d0-cd9692210a67" name="Changes" comment="" />
<created>1755101486887</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1755101486887</updated>
<workItem from="1755101488483" duration="947000" />
<workItem from="1755102648895" duration="6458000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VgoProject">
<settings-migrated>true</settings-migrated>
</component>
</project>

View File

@@ -118,6 +118,10 @@ Replace the following parameters with your values:
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
**Note:** To avoid a `404 Page Not Found` error:
- If hosting on a **public server**, make sure your DNS **A record** points to your server's IP.
- If hosting **locally**, map your domain to `127.0.0.1` in your `/etc/hosts` file:
## Development Setup
### Docker

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
parserPreset: "conventional-changelog-conventionalcommits",
rules: {
"subject-empty": [2, "never"],

View File

@@ -1,6 +1,6 @@
const { defineConfig } = require("cypress");
import { defineConfig } from "cypress";
module.exports = defineConfig({
export default defineConfig({
projectId: "vandxn",
adminPassword: "admin",
testUser: "frappe@example.com",

View File

@@ -0,0 +1,180 @@
describe("Batch Creation", () => {
it("creates a new batch", () => {
cy.login();
cy.wait(500);
cy.visit("/lms/batches");
cy.closeOnboardingModal();
// Open Settings
cy.get("span").contains("Learning").click();
cy.get("span").contains("Settings").click();
// Add a new member
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("span")
.contains(/^Members$/)
.click();
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("button")
.contains("New")
.click();
const dateNow = Date.now();
const randomEmail = `testuser_${dateNow}@example.com`;
const randomName = `Test User ${dateNow}`;
cy.get("input[placeholder='jane@doe.com']").type(randomEmail);
cy.get("input[placeholder='Jane']").type(randomName);
cy.get("button").contains("Add").click();
// Add evaluator
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("span")
.contains(/^Evaluators$/)
.click();
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("button")
.contains("New")
.click();
const randomEvaluator = `evaluator${dateNow}@example.com`;
cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
cy.get("button").contains("Add").click();
cy.get("div").contains(randomEvaluator).should("be.visible").click();
cy.visit("/lms/batches");
cy.closeOnboardingModal();
// Create a batch
cy.get("button").contains("Create").click();
cy.wait(500);
cy.url().should("include", "/batches/new/edit");
cy.get("label").contains("Title").type("Test Batch");
cy.get("label").contains("Start Date").type("2030-10-01");
cy.get("label").contains("End Date").type("2030-10-31");
cy.get("label").contains("Start Time").type("10:00");
cy.get("label").contains("End Time").type("11:00");
cy.get("label").contains("Timezone").type("IST");
cy.get("label").contains("Seat Count").type("10");
cy.get("label").contains("Published").click();
cy.get("label")
.contains("Short Description")
.type("Test Batch Short Description to test the UI");
cy.get("div[contenteditable=true").invoke(
"text",
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
/* Instructor */
cy.get("label")
.contains("Instructors")
.parent()
.within(() => {
cy.get("input").click().type("evaluator");
cy.get("input")
.invoke("attr", "aria-controls")
.as("instructor_list_id");
});
cy.get("@instructor_list_id").then((instructor_list_id) => {
cy.get(`[id^=${instructor_list_id}`)
.should("be.visible")
.within(() => {
cy.get("[id^=headlessui-combobox-option-").first().click();
});
});
cy.button("Save").click();
cy.wait(1000);
let batchName;
cy.url().then((url) => {
console.log(url);
batchName = url.split("/").pop();
cy.wrap(batchName).as("batchName");
});
cy.wait(500);
// View Batch
cy.wait(1000);
cy.visit("/lms/batches");
cy.closeOnboardingModal();
cy.url().should("include", "/lms/batches");
cy.get('[id^="headlessui-radiogroup-v-"]')
.find("span")
.contains("Upcoming")
.should("be.visible")
.click();
cy.get("@batchName").then((batchName) => {
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
.should("be.visible");
cy.get("span")
.contains("01 Oct 2030 - 31 Oct 2030")
.should("be.visible");
cy.get("span")
.contains("10:00 AM - 11:00 AM")
.should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
});
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
});
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
.should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("span")
.contains("01 Oct 2030 - 31 Oct 2030")
.should("be.visible");
cy.get("span").contains("10:00 AM - 11:00 AM").should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
cy.get("p")
.contains(
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
)
.should("be.visible");
cy.get("button").contains("Manage Batch").click();
/* Add student to batch */
cy.get("button").contains("Add").click();
cy.get('div[id^="headlessui-dialog-panel-v-"]')
.first()
.find("button")
.eq(1)
.click();
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
cy.get("div").contains(randomEmail).click();
cy.get("button").contains("Submit").click();
// Verify Seat Count
cy.get("span").contains("Details").click();
cy.get("div")
.contains("9")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
});
});

View File

@@ -1,12 +1,15 @@
describe("Course Creation", () => {
it("creates a new course", () => {
cy.login();
cy.wait(1000);
cy.wait(500);
cy.visit("/lms/courses");
// Close onboarding modal
cy.closeOnboardingModal();
// Create a course
cy.get("button").contains("New").click();
cy.wait(1000);
cy.get("button").contains("Create").click();
cy.wait(500);
cy.url().should("include", "/courses/new/edit");
cy.get("label").contains("Title").type("Test Course");
@@ -96,14 +99,15 @@ describe("Course Creation", () => {
// View Course
cy.wait(1000);
cy.visit("/lms");
cy.wait(500);
cy.closeOnboardingModal();
cy.url().should("include", "/lms/courses");
cy.get(".grid a:first").within(() => {
cy.get("div").contains("Test Course");
cy.get("div").contains(
"Test Course Short Introduction to test the UI"
);
cy.get(".course-image")
cy.get(".bg-cover")
.invoke("css", "background-image")
.should("include", "/files/profile");
});

View File

@@ -25,6 +25,7 @@
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import "cypress-file-upload";
import "cypress-real-events";
Cypress.Commands.add("login", (email, password) => {
if (!email) {
@@ -68,3 +69,18 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
element.dispatchEvent(event);
});
});
Cypress.Commands.add("closeOnboardingModal", () => {
cy.wait(500);
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.");
}
});
});

View File

@@ -18,12 +18,12 @@ services:
frappe:
image: frappe/bench:latest
command: bash /workspace/init.sh
command: bash /workspace/docker/init.sh
environment:
- SHELL=/bin/bash
working_dir: /home/frappe
volumes:
- .:/workspace
- ..:/workspace
ports:
- 8000:8000
- 9000:9000

View File

@@ -1,4 +1,4 @@
#!bin/bash
#!/bin/bash
if [ -d "/home/frappe/frappe-bench/apps/frappe" ]; then
echo "Bench already exists, skipping init"
@@ -16,9 +16,9 @@ cd frappe-bench
# Use containers instead of localhost
bench set-mariadb-host mariadb
bench set-redis-cache-host redis:6379
bench set-redis-queue-host redis:6379
bench set-redis-socketio-host redis:6379
bench set-redis-cache-host redis://redis:6379
bench set-redis-queue-host redis://redis:6379
bench set-redis-socketio-host redis://redis:6379
# Remove redis, watch from Procfile
sed -i '/redis/d' ./Procfile
@@ -37,4 +37,10 @@ bench --site lms.localhost set-config developer_mode 1
bench --site lms.localhost clear-cache
bench use lms.localhost
# Import French translations from workspace (persisted in repo)
if [ -f "/workspace/lms/translations/fr.csv" ]; then
bench --site lms.localhost import-translations fr /workspace/lms/translations/fr.csv || true
bench --site lms.localhost clear-cache
fi
bench start

1
frappe-ui Submodule

Submodule frappe-ui added at 80d3a010ac

View File

@@ -19,6 +19,10 @@ declare module 'vue' {
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
BadgeAssignmentForm: typeof import('./src/components/Settings/BadgeAssignmentForm.vue')['default']
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
@@ -27,17 +31,21 @@ declare module 'vue' {
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
BrandSettings: typeof import('./src/components/BrandSettings.vue')['default']
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
Categories: typeof import('./src/components/Categories.vue')['default']
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
ChildTable: typeof import('./src/components/Controls/ChildTable.vue')['default']
Code: typeof import('./src/components/Controls/Code.vue')['default']
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.vue')['default']
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
@@ -47,10 +55,14 @@ declare module 'vue' {
Discussions: typeof import('./src/components/Discussions.vue')['default']
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplates: typeof import('./src/components/Settings/EmailTemplates.vue')['default']
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
Evaluators: typeof import('./src/components/Evaluators.vue')['default']
Evaluators: typeof import('./src/components/Settings/Evaluators.vue')['default']
Event: typeof import('./src/components/Modals/Event.vue')['default']
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
@@ -61,37 +73,44 @@ declare module 'vue' {
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default']
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
Members: typeof import('./src/components/Members.vue')['default']
Members: typeof import('./src/components/Settings/Members.vue')['default']
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default']
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
Play: typeof import('./src/components/Icons/Play.vue')['default']
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
Question: typeof import('./src/components/Modals/Question.vue')['default']
Quiz: typeof import('./src/components/Quiz.vue')['default']
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
RelatedCourses: typeof import('./src/components/RelatedCourses.vue')['default']
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingDetails: typeof import('./src/components/SettingDetails.vue')['default']
SettingFields: typeof import('./src/components/SettingFields.vue')['default']
Settings: typeof import('./src/components/Modals/Settings.vue')['default']
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
Settings: typeof import('./src/components/Settings/Settings.vue')['default']
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
Tags: typeof import('./src/components/Tags.vue')['default']
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
}
}

9
frontend/frontend.iml Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -2,6 +2,7 @@
"name": "frappe-ui-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"serve": "vite preview",
@@ -9,6 +10,10 @@
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-python": "^6.2.1",
"@editorjs/checklist": "^1.6.0",
"@editorjs/code": "^2.9.0",
"@editorjs/editorjs": "^2.29.0",
@@ -23,10 +28,10 @@
"ace-builds": "^1.36.2",
"apexcharts": "^4.3.0",
"chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0",
"codemirror": "^6.0.1",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.134",
"frappe-ui": "^0.1.172",
"highlight.js": "^11.11.1",
"lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0",
@@ -34,9 +39,11 @@
"plyr": "^3.7.8",
"socket.io-client": "^4.7.2",
"tailwindcss": "3.4.15",
"thememirror": "^2.0.1",
"typescript": "^5.7.2",
"vue": "^3.4.23",
"vue-chartjs": "^5.3.0",
"vue-codemirror": "^6.1.1",
"vue-draggable-next": "^2.2.1",
"vue-router": "^4.0.12",
"vue3-apexcharts": "^1.8.0",

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},

BIN
frontend/public/Remove.mp4 Normal file

Binary file not shown.

View File

@@ -1,27 +1,29 @@
<template>
<Layout>
<router-view />
</Layout>
<Dialogs />
<Toasts />
<FrappeUIProvider>
<Layout>
<div class="text-base">
<router-view />
</div>
</Layout>
<Dialogs />
</FrappeUIProvider>
</template>
<script setup>
import { Toasts } from 'frappe-ui'
import { FrappeUIProvider } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useScreenSize } from './utils/composables'
import DesktopLayout from './components/DesktopLayout.vue'
import MobileLayout from './components/MobileLayout.vue'
import NoSidebarLayout from './components/NoSidebarLayout.vue'
import { stopSession } from '@/telemetry'
import { init as initTelemetry } from '@/telemetry'
import { usersStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { posthogSettings } from '@/telemetry'
const screenSize = useScreenSize()
let { userResource } = usersStore()
const router = useRouter()
const noSidebar = ref(false)
const { userResource } = usersStore()
router.beforeEach((to, from, next) => {
if (to.query.fromLesson || to.path === '/persona') {
@@ -38,17 +40,18 @@ const Layout = computed(() => {
}
if (screenSize.width < 640) {
return MobileLayout
} else {
return DesktopLayout
}
})
onMounted(async () => {
if (userResource.data) await initTelemetry()
return DesktopLayout
})
onUnmounted(() => {
noSidebar.value = false
stopSession()
})
watch(userResource, () => {
if (userResource.data) {
posthogSettings.reload()
}
})
</script>

View File

@@ -125,7 +125,7 @@
@click="redirectToWebsite()"
/>
</Tooltip>
<Tooltip :text="__('Help')">
<Tooltip v-if="showOnboarding" :text="__('Help')">
<CircleHelp
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="
@@ -157,7 +157,7 @@
v-model="showHelpModal"
v-model:articles="articles"
appName="learning"
title="Frappe Learning"
:title="__('Frappe Learning')"
:logo="LMSLogo"
:afterSkip="(step) => capture('onboarding_step_skipped_' + step)"
:afterSkipAll="() => capture('onboarding_steps_skipped')"
@@ -181,9 +181,17 @@
import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import { useStorage } from '@vueuse/core'
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue'
import { getSidebarLinks } from '../utils'
import {
ref,
onMounted,
inject,
watch,
reactive,
markRaw,
h,
onUnmounted,
} from 'vue'
import { getSidebarLinks } from '@/utils'
import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar'
@@ -217,7 +225,7 @@ import {
IntermediateStepModal,
} from 'frappe-ui/frappe'
const { user, sidebarSettings } = sessionStore()
const { user } = sessionStore()
const { userResource } = usersStore()
let sidebarStore = useSidebar()
const socket = inject('$socket')
@@ -228,6 +236,7 @@ const isModerator = ref(false)
const isInstructor = ref(false)
const pageToEdit = ref(null)
const settingsStore = useSettings()
const { sidebarSettings } = settingsStore
const showOnboarding = ref(false)
const showIntermediateModal = ref(false)
const currentStep = ref({})
@@ -244,6 +253,7 @@ const iconProps = {
onMounted(() => {
addNotifications()
setSidebarLinks()
setUpOnboarding()
socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload()
})
@@ -293,7 +303,7 @@ const unreadNotifications = createResource({
const addNotifications = () => {
if (user) {
sidebarLinks.value.push({
label: 'Notifications',
label: __('Notifications'),
icon: 'Bell',
to: 'Notifications',
activeFor: ['Notifications'],
@@ -304,8 +314,8 @@ const addNotifications = () => {
const addQuizzes = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
label: 'Quizzes',
sidebarLinks.value.splice(4, 0, {
label: __('Quizzes'),
icon: 'CircleHelp',
to: 'Quizzes',
activeFor: [
@@ -320,8 +330,8 @@ const addQuizzes = () => {
const addAssignments = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
label: 'Assignments',
sidebarLinks.value.splice(5, 0, {
label: __('Assignments'),
icon: 'Pencil',
to: 'Assignments',
activeFor: [
@@ -334,6 +344,22 @@ const addAssignments = () => {
}
}
const addProgrammingExercises = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.splice(3, 0, {
label: __('Programming Exercises'),
icon: 'Code',
to: 'ProgrammingExercises',
activeFor: [
'ProgrammingExercises',
'ProgrammingExerciseForm',
'ProgrammingExerciseSubmissions',
'ProgrammingExerciseSubmission',
],
})
}
}
const addPrograms = () => {
let activeFor = ['Programs', 'ProgramForm']
let index = 1
@@ -357,7 +383,7 @@ const addPrograms = () => {
if (canAddProgram) {
sidebarLinks.value.splice(index, 0, {
label: 'Programs',
label: __('Programs'),
icon: 'Route',
to: 'Programs',
activeFor: activeFor,
@@ -388,10 +414,6 @@ const deletePage = (link) => {
)
}
const getSidebarFromStorage = () => {
return useStorage('sidebar_is_collapsed', false)
}
const toggleSidebar = () => {
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
localStorage.setItem(
@@ -438,6 +460,7 @@ const steps = reactive([
title: __('Add your first chapter'),
icon: markRaw(h(FolderTree, iconProps)),
completed: false,
dependsOn: 'create_first_course',
onClick: async () => {
minimize.value = true
let course = await getFirstCourse()
@@ -453,6 +476,7 @@ const steps = reactive([
title: __('Add your first lesson'),
icon: markRaw(h(FileText, iconProps)),
completed: false,
dependsOn: 'create_first_chapter',
onClick: async () => {
minimize.value = true
let course = await getFirstCourse()
@@ -471,6 +495,7 @@ const steps = reactive([
title: __('Create your first quiz'),
icon: markRaw(h(CircleHelp, iconProps)),
completed: false,
dependsOn: 'create_first_course',
onClick: () => {
minimize.value = true
router.push({ name: 'Quizzes' })
@@ -502,6 +527,7 @@ const steps = reactive([
title: __('Add students to your batch'),
icon: markRaw(h(UserPlus, iconProps)),
completed: false,
dependsOn: 'create_first_batch',
onClick: async () => {
minimize.value = true
let batch = await getFirstBatch()
@@ -522,6 +548,7 @@ const steps = reactive([
title: __('Add courses to your batch'),
icon: markRaw(h(BookText, iconProps)),
completed: false,
dependsOn: 'create_first_batch',
onClick: async () => {
minimize.value = true
let batch = await getFirstBatch()
@@ -616,6 +643,7 @@ watch(userResource, () => {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addPrograms()
addProgrammingExercises()
addQuizzes()
addAssignments()
setUpOnboarding()
@@ -625,4 +653,8 @@ watch(userResource, () => {
const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
</script>

View File

@@ -2,17 +2,24 @@
<Dialog
v-model="show"
:options="{
title:
type == 'quiz'
? __('Add a quiz to your lesson')
: __('Add an assignment to your lesson'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: () => {
addAssessment()
},
},
],
}"
>
<template #body>
<div class="p-5 space-y-4">
<div v-if="type == 'quiz'" class="text-lg font-semibold">
{{ __('Add a quiz to your lesson') }}
</div>
<div v-else class="text-lg font-semibold">
{{ __('Add an assignment to your lesson') }}
</div>
<template #body-content>
<div class="">
<div>
<Link
v-if="type == 'quiz'"
@@ -29,17 +36,12 @@
:onCreate="(value, close) => redirectToForm()"
/>
</div>
<div class="flex justify-end space-x-2">
<Button variant="solid" @click="addAssessment()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Button } from 'frappe-ui'
import { Dialog } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'

View File

@@ -40,7 +40,7 @@
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'assessment_type'">
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
{{ getAssessmentTypeLabel(row[column.key]) }}
</div>
<div v-else-if="column.key == 'title'">
{{ row[column.key] }}
@@ -172,6 +172,24 @@ const getRowRoute = (row) => {
},
}
}
} else if (row.assessment_type == 'LMS Programming Exercise') {
if (row.submission) {
return {
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment_name,
submissionID: row.submission.name,
},
}
} else {
return {
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment_name,
submissionID: 'new',
},
}
}
} else {
return {
name: 'QuizPage',
@@ -190,12 +208,12 @@ const canAddAssessments = () => {
const getAssessmentColumns = () => {
let columns = [
{
label: 'Assessment',
label: __('Assessment'),
key: 'title',
width: '25rem',
},
{
label: 'Type',
label: __('Type'),
key: 'assessment_type',
width: '15rem',
},
@@ -203,7 +221,7 @@ const getAssessmentColumns = () => {
if (!user.data?.is_moderator) {
columns.push({
label: 'Status/Percentage',
label: __('Status/Percentage'),
key: 'status',
align: 'left',
width: '10rem',
@@ -213,7 +231,7 @@ const getAssessmentColumns = () => {
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
if (status === 'Pass' || status === 'Passed') {
return 'green'
} else if (status === 'Not Graded') {
return 'orange'
@@ -221,4 +239,14 @@ const getStatusTheme = (status) => {
return 'red'
}
}
const getAssessmentTypeLabel = (type) => {
if (type == 'LMS Assignment') {
return __('Assignment')
} else if (type == 'LMS Quiz') {
return __('Quiz')
} else if (type == 'LMS Programming Exercise') {
return __('Programming Exercise')
}
}
</script>

View File

@@ -191,10 +191,11 @@ import {
FileUploader,
FormControl,
TextEditor,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { showToast, getFileSize } from '@/utils'
import { getFileSize } from '@/utils'
import { useRouter } from 'vue-router'
const submissionFile = ref(null)
@@ -284,7 +285,7 @@ const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission',
name: props.submissionName,
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
auto: false,
cache: [user.data?.name, props.assignmentID],
@@ -338,7 +339,7 @@ const submitAssignment = () => {
},
{
onSuccess(data) {
showToast(__('Success'), __('Changes saved successfully'), 'check')
toast.success(__('Changes saved successfully'))
},
}
)
@@ -352,7 +353,7 @@ const addNewSubmission = () => {
{},
{
onSuccess(data) {
showToast('Success', 'Assignment submitted successfully.', 'check')
toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
@@ -370,7 +371,7 @@ const addNewSubmission = () => {
submissionResource.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -452,9 +453,9 @@ const canModifyAssignment = computed(() => {
const submissionStatusOptions = computed(() => {
return [
{ label: 'Not Graded', value: 'Not Graded' },
{ label: 'Pass', value: 'Pass' },
{ label: 'Fail', value: 'Fail' },
{ label: __('Not Graded'), value: 'Not Graded' },
{ label: __('Pass'), value: 'Pass' },
{ label: __('Fail'), value: 'Fail' },
]
})

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col border hover:border-outline-gray-4 rounded-md p-4 h-full"
class="flex flex-col border hover:border-outline-gray-3 rounded-md p-4 h-full"
style="min-height: 150px"
>
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
@@ -70,9 +70,8 @@
</div>
</template>
<script setup>
import { Badge } from 'frappe-ui'
import { formatTime } from '../utils'
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
import { formatTime } from '@/utils'
import { Clock, Globe } from 'lucide-vue-next'
import DateRange from '@/components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'

View File

@@ -86,9 +86,9 @@ import {
ListRows,
ListView,
ListRowItem,
toast,
} from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast } from '@/utils'
const readOnlyMode = window.read_only_mode
const showCourseModal = ref(false)
@@ -106,7 +106,6 @@ const courses = createResource({
params: {
batch: props.batch,
},
cache: ['batchCourses', props.batchName],
auto: true,
})
@@ -117,17 +116,17 @@ const openCourseModal = () => {
const getCoursesColumns = () => {
return [
{
label: 'Title',
label: __('Title'),
key: 'title',
width: 2,
},
{
label: 'Lessons',
label: __('Lessons'),
key: 'lessons',
align: 'right',
},
{
label: 'Enrollments',
label: __('Enrollments'),
align: 'right',
key: 'enrollments',
},
@@ -152,7 +151,7 @@ const removeCourses = (selections, unselectAll) => {
{
onSuccess(data) {
courses.reload()
showToast(__('Success'), __('Courses deleted successfully'), 'check')
toast.success(__('Courses deleted successfully'))
unselectAll()
},
}

View File

@@ -6,13 +6,12 @@
:courses="batch.data.courses"
/>
<Assessments :batch="batch.data.name" />
<StudentHeatmap />
<!-- <StudentHeatmap /> -->
</div>
</template>
<script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/components/Assessments.vue'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const props = defineProps({
batch: {

View File

@@ -1,44 +1,49 @@
<template>
<div v-if="user.data?.is_student">
<div
v-if="feedbackList.data?.length"
class="bg-surface-blue-2 text-blue-700 p-2 rounded-md mb-5"
>
{{ __('Thank you for providing your feedback!') }}
</div>
<div v-else class="flex justify-between items-center mb-5">
<div class="text-lg font-semibold">
{{ __('Help Us Improve') }}
<div>
<div class="leading-5 mb-4">
<div v-if="readOnly">
{{ __('Thank you for providing your feedback.') }}
<span
@click="showFeedbackForm = !showFeedbackForm"
class="underline cursor-pointer"
>{{ __('Click here') }}</span
>
{{ __('to view your feedback.') }}
</div>
<div v-else>
{{ __('Help us improve by providing your feedback.') }}
</div>
</div>
<Button @click="submitFeedback()">
{{ __('Submit') }}
</Button>
</div>
<div class="space-y-8">
<div class="flex items-center justify-between">
<Rating
v-for="key in ratingKeys"
v-model="feedback[key]"
:label="__(convertToTitleCase(key))"
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="feedback[key]"
:label="__(convertToTitleCase(key))"
:readonly="readOnly"
/>
</div>
<FormControl
v-model="feedback.feedback"
type="textarea"
:label="__('Feedback')"
:rows="9"
:readonly="readOnly"
/>
<Button v-if="!readOnly" @click="submitFeedback">
{{ __('Submit Feedback') }}
</Button>
</div>
<FormControl
v-model="feedback.feedback"
type="textarea"
:label="__('Feedback')"
:rows="7"
:readonly="readOnly"
/>
</div>
</div>
<div v-else-if="feedbackList.data?.length">
<div class="text-lg font-semibold mb-5">
{{ __('Average of Feedback Received') }}
<div class="leading-5 text-sm mb-2 mt-5">
{{ __('Average Feedback Received') }}
</div>
<div class="flex items-center justify-between mb-10">
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="average[key]"
@@ -47,81 +52,32 @@
/>
</div>
<div class="text-lg font-semibold mb-5">
{{ __('All Feedback') }}
</div>
<ListView
:columns="feedbackColumns"
:rows="feedbackList.data"
row-key="name"
:options="{
showTooltip: false,
rowHeight: 'h-16',
selectable: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
></ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in feedbackList.data"
class="group cursor-pointer feedback-list"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="ratingKeys.includes(column.key)">
<Rating v-model="row[column.key]" :readonly="true" />
</div>
<div v-else class="leading-5">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
<Button variant="outline" class="mt-5" @click="showAllFeedback = true">
{{ __('View all feedback') }}
</Button>
</div>
<div v-else class="text-sm italic text-center text-ink-gray-7 mt-5">
<div v-else class="text-ink-gray-7 mt-5 leading-5">
{{ __('No feedback received yet.') }}
</div>
<FeedbackModal
v-if="feedbackList.data?.length"
v-model="showAllFeedback"
:feedbackList="feedbackList.data"
/>
</template>
<script setup>
import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
import { inject, onMounted, reactive, ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils'
import {
Avatar,
Button,
createListResource,
FormControl,
ListView,
ListHeader,
ListRows,
ListRow,
ListRowItem,
Rating,
} from 'frappe-ui'
import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
const user = inject('$user')
const ratingKeys = ['content', 'instructors', 'value']
const readOnly = ref(false)
const average = reactive({})
const feedback = reactive({})
const showFeedbackForm = ref(true)
const showAllFeedback = ref(false)
const props = defineProps({
batch: {
@@ -167,6 +123,7 @@ watch(
if (feedbackList.data.length) {
let data = feedbackList.data
readOnly.value = true
showFeedbackForm.value = false
ratingKeys.forEach((key) => {
average[key] = 0
@@ -201,40 +158,11 @@ const submitFeedback = () => {
{
onSuccess: () => {
feedbackList.reload()
showFeedbackForm.value = false
},
}
)
}
const feedbackColumns = computed(() => {
return [
{
label: 'Member',
key: 'member_name',
width: '10rem',
},
{
label: 'Feedback',
key: 'feedback',
width: '15rem',
},
{
label: 'Content',
key: 'content',
width: '9rem',
},
{
label: 'Instructors',
key: 'instructors',
width: '9rem',
},
{
label: 'Value',
key: 'value',
width: '9rem',
},
]
})
</script>
<style>
.feedback-list > button > div {

View File

@@ -2,7 +2,12 @@
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<div
v-if="batch.data.seat_count && seats_left > 0"
class="text-xs bg-green-100 text-green-700 float-right px-2 py-0.5 rounded-md"
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
:class="
batch.data.amount || batch.data.courses.length
? 'float-right'
: 'w-fit mb-4'
"
>
{{ seats_left }}
<span v-if="seats_left > 1">
@@ -60,6 +65,10 @@
}"
>
<Button variant="solid" class="w-full mt-4">
<template #prefix>
<Settings v-if="isModerator" class="size-4 stroke-1.5" />
<LogIn v-else class="size-4 stroke-1.5" />
</template>
<span>
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
</span>
@@ -80,6 +89,9 @@
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Register Now') }}
</span>
@@ -95,6 +107,9 @@
"
@click="enrollInBatch()"
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Enroll Now') }}
</Button>
<router-link
@@ -107,6 +122,9 @@
}"
>
<Button class="w-full mt-2">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
@@ -117,9 +135,18 @@
</template>
<script setup>
import { inject, computed } from 'vue'
import { Badge, Button, createResource } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
import { Button, createResource, toast } from 'frappe-ui'
import {
BookOpen,
Clock,
CreditCard,
Globe,
GraduationCap,
LogIn,
Pencil,
Settings,
} from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router'
@@ -151,11 +178,7 @@ const enrollInBatch = () => {
{},
{
onSuccess(data) {
showToast(
__('Success'),
__('You have been enrolled in this batch'),
'check'
)
toast.success(__('You have been enrolled in this batch'))
router.push({
name: 'Batch',
params: {

View File

@@ -1,108 +1,64 @@
<template>
<div class="">
<div v-if="batch.data" class="">
<div class="w-full flex items-center justify-between pb-4">
<div class="font-medium text-ink-gray-7">
{{ __('Statistics') }}
</div>
</div>
<div class="grid grid-cols-4 gap-5 mb-8">
<div
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
>
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
<User class="w-5 h-5 stroke-1.5" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ students.data?.length }}
</span>
<span class="">
{{ __('Students') }}
</span>
</div>
</div>
<div
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
>
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
<GraduationCap class="w-5 h-5 stroke-1.5" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ certificationCount.data }}
</span>
<span class="">
{{ __('Certified') }}
</span>
</div>
</div>
<div
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
>
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
<BookOpen class="w-5 h-5 stroke-1.5" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ batch.courses?.length }}
</span>
<span>
{{ __('Courses') }}
</span>
</div>
</div>
<div
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
>
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
<ShieldCheck class="w-5 h-5 stroke-1.5" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ assessmentCount }}
</span>
<span>
{{ __('Assessments') }}
</span>
</div>
</div>
</div>
<div v-if="showProgressChart" class="mb-8">
<div class="text-ink-gray-7 font-medium">
{{ __('Progress') }}
</div>
<ApexChart
:options="chartOptions"
:series="chartData"
type="bar"
:height="chartData[0].data.length * 30 + 100"
<NumberChart
class="border rounded-md"
:config="{ title: __('Students'), value: students.data?.length || 0 }"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Certified'),
value: certificationCount.data || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Courses'),
value: batch.data.courses?.length || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{ title: __('Assessments'), value: assessmentCount || 0 }"
/>
<div
class="flex items-center justify-center text-sm text-ink-gray-7 space-x-4"
>
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-sm"
:style="{ 'background-color': theme.colors.green[600] }"
></div>
<div>
{{ __('Courses') }}
</div>
</div>
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-sm"
:style="{ 'background-color': theme.colors.blue[600] }"
></div>
<div>
{{ __('Assessments') }}
</div>
</div>
</div>
</div>
<AxisChart
v-if="showProgressChart"
:config="{
data: chartData,
title: __('Batch Summary'),
subtitle: __('Progress of students in courses and assessments'),
xAxis: {
key: 'task',
title: 'Tasks',
type: 'category',
},
yAxis: {
title: __('Number of Students'),
echartOptions: {
minInterval: 1,
},
},
swapXY: true,
series: [
{
name: 'value',
type: 'bar',
},
],
}"
/>
</div>
<div>
@@ -201,9 +157,10 @@
</div>
<StudentModal
:batch="props.batch.name"
:batch="props.batch.data.name"
v-model="showStudentModal"
v-model:reloadStudents="students"
v-model:batchModal="props.batch"
/>
<BatchStudentProgress
:student="selectedStudent"
@@ -213,6 +170,7 @@
<script setup>
import {
Avatar,
AxisChart,
Button,
createResource,
FeatherIcon,
@@ -223,6 +181,8 @@ import {
ListRows,
ListView,
ListRowItem,
NumberChart,
toast,
} from 'frappe-ui'
import {
BookOpen,
@@ -234,7 +194,6 @@ import {
} from 'lucide-vue-next'
import { ref, watch } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
import { showToast } from '@/utils'
import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
import ApexChart from 'vue3-apexcharts'
@@ -244,7 +203,6 @@ const showStudentModal = ref(false)
const showStudentProgressModal = ref(false)
const selectedStudent = ref(null)
const chartData = ref(null)
const chartOptions = ref(null)
const showProgressChart = ref(false)
const assessmentCount = ref(0)
const readOnlyMode = window.read_only_mode
@@ -258,34 +216,34 @@ const props = defineProps({
const students = createResource({
url: 'lms.lms.utils.get_batch_students',
cache: ['students', props.batch.name],
params: {
batch: props.batch?.name,
batch: props.batch?.data?.name,
},
auto: true,
onSuccess(data) {
chartData.value = getChartData()
showProgressChart.value =
data.length && (props.batch?.courses?.length || assessmentCount.value)
data.length &&
(props.batch?.data?.courses?.length || assessmentCount.value)
},
})
const getStudentColumns = () => {
let columns = [
{
label: 'Full Name',
label: __('Full Name'),
key: 'full_name',
width: '20rem',
icon: 'user',
},
{
label: 'Progress',
label: __('Progress'),
key: 'progress',
width: '15rem',
icon: 'activity',
},
{
label: 'Last Active',
label: __('Last Active'),
key: 'last_active',
width: '10rem',
align: 'center',
@@ -323,7 +281,8 @@ const removeStudents = (selections, unselectAll) => {
{
onSuccess(data) {
students.reload()
showToast(__('Success'), __('Students deleted successfully'), 'check')
props.batch.reload()
toast.success(__('Students deleted successfully'))
unselectAll()
},
}
@@ -331,96 +290,49 @@ const removeStudents = (selections, unselectAll) => {
}
const getChartData = () => {
let categories = {}
let tasks = []
let data = []
if (!students.data?.length) return []
Object.keys(students.data[0].courses).forEach((course) => {
categories[course] = {
value: 0,
type: 'course',
label: course,
}
students.data.forEach((row) => {
tasks = countAssessments(row, tasks)
tasks = countCourses(row, tasks)
})
Object.keys(students.data?.[0].assessments).forEach((assessment) => {
categories[assessment] = {
value: 0,
type: 'assessment',
label: assessment,
}
})
students.data.forEach((student) => {
Object.keys(student.courses).forEach((course) => {
if (student.courses[course] === 100) {
categories[course].value += 1
}
})
Object.keys(student.assessments).forEach((assessment) => {
if (student.assessments[assessment].result === 'Pass') {
categories[assessment].value += 1
}
tasks.forEach((task) => {
data.push({
task: task.label,
value: task.value,
})
})
chartOptions.value = getChartOptions(categories)
return [
{
name: __('Completed by Students'),
data: Object.values(categories).map((item) => item.value),
},
]
return data
}
const getChartOptions = (categories) => {
const courseColor = theme.colors.green[700]
const assessmentColor = theme.colors.blue[700]
const maxY =
students.data?.length % 5
? students.data?.length + (5 - (students.data?.length % 5))
: students.data?.length
const countAssessments = (row, tasks) => {
Object.keys(row.assessments).forEach((assessment) => {
if (row.assessments[assessment].result === 'Pass') {
tasks.filter((task) => task.label === assessment).length
? tasks.filter((task) => task.label === assessment)[0].value++
: tasks.push({
value: 1,
label: assessment,
})
}
})
return tasks
}
return {
chart: {
type: 'bar',
toolbar: {
show: false,
},
},
plotOptions: {
bar: {
distributed: true,
borderRadius: 3,
borderRadiusApplication: 'end',
horizontal: true,
barHeight: '40%',
},
},
colors: Object.values(categories).map((item) =>
item.type === 'course' ? courseColor : assessmentColor
),
xaxis: {
categories: Object.values(categories).map((item) => item.label),
labels: {
style: {
fontSize: '10px',
},
rotate: 0,
formatter: function (value) {
return value.length > 30 ? `${value.substring(0, 30)}...` : value
},
},
},
yaxis: {
max: maxY,
min: 0,
stepSize: 10,
tickAmount: maxY / 5,
/* reversed: true */
},
}
const countCourses = (row, tasks) => {
Object.keys(row.courses).forEach((course) => {
if (row.courses[course] === 100) {
tasks.filter((task) => task.label === course).length
? tasks.filter((task) => task.label === course)[0].value++
: tasks.push({
value: 1,
label: course,
})
}
})
return tasks
}
watch(students, () => {
@@ -434,14 +346,9 @@ const certificationCount = createResource({
params: {
doctype: 'LMS Certificate',
filters: {
batch_name: props.batch.name,
batch_name: props.batch?.data?.name,
},
},
auto: true,
})
</script>
<style>
.apexcharts-legend {
display: none !important;
}
</style>

View File

@@ -1,130 +0,0 @@
<template>
<div class="flex flex-col min-h-0">
<div class="flex items-center justify-between">
<div class="text-xl font-semibold mb-5 text-ink-gray-9">
{{ label }}
</div>
<Button @click="() => showCategoryForm()">
<template #icon>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" />
</template>
</Button>
</div>
<div
v-if="showForm"
class="flex items-center justify-between my-4 space-x-2"
>
<FormControl
ref="categoryInput"
v-model="category"
:placeholder="__('Category Name')"
class="flex-1"
/>
<Button @click="addCategory()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="overflow-y-scroll">
<div class="text-base divide-y space-y-2">
<FormControl
:value="cat.category"
type="text"
v-for="cat in categories.data"
class=""
@change.stop="(e) => update(cat.name, e.target.value)"
/>
</div>
</div>
</div>
</template>
<script setup>
import {
Button,
FormControl,
createListResource,
createResource,
debounce,
} from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next'
import { ref } from 'vue'
const showForm = ref(false)
const category = ref(null)
const categoryInput = ref(null)
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
})
const categories = createListResource({
doctype: 'LMS Category',
fields: ['name', 'category'],
auto: true,
})
const newCategory = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Category',
category: category.value,
},
}
},
})
const addCategory = () => {
newCategory.submit(
{},
{
onSuccess(data) {
categories.reload()
category.value = null
},
}
)
}
const showCategoryForm = () => {
showForm.value = !showForm.value
setTimeout(() => {
categoryInput.value.$el.querySelector('input').focus()
}, 0)
}
const updateCategory = createResource({
url: 'frappe.client.rename_doc',
makeParams(values) {
return {
doctype: 'LMS Category',
old_name: values.name,
new_name: values.category,
}
},
})
const update = (name, value) => {
updateCategory.submit(
{
name: name,
category: value,
},
{
onSuccess() {
categories.reload()
},
}
)
}
</script>

View File

@@ -1,124 +1,140 @@
<template>
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full">
<button
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="() => togglePopover()"
>
<div class="flex items-center">
<slot name="prefix" />
<span
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
v-if="selectedValue"
>
{{ displayValue(selectedValue) }}
</span>
<span class="text-base leading-5 text-ink-gray-4" v-else>
{{ placeholder || '' }}
</span>
</div>
<ChevronDown class="h-4 w-4 stroke-1.5" />
</button>
</div>
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2">
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="
(e) => {
query = e.target.value
}
"
:value="query"
autocomplete="off"
placeholder="Search"
/>
<div>
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
{{ __(label) }}
<span class="text-ink-red-3" v-if="attrs.required">*</span>
</div>
<Combobox
v-model="selectedValue"
nullable
v-slot="{ open: isComboboxOpen }"
>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full">
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="() => togglePopover()"
:disabled="attrs.readonly"
>
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
<div class="flex items-center">
<slot name="prefix" />
<span
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
v-if="selectedValue"
>
{{ displayValue(selectedValue) }}
</span>
<span class="text-base leading-5 text-ink-gray-4" v-else>
{{ placeholder || '' }}
</span>
</div>
<ChevronDown class="h-4 w-4 stroke-1.5" />
</button>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
>
<div
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="
(e) => {
query = e.target.value
}
"
:value="query"
autocomplete="off"
:placeholder="__('Search')"
/>
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
>
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
</button>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
>
{{ group.group }}
</div>
<ComboboxOption
as="template"
v-for="option in group.items"
:key="option.value"
:value="option"
v-slot="{ active, selected }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
{ 'bg-surface-gray-2': active },
]"
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
>
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
{{ group.group }}
</div>
<ComboboxOption
as="template"
v-for="option in group.items"
:key="option.value"
:value="option"
v-slot="{ active, selected }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<div class="flex flex-col space-y-1 text-ink-gray-8">
<div>
{{ option.label }}
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
>
<div class="flex flex-col space-y-1 text-ink-gray-8">
<div>
{{ option.label }}
</div>
<div
v-if="
option.description &&
option.description != option.label
"
class="text-xs text-ink-gray-7"
v-html="option.description"
></div>
</div>
<div
v-if="option.description"
class="text-xs text-ink-gray-7"
v-html="option.description"
></div>
</div>
</slot>
</li>
</ComboboxOption>
</slot>
</li>
</ComboboxOption>
</div>
<li
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
>
{{ __('No results found') }}
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{ value: search?.el._value, close }"
></slot>
</div>
<li
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
>
No results found
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{ value: search?.el._value, close }"
></slot>
</div>
</div>
</div>
</template>
</Popover>
</Combobox>
</template>
</Popover>
</Combobox>
</div>
</template>
<script setup>
@@ -145,6 +161,10 @@ const props = defineProps({
type: String,
default: 'md',
},
label: {
type: String,
default: '',
},
variant: {
type: String,
default: 'subtle',

View File

@@ -0,0 +1,149 @@
<template>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ label }}
</div>
<div class="overflow-x-auto border rounded-md">
<div
class="grid items-center space-x-4 p-2 border-b"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<div
v-for="(column, index) in columns"
:key="index"
class="text-sm text-ink-gray-5"
>
{{ column }}
</div>
<div></div>
</div>
<div
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="grid items-center space-x-4 p-2"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<template v-for="key in Object.keys(row)" :key="key">
<input
v-if="showKey(key)"
v-model="row[key]"
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-sm text-sm focus:outline-none"
/>
</template>
<div class="relative" ref="menuRef">
<Button
variant="ghost"
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
>
<template #icon>
<Ellipsis
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
/>
</template>
</Button>
<div
v-if="menuOpenIndex === rowIndex"
class="absolute right-[30px] top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
>
<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"
>
<Trash2 class="size-4 stroke-1.5" />
<span>
{{ __('Delete') }}
</span>
</button>
</div>
</div>
</div>
</div>
<div class="mt-2">
<Button @click="addRow">
<template #prefix>
<Plus class="size-4 text-ink-gray-7" />
</template>
{{ __('Add Row') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Button } from 'frappe-ui'
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
const rows = defineModel<Cell[][]>()
const menuRef = ref(null)
const menuOpenIndex = ref<number | null>(null)
const menuTopPosition = ref<string>('')
const emit = defineEmits<{
(e: 'update:modelValue', value: Cell[][]): void
}>()
type Cell = {
value: string
editable?: boolean
}
const props = withDefaults(
defineProps<{
modelValue?: Cell[][]
columns?: string[]
label?: string
}>(),
{
columns: [],
}
)
const columns = ref(props.columns)
watch(rows, () => {
if (rows.value?.length < 1) {
addRow()
}
})
const addRow = () => {
if (!rows.value) {
rows.value = []
}
let newRow: { [key: string]: string } = {}
columns.value.forEach((column: any) => {
newRow[column.toLowerCase().split(' ').join('_')] = ''
})
rows.value.push(newRow)
emit('update:modelValue', rows.value)
}
const deleteRow = (index: number) => {
rows.value.splice(index, 1)
emit('update:modelValue', rows.value)
}
const getGridTemplateColumns = () => {
return [...Array(columns.value.length).fill('1fr'), '0.25fr'].join(' ')
}
const toggleMenu = (index: number, event: MouseEvent) => {
menuOpenIndex.value = menuOpenIndex.value === index ? null : index
menuTopPosition.value = `${event.clientY + 10}px`
}
onClickOutside(menuRef, () => {
menuOpenIndex.value = null
})
const showKey = (key: string) => {
let columnsLower = columns.value.map((col) =>
col.toLowerCase().split(' ').join('_')
)
return columnsLower.includes(key)
}
</script>

View File

@@ -0,0 +1,162 @@
<template>
<div class="flex w-full flex-col gap-1.5">
<div v-if="label" class="text-xs text-ink-gray-5">
{{ __(label) }}
</div>
<codemirror
v-model="code"
:extensions="extensions"
:tab-size="2"
:autofocus="autofocus"
:indent-with-tab="true"
:style="{ height: height, maxHeight: maxHeight }"
:disabled="readonly"
@blur="emitEditorValue"
:class="{
'border border-outline-gray-1': showBorder,
}"
/>
<Button
v-if="showSaveButton"
@click="emit('save', code)"
class="mt-3 w-full text-base"
>
{{ __('Save') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed, watch } from 'vue'
import { Button } from 'frappe-ui'
import { Codemirror } from 'vue-codemirror'
import { autocompletion, closeBrackets } from '@codemirror/autocomplete'
import { LanguageSupport } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import { tomorrow } from 'thememirror'
const props = withDefaults(
defineProps<{
language: 'json' | 'javascript' | 'html' | 'css' | 'python'
modelValue: string | object | Array<string | object> | null
height?: string
maxHeight?: string
autofocus?: boolean
showSaveButton?: boolean
showLineNumbers?: boolean
completions?: Function | null
label?: string
showBorder?: boolean
required?: boolean
readonly?: boolean
}>(),
{
language: 'javascript',
modelValue: null,
height: 'auto',
maxHeight: '250px',
showLineNumbers: true,
completions: null,
}
)
const emit = defineEmits(['update:modelValue', 'save'])
const code = ref<string>('')
watch(
() => props.modelValue,
(newVal) => {
code.value =
typeof newVal === 'string' ? newVal : JSON.stringify(newVal, null, 2)
},
{ immediate: true }
)
watch(code, (val) => {
emit('update:modelValue', val)
})
const errorMessage = ref('')
const emitEditorValue = () => {
try {
errorMessage.value = ''
let value = code.value || ''
if (!props.showSaveButton && !props.readonly) {
emit('update:modelValue', value)
}
} catch (e) {
console.error('Error while parsing JSON for editor', e)
errorMessage.value = `Invalid object/JSON: ${e.message}`
}
}
const languageExtension = ref<LanguageSupport>()
const autocompleteExtension = ref()
async function setLanguageExtension() {
const importMap = {
json: () => import('@codemirror/lang-json'),
javascript: () => import('@codemirror/lang-javascript'),
html: () => import('@codemirror/lang-html'),
css: () => import('@codemirror/lang-css'),
python: () => import('@codemirror/lang-python'),
}
const languageImport = importMap[props.language]
if (!languageImport) return
const module = await languageImport()
languageExtension.value = (module as any)[props.language]()
if (props.completions) {
const languageData = (module as any)[`${props.language}Language`]
autocompleteExtension.value = languageData.data.of({
autocomplete: props.completions,
})
}
}
onMounted(async () => {
await setLanguageExtension()
})
watch(
() => props.language,
async () => {
await setLanguageExtension()
},
{ immediate: true }
)
const extensions = computed(() => {
const baseExtensions = [
closeBrackets(),
tomorrow,
EditorView.theme({
'&': {
fontFamily: 'monospace',
fontSize: '12px',
},
'.cm-gutters': {
display: props.showLineNumbers ? 'flex' : 'none',
},
}),
]
if (languageExtension.value) {
baseExtensions.push(languageExtension.value)
}
if (autocompleteExtension.value) {
baseExtensions.push(autocompleteExtension.value)
}
const autocompletionOptions = {
activateOnTyping: true,
maxRenderedOptions: 10,
closeOnBlur: false,
icons: false,
optionClass: () => 'flex h-7 !px-2 items-center rounded !text-gray-600',
}
baseExtensions.push(autocompletion(autocompletionOptions))
return baseExtensions
})
</script>

View File

@@ -5,7 +5,7 @@
height: height,
}"
>
<span class="text-xs text-ink-gray-7" v-if="label">
<span class="text-xs text-ink-gray-7 mb-1" v-if="label">
{{ label }}
</span>
<div

View File

@@ -0,0 +1,108 @@
<template>
<div>
<div class="text-xs text-ink-gray-5 mb-1">
{{ __(label) }}
</div>
<Popover placement="bottom" class="!block">
<template #target="{ togglePopover, isOpen }">
<div class="space-y-2">
<FormControl
type="text"
autocomplete="off"
class="w-full"
:placeholder="__('Set Color')"
@focus="togglePopover"
:modelValue="modelValue"
@update:modelValue="(val: string) => emit('update:modelValue', val)"
>
<template #prefix>
<div
class="size-4 rounded-full"
:style="
modelValue
? {
backgroundColor:
theme.backgroundColor[modelValue.toLowerCase()][400],
}
: {}
"
>
<Palette
v-if="!modelValue"
class="size-4 stroke-1.5 text-ink-gray-5"
/>
</div>
</template>
<template #suffix>
<Button variant="ghost">
<X
class="size-3 text-ink-gray-5"
@click="emit('update:modelValue', null)"
/>
</Button>
</template>
</FormControl>
</div>
</template>
<template #body="{ close }">
<div class="rounded-lg bg-surface-white p-3 border w-fit mt-2">
<div class="text-xs text-ink-gray-5 mb-1.5">
{{ __('Swatches') }}
</div>
<div class="grid grid-cols-7 gap-2">
<div
v-for="color in colors"
:key="color"
class="size-5 rounded-full cursor-pointer"
:style="{
backgroundColor:
theme.backgroundColor[color.toLowerCase()][400],
}"
@click="
(e) => {
emit('update:modelValue', color)
close()
emit('change', color)
}
"
></div>
</div>
</div>
</template>
</Popover>
<div class="text-sm text-ink-gray-5 mt-2">
{{ description }}
</div>
</div>
</template>
<script setup lang="ts">
import { Button, FormControl, Popover } from 'frappe-ui'
import { computed } from 'vue'
import { Palette, X } from 'lucide-vue-next'
import { theme } from '@/utils/theme'
const emit = defineEmits(['update:modelValue', 'change'])
const props = defineProps<{
modelValue: string
label: string
description?: string
}>()
const colors = computed(() => {
return [
'Red',
'Blue',
'Green',
'Amber',
'Purple',
'Cyan',
'Orange',
'Violet',
'Pink',
'Teal',
'Gray',
'Yellow',
]
})
</script>

View File

@@ -12,6 +12,7 @@
:variant="attrs.variant"
:placeholder="attrs.placeholder"
:filterable="false"
:readonly="attrs.readonly"
>
<template #target="{ open, togglePopover }">
<slot name="target" v-bind="{ open, togglePopover }" />
@@ -34,7 +35,7 @@
<Button
variant="ghost"
class="w-full !justify-start"
label="Create New"
:label="__('Create New')"
@click="attrs.onCreate(value, close)"
>
<template #prefix>

View File

@@ -4,78 +4,92 @@
{{ label }}
<span class="text-ink-red-3" v-if="required">*</span>
</label>
<div class="grid grid-cols-3 gap-2">
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
class="rounded-md word-break-all"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" />
</template>
</Button>
<div class="">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full focus-visible:!ring-0"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
<div class="w-full">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full focus-visible:!ring-0"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen, close }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
>
<ComboboxOptions
class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5"
static
>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{ option.description }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{ option.description }}
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
<div class="h-10"></div>
<div
v-if="attrs.onCreate"
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(close)"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</ComboboxOptions>
</div>
</template>
</Popover>
</Combobox>
</div>
</template>
</Popover>
</Combobox>
</div>
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1">
<div
v-for="value in values"
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 word-wrap p-2 rounded-md mr-2"
>
<span class="break-all">
{{ value }}
</span>
<X
class="size-4 stroke-1.5 cursor-pointer"
@click="removeValue(value)"
/>
</div>
</div>
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
@@ -90,9 +104,9 @@ import {
ComboboxOption,
} from '@headlessui/vue'
import { createResource, Popover, Button } from 'frappe-ui'
import { ref, computed, nextTick } from 'vue'
import { ref, computed, nextTick, useAttrs } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import { X, Plus } from 'lucide-vue-next'
const props = defineProps({
label: {
@@ -124,7 +138,7 @@ const props = defineProps({
})
const values = defineModel()
const attrs = useAttrs()
const emails = ref([])
const search = ref(null)
const error = ref(null)

View File

@@ -0,0 +1,76 @@
<template>
<div class="mb-4">
<div v-if="label" class="text-xs text-ink-gray-5 mb-2">
{{ __(label) }}
<span class="text-ink-red-3">*</span>
</div>
<FileUploader
v-if="!modelValue"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file: File) => saveImage(file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center">
<div class="border rounded-md w-fit py-7 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
{{ __(description) }}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img :src="modelValue" class="border rounded-md w-44 h-auto" />
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div
v-if="description"
class="mt-2 text-ink-gray-5 text-sm leading-5"
>
{{ __(description) }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { validateFile } from '@/utils'
import { Button, FileUploader } from 'frappe-ui'
import { Image } from 'lucide-vue-next'
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const props = withDefaults(
defineProps<{
modelValue: string
label?: string
description?: string
}>(),
{
modelValue: '',
label: '',
description: '',
}
)
const saveImage = (file: any) => {
emit('update:modelValue', file.file_url)
}
const removeImage = () => {
emit('update:modelValue', '')
}
</script>

View File

@@ -1,41 +1,51 @@
<template>
<div
v-if="course.title"
class="flex flex-col h-full rounded-md border-2 overflow-auto"
class="flex flex-col h-full rounded-md border-2 overflow-auto text-ink-gray-9"
style="min-height: 350px"
>
<div
class="course-image"
:class="{ 'default-image': !course.image }"
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
class="w-[100%] h-[168px] bg-cover bg-center bg-no-repeat"
:style="
course.image
? { backgroundImage: `url('${encodeURI(course.image)}')` }
: {
backgroundImage: getGradientColor(),
backgroundBlendMode: 'screen',
}
"
>
<div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
<Badge
<div
v-if="course.featured"
variant="subtle"
theme="green"
size="md"
class="mb-1 mr-1"
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"
>
{{ __('Featured') }}
</Badge>
<Star class="size-3 stroke-2" />
<span>
{{ __('Featured') }}
</span>
</div>
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="text-xs bg-white text-gray-800 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 mr-1"
>
{{ tag }}
</div>
</div>
<div v-if="!course.image" class="image-placeholder">
{{ course.title[0] }}
<div
v-if="!course.image"
class="flex items-center justify-center text-white flex-1 font-extrabold text-2xl my-auto px-5 text-center leading-6"
:class="course.tags ? 'h-[80%]' : 'h-full'"
>
{{ course.title }}
</div>
</div>
<div class="flex flex-col flex-auto p-4">
<div class="flex items-center justify-between mb-2">
<div v-if="course.lessons">
<Tooltip :text="__('Lessons')">
<span class="flex items-center text-ink-gray-7">
<span class="flex items-center">
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
{{ course.lessons }}
</span>
@@ -44,8 +54,8 @@
<div v-if="course.enrollments">
<Tooltip :text="__('Enrolled Students')">
<span class="flex items-center text-ink-gray-7">
<Users class="h-4 w-4 stroke-1. mr-1" />
<span class="flex items-center">
<Users class="h-4 w-4 stroke-1.5 mr-1" />
{{ course.enrollments }}
</span>
</Tooltip>
@@ -53,14 +63,14 @@
<div v-if="course.rating">
<Tooltip :text="__('Average Rating')">
<span class="flex items-center text-ink-gray-7">
<span class="flex items-center">
<Star class="h-4 w-4 stroke-1.5 mr-1" />
{{ course.rating }}
</span>
</Tooltip>
</div>
<div v-if="course.status != 'Approved'">
<!-- <div v-if="course.status != 'Approved'">
<Badge
variant="subtle"
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
@@ -68,14 +78,14 @@
>
{{ course.status }}
</Badge>
</div>
</div> -->
</div>
<div class="text-xl font-semibold leading-6 text-ink-gray-9">
<div v-if="course.image" class="text-xl font-semibold leading-6">
{{ course.title }}
</div>
<div class="short-introduction text-ink-gray-7 text-sm">
<div class="short-introduction text-sm">
{{ course.short_introduction }}
</div>
@@ -84,11 +94,8 @@
:progress="course.membership.progress"
/>
<div
v-if="user && course.membership"
class="text-sm text-ink-gray-7 mt-2 mb-4"
>
{{ Math.ceil(course.membership.progress) }}% completed
<div v-if="user && course.membership" class="text-sm mt-2 mb-4">
{{ Math.ceil(course.membership.progress) }}% {{ __('completed') }}
</div>
<div class="flex items-center justify-between mt-auto">
@@ -108,21 +115,23 @@
<div v-if="course.paid_course" class="font-semibold">
{{ course.price }}
</div>
<div
<Tooltip
v-if="course.paid_certificate || course.enable_certification"
class="text-xs text-ink-blue-3 bg-surface-blue-1 py-0.5 px-1 rounded-md"
:text="__('Get Certified')"
>
{{ __('Certification') }}
</div>
<GraduationCap class="size-5 stroke-1.5 text-ink-gray-7" />
</Tooltip>
</div>
</div>
</div>
</template>
<script setup>
import { BookOpen, Users, Star } from 'lucide-vue-next'
import { BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue'
import { sessionStore } from '@/stores/session'
import { Badge, Tooltip } from 'frappe-ui'
import { Tooltip } from 'frappe-ui'
import { theme } from '@/utils/theme'
import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue'
@@ -134,16 +143,24 @@ const props = defineProps({
default: null,
},
})
const getGradientColor = () => {
let color = props.course.card_gradient?.toLowerCase() || 'blue'
let colorMap = theme.backgroundColor[color]
return `linear-gradient(to top right, black, ${colorMap[400]})`
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */
/* return `radial-gradient(ellipse at 30% 70%, black 50%, ${colorMap[500]} 100%)` */
/* return `radial-gradient(ellipse at 80% 20%, ${colorMap[100]} 0%, ${colorMap[300]} 50%, ${colorMap[500]} 100%)` */
/* return `conic-gradient(from 180deg at 50% 50%, ${colorMap[100]} 0%, ${colorMap[200]} 50%, ${colorMap[400]} 100%)` */
/* return `linear-gradient(135deg, ${colorMap[100]}, ${colorMap[300]}), linear-gradient(120deg, rgba(255,255,255,0.4) 0%, transparent 60%) ` */
/* return `radial-gradient(circle at 20% 30%, ${colorMap[100]} 0%, transparent 40%),
radial-gradient(circle at 80% 40%, ${colorMap[200]} 0%, transparent 50%),
linear-gradient(135deg, ${colorMap[300]} 0%, ${colorMap[400]} 100%);` */
}
</script>
<style>
.course-image {
height: 168px;
width: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.course-card-pills {
background: #ffffff;
margin-left: 0;
@@ -157,14 +174,6 @@ const props = defineProps({
width: fit-content;
}
.default-image {
display: flex;
flex-direction: column;
align-items: center;
background-color: theme('colors.green.100');
color: theme('colors.green.600');
}
.avatar-group {
display: inline-flex;
align-items: center;
@@ -173,14 +182,7 @@ const props = defineProps({
.avatar-group .avatar {
transition: margin 0.1s ease-in-out;
}
.image-placeholder {
display: flex;
align-items: center;
flex: 1;
font-size: 5rem;
color: theme('colors.gray.700');
font-weight: 600;
}
.avatar-group.overlap .avatar + .avatar {
margin-left: calc(-8px);
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="border-2 rounded-md min-w-80">
<div class="border-2 rounded-md min-w-80 max-w-sm">
<iframe
v-if="course.data.video_link"
:src="video_link"
@@ -26,6 +26,9 @@
}"
>
<Button variant="solid" size="md" class="w-full">
<template #prefix>
<BookText class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Continue Learning') }}
</span>
@@ -44,6 +47,9 @@
}"
>
<Button variant="solid" size="md" class="w-full">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Buy this course') }}
</span>
@@ -57,12 +63,15 @@
{{ __('Contact the Administrator to enroll for this course.') }}
</Badge>
<Button
v-else
v-else-if="!user.data?.is_moderator && !is_instructor()"
@click="enrollStudent()"
variant="solid"
class="w-full"
size="md"
>
<template #prefix>
<BookText class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Start Learning') }}
</span>
@@ -74,8 +83,22 @@
class="w-full mt-2"
size="md"
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certificate') }}
</Button>
<Button
v-if="user.data?.is_moderator || is_instructor()"
class="w-full mt-2"
size="md"
@click="showProgressSummary"
>
<template #prefix>
<TrendingUp class="size-4 stroke-1.5" />
{{ __('Progress Summary') }}
</template>
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
@@ -86,6 +109,9 @@
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
@@ -116,7 +142,7 @@
v-if="parseInt(course.data.rating) > 0"
class="flex items-center text-ink-gray-9"
>
<Star class="h-4 w-4 stroke-1.5 fill-orange-500 text-gray-50" />
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
<span class="ml-2">
{{ course.data.rating }} {{ __('Rating') }}
</span>
@@ -142,18 +168,34 @@
</div>
</div>
</div>
<CourseProgressSummary
v-model="showProgressModal"
:courseName="course.data.name"
:enrollments="course.data.enrollments"
/>
</template>
<script setup>
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue'
import { Badge, Button, createResource } from 'frappe-ui'
import { showToast, formatAmount } from '@/utils/'
import {
BookOpen,
BookText,
CreditCard,
GraduationCap,
Pencil,
Star,
TrendingUp,
Users,
} from 'lucide-vue-next'
import { computed, inject, ref } from 'vue'
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
import { formatAmount } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue'
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
const router = useRouter()
const user = inject('$user')
const showProgressModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
@@ -172,31 +214,19 @@ const video_link = computed(() => {
function enrollStudent() {
if (!user.data) {
showToast(
__('Please Login'),
__('You need to login first to enroll for this course'),
'alert-circle'
)
toast.success(__('You need to login first to enroll for this course'))
setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 1000)
}, 500)
} else {
const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
course: props.course.data.name,
})
enrollStudentResource
.submit({
course: props.course.data.name,
})
.then(() => {
capture('enrolled_in_course', {
course: props.course.data.name,
})
showToast(
__('Success'),
__('You have been enrolled in this course'),
'check'
)
toast.success(__('You have been enrolled in this course'))
setTimeout(() => {
router.push({
name: 'Lesson',
@@ -206,7 +236,11 @@ function enrollStudent() {
lessonNumber: 1,
},
})
}, 2000)
}, 1000)
})
.catch((err) => {
toast.warning(__(err.messages?.[0] || err))
console.error(err)
})
}
}
@@ -254,4 +288,8 @@ const fetchCertificate = () => {
member: user.data?.name,
})
}
const showProgressSummary = () => {
showProgressModal.value = true
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="text-ink-gray-7">
<div class="">
<span v-if="instructors?.length == 1">
<router-link
:to="{
@@ -19,7 +19,7 @@
>
{{ instructors[0].first_name }}
</router-link>
and
{{ __('and') }}
<router-link
:to="{
name: 'Profile',
@@ -38,7 +38,7 @@
>
{{ instructors[0].first_name }}
</router-link>
and {{ instructors?.length - 1 }} others
{{ __('and') }} {{ instructors?.length - 1 }} {{ __('others') }}
</span>
</div>
</template>

View File

@@ -23,119 +23,135 @@
'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length,
}"
>
<Disclosure
v-slot="{ open }"
v-for="(chapter, index) in outline.data"
:key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)"
<Draggable
:list="outline.data"
:disabled="!allowEdit"
item-key="name"
group="chapters"
@end="updateChapterOrder"
>
<DisclosureButton ref="" class="flex items-center w-full p-2 group">
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
'duration-200': !open,
hidden: chapter.is_scorm_package,
open: index == 1,
}"
class="h-4 w-4 text-ink-gray-9 stroke-1"
/>
<div
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
@click="redirectToChapter(chapter)"
>
{{ chapter.title }}
</div>
<div class="flex ml-auto space-x-4">
<Tooltip :text="__('Edit Chapter')" placement="bottom">
<FilePenLine
v-if="allowEdit"
@click.prevent="openChapterModal(chapter)"
class="h-4 w-4 text-ink-gray-9 invisible group-hover:visible"
/>
</Tooltip>
<Tooltip :text="__('Delete Chapter')" placement="bottom">
<Trash2
v-if="allowEdit"
@click.prevent="trashChapter(chapter.name)"
class="h-4 w-4 text-ink-red-3 invisible group-hover:visible"
/>
</Tooltip>
</div>
</DisclosureButton>
<DisclosurePanel v-if="!chapter.is_scorm_package">
<Draggable
v-if="!chapter.is_scorm_package"
:list="chapter.lessons"
:disabled="!allowEdit"
item-key="name"
group="items"
@end="updateOutline"
:data-chapter="chapter.name"
>
<template #item="{ element: lesson }">
<div
class="outline-lesson pl-8 py-2 pr-4 text-ink-gray-9"
:class="
isActiveLesson(lesson.number) ? 'bg-surface-gray-3' : ''
"
<template #item="{ element: chapter, index }">
<div class="chapter-item">
<Disclosure
v-slot="{ open }"
:key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)"
>
<DisclosureButton
ref=""
class="flex items-center w-full p-2 group"
>
<router-link
:to="{
name: allowEdit ? 'LessonForm' : 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
lessonNumber: lesson.number.split('.')[1],
},
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
'duration-200': !open,
hidden: chapter.is_scorm_package,
open: index == 1,
}"
class="h-4 w-4 text-ink-gray-9 stroke-1"
/>
<div
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
@click="redirectToChapter(chapter)"
>
<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"
{{ chapter.title }}
</div>
<div class="flex ml-auto space-x-4">
<Tooltip :text="__('Edit Chapter')" placement="bottom">
<FilePenLine
v-if="allowEdit"
@click.prevent="openChapterModal(chapter)"
class="h-4 w-4 text-ink-gray-9 invisible group-hover:visible"
/>
<HelpCircle
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
/>
{{ lesson.title }}
</Tooltip>
<Tooltip :text="__('Delete Chapter')" placement="bottom">
<Trash2
v-if="allowEdit"
@click.prevent="trashLesson(lesson.name, chapter.name)"
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
@click.prevent="trashChapter(chapter.name)"
class="h-4 w-4 text-ink-red-3 invisible group-hover:visible"
/>
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-700 ml-2"
/>
</div>
</router-link>
</div>
</template>
</Draggable>
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<router-link
v-if="!chapter.is_scorm_package"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: chapter.idx,
lessonNumber: chapter.lessons.length + 1,
},
}"
>
<Button>
{{ __('Add Lesson') }}
</Button>
</router-link>
</Tooltip>
</div>
</DisclosureButton>
<DisclosurePanel v-if="!chapter.is_scorm_package">
<Draggable
v-if="!chapter.is_scorm_package"
:list="chapter.lessons"
:disabled="!allowEdit"
item-key="name"
group="items"
@end="updateOutline"
:data-chapter="chapter.name"
>
<template #item="{ element: lesson }">
<div
class="outline-lesson pl-8 py-2 pr-4 text-ink-gray-9"
:class="
isActiveLesson(lesson.number) ? 'bg-surface-gray-3' : ''
"
>
<router-link
:to="{
name: allowEdit ? 'LessonForm' : 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
lessonNumber: lesson.number.split('.')[1],
},
}"
>
<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"
/>
<HelpCircle
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
/>
{{ lesson.title }}
<Trash2
v-if="allowEdit"
@click.prevent="
trashLesson(lesson.name, chapter.name)
"
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
/>
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-700 ml-2"
/>
</div>
</router-link>
</div>
</template>
</Draggable>
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<router-link
v-if="!chapter.is_scorm_package"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: chapter.idx,
lessonNumber: chapter.lessons.length + 1,
},
}"
>
<Button>
{{ __('Add Lesson') }}
</Button>
</router-link>
</div>
</DisclosurePanel>
</Disclosure>
</div>
</DisclosurePanel>
</Disclosure>
</template>
</Draggable>
</div>
</div>
<ChapterModal
@@ -147,8 +163,8 @@
/>
</template>
<script setup>
import { Button, createResource, Tooltip } from 'frappe-ui'
import { getCurrentInstance, inject, ref } from 'vue'
import { Button, createResource, Tooltip, toast } from 'frappe-ui'
import { getCurrentInstance, inject, ref, watch } from 'vue'
import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import {
@@ -162,7 +178,6 @@ import {
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue'
import { showToast } from '@/utils'
const route = useRoute()
const router = useRouter()
@@ -198,13 +213,22 @@ const props = defineProps({
const outline = createResource({
url: 'lms.lms.utils.get_course_outline',
cache: ['course_outline', props.courseName],
params: {
course: props.courseName,
progress: props.getProgress,
makeParams() {
return {
course: props.courseName,
progress: props.getProgress,
}
},
auto: true,
})
watch(
() => props.courseName,
() => {
outline.reload()
}
)
const deleteLesson = createResource({
url: 'lms.lms.api.delete_lesson',
makeParams(values) {
@@ -215,7 +239,7 @@ const deleteLesson = createResource({
},
onSuccess() {
outline.reload()
showToast('Success', 'Lesson deleted successfully', 'check')
toast.success(__('Lesson deleted successfully'))
},
})
@@ -230,7 +254,21 @@ const updateLessonIndex = createResource({
}
},
onSuccess() {
showToast('Success', 'Lesson moved successfully', 'check')
toast.success(__('Lesson moved successfully'))
},
})
const updateChapterIndex = createResource({
url: 'lms.lms.api.update_chapter_index',
makeParams(values) {
return {
chapter: values.chapter,
course: values.course,
idx: values.idx,
}
},
onSuccess() {
toast.success(__('Chapter moved successfully'))
},
})
@@ -279,6 +317,14 @@ const updateOutline = (e) => {
})
}
const updateChapterOrder = (e) => {
updateChapterIndex.submit({
chapter: e.item.__draggable_context.element.name,
course: props.courseName,
idx: e.newIndex,
})
}
const deleteChapter = createResource({
url: 'lms.lms.api.delete_chapter',
makeParams(values) {
@@ -288,7 +334,7 @@ const deleteChapter = createResource({
},
onSuccess() {
outline.reload()
showToast('Success', 'Chapter deleted successfully', 'check')
toast.success(__('Chapter deleted successfully'))
},
})
@@ -317,11 +363,7 @@ const redirectToChapter = (chapter) => {
event.preventDefault()
if (props.allowEdit) return
if (!user.data) {
showToast(
__('You are not enrolled'),
__('Please enroll for this course to view this lesson'),
'alert-circle'
)
toast.success(__('Please enroll for this course to view this lesson'))
return
}

View File

@@ -35,14 +35,14 @@
<span class="text-ink-gray-7">
{{ review.creation }}
</span>
<div class="flex mt-2">
<div class="flex mt-2 space-x-1">
<Star
v-for="index in 5"
class="h-5 w-5 text-ink-gray-1 rounded-sm mr-2"
class="size-4 text-transparent rounded-sm"
:class="
index <= Math.ceil(review.rating)
? 'fill-orange-500'
: 'fill-gray-600'
? 'fill-yellow-500'
: 'fill-gray-300'
"
/>
</div>
@@ -64,7 +64,7 @@
<script setup>
import { Star } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui'
import { computed, ref, inject } from 'vue'
import { watch, ref, inject } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import ReviewModal from '@/components/Modals/ReviewModal.vue'
@@ -101,12 +101,21 @@ const hasReviewed = createResource({
const reviews = createResource({
url: 'lms.lms.utils.get_reviews',
cache: ['course_reviews', props.courseName],
params: {
course: props.courseName,
makeParams() {
return {
course: props.courseName,
}
},
auto: true,
})
watch(
() => props.courseName,
() => {
reviews.reload()
}
)
const showReviewModal = ref(false)
function openReviewModal() {

View File

@@ -32,13 +32,13 @@
"
:options="[
{
label: 'Edit',
label: __('Edit'),
onClick() {
reply.editable = true
},
},
{
label: 'Delete',
label: __('Delete'),
onClick() {
deleteReply(reply)
},
@@ -93,12 +93,11 @@
</div>
</template>
<script setup>
import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
import { timeAgo } from '../utils'
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
import { timeAgo } from '@/utils'
import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted } from 'vue'
import { createToast } from '../utils'
import { ref, inject, onMounted, onUnmounted } from 'vue'
const showTopics = defineModel('showTopics')
const newReply = ref('')
@@ -192,14 +191,7 @@ const postReply = () => {
replies.reload()
},
onError(err) {
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
toast.error(err.messages?.[0] || err)
},
}
)
@@ -259,4 +251,10 @@ const deleteReply = (reply) => {
}
)
}
onUnmounted(() => {
socket.off('publish_message')
socket.off('update_message')
socket.off('delete_message')
})
</script>

View File

@@ -69,8 +69,8 @@
<script setup>
import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue'
import { singularize, timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue'
import { singularize, timeAgo } from '@/utils'
import { ref, onMounted, inject, onUnmounted } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
import { MessageSquareText } from 'lucide-vue-next'
@@ -102,7 +102,7 @@ const props = defineProps({
},
emptyStateText: {
type: String,
default: 'Start a discussion',
default: __('Start a discussion'),
},
singleThread: {
type: Boolean,
@@ -153,4 +153,8 @@ const showReplies = (topic) => {
const openTopicModal = () => {
showTopicModal.value = true
}
onUnmounted(() => {
socket.off('new_discussion_topic')
})
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div class="flex flex-col items-center justify-center mt-60">
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
{{ __('No {0}').format(props.type) }}
</div>
<div
class="leading-5 text-base w-2/5 text-base text-center text-ink-gray-7"
>
{{
__(
'There are no {0} currently. Keep an eye out, fresh learning experiences are on the way!'
).format(props.type)
}}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { BookOpen, GraduationCap } from 'lucide-vue-next'
const props = defineProps({
type: String,
})
</script>

View File

@@ -1,129 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div> -->
</div>
<div class="flex item-center space-x-2">
<FormControl
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
/>
<Button @click="() => (showForm = !showForm)">
<template #icon>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" />
</template>
</Button>
</div>
</div>
<!-- Form to add new member -->
<div v-if="showForm" class="flex items-center space-x-2 my-4">
<FormControl
v-model="email"
:placeholder="__('Email')"
type="email"
class="w-full"
/>
<Button @click="addEvaluator()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="divide-y">
<div
v-for="evaluator in evaluators.data"
@click="openProfile(evaluator.username)"
class="cursor-pointer"
>
<div class="flex items-center justify-between py-3">
<div class="flex items-center space-x-3">
<Avatar
:image="evaluator.user_image"
:label="evaluator.full_name"
size="lg"
/>
<div>
<div class="text-base font-semibold text-ink-gray-9">
{{ evaluator.full_name }}
</div>
<div class="text-xs text-ink-gray-5">
{{ evaluator.evaluator }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui'
import { ref, watch } from 'vue'
import { Plus, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
const show = defineModel('show')
const search = ref('')
const showForm = ref(false)
const email = ref('')
const router = useRouter()
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
show: {
type: Boolean,
},
})
const evaluators = createResource({
url: 'frappe.client.get_list',
makeParams: () => {
return {
doctype: 'Course Evaluator',
fields: ['evaluator', 'full_name', 'user_image', 'username'],
filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
}
},
auto: true,
})
const addEvaluator = () => {
call('lms.lms.api.add_an_evaluator', {
email: email.value,
}).then((data) => {
showForm.value = false
email.value = ''
evaluators.reload()
})
}
watch(search, () => {
evaluators.reload()
})
const openProfile = (username) => {
show.value = false
router.push({
name: 'Profile',
params: {
username: username,
},
})
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-4"
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 flex-col space-y-2 flex-1">

View File

@@ -15,60 +15,18 @@
</div>
</div>
<div class="space-y-2">
<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"
@click="openHelpDialog('quiz')"
@click="openHelpDialog(key)"
>
<span>
{{ __('How to add a Quiz?') }}
{{ __(item.title) }}
</span>
<Info class="w-3 h-3 text-ink-gray-7" />
</div>
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
{{
__(
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('upload')"
>
<span class="leading-5">
{{ __(contentMap['upload']) }}
</span>
<Info class="w-3 h-3 text-ink-gray-7" />
</div>
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
{{
__(
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('youtube')"
>
<span>
{{ __(contentMap['youtube']) }}
</span>
<Info class="w-3 h-3 text-ink-gray-7" />
</div>
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
{{
__(
'Copy the URL of the video from YouTube and paste it in the editor.'
)
}}
{{ __(item.description) }}
</div>
</div>
</div>
@@ -83,14 +41,31 @@ const showExplanation = ref(false)
const type = ref(null)
const title = ref(null)
const contentMap = {
quiz: 'How to add a Quiz?',
upload: 'How to upload content from your system?',
youtube: 'How to add a YouTube Video?',
quiz: {
title: 'How to add a Quiz?',
description:
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.',
},
upload: {
title: 'How to upload content from your system?',
description:
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.',
},
youtube: {
title: 'How to add a YouTube Video?',
description:
'Copy the URL of the video from YouTube and paste it in the editor.',
},
remove: {
title: 'How to remove an embed?',
description:
'To remove an embed like YouTube or Vimeo, put your cursor on the line below the embed, then drag your mouse cursor upwards to select the embed. Once the embed is selected press BackSpace.',
},
}
const openHelpDialog = (contentType) => {
type.value = contentType
title.value = contentMap[contentType]
title.value = contentMap[contentType].title
showExplanation.value = true
}
</script>

View File

@@ -1,5 +1,15 @@
<template>
<div class="flex items-center justify-between mb-5">
<div
v-if="hasPermission() && !props.zoomAccount"
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3"
>
<AlertCircle class="size-4 stroke-1.5" />
<span>
{{ __('Please add a zoom account to the batch to create live classes.') }}
</span>
</div>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Live Class') }}
</div>
@@ -12,10 +22,18 @@
</span>
</Button>
</div>
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
<div v-if="liveClasses.data?.length" class="grid grid-cols-3 gap-5 mt-5">
<div
v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full text-ink-gray-7 p-3"
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
:class="{
'cursor-pointer': hasPermission() && cls.attendees > 0,
}"
@click="
() => {
openAttendanceModal(cls)
}
"
>
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
{{ cls.title }}
@@ -23,7 +41,7 @@
<div class="short-introduction">
{{ cls.description }}
</div>
<div class="space-y-3">
<div class="mt-auto space-y-3">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
@@ -33,18 +51,20 @@
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }}
{{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm') }}
</span>
</div>
<div
v-if="cls.date >= dayjs().format('YYYY-MM-DD')"
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
@@ -58,42 +78,63 @@
{{ __('Join') }}
</a>
</div>
<div v-else class="flex items-center space-x-2 text-yellow-700">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('This class has ended') }}
</span>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
<div v-else class="text-sm italic text-ink-gray-5 mt-2">
{{ __('No live classes scheduled') }}
</div>
<LiveClassModal
:batch="props.batch"
:zoomAccount="props.zoomAccount"
v-model="showLiveClassModal"
v-model:reloadLiveClasses="liveClasses"
/>
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" />
</template>
<script setup>
import { createListResource, Button } from 'frappe-ui'
import { Plus, Clock, Calendar, Video, Monitor, Info } from 'lucide-vue-next'
import { inject } from 'vue'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import { ref } from 'vue'
import { createListResource, Button, Tooltip } from 'frappe-ui'
import {
Plus,
Clock,
Calendar,
Video,
Monitor,
Info,
AlertCircle,
} from 'lucide-vue-next'
import { inject, ref } from 'vue'
import { formatTime } from '@/utils/'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
const user = inject('$user')
const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode
const showAttendance = ref(false)
const attendanceFor = ref(null)
const props = defineProps({
batch: {
type: String,
required: true,
},
zoomAccount: String,
})
const liveClasses = createListResource({
@@ -106,6 +147,8 @@ const liveClasses = createListResource({
'description',
'time',
'date',
'duration',
'attendees',
'start_url',
'join_url',
'owner',
@@ -120,8 +163,38 @@ const openLiveClassModal = () => {
const canCreateClass = () => {
if (readOnlyMode) return false
if (!props.zoomAccount) return false
return hasPermission()
}
const hasPermission = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
const canAccessClass = (cls) => {
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
if (hasClassEnded(cls)) return false
return true
}
const getClassEnd = (cls) => {
const classStart = new Date(`${cls.date}T${cls.time}`)
return new Date(classStart.getTime() + cls.duration * 60000)
}
const hasClassEnded = (cls) => {
const classEnd = getClassEnd(cls)
const now = new Date()
return now > classEnd
}
const openAttendanceModal = (cls) => {
if (!hasPermission()) return
if (cls.attendees <= 0) return
showAttendance.value = true
attendanceFor.value = cls
}
</script>
<style>
.short-introduction {

View File

@@ -1,98 +1,115 @@
<template>
<div class="flex h-full flex-col">
<div class="flex h-full flex-col relative">
<div class="h-full pb-10" id="scrollContainer">
<slot />
</div>
<div
v-if="sidebarSettings.data"
class="fixed flex items-center justify-around border-t border-outline-gray-2 bottom-0 z-10 w-full bg-surface-white standalone:pb-4"
:style="{
gridTemplateColumns: `repeat(${
sidebarLinks.length + 1
}, minmax(0, 1fr))`,
}"
>
<button
v-for="tab in sidebarLinks"
:key="tab.label"
:class="isVisible(tab) ? 'block' : 'hidden'"
class="flex flex-col items-center justify-center py-3 transition active:scale-95"
@click="handleClick(tab)"
<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"
v-if="showMenu"
ref="menu"
>
<component
:is="icons[tab.icon]"
class="h-6 w-6 stroke-1.5"
:class="[isActive(tab) ? 'text-ink-gray-9' : 'text-ink-gray-5']"
/>
</button>
<Popover
trigger="hover"
popoverClass="bottom-28 mx-2"
placement="top-start"
<div
v-for="link in otherLinks"
:key="link.label"
class="flex items-center space-x-2 cursor-pointer"
@click="handleClick(link)"
>
<component
:is="icons[link.icon]"
class="h-4 w-4 stroke-1.5 text-ink-gray-5"
/>
<div>{{ link.label }}</div>
</div>
</div>
<!-- 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"
>
<template #target>
<button
v-for="tab in sidebarLinks"
:key="tab.label"
:class="isVisible(tab) ? 'block' : 'hidden'"
class="flex flex-col items-center justify-center py-3 transition active:scale-95"
@click="handleClick(tab)"
>
<component
:is="icons[tab.icon]"
class="h-6 w-6 stroke-1.5"
:class="[isActive(tab) ? 'text-ink-gray-9' : 'text-ink-gray-5']"
/>
</button>
<button @click="toggleMenu">
<component
:is="icons['List']"
class="h-6 w-6 stroke-1.5 text-ink-gray-5"
/>
</template>
<template #body-main>
<div class="text-base p-5 space-y-4">
<div
v-for="link in otherLinks"
:key="link.label"
class="flex items-center space-x-2"
@click="handleClick(link)"
>
<component
:is="icons[link.icon]"
class="h-4 w-4 stroke-1.5 text-ink-gray-5"
/>
<div>
{{ link.label }}
</div>
</div>
</div>
</template>
</Popover>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { getSidebarLinks } from '../utils'
import { getSidebarLinks } from '@/utils'
import { useRouter } from 'vue-router'
import { watch, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session'
import { useSettings } from '@/stores/settings'
import { usersStore } from '@/stores/user'
import { Popover } from 'frappe-ui'
import * as icons from 'lucide-vue-next'
const { logout, user, sidebarSettings } = sessionStore()
const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore()
const { sidebarSettings } = useSettings()
const router = useRouter()
let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks())
const otherLinks = ref([])
const showMenu = ref(false)
const menu = ref(null)
onMounted(() => {
sidebarSettings.reload(
{},
{
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label.toLowerCase().split(' ').join('_') !== key
)
}
})
filterLinksToShow(data)
addOtherLinks()
},
}
)
})
const handleOutsideClick = (e) => {
if (menu.value && !menu.value.contains(e.target)) {
showMenu.value = false
}
}
watch(showMenu, (val) => {
if (val) {
setTimeout(() => {
document.addEventListener('click', handleOutsideClick)
}, 0)
} else {
document.removeEventListener('click', handleOutsideClick)
}
})
const filterLinksToShow = (data) => {
Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label.toLowerCase().split(' ').join('_') !== key
)
}
})
}
const addOtherLinks = () => {
if (user) {
otherLinks.value.push({
@@ -122,6 +139,7 @@ watch(userResource, () => {
(userResource.data.is_moderator || userResource.data.is_instructor)
) {
addQuizzes()
addAssignments()
}
})
@@ -133,6 +151,14 @@ const addQuizzes = () => {
})
}
const addAssignments = () => {
otherLinks.value.push({
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
})
}
let isActive = (tab) => {
return tab.activeFor?.includes(router.currentRoute.value.name)
}
@@ -158,4 +184,8 @@ const isVisible = (tab) => {
else if (tab.label == 'Log out') return isLoggedIn
else return true
}
const toggleMenu = () => {
showMenu.value = !showMenu.value
}
</script>

View File

@@ -25,12 +25,14 @@
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Reply To') }}
<span class="text-ink-red-3">*</span>
</div>
<Input type="text" v-model="announcement.replyTo" />
</div>
<div class="mb-4">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Announcement') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:fixedMenu="true"
@@ -43,9 +45,8 @@
</Dialog>
</template>
<script setup>
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
import { Dialog, Input, TextEditor, createResource, toast } from 'frappe-ui'
import { reactive } from 'vue'
import { showToast } from '@/utils/'
const show = defineModel()
@@ -70,8 +71,8 @@ const announcementResource = createResource({
url: 'frappe.core.doctype.communication.email.make',
makeParams(values) {
return {
recipients: props.students.join(', '),
cc: announcement.replyTo,
recipients: announcement.replyTo,
bcc: props.students.join(', '),
subject: announcement.subject,
content: announcement.announcement,
doctype: 'LMS Batch',
@@ -87,22 +88,24 @@ const makeAnnouncement = (close) => {
{
validate() {
if (!props.students.length) {
return 'No students in this batch'
return __('No students in this batch')
}
if (!announcement.subject) {
return 'Subject is required'
return __('Subject is required')
}
if (!announcement.announcement) {
return __('Announcement is required')
}
if (!announcement.replyTo) {
return __('Reply To is required')
}
},
onSuccess() {
close()
showToast(
__('Success'),
__('Announcement has been sent successfully'),
'check'
)
toast.success(__('Announcement has been sent successfully'))
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'alert-circle')
toast.error(__(err.messages?.[0] || err))
},
}
)

View File

@@ -25,21 +25,39 @@
v-model="assessment"
:doctype="assessmentType"
:label="__('Assessment')"
:onCreate="
(value, close) => {
close()
if (assessmentType === 'LMS Quiz') {
router.push({
name: 'QuizForm',
params: {
quizID: 'new',
},
})
} else if (assessmentType === 'LMS Assignment') {
router.push({
name: 'Assignments',
})
}
}
"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui'
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { computed, ref } from 'vue'
import { showToast } from '@/utils'
import { useRouter } from 'vue-router'
const show = defineModel()
const assessmentType = ref(null)
const assessment = ref(null)
const assessments = defineModel('assessments')
const router = useRouter()
const props = defineProps({
batch: {
@@ -70,7 +88,7 @@ const addAssessment = (close) => {
{
onSuccess(data) {
assessments.value.reload()
showToast(__('Success'), __('Assessment added successfully'), 'check')
toast.success(__('Assessment added successfully'))
close()
},
}
@@ -79,8 +97,9 @@ const addAssessment = (close) => {
const assessmentTypes = computed(() => {
return [
{ label: 'Quiz', value: 'LMS Quiz' },
{ label: 'Assignment', value: 'LMS Assignment' },
{ label: __('Quiz'), value: 'LMS Quiz' },
{ label: __('Assignment'), value: 'LMS Assignment' },
{ label: __('Programming Exercise'), value: 'LMS Programming Exercise' },
]
})
</script>

View File

@@ -6,7 +6,7 @@
}"
>
<template #body>
<div class="p-5 text-base max-h-[75vh] overflow-y-auto">
<div class="p-5 text-base">
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
{{
assignmentID === 'new'
@@ -14,7 +14,7 @@
: __('Edit Assignment')
}}
</div>
<div class="space-y-4">
<div class="space-y-4 max-h-[75vh] overflow-y-auto">
<FormControl
v-model="assignment.title"
:label="__('Title')"
@@ -37,7 +37,7 @@
@change="(val) => (assignment.question = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
/>
</div>
</div>
@@ -64,9 +64,8 @@
</Dialog>
</template>
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor } from 'frappe-ui'
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { computed, reactive, watch } from 'vue'
import { showToast } from '@/utils'
const show = defineModel()
const assignments = defineModel<Assignments>('assignments')
@@ -123,11 +122,7 @@ const saveAssignment = () => {
{
onSuccess() {
show.value = false
showToast(
__('Success'),
__('Assignment created successfully'),
'check'
)
toast.success(__('Assignment created successfully'))
},
}
)
@@ -140,11 +135,7 @@ const saveAssignment = () => {
{
onSuccess() {
show.value = false
showToast(
__('Success'),
__('Assignment updated successfully'),
'check'
)
toast.success(__('Assignment updated successfully'))
},
}
)
@@ -153,11 +144,11 @@ const saveAssignment = () => {
const assignmentOptions = computed(() => {
return [
{ label: 'PDF', value: 'PDF' },
{ label: 'Image', value: 'Image' },
{ label: 'Document', value: 'Document' },
{ label: 'Text', value: 'Text' },
{ label: 'URL', value: 'URL' },
{ label: __('PDF'), value: 'PDF' },
{ label: __('Image'), value: 'Image' },
{ label: __('Document'), value: 'Document' },
{ label: __('Text'), value: 'Text' },
{ label: __('URL'), value: 'URL' },
]
})
</script>

View File

@@ -19,32 +19,43 @@
v-model="course"
:label="__('Course')"
:required="true"
:onCreate="
(value, close) => {
close()
router.push({
name: 'CourseForm',
params: {
courseName: 'new',
},
})
}
"
/>
<Link
doctype="Course Evaluator"
v-model="evaluator"
:label="__('Evaluator')"
:onCreate="(value, close) => openSettings(close)"
:onCreate="(value, close) => openSettings('Evaluators', close)"
class="mt-4"
/>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, createResource } from 'frappe-ui'
import { Dialog, createResource, toast } from 'frappe-ui'
import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
import { useOnboarding } from 'frappe-ui/frappe'
import { useSettings } from '@/stores/settings'
import { openSettings } from '@/utils'
import { useRouter } from 'vue-router'
const show = defineModel()
const course = ref(null)
const evaluator = ref(null)
const user = inject('$user')
const courses = defineModel('courses')
const router = useRouter()
const { updateOnboardingStep } = useOnboarding('learning')
const settingsStore = useSettings()
const props = defineProps({
batch: {
@@ -83,15 +94,9 @@ const addCourse = (close) => {
evaluator.value = null
},
onError(err) {
showToast('Error', err.message[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
}
const openSettings = (close) => {
close()
settingsStore.activeTab = 'Evaluators'
settingsStore.isSettingsOpen = true
}
</script>

View File

@@ -14,7 +14,13 @@
<div class="text-xl font-semibold">
{{ student.full_name }}
</div>
<Badge :theme="student.progress === 100 ? 'green' : 'red'">
<Badge
v-if="
Object.keys(student.assessments).length ||
Object.keys(student.courses).length
"
:theme="student.progress === 100 ? 'green' : 'red'"
>
{{ student.progress }}% {{ __('Complete') }}
</Badge>
</div>
@@ -26,7 +32,10 @@
<div class="space-y-8">
<!-- Assessments -->
<div class="space-y-2 text-sm">
<div
v-if="Object.keys(student.assessments).length"
class="space-y-2 text-sm"
>
<div class="flex items-center border-b pb-1 font-medium">
<span class="flex-1">
{{ __('Assessment') }}
@@ -73,7 +82,10 @@
</div>
<!-- Courses -->
<div class="space-y-2 text-sm">
<div
v-if="Object.keys(student.courses).length"
class="space-y-2 text-sm"
>
<div class="flex items-center border-b pb-1 font-medium">
<span class="flex-1">
{{ __('Courses') }}

View File

@@ -62,9 +62,8 @@
</template>
<script setup>
import { inject, reactive } from 'vue'
import { createResource, Dialog, FormControl, Switch } from 'frappe-ui'
import { createResource, Dialog, FormControl, Switch, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
const show = defineModel()
const dayjs = inject('$dayjs')
@@ -112,13 +111,13 @@ const generateCertificates = (close) => {
},
{
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
})
close()
showToast(__('Success'), __('Certificates generated successfully'), 'check')
toast.success(__('Certificates generated successfully'))
}
const getCourses = () => {

View File

@@ -76,9 +76,10 @@ import {
FileUploader,
FormControl,
Switch,
toast,
} from 'frappe-ui'
import { reactive, watch, inject } from 'vue'
import { showToast, getFileSize } from '@/utils/'
import { getFileSize } from '@/utils/'
import { capture } from '@/telemetry'
import { FileText, X } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe'
@@ -150,21 +151,17 @@ const addChapter = async (close) => {
onSuccess(data) {
cleanChapter()
outline.value.reload()
showToast(
__('Success'),
__('Chapter added successfully'),
'check'
)
toast.success(__('Chapter added successfully'))
},
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
close()
},
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -196,11 +193,11 @@ const editChapter = (close) => {
},
onSuccess() {
outline.value.reload()
showToast(__('Success'), __('Chapter updated successfully'), 'check')
toast.success(__('Chapter updated successfully'))
close()
},
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -0,0 +1,230 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Course Progress Summary'),
size: '5xl',
}"
>
<template #body-content>
<div class="flex justify-between space-x-10 text-base mt-10">
<div class="w-full">
<div class="flex items-center justify-between space-x-5 mb-4">
<!-- <div class="text-xl font-semibold text-ink-gray-6">
{{ __('{0} Members').format(memberCount) }}
</div> -->
<FormControl
v-model="searchFilter"
:placeholder="__('Search by Member')"
type="text"
class="w-full"
/>
</div>
<div class="max-h-[70vh] overflow-y-auto">
<ListView
v-if="progressList.loading || progressList.data?.length"
:columns="progressColumns"
:rows="progressList.data"
rowKey="name"
:options="{
selectable: false,
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem
:item="item"
v-for="item in progressColumns"
:key="item.key"
>
<template #prefix="{ item }">
<FeatherIcon
:name="item.icon?.toString()"
class="h-4 w-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in progressList.data">
<router-link
:to="{
name: 'Profile',
params: { username: row.member_username },
}"
>
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
</ListRows>
</ListView>
<div
v-if="progressList.data && progressList.hasNextPage"
class="flex justify-center my-5"
>
<Button @click="progressList.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
<div class="mb-4 self-start w-full space-y-5">
<div class="flex items-center space-x-4">
<NumberChart
class="border rounded-md w-full"
:config="{
title: __('Enrollments'),
value: memberCount || 0,
}"
/>
<NumberChart
class="border rounded-md w-full"
:config="{
title: __('Average Progress %'),
value: chartDetails.data?.average_progress || 0,
}"
/>
</div>
<DonutChart
:config="{
data: chartDetails.data?.progress_distribution || [],
title: __('Progress Distribution'),
categoryColumn: 'category',
valueColumn: 'count',
colors: [
theme.colors.red['400'],
theme.colors.amber['400'],
theme.colors.pink['400'],
theme.colors.blue['400'],
theme.colors.green['400'],
],
}"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
createListResource,
createResource,
Dialog,
DonutChart,
FeatherIcon,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
NumberChart,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { theme } from '@/utils/theme'
const show = defineModel<boolean | undefined>()
const searchFilter = ref<string | null>(null)
type Filters = {
course: string | undefined
member_name?: string[]
}
const props = defineProps<{
courseName?: string
enrollments?: number
}>()
const memberCount = ref<number>(props.enrollments || 0)
const chartDetails = createResource({
url: 'lms.lms.api.get_course_progress_distribution',
params: {
course: props.courseName,
},
auto: true,
})
const progressList = createListResource({
doctype: 'LMS Enrollment',
filters: {
course: props.courseName,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'progress',
],
pageLength: 50,
auto: true,
})
watch([searchFilter], () => {
let filterApplied = false
let filters: Filters = {
course: props.courseName,
}
if (searchFilter.value) {
filters.member_name = ['like', `%${searchFilter.value}%`]
filterApplied = true
}
progressList.update({
filters: filters,
})
progressList.reload(
{},
{
onSuccess(data: any[]) {
memberCount.value = filterApplied ? data.length : props.enrollments || 0
},
}
)
})
const progressColumns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
width: '60%',
icon: 'user',
},
{
label: __('Progress'),
key: 'progress',
width: '30%',
align: 'right',
icon: 'trending-up',
},
]
})
</script>

View File

@@ -34,9 +34,15 @@
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
import {
Dialog,
FormControl,
TextEditor,
createResource,
toast,
} from 'frappe-ui'
import { reactive } from 'vue'
import { showToast, singularize } from '@/utils'
import { singularize } from '@/utils'
const topics = defineModel('reloadTopics')
@@ -115,7 +121,7 @@ const submitTopic = (close) => {
)
},
onError(err) {
showToast('Error', err.message, 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -93,10 +93,11 @@ import {
Button,
createResource,
TextEditor,
toast,
} from 'frappe-ui'
import { reactive, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { getFileSize, showToast, escapeHTML } from '@/utils'
import { getFileSize } from '@/utils'
const reloadProfile = defineModel('reloadProfile')
@@ -131,7 +132,6 @@ const imageResource = createResource({
const updateProfile = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
profile.bio = escapeHTML(profile.bio)
return {
doctype: 'User',
name: props.profile.data.name,
@@ -155,7 +155,7 @@ const saveProfile = (close) => {
reloadProfile.value.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -0,0 +1,192 @@
<template>
<Dialog
v-model="show"
:options="{
title:
templateID == 'new'
? __('New Email Template')
: __('Edit Email Template'),
size: 'lg',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveTemplate(close)
},
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
:label="__('Name')"
v-model="template.name"
type="text"
:required="true"
:placeholder="__('Batch Enrollment Confirmation')"
/>
<FormControl
:label="__('Subject')"
v-model="template.subject"
type="text"
:required="true"
:placeholder="__('Your enrollment in {{ batch_name }} is confirmed')"
/>
<FormControl
:label="__('Use HTML')"
v-model="template.use_html"
type="checkbox"
/>
<FormControl
v-if="template.use_html"
:label="__('Content')"
v-model="template.response_html"
type="textarea"
:required="true"
:rows="10"
:placeholder="
__(
'<p>Dear {{ member_name }},</p>\n\n<p>You have been enrolled in our upcoming batch {{ batch_name }}.</p>\n\n<p>Thanks,</p>\n<p>Frappe Learning</p>'
)
"
/>
<div v-else>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Content') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="template.response"
@change="(val) => (template.response = val)"
:editable="true"
:fixedMenu="true"
:placeholder="
__(
'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nFrappe Learning'
)
"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { reactive, watch } from 'vue'
import { cleanError } from '@/utils'
const props = defineProps({
templateID: {
type: String,
default: 'new',
},
})
const show = defineModel()
const emailTemplates = defineModel('emailTemplates')
const template = reactive({
name: '',
subject: '',
use_html: false,
response: '',
response_html: '',
})
const saveTemplate = (close) => {
if (props.templateID == 'new') {
createNewTemplate(close)
} else {
updateTemplate(close)
}
}
const createNewTemplate = (close) => {
emailTemplates.value.insert.submit(
{
__newname: template.name,
...template,
},
{
onSuccess() {
emailTemplates.value.reload()
refreshForm(close)
toast.success(__('Email Template created successfully'))
},
onError(err) {
refreshForm(close)
toast.error(
cleanError(err.messages[0]) || __('Error creating email template')
)
},
}
)
}
const updateTemplate = async (close) => {
if (props.templateID != template.name) {
await renameDoc()
}
setValue(close)
}
const setValue = (close) => {
emailTemplates.value.setValue.submit(
{
...template,
name: template.name,
},
{
onSuccess() {
emailTemplates.value.reload()
refreshForm(close)
toast.success(__('Email Template updated successfully'))
},
onError(err) {
refreshForm(close)
toast.error(
cleanError(err.messages[0]) || __('Error updating email template')
)
},
}
)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'Email Template',
old_name: props.templateID,
new_name: template.name,
})
}
watch(
() => props.templateID,
(val) => {
if (val !== 'new') {
emailTemplates.value?.data.forEach((row) => {
if (row.name === val) {
template.name = row.name
template.subject = row.subject
template.use_html = row.use_html
template.response = row.response
template.response_html = row.response_html
}
})
}
},
{ flush: 'post' }
)
const refreshForm = (close) => {
close()
template.name = ''
template.subject = ''
template.use_html = false
template.response = ''
template.response_html = ''
}
</script>

View File

@@ -42,10 +42,11 @@
<div class="grid grid-cols-2 gap-2">
<div v-for="slot in slots.data">
<div
class="text-base text-center border rounded-md bg-surface-gray-3 p-2 cursor-pointer"
class="text-base text-center border rounded-md text-ink-gray-8 bg-surface-gray-3 p-2 cursor-pointer"
@click="saveSlot(slot)"
:class="{
'border-gray-900': evaluation.start_time == slot.start_time,
'border-outline-gray-4':
evaluation.start_time == slot.start_time,
}"
>
{{ formatTime(slot.start_time) }} -
@@ -65,9 +66,9 @@
</Dialog>
</template>
<script setup>
import { Dialog, createResource, Select, FormControl } from 'frappe-ui'
import { Dialog, createResource, Select, FormControl, toast } from 'frappe-ui'
import { reactive, watch, inject } from 'vue'
import { createToast, formatTime } from '@/utils/'
import { formatTime } from '@/utils/'
const user = inject('$user')
const dayjs = inject('$dayjs')
@@ -89,7 +90,7 @@ const props = defineProps({
},
})
let evaluation = reactive({
const evaluation = reactive({
course: '',
date: '',
start_time: '',
@@ -138,29 +139,13 @@ function submitEvaluation(close) {
close()
},
onError(err) {
let message = err.messages?.[0] || err
let unavailabilityMessage
if (typeof message === 'string') {
unavailabilityMessage = message?.includes('unavailable')
} else {
unavailabilityMessage = false
}
createToast({
title: unavailabilityMessage ? __('Evaluator is Unavailable') : '',
text: message,
icon: unavailabilityMessage ? 'alert-circle' : 'x',
iconClasses: 'bg-yellow-600 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
toast.warning(__(err.messages?.[0] || err))
},
})
}
const getCourses = () => {
let courses = []
const courses = []
for (const course of props.courses) {
if (course.evaluator) {
courses.push({
@@ -170,7 +155,7 @@ const getCourses = () => {
}
}
if (courses.length == 1) {
if (courses.length === 1) {
evaluation.course = courses[0].value
}

View File

@@ -76,8 +76,8 @@
</Button>
</div>
</div>
<Tabs :tabs="tabs" v-model="tabIndex" class="border-l w-1/2">
<template #default="{ tab }">
<Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-l w-1/2">
<template #tab-panel="{ tab }">
<div
v-if="tab.label == 'Evaluation'"
class="flex flex-col space-y-4 p-5"
@@ -144,6 +144,7 @@ import {
Tabs,
Tooltip,
Textarea,
toast,
} from 'frappe-ui'
import {
User,
@@ -157,7 +158,7 @@ import {
ClipboardList,
} from 'lucide-vue-next'
import { inject, reactive, watch, ref, computed } from 'vue'
import { formatTime, showToast } from '@/utils'
import { formatTime } from '@/utils'
import Rating from '@/components/Controls/Rating.vue'
import Link from '@/components/Controls/Link.vue'
@@ -252,7 +253,10 @@ const saveEvaluation = () => {
} else {
show.value = false
}
showToast(__('Success'), __('Evaluation saved successfully'), 'check')
toast.success(__('Evaluation saved successfully'))
},
onError(err) {
toast.warning(__(err.messages?.[0] || err))
},
}
)
@@ -276,6 +280,9 @@ const certificateResource = createResource({
onSuccess(data) {
certificate.name = data
},
onError(err) {
toast.warning(__(err.messages?.[0] || err))
},
})
const certificateDetails = createResource({
@@ -307,7 +314,10 @@ const saveCertificate = () => {
{},
{
onSuccess: () => {
showToast(__('Success'), __('Certificate saved successfully'), 'check')
toast.success(__('Certificate saved successfully'))
},
onError(err) {
toast.error(__(err.messages?.[0] || err))
},
}
)

View File

@@ -35,5 +35,6 @@ const file = computed(() => {
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
if (props.type == 'remove') return '/assets/lms/frontend/Remove.mp4'
})
</script>

View File

@@ -0,0 +1,115 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
}"
>
<template #body>
<div class="p-5 min-h-[300px]">
<div class="text-lg font-semibold mb-4">
{{ __('Training Feedback') }}
</div>
<ListView
:columns="feedbackColumns"
:rows="feedbackList"
row-key="name"
:options="{
showTooltip: false,
rowHeight: 'h-16',
selectable: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
></ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in feedbackList"
class="group feedback-list"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="ratingKeys.includes(column.key)">
<Rating v-model="row[column.key]" :readonly="true" />
</div>
<div v-else class="leading-5">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Dialog,
ListView,
Avatar,
ListHeader,
ListRows,
ListRow,
ListRowItem,
Rating,
} from 'frappe-ui'
import { reactive, computed } from 'vue'
const show = defineModel()
const ratingKeys = ['content', 'instructors', 'value']
const props = defineProps({
feedbackList: {
type: Array,
required: true,
},
})
const feedbackColumns = computed(() => {
return [
{
label: 'Member',
key: 'member_name',
width: '10rem',
},
{
label: 'Feedback',
key: 'feedback',
width: '15rem',
},
{
label: 'Content',
key: 'content',
width: '9rem',
},
{
label: 'Instructors',
key: 'instructors',
width: '9rem',
},
{
label: 'Value',
key: 'value',
width: '9rem',
},
]
})
</script>

View File

@@ -64,10 +64,10 @@
</Dialog>
</template>
<script setup>
import { Dialog, FileUploader, Button, createResource } from 'frappe-ui'
import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui'
import { FileText } from 'lucide-vue-next'
import { ref, inject } from 'vue'
import { createToast, getFileSize } from '@/utils/'
import { getFileSize } from '@/utils/'
const resume = ref(null)
const show = defineModel()
@@ -112,24 +112,12 @@ const submitResume = (close) => {
}
},
onSuccess() {
createToast({
title: 'Success',
text: 'Your application has been submitted',
icon: 'check',
iconClasses: 'bg-surface-green-3 text-ink-white rounded-md p-px',
})
toast.success('Your application has been submitted successfully')
application.value.reload()
close()
},
onError(err) {
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -0,0 +1,106 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Attendance for Class - {0}').format(live_class?.title),
size: '4xl',
}"
>
<template #body-content>
<div
class="grid grid-cols-2 gap-12 text-sm font-semibold text-ink-gray-5 pb-2"
>
<div>
{{ __('Member') }}
</div>
<div class="grid grid-cols-3 gap-20">
<div>
{{ __('Joined at') }}
</div>
<div class="text-center">
{{ __('Left at') }}
</div>
<div>
{{ __('Attended for') }}
</div>
</div>
</div>
<div class="divide-y text-base">
<div
v-for="participant in participants.data"
@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">
<Avatar
:image="participant.member_image"
:label="participant.member_name"
size="xl"
/>
<div class="space-y-1">
<div class="font-medium">
{{ participant.member_name }}
</div>
<div>
{{ participant.member }}
</div>
</div>
</div>
<div class="grid grid-cols-3 gap-20 text-right">
<div>
{{ dayjs(participant.joined_at).format('HH:mm a') }}
</div>
<div>
{{ dayjs(participant.left_at).format('HH:mm a') }}
</div>
<div>{{ participant.duration }} {{ __('minutes') }}</div>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Avatar, createListResource, Dialog, Tooltip } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { inject } from 'vue'
const show = defineModel()
const router = useRouter()
const dayjs = inject('$dayjs')
interface LiveClass {
name: String
title: String
}
const props = defineProps<{
live_class: LiveClass | null
}>()
const participants = createListResource({
doctype: 'LMS Live Class Participant',
filter: {
live_class: props.live_class?.name,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'joined_at',
'left_at',
'duration',
],
auto: true,
})
const redirectToProfile = (username: string) => {
router.push({
name: 'Profile',
params: { username },
})
}
</script>

View File

@@ -8,7 +8,7 @@
{
label: 'Submit',
variant: 'solid',
onClick: (close) => submitLiveClass(close),
onClick: ({ close }) => submitLiveClass(close),
},
],
}"
@@ -16,14 +16,29 @@
<template #body-content>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="space-y-4">
<FormControl
type="text"
v-model="liveClass.title"
:label="__('Title')"
class="mb-4"
:required="true"
/>
<FormControl
v-model="liveClass.date"
type="date"
: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>
</div>
<div class="space-y-4">
<Tooltip
:text="
__(
@@ -35,7 +50,6 @@
v-model="liveClass.time"
type="time"
:label="__('Time')"
class="mb-4"
:required="true"
/>
</Tooltip>
@@ -52,24 +66,6 @@
:required="true"
/>
</div>
</div>
<div>
<FormControl
v-model="liveClass.date"
type="date"
class="mb-4"
:label="__('Date')"
:required="true"
/>
<Tooltip :text="__('Duration of the live class in minutes')">
<FormControl
type="number"
v-model="liveClass.duration"
:label="__('Duration')"
class="mb-4"
:required="true"
/>
</Tooltip>
<FormControl
v-model="liveClass.auto_recording"
type="select"
@@ -94,9 +90,10 @@ import {
Tooltip,
FormControl,
Autocomplete,
toast,
} from 'frappe-ui'
import { reactive, inject, onMounted } from 'vue'
import { getTimezones, createToast, getUserTimezone } from '@/utils/'
import { getTimezones, getUserTimezone } from '@/utils/'
const liveClasses = defineModel('reloadLiveClasses')
const show = defineModel()
@@ -106,7 +103,11 @@ const dayjs = inject('$dayjs')
const props = defineProps({
batch: {
type: String,
default: null,
required: true,
},
zoomAccount: {
type: String,
required: true,
},
})
@@ -138,15 +139,15 @@ const getTimezoneOptions = () => {
const getRecordingOptions = () => {
return [
{
label: 'No Recording',
label: __('No Recording'),
value: 'No Recording',
},
{
label: 'Local',
label: __('Local'),
value: 'Local',
},
{
label: 'Cloud',
label: __('Cloud'),
value: 'Cloud',
},
]
@@ -158,6 +159,7 @@ const createLiveClass = createResource({
return {
doctype: 'LMS Live Class',
batch_name: values.batch,
zoom_account: props.zoomAccount,
...values,
}
},
@@ -166,54 +168,52 @@ const createLiveClass = createResource({
const submitLiveClass = (close) => {
return createLiveClass.submit(liveClass, {
validate() {
if (!liveClass.title) {
return __('Please enter a title.')
}
if (!liveClass.date) {
return __('Please select a date.')
}
if (!liveClass.time) {
return __('Please select a time.')
}
if (!liveClass.timezone) {
return __('Please select a timezone.')
}
if (!valideTime()) {
return __('Please enter a valid time in the format HH:mm.')
}
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
liveClass.timezone,
true
)
if (
liveClassDateTime.isSameOrBefore(
dayjs().tz(liveClass.timezone, false),
'minute'
)
) {
return __('Please select a future date and time.')
}
if (!liveClass.duration) {
return __('Please select a duration.')
}
validateFormFields()
},
onSuccess() {
liveClasses.value.reload()
refreshForm()
close()
},
onError(err) {
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
toast.error(err.messages?.[0] || err)
},
})
}
const validateFormFields = () => {
if (!liveClass.title) {
return __('Please enter a title.')
}
if (!liveClass.date) {
return __('Please select a date.')
}
if (!liveClass.time) {
return __('Please select a time.')
}
if (!liveClass.timezone) {
return __('Please select a timezone.')
}
if (!valideTime()) {
return __('Please enter a valid time in the format HH:mm.')
}
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
liveClass.timezone,
true
)
if (
liveClassDateTime.isSameOrBefore(
dayjs().tz(liveClass.timezone, false),
'minute'
)
) {
return __('Please select a future date and time.')
}
if (!liveClass.duration) {
return __('Please select a duration.')
}
}
const valideTime = () => {
let time = liveClass.time.split(':')
if (time.length != 2) {
@@ -227,4 +227,14 @@ const valideTime = () => {
}
return true
}
const refreshForm = () => {
liveClass.title = ''
liveClass.description = ''
liveClass.date = ''
liveClass.time = ''
liveClass.duration = ''
liveClass.timezone = getUserTimezone()
liveClass.auto_recording = 'No Recording'
}
</script>

View File

@@ -30,11 +30,10 @@
</Dialog>
</template>
<script setup>
import { Dialog, createResource } from 'frappe-ui'
import { Dialog, createResource, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { reactive, watch } from 'vue'
import IconPicker from '@/components/Controls/IconPicker.vue'
import { showToast } from '@/utils'
const sidebar = defineModel('reloadSidebar')
const show = defineModel()
@@ -78,10 +77,10 @@ const addWebPage = (close) => {
onSuccess() {
sidebar.value.reload()
close()
showToast('Success', 'Web page added to sidebar', 'check')
toast.success(__('Web page added to sidebar'))
},
onError(err) {
showToast('Error', err.message[0] || err, 'x')
toast.error(err.message[0] || err)
close()
},
}

View File

@@ -2,7 +2,7 @@
<Dialog
v-model="show"
:options="{
size: '3xl',
size: '5xl',
}"
>
<template #body>
@@ -21,7 +21,7 @@
class="!p-0"
/>
</div>
<div v-if="!chooseFromExisting || editMode" class="space-y-2">
<div v-if="!chooseFromExisting || editMode">
<div>
<label class="block text-xs text-ink-gray-5 mb-1">
{{ __('Question') }}
@@ -34,7 +34,7 @@
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-8 mt-4">
<FormControl
v-model="question.marks"
:label="__('Marks')"
@@ -51,7 +51,7 @@
</div>
<div
v-if="question.type == 'Choices'"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-10"
>
{{ __('Options') }}
</div>
@@ -61,7 +61,10 @@
>
{{ __('Possibilities') }}
</div>
<div v-if="question.type == 'Choices'" class="grid grid-cols-2 gap-4">
<div
v-if="question.type == 'Choices'"
class="grid grid-cols-2 gap-x-8 gap-y-4"
>
<div v-for="n in 4" class="space-y-4 py-2">
<FormControl
:label="__('Option') + ' ' + n"
@@ -81,7 +84,7 @@
</div>
<div
v-else-if="question.type == 'User Input'"
class="grid grid-cols-2 gap-4 py-2"
class="grid grid-cols-2 gap-x-8 gap-y-4 py-2"
>
<div v-for="n in 4">
<FormControl
@@ -106,7 +109,7 @@
</div>
<div class="flex items-center justify-end space-x-2 mt-5">
<Button variant="solid" @click="submitQuestion()">
{{ __('Submit') }}
{{ __('Save') }}
</Button>
</div>
</div>
@@ -121,10 +124,10 @@ import {
createResource,
Switch,
Button,
toast,
} from 'frappe-ui'
import { computed, watch, reactive, ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
import { useOnboarding } from 'frappe-ui/frappe'
const show = defineModel()
@@ -217,7 +220,7 @@ const questionRow = createResource({
return {
doc: {
doctype: 'LMS Quiz Question',
parent: quiz.value.data.name,
parent: quiz.value.doc.name,
parentfield: 'questions',
parenttype: 'LMS Quiz',
...values,
@@ -260,7 +263,7 @@ const addQuestion = () => {
})
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
}
)
@@ -278,12 +281,12 @@ const addQuestionRow = (question) => {
updateOnboardingStep('create_first_quiz')
show.value = false
showToast(__('Success'), __('Question added successfully'), 'check')
toast.success(__('Question added successfully'))
quiz.value.reload()
show.value = false
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
show.value = false
},
}
@@ -328,18 +331,14 @@ const updateQuestion = () => {
{
onSuccess() {
show.value = false
showToast(
__('Success'),
__('Question updated successfully'),
'check'
)
toast.success(__('Question updated successfully'))
quiz.value.reload()
},
}
)
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -0,0 +1,225 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add quiz to this video'),
size: '2xl',
}"
>
<template #body-content>
<div class="text-base">
<div class="flex items-end gap-4">
<FormControl
:label="__('Time in Video')"
v-model="quiz.time"
type="text"
placeholder="2:15"
class="flex-1"
/>
<Link
v-model="quiz.quiz"
:label="__('Quiz')"
doctype="LMS Quiz"
class="flex-1"
/>
<Button @click="addQuiz()" variant="solid">
<template #prefix>
<Plus class="w-4 h-4 stroke-1.5" />
</template>
{{ __('Add') }}
</Button>
</div>
<div class="mt-10 mb-5">
<div class="font-medium mb-4">
{{ __('Quizzes in this video') }}
</div>
<ListView
v-if="allQuizzes.length"
:columns="columns"
:rows="allQuizzes"
row-key="quiz"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in allQuizzes">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key as keyof Quiz]"
:align="column.align"
>
<div v-if="column.key == 'time'" class="leading-5 text-sm">
{{ formatTimestamp(row[column.key as keyof Quiz]) }}
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key as keyof Quiz] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeQuiz(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
<div v-else class="text-ink-gray-5 italic text-xs">
{{ __('No quizzes added yet.') }}
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Dialog,
Button,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, reactive, ref, watch } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { formatTimestamp } from '@/utils'
import Link from '@/components/Controls/Link.vue'
type Quiz = {
time: string
quiz: string
}
const show = defineModel()
const allQuizzes = ref<Quiz[]>([])
const quiz = reactive<Quiz>({
time: '',
quiz: '',
})
const props = defineProps({
quizzes: {
type: Array as () => Quiz[],
default: () => [],
},
saveQuizzes: {
type: Function,
required: true,
},
duration: {
type: Number,
default: 0,
},
})
const addQuiz = () => {
quiz.time = `${getTimeInSeconds()}`
if (!isTimeValid() || !isFormComplete()) return
allQuizzes.value.push({
time: quiz.time,
quiz: quiz.quiz,
})
props.saveQuizzes(allQuizzes.value)
quiz.time = ''
quiz.quiz = ''
}
const getTimeInSeconds = () => {
if (quiz.time && !quiz.time.includes(':')) {
quiz.time = `${quiz.time}:00`
}
const timeParts = quiz.time.split(':')
const timeInSeconds = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1])
return timeInSeconds
}
const isTimeValid = () => {
if (parseInt(quiz.time) > props.duration) {
toast.error(__('Time in video exceeds the total duration of the video.'))
return false
}
return true
}
const isFormComplete = () => {
if (!quiz.time) {
toast.error(__('Please enter a valid timestamp'))
return false
}
if (!quiz.quiz) {
toast.error(__('Please select a quiz'))
return false
}
return true
}
const removeQuiz = (selections: string, unselectAll: () => void) => {
Array.from(selections).forEach((selection) => {
const index = allQuizzes.value.findIndex((q) => q.quiz === selection)
if (index !== -1) {
allQuizzes.value.splice(index, 1)
}
unselectAll()
})
props.saveQuizzes(allQuizzes.value)
}
watch(
() => props.quizzes,
(newQuizzes) => {
allQuizzes.value = newQuizzes
},
{ immediate: true }
)
const columns = computed(() => {
return [
{
key: 'quiz',
label: __('Quiz'),
},
{
key: 'time',
label: __('Time in Video (minutes)'),
align: 'center',
},
]
})
</script>

View File

@@ -15,27 +15,20 @@
>
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Rating') }}
</div>
<Rating v-model="review.rating" />
</div>
<div>
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Review') }}
</div>
<Textarea type="text" size="md" rows="5" v-model="review.review" />
</div>
<Rating v-model="review.rating" :label="__('Rating')" />
<FormControl
:label="__('Review')"
type="textarea"
v-model="review.review"
:rows="5"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Textarea, createResource } from 'frappe-ui'
import { Dialog, FormControl, createResource, toast, Rating } from 'frappe-ui'
import { reactive } from 'vue'
import Rating from '@/components/Controls/Rating.vue'
import { createToast } from '@/utils/'
const show = defineModel()
const reviews = defineModel('reloadReviews')
@@ -78,11 +71,7 @@ function submitReview(close) {
hasReviewed.value.reload()
},
onError(err) {
createToast({
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'text-ink-red-4 bg-surface-red-4',
})
toast.error(err.messages?.[0] || err)
},
})
close()

View File

@@ -19,19 +19,25 @@
doctype="User"
v-model="student"
:filters="{ ignore_user_type: 1 }"
:onCreate="
(value, close) => {
openSettings('Members', close)
}
"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, createResource } from 'frappe-ui'
import { Dialog, createResource, toast } from 'frappe-ui'
import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
import { useOnboarding } from 'frappe-ui/frappe'
import { openSettings } from '@/utils'
const students = defineModel('reloadStudents')
const batchModal = defineModel('batchModal')
const student = ref()
const user = inject('$user')
const { updateOnboardingStep } = useOnboarding('learning')
@@ -66,11 +72,12 @@ const addStudent = (close) => {
updateOnboardingStep('add_batch_student')
students.value.reload()
batchModal.value.reload()
student.value = null
close()
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -0,0 +1,250 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
title: __('Video Statistics for {0}').format(lessonTitle),
}"
>
<template #body-content>
<div class="text-base">
<div class="flex items-center justify-between">
<TabButtons
v-if="tabs.length > 1"
:buttons="tabs"
v-model="currentTab"
class="w-fit"
/>
<!-- <FormControl
v-model="searchText"
:placeholder="__('Search by Member')"
class="mt-2 mr-5 w-[25%]"
/> -->
</div>
<div v-if="currentTab" class="mt-4">
<div class="grid grid-cols-[55%,40%] gap-5">
<div class="space-y-5 border rounded-md p-2 pt-4">
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
<div class="px-4">
{{ __('Member') }}
</div>
<div class="text-center">
{{ __('Watch Time') }}
</div>
</div>
<div
v-for="row in currentTabData"
class="hover:bg-surface-gray-1 cursor-pointer rounded-md py-1 px-2"
>
<router-link
:to="{
name: 'Profile',
params: { username: row.member_username },
}"
>
<div class="grid grid-cols-[70%,30%] items-center">
<div class="flex items-center space-x-2">
<Avatar
:image="row.member_image"
:label="row.member_name"
size="xl"
/>
<div class="space-y-1">
<div class="font-medium">
{{ row.member_name }}
</div>
<div class="text-sm text-ink-gray-6">
{{ row.member }}
</div>
</div>
</div>
<div class="text-center text-sm">
{{ convertToMinutes(row.watch_time) }}
</div>
</div>
</router-link>
</div>
</div>
<div class="space-y-5">
<NumberChart
class="border rounded-md"
:config="{
title: __('Average Watch Time (minutes)'),
value: averageWatchTime,
}"
/>
<div v-if="isPlyrSource">
<div class="video-player" :src="currentTab"></div>
</div>
<VideoBlock v-else :file="currentTab" />
</div>
</div>
</div>
<div v-else class="text-sm text-ink-gray-5">
{{ __('No statistics available for this video.') }}
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
createListResource,
Dialog,
FormControl,
NumberChart,
TabButtons,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { enablePlyr, convertToMinutes } from '@/utils'
import VideoBlock from '@/components/VideoBlock.vue'
const show = defineModel<boolean | undefined>()
const currentTab = ref<string>('')
const searchText = ref<string>('')
type Filters = {
lesson: string | undefined
member_name?: string[]
}
const props = defineProps<{
lessonName?: string
lessonTitle?: string
}>()
const statistics = createListResource({
doctype: 'LMS Video Watch Duration',
filters: {
lesson: props.lessonName,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'source',
'watch_time',
],
cache: ['videoStatistics', props.lessonName],
onSuccess() {
currentTab.value = Object.keys(statisticsData.value)[0]
},
})
watch(
() => props.lessonName,
() => {
if (props.lessonName) {
statistics.filters.lesson = props.lessonName
statistics.reload()
}
}
)
watch(searchText, () => {
let filterApplied = false
let filters: Filters = {
lesson: props.lessonName,
}
if (searchText.value) {
filters.member_name = ['like', `%${searchText.value}%`]
filterApplied = true
}
statistics.update({
filters: filters,
})
statistics.reload({})
})
watch(show, () => {
if (show.value) {
enablePlyr()
}
})
const statisticsData = computed(() => {
const grouped = <Record<string, any[]>>{}
statistics.data.forEach((item: { source: string }) => {
if (!grouped[item.source]) {
grouped[item.source] = []
}
grouped[item.source].push(item)
})
return grouped
})
const averageWatchTime = computed(() => {
let totalWatchTime = 0
currentTabData.value.forEach((item: { watch_time: string }) => {
totalWatchTime += parseFloat(item.watch_time)
})
return convertToMinutes(totalWatchTime / currentTabData.value.length)
})
const currentTabData = computed(() => {
return statisticsData.value[currentTab.value] || []
})
const isPlyrSource = computed(() => {
return (
currentTab.value.includes('youtube') || currentTab.value.includes('vimeo')
)
})
const provider = computed(() => {
if (currentTab.value.includes('youtube')) {
return 'youtube'
} else if (currentTab.value.includes('vimeo')) {
return 'vimeo'
}
return ''
})
const embedURL = computed(() => {
if (isPlyrSource.value) {
return currentTab.value.replace('watch?v=', 'embed/')
}
return ''
})
const tabs = computed(() => {
return Object.keys(statisticsData.value).map((source, index) => ({
label: __(`Video ${index + 1}`),
value: source,
}))
})
</script>
<style>
.plyr__volume input[type='range'] {
display: none;
}
.plyr__control--overlaid {
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.5) 50%
);
}
.plyr__control:hover {
background: none;
}
.plyr--video {
border: 1px solid theme('colors.gray.200');
border-radius: 8px;
}
:root {
--plyr-range-fill-background: white;
--plyr-video-control-background-hover: transparent;
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<Dialog
v-model="show"
:options="{
title:
accountID === 'new' ? __('New Zoom Account') : __('Edit Zoom Account'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveAccount(close)
},
},
],
}"
>
<template #body-content>
<div class="mb-4">
<FormControl
v-model="account.enabled"
:label="__('Enabled')"
type="checkbox"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="account.name"
:label="__('Account Name')"
type="text"
:required="true"
/>
<FormControl
v-model="account.client_id"
:label="__('Client ID')"
type="text"
:required="true"
/>
<Link
v-model="account.member"
:label="__('Member')"
doctype="Course Evaluator"
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
:required="true"
/>
<FormControl
v-model="account.client_secret"
:label="__('Client Secret')"
type="password"
:required="true"
/>
<FormControl
v-model="account.account_id"
:label="__('Account ID')"
type="text"
:required="true"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, toast } from 'frappe-ui'
import { inject, reactive, watch } from 'vue'
import { User } from '@/components/Settings/types'
import { openSettings, cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
interface ZoomAccount {
name: string
account_name: string
enabled: boolean
member: string
account_id: string
client_id: string
client_secret: string
}
interface ZoomAccounts {
data: ZoomAccount[]
reload: () => void
insert: {
submit: (
data: ZoomAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
setValue: {
submit: (
data: ZoomAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
}
const show = defineModel('show')
const user = inject<User | null>('$user')
const zoomAccounts = defineModel<ZoomAccounts>('zoomAccounts')
const account = reactive({
name: '',
enabled: false,
member: user?.data?.name || '',
account_id: '',
client_id: '',
client_secret: '',
})
const props = defineProps({
accountID: {
type: String,
default: 'new',
},
})
watch(
() => props.accountID,
(val) => {
if (val != 'new') {
zoomAccounts.value?.data.forEach((acc) => {
if (acc.name === val) {
account.name = acc.name
account.enabled = acc.enabled || false
account.member = acc.member
account.account_id = acc.account_id
account.client_id = acc.client_id
account.client_secret = acc.client_secret
}
})
}
}
)
watch(show, (val) => {
if (!val) {
account.name = ''
account.enabled = false
account.member = user?.data?.name || ''
account.account_id = ''
account.client_id = ''
account.client_secret = ''
}
})
const saveAccount = (close: () => void) => {
if (props.accountID == 'new') {
createAccount(close)
} else {
updateAccount(close)
}
}
const createAccount = (close: () => void) => {
zoomAccounts.value?.insert.submit(
{
account_name: account.name,
...account,
},
{
onSuccess() {
zoomAccounts.value?.reload()
close()
toast.success(__('Zoom Account created successfully'))
},
onError(err) {
close()
toast.error(
cleanError(err.messages[0]) || __('Error creating Zoom Account')
)
},
}
)
}
const updateAccount = async (close: () => void) => {
if (props.accountID != account.name) {
await renameDoc()
}
setValue(close)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'LMS Zoom Settings',
old_name: props.accountID,
new_name: account.name,
})
}
const setValue = (close: () => void) => {
zoomAccounts.value?.setValue.submit(
{
...account,
name: account.name,
account_name: props.accountID,
},
{
onSuccess() {
zoomAccounts.value?.reload()
close()
toast.success(__('Zoom Account updated successfully'))
},
onError(err: any) {
close()
toast.error(
cleanError(err.messages[0]) || __('Error updating Zoom Account')
)
},
}
)
}
</script>

View File

@@ -1,8 +1,11 @@
<template>
<div v-if="quiz.data">
<div
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3"
class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 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)
@@ -38,6 +41,16 @@
)
}}
</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'
)
}}
</div>
</div>
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
@@ -55,19 +68,30 @@
<div class="font-semibold text-lg text-ink-gray-9">
{{ quiz.data.title }}
</div>
<Button
<div class="flex items-center justify-center space-x-2 mt-4">
<Button
v-if="
!quiz.data.max_attempts ||
attempts.data?.length < quiz.data.max_attempts
"
variant="solid"
@click="startQuiz"
>
<span>
{{ inVideo ? __('Start the Quiz') : __('Start') }}
</span>
</Button>
<Button v-if="inVideo" @click="props.backToVideo()">
{{ __('Resume Video') }}
</Button>
</div>
<div
v-if="
!quiz.data.max_attempts ||
attempts.data?.length < quiz.data.max_attempts
quiz.data.max_attempts &&
attempts.data?.length >= quiz.data.max_attempts
"
@click="startQuiz"
class="mt-2"
class="leading-5 text-ink-gray-7"
>
<span>
{{ __('Start') }}
</span>
</Button>
<div v-else class="leading-5 text-ink-gray-7">
{{
__(
'You have already exceeded the maximum number of attempts allowed for this quiz.'
@@ -247,18 +271,23 @@
)
}}
</div>
<Button
@click="resetQuiz()"
class="mt-2"
v-if="
!quiz.data.max_attempts ||
attempts?.data.length < quiz.data.max_attempts
"
>
<span>
{{ __('Try Again') }}
</span>
</Button>
<div class="space-x-2">
<Button
@click="resetQuiz()"
class="mt-2"
v-if="
!quiz.data.max_attempts ||
attempts?.data.length < quiz.data.max_attempts
"
>
<span>
{{ __('Try Again') }}
</span>
</Button>
<Button v-if="inVideo" @click="props.backToVideo()">
{{ __('Resume Video') }}
</Button>
</div>
</div>
<div
v-if="
@@ -291,9 +320,9 @@ import {
ListView,
TextEditor,
FormControl,
toast,
} from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue'
import { createToast, showToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils'
import { useRouter } from 'vue-router'
@@ -308,13 +337,20 @@ let questions = reactive([])
const possibleAnswer = ref(null)
const timer = ref(0)
let timerInterval = null
const router = useRouter()
const props = defineProps({
quizName: {
type: String,
required: true,
},
inVideo: {
type: Boolean,
default: false,
},
backToVideo: {
type: Function,
default: () => {},
},
})
const quiz = createResource({
@@ -494,12 +530,7 @@ const getAnswers = () => {
const checkAnswer = () => {
let answers = getAnswers()
if (!answers.length) {
createToast({
title: 'Please select an option',
icon: 'alert-circle',
iconClasses: 'text-yellow-600 bg-yellow-100 rounded-full',
position: 'top-center',
})
toast.warning(__('Please select an option'))
return
}
@@ -589,7 +620,7 @@ const createSubmission = () => {
const errorTitle = err?.message || ''
if (errorTitle.includes('MaximumAttemptsExceededError')) {
const errorMessage = err.messages?.[0] || err
showToast(__('Error'), __(errorMessage), 'x')
toast.error(__(errorMessage))
setTimeout(() => {
window.location.reload()
}, 3000)
@@ -616,11 +647,17 @@ const getInstructions = (question) => {
}
const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') {
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: router.currentRoute.value.params.courseName,
chapter_number: router.currentRoute.value.params.chapterNumber,
lesson_number: router.currentRoute.value.params.lessonNumber,
course: pathname[3],
chapter_number: lessonIndex[0],
lesson_number: lessonIndex[1],
})
}
}
@@ -628,25 +665,25 @@ const markLessonProgress = () => {
const getSubmissionColumns = () => {
return [
{
label: 'No.',
label: __('No.'),
key: 'idx',
},
{
label: 'Date',
label: __('Date'),
key: 'creation',
},
{
label: 'Score',
label: __('Score'),
key: 'score',
align: 'center',
},
{
label: 'Score out of',
label: __('Score out of'),
key: 'score_out_of',
align: 'center',
},
{
label: 'Percentage',
label: __('Percentage'),
key: 'percentage',
align: 'center',
},

View File

@@ -0,0 +1,52 @@
<template>
<div v-if="relatedCourses.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-6">
<div class="text-2xl font-semibold text-ink-gray-9">
{{ __('Related Courses') }}
</div>
</div>
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
>
<router-link
v-for="course in relatedCourses.data"
:key="course.name"
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
class="cursor-pointer"
>
<CourseCard :course="course" />
</router-link>
</div>
</div>
</template>
<script setup>
import { createResource } from 'frappe-ui'
import { watch } from 'vue'
import CourseCard from '@/components/CourseCard.vue'
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
const relatedCourses = createResource({
url: 'lms.lms.utils.get_related_courses',
cache: ['related_courses', props.courseName],
makeParams() {
return {
course: props.courseName,
}
},
auto: true,
})
watch(
() => props.courseName,
() => {
relatedCourses.reload()
}
)
</script>

View File

@@ -0,0 +1,142 @@
<template>
<Dialog
v-model="show"
:options="{
title:
props.badgeAssignmentID === 'new'
? __('Assign a Badge')
: __('Edit Badge Assignment'),
size: 'sm',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveBadgeAssignment(close)
},
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<Link
doctype="User"
v-model="badgeAssignment.member"
:label="__('Member')"
:required="true"
/>
<Link
doctype="LMS Badge"
v-model="badgeAssignment.badge"
:label="__('Badge')"
:required="true"
/>
<div>
<label class="text-xs text-ink-gray-5 mb-1">
{{ __('Issued On') }}
<span class="text-ink-red-3">*</span>
</label>
<DatePicker
v-model="badgeAssignment.issued_on"
:placeholder="__('Select Date')"
:required="true"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Dialog, DatePicker, toast } from 'frappe-ui'
import type {
BadgeAssignments,
BadgeAssignment,
} from '@/components/Settings/types'
import { ref, watch } from 'vue'
import { cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const show = defineModel<boolean>({ required: true, default: false })
const defaultBadgeAssignment = {
name: '',
badge: '',
member: '',
issued_on: '',
member_name: '',
member_username: '',
member_image: '',
}
const badgeAssignments = defineModel<BadgeAssignments>('badgeAssignments')
const badgeAssignment = ref<BadgeAssignment>(defaultBadgeAssignment)
const props = defineProps<{
badgeAssignmentID: string
badge: string | null
}>()
watch(
() => props.badgeAssignmentID,
(newID) => {
if (newID === 'new') {
badgeAssignment.value = {
...defaultBadgeAssignment,
badge: props.badge || '',
}
} else {
const assignment = badgeAssignments.value?.data?.find(
(assignment) => assignment.name === newID
)
if (assignment) {
badgeAssignment.value = { ...assignment }
}
}
}
)
const saveBadgeAssignment = (close: () => void) => {
if (props.badgeAssignmentID === 'new') {
createBadgeAssignment(close)
} else {
updateBadgeAssignment(close)
}
}
const updateBadgeAssignment = async (close: () => void) => {
badgeAssignments.value?.setValue.submit(
{
...badgeAssignment.value,
},
{
onSuccess: () => {
toast.success(__('Badge assignment updated successfully'))
close()
},
onError: (error) => {
toast.error(
__('Failed to update badge assignment: ') + cleanError(error)
)
},
}
)
}
const createBadgeAssignment = (close: () => void) => {
badgeAssignments.value?.insert.submit(
{
...badgeAssignment.value,
},
{
onSuccess: () => {
toast.success(__('Badge assignment created successfully'))
close()
},
onError: (error) => {
toast.error(
__('Failed to create badge assignment: ') + cleanError(error)
)
},
}
)
}
</script>

View File

@@ -0,0 +1,192 @@
<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">
<ChevronLeft
class="size-5 stroke-1.5 text-ink-gray-5 cursor-pointer"
@click="
() => {
show = false
}
"
/>
<div class="text-xl font-semibold text-ink-gray-9">
{{ props.badgeName }}
</div>
</div>
<Button @click="openForm('new')">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
<div v-if="assignments.data?.length">
<ListView
:rows="assignments.data"
:columns="columns"
rowKey="name"
:options="{
showTooltip: false,
onRowClick: (row: BadgeAssignment) => {
openForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in assignments.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteBadgeAssignment(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="flex flex-col items-center justify-center mt-44">
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
{{ __('No Assignments') }}
</div>
<div
class="leading-5 text-base w-2/5 text-base text-center text-ink-gray-7"
>
{{ __('This badge has not been assigned to any students yet') }}
</div>
</div>
</div>
<BadgeAssignmentForm
v-model="showForm"
:badgeAssignmentID="currentAssignmentID"
:badge="props.badgeName"
v-model:badgeAssignments="assignments"
/>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
createListResource,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { ChevronLeft, GraduationCap, Plus, Trash2 } from 'lucide-vue-next'
import { computed, inject, ref } from 'vue'
import type { BadgeAssignment } from '@/components/Settings/types'
import BadgeAssignmentForm from '@/components/Settings/BadgeAssignmentForm.vue'
const show = defineModel<boolean>()
const dayjs = inject('$dayjs') as any
const showForm = ref(false)
const currentAssignmentID = ref<string>('')
const props = defineProps<{
badgeName: string | null
}>()
const assignments = createListResource({
doctype: 'LMS Badge Assignment',
fields: [
'name',
'member',
'member_name',
'member_username',
'member_image',
'issued_on',
'badge',
],
filters: {
badge: props.badgeName,
},
order_by: 'issued_on desc',
transform(data: BadgeAssignment[]) {
return data.map((item: BadgeAssignment) => {
return {
...item,
issued_on: item.issued_on
? dayjs(item.issued_on).format('DD MMM YYYY')
: null,
}
})
},
auto: true,
})
const openForm = (assignmentID: string) => {
currentAssignmentID.value = assignmentID
showForm.value = true
}
const deleteBadgeAssignment = (
selections: Set<string>,
unselectAll: () => void
) => {
Array.from(selections).forEach(async (assignment: string) => {
await assignments.delete.submit(assignment)
})
unselectAll()
toast.success(__('Badge assignments deleted successfully'))
}
const columns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
icon: 'user',
width: '60%',
},
{
label: __('Issued On'),
key: 'issued_on',
icon: 'calendar',
align: 'center',
},
]
})
</script>

View File

@@ -0,0 +1,219 @@
<template>
<Dialog
v-model="show"
:options="{
title: badge ? __('Edit Badge') : __('Create a new Badge'),
size: '3xl',
}"
>
<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"
/>
<FormControl
v-model="badge.title"
:label="__('Title')"
type="text"
:required="true"
/>
<Autocomplete
@update:modelValue="(opt: any) => (badge.reference_doctype = opt.value)"
:modelValue="badge.reference_doctype"
:options="referenceDoctypeOptions"
:required="true"
:label="__('Assign For')"
/>
<FormControl
v-model="badge.description"
:label="__('Description')"
:required="true"
type="textarea"
/>
<Uploader
v-model="badge.image"
label="Badge Image"
description="An image that represents the badge."
/>
</div>
<div class="space-y-4">
<FormControl
v-model="badge.grant_only_once"
:label="__('Grant Only Once')"
type="checkbox"
/>
<FormControl
v-model="badge.event"
:label="__('Event')"
type="select"
:options="eventOptions"
:required="true"
/>
<FormControl
v-model="badge.user_field"
:label="__('Assign To')"
type="select"
:options="userFieldOptions"
:required="true"
/>
<CodeEditor
v-model="badge.condition"
:label="__('Condition')"
type="JavaScript"
:required="true"
:showBorder="true"
height="82px"
/>
</div>
<div class="space-y-4"></div>
</div>
</template>
<template #actions="{ close }">
<div class="pb-5 float-right">
<Button variant="solid" @click="saveBadge(close)">
{{ __('Save') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { cleanError } from '@/utils'
import type { Badges, Badge } from '@/components/Settings/types'
import Autocomplete from '@/components/Controls/Autocomplete.vue'
import CodeEditor from '@/components/Controls/CodeEditor.vue'
import Uploader from '@/components/Controls/Uploader.vue'
const defaultBadge = {
name: '',
title: '',
enabled: true,
description: '',
image: '',
grant_only_once: false,
event: 'New',
reference_doctype: '',
condition: '',
user_field: 'member',
field_to_check: '',
}
const show = defineModel<boolean>({ required: true, default: false })
const badges = defineModel<Badges>('badges')
const badge = ref<Badge>(defaultBadge)
const props = defineProps<{
badgeName: string | null
}>()
watch(
() => props.badgeName,
(val) => {
if (val != 'new') {
badges.value?.data.forEach((bdg: Badge) => {
if (bdg.name === val) {
badge.value = bdg
}
})
} else {
badge.value = { ...defaultBadge }
}
}
)
const saveBadge = (close: () => void) => {
if (props.badgeName == 'new') {
createBadge(close)
} else {
updateBadge(close)
}
}
const updateBadge = async (close: () => void) => {
if (props.badgeName != badge.value?.title) {
await renameDoc()
}
setValue(close)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'LMS Badge',
old_name: props.badgeName,
new_name: badge.value?.title,
})
}
const setValue = (close: () => void) => {
badges.value?.setValue.submit(
{
...badge.value,
name: badge.value.title,
},
{
onSuccess() {
badges.value?.reload()
close()
toast.success(__('Badge updated successfully'))
},
onError(err: any) {
close()
toast.error(cleanError(err.messages[0]) || err)
},
}
)
}
const createBadge = (close: () => void) => {
badges.value?.insert.submit(
{
...badge.value,
name: badge.value.name,
},
{
onSuccess() {
badges.value?.reload()
close()
toast.success(__('Badge created successfully'))
},
onError(err) {
close()
toast.error(cleanError(err.messages[0]) || __('Error creating badge'))
},
}
)
}
const referenceDoctypeOptions = computed(() => {
return [
{ label: __('Course'), value: 'LMS Course' },
{ label: __('Batch'), value: 'LMS Batch' },
{ label: __('User'), value: 'Member' },
{ label: __('Quiz Submission'), value: 'LMS Quiz Submission' },
{ label: __('Assignment Submission'), value: 'LMS Assignment Submission' },
{
label: __('Programming Exercise Submission'),
value: 'LMS Programming Exercise Submission',
},
{ label: __('Course Enrollment'), value: 'LMS Enrollment' },
{ label: __('Batch Enrollment'), value: 'LMS Batch Enrollment' },
]
})
const eventOptions = computed(() => {
let options = ['New', 'Value Change', 'Auto Assign']
return options.map((event) => ({ label: __(event), value: event }))
})
const userFieldOptions = computed(() => {
return [
{ label: __('Member'), value: 'member' },
{ label: __('Owner'), value: 'owner' },
]
})
</script>

View File

@@ -0,0 +1,247 @@
<template>
<BadgeAssignments
v-if="showAssignments"
v-model="showAssignments"
:badgeName="showAssignmentsFor"
/>
<div v-else class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
<div v-if="badges.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="badges.data"
row-key="name"
:options="{
showTooltip: false,
selectable: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns" :key="item.key">
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in badges.data" :key="row.name">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="green">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else-if="column.key == 'reference_doctype'">
{{
doctypeLabel[
row[column.key] as keyof typeof doctypeLabel
] || row[column.key]
}}
</div>
<FormControl
v-else-if="column.key == 'grant_only_once'"
v-model="row[column.key]"
type="checkbox"
:disabled="true"
/>
<div
v-else-if="column.key != 'action'"
class="leading-5 text-sm"
>
{{ row[column.key] }}
</div>
<Dropdown
v-else
:options="getMoreOptions(row.name)"
:button="{
icon: 'more-horizontal',
onblur: (e: Event) => {
e.stopPropagation()
},
}"
placement="right"
/>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
</div>
<BadgeForm
v-model="showForm"
:badgeName="selectedBadge"
v-model:badges="badges"
/>
</template>
<script setup lang="ts">
import {
Badge,
Button,
createListResource,
Dropdown,
FeatherIcon,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
toast,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import BadgeForm from '@/components/Settings/BadgeForm.vue'
import BadgeAssignments from '@/components/Settings/BadgeAssignments.vue'
const showForm = ref<boolean>(false)
const selectedBadge = ref<string | null>(null)
const showAssignments = ref<boolean>(false)
const showAssignmentsFor = ref<string | null>(null)
const props = defineProps<{
label: string
description: string
}>()
const badges = createListResource({
doctype: 'LMS Badge',
fields: [
'name',
'title',
'enabled',
'description',
'image',
'grant_only_once',
'event',
'reference_doctype',
'condition',
'user_field',
'field_to_check',
],
order_by: 'creation desc',
auto: true,
})
const getMoreOptions = (badgeName: string) => {
return [
{
label: __('Edit'),
icon: 'edit',
onClick() {
openForm(badgeName)
},
},
{
label: __('Assignments'),
icon: 'download',
onClick() {
showAssignmentsFor.value = badgeName
showAssignments.value = true
},
},
{
label: __('Delete'),
icon: 'trash-2',
onClick() {
deleteBadge(badgeName)
},
},
]
}
const openForm = (badgeName: string) => {
selectedBadge.value = badgeName
showForm.value = true
}
const deleteBadge = (badgeName: string) => {
badges.delete
.submit(badgeName)
.then(() => {
badges.reload()
toast.success(__('Badge deleted successfully'))
})
.catch((err: any) => {
toast.error(cleanError(err.messages[0]) || __('Error deleting badge'))
})
}
const doctypeLabel = computed(() => {
return {
'LMS Course': __('Course'),
'LMS Batch': __('Batch'),
'LMS Enrollment': __('Course Enrollment'),
'LMS Batch Enrollment': __('Batch Enrollment'),
'LMS Quiz Submission': __('Quiz Submission'),
'LMS Assignment Submission': __('Assignment Submission'),
'LMS Programming Exercise Submission': __(
'Programming Exercise Submission'
),
}
})
const columns = computed(() => {
return [
{
label: __('Badge'),
key: 'title',
icon: 'award',
align: 'left',
width: '25%',
},
{
label: __('Assigned For'),
key: 'reference_doctype',
icon: 'info',
align: 'left',
width: '35%',
},
{
label: __('Status'),
key: 'enabled',
icon: 'check-square',
align: 'left',
width: '15%',
},
{
label: __('Grant Only Once'),
key: 'grant_only_once',
icon: 'check',
align: 'center',
width: '20%',
},
{
key: 'action',
align: 'right',
},
]
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col justify-between min-h-0">
<div class="flex flex-col justify-between h-full">
<div>
<div class="flex items-center justify-between">
<div class="font-semibold mb-1 text-ink-gray-9">
@@ -17,18 +17,18 @@
</div>
</div>
<div class="overflow-y-auto">
<SettingFields :fields="fields" :data="data.data" />
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
<SettingFields :fields="fields" :data="branding.data" />
</div>
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
</div>
</template>
<script setup>
import { createResource, Button, Badge } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue'
import SettingFields from '@/components/Settings/SettingFields.vue'
import { watch, ref } from 'vue'
const isDirty = ref(false)
@@ -38,10 +38,6 @@ const props = defineProps({
type: Array,
required: true,
},
data: {
type: Object,
required: true,
},
label: {
type: String,
required: true,
@@ -51,6 +47,12 @@ const props = defineProps({
},
})
const branding = createResource({
url: 'lms.lms.api.get_branding',
auto: true,
cache: 'brand',
})
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
@@ -64,7 +66,7 @@ const saveSettings = createResource({
const update = () => {
let fieldsToSave = {}
let imageFields = ['favicon', 'banner_image', 'footer_logo']
let imageFields = ['favicon', 'banner_image']
props.fields.forEach((f) => {
if (imageFields.includes(f.name)) {
fieldsToSave[f.name] = f.value ? f.value.file_url : null
@@ -72,6 +74,8 @@ const update = () => {
fieldsToSave[f.name] = f.value
}
})
fieldsToSave['app_logo'] = fieldsToSave['banner_image']
saveSettings.submit(
{
fields: fieldsToSave,
@@ -84,9 +88,31 @@ const update = () => {
)
}
watch(props.data, (newData) => {
if (newData && !isDirty.value) {
isDirty.value = true
}
watch(branding, (updatedDoc) => {
let textFields = []
let imageFields = []
props.fields.forEach((f) => {
if (f.type === 'Upload') {
imageFields.push(f.name)
} else {
textFields.push(f.name)
}
})
textFields.forEach((field) => {
if (updatedDoc.data[field] != updatedDoc.previousData[field]) {
isDirty.value = true
}
})
imageFields.forEach((field) => {
if (
updatedDoc.data[field] &&
updatedDoc.data[field].file_url != updatedDoc.previousData[field].file_url
) {
isDirty.value = true
}
})
})
</script>

View File

@@ -0,0 +1,216 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div>
</div>
<div class="flex items-center space-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"
v-if="saving"
>
<LoadingIndicator class="size-2" />
<span class="text-xs">
{{ __('saving...') }}
</span>
</div>
<Button @click="() => showCategoryForm()">
<template #prefix>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" />
</template>
{{ showForm ? __('Close') : __('New') }}
</Button>
</div>
</div>
<div
v-if="showForm"
class="flex items-center justify-between my-4 space-x-2"
>
<FormControl
ref="categoryInput"
v-model="category"
:placeholder="__('Category Name')"
class="flex-1"
/>
<Button @click="addCategory()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="overflow-y-scroll">
<div class="divide-y space-y-2">
<div
v-for="(cat, index) in categories.data"
:key="cat.name"
class="pt-2"
>
<div
v-if="editing?.name !== cat.name"
class="flex items-center justify-between group text-sm"
>
<div @dblclick="allowEdit(cat, index)">
{{ cat.category }}
</div>
<Button
variant="ghost"
theme="red"
class="invisible group-hover:visible"
@click="deleteCategory(cat.name)"
>
<template #icon>
<Trash2 class="size-4 stroke-1.5 text-ink-red-4" />
</template>
</Button>
</div>
<FormControl
v-else
:ref="(el) => (editInputRef[index] = el)"
v-model="editedValue"
type="text"
class="w-full"
@keyup.enter="saveChanges(cat.name, editedValue)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
Button,
FormControl,
LoadingIndicator,
createListResource,
createResource,
toast,
} from 'frappe-ui'
import { Plus, Trash2, X } from 'lucide-vue-next'
import { ref } from 'vue'
import { cleanError } from '@/utils'
const showForm = ref(false)
const category = ref(null)
const categoryInput = ref(null)
const saving = ref(false)
const editing = ref(null)
const editedValue = ref('')
const editInputRef = ref([])
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
})
const categories = createListResource({
doctype: 'LMS Category',
fields: ['name', 'category'],
auto: true,
})
const addCategory = () => {
categories.insert.submit(
{
category: category.value,
},
{
onSuccess(data) {
categories.reload()
category.value = null
showForm.value = false
toast.success(__('Category added successfully'))
},
onError(err) {
toast.error(__(cleanError(err.messages[0]) || 'Unable to add category'))
},
}
)
}
const showCategoryForm = () => {
showForm.value = !showForm.value
setTimeout(() => {
categoryInput.value.$el.querySelector('input').focus()
}, 0)
}
const updateCategory = createResource({
url: 'frappe.client.rename_doc',
makeParams(values) {
return {
doctype: 'LMS Category',
old_name: values.name,
new_name: values.category,
}
},
})
const update = (name, value) => {
saving.value = true
updateCategory.submit(
{
name: name,
category: value,
},
{
onSuccess() {
saving.value = false
categories.reload()
editing.value = null
editedValue.value = ''
toast.success(__('Category updated successfully'))
},
onError(err) {
saving.value = false
editing.value = null
editedValue.value = ''
toast.error(
__(cleanError(err.messages[0]) || 'Unable to update category')
)
},
}
)
}
const deleteCategory = (name) => {
saving.value = true
categories.delete.submit(name, {
onSuccess() {
saving.value = false
categories.reload()
toast.success(__('Category deleted successfully'))
},
onError(err) {
saving.value = false
toast.error(
__(cleanError(err.messages[0]) || 'Unable to delete category')
)
},
})
}
const saveChanges = (name, value) => {
saving.value = true
update(name, value)
}
const allowEdit = (cat, index) => {
editing.value = cat
editedValue.value = cat.category
setTimeout(() => {
editInputRef.value[index].$el.querySelector('input').focus()
}, 0)
}
</script>

View File

@@ -0,0 +1,160 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div> -->
</div>
<div class="flex items-center space-x-5">
<Button @click="openTemplateForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div v-if="emailTemplates.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="emailTemplates.data"
row-key="name"
:options="{
showTooltip: false,
onRowClick: (row) => {
openTemplateForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-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"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in emailTemplates.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeTemplate(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
<EmailTemplateModal
v-model="showForm"
v-model:emailTemplates="emailTemplates"
:templateID="selectedTemplate"
/>
</template>
<script setup lang="ts">
import {
Button,
call,
createListResource,
ListView,
ListHeader,
ListHeaderItem,
ListSelectBanner,
ListRows,
ListRow,
ListRowItem,
toast,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
})
const showForm = ref(false)
const readOnlyMode = window.read_only_mode
const selectedTemplate = 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 removeTemplate = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'Email Template',
documents: Array.from(selections),
})
.then(() => {
emailTemplates.reload()
toast.success(__('Email Templates deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting email templates')
)
})
}
const openTemplateForm = (templateID) => {
if (readOnlyMode) {
return
}
selectedTemplate.value = templateID
showForm.value = true
}
const columns = computed(() => {
return [
{
label: 'Name',
key: 'name',
width: '20rem',
},
{
label: 'Subject',
key: 'subject',
width: '25rem',
},
]
})
</script>

View File

@@ -0,0 +1,204 @@
<template>
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="flex item-center space-x-2">
<Button variant="solid" @click="() => (showForm = !showForm)">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div class="mt-8 pb-5">
<FormControl
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
class="w-1/4 mb-4"
>
<template #prefix>
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
</template>
</FormControl>
<div class="overflow-auto h-[60vh]">
<div class="divide-y">
<div
v-for="evaluator in evaluators.data"
:key="evaluator.evaluator"
class="cursor-pointer"
>
<div class="flex items-center justify-between group py-3">
<div
class="flex items-center space-x-3"
@click="openProfile(evaluator.username)"
>
<Avatar
:image="evaluator.user_image"
:label="evaluator.full_name"
size="xl"
/>
<div class="space-y-1">
<div class="text-base font-semibold text-ink-gray-9">
{{ evaluator.full_name }}
</div>
<div class="text-xs text-ink-gray-5">
{{ evaluator.evaluator }}
</div>
</div>
</div>
<div class="invisible group-hover:visible">
<Button
variant="ghost"
@click="deleteEvaluator(evaluator.evaluator)"
>
<template #icon>
<Trash2 class="size-4 stroke-1.5 text-ink-red-3" />
</template>
</Button>
</div>
</div>
</div>
</div>
<div
v-if="evaluators.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="evaluators.reload()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
</div>
</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>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
call,
createListResource,
Dialog,
FormControl,
toast,
} from 'frappe-ui'
import { ref, watch } from 'vue'
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
const show = defineModel('show')
const search = ref('')
const showForm = ref(false)
const email = ref('')
const router = useRouter()
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
show: {
type: Boolean,
},
})
const evaluators = createListResource({
doctype: 'Course Evaluator',
fields: ['evaluator', 'username', 'full_name', 'user_image'],
auto: true,
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)
})
}
watch(search, () => {
evaluators.update({
filters: {
full_name: ['like', `%${search.value}%`],
},
})
evaluators.reload()
})
const openProfile = (username: string) => {
show.value = false
router.push({
name: 'Profile',
params: {
username: username,
},
})
}
const deleteEvaluator = (evaluator: string) => {
call('lms.lms.api.delete_evaluator', {
evaluator: evaluator,
})
.then(() => {
toast.success(__('Evaluator deleted successfully'))
evaluators.reload()
})
.catch((error: any) => {
toast.error(__(error.messages[0] || error.messages))
console.error('Error deleting evaluator:', error)
})
}
</script>

View File

@@ -5,52 +5,37 @@
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div> -->
</div>
</div>
<div class="flex item-center space-x-2">
<FormControl
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
/>
<Button @click="() => (showForm = !showForm)">
<template #icon>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" />
<Button variant="solid" @click="() => (showForm = !showForm)">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<!-- Form to add new member -->
<div v-if="showForm" class="flex items-center space-x-2 my-4">
<div class="mt-8 pb-10">
<FormControl
v-model="member.email"
:placeholder="__('Email')"
type="email"
class="w-full"
/>
<FormControl
v-model="member.first_name"
:placeholder="__('First Name')"
v-model="search"
:placeholder="__('Search')"
type="text"
class="w-full"
/>
<Button @click="addMember()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="mt-2 pb-10 overflow-auto">
<!-- Member list -->
<div class="overflow-y-scroll">
:debounce="300"
class="w-1/4 mb-4"
>
<template #prefix>
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
</template>
</FormControl>
<div class="overflow-y-scroll h-[60vh]">
<ul class="divide-y">
<li
v-for="member in memberList"
class="grid grid-cols-3 gap-10 py-2 cursor-pointer"
class="flex items-center justify-between py-2 cursor-pointer"
>
<div
@click="openProfile(member.username)"
@@ -59,27 +44,13 @@
<Avatar
:image="member.user_image"
:label="member.full_name"
size="lg"
size="xl"
/>
<div class="space-y-1">
<div class="flex">
<div class="text-ink-gray-9">
{{ member.full_name }}
</div>
<div
class="px-1"
v-if="member.role && getRole(member.role) !== 'Student'"
>
<Badge
:variant="'subtle'"
:ref_for="true"
theme="blue"
size="sm"
label="Badge"
>
{{ getRole(member.role) }}
</Badge>
</div>
</div>
<div class="text-sm text-ink-gray-7">
{{ member.name }}
@@ -87,59 +58,92 @@
</div>
</div>
<div
class="flex items-center justify-center text-ink-gray-7 text-sm"
class="flex items-center space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
v-if="member.role && member.role !== 'LMS Student'"
>
<div v-if="member.last_active">
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }}
</div>
<div v-else>-</div>
<Shield class="size-4 stroke-1.5" />
<span class="text-sm">
{{ getRole(member.role) }}
</span>
</div>
</li>
</ul>
</div>
<div
v-if="memberList.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="members.reload()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
<div
v-if="memberList.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="members.reload()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
</div>
</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>
</template>
<script setup lang="ts">
import { createResource, Avatar, Button, FormControl, Badge } from 'frappe-ui'
import {
Avatar,
Badge,
Button,
createResource,
Dialog,
FormControl,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue'
import { RefreshCw, Plus, X } from 'lucide-vue-next'
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe'
import type { User } from '@/components/Settings/types'
interface User {
data: {
email: string
name: string
enabled: boolean
user_image: string
full_name: string
user_type: ['System User', 'Website User']
username: string
is_moderator: boolean
is_system_manager: boolean
is_evaluator: boolean
is_instructor: boolean
is_fc_site: boolean
}
type Member = {
username: string
full_name: string
name: string
role?: string
user_image?: string
}
const router = useRouter()
const show = defineModel('show')
const search = ref('')
const start = ref(0)
const memberList = ref([])
const memberList = ref<Member[]>([])
const hasNextPage = ref(false)
const showForm = ref(false)
const dayjs = inject('$dayjs')
@@ -173,7 +177,7 @@ const members = createResource({
start: start.value,
}
},
onSuccess(data) {
onSuccess(data: Member[]) {
memberList.value = memberList.value.concat(data)
start.value = start.value + 20
hasNextPage.value = data.length === 20
@@ -181,7 +185,7 @@ const members = createResource({
auto: true,
})
const openProfile = (username) => {
const openProfile = (username: string) => {
show.value = false
router.push({
name: 'Profile',
@@ -193,7 +197,7 @@ const openProfile = (username) => {
const newMember = createResource({
url: 'frappe.client.insert',
makeParams(values) {
makeParams() {
return {
doc: {
doctype: 'User',
@@ -203,13 +207,12 @@ const newMember = createResource({
}
},
auto: false,
onSuccess(data) {
onSuccess(data: Member) {
show.value = false
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
router.push({
name: 'Profile',
name: 'ProfileRoles',
params: {
username: data.username,
},
@@ -217,8 +220,9 @@ const newMember = createResource({
},
})
const addMember = () => {
const addMember = (close: () => void) => {
newMember.reload()
close()
}
watch(search, () => {
@@ -227,8 +231,8 @@ watch(search, () => {
members.reload()
})
const getRole = (role) => {
const map = {
const getRole = (role: string) => {
const map: Record<string, string> = {
'LMS Student': 'Student',
'Course Creator': 'Instructor',
Moderator: 'Moderator',

View File

@@ -12,13 +12,13 @@
/> -->
</div>
<div class="overflow-y-scroll">
<div class="flex space-x-4">
<SettingFields :fields="fields" :data="data.doc" class="w-1/2" />
<div class="flex flex-col divide-y">
<SettingFields :fields="fields" :data="data.doc" />
<SettingFields
v-if="paymentGateway.data"
:fields="paymentGateway.data.fields"
:data="paymentGateway.data.data"
class="w-1/2"
class="pt-5 my-0"
/>
</div>
</div>
@@ -30,9 +30,9 @@
</div>
</template>
<script setup>
import SettingFields from '@/components/SettingFields.vue'
import SettingFields from '@/components/Settings/SettingFields.vue'
import { createResource, Badge, Button } from 'frappe-ui'
import { watch, ref } from 'vue'
import { watch } from 'vue'
const props = defineProps({
label: {
@@ -60,9 +60,28 @@ const paymentGateway = createResource({
payment_gateway: props.data.doc.payment_gateway,
}
},
transform(data) {
arrangeFields(data.fields)
return data
},
auto: true,
})
const arrangeFields = (fields) => {
fields = fields.sort((a, b) => {
if (a.type === 'Upload' && b.type !== 'Upload') {
return 1
} else if (a.type !== 'Upload' && b.type === 'Upload') {
return -1
}
return 0
})
fields.splice(3, 0, {
type: 'Column Break',
})
}
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {

View File

@@ -27,9 +27,8 @@
</template>
<script setup>
import { Button, Badge } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue'
import { showToast } from '@/utils'
import { Button, Badge, toast } from 'frappe-ui'
import SettingFields from '@/components/Settings/SettingFields.vue'
const props = defineProps({
fields: {
@@ -61,7 +60,7 @@ const update = () => {
{},
{
onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x')
toast.error(err.messages?.[0] || err)
},
}
)

View File

@@ -6,7 +6,7 @@
<div v-for="(column, index) in columns" :key="index">
<div
class="flex flex-col space-y-5"
:class="columns.length > 1 ? 'w-72' : 'w-full'"
:class="columns.length > 1 ? 'w-[21rem]' : 'w-full'"
>
<div v-for="field in column">
<Link
@@ -14,6 +14,7 @@
v-model="data[field.name]"
:doctype="field.doctype"
:label="__(field.label)"
:description="__(field.description)"
/>
<div v-else-if="field.type == 'Code'">
@@ -54,11 +55,13 @@
<div v-else>
<div class="flex items-center text-sm space-x-2">
<div
class="flex items-center justify-center rounded border border-outline-gray-modals bg-white w-[10rem] py-2"
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
>
<img
:src="data[field.name]?.file_url || data[field.name]"
class="w-[80%] rounded"
class="rounded"
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
/>
</div>
<div class="flex flex-col flex-wrap">
@@ -100,6 +103,7 @@
:rows="field.rows"
:options="field.options"
:description="field.description"
:class="columns.length > 1 ? 'w-full' : 'w-1/2'"
/>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<Dialog v-model="show" :options="{ size: '4xl' }">
<Dialog v-model="show" :options="{ size: '5xl' }">
<template #body>
<div class="flex h-[calc(100vh_-_8rem)]">
<div class="flex w-52 shrink-0 flex-col bg-surface-gray-2 p-2">
@@ -34,22 +34,16 @@
:key="activeTab.label"
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
>
<Members
v-if="activeTab.label === 'Members'"
:label="activeTab.label"
:description="activeTab.description"
v-model:show="show"
/>
<Evaluators
v-else-if="activeTab.label === 'Evaluators'"
:label="activeTab.label"
:description="activeTab.description"
v-model:show="show"
/>
<Categories
v-else-if="activeTab.label === 'Categories'"
:label="activeTab.label"
:description="activeTab.description"
<component
v-if="activeTab.template"
:is="activeTab.template"
v-bind="{
label: activeTab.label,
description: activeTab.description,
...(activeTab.label === 'Branding'
? { fields: activeTab.fields }
: {}),
}"
/>
<PaymentSettings
v-else-if="activeTab.label === 'Payment Gateway'"
@@ -58,13 +52,6 @@
:data="data"
:fields="activeTab.fields"
/>
<BrandSettings
v-else-if="activeTab.label === 'Branding'"
:label="activeTab.label"
:description="activeTab.description"
:fields="activeTab.fields"
:data="branding"
/>
<SettingDetails
v-else
:fields="activeTab.fields"
@@ -78,16 +65,19 @@
</Dialog>
</template>
<script setup>
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue'
import { Dialog, createDocumentResource } from 'frappe-ui'
import { computed, markRaw, ref, watch } from 'vue'
import { useSettings } from '@/stores/settings'
import SettingDetails from '../SettingDetails.vue'
import SettingDetails from '@/components/Settings/SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import Members from '@/components/Members.vue'
import Evaluators from '@/components/Evaluators.vue'
import Categories from '@/components/Categories.vue'
import BrandSettings from '@/components/BrandSettings.vue'
import PaymentSettings from '@/components/PaymentSettings.vue'
import Members from '@/components/Settings/Members.vue'
import Evaluators from '@/components/Settings/Evaluators.vue'
import Categories from '@/components/Settings/Categories.vue'
import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
import BrandSettings from '@/components/Settings/BrandSettings.vue'
import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
import Badges from '@/components/Settings/Badges.vue'
const show = defineModel()
const doctype = ref('LMS Settings')
@@ -102,12 +92,6 @@ const data = createDocumentResource({
auto: true,
})
const branding = createResource({
url: 'lms.lms.api.get_branding',
auto: true,
cache: 'brand',
})
const tabsStructure = computed(() => {
return [
{
@@ -118,13 +102,6 @@ const tabsStructure = computed(() => {
label: 'General',
icon: 'Wrench',
fields: [
{
label: 'Enable Learning Paths',
name: 'enable_learning_paths',
description:
'This will enforce students to go through programs assigned to them in the correct order.',
type: 'checkbox',
},
{
label: 'Allow Guest Access',
name: 'allow_guest_access',
@@ -132,6 +109,20 @@ const tabsStructure = computed(() => {
'If enabled, users can access the course and batch lists without logging in.',
type: 'checkbox',
},
{
label: 'Enable Learning Paths',
name: 'enable_learning_paths',
description:
'This will ensure students follow the assigned programs in order.',
type: 'checkbox',
},
{
label: 'Prevent Skipping Videos',
name: 'prevent_skipping_videos',
type: 'checkbox',
description:
'If enabled, users will no able to move forward in a video',
},
{
label: 'Send calendar invite for evaluations',
name: 'send_calendar_invite_for_evaluations',
@@ -139,11 +130,34 @@ const tabsStructure = computed(() => {
'If enabled, it sends google calendar invite to the student for evaluations.',
type: 'checkbox',
},
{
type: 'Column Break',
},
{
label: 'Livecode URL',
name: 'livecode_url',
doctype: 'Livecode URL',
type: 'text',
description:
'https://docs.frappe.io/learning/falcon-self-hosting-guide',
},
{
label: 'Batch Confirmation Email Template',
name: 'batch_confirmation_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Certification Email Template',
name: 'certification_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Unsplash Access Key',
name: 'unsplash_access_key',
description:
'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. https://unsplash.com/documentation#getting-started.',
'Allows users to pick a profile cover image from Unsplash. https://unsplash.com/documentation#getting-started.',
type: 'password',
},
],
@@ -160,6 +174,12 @@ const tabsStructure = computed(() => {
description:
'Configure the payment gateway and other payment related settings',
fields: [
{
label: 'Default Currency',
name: 'default_currency',
type: 'Link',
doctype: 'Currency',
},
{
label: 'Payment Gateway',
name: 'payment_gateway',
@@ -167,10 +187,7 @@ const tabsStructure = computed(() => {
doctype: 'Payment Gateway',
},
{
label: 'Default Currency',
name: 'default_currency',
type: 'Link',
doctype: 'Currency',
type: 'Column Break',
},
{
label: 'Apply GST for India',
@@ -197,28 +214,55 @@ const tabsStructure = computed(() => {
items: [
{
label: 'Members',
description: 'Manage the members of your learning system',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'UserRoundPlus',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: 'Manage the evaluators of your learning system',
description: '',
icon: 'UserCheck',
description:
'Add new evaluators or check the slots existing evaluators',
template: markRaw(Evaluators),
},
{
label: 'Zoom Accounts',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Badges',
description:
'Create badges and assign them to students to acknowledge their achievements',
icon: 'Award',
template: markRaw(Badges),
},
{
label: 'Categories',
description: 'Manage the members of your learning system',
description: 'Double click to edit the category',
icon: 'Network',
template: markRaw(Categories),
},
{
label: 'Email Templates',
description: 'Manage the email templates for your learning system',
icon: 'MailPlus',
template: markRaw(EmailTemplates),
},
],
},
{
label: 'Customise',
label: 'Customize',
hideLabel: false,
items: [
{
label: 'Branding',
icon: 'Blocks',
template: markRaw(BrandSettings),
fields: [
{
label: 'Brand Name',
@@ -235,28 +279,6 @@ const tabsStructure = computed(() => {
name: 'favicon',
type: 'Upload',
},
{
label: 'Footer Logo',
name: 'footer_logo',
type: 'Upload',
},
{
label: 'Address',
name: 'address',
type: 'textarea',
rows: 2,
},
{
label: 'Footer "Powered By"',
name: 'footer_powered',
type: 'textarea',
rows: 4,
},
{
label: 'Copyright',
name: 'copyright',
type: 'text',
},
],
},
{
@@ -275,8 +297,13 @@ const tabsStructure = computed(() => {
type: 'checkbox',
},
{
label: 'Certified Participants',
name: 'certified_participants',
label: 'Programming Exercises',
name: 'programming_exercises',
type: 'checkbox',
},
{
label: 'Certified Members',
name: 'certified_members',
type: 'checkbox',
},
{
@@ -299,24 +326,6 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Email Templates',
icon: 'MailPlus',
fields: [
{
label: 'Batch Confirmation Template',
name: 'batch_confirmation_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Certification Template',
name: 'certification_template',
doctype: 'Email Template',
type: 'Link',
},
],
},
{
label: 'Signup',
icon: 'LogIn',
@@ -335,6 +344,9 @@ const tabsStructure = computed(() => {
description:
'New users will have to be manually registered by Admins.',
},
{
type: 'Column Break',
},
{
label: 'Signup Consent HTML',
name: 'custom_signup_content',
@@ -362,12 +374,16 @@ const tabsStructure = computed(() => {
type: 'textarea',
rows: 4,
description:
'Keywords for search engines to find your website. Separated by commas.',
'Comma separated keywords for search engines to find your website.',
},
{
type: 'Column Break',
},
{
label: 'Meta Image',
name: 'meta_image',
type: 'Upload',
size: 'lg',
},
],
},

View File

@@ -0,0 +1,204 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="flex items-center space-x-5">
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div v-if="zoomAccounts.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="zoomAccounts.data"
row-key="name"
:options="{
showTooltip: false,
onRowClick: (row) => {
openForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in zoomAccounts.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="green">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAccount(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
<ZoomAccountModal
v-model="showForm"
v-model:zoomAccounts="zoomAccounts"
:accountID="currentAccount"
/>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
Badge,
call,
createListResource,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import { User } from '@/components/Settings/types'
import ZoomAccountModal from '@/components/Modals/ZoomAccountModal.vue'
const user = inject<User | null>('$user')
const showForm = ref(false)
const currentAccount = ref<string | null>(null)
const props = defineProps({
label: String,
description: String,
})
const zoomAccounts = createListResource({
doctype: 'LMS Zoom Settings',
fields: [
'name',
'enabled',
'member',
'member_name',
'member_image',
'account_id',
'client_id',
'client_secret',
],
cache: ['zoomAccounts'],
})
onMounted(() => {
fetchZoomAccounts()
})
const fetchZoomAccounts = () => {
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
if (!user?.data?.is_moderator) {
zoomAccounts.update({
filters: {
member: user.data.name,
},
})
}
zoomAccounts.reload()
}
const openForm = (accountID: string) => {
currentAccount.value = accountID
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'LMS Zoom Settings',
documents: Array.from(selections),
})
.then(() => {
zoomAccounts.reload()
toast.success(__('Email Templates deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting email templates')
)
})
}
const columns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
icon: 'user',
},
{
label: __('Account Name'),
key: 'name',
icon: 'video',
},
{
label: __('Status'),
key: 'enabled',
align: 'center',
icon: 'check-square',
},
]
})
</script>

View File

@@ -0,0 +1,74 @@
export interface User {
data: {
email: string
name: string
enabled: boolean
user_image: string
full_name: string
user_type: ['System User', 'Website User']
username: string
is_moderator: boolean
is_system_manager: boolean
is_evaluator: boolean
is_instructor: boolean
is_fc_site: boolean
}
}
export interface Badge {
name: string;
title: string;
enabled: boolean;
description: string;
image: string;
grant_only_once: boolean;
event: string;
reference_doctype: string;
condition: string;
user_field: string;
field_to_check: string;
};
export interface Badges {
data: Badge[],
reload: () => void
insert: {
submit: (
data: Badge,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
setValue: {
submit: (
data: Badge,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
}
export interface BadgeAssignment {
name: string;
member: string;
member_name: string;
member_username: string;
member_image: string;
badge: string;
issued_on: string;
}
export interface BadgeAssignments {
data: BadgeAssignment[],
reload: () => void
insert: {
submit: (
data: BadgeAssignment,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
setValue: {
submit: (
data: BadgeAssignment,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
}

View File

@@ -61,7 +61,7 @@
</button>
</template>
<script setup>
import { Tooltip, Button } from 'frappe-ui'
import { Tooltip } from 'frappe-ui'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import * as icons from 'lucide-vue-next'

View File

@@ -1,14 +1,11 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Upcoming Evaluations') }}
</div>
<Button
v-if="
!upcoming_evals.data?.length ||
upcoming_evals.length == courses.length
"
v-if="upcoming_evals.data?.length != evaluationCourses.length"
@click="openEvalModal"
>
{{ __('Schedule Evaluation') }}
@@ -17,9 +14,9 @@
<div v-if="upcoming_evals.data?.length">
<div class="grid grid-cols-3 gap-4">
<div v-for="evl in upcoming_evals.data">
<div class="border rounded-md p-3">
<div class="border text-ink-gray-7 rounded-md p-3">
<div class="flex justify-between mb-3">
<span class="font-semibold leading-5">
<span class="font-semibold text-ink-gray-9 leading-5">
{{ evl.course_title }}
</span>
<Menu
@@ -42,7 +39,7 @@
leave-to-class="transform scale-95 opacity-0"
>
<MenuItems
class="absolute mt-2 w-32 rounded-md bg-white shadow-lg p-1.5"
class="absolute mt-2 w-32 rounded-md bg-surface-white border p-1.5"
>
<MenuItem v-slot="{ active }">
<Button
@@ -82,12 +79,11 @@
{{ evl.evaluator_name }}
</span>
</div>
<div class="flex items-center justify-between space-x-2 mt-4">
<Button
v-if="evl.google_meet_link"
@click="openEvalCall(evl)"
class="w-full"
>
<div
v-if="evl.google_meet_link"
class="flex items-center justify-between space-x-2 mt-4"
>
<Button @click="openEvalCall(evl)" class="w-full">
<template #prefix>
<HeadsetIcon class="w-4 h-4 stroke-1.5" />
</template>
@@ -119,8 +115,8 @@ import {
HeadsetIcon,
EllipsisVertical,
} from 'lucide-vue-next'
import { inject, ref, getCurrentInstance } from 'vue'
import { formatTime } from '../utils'
import { inject, ref, getCurrentInstance, computed } from 'vue'
import { formatTime } from '@/utils'
import { Button, createResource, call } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
@@ -164,6 +160,12 @@ const openEvalCall = (evl) => {
window.open(evl.google_meet_link, '_blank')
}
const evaluationCourses = computed(() => {
return props.courses.filter((course) => {
return course.evaluator != ''
})
})
const cancelEvaluation = (evl) => {
$dialog({
title: __('Cancel this evaluation?'),

View File

@@ -33,7 +33,7 @@
>
{{ branding.data?.app_name }}
</span>
<span v-else> Learning </span>
<span v-else> {{ __('Learning') }} </span>
</div>
<div
v-if="userResource.data"
@@ -72,7 +72,7 @@ import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref, onMounted, computed } from 'vue'
import { createDialog } from '@/utils/dialogs'
import SettingsModal from '@/components/Modals/Settings.vue'
import SettingsModal from '@/components/Settings/Settings.vue'
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
import {
ChevronDown,
@@ -130,7 +130,7 @@ const userDropdownOptions = computed(() => {
items: [
{
icon: User,
label: 'My Profile',
label: __('My Profile'),
onClick: () => {
router.push(`/user/${userResource.data?.username}`)
},
@@ -140,7 +140,7 @@ const userDropdownOptions = computed(() => {
},
{
icon: theme.value === 'light' ? Moon : Sun,
label: 'Toggle Theme',
label: __('Toggle Theme'),
onClick: () => {
toggleTheme()
},
@@ -158,7 +158,7 @@ const userDropdownOptions = computed(() => {
},
{
icon: Settings,
label: 'Settings',
label: __('Settings'),
onClick: () => {
settingsStore.isSettingsOpen = true
},
@@ -168,7 +168,7 @@ const userDropdownOptions = computed(() => {
},
{
icon: FrappeCloudIcon,
label: 'Login to Frappe Cloud',
label: __('Login to Frappe Cloud'),
onClick: () => {
$dialog({
title: __('Login to Frappe Cloud?'),
@@ -196,7 +196,7 @@ const userDropdownOptions = computed(() => {
},
{
icon: LogOut,
label: 'Log out',
label: __('Log out'),
onClick: () => {
logout.submit().then(() => {
isLoggedIn = false
@@ -208,7 +208,7 @@ const userDropdownOptions = computed(() => {
},
{
icon: LogIn,
label: 'Log in',
label: __('Log in'),
onClick: () => {
window.location.href = '/login'
},

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