Compare commits
286 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d55040e43 | ||
|
|
290f785a47 | ||
|
|
39ef187f6b | ||
|
|
a7a475e763 | ||
|
|
6eb380ea38 | ||
|
|
4d150cb323 | ||
|
|
09d6d99b14 | ||
|
|
5e7fd8baff | ||
|
|
52c159e2e8 | ||
|
|
67e8feb879 | ||
|
|
a5b61d5244 | ||
|
|
decc3a16ed | ||
|
|
7f39e9f0cc | ||
|
|
95afa1a6ad | ||
|
|
0d0bb5f9e2 | ||
|
|
3dd5ce5035 | ||
|
|
549e56d551 | ||
|
|
50b6215d1e | ||
|
|
ff69bfdce7 | ||
|
|
c04cc8ec0f | ||
|
|
f324de2254 | ||
|
|
40af4e6f34 | ||
|
|
5d9b66b5cb | ||
|
|
d2a8277c13 | ||
|
|
ada85fc0f3 | ||
|
|
505345eff7 | ||
|
|
2911ade880 | ||
|
|
8980dc8f9c | ||
|
|
d94a1c47c0 | ||
|
|
99c3e5182d | ||
|
|
70e39fee40 | ||
|
|
26d6bec8a0 | ||
|
|
c9ac1a1402 | ||
|
|
6949c1092c | ||
|
|
aae8a54481 | ||
|
|
e1d93bf670 | ||
|
|
fea0533cb1 | ||
|
|
5cd991f02a | ||
|
|
50a8a605d5 | ||
|
|
9ce7d8f5d6 | ||
|
|
eae2587e4c | ||
|
|
323097f201 | ||
|
|
014499888a | ||
|
|
5662de21ae | ||
|
|
17c2eba455 | ||
|
|
1f2c986e8f | ||
|
|
12040b5f6d | ||
|
|
20a985848f | ||
|
|
c06c6169e5 | ||
|
|
917aeb79ef | ||
|
|
c4f36a39fe | ||
|
|
befedc30ad | ||
|
|
d3bc67daa2 | ||
|
|
5d7e211367 | ||
|
|
fa9daa01ec | ||
|
|
0ed9dc63b8 | ||
|
|
5dd6b33eb2 | ||
|
|
1210b823c7 | ||
|
|
04240b4b3d | ||
|
|
787f592a1a | ||
|
|
e7363fbd40 | ||
|
|
e2762825e5 | ||
|
|
bbbca70c71 | ||
|
|
8dde423866 | ||
|
|
fc4c1c2b7e | ||
|
|
bf02e2de3f | ||
|
|
a26ba4dc6e | ||
|
|
f187cc9314 | ||
|
|
c15c6374f9 | ||
|
|
acec382dfe | ||
|
|
fbc078c6b6 | ||
|
|
170b20185a | ||
|
|
3e8489c13b | ||
|
|
18dfc4c23e | ||
|
|
e6bae3dc77 | ||
|
|
6f9f27c030 | ||
|
|
874bef74c7 | ||
|
|
ad483e0916 | ||
|
|
5b4bbaec20 | ||
|
|
b8ae0db0bd | ||
|
|
f2c18fad52 | ||
|
|
9716655b94 | ||
|
|
efb317191c | ||
|
|
a47b5db40c | ||
|
|
ec94796b9c | ||
|
|
e3e0cd61a2 | ||
|
|
a438473279 | ||
|
|
12b5b8b509 | ||
|
|
22442b47a8 | ||
|
|
30c8b7d64f | ||
|
|
b643575c4f | ||
|
|
7dd7124fac | ||
|
|
4b1eebf5bb | ||
|
|
3257943926 | ||
|
|
24246c83e0 | ||
|
|
a26787f478 | ||
|
|
ec3b88f890 | ||
|
|
7f5f1dad92 | ||
|
|
b6db128214 | ||
|
|
8831635db2 | ||
|
|
e19198b720 | ||
|
|
f618d9dc1a | ||
|
|
66a667a0a3 | ||
|
|
8a4c67f712 | ||
|
|
fa6ef2e989 | ||
|
|
7450b99197 | ||
|
|
023fd272b1 | ||
|
|
84067cb027 | ||
|
|
3087ef70e7 | ||
|
|
387385bb1c | ||
|
|
6766d0d08c | ||
|
|
371d890793 | ||
|
|
57046c1b38 | ||
|
|
2a64144e94 | ||
|
|
9b0320ccf1 | ||
|
|
23f209131e | ||
|
|
d71f1c7f9a | ||
|
|
d21ea2c854 | ||
|
|
cd7f3ba820 | ||
|
|
e057d3ed9a | ||
|
|
5f04607a44 | ||
|
|
9440d13a08 | ||
|
|
85c4f1654e | ||
|
|
eed339cc64 | ||
|
|
3d1a23576a | ||
|
|
ed0e2e4bb5 | ||
|
|
954d0a0637 | ||
|
|
f2c8788602 | ||
|
|
49c63da27c | ||
|
|
24496d1856 | ||
|
|
991ebe09a2 | ||
|
|
85da4f6d85 | ||
|
|
5f065db991 | ||
|
|
ffb40586d7 | ||
|
|
fcfd87fd50 | ||
|
|
eb5b12aa7b | ||
|
|
f6e2438744 | ||
|
|
e3c7dc695d | ||
|
|
82d2025e6c | ||
|
|
91b82d78b8 | ||
|
|
b97e792893 | ||
|
|
13ac5ec7dc | ||
|
|
199f880936 | ||
|
|
ed86c207ba | ||
|
|
b4cf290f4d | ||
|
|
e526a6fd64 | ||
|
|
94cbbf169a | ||
|
|
2837ed16a7 | ||
|
|
68961deb6b | ||
|
|
ec54bfee98 | ||
|
|
385e97b76a | ||
|
|
cbd916877f | ||
|
|
38586034cd | ||
|
|
62b3ba2bff | ||
|
|
dd470b61b5 | ||
|
|
4fa92d2327 | ||
|
|
6f6c2db66d | ||
|
|
e6348cfa20 | ||
|
|
a006d1000a | ||
|
|
4a575e642f | ||
|
|
93525bc577 | ||
|
|
2cf0e9a723 | ||
|
|
c32164bfea | ||
|
|
714b0924e7 | ||
|
|
43079790a8 | ||
|
|
d03e61b625 | ||
|
|
2d760112a3 | ||
|
|
f46507ec72 | ||
|
|
e9e10bdc93 | ||
|
|
0386967a32 | ||
|
|
4900fc8b88 | ||
|
|
99294b5643 | ||
|
|
eb12bcb83c | ||
|
|
22a2e57642 | ||
|
|
5eaae06ceb | ||
|
|
ce7fc35349 | ||
|
|
8d4b5c83ae | ||
|
|
cbd3c56ca0 | ||
|
|
be6dad1424 | ||
|
|
298452fa7b | ||
|
|
4abbd7c35c | ||
|
|
c2f51c51ab | ||
|
|
255cff6664 | ||
|
|
8a9578bb0a | ||
|
|
8831f6cecc | ||
|
|
f3daa7e48b | ||
|
|
6163597958 | ||
|
|
f9e1222065 | ||
|
|
7d85de7c6c | ||
|
|
cf452c2300 | ||
|
|
72bd1d548d | ||
|
|
4556f4dee6 | ||
|
|
3dfbd3165a | ||
|
|
02b8e02131 | ||
|
|
087ded9f9e | ||
|
|
21f122ee82 | ||
|
|
d60a7e8c94 | ||
|
|
b8981c249f | ||
|
|
e71275a0dc | ||
|
|
4fb0db7a1e | ||
|
|
1e9beedc77 | ||
|
|
4a4a0653ef | ||
|
|
c80a900277 | ||
|
|
6fb0394d96 | ||
|
|
a6a7712039 | ||
|
|
dd0687ba29 | ||
|
|
9cb87a5333 | ||
|
|
8ec93d84a0 | ||
|
|
1d38715db9 | ||
|
|
6225c4eb35 | ||
|
|
e58ce2fbe6 | ||
|
|
8881d62e78 | ||
|
|
effb2a1265 | ||
|
|
ab387473b5 | ||
|
|
3cf6079b70 | ||
|
|
53c655bb53 | ||
|
|
87952463c2 | ||
|
|
3a8a63a49a | ||
|
|
debe115044 | ||
|
|
554d2808fd | ||
|
|
12b2c89a25 | ||
|
|
a66fc3a07e | ||
|
|
7b3705cab0 | ||
|
|
8e99e5f5e8 | ||
|
|
c5ba5370bb | ||
|
|
464dec9810 | ||
|
|
c2e2ec8803 | ||
|
|
37378e2360 | ||
|
|
678385d90c | ||
|
|
4c461f087f | ||
|
|
88a2b69980 | ||
|
|
1f57792da7 | ||
|
|
9bb4c45a23 | ||
|
|
75fd19f491 | ||
|
|
0ac16bdeb7 | ||
|
|
223ee41e10 | ||
|
|
c126ded82e | ||
|
|
0edf78b7fd | ||
|
|
5af3580987 | ||
|
|
343cb6f97a | ||
|
|
023c8ac13e | ||
|
|
c385eed795 | ||
|
|
ee5fdd789f | ||
|
|
df1e400f4e | ||
|
|
6c9c298478 | ||
|
|
7106ee150d | ||
|
|
03e2287f80 | ||
|
|
2edcd41e24 | ||
|
|
0fe043bd99 | ||
|
|
6686f5240d | ||
|
|
2936facf0f | ||
|
|
cc208f2c43 | ||
|
|
9a0fc231e5 | ||
|
|
bfc0ae62ec | ||
|
|
5e7d8d97f2 | ||
|
|
70ceb16ed6 | ||
|
|
f162fa639f | ||
|
|
f000c72546 | ||
|
|
32c01f931c | ||
|
|
d0121e2b9d | ||
|
|
1caab8ce1d | ||
|
|
878be435a1 | ||
|
|
6a68ae989e | ||
|
|
00993da781 | ||
|
|
e9ef67e402 | ||
|
|
83ebfececf | ||
|
|
ec8bf6251f | ||
|
|
1b2874b3a5 | ||
|
|
0ac1053a71 | ||
|
|
224d270952 | ||
|
|
c6137545cd | ||
|
|
335417f9f4 | ||
|
|
cb797223ed | ||
|
|
3a2a0313ac | ||
|
|
e221a5a73a | ||
|
|
2b7aaf095f | ||
|
|
6f01e7b8d8 | ||
|
|
d594419200 | ||
|
|
bf50e3f898 | ||
|
|
d434f1781f | ||
|
|
3f311a45ef | ||
|
|
9293b7796e | ||
|
|
b1e7883526 | ||
|
|
212800155b | ||
|
|
c241bf2104 | ||
|
|
bda61f32f3 |
2
.github/helper/install_dependencies.sh
vendored
2
.github/helper/install_dependencies.sh
vendored
@@ -5,7 +5,7 @@ echo "Setting Up System Dependencies..."
|
|||||||
|
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt remove mysql-server mysql-client
|
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() {
|
install_wkhtmltopdf() {
|
||||||
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
|
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
|
||||||
|
|||||||
@@ -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.
|
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
|
## Development Setup
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ describe("Batch Creation", () => {
|
|||||||
const randomEmail = `testuser_${dateNow}@example.com`;
|
const randomEmail = `testuser_${dateNow}@example.com`;
|
||||||
const randomName = `Test User ${dateNow}`;
|
const randomName = `Test User ${dateNow}`;
|
||||||
|
|
||||||
cy.get("input[placeholder='Email']").type(randomEmail);
|
cy.get("input[placeholder='jane@doe.com']").type(randomEmail);
|
||||||
cy.get("input[placeholder='First Name']").type(randomName);
|
cy.get("input[placeholder='Jane']").type(randomName);
|
||||||
cy.get("button").contains("Add").click();
|
cy.get("button").contains("Add").click();
|
||||||
|
|
||||||
// Add evaluator
|
// Add evaluator
|
||||||
@@ -39,7 +39,7 @@ describe("Batch Creation", () => {
|
|||||||
.click();
|
.click();
|
||||||
const randomEvaluator = `evaluator${dateNow}@example.com`;
|
const randomEvaluator = `evaluator${dateNow}@example.com`;
|
||||||
|
|
||||||
cy.get("input[placeholder='Email']").type(randomEvaluator);
|
cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
|
||||||
cy.get("button").contains("Add").click();
|
cy.get("button").contains("Add").click();
|
||||||
cy.get("div").contains(randomEvaluator).should("be.visible").click();
|
cy.get("div").contains(randomEvaluator).should("be.visible").click();
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ describe("Batch Creation", () => {
|
|||||||
cy.closeOnboardingModal();
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
// Create a batch
|
// Create a batch
|
||||||
cy.get("button").contains("New").click();
|
cy.get("button").contains("Create").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.url().should("include", "/batches/new/edit");
|
cy.url().should("include", "/batches/new/edit");
|
||||||
cy.get("label").contains("Title").type("Test Batch");
|
cy.get("label").contains("Title").type("Test Batch");
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe("Course Creation", () => {
|
|||||||
cy.closeOnboardingModal();
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("button").contains("New").click();
|
cy.get("button").contains("Create").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.url().should("include", "/courses/new/edit");
|
cy.url().should("include", "/courses/new/edit");
|
||||||
|
|
||||||
|
|||||||
Submodule frappe-ui updated: fd5252663b...80d3a010ac
10
frontend/components.d.ts
vendored
10
frontend/components.d.ts
vendored
@@ -19,6 +19,10 @@ declare module 'vue' {
|
|||||||
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
||||||
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
||||||
Autocomplete: typeof import('./src/components/Controls/Autocomplete.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']
|
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
|
||||||
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
|
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
|
||||||
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
|
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
|
||||||
@@ -32,12 +36,15 @@ declare module 'vue' {
|
|||||||
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
|
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
|
||||||
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
|
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
|
||||||
ChapterModal: typeof import('./src/components/Modals/ChapterModal.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']
|
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
||||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||||
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
||||||
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
||||||
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
||||||
CourseOutline: typeof import('./src/components/CourseOutline.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']
|
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
|
||||||
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
|
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
|
||||||
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
|
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
|
||||||
@@ -83,6 +90,7 @@ declare module 'vue' {
|
|||||||
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
|
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
|
||||||
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
|
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
|
||||||
Rating: typeof import('./src/components/Controls/Rating.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']
|
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
@@ -95,10 +103,12 @@ declare module 'vue' {
|
|||||||
Tags: typeof import('./src/components/Tags.vue')['default']
|
Tags: typeof import('./src/components/Tags.vue')['default']
|
||||||
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
|
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
|
||||||
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.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']
|
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
|
||||||
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
||||||
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
||||||
VideoBlock: typeof import('./src/components/VideoBlock.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']
|
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
|
||||||
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
|
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/checklist": "^1.6.0",
|
||||||
"@editorjs/code": "^2.9.0",
|
"@editorjs/code": "^2.9.0",
|
||||||
"@editorjs/editorjs": "^2.29.0",
|
"@editorjs/editorjs": "^2.29.0",
|
||||||
@@ -24,10 +28,10 @@
|
|||||||
"ace-builds": "^1.36.2",
|
"ace-builds": "^1.36.2",
|
||||||
"apexcharts": "^4.3.0",
|
"apexcharts": "^4.3.0",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"codemirror-editor-vue3": "^2.8.0",
|
"codemirror": "^6.0.1",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.147",
|
"frappe-ui": "^0.1.172",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-vue-next": "^0.383.0",
|
"lucide-vue-next": "^0.383.0",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
@@ -35,9 +39,11 @@
|
|||||||
"plyr": "^3.7.8",
|
"plyr": "^3.7.8",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"tailwindcss": "3.4.15",
|
"tailwindcss": "3.4.15",
|
||||||
|
"thememirror": "^2.0.1",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vue": "^3.4.23",
|
"vue": "^3.4.23",
|
||||||
"vue-chartjs": "^5.3.0",
|
"vue-chartjs": "^5.3.0",
|
||||||
|
"vue-codemirror": "^6.1.1",
|
||||||
"vue-draggable-next": "^2.2.1",
|
"vue-draggable-next": "^2.2.1",
|
||||||
"vue-router": "^4.0.12",
|
"vue-router": "^4.0.12",
|
||||||
"vue3-apexcharts": "^1.8.0",
|
"vue3-apexcharts": "^1.8.0",
|
||||||
|
|||||||
BIN
frontend/public/Remove.mp4
Normal file
BIN
frontend/public/Remove.mp4
Normal file
Binary file not shown.
@@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<FrappeUIProvider>
|
<FrappeUIProvider>
|
||||||
<Layout>
|
<Layout>
|
||||||
|
<div class="text-base">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
</FrappeUIProvider>
|
</FrappeUIProvider>
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ import {
|
|||||||
h,
|
h,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '@/utils'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { useSidebar } from '@/stores/sidebar'
|
import { useSidebar } from '@/stores/sidebar'
|
||||||
@@ -225,7 +225,7 @@ import {
|
|||||||
IntermediateStepModal,
|
IntermediateStepModal,
|
||||||
} from 'frappe-ui/frappe'
|
} from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const { user, sidebarSettings } = sessionStore()
|
const { user } = sessionStore()
|
||||||
const { userResource } = usersStore()
|
const { userResource } = usersStore()
|
||||||
let sidebarStore = useSidebar()
|
let sidebarStore = useSidebar()
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
@@ -236,6 +236,7 @@ const isModerator = ref(false)
|
|||||||
const isInstructor = ref(false)
|
const isInstructor = ref(false)
|
||||||
const pageToEdit = ref(null)
|
const pageToEdit = ref(null)
|
||||||
const settingsStore = useSettings()
|
const settingsStore = useSettings()
|
||||||
|
const { sidebarSettings } = settingsStore
|
||||||
const showOnboarding = ref(false)
|
const showOnboarding = ref(false)
|
||||||
const showIntermediateModal = ref(false)
|
const showIntermediateModal = ref(false)
|
||||||
const currentStep = ref({})
|
const currentStep = ref({})
|
||||||
@@ -313,7 +314,7 @@ const addNotifications = () => {
|
|||||||
|
|
||||||
const addQuizzes = () => {
|
const addQuizzes = () => {
|
||||||
if (isInstructor.value || isModerator.value) {
|
if (isInstructor.value || isModerator.value) {
|
||||||
sidebarLinks.value.push({
|
sidebarLinks.value.splice(4, 0, {
|
||||||
label: 'Quizzes',
|
label: 'Quizzes',
|
||||||
icon: 'CircleHelp',
|
icon: 'CircleHelp',
|
||||||
to: 'Quizzes',
|
to: 'Quizzes',
|
||||||
@@ -329,7 +330,7 @@ const addQuizzes = () => {
|
|||||||
|
|
||||||
const addAssignments = () => {
|
const addAssignments = () => {
|
||||||
if (isInstructor.value || isModerator.value) {
|
if (isInstructor.value || isModerator.value) {
|
||||||
sidebarLinks.value.push({
|
sidebarLinks.value.splice(5, 0, {
|
||||||
label: 'Assignments',
|
label: 'Assignments',
|
||||||
icon: 'Pencil',
|
icon: 'Pencil',
|
||||||
to: 'Assignments',
|
to: 'Assignments',
|
||||||
|
|||||||
@@ -2,17 +2,24 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
|
title:
|
||||||
|
type == 'quiz'
|
||||||
|
? __('Add a quiz to your lesson')
|
||||||
|
: __('Add an assignment to your lesson'),
|
||||||
size: 'xl',
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: () => {
|
||||||
|
addAssessment()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body-content>
|
||||||
<div class="p-5 space-y-4">
|
<div class="">
|
||||||
<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>
|
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
v-if="type == 'quiz'"
|
v-if="type == 'quiz'"
|
||||||
@@ -29,17 +36,12 @@
|
|||||||
:onCreate="(value, close) => redirectToForm()"
|
:onCreate="(value, close) => redirectToForm()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-2">
|
|
||||||
<Button variant="solid" @click="addAssessment()">
|
|
||||||
{{ __('Save') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, Button } from 'frappe-ui'
|
import { Dialog } from 'frappe-ui'
|
||||||
import { onMounted, ref, nextTick } from 'vue'
|
import { onMounted, ref, nextTick } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<template #default="{ column, item }">
|
<template #default="{ column, item }">
|
||||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
<div v-if="column.key == 'assessment_type'">
|
<div v-if="column.key == 'assessment_type'">
|
||||||
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
|
{{ getAssessmentTypeLabel(row[column.key]) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="column.key == 'title'">
|
<div v-else-if="column.key == 'title'">
|
||||||
{{ row[column.key] }}
|
{{ 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 {
|
} else {
|
||||||
return {
|
return {
|
||||||
name: 'QuizPage',
|
name: 'QuizPage',
|
||||||
@@ -213,7 +231,7 @@ const getAssessmentColumns = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getStatusTheme = (status) => {
|
const getStatusTheme = (status) => {
|
||||||
if (status === 'Pass') {
|
if (status === 'Pass' || status === 'Passed') {
|
||||||
return 'green'
|
return 'green'
|
||||||
} else if (status === 'Not Graded') {
|
} else if (status === 'Not Graded') {
|
||||||
return 'orange'
|
return 'orange'
|
||||||
@@ -221,4 +239,14 @@ const getStatusTheme = (status) => {
|
|||||||
return 'red'
|
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>
|
</script>
|
||||||
|
|||||||
@@ -70,9 +70,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Badge } from 'frappe-ui'
|
import { formatTime } from '@/utils'
|
||||||
import { formatTime } from '../utils'
|
import { Clock, Globe } from 'lucide-vue-next'
|
||||||
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
|
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
|||||||
@@ -6,13 +6,12 @@
|
|||||||
:courses="batch.data.courses"
|
:courses="batch.data.courses"
|
||||||
/>
|
/>
|
||||||
<Assessments :batch="batch.data.name" />
|
<Assessments :batch="batch.data.name" />
|
||||||
<StudentHeatmap />
|
<!-- <StudentHeatmap /> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||||
import Assessments from '@/components/Assessments.vue'
|
import Assessments from '@/components/Assessments.vue'
|
||||||
import StudentHeatmap from '@/components/StudentHeatmap.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
|
|||||||
@@ -65,6 +65,10 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="solid" class="w-full mt-4">
|
<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>
|
<span>
|
||||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -85,6 +89,9 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
||||||
|
<template #prefix>
|
||||||
|
<CreditCard class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Register Now') }}
|
{{ __('Register Now') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -100,6 +107,9 @@
|
|||||||
"
|
"
|
||||||
@click="enrollInBatch()"
|
@click="enrollInBatch()"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<GraduationCap class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
{{ __('Enroll Now') }}
|
{{ __('Enroll Now') }}
|
||||||
</Button>
|
</Button>
|
||||||
<router-link
|
<router-link
|
||||||
@@ -112,6 +122,9 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button class="w-full mt-2">
|
<Button class="w-full mt-2">
|
||||||
|
<template #prefix>
|
||||||
|
<Pencil class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Edit') }}
|
{{ __('Edit') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -122,8 +135,17 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { inject, computed } from 'vue'
|
import { inject, computed } from 'vue'
|
||||||
import { Badge, Button, createResource, toast } from 'frappe-ui'
|
import { Button, createResource, toast } from 'frappe-ui'
|
||||||
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Clock,
|
||||||
|
CreditCard,
|
||||||
|
Globe,
|
||||||
|
GraduationCap,
|
||||||
|
LogIn,
|
||||||
|
Pencil,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
|
<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">
|
<Popover class="w-full" v-model:show="showOptions">
|
||||||
<template #target="{ open: openPopover, togglePopover }">
|
<template #target="{ open: openPopover, togglePopover }">
|
||||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||||
@@ -8,6 +17,7 @@
|
|||||||
class="flex w-full items-center justify-between focus:outline-none"
|
class="flex w-full items-center justify-between focus:outline-none"
|
||||||
:class="inputClasses"
|
:class="inputClasses"
|
||||||
@click="() => togglePopover()"
|
@click="() => togglePopover()"
|
||||||
|
:disabled="attrs.readonly"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<slot name="prefix" />
|
<slot name="prefix" />
|
||||||
@@ -28,7 +38,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #body="{ isOpen }">
|
<template #body="{ isOpen }">
|
||||||
<div v-show="isOpen">
|
<div v-show="isOpen">
|
||||||
<div class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2">
|
<div
|
||||||
|
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
||||||
|
>
|
||||||
<div class="relative px-1.5 pt-0.5">
|
<div class="relative px-1.5 pt-0.5">
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="search"
|
ref="search"
|
||||||
@@ -122,6 +134,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -148,6 +161,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'md',
|
default: 'md',
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
variant: {
|
variant: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'subtle',
|
default: 'subtle',
|
||||||
|
|||||||
149
frontend/src/components/Controls/ChildTable.vue
Normal file
149
frontend/src/components/Controls/ChildTable.vue
Normal 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>
|
||||||
162
frontend/src/components/Controls/Code.vue
Normal file
162
frontend/src/components/Controls/Code.vue
Normal 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>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
height: height,
|
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 }}
|
{{ label }}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
:variant="attrs.variant"
|
:variant="attrs.variant"
|
||||||
:placeholder="attrs.placeholder"
|
:placeholder="attrs.placeholder"
|
||||||
:filterable="false"
|
:filterable="false"
|
||||||
|
:readonly="attrs.readonly"
|
||||||
>
|
>
|
||||||
<template #target="{ open, togglePopover }">
|
<template #target="{ open, togglePopover }">
|
||||||
<slot name="target" v-bind="{ open, togglePopover }" />
|
<slot name="target" v-bind="{ open, togglePopover }" />
|
||||||
|
|||||||
76
frontend/src/components/Controls/Uploader.vue
Normal file
76
frontend/src/components/Controls/Uploader.vue
Normal 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="border-2 rounded-md min-w-80">
|
<div class="border-2 rounded-md min-w-80 max-w-sm">
|
||||||
<iframe
|
<iframe
|
||||||
v-if="course.data.video_link"
|
v-if="course.data.video_link"
|
||||||
:src="video_link"
|
:src="video_link"
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="solid" size="md" class="w-full">
|
<Button variant="solid" size="md" class="w-full">
|
||||||
|
<template #prefix>
|
||||||
|
<BookText class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Continue Learning') }}
|
{{ __('Continue Learning') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -44,6 +47,9 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="solid" size="md" class="w-full">
|
<Button variant="solid" size="md" class="w-full">
|
||||||
|
<template #prefix>
|
||||||
|
<CreditCard class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Buy this course') }}
|
{{ __('Buy this course') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -57,12 +63,15 @@
|
|||||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
v-else
|
v-else-if="!user.data?.is_moderator && !is_instructor()"
|
||||||
@click="enrollStudent()"
|
@click="enrollStudent()"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<BookText class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Start Learning') }}
|
{{ __('Start Learning') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -74,8 +83,22 @@
|
|||||||
class="w-full mt-2"
|
class="w-full mt-2"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<GraduationCap class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
{{ __('Get Certificate') }}
|
{{ __('Get Certificate') }}
|
||||||
</Button>
|
</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
|
<router-link
|
||||||
v-if="user?.data?.is_moderator || is_instructor()"
|
v-if="user?.data?.is_moderator || is_instructor()"
|
||||||
:to="{
|
:to="{
|
||||||
@@ -86,6 +109,9 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="subtle" class="w-full mt-2" size="md">
|
<Button variant="subtle" class="w-full mt-2" size="md">
|
||||||
|
<template #prefix>
|
||||||
|
<Pencil class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Edit') }}
|
{{ __('Edit') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -142,18 +168,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<CourseProgressSummary
|
||||||
|
v-model="showProgressModal"
|
||||||
|
:courseName="course.data.name"
|
||||||
|
:enrollments="course.data.enrollments"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
|
import {
|
||||||
import { computed, inject } from 'vue'
|
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 { Badge, Button, call, createResource, toast } from 'frappe-ui'
|
||||||
import { formatAmount } from '@/utils/'
|
import { formatAmount } from '@/utils/'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||||
|
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const showProgressModal = ref(false)
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -246,4 +288,8 @@ const fetchCertificate = () => {
|
|||||||
member: user.data?.name,
|
member: user.data?.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showProgressSummary = () => {
|
||||||
|
showProgressModal.value = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,13 +23,24 @@
|
|||||||
'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length,
|
'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<Draggable
|
||||||
|
:list="outline.data"
|
||||||
|
:disabled="!allowEdit"
|
||||||
|
item-key="name"
|
||||||
|
group="chapters"
|
||||||
|
@end="updateChapterOrder"
|
||||||
|
>
|
||||||
|
<template #item="{ element: chapter, index }">
|
||||||
|
<div class="chapter-item">
|
||||||
<Disclosure
|
<Disclosure
|
||||||
v-slot="{ open }"
|
v-slot="{ open }"
|
||||||
v-for="(chapter, index) in outline.data"
|
|
||||||
:key="chapter.name"
|
:key="chapter.name"
|
||||||
:defaultOpen="openChapterDetail(chapter.idx)"
|
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||||
>
|
>
|
||||||
<DisclosureButton ref="" class="flex items-center w-full p-2 group">
|
<DisclosureButton
|
||||||
|
ref=""
|
||||||
|
class="flex items-center w-full p-2 group"
|
||||||
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
:class="{
|
:class="{
|
||||||
'rotate-90 transform duration-200': open,
|
'rotate-90 transform duration-200': open,
|
||||||
@@ -105,7 +116,9 @@
|
|||||||
{{ lesson.title }}
|
{{ lesson.title }}
|
||||||
<Trash2
|
<Trash2
|
||||||
v-if="allowEdit"
|
v-if="allowEdit"
|
||||||
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
@click.prevent="
|
||||||
|
trashLesson(lesson.name, chapter.name)
|
||||||
|
"
|
||||||
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
|
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
|
||||||
/>
|
/>
|
||||||
<Check
|
<Check
|
||||||
@@ -137,6 +150,9 @@
|
|||||||
</DisclosurePanel>
|
</DisclosurePanel>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChapterModal
|
<ChapterModal
|
||||||
v-if="user.data"
|
v-if="user.data"
|
||||||
@@ -148,7 +164,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, createResource, Tooltip, toast } from 'frappe-ui'
|
import { Button, createResource, Tooltip, toast } from 'frappe-ui'
|
||||||
import { getCurrentInstance, inject, ref } from 'vue'
|
import { getCurrentInstance, inject, ref, watch } from 'vue'
|
||||||
import Draggable from 'vuedraggable'
|
import Draggable from 'vuedraggable'
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
import {
|
import {
|
||||||
@@ -197,13 +213,22 @@ const props = defineProps({
|
|||||||
const outline = createResource({
|
const outline = createResource({
|
||||||
url: 'lms.lms.utils.get_course_outline',
|
url: 'lms.lms.utils.get_course_outline',
|
||||||
cache: ['course_outline', props.courseName],
|
cache: ['course_outline', props.courseName],
|
||||||
params: {
|
makeParams() {
|
||||||
|
return {
|
||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
progress: props.getProgress,
|
progress: props.getProgress,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.courseName,
|
||||||
|
() => {
|
||||||
|
outline.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const deleteLesson = createResource({
|
const deleteLesson = createResource({
|
||||||
url: 'lms.lms.api.delete_lesson',
|
url: 'lms.lms.api.delete_lesson',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -233,6 +258,20 @@ const updateLessonIndex = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const trashLesson = (lessonName, chapterName) => {
|
const trashLesson = (lessonName, chapterName) => {
|
||||||
$dialog({
|
$dialog({
|
||||||
title: __('Delete this lesson?'),
|
title: __('Delete this lesson?'),
|
||||||
@@ -278,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({
|
const deleteChapter = createResource({
|
||||||
url: 'lms.lms.api.delete_chapter',
|
url: 'lms.lms.api.delete_chapter',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Star } from 'lucide-vue-next'
|
import { Star } from 'lucide-vue-next'
|
||||||
import { createResource, Button } from 'frappe-ui'
|
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 UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import ReviewModal from '@/components/Modals/ReviewModal.vue'
|
import ReviewModal from '@/components/Modals/ReviewModal.vue'
|
||||||
|
|
||||||
@@ -101,12 +101,21 @@ const hasReviewed = createResource({
|
|||||||
const reviews = createResource({
|
const reviews = createResource({
|
||||||
url: 'lms.lms.utils.get_reviews',
|
url: 'lms.lms.utils.get_reviews',
|
||||||
cache: ['course_reviews', props.courseName],
|
cache: ['course_reviews', props.courseName],
|
||||||
params: {
|
makeParams() {
|
||||||
|
return {
|
||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.courseName,
|
||||||
|
() => {
|
||||||
|
reviews.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const showReviewModal = ref(false)
|
const showReviewModal = ref(false)
|
||||||
|
|
||||||
function openReviewModal() {
|
function openReviewModal() {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '@/utils'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||||
import { ref, inject, onMounted, onUnmounted } from 'vue'
|
import { ref, inject, onMounted, onUnmounted } from 'vue'
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { singularize, timeAgo } from '../utils'
|
import { singularize, timeAgo } from '@/utils'
|
||||||
import { ref, onMounted, inject, onUnmounted } from 'vue'
|
import { ref, onMounted, inject, onUnmounted } from 'vue'
|
||||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||||
|
|||||||
@@ -15,60 +15,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2" v-for="(item, key) in contentMap" :key="key">
|
||||||
<div
|
<div
|
||||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||||
@click="openHelpDialog('quiz')"
|
@click="openHelpDialog(key)"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{{ __('How to add a Quiz?') }}
|
{{ __(item.title) }}
|
||||||
</span>
|
</span>
|
||||||
<Info class="w-3 h-3 text-ink-gray-7" />
|
<Info class="w-3 h-3 text-ink-gray-7" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
||||||
{{
|
{{ __(item.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.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</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.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,14 +41,31 @@ const showExplanation = ref(false)
|
|||||||
const type = ref(null)
|
const type = ref(null)
|
||||||
const title = ref(null)
|
const title = ref(null)
|
||||||
const contentMap = {
|
const contentMap = {
|
||||||
quiz: 'How to add a Quiz?',
|
quiz: {
|
||||||
upload: 'How to upload content from your system?',
|
title: 'How to add a Quiz?',
|
||||||
youtube: 'How to add a YouTube Video?',
|
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) => {
|
const openHelpDialog = (contentType) => {
|
||||||
type.value = contentType
|
type.value = contentType
|
||||||
title.value = contentMap[contentType]
|
title.value = contentMap[contentType].title
|
||||||
showExplanation.value = true
|
showExplanation.value = true
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -54,15 +54,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { watch, ref, onMounted } from 'vue'
|
import { watch, ref, onMounted } from 'vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import * as icons from 'lucide-vue-next'
|
import * as icons from 'lucide-vue-next'
|
||||||
|
|
||||||
const { logout, user, sidebarSettings } = sessionStore()
|
const { logout, user } = sessionStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
const { sidebarSettings } = useSettings()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
let { userResource } = usersStore()
|
let { userResource } = usersStore()
|
||||||
const sidebarLinks = ref(getSidebarLinks())
|
const sidebarLinks = ref(getSidebarLinks())
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const assessmentTypes = computed(() => {
|
|||||||
return [
|
return [
|
||||||
{ label: 'Quiz', value: 'LMS Quiz' },
|
{ label: 'Quiz', value: 'LMS Quiz' },
|
||||||
{ label: 'Assignment', value: 'LMS Assignment' },
|
{ label: 'Assignment', value: 'LMS Assignment' },
|
||||||
|
{ label: 'Programming Exercise', value: 'LMS Programming Exercise' },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
230
frontend/src/components/Modals/CourseProgressSummary.vue
Normal file
230
frontend/src/components/Modals/CourseProgressSummary.vue
Normal 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 Name')"
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
type Filters = {
|
||||||
|
course: string | undefined
|
||||||
|
member_name?: string[]
|
||||||
|
}
|
||||||
|
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>
|
||||||
@@ -255,6 +255,9 @@ const saveEvaluation = () => {
|
|||||||
}
|
}
|
||||||
toast.success(__('Evaluation saved successfully'))
|
toast.success(__('Evaluation saved successfully'))
|
||||||
},
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -277,6 +280,9 @@ const certificateResource = createResource({
|
|||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
certificate.name = data
|
certificate.name = data
|
||||||
},
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const certificateDetails = createResource({
|
const certificateDetails = createResource({
|
||||||
@@ -310,6 +316,9 @@ const saveCertificate = () => {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(__('Certificate saved successfully'))
|
toast.success(__('Certificate saved successfully'))
|
||||||
},
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,6 @@ const file = computed(() => {
|
|||||||
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
|
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
|
||||||
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
|
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
|
||||||
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
|
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
|
||||||
|
if (props.type == 'remove') return '/assets/lms/frontend/Remove.mp4'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,17 +3,34 @@
|
|||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
title: __('Attendance for Class - {0}').format(live_class?.title),
|
title: __('Attendance for Class - {0}').format(live_class?.title),
|
||||||
size: 'xl',
|
size: '4xl',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="space-y-5">
|
<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
|
<div
|
||||||
v-for="participant in participants.data"
|
v-for="participant in participants.data"
|
||||||
@click="redirectToProfile(participant.member_username)"
|
@click="redirectToProfile(participant.member_username)"
|
||||||
class="cursor-pointer text-base w-fit"
|
class="grid grid-cols-2 items-center w-full text-base w-fit py-2"
|
||||||
>
|
>
|
||||||
<Tooltip placement="right">
|
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Avatar
|
<Avatar
|
||||||
:image="participant.member_image"
|
:image="participant.member_image"
|
||||||
@@ -29,18 +46,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #body>
|
|
||||||
<div
|
<div class="grid grid-cols-3 gap-20 text-right">
|
||||||
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-p-xs text-ink-white leading-5 shadow-xl"
|
<div>
|
||||||
>
|
{{ dayjs(participant.joined_at).format('HH:mm a') }}
|
||||||
{{ dayjs(participant.joined_at).format('HH:mm a') }} -
|
</div>
|
||||||
{{ dayjs(participant.left_at).format('HH:mm a') }}
|
<div>
|
||||||
<br />
|
{{ dayjs(participant.left_at).format('HH:mm a') }}
|
||||||
{{ __('attended for') }} {{ participant.duration }}
|
</div>
|
||||||
{{ __('minutes') }}
|
<div>{{ participant.duration }} {{ __('minutes') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
size: '3xl',
|
size: '5xl',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
class="!p-0"
|
class="!p-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!chooseFromExisting || editMode" class="space-y-2">
|
<div v-if="!chooseFromExisting || editMode">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-ink-gray-5 mb-1">
|
<label class="block text-xs text-ink-gray-5 mb-1">
|
||||||
{{ __('Question') }}
|
{{ __('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]"
|
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>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-8 mt-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="question.marks"
|
v-model="question.marks"
|
||||||
:label="__('Marks')"
|
:label="__('Marks')"
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="question.type == 'Choices'"
|
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') }}
|
{{ __('Options') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +61,10 @@
|
|||||||
>
|
>
|
||||||
{{ __('Possibilities') }}
|
{{ __('Possibilities') }}
|
||||||
</div>
|
</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">
|
<div v-for="n in 4" class="space-y-4 py-2">
|
||||||
<FormControl
|
<FormControl
|
||||||
:label="__('Option') + ' ' + n"
|
:label="__('Option') + ' ' + n"
|
||||||
@@ -81,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="question.type == 'User Input'"
|
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">
|
<div v-for="n in 4">
|
||||||
<FormControl
|
<FormControl
|
||||||
@@ -106,7 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-end space-x-2 mt-5">
|
<div class="flex items-center justify-end space-x-2 mt-5">
|
||||||
<Button variant="solid" @click="submitQuestion()">
|
<Button variant="solid" @click="submitQuestion()">
|
||||||
{{ __('Submit') }}
|
{{ __('Save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +220,7 @@ const questionRow = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Quiz Question',
|
doctype: 'LMS Quiz Question',
|
||||||
parent: quiz.value.data.name,
|
parent: quiz.value.doc.name,
|
||||||
parentfield: 'questions',
|
parentfield: 'questions',
|
||||||
parenttype: 'LMS Quiz',
|
parenttype: 'LMS Quiz',
|
||||||
...values,
|
...values,
|
||||||
|
|||||||
216
frontend/src/components/Modals/VideoStatistics.vue
Normal file
216
frontend/src/components/Modals/VideoStatistics.vue
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: '4xl',
|
||||||
|
title: __('Video Statistics for {0}').format(lessonTitle),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base">
|
||||||
|
<TabButtons
|
||||||
|
v-if="tabs.length > 1"
|
||||||
|
:buttons="tabs"
|
||||||
|
v-model="currentTab"
|
||||||
|
class="w-fit"
|
||||||
|
/>
|
||||||
|
<div v-if="currentTab" class="mt-8">
|
||||||
|
<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">
|
||||||
|
{{ parseFloat(row.watch_time).toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<NumberChart
|
||||||
|
class="border rounded-md"
|
||||||
|
:config="{
|
||||||
|
title: __('Average Watch Time (seconds)'),
|
||||||
|
value: averageWatchTime,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<div v-if="isPlyrSource">
|
||||||
|
<div class="video-player" :src="currentTab"></div>
|
||||||
|
</div>
|
||||||
|
<VideoBlock v-else :file="currentTab" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
createListResource,
|
||||||
|
Dialog,
|
||||||
|
NumberChart,
|
||||||
|
TabButtons,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { enablePlyr } from '@/utils'
|
||||||
|
import VideoBlock from '@/components/VideoBlock.vue'
|
||||||
|
|
||||||
|
const show = defineModel<boolean | undefined>()
|
||||||
|
const currentTab = ref<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(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 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>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
v-model="account.member"
|
v-model="account.member"
|
||||||
:label="__('Member')"
|
:label="__('Member')"
|
||||||
doctype="Course Evaluator"
|
doctype="Course Evaluator"
|
||||||
:onCreate="(value, close) => openSettings('Members', close)"
|
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
|
||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
@@ -86,6 +86,12 @@ interface ZoomAccounts {
|
|||||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
) => void
|
) => void
|
||||||
}
|
}
|
||||||
|
setValue: {
|
||||||
|
submit: (
|
||||||
|
data: ZoomAccount,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const show = defineModel('show')
|
const show = defineModel('show')
|
||||||
@@ -137,7 +143,7 @@ watch(show, (val) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const saveAccount = (close) => {
|
const saveAccount = (close: () => void) => {
|
||||||
if (props.accountID == 'new') {
|
if (props.accountID == 'new') {
|
||||||
createAccount(close)
|
createAccount(close)
|
||||||
} else {
|
} else {
|
||||||
@@ -145,7 +151,7 @@ const saveAccount = (close) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createAccount = (close) => {
|
const createAccount = (close: () => void) => {
|
||||||
zoomAccounts.value?.insert.submit(
|
zoomAccounts.value?.insert.submit(
|
||||||
{
|
{
|
||||||
account_name: account.name,
|
account_name: account.name,
|
||||||
@@ -167,7 +173,7 @@ const createAccount = (close) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateAccount = async (close) => {
|
const updateAccount = async (close: () => void) => {
|
||||||
if (props.accountID != account.name) {
|
if (props.accountID != account.name) {
|
||||||
await renameDoc()
|
await renameDoc()
|
||||||
}
|
}
|
||||||
@@ -182,11 +188,12 @@ const renameDoc = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const setValue = (close) => {
|
const setValue = (close: () => void) => {
|
||||||
zoomAccounts.value?.setValue.submit(
|
zoomAccounts.value?.setValue.submit(
|
||||||
{
|
{
|
||||||
...account,
|
...account,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
|
account_name: props.accountID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
@@ -194,7 +201,7 @@ const setValue = (close) => {
|
|||||||
close()
|
close()
|
||||||
toast.success(__('Zoom Account updated successfully'))
|
toast.success(__('Zoom Account updated successfully'))
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err: any) {
|
||||||
close()
|
close()
|
||||||
toast.error(
|
toast.error(
|
||||||
cleanError(err.messages[0]) || __('Error updating Zoom Account')
|
cleanError(err.messages[0]) || __('Error updating Zoom Account')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="quiz.data">
|
<div v-if="quiz.data">
|
||||||
<div
|
<div
|
||||||
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3 leading-5"
|
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">
|
<div v-if="inVideo">
|
||||||
{{ __('You will have to complete the quiz to continue the video') }}
|
{{ __('You will have to complete the quiz to continue the video') }}
|
||||||
@@ -41,6 +41,16 @@
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
||||||
@@ -638,6 +648,8 @@ const getInstructions = (question) => {
|
|||||||
|
|
||||||
const markLessonProgress = () => {
|
const markLessonProgress = () => {
|
||||||
let pathname = window.location.pathname.split('/')
|
let pathname = window.location.pathname.split('/')
|
||||||
|
if (!pathname.includes('courses'))
|
||||||
|
pathname = window.parent.location.pathname.split('/')
|
||||||
if (pathname[2] != 'courses') return
|
if (pathname[2] != 'courses') return
|
||||||
let lessonIndex = pathname.pop().split('-')
|
let lessonIndex = pathname.pop().split('-')
|
||||||
|
|
||||||
|
|||||||
52
frontend/src/components/RelatedCourses.vue
Normal file
52
frontend/src/components/RelatedCourses.vue
Normal 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>
|
||||||
142
frontend/src/components/Settings/BadgeAssignmentForm.vue
Normal file
142
frontend/src/components/Settings/BadgeAssignmentForm.vue
Normal 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>
|
||||||
192
frontend/src/components/Settings/BadgeAssignments.vue
Normal file
192
frontend/src/components/Settings/BadgeAssignments.vue
Normal 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>
|
||||||
219
frontend/src/components/Settings/BadgeForm.vue
Normal file
219
frontend/src/components/Settings/BadgeForm.vue
Normal 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>
|
||||||
247
frontend/src/components/Settings/Badges.vue
Normal file
247
frontend/src/components/Settings/Badges.vue
Normal 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>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto">
|
<div class="overflow-y-auto">
|
||||||
<SettingFields :fields="fields" :data="data.data" />
|
<SettingFields :fields="fields" :data="branding.data" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row-reverse mt-auto">
|
<div class="flex flex-row-reverse mt-auto">
|
||||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||||
@@ -38,10 +38,6 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
data: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
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({
|
const saveSettings = createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -64,7 +66,7 @@ const saveSettings = createResource({
|
|||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
let fieldsToSave = {}
|
let fieldsToSave = {}
|
||||||
let imageFields = ['favicon', 'banner_image', 'footer_logo']
|
let imageFields = ['favicon', 'banner_image']
|
||||||
props.fields.forEach((f) => {
|
props.fields.forEach((f) => {
|
||||||
if (imageFields.includes(f.name)) {
|
if (imageFields.includes(f.name)) {
|
||||||
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
||||||
@@ -72,6 +74,8 @@ const update = () => {
|
|||||||
fieldsToSave[f.name] = f.value
|
fieldsToSave[f.name] = f.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
fieldsToSave['app_logo'] = fieldsToSave['banner_image']
|
||||||
saveSettings.submit(
|
saveSettings.submit(
|
||||||
{
|
{
|
||||||
fields: fieldsToSave,
|
fields: fieldsToSave,
|
||||||
@@ -84,9 +88,31 @@ const update = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(props.data, (newData) => {
|
watch(branding, (updatedDoc) => {
|
||||||
if (newData && !isDirty.value) {
|
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
|
isDirty.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
imageFields.forEach((field) => {
|
||||||
|
if (
|
||||||
|
updatedDoc.data[field] &&
|
||||||
|
updatedDoc.data[field].file_url != updatedDoc.previousData[field].file_url
|
||||||
|
) {
|
||||||
|
isDirty.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,60 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-0 flex-col text-base">
|
<div class="flex min-h-0 flex-col text-base">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||||
{{ __(label) }}
|
{{ __(label) }}
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="text-xs text-ink-gray-5">
|
<div class="text-ink-gray-6 leading-5">
|
||||||
{{ __(description) }}
|
{{ __(description) }}
|
||||||
</div> -->
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex item-center space-x-2">
|
<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
|
<FormControl
|
||||||
v-model="search"
|
v-model="search"
|
||||||
:placeholder="__('Search')"
|
:placeholder="__('Search')"
|
||||||
type="text"
|
type="text"
|
||||||
:debounce="300"
|
:debounce="300"
|
||||||
/>
|
class="w-1/4 mb-4"
|
||||||
<Button @click="() => (showForm = !showForm)">
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
|
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||||
<X v-else class="size-4 stroke-1.5" />
|
|
||||||
</template>
|
</template>
|
||||||
{{ showForm ? __('Close') : __('New') }}
|
</FormControl>
|
||||||
</Button>
|
<div class="overflow-auto h-[60vh]">
|
||||||
</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"
|
|
||||||
@keydown.enter="addEvaluator"
|
|
||||||
/>
|
|
||||||
<Button @click="addEvaluator()" variant="subtle">
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-y-scroll">
|
|
||||||
<div class="divide-y">
|
<div class="divide-y">
|
||||||
<div
|
<div
|
||||||
v-for="evaluator in evaluators.data"
|
v-for="evaluator in evaluators.data"
|
||||||
@click="openProfile(evaluator.username)"
|
:key="evaluator.evaluator"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between py-3">
|
<div class="flex items-center justify-between group py-3">
|
||||||
<div class="flex items-center space-x-3">
|
<div
|
||||||
|
class="flex items-center space-x-3"
|
||||||
|
@click="openProfile(evaluator.username)"
|
||||||
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
:image="evaluator.user_image"
|
:image="evaluator.user_image"
|
||||||
:label="evaluator.full_name"
|
:label="evaluator.full_name"
|
||||||
size="lg"
|
size="xl"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div class="space-y-1">
|
||||||
<div class="text-base font-semibold text-ink-gray-9">
|
<div class="text-base font-semibold text-ink-gray-9">
|
||||||
{{ evaluator.full_name }}
|
{{ evaluator.full_name }}
|
||||||
</div>
|
</div>
|
||||||
@@ -63,16 +57,73 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui'
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
call,
|
||||||
|
createListResource,
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { Plus, X } from 'lucide-vue-next'
|
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const show = defineModel('show')
|
const show = defineModel('show')
|
||||||
@@ -95,33 +146,39 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const evaluators = createResource({
|
const evaluators = createListResource({
|
||||||
url: 'frappe.client.get_list',
|
|
||||||
makeParams: () => {
|
|
||||||
return {
|
|
||||||
doctype: 'Course Evaluator',
|
doctype: 'Course Evaluator',
|
||||||
fields: ['evaluator', 'full_name', 'user_image', 'username'],
|
fields: ['evaluator', 'username', 'full_name', 'user_image'],
|
||||||
filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
auto: true,
|
auto: true,
|
||||||
|
orderBy: 'creation desc',
|
||||||
})
|
})
|
||||||
|
|
||||||
const addEvaluator = () => {
|
const addEvaluator = (close: () => void) => {
|
||||||
call('lms.lms.api.add_an_evaluator', {
|
call('lms.lms.api.add_an_evaluator', {
|
||||||
email: email.value,
|
email: email.value,
|
||||||
}).then((data) => {
|
})
|
||||||
showForm.value = false
|
.then(() => {
|
||||||
email.value = ''
|
email.value = ''
|
||||||
evaluators.reload()
|
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, () => {
|
watch(search, () => {
|
||||||
|
evaluators.update({
|
||||||
|
filters: {
|
||||||
|
full_name: ['like', `%${search.value}%`],
|
||||||
|
},
|
||||||
|
})
|
||||||
evaluators.reload()
|
evaluators.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
const openProfile = (username) => {
|
const openProfile = (username: string) => {
|
||||||
show.value = false
|
show.value = false
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
@@ -130,4 +187,18 @@ const openProfile = (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>
|
</script>
|
||||||
|
|||||||
@@ -5,53 +5,37 @@
|
|||||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||||
{{ __(label) }}
|
{{ __(label) }}
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="text-xs text-ink-gray-5">
|
<div class="text-ink-gray-6 leading-5">
|
||||||
{{ __(description) }}
|
{{ __(description) }}
|
||||||
</div> -->
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex item-center space-x-2">
|
<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-10">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="search"
|
v-model="search"
|
||||||
:placeholder="__('Search')"
|
:placeholder="__('Search')"
|
||||||
type="text"
|
type="text"
|
||||||
:debounce="300"
|
:debounce="300"
|
||||||
/>
|
class="w-1/4 mb-4"
|
||||||
<Button @click="() => (showForm = !showForm)">
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
|
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||||
<X v-else class="size-4 stroke-1.5" />
|
|
||||||
</template>
|
</template>
|
||||||
{{ showForm ? __('Close') : __('New') }}
|
</FormControl>
|
||||||
</Button>
|
<div class="overflow-y-scroll h-[60vh]">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form to add new member -->
|
|
||||||
<div v-if="showForm" class="flex items-center space-x-2 my-4">
|
|
||||||
<FormControl
|
|
||||||
v-model="member.email"
|
|
||||||
:placeholder="__('Email')"
|
|
||||||
type="email"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
v-model="member.first_name"
|
|
||||||
:placeholder="__('First Name')"
|
|
||||||
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">
|
|
||||||
<ul class="divide-y">
|
<ul class="divide-y">
|
||||||
<li
|
<li
|
||||||
v-for="member in memberList"
|
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
|
<div
|
||||||
@click="openProfile(member.username)"
|
@click="openProfile(member.username)"
|
||||||
@@ -60,27 +44,13 @@
|
|||||||
<Avatar
|
<Avatar
|
||||||
:image="member.user_image"
|
:image="member.user_image"
|
||||||
:label="member.full_name"
|
:label="member.full_name"
|
||||||
size="lg"
|
size="xl"
|
||||||
/>
|
/>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="text-ink-gray-9">
|
<div class="text-ink-gray-9">
|
||||||
{{ member.full_name }}
|
{{ member.full_name }}
|
||||||
</div>
|
</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>
|
||||||
<div class="text-sm text-ink-gray-7">
|
<div class="text-sm text-ink-gray-7">
|
||||||
{{ member.name }}
|
{{ member.name }}
|
||||||
@@ -88,16 +58,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<Shield class="size-4 stroke-1.5" />
|
||||||
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }}
|
<span class="text-sm">
|
||||||
</div>
|
{{ getRole(member.role) }}
|
||||||
<div v-else>-</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="memberList.length && hasNextPage"
|
v-if="memberList.length && hasNextPage"
|
||||||
class="flex justify-center mt-4"
|
class="flex justify-center mt-4"
|
||||||
@@ -111,20 +81,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
<script setup lang="ts">
|
<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 { useRouter } from 'vue-router'
|
||||||
import { ref, watch, reactive, inject } from 'vue'
|
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 { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import type { User } from '@/components/Settings/types'
|
import type { User } from '@/components/Settings/types'
|
||||||
|
|
||||||
|
type Member = {
|
||||||
|
username: string
|
||||||
|
full_name: string
|
||||||
|
name: string
|
||||||
|
role?: string
|
||||||
|
user_image?: string
|
||||||
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const show = defineModel('show')
|
const show = defineModel('show')
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const start = ref(0)
|
const start = ref(0)
|
||||||
const memberList = ref([])
|
const memberList = ref<Member[]>([])
|
||||||
const hasNextPage = ref(false)
|
const hasNextPage = ref(false)
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
@@ -158,7 +177,7 @@ const members = createResource({
|
|||||||
start: start.value,
|
start: start.value,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data: Member[]) {
|
||||||
memberList.value = memberList.value.concat(data)
|
memberList.value = memberList.value.concat(data)
|
||||||
start.value = start.value + 20
|
start.value = start.value + 20
|
||||||
hasNextPage.value = data.length === 20
|
hasNextPage.value = data.length === 20
|
||||||
@@ -166,7 +185,7 @@ const members = createResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const openProfile = (username) => {
|
const openProfile = (username: string) => {
|
||||||
show.value = false
|
show.value = false
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
@@ -178,7 +197,7 @@ const openProfile = (username) => {
|
|||||||
|
|
||||||
const newMember = createResource({
|
const newMember = createResource({
|
||||||
url: 'frappe.client.insert',
|
url: 'frappe.client.insert',
|
||||||
makeParams(values) {
|
makeParams() {
|
||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'User',
|
doctype: 'User',
|
||||||
@@ -188,13 +207,12 @@ const newMember = createResource({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
auto: false,
|
auto: false,
|
||||||
onSuccess(data) {
|
onSuccess(data: Member) {
|
||||||
show.value = false
|
show.value = false
|
||||||
|
|
||||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Profile',
|
name: 'ProfileRoles',
|
||||||
params: {
|
params: {
|
||||||
username: data.username,
|
username: data.username,
|
||||||
},
|
},
|
||||||
@@ -202,8 +220,9 @@ const newMember = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const addMember = () => {
|
const addMember = (close: () => void) => {
|
||||||
newMember.reload()
|
newMember.reload()
|
||||||
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(search, () => {
|
watch(search, () => {
|
||||||
@@ -212,8 +231,8 @@ watch(search, () => {
|
|||||||
members.reload()
|
members.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
const getRole = (role) => {
|
const getRole = (role: string) => {
|
||||||
const map = {
|
const map: Record<string, string> = {
|
||||||
'LMS Student': 'Student',
|
'LMS Student': 'Student',
|
||||||
'Course Creator': 'Instructor',
|
'Course Creator': 'Instructor',
|
||||||
Moderator: 'Moderator',
|
Moderator: 'Moderator',
|
||||||
|
|||||||
@@ -34,32 +34,16 @@
|
|||||||
:key="activeTab.label"
|
:key="activeTab.label"
|
||||||
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
|
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
|
||||||
>
|
>
|
||||||
<Members
|
<component
|
||||||
v-if="activeTab.label === 'Members'"
|
v-if="activeTab.template"
|
||||||
:label="activeTab.label"
|
:is="activeTab.template"
|
||||||
:description="activeTab.description"
|
v-bind="{
|
||||||
v-model:show="show"
|
label: activeTab.label,
|
||||||
/>
|
description: activeTab.description,
|
||||||
<Evaluators
|
...(activeTab.label === 'Branding'
|
||||||
v-else-if="activeTab.label === 'Evaluators'"
|
? { fields: activeTab.fields }
|
||||||
:label="activeTab.label"
|
: {}),
|
||||||
:description="activeTab.description"
|
}"
|
||||||
v-model:show="show"
|
|
||||||
/>
|
|
||||||
<Categories
|
|
||||||
v-else-if="activeTab.label === 'Categories'"
|
|
||||||
:label="activeTab.label"
|
|
||||||
:description="activeTab.description"
|
|
||||||
/>
|
|
||||||
<EmailTemplates
|
|
||||||
v-else-if="activeTab.label === 'Email Templates'"
|
|
||||||
:label="activeTab.label"
|
|
||||||
:description="activeTab.description"
|
|
||||||
/>
|
|
||||||
<ZoomSettings
|
|
||||||
v-else-if="activeTab.label === 'Zoom Accounts'"
|
|
||||||
:label="activeTab.label"
|
|
||||||
:description="activeTab.description"
|
|
||||||
/>
|
/>
|
||||||
<PaymentSettings
|
<PaymentSettings
|
||||||
v-else-if="activeTab.label === 'Payment Gateway'"
|
v-else-if="activeTab.label === 'Payment Gateway'"
|
||||||
@@ -68,13 +52,6 @@
|
|||||||
:data="data"
|
:data="data"
|
||||||
:fields="activeTab.fields"
|
:fields="activeTab.fields"
|
||||||
/>
|
/>
|
||||||
<BrandSettings
|
|
||||||
v-else-if="activeTab.label === 'Branding'"
|
|
||||||
:label="activeTab.label"
|
|
||||||
:description="activeTab.description"
|
|
||||||
:fields="activeTab.fields"
|
|
||||||
:data="branding"
|
|
||||||
/>
|
|
||||||
<SettingDetails
|
<SettingDetails
|
||||||
v-else
|
v-else
|
||||||
:fields="activeTab.fields"
|
:fields="activeTab.fields"
|
||||||
@@ -88,8 +65,8 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
|
import { Dialog, createDocumentResource } from 'frappe-ui'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { computed, markRaw, ref, watch } from 'vue'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
import SettingDetails from '@/components/Settings/SettingDetails.vue'
|
import SettingDetails from '@/components/Settings/SettingDetails.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
@@ -100,6 +77,7 @@ import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
|
|||||||
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
||||||
import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
|
import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
|
||||||
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
||||||
|
import Badges from '@/components/Settings/Badges.vue'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const doctype = ref('LMS Settings')
|
const doctype = ref('LMS Settings')
|
||||||
@@ -114,12 +92,6 @@ const data = createDocumentResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const branding = createResource({
|
|
||||||
url: 'lms.lms.api.get_branding',
|
|
||||||
auto: true,
|
|
||||||
cache: 'brand',
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabsStructure = computed(() => {
|
const tabsStructure = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -130,6 +102,13 @@ const tabsStructure = computed(() => {
|
|||||||
label: 'General',
|
label: 'General',
|
||||||
icon: 'Wrench',
|
icon: 'Wrench',
|
||||||
fields: [
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Allow Guest Access',
|
||||||
|
name: 'allow_guest_access',
|
||||||
|
description:
|
||||||
|
'If enabled, users can access the course and batch lists without logging in.',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Enable Learning Paths',
|
label: 'Enable Learning Paths',
|
||||||
name: 'enable_learning_paths',
|
name: 'enable_learning_paths',
|
||||||
@@ -138,11 +117,11 @@ const tabsStructure = computed(() => {
|
|||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Allow Guest Access',
|
label: 'Prevent Skipping Videos',
|
||||||
name: 'allow_guest_access',
|
name: 'prevent_skipping_videos',
|
||||||
description:
|
|
||||||
'If enabled, users can access the course and batch lists without logging in.',
|
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
description:
|
||||||
|
'If enabled, users will no able to move forward in a video',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Send calendar invite for evaluations',
|
label: 'Send calendar invite for evaluations',
|
||||||
@@ -154,6 +133,14 @@ const tabsStructure = computed(() => {
|
|||||||
{
|
{
|
||||||
type: 'Column Break',
|
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',
|
label: 'Batch Confirmation Email Template',
|
||||||
name: 'batch_confirmation_template',
|
name: 'batch_confirmation_template',
|
||||||
@@ -227,38 +214,55 @@ const tabsStructure = computed(() => {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Members',
|
label: 'Members',
|
||||||
description: 'Manage the members of your learning system',
|
description:
|
||||||
|
'Add new members or manage roles and permissions of existing members',
|
||||||
icon: 'UserRoundPlus',
|
icon: 'UserRoundPlus',
|
||||||
|
template: markRaw(Members),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Evaluators',
|
label: 'Evaluators',
|
||||||
description: 'Manage the evaluators of your learning system',
|
description: '',
|
||||||
icon: 'UserCheck',
|
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',
|
label: 'Categories',
|
||||||
description: 'Double click to edit the category',
|
description: 'Double click to edit the category',
|
||||||
icon: 'Network',
|
icon: 'Network',
|
||||||
|
template: markRaw(Categories),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Email Templates',
|
label: 'Email Templates',
|
||||||
description: 'Manage the email templates for your learning system',
|
description: 'Manage the email templates for your learning system',
|
||||||
icon: 'MailPlus',
|
icon: 'MailPlus',
|
||||||
},
|
template: markRaw(EmailTemplates),
|
||||||
{
|
|
||||||
label: 'Zoom Accounts',
|
|
||||||
description: 'Manage the Zoom accounts for your learning system',
|
|
||||||
icon: 'Video',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Customise',
|
label: 'Customize',
|
||||||
hideLabel: false,
|
hideLabel: false,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Branding',
|
label: 'Branding',
|
||||||
icon: 'Blocks',
|
icon: 'Blocks',
|
||||||
|
template: markRaw(BrandSettings),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: 'Brand Name',
|
label: 'Brand Name',
|
||||||
@@ -292,6 +296,11 @@ const tabsStructure = computed(() => {
|
|||||||
name: 'batches',
|
name: 'batches',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Programming Exercises',
|
||||||
|
name: 'programming_exercises',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Certified Members',
|
label: 'Certified Members',
|
||||||
name: 'certified_members',
|
name: 'certified_members',
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<div class="text-xl font-semibold text-ink-gray-9">
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="text-xs text-ink-gray-5">
|
<div class="text-ink-gray-6 leading-5">
|
||||||
{{ __(description) }}
|
{{ __(description) }}
|
||||||
</div> -->
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-5">
|
<div class="flex items-center space-x-5">
|
||||||
<Button @click="openForm('new')">
|
<Button @click="openForm('new')">
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
>
|
>
|
||||||
<ListHeaderItem :item="item" v-for="item in columns">
|
<ListHeaderItem :item="item" v-for="item in columns">
|
||||||
<template #prefix="{ item }">
|
<template #prefix="{ item }">
|
||||||
<component
|
<FeatherIcon
|
||||||
v-if="item.icon"
|
v-if="item.icon"
|
||||||
:is="item.icon"
|
:name="item.icon"
|
||||||
class="h-4 w-4 stroke-1.5 ml-4"
|
class="h-4 w-4 stroke-1.5"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ListHeaderItem>
|
</ListHeaderItem>
|
||||||
@@ -48,8 +48,18 @@
|
|||||||
<ListRow :row="row" v-for="row in zoomAccounts.data">
|
<ListRow :row="row" v-for="row in zoomAccounts.data">
|
||||||
<template #default="{ column, item }">
|
<template #default="{ column, item }">
|
||||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
<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'">
|
<div v-if="column.key == 'enabled'">
|
||||||
<Badge v-if="row[column.key]" theme="blue">
|
<Badge v-if="row[column.key]" theme="green">
|
||||||
{{ __('Enabled') }}
|
{{ __('Enabled') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge v-else theme="gray">
|
<Badge v-else theme="gray">
|
||||||
@@ -87,10 +97,12 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
Badge,
|
Badge,
|
||||||
call,
|
call,
|
||||||
createListResource,
|
createListResource,
|
||||||
|
FeatherIcon,
|
||||||
ListView,
|
ListView,
|
||||||
ListHeader,
|
ListHeader,
|
||||||
ListHeaderItem,
|
ListHeaderItem,
|
||||||
@@ -122,6 +134,7 @@ const zoomAccounts = createListResource({
|
|||||||
'enabled',
|
'enabled',
|
||||||
'member',
|
'member',
|
||||||
'member_name',
|
'member_name',
|
||||||
|
'member_image',
|
||||||
'account_id',
|
'account_id',
|
||||||
'client_id',
|
'client_id',
|
||||||
'client_secret',
|
'client_secret',
|
||||||
@@ -170,18 +183,21 @@ const removeAccount = (selections, unselectAll) => {
|
|||||||
|
|
||||||
const columns = computed(() => {
|
const columns = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
label: __('Account'),
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: __('Member'),
|
label: __('Member'),
|
||||||
key: 'member_name',
|
key: 'member_name',
|
||||||
|
icon: 'user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Account Name'),
|
||||||
|
key: 'name',
|
||||||
|
icon: 'video',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: __('Status'),
|
label: __('Status'),
|
||||||
key: 'enabled',
|
key: 'enabled',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
icon: 'check-square',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,3 +14,61 @@ export interface User {
|
|||||||
is_fc_site: 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
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -116,7 +116,7 @@ import {
|
|||||||
EllipsisVertical,
|
EllipsisVertical,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { inject, ref, getCurrentInstance, computed } from 'vue'
|
import { inject, ref, getCurrentInstance, computed } from 'vue'
|
||||||
import { formatTime } from '../utils'
|
import { formatTime } from '@/utils'
|
||||||
import { Button, createResource, call } from 'frappe-ui'
|
import { Button, createResource, call } from 'frappe-ui'
|
||||||
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
||||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div v-if="quizzes.length && !showQuiz && readOnly" class="leading-5">
|
||||||
v-if="quizzes.length && !showQuiz && readOnly"
|
|
||||||
class="bg-surface-blue-2 space-y-1 py-3 px-4 rounded-md text-sm text-ink-blue-3 leading-5"
|
|
||||||
>
|
|
||||||
{{
|
{{
|
||||||
__('This video contains {0} {1}:').format(
|
__('This video contains {0} {1}:').format(
|
||||||
quizzes.length,
|
quizzes.length,
|
||||||
@@ -12,8 +9,10 @@
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
|
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
|
||||||
<span> {{ index + 1 }}. {{ quiz.quiz }} </span>
|
<span>
|
||||||
{{ __('at {0}').format(formatTimestamp(quiz.time)) }}
|
{{ index + 1 }}. <span class="font-semibold"> {{ quiz.quiz }} </span>
|
||||||
|
</span>
|
||||||
|
{{ __('at {0} minutes').format(formatTimestamp(quiz.time)) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -28,9 +27,9 @@
|
|||||||
oncontextmenu="return false"
|
oncontextmenu="return false"
|
||||||
class="rounded-md border border-gray-100 cursor-pointer"
|
class="rounded-md border border-gray-100 cursor-pointer"
|
||||||
ref="videoRef"
|
ref="videoRef"
|
||||||
>
|
:src="fileURL"
|
||||||
<source :src="fileURL" :type="type" />
|
:type="type"
|
||||||
</video>
|
></video>
|
||||||
<div
|
<div
|
||||||
v-if="!playing"
|
v-if="!playing"
|
||||||
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
||||||
@@ -65,6 +64,8 @@
|
|||||||
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
|
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div class="relative flex items-center w-full flex-1">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -72,8 +73,19 @@
|
|||||||
step="0.1"
|
step="0.1"
|
||||||
v-model="currentTime"
|
v-model="currentTime"
|
||||||
@input="changeCurrentTime"
|
@input="changeCurrentTime"
|
||||||
class="duration-slider w-full h-1"
|
class="duration-slider h-1"
|
||||||
/>
|
/>
|
||||||
|
<!-- QUIZ MARKERS -->
|
||||||
|
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
|
||||||
|
<div
|
||||||
|
v-for="(quiz, index) in quizzes"
|
||||||
|
:key="index"
|
||||||
|
:style="getQuizMarkerStyle(quiz.time)"
|
||||||
|
class="absolute top-0 h-full w-2 bg-surface-amber-3"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span class="text-sm font-medium">
|
<span class="text-sm font-medium">
|
||||||
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
|
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -116,12 +128,34 @@
|
|||||||
:saveQuizzes="saveQuizzes"
|
:saveQuizzes="saveQuizzes"
|
||||||
:duration="duration"
|
:duration="duration"
|
||||||
/>
|
/>
|
||||||
|
<Dialog
|
||||||
|
v-model="showQuizLoader"
|
||||||
|
:options="{
|
||||||
|
size: 'sm',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col space-y-2 p-5 text-base leading-5">
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ __('Time for a Quiz') }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'Complete the upcoming quiz to continue watching the video. The quiz will open in {0} {1}.'
|
||||||
|
).format(quizLoadTimer, quizLoadTimer === 1 ? 'second' : 'seconds')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
import { Button } from 'frappe-ui'
|
import { Button, Dialog } from 'frappe-ui'
|
||||||
import { formatSeconds, formatTimestamp } from '@/utils'
|
import { formatSeconds, formatTimestamp } from '@/utils'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
import Play from '@/components/Icons/Play.vue'
|
import Play from '@/components/Icons/Play.vue'
|
||||||
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
|
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
|
||||||
|
|
||||||
@@ -133,8 +167,11 @@ let duration = ref(0)
|
|||||||
let muted = ref(false)
|
let muted = ref(false)
|
||||||
const showQuizModal = ref(false)
|
const showQuizModal = ref(false)
|
||||||
const showQuiz = ref(false)
|
const showQuiz = ref(false)
|
||||||
|
const showQuizLoader = ref(false)
|
||||||
|
const quizLoadTimer = ref(0)
|
||||||
const currentQuiz = ref(null)
|
const currentQuiz = ref(null)
|
||||||
const nextQuiz = ref({})
|
const nextQuiz = ref({})
|
||||||
|
const { preventSkippingVideos } = useSettings()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
file: {
|
file: {
|
||||||
@@ -146,7 +183,7 @@ const props = defineProps({
|
|||||||
default: 'video/mp4',
|
default: 'video/mp4',
|
||||||
},
|
},
|
||||||
readOnly: {
|
readOnly: {
|
||||||
type: String,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
quizzes: {
|
quizzes: {
|
||||||
@@ -155,6 +192,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
saveQuizzes: {
|
saveQuizzes: {
|
||||||
type: Function,
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -175,12 +213,24 @@ const updateCurrentTime = () => {
|
|||||||
playing.value = false
|
playing.value = false
|
||||||
videoRef.value.onTimeupdate = null
|
videoRef.value.onTimeupdate = null
|
||||||
currentQuiz.value = nextQuiz.value.quiz
|
currentQuiz.value = nextQuiz.value.quiz
|
||||||
showQuiz.value = true
|
quizLoadTimer.value = 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(quizLoadTimer, () => {
|
||||||
|
if (quizLoadTimer.value > 0) {
|
||||||
|
showQuizLoader.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
quizLoadTimer.value -= 1
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
showQuizLoader.value = false
|
||||||
|
showQuiz.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const resumeVideo = (restart = false) => {
|
const resumeVideo = (restart = false) => {
|
||||||
showQuiz.value = false
|
showQuiz.value = false
|
||||||
currentQuiz.value = null
|
currentQuiz.value = null
|
||||||
@@ -248,6 +298,11 @@ const toggleMute = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const changeCurrentTime = () => {
|
const changeCurrentTime = () => {
|
||||||
|
if (
|
||||||
|
preventSkippingVideos.data &&
|
||||||
|
currentTime.value > videoRef.value.currentTime
|
||||||
|
)
|
||||||
|
return
|
||||||
videoRef.value.currentTime = currentTime.value
|
videoRef.value.currentTime = currentTime.value
|
||||||
updateNextQuiz()
|
updateNextQuiz()
|
||||||
}
|
}
|
||||||
@@ -259,6 +314,13 @@ const toggleFullscreen = () => {
|
|||||||
videoContainer.value.requestFullscreen()
|
videoContainer.value.requestFullscreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getQuizMarkerStyle = (time) => {
|
||||||
|
const percentage = ((time - 5) / Math.ceil(duration.value)) * 100
|
||||||
|
return {
|
||||||
|
left: `${percentage}%`,
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -278,11 +340,10 @@ iframe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.duration-slider {
|
.duration-slider {
|
||||||
flex: 1;
|
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background-color: theme('colors.gray.100');
|
background-color: theme('colors.gray.600');
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,20 +351,20 @@ iframe {
|
|||||||
width: 2px;
|
width: 2px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background-color: theme('colors.gray.500');
|
background-color: theme('colors.white');
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||||
input[type='range'] {
|
input[type='range'] {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 150px;
|
width: 100%;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='range']::-webkit-slider-thumb {
|
input[type='range']::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: -500px 0 0 500px theme('colors.gray.600');
|
box-shadow: -500px 0 0 500px theme('colors.white');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
11
frontend/src/global.d.ts
vendored
Normal file
11
frontend/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export {}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
function __(text: string): string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'vue' {
|
||||||
|
interface ComponentCustomProperties {
|
||||||
|
__: (text: string) => string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
@import './assets/Inter/inter.css';
|
@import './assets/Inter/inter.css';
|
||||||
@import 'frappe-ui/src/style.css';
|
@import 'frappe-ui/src/style.css';
|
||||||
|
@import './styles/codemirror.css';
|
||||||
@@ -145,7 +145,6 @@ const submissions = createListResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// watch changes in assignmentID, member, and status and if changes in any then reload submissions. Also update the url query params for the same
|
|
||||||
watch([assignmentID, member, status], () => {
|
watch([assignmentID, member, status], () => {
|
||||||
router.push({
|
router.push({
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -16,20 +16,17 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New') }}
|
{{ __('Create') }}
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||||
<div class="flex items-center justify-between mb-5">
|
<div class="flex items-center justify-between mb-5">
|
||||||
<div
|
<div v-if="assignmentCount" class="text-lg font-semibold text-ink-gray-9">
|
||||||
v-if="assignmentCount"
|
|
||||||
class="text-xl font-semibold text-ink-gray-7 mb-4"
|
|
||||||
>
|
|
||||||
{{ __('{0} Assignments').format(assignmentCount) }}
|
{{ __('{0} Assignments').format(assignmentCount) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="assignments.data?.length || assigmentCount > 0"
|
v-if="assignments.data?.length || assignmentCount > 0"
|
||||||
class="grid grid-cols-2 gap-5"
|
class="grid grid-cols-2 gap-5"
|
||||||
>
|
>
|
||||||
<FormControl
|
<FormControl
|
||||||
|
|||||||
@@ -4,9 +4,16 @@
|
|||||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||||
>
|
>
|
||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Button v-if="batchDetail.data?.name" @click="deleteBatch">
|
||||||
|
<template #icon>
|
||||||
|
<Trash2 class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
<Button variant="solid" @click="saveBatch()">
|
<Button variant="solid" @click="saveBatch()">
|
||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="py-5">
|
<div class="py-5">
|
||||||
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
||||||
@@ -209,7 +216,10 @@
|
|||||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="border rounded-md w-fit py-5 px-20">
|
<div
|
||||||
|
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
|
||||||
|
@click="openFileSelector"
|
||||||
|
>
|
||||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
@@ -300,10 +310,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
onMounted,
|
getCurrentInstance,
|
||||||
inject,
|
inject,
|
||||||
reactive,
|
onMounted,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
|
reactive,
|
||||||
ref,
|
ref,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import {
|
import {
|
||||||
@@ -315,21 +326,30 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
toast,
|
toast,
|
||||||
|
call,
|
||||||
|
Toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Image } from 'lucide-vue-next'
|
import { Image, Trash2 } from 'lucide-vue-next'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
|
import {
|
||||||
|
openSettings,
|
||||||
|
getMetaInfo,
|
||||||
|
updateMetaInfo,
|
||||||
|
validateFile,
|
||||||
|
} from '@/utils'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
const instructors = ref([])
|
const instructors = ref([])
|
||||||
|
const app = getCurrentInstance()
|
||||||
|
const { $dialog } = app.appContext.config.globalProperties
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batchName: {
|
batchName: {
|
||||||
@@ -531,6 +551,38 @@ const editBatchDetails = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteBatch = () => {
|
||||||
|
$dialog({
|
||||||
|
title: __('Confirm your action to delete'),
|
||||||
|
message: __(
|
||||||
|
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Delete'),
|
||||||
|
theme: 'red',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick({ close }) {
|
||||||
|
trashBatch(close)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trashBatch = (close) => {
|
||||||
|
call('lms.lms.api.delete_batch', {
|
||||||
|
batch: props.batchName,
|
||||||
|
}).then(() => {
|
||||||
|
toast.success(__('Batch deleted successfully'))
|
||||||
|
close()
|
||||||
|
router.push({
|
||||||
|
name: 'Batches',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const saveImage = (file) => {
|
const saveImage = (file) => {
|
||||||
batch.image = file
|
batch.image = file
|
||||||
}
|
}
|
||||||
@@ -539,13 +591,6 @@ const removeImage = () => {
|
|||||||
batch.image = null
|
batch.image = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
|
||||||
return 'Only image file is allowed.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New') }}
|
{{ __('Create') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
/>
|
/>
|
||||||
<FormControl :label="__('City')" v-model="billingDetails.city" />
|
<FormControl :label="__('City')" v-model="billingDetails.city" />
|
||||||
<FormControl
|
<FormControl
|
||||||
:label="__('State')"
|
:label="__('State/Province')"
|
||||||
v-model="billingDetails.state"
|
v-model="billingDetails.state"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,6 +303,7 @@ const validateAddress = () => {
|
|||||||
'Gujarat',
|
'Gujarat',
|
||||||
'Haryana',
|
'Haryana',
|
||||||
'Himachal Pradesh',
|
'Himachal Pradesh',
|
||||||
|
'Jammu and Kashmir',
|
||||||
'Jharkhand',
|
'Jharkhand',
|
||||||
'Karnataka',
|
'Karnataka',
|
||||||
'Kerala',
|
'Kerala',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
</header>
|
</header>
|
||||||
<div class="m-5">
|
<div class="m-5">
|
||||||
<div class="flex justify-between w-full">
|
<div class="flex justify-between w-full space-x-5">
|
||||||
<div class="md:w-2/3">
|
<div class="md:w-2/3">
|
||||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||||
{{ course.data.title }}
|
{{ course.data.title }}
|
||||||
@@ -66,7 +66,9 @@
|
|||||||
{{ tag }}
|
{{ tag }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CourseCardOverlay :course="course" class="md:hidden mb-4" />
|
<div class="md:hidden mb-4">
|
||||||
|
<CourseCardOverlay :course="course" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-html="course.data.description"
|
v-html="course.data.description"
|
||||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
||||||
@@ -76,6 +78,7 @@
|
|||||||
:title="__('Course Outline')"
|
:title="__('Course Outline')"
|
||||||
:courseName="course.data.name"
|
:courseName="course.data.name"
|
||||||
:showOutline="true"
|
:showOutline="true"
|
||||||
|
:getProgress="course.data.membership ? true : false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CourseReviews
|
<CourseReviews
|
||||||
@@ -88,6 +91,7 @@
|
|||||||
<CourseCardOverlay :course="course" />
|
<CourseCardOverlay :course="course" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<RelatedCourses :courseName="course.data.name" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -99,7 +103,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { computed } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
import { Users, Star } from 'lucide-vue-next'
|
import { Users, Star } from 'lucide-vue-next'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
||||||
@@ -107,6 +111,7 @@ import CourseOutline from '@/components/CourseOutline.vue'
|
|||||||
import CourseReviews from '@/components/CourseReviews.vue'
|
import CourseReviews from '@/components/CourseReviews.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
import RelatedCourses from '@/components/RelatedCourses.vue'
|
||||||
|
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
|
|
||||||
@@ -120,12 +125,21 @@ const props = defineProps({
|
|||||||
const course = createResource({
|
const course = createResource({
|
||||||
url: 'lms.lms.utils.get_course_details',
|
url: 'lms.lms.utils.get_course_details',
|
||||||
cache: ['course', props.courseName],
|
cache: ['course', props.courseName],
|
||||||
params: {
|
makeParams() {
|
||||||
|
return {
|
||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.courseName,
|
||||||
|
() => {
|
||||||
|
course.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||||
items.push({
|
items.push({
|
||||||
|
|||||||
@@ -50,11 +50,12 @@
|
|||||||
<div class="mb-1.5 text-xs text-ink-gray-5">
|
<div class="mb-1.5 text-xs text-ink-gray-5">
|
||||||
{{ __('Tags') }}
|
{{ __('Tags') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div>
|
||||||
|
<div class="flex items-center flex-wrap gap-2">
|
||||||
<div
|
<div
|
||||||
v-if="course.tags"
|
v-if="course.tags"
|
||||||
v-for="tag in course.tags?.split(', ')"
|
v-for="tag in course.tags?.split(', ')"
|
||||||
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md mr-2"
|
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
<X
|
<X
|
||||||
@@ -62,10 +63,15 @@
|
|||||||
@click="removeTag(tag)"
|
@click="removeTag(tag)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="newTag"
|
v-model="newTag"
|
||||||
:placeholder="__('Add a keyword and then press enter')"
|
:placeholder="__('Add a keyword and then press enter')"
|
||||||
class="w-full"
|
:class="[
|
||||||
|
'w-full',
|
||||||
|
'flex-1',
|
||||||
|
{ 'mt-2': course.tags?.length },
|
||||||
|
]"
|
||||||
@keyup.enter="updateTags()"
|
@keyup.enter="updateTags()"
|
||||||
id="tags"
|
id="tags"
|
||||||
/>
|
/>
|
||||||
@@ -100,7 +106,10 @@
|
|||||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="border rounded-md w-fit py-5 px-20">
|
<div
|
||||||
|
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
|
||||||
|
@click="openFileSelector"
|
||||||
|
>
|
||||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
@@ -199,6 +208,21 @@
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MultiSelect
|
||||||
|
v-model="related_courses"
|
||||||
|
doctype="LMS Course"
|
||||||
|
:label="__('Related Courses')"
|
||||||
|
:filters="{ name: ['!=', courseResource.data?.name] }"
|
||||||
|
:onCreate="
|
||||||
|
(close) => {
|
||||||
|
router.push({
|
||||||
|
name: 'CourseForm',
|
||||||
|
params: { courseName: 'new' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-10 pb-5 space-y-5 border-b">
|
<div class="px-10 pb-5 space-y-5 border-b">
|
||||||
@@ -309,7 +333,12 @@ import { useRouter } from 'vue-router'
|
|||||||
import { capture, startRecording, stopRecording } from '@/telemetry'
|
import { capture, startRecording, stopRecording } from '@/telemetry'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
|
import {
|
||||||
|
openSettings,
|
||||||
|
getMetaInfo,
|
||||||
|
updateMetaInfo,
|
||||||
|
validateFile,
|
||||||
|
} from '@/utils'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
@@ -319,6 +348,7 @@ const newTag = ref('')
|
|||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const instructors = ref([])
|
const instructors = ref([])
|
||||||
|
const related_courses = ref([])
|
||||||
const app = getCurrentInstance()
|
const app = getCurrentInstance()
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
const { $dialog } = app.appContext.config.globalProperties
|
const { $dialog } = app.appContext.config.globalProperties
|
||||||
@@ -400,6 +430,9 @@ const courseCreationResource = createResource({
|
|||||||
instructors: instructors.value.map((instructor) => ({
|
instructors: instructors.value.map((instructor) => ({
|
||||||
instructor: instructor,
|
instructor: instructor,
|
||||||
})),
|
})),
|
||||||
|
related_courses: related_courses.value.map((course) => ({
|
||||||
|
course: course,
|
||||||
|
})),
|
||||||
...values,
|
...values,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -418,6 +451,9 @@ const courseEditResource = createResource({
|
|||||||
instructors: instructors.value.map((instructor) => ({
|
instructors: instructors.value.map((instructor) => ({
|
||||||
instructor: instructor,
|
instructor: instructor,
|
||||||
})),
|
})),
|
||||||
|
related_courses: related_courses.value.map((course) => ({
|
||||||
|
course: course,
|
||||||
|
})),
|
||||||
...course,
|
...course,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -440,6 +476,11 @@ const courseResource = createResource({
|
|||||||
data.instructors.forEach((instructor) => {
|
data.instructors.forEach((instructor) => {
|
||||||
instructors.value.push(instructor.instructor)
|
instructors.value.push(instructor.instructor)
|
||||||
})
|
})
|
||||||
|
} else if (key == 'related_courses') {
|
||||||
|
related_courses.value = []
|
||||||
|
data.related_courses.forEach((course) => {
|
||||||
|
related_courses.value.push(course.course)
|
||||||
|
})
|
||||||
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||||
})
|
})
|
||||||
let checkboxes = [
|
let checkboxes = [
|
||||||
@@ -564,13 +605,6 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
|
||||||
return __('Only image file is allowed.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateTags = () => {
|
const updateTags = () => {
|
||||||
if (newTag.value) {
|
if (newTag.value) {
|
||||||
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
|
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New') }}
|
{{ __('Create') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</header>
|
</header>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="courses.data?.length"
|
v-if="courses.data?.length"
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-5"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="course in courses.data"
|
v-for="course in courses.data"
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ import { computed, onMounted, reactive, inject } from 'vue'
|
|||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getFileSize } from '@/utils'
|
import { getFileSize, validateFile } from '@/utils'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -293,13 +293,6 @@ const removeImage = () => {
|
|||||||
job.image = null
|
job.image = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
|
||||||
return 'Only image file is allowed.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobTypes = computed(() => {
|
const jobTypes = computed(() => {
|
||||||
return [
|
return [
|
||||||
{ label: 'Full Time', value: 'Full Time' },
|
{ label: 'Full Time', value: 'Full Time' },
|
||||||
|
|||||||
@@ -122,9 +122,6 @@ onMounted(() => {
|
|||||||
const jobs = createResource({
|
const jobs = createResource({
|
||||||
url: 'lms.lms.api.get_job_opportunities',
|
url: 'lms.lms.api.get_job_opportunities',
|
||||||
cache: ['jobs'],
|
cache: ['jobs'],
|
||||||
onSuccess(data) {
|
|
||||||
jobCount.value = data.length
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateJobs = () => {
|
const updateJobs = () => {
|
||||||
@@ -169,6 +166,10 @@ watch(country, (val) => {
|
|||||||
updateJobs()
|
updateJobs()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(jobs, () => {
|
||||||
|
jobCount.value = jobs.data?.length || 0
|
||||||
|
})
|
||||||
|
|
||||||
const jobTypes = computed(() => {
|
const jobTypes = computed(() => {
|
||||||
return [
|
return [
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Button v-if="canSeeStats()" @click="showVideoStats()">
|
||||||
|
<template #prefix>
|
||||||
|
<TrendingUp class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Video Statistics') }}
|
||||||
|
</Button>
|
||||||
<CertificationLinks :courseName="courseName" />
|
<CertificationLinks :courseName="courseName" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -65,7 +71,7 @@
|
|||||||
<div
|
<div
|
||||||
class="border-r container pt-5 pb-10 px-5 h-full"
|
class="border-r container pt-5 pb-10 px-5 h-full"
|
||||||
:class="{
|
:class="{
|
||||||
'w-full md:w-3/4 mx-auto border-none !pt-10': zenModeEnabled,
|
'w-full md:w-3/5 mx-auto border-none !pt-10': zenModeEnabled,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -100,18 +106,7 @@
|
|||||||
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
<router-link
|
<Button v-if="lesson.data.prev" @click="switchLesson('prev')">
|
||||||
v-if="lesson.data.prev"
|
|
||||||
:to="{
|
|
||||||
name: 'Lesson',
|
|
||||||
params: {
|
|
||||||
courseName: courseName,
|
|
||||||
chapterNumber: lesson.data.prev.split('.')[0],
|
|
||||||
lessonNumber: lesson.data.prev.split('.')[1],
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<ChevronLeft class="w-4 h-4 stroke-1" />
|
<ChevronLeft class="w-4 h-4 stroke-1" />
|
||||||
</template>
|
</template>
|
||||||
@@ -119,7 +114,7 @@
|
|||||||
{{ __('Previous') }}
|
{{ __('Previous') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
|
||||||
<router-link
|
<router-link
|
||||||
v-if="allowEdit()"
|
v-if="allowEdit()"
|
||||||
:to="{
|
:to="{
|
||||||
@@ -135,18 +130,8 @@
|
|||||||
{{ __('Edit') }}
|
{{ __('Edit') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
|
||||||
v-if="lesson.data.next"
|
<Button v-if="lesson.data.next" @click="switchLesson('next')">
|
||||||
:to="{
|
|
||||||
name: 'Lesson',
|
|
||||||
params: {
|
|
||||||
courseName: courseName,
|
|
||||||
chapterNumber: lesson.data.next.split('.')[0],
|
|
||||||
lessonNumber: lesson.data.next.split('.')[1],
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
<ChevronRight class="w-4 h-4 stroke-1" />
|
||||||
</template>
|
</template>
|
||||||
@@ -154,7 +139,7 @@
|
|||||||
{{ __('Next') }}
|
{{ __('Next') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
|
||||||
<router-link
|
<router-link
|
||||||
v-else
|
v-else
|
||||||
:to="{
|
:to="{
|
||||||
@@ -262,13 +247,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<VideoStatistics
|
||||||
|
v-model="showStatsDialog"
|
||||||
|
:lessonName="lesson.data?.name"
|
||||||
|
:lessonTitle="lesson.data?.title"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
createResource,
|
|
||||||
Badge,
|
Badge,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
|
call,
|
||||||
|
createResource,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
@@ -292,15 +283,18 @@ import {
|
|||||||
Focus,
|
Focus,
|
||||||
Info,
|
Info,
|
||||||
MessageCircleQuestion,
|
MessageCircleQuestion,
|
||||||
|
TrendingUp,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import Discussions from '@/components/Discussions.vue'
|
import Discussions from '@/components/Discussions.vue'
|
||||||
import { getEditorTools, enablePlyr } from '@/utils'
|
import { getEditorTools, enablePlyr } from '@/utils'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useSidebar } from '@/stores/sidebar'
|
||||||
import EditorJS from '@editorjs/editorjs'
|
import EditorJS from '@editorjs/editorjs'
|
||||||
import LessonContent from '@/components/LessonContent.vue'
|
import LessonContent from '@/components/LessonContent.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||||
|
import VideoStatistics from '@/components/Modals/VideoStatistics.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
@@ -312,10 +306,13 @@ const instructorEditor = ref(null)
|
|||||||
const lessonProgress = ref(0)
|
const lessonProgress = ref(0)
|
||||||
const lessonContainer = ref(null)
|
const lessonContainer = ref(null)
|
||||||
const zenModeEnabled = ref(false)
|
const zenModeEnabled = ref(false)
|
||||||
|
const showStatsDialog = ref(false)
|
||||||
const hasQuiz = ref(false)
|
const hasQuiz = ref(false)
|
||||||
const discussionsContainer = ref(null)
|
const discussionsContainer = ref(null)
|
||||||
const timer = ref(0)
|
const timer = ref(0)
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
|
const sidebarStore = useSidebar()
|
||||||
|
const plyrSources = ref([])
|
||||||
let timerInterval
|
let timerInterval
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -335,6 +332,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
startTimer()
|
startTimer()
|
||||||
|
sidebarStore.isSidebarCollapsed = true
|
||||||
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
||||||
socket.on('update_lesson_progress', (data) => {
|
socket.on('update_lesson_progress', (data) => {
|
||||||
if (data.course === props.courseName) {
|
if (data.course === props.courseName) {
|
||||||
@@ -357,6 +355,8 @@ const attachFullscreenEvent = () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
|
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
|
||||||
|
sidebarStore.isSidebarCollapsed = false
|
||||||
|
trackVideoWatchDuration()
|
||||||
})
|
})
|
||||||
|
|
||||||
const lesson = createResource({
|
const lesson = createResource({
|
||||||
@@ -452,13 +452,39 @@ const breadcrumbs = computed(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const switchLesson = (direction) => {
|
||||||
|
trackVideoWatchDuration()
|
||||||
|
let lessonIndex =
|
||||||
|
direction === 'prev'
|
||||||
|
? lesson.data.prev.split('.')
|
||||||
|
: lesson.data.next.split('.')
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
name: 'Lesson',
|
||||||
|
params: {
|
||||||
|
courseName: props.courseName,
|
||||||
|
chapterNumber: lessonIndex[0],
|
||||||
|
lessonNumber: lessonIndex[1],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
||||||
(
|
async (
|
||||||
[newChapterNumber, newLessonNumber],
|
[newChapterNumber, newLessonNumber],
|
||||||
[oldChapterNumber, oldLessonNumber]
|
[oldChapterNumber, oldLessonNumber]
|
||||||
) => {
|
) => {
|
||||||
if (newChapterNumber || newLessonNumber) {
|
if (newChapterNumber || newLessonNumber) {
|
||||||
|
plyrSources.value = []
|
||||||
|
await nextTick()
|
||||||
|
resetLessonState(newChapterNumber, newLessonNumber)
|
||||||
|
startTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetLessonState = (newChapterNumber, newLessonNumber) => {
|
||||||
editor.value = null
|
editor.value = null
|
||||||
instructorEditor.value = null
|
instructorEditor.value = null
|
||||||
allowDiscussions.value = false
|
allowDiscussions.value = false
|
||||||
@@ -468,20 +494,115 @@ watch(
|
|||||||
})
|
})
|
||||||
clearInterval(timerInterval)
|
clearInterval(timerInterval)
|
||||||
timer.value = 0
|
timer.value = 0
|
||||||
startTimer()
|
|
||||||
enablePlyr()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trackVideoWatchDuration = () => {
|
||||||
|
if (!lesson.data.membership) return
|
||||||
|
let videoDetails = getVideoDetails()
|
||||||
|
videoDetails = videoDetails.concat(getPlyrSourceDetails())
|
||||||
|
call('lms.lms.api.track_video_watch_duration', {
|
||||||
|
lesson: lesson.data.name,
|
||||||
|
videos: videoDetails,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVideoDetails = () => {
|
||||||
|
let details = []
|
||||||
|
const videos = document.querySelectorAll('video')
|
||||||
|
if (videos.length > 0) {
|
||||||
|
videos.forEach((video) => {
|
||||||
|
if (video.currentTime == video.duration) markProgress()
|
||||||
|
details.push({
|
||||||
|
source: video.src,
|
||||||
|
watch_time: video.currentTime,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlyrSourceDetails = () => {
|
||||||
|
let details = []
|
||||||
|
plyrSources.value.forEach((source) => {
|
||||||
|
if (source.currentTime == source.duration) markProgress()
|
||||||
|
let src = cleanYouTubeUrl(source.source)
|
||||||
|
details.push({
|
||||||
|
source: src,
|
||||||
|
watch_time: source.currentTime,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanYouTubeUrl = (url) => {
|
||||||
|
if (!url) return url
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
urlObj.searchParams.delete('t')
|
||||||
|
return urlObj.toString()
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => lesson.data,
|
() => lesson.data,
|
||||||
(data) => {
|
async (data) => {
|
||||||
setupLesson(data)
|
setupLesson(data)
|
||||||
enablePlyr()
|
getPlyrSource()
|
||||||
|
if (data.icon == 'icon-youtube') clearInterval(timerInterval)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const getPlyrSource = async () => {
|
||||||
|
await nextTick()
|
||||||
|
if (plyrSources.value.length == 0) {
|
||||||
|
plyrSources.value = await enablePlyr()
|
||||||
|
}
|
||||||
|
updateVideoWatchDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVideoWatchDuration = () => {
|
||||||
|
if (lesson.data.videos && lesson.data.videos.length > 0) {
|
||||||
|
lesson.data.videos.forEach((video) => {
|
||||||
|
if (video.source.includes('youtube') || video.source.includes('vimeo')) {
|
||||||
|
updatePlyrVideoTime(video)
|
||||||
|
} else {
|
||||||
|
updateVideoTime(video)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePlyrVideoTime = (video) => {
|
||||||
|
plyrSources.value.forEach((plyrSource) => {
|
||||||
|
let lastWatchedTime = 0
|
||||||
|
let isSeeking = false
|
||||||
|
|
||||||
|
plyrSource.on('ready', () => {
|
||||||
|
if (plyrSource.source === video.source) {
|
||||||
|
plyrSource.embed.seekTo(video.watch_time, true)
|
||||||
|
plyrSource.play()
|
||||||
|
plyrSource.pause()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVideoTime = (video) => {
|
||||||
|
const videos = document.querySelectorAll('video')
|
||||||
|
if (videos.length > 0) {
|
||||||
|
videos.forEach((vid) => {
|
||||||
|
if (vid.src === video.source) {
|
||||||
|
let watch_time = video.watch_time < vid.duration ? video.watch_time : 0
|
||||||
|
if (vid.readyState >= 1) {
|
||||||
|
vid.currentTime = watch_time
|
||||||
|
} else {
|
||||||
|
vid.addEventListener('loadedmetadata', () => {
|
||||||
|
vid.currentTime = watch_time
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const startTimer = () => {
|
const startTimer = () => {
|
||||||
timerInterval = setInterval(() => {
|
timerInterval = setInterval(() => {
|
||||||
timer.value++
|
timer.value++
|
||||||
@@ -548,13 +669,22 @@ const enrollStudent = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canSeeStats = () => {
|
||||||
|
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const showVideoStats = () => {
|
||||||
|
showStatsDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const canGoZen = () => {
|
const canGoZen = () => {
|
||||||
if (
|
if (
|
||||||
user.data?.is_moderator ||
|
user.data?.is_moderator ||
|
||||||
user.data?.is_instructor ||
|
user.data?.is_instructor ||
|
||||||
user.data?.is_evaluator
|
user.data?.is_evaluator
|
||||||
)
|
)
|
||||||
return false
|
return true
|
||||||
if (lesson.data?.membership) return true
|
if (lesson.data?.membership) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ const addInstructorNotes = (data) => {
|
|||||||
const enableAutoSave = () => {
|
const enableAutoSave = () => {
|
||||||
autoSaveInterval = setInterval(() => {
|
autoSaveInterval = setInterval(() => {
|
||||||
saveLesson({ showSuccessMessage: false })
|
saveLesson({ showSuccessMessage: false })
|
||||||
}, 5000)
|
}, 10000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
const keyboardShortcut = (e) => {
|
||||||
@@ -385,8 +385,10 @@ const saveLesson = (e) => {
|
|||||||
showSuccessMessage = true
|
showSuccessMessage = true
|
||||||
}
|
}
|
||||||
editor.value.save().then((outputData) => {
|
editor.value.save().then((outputData) => {
|
||||||
|
outputData = removeEmptyBlocks(outputData)
|
||||||
lesson.content = JSON.stringify(outputData)
|
lesson.content = JSON.stringify(outputData)
|
||||||
instructorEditor.value.save().then((outputData) => {
|
instructorEditor.value.save().then((outputData) => {
|
||||||
|
outputData = removeEmptyBlocks(outputData)
|
||||||
lesson.instructor_content = JSON.stringify(outputData)
|
lesson.instructor_content = JSON.stringify(outputData)
|
||||||
if (lessonDetails.data?.lesson) {
|
if (lessonDetails.data?.lesson) {
|
||||||
editCurrentLesson()
|
editCurrentLesson()
|
||||||
@@ -397,6 +399,14 @@ const saveLesson = (e) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeEmptyBlocks = (outputData) => {
|
||||||
|
let blocks = outputData.blocks.filter((block) => {
|
||||||
|
return Object.keys(block.data).length > 0 || block.type == 'paragraph'
|
||||||
|
})
|
||||||
|
outputData.blocks = blocks
|
||||||
|
return outputData
|
||||||
|
}
|
||||||
|
|
||||||
const createNewLesson = () => {
|
const createNewLesson = () => {
|
||||||
newLessonResource.submit(
|
newLessonResource.submit(
|
||||||
{},
|
{},
|
||||||
@@ -653,6 +663,57 @@ iframe {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ce-popover__container {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__input {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__input::before {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__input:focus {
|
||||||
|
--tw-ring-color: theme('colors.gray.100');
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-popover-item__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-popover-item__icon svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-popover--opened > .ce-popover__container {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__icon svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-block.embed-tool {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--plyr-range-fill-background: white;
|
--plyr-range-fill-background: white;
|
||||||
--plyr-video-control-background-hover: transparent;
|
--plyr-video-control-background-hover: transparent;
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const persona = reactive({
|
|||||||
const submitPersona = () => {
|
const submitPersona = () => {
|
||||||
let responses = {
|
let responses = {
|
||||||
site: user.data?.sitename,
|
site: user.data?.sitename,
|
||||||
no_of_students: persona.noOfStudents,
|
role: persona.role,
|
||||||
use_case: persona.useCase,
|
use_case: persona.useCase,
|
||||||
}
|
}
|
||||||
call('lms.lms.api.capture_user_persona', {
|
call('lms.lms.api.capture_user_persona', {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
v-model="activeTab"
|
v-model="activeTab"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<router-view :profile="profile" />
|
<router-view :profile="profile" :key="profile.data?.name" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EditProfile
|
<EditProfile
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const evaluations = createListResource({
|
|||||||
],
|
],
|
||||||
auto: true,
|
auto: true,
|
||||||
orderBy: 'creation desc',
|
orderBy: 'creation desc',
|
||||||
limit: 100,
|
pageLength: 500,
|
||||||
cache: ['schedule', user.data?.name],
|
cache: ['schedule', user.data?.name],
|
||||||
transform(data) {
|
transform(data) {
|
||||||
return data.map((d) => {
|
return data.map((d) => {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { FormControl, createResource, toast } from 'frappe-ui'
|
import { FormControl, createResource, toast } from 'frappe-ui'
|
||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { convertToTitleCase } from '@/utils'
|
import { convertToTitleCase } from '@/utils'
|
||||||
import { CircleAlert } from 'lucide-vue-next'
|
import { CircleAlert } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -66,10 +66,9 @@ const roles = createResource({
|
|||||||
url: 'lms.lms.utils.get_roles',
|
url: 'lms.lms.utils.get_roles',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
name: props.profile.data?.name,
|
name: values.member,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
let roles = [
|
let roles = [
|
||||||
'moderator',
|
'moderator',
|
||||||
@@ -83,6 +82,16 @@ const roles = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.profile,
|
||||||
|
(newValue) => {
|
||||||
|
roles.reload({
|
||||||
|
member: newValue.data?.name,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const updateRole = createResource({
|
const updateRole = createResource({
|
||||||
url: 'lms.lms.api.save_role',
|
url: 'lms.lms.api.save_role',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -97,7 +106,10 @@ const updateRole = createResource({
|
|||||||
const changeRole = (role) => {
|
const changeRole = (role) => {
|
||||||
updateRole.submit(
|
updateRole.submit(
|
||||||
{
|
{
|
||||||
role: convertToTitleCase(role.split('_').join(' ')),
|
role:
|
||||||
|
role == 'lms_student'
|
||||||
|
? 'LMS Student'
|
||||||
|
: convertToTitleCase(role.split('_').join(' ')),
|
||||||
value: eval(role).value,
|
value: eval(role).value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="show" :options="{ size: '5xl' }">
|
||||||
|
<template #body-title>
|
||||||
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{
|
||||||
|
props.exerciseID === 'new'
|
||||||
|
? __('Create Programming Exercise')
|
||||||
|
: __('Edit Programming Exercise')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="grid grid-cols-2 gap-10">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="exercise.title"
|
||||||
|
:label="__('Title')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="exercise.language"
|
||||||
|
:label="__('Language')"
|
||||||
|
type="select"
|
||||||
|
:options="languageOptions"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<ChildTable
|
||||||
|
v-model="exercise.test_cases"
|
||||||
|
:label="__('Test Cases')"
|
||||||
|
:columns="testCaseColumns"
|
||||||
|
:required="true"
|
||||||
|
:addable="true"
|
||||||
|
:deletable="true"
|
||||||
|
:editable="true"
|
||||||
|
:placeholder="__('Add Test Case')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-2">
|
||||||
|
{{ __('Problem Statement') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="exercise.problem_statement"
|
||||||
|
@change="(val: string) => (exercise.problem_statement = 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] max-h-[21rem] overflow-y-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ close }">
|
||||||
|
<div class="flex justify-end space-x-2 group">
|
||||||
|
<Button
|
||||||
|
v-if="exerciseID != 'new'"
|
||||||
|
@click="deleteExercise(close)"
|
||||||
|
variant="outline"
|
||||||
|
theme="red"
|
||||||
|
class="invisible group-hover:visible"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Trash2 class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Delete') }}
|
||||||
|
</Button>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: {
|
||||||
|
exerciseID: props.exerciseID,
|
||||||
|
submissionID: 'new',
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
<template #prefix>
|
||||||
|
<Play class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Test this Exercise') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'ProgrammingExerciseSubmissions',
|
||||||
|
query: {
|
||||||
|
exercise: props.exerciseID,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
<template #prefix>
|
||||||
|
<ClipboardList class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Check Submission') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button variant="solid" @click="saveExercise(close)">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
TextEditor,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
ProgrammingExercise,
|
||||||
|
ProgrammingExercises,
|
||||||
|
TestCase,
|
||||||
|
} from '@/types/programming-exercise'
|
||||||
|
import ChildTable from '@/components/Controls/ChildTable.vue'
|
||||||
|
import { ClipboardList, Play, Trash2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const exercises = defineModel<ProgrammingExercises>('exercises')
|
||||||
|
|
||||||
|
const exercise = ref<ProgrammingExercise>({
|
||||||
|
title: '',
|
||||||
|
language: 'Python',
|
||||||
|
problem_statement: '',
|
||||||
|
test_cases: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const languageOptions = [
|
||||||
|
{ label: 'Python', value: 'Python' },
|
||||||
|
{ label: 'JavaScript', value: 'JavaScript' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
exerciseID: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
exerciseID: 'new',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.exerciseID,
|
||||||
|
() => {
|
||||||
|
setExerciseData()
|
||||||
|
fetchTestCases()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const setExerciseData = () => {
|
||||||
|
let isNew = true
|
||||||
|
exercises.value?.data.forEach((ex: ProgrammingExercise) => {
|
||||||
|
if (ex.name === props.exerciseID) {
|
||||||
|
isNew = false
|
||||||
|
exercise.value = { ...ex }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
exercise.value = {
|
||||||
|
title: '',
|
||||||
|
language: 'Python',
|
||||||
|
problem_statement: '',
|
||||||
|
test_cases: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCases = createListResource({
|
||||||
|
doctype: 'LMS Test Case',
|
||||||
|
fields: ['input', 'expected_output', 'name'],
|
||||||
|
cache: ['testCases', props.exerciseID],
|
||||||
|
parent: 'LMS Programming Exercise',
|
||||||
|
onSuccess(data: TestCase[]) {
|
||||||
|
exercise.value.test_cases = data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchTestCases = () => {
|
||||||
|
testCases.update({
|
||||||
|
filters: {
|
||||||
|
parent: props.exerciseID,
|
||||||
|
parenttype: 'LMS Programming Exercise',
|
||||||
|
parentfield: 'test_cases',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
testCases.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveExercise = (close: () => void) => {
|
||||||
|
if (props.exerciseID == 'new') createNewExercise(close)
|
||||||
|
else updateExercise(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewExercise = (close: () => void) => {
|
||||||
|
exercises.value?.insert.submit(
|
||||||
|
{
|
||||||
|
...exercise.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
close()
|
||||||
|
exercises.value?.reload()
|
||||||
|
toast.success(__('Programming Exercise created successfully'))
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateExercise = (close: () => void) => {
|
||||||
|
exercises.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
name: props.exerciseID,
|
||||||
|
...exercise.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
close()
|
||||||
|
exercises.value?.reload()
|
||||||
|
toast.success(__('Programming Exercise updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCaseColumns = computed(() => {
|
||||||
|
return ['Input', 'Expected Output']
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteExercise = (close: () => void) => {
|
||||||
|
if (props.exerciseID == 'new') return
|
||||||
|
exercises.value?.delete.submit(props.exerciseID, {
|
||||||
|
onSuccess() {
|
||||||
|
toast.success(__('Programming Exercise deleted successfully'))
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Add a programming exercise to your lesson'),
|
||||||
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: () => {
|
||||||
|
saveExercise()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base">
|
||||||
|
<Link
|
||||||
|
v-model="exercise"
|
||||||
|
doctype="LMS Programming Exercise"
|
||||||
|
:label="__('Select a Programming Exercise')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Dialog } from 'frappe-ui'
|
||||||
|
import { onMounted, nextTick, ref } from 'vue'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
const show = ref(false)
|
||||||
|
const exercise = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
onSave: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
show.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveExercise = () => {
|
||||||
|
props.onSave(exercise.value)
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
v-if="!fromLesson"
|
||||||
|
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
v-if="falconError"
|
||||||
|
class="flex items-center justify-between p-3 text-sm bg-surface-amber-1 text-ink-amber-3"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ falconError }}
|
||||||
|
</span>
|
||||||
|
<Button v-if="user.data?.is_moderator" @click="openSettings('General')">
|
||||||
|
<template #prefix>
|
||||||
|
<Settings class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Settings') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 h-[calc(100vh_-_3rem)]">
|
||||||
|
<div class="border-r py-5 px-8 h-full">
|
||||||
|
<div class="font-semibold mb-2">
|
||||||
|
{{ __('Problem Statement') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-html="exercise.doc?.problem_statement"
|
||||||
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between p-2 bg-surface-gray-2">
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ exercise.doc?.language }}
|
||||||
|
</div>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<Badge
|
||||||
|
v-if="submission.doc?.status"
|
||||||
|
:theme="submission.doc.status == 'Passed' ? 'green' : 'red'"
|
||||||
|
>
|
||||||
|
{{ submission.doc.status }}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
v-if="
|
||||||
|
!falconError &&
|
||||||
|
(submissionID == 'new' ||
|
||||||
|
user.data?.name == submission.doc?.owner)
|
||||||
|
"
|
||||||
|
variant="solid"
|
||||||
|
@click="submitCode"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Play class="size-3" />
|
||||||
|
</template>
|
||||||
|
{{ __('Run') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col space-y-4 pt-5 border-b">
|
||||||
|
<Code
|
||||||
|
v-model="code"
|
||||||
|
:language="exercise.doc?.language.toLowerCase()"
|
||||||
|
height="400px"
|
||||||
|
maxHeight="1000px"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col space-y-1">
|
||||||
|
<span v-if="error" class="text-xs text-ink-gray-5 px-1">
|
||||||
|
{{ __('Compiler Message') }}:
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
v-if="error"
|
||||||
|
v-model="errorMessage"
|
||||||
|
class="font-mono text-ink-red-3 bg-surface-gray-1 border-none text-sm h-32 leading-6"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- <textarea v-else v-model="output" class="bg-surface-gray-1 border-none text-sm h-28 leading-6" readonly /> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="testCaseSection" class="p-5">
|
||||||
|
<span class="text-lg font-semibold text-ink-gray-9">
|
||||||
|
{{ __('Test Cases') }}
|
||||||
|
</span>
|
||||||
|
<div v-if="testCases.length" class="divide-y mt-5">
|
||||||
|
<div
|
||||||
|
v-for="(testCase, index) in testCases"
|
||||||
|
:key="testCase.input"
|
||||||
|
class="py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<span class=""> {{ __('Test {0}').format(index + 1) }} - </span>
|
||||||
|
<span
|
||||||
|
class="font-semibold ml-2 mr-1"
|
||||||
|
:class="
|
||||||
|
testCase.status === 'Passed'
|
||||||
|
? 'text-ink-green-3'
|
||||||
|
: 'text-ink-red-3'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ testCase.status }}
|
||||||
|
</span>
|
||||||
|
<!-- <span v-if="testCase.status === 'Passed'">
|
||||||
|
<Check class="size-4 text-ink-green-3" />
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<X class="size-4 text-ink-red-3" />
|
||||||
|
</span> -->
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between w-[60%]">
|
||||||
|
<div v-if="testCase.input" class="space-y-2">
|
||||||
|
<div class="text-xs text-ink-gray-7">
|
||||||
|
{{ __('Input') }}
|
||||||
|
</div>
|
||||||
|
<div>{{ testCase.input }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs text-ink-gray-7">
|
||||||
|
{{ __('Your Output') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ testCase.output }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs text-ink-gray-7">
|
||||||
|
{{ __('Expected Output') }}
|
||||||
|
</div>
|
||||||
|
<div>{{ testCase.expected_output }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-ink-gray-6 mt-4">
|
||||||
|
{{ __('Please run the code to execute the test cases.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
call,
|
||||||
|
createDocumentResource,
|
||||||
|
toast,
|
||||||
|
usePageMeta,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||||
|
import { Play, X, Check, Settings } from 'lucide-vue-next'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { openSettings } from '@/utils'
|
||||||
|
|
||||||
|
const user = inject<any>('$user')
|
||||||
|
const code = ref<string | null>('')
|
||||||
|
const output = ref<string | null>(null)
|
||||||
|
const error = ref<boolean | null>(null)
|
||||||
|
const errorMessage = ref<string | null>(null)
|
||||||
|
const testCaseSection = ref<HTMLElement | null>(null)
|
||||||
|
const testCases = ref<TestCase[]>([])
|
||||||
|
const boilerplate = ref<string>('')
|
||||||
|
const { brand, livecodeURL } = sessionStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const fromLesson = ref(false)
|
||||||
|
const falconURL = ref<string>('https://falcon.frappe.io/')
|
||||||
|
const falconError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
exerciseID: string
|
||||||
|
submissionID?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
submissionID: 'new',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadFalcon()
|
||||||
|
checkIfUserIsPermitted()
|
||||||
|
checkIfInLesson()
|
||||||
|
fetchSubmission()
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkIfInLesson = () => {
|
||||||
|
if (new URLSearchParams(window.location.search).get('fromLesson')) {
|
||||||
|
fromLesson.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSubmission = (name: string = '') => {
|
||||||
|
if (name) {
|
||||||
|
submission.name = name
|
||||||
|
submission.reload()
|
||||||
|
} else if (props.submissionID != 'new') {
|
||||||
|
submission.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exercise = createDocumentResource({
|
||||||
|
doctype: 'LMS Programming Exercise',
|
||||||
|
name: props.exerciseID,
|
||||||
|
cache: ['programmingExercise', props.exerciseID],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const submission = createDocumentResource({
|
||||||
|
doctype: 'LMS Programming Exercise Submission',
|
||||||
|
name: props.submissionID,
|
||||||
|
auto: false,
|
||||||
|
onError(error: any) {
|
||||||
|
if (error.messages?.[0].includes('not found')) {
|
||||||
|
router.push({
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: { exerciseID: props.exerciseID, submissionID: 'new' },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.error(__(error.messages?.[0] || error))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(exercise, () => {
|
||||||
|
updateCode()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateCode = (submissionCode = '') => {
|
||||||
|
updateBoilerPlate()
|
||||||
|
if (!code.value?.includes(boilerplate.value)) {
|
||||||
|
code.value = `${boilerplate.value}${code.value}`
|
||||||
|
}
|
||||||
|
if (submissionCode && !code.value?.includes(submissionCode)) {
|
||||||
|
code.value = `${code.value}${submissionCode}`
|
||||||
|
} else if (!submissionCode && !code.value) {
|
||||||
|
code.value = boilerplate.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBoilerPlate = () => {
|
||||||
|
if (exercise.doc?.language == 'Python') {
|
||||||
|
boilerplate.value = `with open("stdin", "r") as f:\n data = f.read()\n\ninputs = data.split() if len(data) else []\n\n# inputs is a list of strings\n# write your code below\n\n`
|
||||||
|
} else if (exercise.doc?.language == 'JavaScript') {
|
||||||
|
boilerplate.value = `const fs = require('fs');\n\nlet input = fs.readFileSync('/app/stdin', 'utf8').trim();\nconst inputs = input.split("\\n");\n// inputs is an array of strings\n// write your code below\n`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkIfUserIsPermitted = (doc: any = null) => {
|
||||||
|
if (!user.data) {
|
||||||
|
window.location.href = `/login?redirect-to=/lms/programming-exercises/${props.exerciseID}/submission/${props.submissionID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc) return
|
||||||
|
if (
|
||||||
|
doc.owner != user.data?.name &&
|
||||||
|
!user.data?.is_instructor &&
|
||||||
|
!user.data?.is_moderator &&
|
||||||
|
!user.data.is_evaluator
|
||||||
|
) {
|
||||||
|
router.push({
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: { exerciseID: props.exerciseID, submissionID: 'new' },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTestCases = (doc: any) => {
|
||||||
|
if (testCases.value.length === 0) {
|
||||||
|
testCases.value = doc.test_cases || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => submission.doc,
|
||||||
|
(doc) => {
|
||||||
|
if (doc) {
|
||||||
|
checkIfUserIsPermitted(doc)
|
||||||
|
updateTestCases(doc)
|
||||||
|
updateCode(doc.code)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const loadFalcon = () => {
|
||||||
|
if (livecodeURL.data) {
|
||||||
|
falconURL.value = livecodeURL.data
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = `${falconURL.value}static/livecode.js`
|
||||||
|
script.onload = resolve
|
||||||
|
script.onerror = reject
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCode = async () => {
|
||||||
|
await runCode()
|
||||||
|
createSubmission()
|
||||||
|
}
|
||||||
|
|
||||||
|
const runCode = async () => {
|
||||||
|
if (!exercise.doc?.test_cases?.length) return
|
||||||
|
|
||||||
|
testCases.value = []
|
||||||
|
if (testCaseSection.value) {
|
||||||
|
testCaseSection.value.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const test_case of exercise.doc.test_cases) {
|
||||||
|
let result = await execute(test_case.input)
|
||||||
|
if (error.value) {
|
||||||
|
errorMessage.value = result
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
output.value = result
|
||||||
|
}
|
||||||
|
let status =
|
||||||
|
result.trim() === test_case.expected_output.trim() ? 'Passed' : 'Failed'
|
||||||
|
testCases.value.push({
|
||||||
|
input: test_case.input,
|
||||||
|
output: result,
|
||||||
|
expected_output: test_case.expected_output,
|
||||||
|
status: status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSubmission = () => {
|
||||||
|
if (!testCases.value.length) return
|
||||||
|
let codeToSave = code.value?.replace(boilerplate.value, '') || ''
|
||||||
|
|
||||||
|
call('lms.lms.api.create_programming_exercise_submission', {
|
||||||
|
exercise: props.exerciseID,
|
||||||
|
submission: props.submissionID,
|
||||||
|
code: codeToSave,
|
||||||
|
test_cases: testCases.value,
|
||||||
|
})
|
||||||
|
.then((data: any) => {
|
||||||
|
if (props.submissionID == 'new') {
|
||||||
|
router.push({
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: { exerciseID: props.exerciseID, submissionID: data },
|
||||||
|
})
|
||||||
|
fetchSubmission(data)
|
||||||
|
} else {
|
||||||
|
fetchSubmission(props.submissionID)
|
||||||
|
}
|
||||||
|
toast.success(__('Submission saved!'))
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error('Error creating submission:', error)
|
||||||
|
toast.error(
|
||||||
|
__('Failed to submit. Please try again. {0}').format({ error })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const execute = (stdin = ''): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let outputChunks: string[] = []
|
||||||
|
let hasExited = false
|
||||||
|
let hasError = false
|
||||||
|
|
||||||
|
let session = new LiveCodeSession({
|
||||||
|
base_url: falconURL.value,
|
||||||
|
runtime: exercise.doc?.language.toLowerCase() || 'python',
|
||||||
|
code: code.value,
|
||||||
|
files: [{ filename: 'stdin', contents: stdin }],
|
||||||
|
onMessage: (msg: any) => {
|
||||||
|
console.log('msg', msg)
|
||||||
|
|
||||||
|
if (msg.msgtype === 'write' && msg.file === 'stdout') {
|
||||||
|
outputChunks.push(msg.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.msgtype === 'write' && msg.file === 'stderr') {
|
||||||
|
hasError = true
|
||||||
|
errorMessage.value = msg.data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.msgtype === 'exitstatus') {
|
||||||
|
hasExited = true
|
||||||
|
if (msg.exitstatus !== 0) {
|
||||||
|
error.value = true
|
||||||
|
} else {
|
||||||
|
error.value = false
|
||||||
|
}
|
||||||
|
resolve(outputChunks.join('').trim())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!hasExited) {
|
||||||
|
error.value = true
|
||||||
|
errorMessage.value = 'Execution timed out.'
|
||||||
|
reject('Execution timed out.')
|
||||||
|
}
|
||||||
|
}, 20000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Programming Exercise Submissions'),
|
||||||
|
route: { name: 'ProgrammingExerciseSubmissions' },
|
||||||
|
},
|
||||||
|
{ label: exercise.doc?.title },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
return {
|
||||||
|
title: __('Programming Exercise Submission'),
|
||||||
|
icon: brand.favicon,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.ProseMirror pre {
|
||||||
|
background: theme('colors.gray.200');
|
||||||
|
color: theme('colors.gray.900');
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
</header>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between space-x-32 mb-5">
|
||||||
|
<div class="text-xl font-semibold text-ink-gray-7">
|
||||||
|
{{
|
||||||
|
submissions.data?.length
|
||||||
|
? __('{0} Submissions').format(submissions.data.length)
|
||||||
|
: __('No Submissions')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="submissions.data?.length"
|
||||||
|
class="grid grid-cols-3 gap-5 flex-1"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
doctype="LMS Programming Exercise"
|
||||||
|
v-model="filters.exercise"
|
||||||
|
:placeholder="__('Filter by Exercise')"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
doctype="User"
|
||||||
|
v-model="filters.member"
|
||||||
|
:placeholder="__('Filter by Member')"
|
||||||
|
:readonly="isStudent"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="filters.status"
|
||||||
|
type="select"
|
||||||
|
:options="[
|
||||||
|
{ label: __(''), value: '' },
|
||||||
|
{ label: __('Passed'), value: 'Passed' },
|
||||||
|
{ label: __('Failed'), value: 'Failed' },
|
||||||
|
]"
|
||||||
|
:placeholder="__('Filter by Status')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
v-if="submissions.loading || submissions.data?.length"
|
||||||
|
:columns="submissionColumns"
|
||||||
|
:rows="submissions.data"
|
||||||
|
rowKey="name"
|
||||||
|
:options="{
|
||||||
|
selectable: true,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem
|
||||||
|
:item="item"
|
||||||
|
v-for="item in submissionColumns"
|
||||||
|
:key="item.key"
|
||||||
|
>
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<router-link
|
||||||
|
v-for="row in submissions.data"
|
||||||
|
:to="{
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: {
|
||||||
|
exerciseID: row.exercise,
|
||||||
|
submissionID: row.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<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 v-if="column.key == 'status'">
|
||||||
|
<Badge
|
||||||
|
:theme="row[column.key] === 'Passed' ? 'green' : 'red'"
|
||||||
|
>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="column.key == 'modified'"
|
||||||
|
class="text-sm text-ink-gray-5"
|
||||||
|
>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</router-link>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="deleteExercises(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
<EmptyState v-else type="Programming Exercise Submissions" />
|
||||||
|
<div
|
||||||
|
v-if="submissions.data && submissions.hasNextPage"
|
||||||
|
class="flex justify-center my-5"
|
||||||
|
>
|
||||||
|
<Button @click="submissions.next()">
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
FeatherIcon,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
usePageMeta,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import type {
|
||||||
|
ProgrammingExerciseSubmission,
|
||||||
|
Filters,
|
||||||
|
} from '@/pages/ProgrammingExercises/types'
|
||||||
|
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Trash2 } from 'lucide-vue-next'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import EmptyState from '@/components/EmptyState.vue'
|
||||||
|
|
||||||
|
const { brand } = sessionStore()
|
||||||
|
const dayjs = inject('$dayjs') as any
|
||||||
|
const user = inject('$user') as any
|
||||||
|
const filterFields = ['exercise', 'member', 'status']
|
||||||
|
const filters = ref<Filters>({
|
||||||
|
exercise: '',
|
||||||
|
member: '',
|
||||||
|
status: '',
|
||||||
|
})
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setFiltersFromRoute()
|
||||||
|
fetchBasedOnRole()
|
||||||
|
})
|
||||||
|
|
||||||
|
const setFiltersFromRoute = () => {
|
||||||
|
filterFields.forEach((field) => {
|
||||||
|
if (router.currentRoute.value.query[field]) {
|
||||||
|
filters.value[field as keyof Filters] = router.currentRoute.value.query[
|
||||||
|
field
|
||||||
|
] as string
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchBasedOnRole = () => {
|
||||||
|
if (isStudent.value) {
|
||||||
|
filters.value['member'] = user.data?.name
|
||||||
|
} else {
|
||||||
|
submissions.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissions = createListResource({
|
||||||
|
doctype: 'LMS Programming Exercise Submission',
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'exercise',
|
||||||
|
'exercise_title',
|
||||||
|
'member_name',
|
||||||
|
'member_image',
|
||||||
|
'status',
|
||||||
|
'modified',
|
||||||
|
],
|
||||||
|
orderBy: 'modified desc',
|
||||||
|
transform(data: ProgrammingExercise[]) {
|
||||||
|
return data.map((submission: ProgrammingExerciseSubmission) => {
|
||||||
|
return {
|
||||||
|
...submission,
|
||||||
|
modified: dayjs(submission.modified).fromNow(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(filters.value, () => {
|
||||||
|
let filtersToApply: Record<string, any> = {}
|
||||||
|
filterFields.forEach((field) => {
|
||||||
|
if (filters.value[field as keyof Filters]) {
|
||||||
|
filtersToApply[field] = filters.value[field as keyof Filters]
|
||||||
|
router.push({
|
||||||
|
query: {
|
||||||
|
...router.currentRoute.value.query,
|
||||||
|
[field]: filters.value[field as keyof Filters],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
delete filtersToApply[field]
|
||||||
|
const query = { ...router.currentRoute.value.query }
|
||||||
|
delete query[field]
|
||||||
|
router.push({
|
||||||
|
query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
submissions.update({
|
||||||
|
filters: {
|
||||||
|
...filtersToApply,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
submissions.reload()
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteExercises = (selections: Set<string>, unselectAll: () => void) => {
|
||||||
|
Array.from(selections).forEach(async (submission: string) => {
|
||||||
|
await submissions.delete.submit(submission)
|
||||||
|
})
|
||||||
|
unselectAll()
|
||||||
|
toast.success(__('Submissions deleted successfully'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStudent = computed(() => {
|
||||||
|
return (
|
||||||
|
!user.data?.is_instructor &&
|
||||||
|
!user.data?.is_moderator &&
|
||||||
|
!user.data?.is_evaluator
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const submissionColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Member'),
|
||||||
|
key: 'member_name',
|
||||||
|
width: '30%',
|
||||||
|
icon: 'user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Exercise'),
|
||||||
|
key: 'exercise_title',
|
||||||
|
width: '30%',
|
||||||
|
icon: 'code',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Status'),
|
||||||
|
key: 'status',
|
||||||
|
width: '20%',
|
||||||
|
icon: 'check-circle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Modified'),
|
||||||
|
key: 'modified',
|
||||||
|
width: '15%',
|
||||||
|
icon: 'clock',
|
||||||
|
align: 'right',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Programming Exercise Submissions'),
|
||||||
|
route: {
|
||||||
|
name: 'ProgrammingExerciseSubmissions',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
return {
|
||||||
|
title: __('Programming Exercises'),
|
||||||
|
icon: brand.favicon,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
171
frontend/src/pages/ProgrammingExercises/ProgrammingExercises.vue
Normal file
171
frontend/src/pages/ProgrammingExercises/ProgrammingExercises.vue
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
<div class="space-x-2">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'ProgrammingExerciseSubmissions',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
<template #prefix>
|
||||||
|
<ClipboardList class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Check All Submissions') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button
|
||||||
|
v-if="!readOnlyMode"
|
||||||
|
variant="solid"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
exerciseID = 'new'
|
||||||
|
showForm = true
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Create') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="md:w-4/5 md:mx-auto p-5">
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div v-if="exerciseCount" class="text-lg font-semibold text-ink-gray-9">
|
||||||
|
{{ __('{0} Exercises').format(exerciseCount) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="exercises.data?.length || exerciseCount > 0"
|
||||||
|
class="grid grid-cols-2 gap-5"
|
||||||
|
>
|
||||||
|
<!-- <FormControl
|
||||||
|
v-model="titleFilter"
|
||||||
|
:placeholder="__('Search by title')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="typeFilter"
|
||||||
|
type="select"
|
||||||
|
:options="assignmentTypes"
|
||||||
|
:placeholder="__('Type')"
|
||||||
|
/> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="exercises.data?.length"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="exercise in exercises.data"
|
||||||
|
:key="exercise.name"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
exerciseID = exercise.name
|
||||||
|
showForm = true
|
||||||
|
}
|
||||||
|
"
|
||||||
|
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3 space-y-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div class="text-lg font-semibold text-ink-gray-9">
|
||||||
|
{{ exercise.title }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-ink-gray-7">
|
||||||
|
{{ exercise.language }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EmptyState v-else type="Programming Exercises" />
|
||||||
|
<div
|
||||||
|
v-if="exercises.data && exercises.hasNextPage"
|
||||||
|
class="flex justify-center my-5"
|
||||||
|
>
|
||||||
|
<Button @click="exercises.next()">
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProgrammingExerciseForm
|
||||||
|
v-model="showForm"
|
||||||
|
:exerciseID="exerciseID"
|
||||||
|
v-model:exercises="exercises"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
call,
|
||||||
|
createListResource,
|
||||||
|
usePageMeta,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { ClipboardList, Plus } from 'lucide-vue-next'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import ProgrammingExerciseForm from '@/pages/ProgrammingExercises/ProgrammingExerciseForm.vue'
|
||||||
|
|
||||||
|
const exerciseCount = ref<number>(0)
|
||||||
|
const readOnlyMode = window.read_only_mode
|
||||||
|
const { brand } = sessionStore()
|
||||||
|
const showForm = ref<boolean>(false)
|
||||||
|
const exerciseID = ref<string | null>('new')
|
||||||
|
const user = inject<any>('$user')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
validatePermissions()
|
||||||
|
getExerciseCount()
|
||||||
|
})
|
||||||
|
|
||||||
|
const validatePermissions = () => {
|
||||||
|
if (
|
||||||
|
!user.data?.is_instructor &&
|
||||||
|
!user.data?.is_moderator &&
|
||||||
|
!user.data?.is_evaluator
|
||||||
|
) {
|
||||||
|
router.push({
|
||||||
|
name: 'ProgrammingExerciseSubmissions',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExerciseCount = () => {
|
||||||
|
call('frappe.client.get_count', {
|
||||||
|
doctype: 'LMS Programming Exercise',
|
||||||
|
})
|
||||||
|
.then((count: number) => {
|
||||||
|
exerciseCount.value = count
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error('Error fetching exercise count:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const exercises = createListResource({
|
||||||
|
doctype: 'LMS Programming Exercise',
|
||||||
|
cache: ['programmingExercises'],
|
||||||
|
fields: ['name', 'title', 'language', 'problem_statement'],
|
||||||
|
auto: true,
|
||||||
|
orderBy: 'modified desc',
|
||||||
|
})
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
return {
|
||||||
|
title: __('Programming Exercises'),
|
||||||
|
icon: brand.favicon,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Programming Exercises'),
|
||||||
|
route: { name: 'ProgrammingExercises' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
47
frontend/src/pages/ProgrammingExercises/types.ts
Normal file
47
frontend/src/pages/ProgrammingExercises/types.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
interface ProgrammingExercise {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
language: 'Python' | 'JavaScript';
|
||||||
|
test_cases_count: number;
|
||||||
|
problem_statement: string;
|
||||||
|
test_cases: [TestCase];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestCase {
|
||||||
|
name: string;
|
||||||
|
input: string;
|
||||||
|
expected_output: string;
|
||||||
|
output: string;
|
||||||
|
status: 'Passed' | 'Failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
type Filters = {
|
||||||
|
exercise?: string,
|
||||||
|
member?: string,
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgrammingExercises = {
|
||||||
|
data: ProgrammingExercise[]
|
||||||
|
reload: () => void
|
||||||
|
hasNextPage: boolean
|
||||||
|
next: () => void
|
||||||
|
setValue: {
|
||||||
|
submit: (
|
||||||
|
data: ProgrammingExercise,
|
||||||
|
options?: { onSuccess?: () => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
insert: {
|
||||||
|
submit: (
|
||||||
|
data: ProgrammingExercise,
|
||||||
|
options?: { onSuccess?: () => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
delete: {
|
||||||
|
submit: (
|
||||||
|
name: string,
|
||||||
|
options?: { onSuccess?: () => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,30 +4,39 @@
|
|||||||
>
|
>
|
||||||
<Breadcrumbs :items="breadcrumbs" />
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
<div v-if="!readOnlyMode" class="space-x-2">
|
<div v-if="!readOnlyMode" class="space-x-2">
|
||||||
|
<Badge v-if="quizDetails.isDirty" theme="orange">
|
||||||
|
{{ __('Not Saved') }}
|
||||||
|
</Badge>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="quizDetails.data?.name"
|
v-if="quizDetails.doc?.name"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'QuizPage',
|
name: 'QuizPage',
|
||||||
params: {
|
params: {
|
||||||
quizID: quizDetails.data.name,
|
quizID: quizDetails.doc.name,
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button>
|
<Button>
|
||||||
{{ __('Open') }}
|
<template #prefix>
|
||||||
|
<ListChecks class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Test Quiz') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="quizDetails.data?.name"
|
v-if="quizDetails.doc?.name"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'QuizSubmissionList',
|
name: 'QuizSubmissionList',
|
||||||
params: {
|
params: {
|
||||||
quizID: quizDetails.data.name,
|
quizID: quizDetails.doc.name,
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button>
|
<Button>
|
||||||
{{ __('Submission List') }}
|
<template #prefix>
|
||||||
|
<ClipboardList class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Check Submissions') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<Button variant="solid" @click="submitQuiz()">
|
<Button variant="solid" @click="submitQuiz()">
|
||||||
@@ -35,85 +44,90 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="w-3/4 mx-auto py-5">
|
<div v-if="quizDetails.doc" class="py-5">
|
||||||
<!-- Details -->
|
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
||||||
<div class="mb-8">
|
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||||
<div class="font-semibold text-ink-gray-9 mb-4">
|
|
||||||
{{ __('Details') }}
|
{{ __('Details') }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-5">
|
||||||
|
<div class="space-y-5">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="quiz.title"
|
v-model="quizDetails.doc.title"
|
||||||
:label="
|
:label="__('Title')"
|
||||||
quizDetails.data?.name
|
|
||||||
? __('Title')
|
|
||||||
: __('Enter a title and save the quiz to proceed')
|
|
||||||
"
|
|
||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
<div v-if="quizDetails.data?.name">
|
|
||||||
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
|
|
||||||
<FormControl
|
<FormControl
|
||||||
type="number"
|
type="number"
|
||||||
v-model="quiz.max_attempts"
|
v-model="quizDetails.doc.max_attempts"
|
||||||
:label="__('Maximum Attempts')"
|
:label="__('Maximum Attempts')"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
type="number"
|
type="number"
|
||||||
v-model="quiz.duration"
|
v-model="quizDetails.doc.duration"
|
||||||
:label="__('Duration (in minutes)')"
|
:label="__('Duration (in minutes)')"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="quiz.total_marks"
|
v-model="quizDetails.doc.total_marks"
|
||||||
:label="__('Total Marks')"
|
:label="__('Total Marks')"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="quiz.passing_percentage"
|
v-model="quizDetails.doc.passing_percentage"
|
||||||
:label="__('Passing Percentage')"
|
:label="__('Passing Percentage')"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Settings -->
|
</div>
|
||||||
<div class="mb-8">
|
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
||||||
<div class="font-semibold text-ink-gray-9 mb-4">
|
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||||
{{ __('Settings') }}
|
{{ __('Settings') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-5 my-4">
|
<div class="grid grid-cols-3 gap-5">
|
||||||
|
<div class="flex flex-col space-y-10">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="quiz.show_answers"
|
v-model="quizDetails.doc.show_answers"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:label="__('Show Answers')"
|
:label="__('Show Answers')"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="quiz.show_submission_history"
|
v-model="quizDetails.doc.show_submission_history"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:label="__('Show Submission History')"
|
:label="__('Show Submission History')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex flex-col space-y-5">
|
||||||
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="font-semibold text-ink-gray-9 mb-4">
|
|
||||||
{{ __('Shuffle Settings') }}
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-3">
|
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="quiz.shuffle_questions"
|
v-model="quizDetails.doc.shuffle_questions"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:label="__('Shuffle Questions')"
|
:label="__('Shuffle Questions')"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-if="quiz.shuffle_questions"
|
v-if="quizDetails.doc.shuffle_questions"
|
||||||
v-model="quiz.limit_questions_to"
|
v-model="quizDetails.doc.limit_questions_to"
|
||||||
:label="__('Limit Questions To')"
|
:label="__('Limit Questions To')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col space-y-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="quizDetails.doc.enable_negative_marking"
|
||||||
|
type="checkbox"
|
||||||
|
:label="__('Enable Negative Marking')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-if="quizDetails.doc.enable_negative_marking"
|
||||||
|
v-model="quizDetails.doc.marks_to_cut"
|
||||||
|
:label="__('Marks to Cut')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Questions -->
|
<div class="px-20 pb-5 space-y-5 mb-5">
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="font-semibold text-ink-gray-9">
|
<div class="text-lg font-semibold text-ink-gray-9">
|
||||||
{{ __('Questions') }}
|
{{ __('Questions') }}
|
||||||
</div>
|
</div>
|
||||||
<Button v-if="!readOnlyMode" @click="openQuestionModal()">
|
<Button v-if="!readOnlyMode" @click="openQuestionModal()">
|
||||||
@@ -124,8 +138,9 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
|
v-if="questions.length"
|
||||||
:columns="questionColumns"
|
:columns="questionColumns"
|
||||||
:rows="quiz.questions"
|
:rows="questions"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
:options="{
|
:options="{
|
||||||
showTooltip: false,
|
showTooltip: false,
|
||||||
@@ -140,7 +155,7 @@
|
|||||||
<ListRow
|
<ListRow
|
||||||
:row="row"
|
:row="row"
|
||||||
v-slot="{ idx, column, item }"
|
v-slot="{ idx, column, item }"
|
||||||
v-for="row in quiz.questions"
|
v-for="row in questions"
|
||||||
@click="openQuestionModal(row)"
|
@click="openQuestionModal(row)"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -169,10 +184,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</ListSelectBanner>
|
</ListSelectBanner>
|
||||||
</ListView>
|
</ListView>
|
||||||
|
<div v-else class="text-ink-gray-6 text-sm">
|
||||||
|
{{ __('No questions added yet') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Question
|
<Question
|
||||||
v-model="showQuestionModal"
|
v-model="showQuestionModal"
|
||||||
:questionDetail="currentQuestion"
|
:questionDetail="currentQuestion"
|
||||||
@@ -199,6 +216,8 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
toast,
|
toast,
|
||||||
|
createDocumentResource,
|
||||||
|
Badge,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
@@ -210,8 +229,7 @@ import {
|
|||||||
watch,
|
watch,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import Question from '@/components/Modals/Question.vue'
|
import Question from '@/components/Modals/Question.vue'
|
||||||
|
|
||||||
@@ -233,18 +251,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const quiz = reactive({
|
const questions = ref([])
|
||||||
title: '',
|
|
||||||
total_marks: 0,
|
|
||||||
passing_percentage: 0,
|
|
||||||
max_attempts: 0,
|
|
||||||
duration: 0,
|
|
||||||
limit_questions_to: 0,
|
|
||||||
show_answers: true,
|
|
||||||
show_submission_history: false,
|
|
||||||
shuffle_questions: false,
|
|
||||||
questions: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (
|
if (
|
||||||
@@ -280,86 +287,26 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const quizDetails = createResource({
|
const quizDetails = createDocumentResource({
|
||||||
url: 'frappe.client.get',
|
|
||||||
makeParams(values) {
|
|
||||||
return { doctype: 'LMS Quiz', name: props.quizID }
|
|
||||||
},
|
|
||||||
auto: false,
|
|
||||||
onSuccess(data) {
|
|
||||||
Object.keys(data).forEach((key) => {
|
|
||||||
if (Object.hasOwn(quiz, key)) quiz[key] = data[key]
|
|
||||||
})
|
|
||||||
|
|
||||||
let checkboxes = [
|
|
||||||
'show_answers',
|
|
||||||
'show_submission_history',
|
|
||||||
'shuffle_questions',
|
|
||||||
]
|
|
||||||
for (let idx in checkboxes) {
|
|
||||||
let key = checkboxes[idx]
|
|
||||||
quiz[key] = quiz[key] ? true : false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const quizCreate = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
auto: false,
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'LMS Quiz',
|
doctype: 'LMS Quiz',
|
||||||
...quiz,
|
name: props.quizID,
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const quizUpdate = createResource({
|
|
||||||
url: 'frappe.client.set_value',
|
|
||||||
auto: false,
|
auto: false,
|
||||||
makeParams(values) {
|
onSuccess(doc) {
|
||||||
return {
|
if (doc.questions && doc.questions.length > 0) {
|
||||||
doctype: 'LMS Quiz',
|
questions.value = doc.questions.map((question) => question)
|
||||||
name: values.quizID,
|
|
||||||
fieldname: {
|
|
||||||
total_marks: calculateTotalMarks(),
|
|
||||||
...quiz,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitQuiz = () => {
|
const submitQuiz = () => {
|
||||||
if (quizDetails.data?.name) updateQuiz()
|
quizDetails.setValue.submit(
|
||||||
else createQuiz()
|
{
|
||||||
}
|
...quizDetails.doc,
|
||||||
|
total_marks: calculateTotalMarks(),
|
||||||
const createQuiz = () => {
|
},
|
||||||
quizCreate.submit(
|
|
||||||
{},
|
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
toast.success(__('Quiz created successfully'))
|
quizDetails.doc.total_marks = data.total_marks
|
||||||
router.push({
|
|
||||||
name: 'QuizForm',
|
|
||||||
params: { quizID: data.name },
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
toast.error(err.messages?.[0] || err)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateQuiz = () => {
|
|
||||||
quizUpdate.submit(
|
|
||||||
{ quizID: quizDetails.data?.name },
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
quiz.total_marks = data.total_marks
|
|
||||||
toast.success(__('Quiz updated successfully'))
|
toast.success(__('Quiz updated successfully'))
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
@@ -371,9 +318,15 @@ const updateQuiz = () => {
|
|||||||
|
|
||||||
const calculateTotalMarks = () => {
|
const calculateTotalMarks = () => {
|
||||||
let totalMarks = 0
|
let totalMarks = 0
|
||||||
if (quiz.limit_questions_to && quiz.questions.length > 0)
|
if (
|
||||||
return quiz.questions[0].marks * quiz.limit_questions_to
|
quizDetails.doc?.limit_questions_to &&
|
||||||
quiz.questions.forEach((question) => {
|
quizDetails.doc?.questions.length > 0
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
quizDetails.doc.questions[0].marks * quizDetails.doc.limit_questions_to
|
||||||
|
)
|
||||||
|
|
||||||
|
quizDetails.doc?.questions.forEach((question) => {
|
||||||
totalMarks += question.marks
|
totalMarks += question.marks
|
||||||
})
|
})
|
||||||
return totalMarks
|
return totalMarks
|
||||||
@@ -448,7 +401,7 @@ const breadcrumbs = computed(() => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
|
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.doc?.title,
|
||||||
route: { name: 'QuizForm', params: { quizID: props.quizID } },
|
route: { name: 'QuizForm', params: { quizID: props.quizID } },
|
||||||
})
|
})
|
||||||
return crumbs
|
return crumbs
|
||||||
@@ -456,7 +409,7 @@ const breadcrumbs = computed(() => {
|
|||||||
|
|
||||||
usePageMeta(() => {
|
usePageMeta(() => {
|
||||||
return {
|
return {
|
||||||
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
|
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.doc?.title,
|
||||||
icon: brand.favicon,
|
icon: brand.favicon,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,37 +3,42 @@
|
|||||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||||
>
|
>
|
||||||
<Breadcrumbs :items="breadcrumbs" />
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
<router-link
|
<Button v-if="!readOnlyMode" variant="solid" @click="showForm = true">
|
||||||
v-if="!readOnlyMode"
|
|
||||||
:to="{
|
|
||||||
name: 'QuizForm',
|
|
||||||
params: {
|
|
||||||
quizID: 'new',
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button variant="solid">
|
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New Quiz') }}
|
{{ __('Create') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
|
||||||
</header>
|
</header>
|
||||||
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
<div class="py-5 mx-5">
|
||||||
<div v-if="quizCount" class="text-xl font-semibold text-ink-gray-7 mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
{{ __('{0} Quizzes').format(quizCount) }}
|
<div class="text-lg font-semibold text-ink-gray-7">
|
||||||
|
{{
|
||||||
|
quizzes.data?.length
|
||||||
|
? __('{0} Quizzes').format(quizzes.data.length)
|
||||||
|
: __('No Quizzes')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<FormControl v-model="search" type="text" placeholder="Search">
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="search" class="size-4 text-ink-gray-5" />
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
|
v-if="quizzes.data?.length"
|
||||||
:columns="quizColumns"
|
:columns="quizColumns"
|
||||||
:rows="quizzes.data"
|
:rows="quizzes.data"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
:options="{ showTooltip: false, selectable: false }"
|
:options="{ showTooltip: false, selectable: true }"
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
>
|
>
|
||||||
<ListHeaderItem :item="item" v-for="item in quizColumns">
|
<ListHeaderItem :item="item" v-for="item in quizColumns">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
</ListHeaderItem>
|
</ListHeaderItem>
|
||||||
</ListHeader>
|
</ListHeader>
|
||||||
<ListRows>
|
<ListRows>
|
||||||
@@ -46,72 +51,176 @@
|
|||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<ListRow :row="row" />
|
<ListRow :row="row">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<div v-if="column.key == 'show_answers'">
|
||||||
|
<FormControl
|
||||||
|
type="checkbox"
|
||||||
|
v-model="row[column.key]"
|
||||||
|
:disabled="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="column.key == 'modified'"
|
||||||
|
class="text-xs text-ink-gray-5"
|
||||||
|
>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
</router-link>
|
</router-link>
|
||||||
</ListRows>
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="deleteQuiz(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<FeatherIcon name="trash-2" class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
</ListView>
|
</ListView>
|
||||||
<div class="flex justify-center my-5">
|
<EmptyState v-else type="Quizzes" />
|
||||||
<Button v-if="quizzes.hasNextPage" @click="quizzes.next()">
|
<div v-if="quizzes.hasNextPage" class="flex justify-center my-5">
|
||||||
|
<Button @click="quizzes.next()">
|
||||||
{{ __('Load More') }}
|
{{ __('Load More') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState v-else type="Quizzes" />
|
<Dialog
|
||||||
|
v-model="showForm"
|
||||||
|
:options="{
|
||||||
|
title: __('Create a Quiz'),
|
||||||
|
size: 'sm',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick({ close }) {
|
||||||
|
insertQuiz(close)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<FormControl v-model="title" :label="__('Title')" type="text" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
call,
|
|
||||||
createListResource,
|
createListResource,
|
||||||
|
Dialog,
|
||||||
|
FeatherIcon,
|
||||||
|
FormControl,
|
||||||
ListView,
|
ListView,
|
||||||
ListRows,
|
ListRows,
|
||||||
ListRow,
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
ListHeader,
|
ListHeader,
|
||||||
ListHeaderItem,
|
ListHeaderItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
toast,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { computed, inject, onMounted, ref } from 'vue'
|
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { Plus } from 'lucide-vue-next'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import EmptyState from '@/components/EmptyState.vue'
|
import EmptyState from '@/components/EmptyState.vue'
|
||||||
|
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const quizCount = ref(0)
|
const search = ref('')
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
|
const quizFilters = ref({})
|
||||||
|
const showForm = ref(false)
|
||||||
|
const title = ref('')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||||
router.push({ name: 'Courses' })
|
router.push({ name: 'Courses' })
|
||||||
|
} else if (!user.data?.is_moderator) {
|
||||||
|
quizFilters.value['owner'] = user.data?.name
|
||||||
}
|
}
|
||||||
getQuizCount()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const quizFilter = computed(() => {
|
watch(search, () => {
|
||||||
if (user.data?.is_moderator) return {}
|
quizFilters.value['title'] = ['like', `%${search.value}%`]
|
||||||
return {
|
quizzes.update({
|
||||||
owner: user.data?.name,
|
filters: quizFilters.value,
|
||||||
}
|
})
|
||||||
|
quizzes.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
const quizzes = createListResource({
|
const quizzes = createListResource({
|
||||||
doctype: 'LMS Quiz',
|
doctype: 'LMS Quiz',
|
||||||
filters: quizFilter,
|
filters: quizFilters,
|
||||||
fields: ['name', 'title', 'passing_percentage', 'total_marks'],
|
fields: [
|
||||||
|
'name',
|
||||||
|
'title',
|
||||||
|
'passing_percentage',
|
||||||
|
'total_marks',
|
||||||
|
'show_answers',
|
||||||
|
'max_attempts',
|
||||||
|
'modified',
|
||||||
|
],
|
||||||
auto: true,
|
auto: true,
|
||||||
cache: ['quizzes', user.data?.name],
|
cache: ['quizzes', user.data?.name],
|
||||||
orderBy: 'modified desc',
|
orderBy: 'modified desc',
|
||||||
|
transform(data) {
|
||||||
|
return data.map((quiz) => {
|
||||||
|
return {
|
||||||
|
...quiz,
|
||||||
|
modified: dayjs(quiz.modified).fromNow(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const getQuizCount = () => {
|
const insertQuiz = (close) => {
|
||||||
call('frappe.client.get_count', {
|
quizzes.insert.submit(
|
||||||
doctype: 'LMS Quiz',
|
{
|
||||||
}).then((data) => {
|
title: title.value,
|
||||||
quizCount.value = data
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
toast.success(__('Quiz created successfully'))
|
||||||
|
close()
|
||||||
|
title.value = ''
|
||||||
|
router.push({
|
||||||
|
name: 'QuizForm',
|
||||||
|
params: {
|
||||||
|
quizID: data.name,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast.error(__('Error creating quiz: {0}', error.message))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteQuiz = (selections, unselectAll) => {
|
||||||
|
Array.from(selections).forEach(async (quizName) => {
|
||||||
|
await quizzes.delete.submit(quizName)
|
||||||
|
})
|
||||||
|
unselectAll()
|
||||||
|
toast.success(__('Quizzes deleted successfully'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const quizColumns = computed(() => {
|
const quizColumns = computed(() => {
|
||||||
@@ -120,18 +229,42 @@ const quizColumns = computed(() => {
|
|||||||
label: __('Title'),
|
label: __('Title'),
|
||||||
key: 'title',
|
key: 'title',
|
||||||
width: 2,
|
width: 2,
|
||||||
|
icon: 'file-text',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: __('Total Marks'),
|
label: __('Total Marks'),
|
||||||
key: 'total_marks',
|
key: 'total_marks',
|
||||||
width: 1,
|
width: 1,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
icon: 'hash',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: __('Passing Percentage'),
|
label: __('Passing Percentage'),
|
||||||
key: 'passing_percentage',
|
key: 'passing_percentage',
|
||||||
width: 1,
|
width: 1,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
icon: 'percent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Max Attempts'),
|
||||||
|
key: 'max_attempts',
|
||||||
|
width: 1,
|
||||||
|
align: 'center',
|
||||||
|
icon: 'repeat',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Show Answers'),
|
||||||
|
key: 'show_answers',
|
||||||
|
width: 1,
|
||||||
|
align: 'center',
|
||||||
|
icon: 'eye',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Modified'),
|
||||||
|
key: 'modified',
|
||||||
|
width: 1,
|
||||||
|
align: 'center',
|
||||||
|
icon: 'clock',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -215,6 +215,30 @@ const routes = [
|
|||||||
name: 'PersonaForm',
|
name: 'PersonaForm',
|
||||||
component: () => import('@/pages/PersonaForm.vue'),
|
component: () => import('@/pages/PersonaForm.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/programming-exercises',
|
||||||
|
name: 'ProgrammingExercises',
|
||||||
|
component: () =>
|
||||||
|
import('@/pages/ProgrammingExercises/ProgrammingExercises.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/programming-exercises/submissions',
|
||||||
|
name: 'ProgrammingExerciseSubmissions',
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
'@/pages/ProgrammingExercises/ProgrammingExerciseSubmissions.vue'
|
||||||
|
),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/programming-exercises/:exerciseID/submission/:submissionID',
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
'@/pages/ProgrammingExercises/ProgrammingExerciseSubmission.vue'
|
||||||
|
),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
let router = createRouter({
|
let router = createRouter({
|
||||||
|
|||||||
@@ -54,10 +54,14 @@ export const sessionStore = defineStore('lms-session', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const sidebarSettings = createResource({
|
const livecodeURL = createResource({
|
||||||
url: 'lms.lms.api.get_sidebar_settings',
|
url: 'frappe.client.get_single_value',
|
||||||
cache: 'Sidebar Settings',
|
params: {
|
||||||
auto: false,
|
doctype: 'LMS Settings',
|
||||||
|
field: 'livecode_url',
|
||||||
|
},
|
||||||
|
cache: 'livecodeURL',
|
||||||
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -67,6 +71,6 @@ export const sessionStore = defineStore('lms-session', () => {
|
|||||||
logout,
|
logout,
|
||||||
brand,
|
brand,
|
||||||
branding,
|
branding,
|
||||||
sidebarSettings,
|
livecodeURL,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,21 +9,38 @@ export const useSettings = defineStore('settings', () => {
|
|||||||
const activeTab = ref(null)
|
const activeTab = ref(null)
|
||||||
|
|
||||||
const learningPaths = createResource({
|
const learningPaths = createResource({
|
||||||
url: 'lms.lms.api.is_learning_path_enabled',
|
url: 'lms.lms.api.get_lms_setting',
|
||||||
|
params: { field: 'enable_learning_paths' },
|
||||||
auto: true,
|
auto: true,
|
||||||
cache: ['learningPath'],
|
cache: ['learningPath'],
|
||||||
})
|
})
|
||||||
|
|
||||||
const allowGuestAccess = createResource({
|
const allowGuestAccess = createResource({
|
||||||
url: 'lms.lms.api.is_guest_allowed',
|
url: 'lms.lms.api.get_lms_setting',
|
||||||
|
params: { field: 'allow_guest_access' },
|
||||||
auto: true,
|
auto: true,
|
||||||
cache: ['allowGuestAccess'],
|
cache: ['allowGuestAccess'],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const preventSkippingVideos = createResource({
|
||||||
|
url: 'lms.lms.api.get_lms_setting',
|
||||||
|
params: { field: 'prevent_skipping_videos' },
|
||||||
|
auto: true,
|
||||||
|
cache: ['preventSkippingVideos'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const sidebarSettings = createResource({
|
||||||
|
url: 'lms.lms.api.get_sidebar_settings',
|
||||||
|
cache: 'Sidebar Settings',
|
||||||
|
auto: false,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isSettingsOpen,
|
isSettingsOpen,
|
||||||
activeTab,
|
activeTab,
|
||||||
learningPaths,
|
learningPaths,
|
||||||
allowGuestAccess,
|
allowGuestAccess,
|
||||||
|
preventSkippingVideos,
|
||||||
|
sidebarSettings,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
65
frontend/src/styles/codemirror.css
Normal file
65
frontend/src/styles/codemirror.css
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
.cm-editor {
|
||||||
|
user-select: text;
|
||||||
|
padding: 0px !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
.cm-gutters {
|
||||||
|
@apply !border-0 !bg-transparent !px-1.5 !text-xs !leading-6 !text-gray-500;
|
||||||
|
}
|
||||||
|
.cm-foldGutter span {
|
||||||
|
@apply !hidden !opacity-0;
|
||||||
|
}
|
||||||
|
.cm-gutterElement {
|
||||||
|
@apply !text-left;
|
||||||
|
}
|
||||||
|
.cm-activeLine {
|
||||||
|
@apply !bg-transparent;
|
||||||
|
}
|
||||||
|
.cm-activeLineGutter {
|
||||||
|
@apply !bg-transparent text-gray-600;
|
||||||
|
}
|
||||||
|
.cm-editor {
|
||||||
|
width: 100%;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
.cm-placeholder {
|
||||||
|
@apply !leading-6 !text-gray-500;
|
||||||
|
}
|
||||||
|
.cm-scroller {
|
||||||
|
@apply !font-mono !leading-6 !text-gray-600;
|
||||||
|
}
|
||||||
|
.cm-matchingBracket {
|
||||||
|
font-weight: 500 !important;
|
||||||
|
background: none !important;
|
||||||
|
border-bottom: 1px solid #000 !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
.cm-focused {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
.cm-tooltip-autocomplete {
|
||||||
|
@apply !rounded-lg !shadow-md !bg-surface-white !p-1.5 !border-none;
|
||||||
|
}
|
||||||
|
.cm-tooltip-autocomplete > ul {
|
||||||
|
font-family: 'Inter' !important;
|
||||||
|
}
|
||||||
|
.cm-tooltip-autocomplete ul li[aria-selected='true'] {
|
||||||
|
@apply !rounded !bg-gray-200/80;
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
.cm-completionLabel {
|
||||||
|
margin-right: 1rem !important;
|
||||||
|
}
|
||||||
|
.cm-completionDetail {
|
||||||
|
margin-left: auto !important;
|
||||||
|
}
|
||||||
|
.inline-expression .cm-content {
|
||||||
|
padding: 0 !important;
|
||||||
|
line-height: 26px !important;
|
||||||
|
}
|
||||||
|
.inline-expression .cm-placeholder {
|
||||||
|
line-height: 26px !important;
|
||||||
|
}
|
||||||
|
.inline-expression .cm-gutters {
|
||||||
|
line-height: 26px !important;
|
||||||
|
}
|
||||||
@@ -56,12 +56,18 @@ export class Assignment {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-surface-menu-bar mb-2'>
|
call('frappe.client.get_value', {
|
||||||
|
doctype: 'LMS Assignment',
|
||||||
|
name: assignment,
|
||||||
|
fieldname: ['title'],
|
||||||
|
}).then((data) => {
|
||||||
|
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
|
||||||
<span class="font-medium">
|
<span class="font-medium">
|
||||||
Assignment: ${assignment}
|
Assignment: ${data.title}
|
||||||
</span>
|
</span>
|
||||||
</div>`
|
</div>`
|
||||||
return
|
return
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAssignmentModal() {
|
renderAssignmentModal() {
|
||||||
@@ -79,7 +85,8 @@ export class Assignment {
|
|||||||
app.mount(this.wrapper)
|
app.mount(this.wrapper)
|
||||||
}
|
}
|
||||||
|
|
||||||
save(blockContent) {
|
save() {
|
||||||
|
if (Object.keys(this.data).length === 0) return {}
|
||||||
return {
|
return {
|
||||||
assignment: this.data.assignment,
|
assignment: this.data.assignment,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { watch } from 'vue'
|
|
||||||
import { call, toast } from 'frappe-ui'
|
import { call, toast } from 'frappe-ui'
|
||||||
import { useTimeAgo } from '@vueuse/core'
|
import { useTimeAgo } from '@vueuse/core'
|
||||||
import { Quiz } from '@/utils/quiz'
|
import { Quiz } from '@/utils/quiz'
|
||||||
|
import { Program } from '@/utils/program'
|
||||||
import { Assignment } from '@/utils/assignment'
|
import { Assignment } from '@/utils/assignment'
|
||||||
import { Upload } from '@/utils/upload'
|
import { Upload } from '@/utils/upload'
|
||||||
import { Markdown } from '@/utils/markdownParser'
|
import { Markdown } from '@/utils/markdownParser'
|
||||||
@@ -103,24 +103,6 @@ export function getImgDimensions(imgSrc) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateDocumentTitle(meta) {
|
|
||||||
watch(
|
|
||||||
() => meta,
|
|
||||||
(meta) => {
|
|
||||||
if (!meta.value.title) return
|
|
||||||
if (meta.value.title && meta.value.subtitle) {
|
|
||||||
document.title = `${meta.value.title} | ${meta.value.subtitle}`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (meta.value.title) {
|
|
||||||
document.title = `${meta.value.title}`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true, deep: true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function htmlToText(html) {
|
export function htmlToText(html) {
|
||||||
const div = document.createElement('div')
|
const div = document.createElement('div')
|
||||||
div.innerHTML = html
|
div.innerHTML = html
|
||||||
@@ -135,18 +117,26 @@ export function getEditorTools() {
|
|||||||
placeholder: 'Header',
|
placeholder: 'Header',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
list: {
|
||||||
|
class: NestedList,
|
||||||
|
inlineToolbar: true,
|
||||||
|
config: {
|
||||||
|
defaultStyle: 'ordered',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
class: Table,
|
||||||
|
inlineToolbar: true,
|
||||||
|
},
|
||||||
quiz: Quiz,
|
quiz: Quiz,
|
||||||
assignment: Assignment,
|
assignment: Assignment,
|
||||||
|
program: Program,
|
||||||
upload: Upload,
|
upload: Upload,
|
||||||
markdown: {
|
markdown: {
|
||||||
class: Markdown,
|
class: Markdown,
|
||||||
inlineToolbar: true,
|
inlineToolbar: true,
|
||||||
},
|
},
|
||||||
image: SimpleImage,
|
image: SimpleImage,
|
||||||
table: {
|
|
||||||
class: Table,
|
|
||||||
inlineToolbar: true,
|
|
||||||
},
|
|
||||||
paragraph: {
|
paragraph: {
|
||||||
class: Paragraph,
|
class: Paragraph,
|
||||||
inlineToolbar: true,
|
inlineToolbar: true,
|
||||||
@@ -160,13 +150,6 @@ export function getEditorTools() {
|
|||||||
useDefaultTheme: 'dark',
|
useDefaultTheme: 'dark',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
list: {
|
|
||||||
class: NestedList,
|
|
||||||
inlineToolbar: true,
|
|
||||||
config: {
|
|
||||||
defaultStyle: 'ordered',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
inlineCode: {
|
inlineCode: {
|
||||||
class: InlineCode,
|
class: InlineCode,
|
||||||
shortcut: 'CMD+SHIFT+M',
|
shortcut: 'CMD+SHIFT+M',
|
||||||
@@ -197,6 +180,14 @@ export function getEditorTools() {
|
|||||||
window.innerWidth < 640 ? '15rem' : '30rem'
|
window.innerWidth < 640 ? '15rem' : '30rem'
|
||||||
};" frameborder="0" allowfullscreen></iframe>`,
|
};" frameborder="0" allowfullscreen></iframe>`,
|
||||||
},
|
},
|
||||||
|
bunnyStream: {
|
||||||
|
regex: /https:\/\/(?:iframe\.mediadelivery\.net|video\.bunnycdn\.com)\/play\/([a-zA-Z0-9]+\/[a-zA-Z0-9-]+)/,
|
||||||
|
embedUrl:
|
||||||
|
'https://iframe.mediadelivery.net/embed/<%= remote_id %>',
|
||||||
|
html: `<iframe style="width:100%; height: ${
|
||||||
|
window.innerWidth < 640 ? '15rem' : '30rem'
|
||||||
|
};" frameborder="0" allowfullscreen></iframe>`,
|
||||||
|
},
|
||||||
codepen: true,
|
codepen: true,
|
||||||
aparat: {
|
aparat: {
|
||||||
regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/,
|
regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/,
|
||||||
@@ -429,6 +420,17 @@ export function getSidebarLinks() {
|
|||||||
to: 'Batches',
|
to: 'Batches',
|
||||||
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
|
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Programming Exercises',
|
||||||
|
icon: 'Code',
|
||||||
|
to: 'ProgrammingExercises',
|
||||||
|
activeFor: [
|
||||||
|
'ProgrammingExercises',
|
||||||
|
'ProgrammingExerciseForm',
|
||||||
|
'ProgrammingExerciseSubmissions',
|
||||||
|
'ProgrammingExerciseSubmission',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Certified Members',
|
label: 'Certified Members',
|
||||||
icon: 'GraduationCap',
|
icon: 'GraduationCap',
|
||||||
@@ -495,10 +497,13 @@ export function singularize(word) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const validateFile = (file) => {
|
export const validateFile = (file, showToast = true) => {
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
if (!file.type.startsWith('image/')) {
|
||||||
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
const errorMessage = __('Only image file is allowed.')
|
||||||
return __('Only image file is allowed.')
|
if (showToast) {
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
return errorMessage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,22 +533,32 @@ export const canCreateCourse = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enablePlyr = () => {
|
export const enablePlyr = async () => {
|
||||||
setTimeout(() => {
|
await wait(500)
|
||||||
const videoElement = document.getElementsByClassName('video-player')
|
|
||||||
if (videoElement.length === 0) return
|
|
||||||
|
|
||||||
Array.from(videoElement).forEach((video) => {
|
const players = []
|
||||||
|
const videoElements = document.getElementsByClassName('video-player')
|
||||||
|
|
||||||
|
if (videoElements.length === 0) return players
|
||||||
|
|
||||||
|
Array.from(videoElements).forEach((video) => {
|
||||||
|
setupPlyrForVideo(video, players)
|
||||||
|
})
|
||||||
|
|
||||||
|
return players
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
const setupPlyrForVideo = (video, players) => {
|
||||||
const src = video.getAttribute('src')
|
const src = video.getAttribute('src')
|
||||||
|
|
||||||
if (src) {
|
if (src) {
|
||||||
let videoID = src.split('/').pop()
|
const videoID = extractYouTubeId(src)
|
||||||
video.setAttribute('data-plyr-embed-id', videoID)
|
video.setAttribute('data-plyr-embed-id', videoID)
|
||||||
}
|
}
|
||||||
new Plyr(video, {
|
|
||||||
youtube: {
|
let controls = [
|
||||||
noCookie: true,
|
|
||||||
},
|
|
||||||
controls: [
|
|
||||||
'play-large',
|
'play-large',
|
||||||
'play',
|
'play',
|
||||||
'progress',
|
'progress',
|
||||||
@@ -551,22 +566,63 @@ export const enablePlyr = () => {
|
|||||||
'mute',
|
'mute',
|
||||||
'volume',
|
'volume',
|
||||||
'fullscreen',
|
'fullscreen',
|
||||||
],
|
]
|
||||||
})
|
|
||||||
}, 500)
|
const player = new Plyr(video, {
|
||||||
|
youtube: { noCookie: true },
|
||||||
|
controls: controls,
|
||||||
|
listeners: {
|
||||||
|
seek: function customSeekBehavior(e) {
|
||||||
|
const current_time = player.currentTime
|
||||||
|
const newTime = getTargetTime(player, e)
|
||||||
|
if (
|
||||||
|
useSettings().preventSkippingVideos.data &&
|
||||||
|
parseFloat(newTime) > current_time
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
player.currentTime = current_time
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
players.push(player)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const openSettings = (category, close) => {
|
const getTargetTime = (plyr, input) => {
|
||||||
|
if (
|
||||||
|
typeof input === 'object' &&
|
||||||
|
(input.type === 'input' || input.type === 'change')
|
||||||
|
) {
|
||||||
|
return (input.target.value / input.target.max) * plyr.duration
|
||||||
|
} else {
|
||||||
|
return Number(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractYouTubeId = (url) => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url)
|
||||||
|
return (
|
||||||
|
parsedUrl.searchParams.get('v') ||
|
||||||
|
parsedUrl.pathname.split('/').pop()
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return url.split('/').pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openSettings = (category, close = null) => {
|
||||||
const settingsStore = useSettings()
|
const settingsStore = useSettings()
|
||||||
|
if (close) {
|
||||||
close()
|
close()
|
||||||
|
}
|
||||||
settingsStore.activeTab = category
|
settingsStore.activeTab = category
|
||||||
settingsStore.isSettingsOpen = true
|
settingsStore.isSettingsOpen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cleanError = (message) => {
|
export const cleanError = (message) => {
|
||||||
// Remove HTML tags but keep the text within the tags
|
|
||||||
|
|
||||||
const cleanMessage = message.replace(/<[^>]+>/g, (match) => {
|
const cleanMessage = message.replace(/<[^>]+>/g, (match) => {
|
||||||
return match.replace(/<\/?[^>]+(>|$)/g, '')
|
return match.replace(/<\/?[^>]+(>|$)/g, '')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export class Markdown {
|
|||||||
this.config = config || {}
|
this.config = config || {}
|
||||||
this.text = data.text || ''
|
this.text = data.text || ''
|
||||||
this.readOnly = readOnly
|
this.readOnly = readOnly
|
||||||
|
this.placeholder = __("Type '/' for commands or select text to format")
|
||||||
}
|
}
|
||||||
|
|
||||||
static get isReadOnlySupported() {
|
static get isReadOnlySupported() {
|
||||||
@@ -64,7 +65,15 @@ export class Markdown {
|
|||||||
this.wrapper.contentEditable = true
|
this.wrapper.contentEditable = true
|
||||||
this.wrapper.innerHTML = this.text
|
this.wrapper.innerHTML = this.text
|
||||||
|
|
||||||
|
this.wrapper.addEventListener('focus', () =>
|
||||||
|
this._togglePlaceholder()
|
||||||
|
)
|
||||||
|
this.wrapper.addEventListener('blur', () =>
|
||||||
|
this._togglePlaceholder()
|
||||||
|
)
|
||||||
|
|
||||||
this.wrapper.addEventListener('input', (event) => {
|
this.wrapper.addEventListener('input', (event) => {
|
||||||
|
this._togglePlaceholder()
|
||||||
let value = event.target.textContent
|
let value = event.target.textContent
|
||||||
if (event.keyCode === 32 && value.startsWith('#')) {
|
if (event.keyCode === 32 && value.startsWith('#')) {
|
||||||
this.convertToHeader(event, value)
|
this.convertToHeader(event, value)
|
||||||
@@ -85,6 +94,22 @@ export class Markdown {
|
|||||||
return this.wrapper
|
return this.wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_togglePlaceholder() {
|
||||||
|
const blocks = document.querySelectorAll(
|
||||||
|
'.cdx-block.ce-paragraph[data-placeholder]'
|
||||||
|
)
|
||||||
|
blocks.forEach((block) => {
|
||||||
|
if (block !== this.wrapper) {
|
||||||
|
delete block.dataset.placeholder
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (this.wrapper.innerHTML.trim() === '') {
|
||||||
|
this.wrapper.dataset.placeholder = this.placeholder
|
||||||
|
} else {
|
||||||
|
delete this.wrapper.dataset.placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
convertToHeader(event, value) {
|
convertToHeader(event, value) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (['#', '##', '###', '####', '#####', '######'].includes(value)) {
|
if (['#', '##', '###', '####', '#####', '######'].includes(value)) {
|
||||||
|
|||||||
101
frontend/src/utils/program.ts
Normal file
101
frontend/src/utils/program.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { createApp, h } from 'vue'
|
||||||
|
import { Code } from 'lucide-vue-next'
|
||||||
|
import translationPlugin from '@/translation'
|
||||||
|
import ProgrammingExerciseModal from '@/pages/ProgrammingExercises/ProgrammingExerciseModal.vue';
|
||||||
|
import { call } from 'frappe-ui';
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
|
|
||||||
|
|
||||||
|
export class Program {
|
||||||
|
|
||||||
|
data: any;
|
||||||
|
api: any;
|
||||||
|
readOnly: boolean;
|
||||||
|
wrapper: HTMLDivElement;
|
||||||
|
|
||||||
|
constructor({ data, api, readOnly }: { data: any; api: any; readOnly: boolean }) {
|
||||||
|
this.data = data;
|
||||||
|
this.api = api;
|
||||||
|
this.readOnly = readOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get toolbox() {
|
||||||
|
const app = createApp({
|
||||||
|
render: () => h(Code, { size: 5, strokeWidth: 1.5 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const div = document.createElement('div')
|
||||||
|
app.mount(div)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: __('Programming Exercise'),
|
||||||
|
icon: div.innerHTML,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get isReadOnlySupported() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.wrapper = document.createElement('div')
|
||||||
|
if (Object.keys(this.data).length) {
|
||||||
|
this.renderExercise(this.data.exercise)
|
||||||
|
} else {
|
||||||
|
this.renderModal()
|
||||||
|
}
|
||||||
|
return this.wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
renderModal() {
|
||||||
|
if (this.readOnly) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const app = createApp(ProgrammingExerciseModal, {
|
||||||
|
onSave: (exercise: string) => {
|
||||||
|
this.data.exercise = exercise
|
||||||
|
this.renderExercise(exercise)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app.use(translationPlugin)
|
||||||
|
app.mount(this.wrapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderExercise(exercise: string) {
|
||||||
|
if (this.readOnly) {
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
call('frappe.client.get_value', {
|
||||||
|
doctype: 'LMS Programming Exercise Submission',
|
||||||
|
filters: {
|
||||||
|
exercise: exercise,
|
||||||
|
member: userResource.data?.name,
|
||||||
|
},
|
||||||
|
fieldname: ['name'],
|
||||||
|
}).then((data: { name: string }) => {
|
||||||
|
let submission = data.name || 'new'
|
||||||
|
this.wrapper.innerHTML = `<iframe src="/lms/exercises/${exercise}/submission/${submission}?fromLesson=1" class="w-full h-[900px] border rounded-md"></iframe>`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call("frappe.client.get_value", {
|
||||||
|
doctype: 'LMS Programming Exercise',
|
||||||
|
name: exercise,
|
||||||
|
fieldname: "title"
|
||||||
|
}).then((data: { title: string }) => {
|
||||||
|
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
|
||||||
|
<span class="font-medium">
|
||||||
|
Programming Exercise: ${data.title}
|
||||||
|
</span>
|
||||||
|
</div>`
|
||||||
|
return
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if (!this.data.exercise) return {}
|
||||||
|
return {
|
||||||
|
exercise: this.data.exercise,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,7 @@ export class Quiz {
|
|||||||
|
|
||||||
static get toolbox() {
|
static get toolbox() {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
render: () =>
|
render: () => h(CircleHelp, { size: 5, strokeWidth: 1.5 }),
|
||||||
h(CircleHelp, { size: 18, strokeWidth: 1.5, color: 'black' }),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const div = document.createElement('div')
|
const div = document.createElement('div')
|
||||||
@@ -46,7 +45,7 @@ export class Quiz {
|
|||||||
this.wrapper.innerHTML = `<iframe src="/lms/quiz/${quiz}?fromLesson=1" class="w-full h-[500px]"></iframe>`
|
this.wrapper.innerHTML = `<iframe src="/lms/quiz/${quiz}?fromLesson=1" class="w-full h-[500px]"></iframe>`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-surface-menu-bar mb-2'>
|
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
|
||||||
<span class="font-medium">
|
<span class="font-medium">
|
||||||
Quiz: ${quiz}
|
Quiz: ${quiz}
|
||||||
</span>
|
</span>
|
||||||
@@ -69,7 +68,8 @@ export class Quiz {
|
|||||||
app.mount(this.wrapper)
|
app.mount(this.wrapper)
|
||||||
}
|
}
|
||||||
|
|
||||||
save(blockContent) {
|
save() {
|
||||||
|
if (Object.keys(this.data).length === 0) return {}
|
||||||
return {
|
return {
|
||||||
quiz: this.data.quiz,
|
quiz: this.data.quiz,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import VideoBlock from '@/components/VideoBlock.vue'
|
|||||||
import UploadPlugin from '@/components/UploadPlugin.vue'
|
import UploadPlugin from '@/components/UploadPlugin.vue'
|
||||||
import { h, createApp } from 'vue'
|
import { h, createApp } from 'vue'
|
||||||
import { Upload as UploadIcon } from 'lucide-vue-next'
|
import { Upload as UploadIcon } from 'lucide-vue-next'
|
||||||
|
import { createDialog } from '@/utils/dialogs'
|
||||||
import translationPlugin from '../translation'
|
import translationPlugin from '../translation'
|
||||||
|
|
||||||
export class Upload {
|
export class Upload {
|
||||||
@@ -54,6 +55,7 @@ export class Upload {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
app.use(translationPlugin)
|
app.use(translationPlugin)
|
||||||
|
app.config.globalProperties.$dialog = createDialog
|
||||||
app.mount(this.wrapper)
|
app.mount(this.wrapper)
|
||||||
return
|
return
|
||||||
} else if (this.isAudio(file.file_type)) {
|
} else if (this.isAudio(file.file_type)) {
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"lib": ["ESNext", "DOM"],
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"types": ["./globals"],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "src/*.d.ts"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
|||||||
'showdown',
|
'showdown',
|
||||||
'engine.io-client',
|
'engine.io-client',
|
||||||
'tailwind.config.js',
|
'tailwind.config.js',
|
||||||
|
'interactjs',
|
||||||
'highlight.js',
|
'highlight.js',
|
||||||
'plyr',
|
'plyr',
|
||||||
],
|
],
|
||||||
|
|||||||
3068
frontend/yarn.lock
Normal file
3068
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
__version__ = "2.30.0"
|
__version__ = "2.32.2"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to
|
from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to
|
||||||
from lms.lms.api import give_dicussions_permission
|
from lms.lms.api import give_discussions_permission
|
||||||
|
|
||||||
|
|
||||||
def after_install():
|
def after_install():
|
||||||
create_batch_source()
|
create_batch_source()
|
||||||
give_dicussions_permission()
|
give_discussions_permission()
|
||||||
|
|
||||||
|
|
||||||
def after_sync():
|
def after_sync():
|
||||||
|
|||||||
241
lms/lms/api.py
241
lms/lms/api.py
@@ -553,6 +553,7 @@ def get_sidebar_settings():
|
|||||||
"jobs",
|
"jobs",
|
||||||
"statistics",
|
"statistics",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"programming_exercises",
|
||||||
]
|
]
|
||||||
for item in items:
|
for item in items:
|
||||||
sidebar_items[item] = lms_settings.get(item)
|
sidebar_items[item] = lms_settings.get(item)
|
||||||
@@ -675,6 +676,27 @@ def update_index(lessons, chapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def update_chapter_index(chapter, course, idx):
|
||||||
|
"""Update the index of a chapter within a course"""
|
||||||
|
chapters = frappe.get_all(
|
||||||
|
"Chapter Reference",
|
||||||
|
{"parent": course},
|
||||||
|
pluck="chapter",
|
||||||
|
order_by="idx",
|
||||||
|
)
|
||||||
|
|
||||||
|
if chapter in chapters:
|
||||||
|
chapters.remove(chapter)
|
||||||
|
|
||||||
|
chapters.insert(idx, chapter)
|
||||||
|
|
||||||
|
for i, chapter_name in enumerate(chapters):
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Chapter Reference", {"chapter": chapter_name, "parent": course}, "idx", i + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_categories(doctype, filters):
|
def get_categories(doctype, filters):
|
||||||
categoryOptions = []
|
categoryOptions = []
|
||||||
@@ -695,15 +717,6 @@ def get_categories(doctype, filters):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_members(start=0, search=""):
|
def get_members(start=0, search=""):
|
||||||
"""Get members for the given search term and start index.
|
|
||||||
Args: start (int): Start index for the query.
|
|
||||||
<<<<<<< HEAD
|
|
||||||
search (str): Search term to filter the results.
|
|
||||||
=======
|
|
||||||
search (str): Search term to filter the results.
|
|
||||||
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
|
|
||||||
Returns: List of members.
|
|
||||||
"""
|
|
||||||
|
|
||||||
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
|
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
|
||||||
or_filters = {}
|
or_filters = {}
|
||||||
@@ -722,7 +735,14 @@ def get_members(start=0, search=""):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for member in members:
|
for member in members:
|
||||||
roles = frappe.get_roles(member.name)
|
roles = frappe.get_all(
|
||||||
|
"Has Role",
|
||||||
|
{
|
||||||
|
"parent": member.name,
|
||||||
|
"parenttype": "User",
|
||||||
|
},
|
||||||
|
pluck="role",
|
||||||
|
)
|
||||||
if "Moderator" in roles:
|
if "Moderator" in roles:
|
||||||
member.role = "Moderator"
|
member.role = "Moderator"
|
||||||
elif "Course Creator" in roles:
|
elif "Course Creator" in roles:
|
||||||
@@ -991,7 +1011,30 @@ def delete_course(course):
|
|||||||
frappe.delete_doc("LMS Course", course)
|
frappe.delete_doc("LMS Course", course)
|
||||||
|
|
||||||
|
|
||||||
def give_dicussions_permission():
|
@frappe.whitelist()
|
||||||
|
def delete_batch(batch):
|
||||||
|
frappe.db.delete("LMS Batch Enrollment", {"batch": batch})
|
||||||
|
frappe.db.delete("Batch Course", {"parent": batch, "parenttype": "LMS Batch"})
|
||||||
|
frappe.db.delete("LMS Assessment", {"parent": batch, "parenttype": "LMS Batch"})
|
||||||
|
frappe.db.delete("LMS Batch Timetable", {"parent": batch, "parenttype": "LMS Batch"})
|
||||||
|
frappe.db.delete("LMS Batch Feedback", {"batch": batch})
|
||||||
|
delete_batch_discussions(batch)
|
||||||
|
frappe.db.delete("LMS Batch", batch)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_batch_discussions(batch):
|
||||||
|
topics = frappe.get_all(
|
||||||
|
"Discussion Topic",
|
||||||
|
{"reference_doctype": "LMS Batch", "reference_docname": batch},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
|
||||||
|
for topic in topics:
|
||||||
|
frappe.db.delete("Discussion Reply", {"topic": topic})
|
||||||
|
frappe.db.delete("Discussion Topic", topic)
|
||||||
|
|
||||||
|
|
||||||
|
def give_discussions_permission():
|
||||||
doctypes = ["Discussion Topic", "Discussion Reply"]
|
doctypes = ["Discussion Topic", "Discussion Reply"]
|
||||||
roles = ["LMS Student", "Course Creator", "Moderator", "Batch Evaluator"]
|
roles = ["LMS Student", "Course Creator", "Moderator", "Batch Evaluator"]
|
||||||
for doctype in doctypes:
|
for doctype in doctypes:
|
||||||
@@ -1304,13 +1347,8 @@ def get_notifications(filters):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def is_guest_allowed():
|
def get_lms_setting(field):
|
||||||
return frappe.get_cached_value("LMS Settings", None, "allow_guest_access")
|
return frappe.get_cached_value("LMS Settings", None, field)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
|
||||||
def is_learning_path_enabled():
|
|
||||||
return frappe.get_cached_value("LMS Settings", None, "enable_learning_paths")
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -1398,6 +1436,7 @@ def save_role(user, role, value):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def add_an_evaluator(email):
|
def add_an_evaluator(email):
|
||||||
|
frappe.only_for("Moderator")
|
||||||
if not frappe.db.exists("User", email):
|
if not frappe.db.exists("User", email):
|
||||||
user = frappe.new_doc("User")
|
user = frappe.new_doc("User")
|
||||||
user.update(
|
user.update(
|
||||||
@@ -1417,6 +1456,16 @@ def add_an_evaluator(email):
|
|||||||
return evaluator
|
return evaluator
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def delete_evaluator(evaluator):
|
||||||
|
frappe.only_for("Moderator")
|
||||||
|
if not frappe.db.exists("Course Evaluator", evaluator):
|
||||||
|
frappe.throw(_("Evaluator does not exist."))
|
||||||
|
|
||||||
|
frappe.db.delete("Has Role", {"parent": evaluator, "role": "Batch Evaluator"})
|
||||||
|
frappe.db.delete("Course Evaluator", evaluator)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def capture_user_persona(responses):
|
def capture_user_persona(responses):
|
||||||
frappe.only_for("System Manager")
|
frappe.only_for("System Manager")
|
||||||
@@ -1493,3 +1542,159 @@ def update_meta_info(type, route, meta_tags):
|
|||||||
print(new_tag)
|
print(new_tag)
|
||||||
new_tag.insert()
|
new_tag.insert()
|
||||||
print(new_tag.as_dict())
|
print(new_tag.as_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def create_programming_exercise_submission(exercise, submission, code, test_cases):
|
||||||
|
if submission == "new":
|
||||||
|
return make_new_exercise_submission(exercise, code, test_cases)
|
||||||
|
else:
|
||||||
|
update_exercise_submission(submission, code, test_cases)
|
||||||
|
|
||||||
|
|
||||||
|
def make_new_exercise_submission(exercise, code, test_cases):
|
||||||
|
submission = frappe.new_doc("LMS Programming Exercise Submission")
|
||||||
|
submission.exercise = exercise
|
||||||
|
submission.member = frappe.session.user
|
||||||
|
submission.code = code
|
||||||
|
|
||||||
|
for test_case in test_cases:
|
||||||
|
submission.append(
|
||||||
|
"test_cases",
|
||||||
|
{
|
||||||
|
"input": test_case.get("input"),
|
||||||
|
"output": test_case.get("output"),
|
||||||
|
"expected_output": test_case.get("expected_output"),
|
||||||
|
"status": test_case.get("status", test_case.get("status", "Failed")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
submission.status = get_exercise_status(test_cases)
|
||||||
|
submission.insert()
|
||||||
|
return submission.name
|
||||||
|
|
||||||
|
|
||||||
|
def update_exercise_submission(submission, code, test_cases):
|
||||||
|
update_test_cases(test_cases, submission)
|
||||||
|
status = get_exercise_status(test_cases)
|
||||||
|
frappe.db.set_value(
|
||||||
|
"LMS Programming Exercise Submission", submission, {"status": status, "code": code}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_exercise_status(test_cases):
|
||||||
|
if not test_cases:
|
||||||
|
return "Failed"
|
||||||
|
|
||||||
|
if all(row.get("status", "Failed") == "Passed" for row in test_cases):
|
||||||
|
return "Passed"
|
||||||
|
else:
|
||||||
|
return "Failed"
|
||||||
|
|
||||||
|
|
||||||
|
def update_test_cases(test_cases, submission):
|
||||||
|
frappe.db.delete("LMS Test Case Submission", {"parent": submission})
|
||||||
|
for row in test_cases:
|
||||||
|
test_case = frappe.new_doc("LMS Test Case Submission")
|
||||||
|
test_case.update(
|
||||||
|
{
|
||||||
|
"parent": submission,
|
||||||
|
"parenttype": "LMS Programming Exercise Submission",
|
||||||
|
"parentfield": "test_cases",
|
||||||
|
"input": row.get("input"),
|
||||||
|
"output": row.get("output"),
|
||||||
|
"expected_output": row.get("expected_output"),
|
||||||
|
"status": row.get("status", "Failed"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
test_case.insert()
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def track_video_watch_duration(lesson, videos):
|
||||||
|
"""
|
||||||
|
Track the watch duration of videos in a lesson.
|
||||||
|
"""
|
||||||
|
if not isinstance(videos, list):
|
||||||
|
videos = json.loads(videos)
|
||||||
|
|
||||||
|
for video in videos:
|
||||||
|
filters = {
|
||||||
|
"lesson": lesson,
|
||||||
|
"source": video.get("source"),
|
||||||
|
"member": frappe.session.user,
|
||||||
|
}
|
||||||
|
existing_record = frappe.db.get_value(
|
||||||
|
"LMS Video Watch Duration", filters, ["name", "watch_time"], as_dict=True
|
||||||
|
)
|
||||||
|
if existing_record and flt(existing_record.watch_time) < flt(video.get("watch_time")):
|
||||||
|
frappe.db.set_value(
|
||||||
|
"LMS Video Watch Duration",
|
||||||
|
filters,
|
||||||
|
"watch_time",
|
||||||
|
video.get("watch_time"),
|
||||||
|
)
|
||||||
|
elif not existing_record:
|
||||||
|
track_new_watch_time(lesson, video)
|
||||||
|
|
||||||
|
|
||||||
|
def track_new_watch_time(lesson, video):
|
||||||
|
doc = frappe.new_doc("LMS Video Watch Duration")
|
||||||
|
doc.lesson = lesson
|
||||||
|
doc.source = video.get("source")
|
||||||
|
doc.watch_time = video.get("watch_time")
|
||||||
|
doc.member = frappe.session.user
|
||||||
|
doc.save()
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_course_progress_distribution(course):
|
||||||
|
all_progress = frappe.get_all(
|
||||||
|
"LMS Enrollment",
|
||||||
|
{
|
||||||
|
"course": course,
|
||||||
|
},
|
||||||
|
pluck="progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
average_progress = get_average_course_progress(all_progress)
|
||||||
|
progress_distribution = get_progress_distribution(all_progress)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"average_progress": average_progress,
|
||||||
|
"progress_distribution": progress_distribution,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_average_course_progress(progress_list):
|
||||||
|
if not progress_list:
|
||||||
|
return 0
|
||||||
|
average_progress = sum(progress_list) / len(progress_list)
|
||||||
|
return flt(average_progress, frappe.get_system_settings("float_precision") or 3)
|
||||||
|
|
||||||
|
|
||||||
|
def get_progress_distribution(progressList):
|
||||||
|
distribution = [
|
||||||
|
{
|
||||||
|
"category": "0-20%",
|
||||||
|
"count": len([p for p in progressList if 0 <= p < 20]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "20-40%",
|
||||||
|
"count": len([p for p in progressList if 20 <= p < 40]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "40-60%",
|
||||||
|
"count": len([p for p in progressList if 40 <= p < 60]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "60-80%",
|
||||||
|
"count": len([p for p in progressList if 60 <= p < 80]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "80-100%",
|
||||||
|
"count": len([p for p in progressList if 80 <= p <= 100]),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return distribution
|
||||||
|
|||||||
@@ -58,8 +58,7 @@
|
|||||||
"fetch_from": "evaluator.full_name",
|
"fetch_from": "evaluator.full_name",
|
||||||
"fieldname": "full_name",
|
"fieldname": "full_name",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Full Name",
|
"label": "Full Name"
|
||||||
"read_only": 1
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_casg",
|
"fieldname": "column_break_casg",
|
||||||
@@ -73,21 +72,19 @@
|
|||||||
"fetch_from": "evaluator.user_image",
|
"fetch_from": "evaluator.user_image",
|
||||||
"fieldname": "user_image",
|
"fieldname": "user_image",
|
||||||
"fieldtype": "Attach Image",
|
"fieldtype": "Attach Image",
|
||||||
"label": "User Image",
|
"label": "User Image"
|
||||||
"read_only": 1
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fetch_from": "evaluator.username",
|
"fetch_from": "evaluator.username",
|
||||||
"fieldname": "username",
|
"fieldname": "username",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Username",
|
"label": "Username"
|
||||||
"read_only": 1
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-06-05 11:04:32.475711",
|
"modified": "2025-07-04 12:04:11.007945",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "Course Evaluator",
|
"name": "Course Evaluator",
|
||||||
"naming_rule": "By fieldname",
|
"naming_rule": "By fieldname",
|
||||||
|
|||||||
@@ -146,11 +146,12 @@
|
|||||||
"fieldtype": "Section Break"
|
"fieldtype": "Section Break"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2025-02-17 18:40:53.374932",
|
"modified": "2025-07-14 10:24:23.526176",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Assignment Submission",
|
"name": "LMS Assignment Submission",
|
||||||
"naming_rule": "Expression (old style)",
|
"naming_rule": "Expression (old style)",
|
||||||
@@ -179,8 +180,45 @@
|
|||||||
"role": "LMS Student",
|
"role": "LMS Student",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Moderator",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Batch Evaluator",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Course Creator",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [
|
"states": [
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
"enabled",
|
"enabled",
|
||||||
"title",
|
"title",
|
||||||
"description",
|
"description",
|
||||||
|
"reference_doctype",
|
||||||
|
"event",
|
||||||
"image",
|
"image",
|
||||||
"column_break_wgum",
|
"column_break_wgum",
|
||||||
"grant_only_once",
|
"grant_only_once",
|
||||||
"event",
|
|
||||||
"reference_doctype",
|
|
||||||
"user_field",
|
"user_field",
|
||||||
"field_to_check",
|
"field_to_check",
|
||||||
"condition"
|
"condition"
|
||||||
@@ -91,6 +91,7 @@
|
|||||||
"reqd": 1
|
"reqd": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
"link_fieldname": "badge"
|
"link_fieldname": "badge"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2024-05-27 17:25:55.399830",
|
"modified": "2025-07-04 13:02:19.048994",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Badge",
|
"name": "LMS Badge",
|
||||||
@@ -127,6 +128,7 @@
|
|||||||
"share": 1
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
|
|||||||
@@ -27,17 +27,9 @@ class LMSBadge(Document):
|
|||||||
def rule_condition_satisfied(self, doc):
|
def rule_condition_satisfied(self, doc):
|
||||||
doc_before_save = doc.get_doc_before_save()
|
doc_before_save = doc.get_doc_before_save()
|
||||||
|
|
||||||
if self.event == "Manual Assignment":
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.event == "New" and doc_before_save != None:
|
if self.event == "New" and doc_before_save != None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.event == "Value Change":
|
|
||||||
field_to_check = self.field_to_check
|
|
||||||
if not field_to_check:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.condition:
|
if self.condition:
|
||||||
return eval_condition(doc, self.condition)
|
return eval_condition(doc, self.condition)
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
"field_order": [
|
"field_order": [
|
||||||
"member",
|
"member",
|
||||||
"member_name",
|
"member_name",
|
||||||
"issued_on",
|
"member_username",
|
||||||
|
"member_image",
|
||||||
"column_break_ugix",
|
"column_break_ugix",
|
||||||
|
"issued_on",
|
||||||
"badge",
|
"badge",
|
||||||
"badge_image",
|
"badge_image",
|
||||||
"badge_description"
|
"badge_description"
|
||||||
@@ -65,12 +67,25 @@
|
|||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Member Name",
|
"label": "Member Name",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "member.username",
|
||||||
|
"fieldname": "member_username",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Member Username"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "member.user_image",
|
||||||
|
"fieldname": "member_image",
|
||||||
|
"fieldtype": "Attach Image",
|
||||||
|
"label": "Member Image"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-01-06 12:32:28.450028",
|
"modified": "2025-07-07 20:37:22.449149",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Badge Assignment",
|
"name": "LMS Badge Assignment",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
@@ -122,6 +137,7 @@
|
|||||||
"share": 1
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"show_title_field_in_link": 1,
|
"show_title_field_in_link": 1,
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"member",
|
"member",
|
||||||
"member_name",
|
"member_name",
|
||||||
"member_username",
|
"member_username",
|
||||||
|
"member_image",
|
||||||
"certification_section",
|
"certification_section",
|
||||||
"purchased_certificate",
|
"purchased_certificate",
|
||||||
"certificate",
|
"certificate",
|
||||||
@@ -143,13 +144,19 @@
|
|||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Certificate",
|
"label": "Certificate",
|
||||||
"options": "LMS Certificate"
|
"options": "LMS Certificate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "member.user_image",
|
||||||
|
"fieldname": "member_image",
|
||||||
|
"fieldtype": "Attach Image",
|
||||||
|
"label": "Member Image"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-04-25 10:06:25.824119",
|
"modified": "2025-07-02 21:27:30.733482",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Enrollment",
|
"name": "LMS Enrollment",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2025, Frappe and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("LMS Programming Exercise", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user