mirror of
https://github.com/frappe/lms.git
synced 2026-05-06 07:29:32 +03:00
Compare commits
386 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fa4dd7121 | |||
| c2a4ef692f | |||
| 6211c507ff | |||
| ffbe3d6e69 | |||
| 1b13f20ff8 | |||
| ee9c460f8d | |||
| 6e57e246e9 | |||
| 5e18ba3c35 | |||
| 3bb7f51295 | |||
| ad037e5f90 | |||
| 07ca95caa8 | |||
| 47b5b603c7 | |||
| eff914c73b | |||
| e3e19c6252 | |||
| e702853b2f | |||
| 1e7da9b40c | |||
| ad1f516d66 | |||
| 08794c2424 | |||
| 8cff1cf916 | |||
| 7fecbe5799 | |||
| 7c46d8bbfc | |||
| e25b7c67b9 | |||
| 65f4cde5b8 | |||
| 56eb556929 | |||
| 371b7663e9 | |||
| aea1059473 | |||
| bb5e277e9e | |||
| e4e6f047a4 | |||
| 23996da0bc | |||
| dc6e3265c0 | |||
| e3e4b9a648 | |||
| 79d5da75b3 | |||
| 80b802a76b | |||
| c082f1d30d | |||
| 8db8fd489e | |||
| d94d08f7c3 | |||
| 0fe8d3ac74 | |||
| 193374df47 | |||
| d55951525f | |||
| 70a9ee1bd7 | |||
| 2878f0232c | |||
| 189cae3490 | |||
| 2b1df68e78 | |||
| 46c24b1166 | |||
| bc139767a8 | |||
| 0838a9d325 | |||
| aed4c6915d | |||
| 07e9b62467 | |||
| 5de889c71b | |||
| 4278361559 | |||
| f891f72e20 | |||
| cb5d19e523 | |||
| 8b3930169d | |||
| 3e05dcedeb | |||
| aec9556cf0 | |||
| c5236b2e50 | |||
| fa94e5f96a | |||
| 88e86e6cfb | |||
| 04fe73531f | |||
| dc4f188648 | |||
| 411b400d04 | |||
| ec5e45e6c6 | |||
| f5b1feade6 | |||
| 19cb56cb21 | |||
| af08e6842a | |||
| c7ccb2d1c5 | |||
| 2ebb6ca745 | |||
| 1f040c4561 | |||
| ee85af09ed | |||
| 453862a653 | |||
| ff98df6acd | |||
| 75ed9c5ec2 | |||
| 0f7a2d1975 | |||
| a4f8497988 | |||
| 79d82647ef | |||
| 4e003a2490 | |||
| 0d394646d9 | |||
| 071e8dc529 | |||
| def3e3d372 | |||
| 97228e4655 | |||
| a507ab425c | |||
| e1b425ed5b | |||
| e4ad66c226 | |||
| 9003e92d6c | |||
| f244a6c9ff | |||
| 226ed85636 | |||
| 717f9000f2 | |||
| 0d8898576f | |||
| ab1bed8f30 | |||
| 93161b8278 | |||
| 090f26f58f | |||
| 1d04f4fd91 | |||
| abda48eaad | |||
| ad6f24dd7c | |||
| 2fe39ee2ba | |||
| 221ac4fad9 | |||
| 831f119398 | |||
| 540c676206 | |||
| 90d4f32c47 | |||
| 7fe8d6c500 | |||
| 7c1869853f | |||
| 3ece2fc3ec | |||
| f9f17ef8ac | |||
| a263ca9330 | |||
| ab96e354cc | |||
| 3d37461a73 | |||
| b1c68ad4f3 | |||
| 6338a5911f | |||
| 6ebaf0e28b | |||
| 55f01dc313 | |||
| c0df21c076 | |||
| 564d10feb6 | |||
| e1e2c08493 | |||
| cd85c5c57f | |||
| 03e5bae0aa | |||
| a4a0a76ad7 | |||
| 1d2b3b0996 | |||
| c3e3337de4 | |||
| 94a80603b0 | |||
| 42abc678a2 | |||
| 78a9eac356 | |||
| 8100c67a00 | |||
| 1c43e6f857 | |||
| 46c0e86723 | |||
| 21c63722f9 | |||
| f736c896ed | |||
| 0a732da414 | |||
| dc0a4ca45c | |||
| 399c893028 | |||
| ff96120cd4 | |||
| c87c21ce7c | |||
| 2a3a5bc875 | |||
| d8faa43820 | |||
| 37a63c5771 | |||
| 42c88235af | |||
| eb4f348a4c | |||
| b35eda205d | |||
| d26c704389 | |||
| e6dba195ce | |||
| ed657138dc | |||
| 47148353fb | |||
| 9aa8e72dc4 | |||
| b580b38a04 | |||
| c95662a96c | |||
| 15de77c8a6 | |||
| b80f6bcb1a | |||
| 5845308344 | |||
| b660c81a56 | |||
| a02cc1e213 | |||
| 755a69420c | |||
| 36c8c291f1 | |||
| bb1b1f6adc | |||
| 0e9abf91a1 | |||
| 0fd5d6b2b0 | |||
| e926fde159 | |||
| 81e8ff5bff | |||
| c0f5ceacfb | |||
| 79441ae09c | |||
| 3509da679c | |||
| edf3a80d8d | |||
| 0f7322b67d | |||
| c510f28a01 | |||
| 839c8eca6e | |||
| ba01c1e803 | |||
| 5efcaab95a | |||
| 73122d1faa | |||
| 26623ecc25 | |||
| 79c732e357 | |||
| 5b50701b3b | |||
| 71c13d634c | |||
| 029d76cf59 | |||
| 72bc9f9630 | |||
| ce719f8159 | |||
| b62edef938 | |||
| aaa866e3ff | |||
| 15e9e95129 | |||
| 924e118d92 | |||
| e3a70a04a3 | |||
| 0ec9ad0b26 | |||
| b4cd463fef | |||
| c4a64c26cd | |||
| d2d36c75c0 | |||
| c2287a5d08 | |||
| bcd55984a9 | |||
| ecc01825c0 | |||
| 400c950bb7 | |||
| 681923e3f7 | |||
| 89505eac7d | |||
| e7d2594142 | |||
| 0486842bc8 | |||
| 99397ad1f4 | |||
| 847719ab77 | |||
| 7d51da78c9 | |||
| 5f9b93280a | |||
| 5614c72472 | |||
| 2d3ba826cf | |||
| 6ca69ecda9 | |||
| 8ff9cde1e3 | |||
| 9fa73ecca2 | |||
| fdc019c106 | |||
| f4eff5d088 | |||
| ea18d07baf | |||
| ffd7d0e466 | |||
| 080dbdf9cd | |||
| 9533ba3b76 | |||
| 17ebc7ae4f | |||
| b2616817e5 | |||
| 32fe61b965 | |||
| 4e92c700bb | |||
| f1e5ce4499 | |||
| 410f06b2a2 | |||
| 4b701e5638 | |||
| 8919f8933a | |||
| 3617dd04e9 | |||
| 1496add6e4 | |||
| b8a0105d85 | |||
| 3f57a18b3f | |||
| c65e38fd1b | |||
| 8737b29475 | |||
| c25a8896ce | |||
| 4317c2297c | |||
| 6e852cb86f | |||
| 2f468ea0ec | |||
| 9bb9177d36 | |||
| f92b7faf0f | |||
| e94fd2949d | |||
| e1ac29d79f | |||
| f559fa1b32 | |||
| 46ee36a6dc | |||
| ca1b5da8e5 | |||
| 189fc08cdb | |||
| 71b96d836a | |||
| f2807d3e38 | |||
| 9b8721fa87 | |||
| a6abef224c | |||
| 1e646e35a2 | |||
| 6f0c695856 | |||
| 9d71915b7d | |||
| 29faf4d3b8 | |||
| 7730b58a02 | |||
| 8f4bd7afaf | |||
| 0d39f1cce1 | |||
| e18d27e9de | |||
| 52e44bee12 | |||
| 5c03754888 | |||
| b34a23ec48 | |||
| 5511576a65 | |||
| 30632e9b3a | |||
| ee6ee469f4 | |||
| f424fe8bbb | |||
| 515ff5662b | |||
| 0cc68cced9 | |||
| bd321dbab4 | |||
| 9b01dfaa14 | |||
| 7bfbbc5926 | |||
| 8d49252418 | |||
| b70b69eb63 | |||
| 8da726a280 | |||
| 7f95a3eb60 | |||
| 7e0bea60ee | |||
| 74862c131d | |||
| f2f042e0fa | |||
| b8dab3e54a | |||
| 186cd90d42 | |||
| a7598233a7 | |||
| 83b6a02e0f | |||
| 755b0af9d0 | |||
| d821ec56aa | |||
| e8c9510511 | |||
| 674512444e | |||
| 29d11a42df | |||
| 613ee475b7 | |||
| 5a0bbae746 | |||
| 0c0820a826 | |||
| dd8a0d4238 | |||
| 5d8090c0a0 | |||
| 5cc8ef227e | |||
| d0261d178d | |||
| 33635408f5 | |||
| 2fc68d12db | |||
| 791601f573 | |||
| 328804c50e | |||
| dfc138fa00 | |||
| c5e0dee764 | |||
| d3c1890ba1 | |||
| 2d840f3c0c | |||
| da3cd25880 | |||
| b987fa0e27 | |||
| dff5359b08 | |||
| 6d05a39b74 | |||
| d6b79b19bc | |||
| a93571d1e1 | |||
| 9ace1381c6 | |||
| 4d6aec0bca | |||
| d6f2720927 | |||
| c5bd65ab23 | |||
| cbabe5bce1 | |||
| f718f0aa61 | |||
| 76776dbc2f | |||
| 49bd5e6766 | |||
| cfefb2101e | |||
| 857c7c6a55 | |||
| b46d5a1f9c | |||
| e8d8a6feb5 | |||
| 8c68584fc2 | |||
| e2550cca31 | |||
| 6646a83378 | |||
| 1ff071a147 | |||
| 4684411d09 | |||
| d6714e6123 | |||
| 77bdc29b3e | |||
| 952da4d240 | |||
| 51cf663eb7 | |||
| b8ec83c25a | |||
| 0d096257c9 | |||
| 86faf86183 | |||
| c33247e347 | |||
| a47125d0d1 | |||
| 9bda76f5f5 | |||
| fde1c106c5 | |||
| 53f98b2788 | |||
| 6a467ea8e2 | |||
| a8575b7ff0 | |||
| 707bbed8d7 | |||
| f26eec09c4 | |||
| 0a056e101f | |||
| bac875baed | |||
| 496f1c0acd | |||
| 6085471053 | |||
| a72aa1366b | |||
| 83b003a303 | |||
| 62685b93e2 | |||
| 82e5af1dee | |||
| 7d08a76cff | |||
| 61b3bd651d | |||
| cd17b7dcfb | |||
| b6a82c5850 | |||
| 747da123aa | |||
| 7cc2f0c52c | |||
| 2f66dd8046 | |||
| 8458985c28 | |||
| 6a6b4e0139 | |||
| ba394926c5 | |||
| e29c9354fd | |||
| 429d38f771 | |||
| b8283860a7 | |||
| 456e1db6c8 | |||
| 40aae3a2ed | |||
| 4f27b9b763 | |||
| be4934862e | |||
| efda159191 | |||
| a664296fe5 | |||
| 189de76a42 | |||
| 1661389b07 | |||
| e90a730a29 | |||
| 9820db329e | |||
| d572f54e3b | |||
| 97405d4ad8 | |||
| 6beae3496f | |||
| e295424d1d | |||
| 5e96911834 | |||
| fdd8c083e8 | |||
| 45298a6f85 | |||
| 00c4d5b878 | |||
| 7343691bb1 | |||
| 9aaff97f06 | |||
| 226b0fb5d1 | |||
| 549a3281ec | |||
| 27f516e383 | |||
| 62d748b6b3 | |||
| bef52063c9 | |||
| b0ae913b33 | |||
| 57b5240c5c | |||
| 193f014627 | |||
| bd005c82c2 | |||
| 0f516a452b | |||
| 9ebf895733 | |||
| 554e111329 | |||
| 2f5010fbe2 | |||
| e1710eb59e | |||
| d072c6259b | |||
| 80de3ad5e1 | |||
| db7c8499b4 | |||
| 005acc2815 | |||
| d68a362115 | |||
| c583ad72d1 |
@@ -38,9 +38,9 @@ jobs:
|
||||
|
||||
- name: Set Branch
|
||||
run: |
|
||||
export APPS_JSON='[{"url": "https://github.com/frappe/lms","branch": "main"}]'
|
||||
export APPS_JSON='[{"url": "https://github.com/frappe/payments","branch": "version-15"},{"url": "https://github.com/frappe/lms","branch": "main"}]'
|
||||
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
|
||||
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
|
||||
echo "FRAPPE_BRANCH=version-16" >> $GITHUB_ENV
|
||||
|
||||
- name: Set Image Tag
|
||||
run: |
|
||||
@@ -61,4 +61,4 @@ jobs:
|
||||
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
|
||||
build-args: |
|
||||
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
|
||||
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
|
||||
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: UI
|
||||
name: UI Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -16,14 +16,14 @@ permissions:
|
||||
jobs:
|
||||
|
||||
test:
|
||||
name: UI Tests (Cypress) - ${{ matrix.containers }}
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'frappe' }}
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
name: UI Tests (Cypress)
|
||||
matrix:
|
||||
containers: [1, 2]
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
@@ -115,6 +115,15 @@ jobs:
|
||||
env:
|
||||
CYPRESS_BASE_URL: http://lms.test:8000
|
||||
CYPRESS_RECORD_KEY: 095366ec-7b9f-41bd-aeec-03bb76d627fe
|
||||
SPLIT: ${{ strategy.job-total }}
|
||||
SPLIT_INDEX: ${{ strategy.job-index }}
|
||||
|
||||
- name: Upload Cypress screenshots if tests fail
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cypress-screenshots-${{ matrix.containers }}
|
||||
path: cypress/screenshots
|
||||
|
||||
- name: Stop server and wait for coverage file
|
||||
run: |
|
||||
|
||||
@@ -152,11 +152,19 @@ You need Docker, docker-compose and git setup on your machine. Refer [Docker doc
|
||||
To setup the repository locally follow the steps mentioned below:
|
||||
|
||||
1. Install bench and setup a `frappe-bench` directory by following the [Installation Steps](https://frappeframework.com/docs/user/en/installation)
|
||||
1. Start the server by running `bench start`
|
||||
1. In a separate terminal window, create a new site by running `bench new-site learning.test`
|
||||
1. Map your site to localhost with the command `bench --site learning.test add-to-hosts`
|
||||
1. Get the Learning app. Run `bench get-app https://github.com/frappe/lms`
|
||||
1. Run `bench --site learning.test install-app lms`.
|
||||
1. Start the server by running
|
||||
```sh
|
||||
$ bench start
|
||||
```
|
||||
1. In a separate terminal window, run the following commands.
|
||||
```sh
|
||||
$ bench new-site learning.test
|
||||
$ bench --site learning.test add-to-hosts
|
||||
$ bench get-app https://github.com/frappe/payments
|
||||
$ bench get-app https://github.com/frappe/lms
|
||||
$ bench --site learning.test install-app lms
|
||||
|
||||
```
|
||||
1. Now open the URL `http://learning.test:8000/lms` in your browser, you should see the app running
|
||||
|
||||
## Learn and connect
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 1%
|
||||
|
||||
ignore:
|
||||
- "**/test_helper.py"
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from "cypress";
|
||||
import cypressSplit from "cypress-split";
|
||||
|
||||
export default defineConfig({
|
||||
projectId: "vandxn",
|
||||
@@ -14,5 +15,12 @@ export default defineConfig({
|
||||
},
|
||||
e2e: {
|
||||
baseUrl: "http://pertest:8000",
|
||||
setupNodeEvents(on, config) {
|
||||
// Splitting tests only works when Cypress Cloud is not orchestrating parallel runs.
|
||||
if (process.env.CYPRESS_CLOUD_PARALLEL !== "1") {
|
||||
cypressSplit(on, config);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("Batch Creation", () => {
|
||||
|
||||
// Open Settings
|
||||
cy.get("span").contains("Learning").click();
|
||||
cy.get("span").contains("Settings").click();
|
||||
cy.contains('[role="menuitem"]', "Settings").click();
|
||||
|
||||
// Add a new member
|
||||
cy.get("[data-dismissable-layer]")
|
||||
@@ -27,24 +27,24 @@ describe("Batch Creation", () => {
|
||||
cy.get("input[placeholder='Jane']").type(randomName);
|
||||
cy.get("button").contains("Add").click();
|
||||
|
||||
// Open Settings
|
||||
cy.get("span").contains("Learning").click();
|
||||
cy.get("span").contains("Settings").click();
|
||||
|
||||
// Add evaluator
|
||||
// Switch to Evaluators tab
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("span")
|
||||
.contains(/^Evaluators$/)
|
||||
.click();
|
||||
|
||||
// Click "New" dropdown and select "New Evaluator"
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("button")
|
||||
.contains("New")
|
||||
.click();
|
||||
const randomEvaluator = `evaluator${dateNow}@example.com`;
|
||||
cy.contains('[role="menuitem"]', "New Evaluator").click();
|
||||
|
||||
const randomEvaluator = `evaluator${dateNow}@example.com`;
|
||||
cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
|
||||
cy.get("input[placeholder='Jane']").type("Evaluator");
|
||||
cy.get("button").contains("Add").click();
|
||||
cy.wait(500);
|
||||
cy.get("div").contains(randomEvaluator).should("be.visible").click();
|
||||
|
||||
cy.visit("/lms/batches");
|
||||
@@ -52,7 +52,7 @@ describe("Batch Creation", () => {
|
||||
|
||||
// Create a batch
|
||||
cy.get("button").contains("Create").click();
|
||||
cy.get("span").contains("New Batch").click();
|
||||
cy.contains('[role="menuitem"]', "New Batch").click();
|
||||
cy.wait(500);
|
||||
cy.get("label").contains("Title").type("Test Batch");
|
||||
cy.get("label").contains("Start Date").type("2030-10-01");
|
||||
@@ -65,7 +65,7 @@ describe("Batch Creation", () => {
|
||||
cy.get("label")
|
||||
.contains("Description")
|
||||
.type("Test Batch Short Description to test the UI");
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
cy.get("div.ProseMirror").invoke(
|
||||
"text",
|
||||
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
@@ -74,7 +74,7 @@ describe("Batch Creation", () => {
|
||||
.contains("Instructors")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("input").click().type("evaluator");
|
||||
cy.get("input").click().clear().type(randomEvaluator);
|
||||
cy.get("input")
|
||||
.invoke("attr", "aria-controls")
|
||||
.as("instructor_list_id");
|
||||
@@ -87,7 +87,20 @@ describe("Batch Creation", () => {
|
||||
});
|
||||
});
|
||||
cy.button("Save").click();
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.wait(1000);
|
||||
|
||||
// going to batch settings and publishing the batch
|
||||
cy.url().should("include", "#settings");
|
||||
cy.closeOnboardingModal();
|
||||
cy.contains("label", "Published")
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`#${id}`)
|
||||
.scrollIntoView()
|
||||
.should("be.visible")
|
||||
.click({ force: true });
|
||||
cy.get(`#${id}`).should("have.attr", "aria-checked", "true");
|
||||
});
|
||||
cy.button("Save").click();
|
||||
cy.wait(1000);
|
||||
let batchName;
|
||||
@@ -105,11 +118,7 @@ describe("Batch Creation", () => {
|
||||
|
||||
cy.url().should("include", "/lms/batches");
|
||||
|
||||
cy.get('[id^="headlessui-radiogroup-v-"]')
|
||||
.find("span")
|
||||
.contains("Upcoming")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
cy.contains('[role="radio"]', "Upcoming").should("be.visible").click();
|
||||
|
||||
cy.get("@batchName").then((batchName) => {
|
||||
cy.get(`a[href='/lms/batches/${batchName}'`).within(() => {
|
||||
@@ -154,6 +163,7 @@ describe("Batch Creation", () => {
|
||||
cy.get("button:visible").contains("Dashboard").click();
|
||||
|
||||
/* Add student to batch */
|
||||
cy.closeOnboardingModal();
|
||||
cy.get("button").contains("Enroll").click();
|
||||
cy.get('div[role="dialog"]')
|
||||
.first()
|
||||
|
||||
@@ -9,22 +9,29 @@ describe("Course Creation", () => {
|
||||
|
||||
// Create a course
|
||||
cy.get("button").contains("Create").click();
|
||||
cy.get("span").contains("New Course").click();
|
||||
cy.contains('[role="menuitem"]', "New Course").click();
|
||||
cy.wait(500);
|
||||
|
||||
cy.get("label").contains("Title").type("Test Course");
|
||||
cy.get("label")
|
||||
.contains("Short Introduction")
|
||||
.type("Test Course Short Introduction to test the UI");
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
cy.get("div.ProseMirror").invoke(
|
||||
"text",
|
||||
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
|
||||
cy.fixture("profile.png", "base64").then((fileContent) => {
|
||||
cy.contains("Course Image")
|
||||
.should("exist")
|
||||
.parent()
|
||||
.find('input[type="file"]')
|
||||
.attachFile("profile.png", { force: true });
|
||||
|
||||
/* cy.fixture("profile.png", "base64").then((fileContent) => {
|
||||
expect(fileContent).to.exist;
|
||||
cy.get("div")
|
||||
.contains("Course Image")
|
||||
.siblings("div")
|
||||
.parent()
|
||||
.children('input[type="file"]')
|
||||
.attachFile({
|
||||
fileContent,
|
||||
@@ -32,7 +39,7 @@ describe("Course Creation", () => {
|
||||
mimeType: "image/png",
|
||||
encoding: "base64",
|
||||
});
|
||||
});
|
||||
}); */
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
@@ -54,7 +61,7 @@ describe("Course Creation", () => {
|
||||
});
|
||||
|
||||
cy.button("Save").last().click();
|
||||
|
||||
cy.closeOnboardingModal();
|
||||
// Edit Course Details
|
||||
cy.wait(500);
|
||||
cy.get("label")
|
||||
@@ -74,10 +81,10 @@ describe("Course Creation", () => {
|
||||
cy.button("Save").click();
|
||||
|
||||
// Add Chapter
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.button("Add").click();
|
||||
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
@@ -86,12 +93,10 @@ describe("Course Creation", () => {
|
||||
});
|
||||
|
||||
// Add Lesson
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.button("Add Lesson").click();
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/learn/1-1/edit");
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get("label").contains("Title").type("Test Lesson");
|
||||
cy.get("#content .ce-block").type(
|
||||
"{enter}This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
@@ -99,7 +104,7 @@ describe("Course Creation", () => {
|
||||
cy.button("Save").click();
|
||||
|
||||
// View Course
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.visit("/lms/courses");
|
||||
cy.closeOnboardingModal();
|
||||
|
||||
@@ -133,7 +138,7 @@ describe("Course Creation", () => {
|
||||
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
|
||||
cy.get("div").contains("Test Lesson").click();
|
||||
});
|
||||
cy.wait(3000);
|
||||
cy.wait(500);
|
||||
|
||||
// View Lesson
|
||||
cy.url().should("include", "/learn/1-1");
|
||||
@@ -148,7 +153,7 @@ describe("Course Creation", () => {
|
||||
cy.wait(500);
|
||||
cy.get("[data-dismissable-layer]").within(() => {
|
||||
cy.get("label").contains("Title").type("Test Discussion");
|
||||
cy.get("div[contenteditable=true]").invoke(
|
||||
cy.get("div.ProseMirror").invoke(
|
||||
"text",
|
||||
"This is a test discussion. This will check if the UI is working properly."
|
||||
);
|
||||
@@ -158,7 +163,7 @@ describe("Course Creation", () => {
|
||||
// View Discussion
|
||||
cy.wait(500);
|
||||
cy.get("div").contains("Test Discussion").click();
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
cy.get("div.ProseMirror").invoke(
|
||||
"text",
|
||||
"This is a test comment. This will check if the UI is working properly."
|
||||
);
|
||||
@@ -166,5 +171,19 @@ describe("Course Creation", () => {
|
||||
cy.get("div").contains(
|
||||
"This is a test comment. This will check if the UI is working properly."
|
||||
);
|
||||
|
||||
// Delete Course
|
||||
cy.get("div").contains("Test Course").click();
|
||||
cy.get("button").contains("Settings").click();
|
||||
cy.get("header").within(() => {
|
||||
cy.get("svg.lucide.lucide-ellipsis-icon").click();
|
||||
});
|
||||
cy.get("div[role=menu]").within(() => {
|
||||
cy.contains('[role="menuitem"]', "Delete").click();
|
||||
});
|
||||
cy.get("span").contains("Delete").click();
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/lms/courses");
|
||||
cy.get("div").contains("Test Course").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,15 +72,19 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
|
||||
|
||||
Cypress.Commands.add("closeOnboardingModal", () => {
|
||||
cy.wait(500);
|
||||
const modalSelector = '[data-testid="onboarding-help-modal"]';
|
||||
cy.get("body").then(($body) => {
|
||||
// Check if any element with class including 'z-50' exists
|
||||
if ($body.find('[class*="z-50"]').length > 0) {
|
||||
cy.get('[class*="z-50"]')
|
||||
.find('button:has(svg[class*="feather-x"])')
|
||||
.realClick();
|
||||
cy.wait(1000);
|
||||
} else {
|
||||
cy.log("Onboarding modal not found, skipping close.");
|
||||
if (!$body.find(modalSelector).length) {
|
||||
cy.log("Onboarding modal not present, skipping close.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip onboarding steps if the button exists, otherwise just close the modal.
|
||||
if ($body.find(`${modalSelector} button:contains("Skip all")`).length) {
|
||||
cy.get(modalSelector).contains("button", "Skip all").click();
|
||||
}
|
||||
|
||||
cy.get(modalSelector).find("button:has(svg.feather-x)").click();
|
||||
cy.get(modalSelector).should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ bench set-redis-socketio-host redis://redis:6379
|
||||
sed -i '/redis/d' ./Procfile
|
||||
sed -i '/watch/d' ./Procfile
|
||||
|
||||
bench get-app payments
|
||||
bench get-app lms
|
||||
|
||||
bench new-site lms.localhost \
|
||||
@@ -32,6 +33,7 @@ bench new-site lms.localhost \
|
||||
--admin-password admin \
|
||||
--no-mariadb-socket
|
||||
|
||||
bench --site lms.localhost install-app payments
|
||||
bench --site lms.localhost install-app lms
|
||||
bench --site lms.localhost set-config developer_mode 1
|
||||
bench --site lms.localhost clear-cache
|
||||
|
||||
Vendored
+2
@@ -8,6 +8,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AddEvaluatorModal: typeof import('./src/components/Modals/AddEvaluatorModal.vue')['default']
|
||||
Apps: typeof import('./src/components/Sidebar/Apps.vue')['default']
|
||||
AppSidebar: typeof import('./src/components/Sidebar/AppSidebar.vue')['default']
|
||||
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
|
||||
@@ -78,6 +79,7 @@ declare module 'vue' {
|
||||
Members: typeof import('./src/components/Settings/Members.vue')['default']
|
||||
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
|
||||
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
||||
NewMemberModal: typeof import('./src/components/Modals/NewMemberModal.vue')['default']
|
||||
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
||||
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
|
||||
|
||||
+15
-15
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{ boot.lang }}" dir="{{ boot.text_direction }}">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="{{ favicon }}" />
|
||||
@@ -201,26 +201,26 @@
|
||||
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ title }}</title>
|
||||
<meta name="title" content="{{ meta.title }}" />
|
||||
<meta name="image" content="{{ meta.image }}" />
|
||||
<meta name="description" content="{{ meta.description }}" />
|
||||
<meta name="keywords" content="{{ meta.keywords }}" />
|
||||
<meta property="og:title" content="{{ meta.title }}" />
|
||||
<meta property="og:image" content="{{ meta.image }}" />
|
||||
<meta property="og:description" content="{{ meta.description }}" />
|
||||
<meta name="twitter:title" content="{{ meta.title }}" />
|
||||
<meta name="twitter:image" content="{{ meta.image }}" />
|
||||
<meta name="twitter:description" content="{{ meta.description }}" />
|
||||
<title>{{ title | e }}</title>
|
||||
<meta name="title" content="{{ meta.title | e }}" />
|
||||
<meta name="image" content="{{ meta.image | e }}" />
|
||||
<meta name="description" content="{{ meta.description | e }}" />
|
||||
<meta name="keywords" content="{{ meta.keywords | e }}" />
|
||||
<meta property="og:title" content="{{ meta.title | e }}" />
|
||||
<meta property="og:image" content="{{ meta.image | e }}" />
|
||||
<meta property="og:description" content="{{ meta.description | e }}" />
|
||||
<meta name="twitter:title" content="{{ meta.title | e }}" />
|
||||
<meta name="twitter:image" content="{{ meta.image | e }}" />
|
||||
<meta name="twitter:description" content="{{ meta.description | e }}" />
|
||||
</head>
|
||||
<body class="sm:overscroll-y-none no-scrollbar">
|
||||
<div id="app">
|
||||
<div id="seo-content">
|
||||
<h1>{{ meta.title }}</h1>
|
||||
<h1>{{ meta.title | e }}</h1>
|
||||
<p>
|
||||
{{ meta.description }}
|
||||
{{ meta.description | e }}
|
||||
</p>
|
||||
<a href="{{ meta.link }}">Know More</a>
|
||||
<a href="{{ meta.link | e }}">Know More</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"dayjs": "1.11.10",
|
||||
"dompurify": "3.2.6",
|
||||
"feather-icons": "4.28.0",
|
||||
"frappe-ui": "^0.1.264",
|
||||
"frappe-ui": "^0.1.276",
|
||||
"highlight.js": "11.11.1",
|
||||
"lucide-vue-next": "0.383.0",
|
||||
"markdown-it": "14.0.0",
|
||||
@@ -49,7 +49,7 @@
|
||||
"vuedraggable": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "5.0.3",
|
||||
"@vitejs/plugin-vue": "5.0.3",
|
||||
"autoprefixer": "10.4.2",
|
||||
"postcss": "8.4.5",
|
||||
"tailwindcss": "^3.4.15",
|
||||
|
||||
@@ -49,8 +49,9 @@
|
||||
:label="__('Select an Assignment')"
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
<Switch
|
||||
size="sm"
|
||||
:description="__('Only show assignments from the current course')"
|
||||
:label="__('Filter assignments by course')"
|
||||
v-model="filterAssignmentsByCourse"
|
||||
/>
|
||||
@@ -61,7 +62,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FormControl } from 'frappe-ui'
|
||||
import { Dialog, Switch } from 'frappe-ui'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getLmsRoute } from '@/utils/basePath'
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:class="{ 'border rounded-lg overflow-auto': !showTitle }"
|
||||
>
|
||||
<div
|
||||
class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]"
|
||||
class="border-e p-5 overflow-y-auto h-[calc(100vh-3.2rem)]"
|
||||
:class="{ 'h-full': !showTitle }"
|
||||
>
|
||||
<div v-if="showTitle" class="text-lg font-semibold mb-5 text-ink-gray-9">
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-ink-gray-9 font-semibold mb-5">
|
||||
{{ __('Assignment Question') }}
|
||||
{{ __('Assignment') }}: {{ assignment.data.title }}
|
||||
</div>
|
||||
<div
|
||||
v-html="assignment.data.question"
|
||||
@@ -31,7 +31,7 @@
|
||||
<div class="font-semibold text-ink-gray-9">
|
||||
{{ __('Submission') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Badge v-if="isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
@@ -43,7 +43,7 @@
|
||||
{{ submissionResource.doc?.status }}
|
||||
</Badge>
|
||||
<Button
|
||||
v-if="canModifyAssignment"
|
||||
v-if="canModifyAssignment || canGradeSubmission"
|
||||
variant="solid"
|
||||
@click="submitAssignment()"
|
||||
>
|
||||
@@ -106,7 +106,7 @@
|
||||
class="cursor-pointer !no-underline text-sm leading-5"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<div class="border rounded-md p-2 me-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5" />
|
||||
</div>
|
||||
<span>
|
||||
@@ -117,7 +117,7 @@
|
||||
<X
|
||||
v-if="canModifyAssignment"
|
||||
@click="removeSubmission()"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ms-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,7 +281,6 @@ const submissionResource = createDocumentResource({
|
||||
|
||||
watch(submissionResource, () => {
|
||||
if (!submissionResource.doc) return
|
||||
console.log(submissionResource.doc)
|
||||
if (submissionResource.doc.answer) {
|
||||
answer.value = submissionResource.doc.answer
|
||||
}
|
||||
@@ -301,7 +300,7 @@ const submitAssignment = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const addNewSubmission = () => {
|
||||
const prepareSubmissionDoc = () => {
|
||||
let doc = {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
assignment: props.assignmentID,
|
||||
@@ -312,24 +311,31 @@ const addNewSubmission = () => {
|
||||
} else {
|
||||
doc.assignment_attachment = attachment.value
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
const addNewSubmission = () => {
|
||||
let doc = prepareSubmissionDoc()
|
||||
if (!doc.assignment_attachment && !doc.answer) {
|
||||
toast.error(
|
||||
__('Please provide an answer or upload a file before submitting.')
|
||||
)
|
||||
return
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: doc,
|
||||
})
|
||||
.then((data) => {
|
||||
toast.success(__('Assignment submitted successfully'))
|
||||
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: props.assignmentID,
|
||||
submissionName: data.name,
|
||||
},
|
||||
query: { fromLesson: router.currentRoute.value.query.fromLesson },
|
||||
})
|
||||
} else {
|
||||
markLessonProgress()
|
||||
router.go()
|
||||
}
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: props.assignmentID,
|
||||
submissionName: data.name,
|
||||
},
|
||||
query: { fromLesson: router.currentRoute.value.query.fromLesson },
|
||||
})
|
||||
markLessonProgress()
|
||||
isDirty.value = false
|
||||
submissionResource.name = data.name
|
||||
submissionResource.reload()
|
||||
@@ -373,15 +379,17 @@ const saveSubmission = (file) => {
|
||||
}
|
||||
|
||||
const markLessonProgress = () => {
|
||||
if (router.currentRoute.value.name == 'Lesson') {
|
||||
let courseName = router.currentRoute.value.params.courseName
|
||||
let chapterNumber = router.currentRoute.value.params.chapterNumber
|
||||
let lessonNumber = router.currentRoute.value.params.lessonNumber
|
||||
let pathname = window.location.pathname.split('/')
|
||||
if (!pathname.includes('courses'))
|
||||
pathname = window.parent.location.pathname.split('/')
|
||||
if (pathname[2] != 'courses') return
|
||||
let lessonIndex = pathname.pop().split('-')
|
||||
|
||||
if (lessonIndex.length == 2) {
|
||||
call('lms.lms.api.mark_lesson_progress', {
|
||||
course: courseName,
|
||||
chapter_number: chapterNumber,
|
||||
lesson_number: lessonNumber,
|
||||
course: pathname[3],
|
||||
chapter_number: lessonIndex[0],
|
||||
lesson_number: lessonIndex[1],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -405,7 +413,7 @@ const getType = () => {
|
||||
|
||||
const removeSubmission = () => {
|
||||
isDirty.value = true
|
||||
submissionResource.doc.assignment_attachment = ''
|
||||
attachment.value = null
|
||||
}
|
||||
|
||||
const canGradeSubmission = computed(() => {
|
||||
@@ -419,9 +427,7 @@ const canGradeSubmission = computed(() => {
|
||||
})
|
||||
|
||||
const canModifyAssignment = computed(() => {
|
||||
if (canGradeSubmission.value) {
|
||||
return true
|
||||
} else if (props.submissionName == 'new') {
|
||||
if (props.submissionName == 'new') {
|
||||
return true
|
||||
} else if (
|
||||
submissionResource.doc?.owner == user.data?.name &&
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<audio @ended="handleAudioEnd" controlsList="nodownload" class="mb-4">
|
||||
<source :src="encodeURI(file)" type="audio/mp3" />
|
||||
</audio>
|
||||
<div class="flex items-center space-x-2 shadow rounded-lg p-1 w-1/2">
|
||||
<div class="flex items-center gap-x-2 shadow rounded-lg p-1 w-1/2">
|
||||
<Button variant="ghost" @click="togglePlay">
|
||||
<template #icon>
|
||||
<Play v-if="!isPlaying" class="w-4 h-4 text-ink-gray-9" />
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<Dialog v-model="show" :options="{ size: '2xl' }">
|
||||
<template #body>
|
||||
<div class="text-base">
|
||||
<div class="flex items-center space-x-2 pl-4.5 border-b">
|
||||
<div class="flex items-center gap-x-2 ps-4.5 border-b">
|
||||
<Search class="size-4 text-ink-gray-4" />
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
class="w-full border-none bg-transparent py-3 !pl-2 pr-4.5 text-base text-ink-gray-7 placeholder-ink-gray-4 focus:ring-0"
|
||||
class="w-full border-none bg-transparent py-3 !ps-2 pe-4.5 text-base text-ink-gray-7 placeholder-ink-gray-4 focus:ring-0"
|
||||
@input="onInput"
|
||||
v-model="query"
|
||||
autocomplete="off"
|
||||
@@ -32,9 +32,9 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center space-x-5 w-full border-t py-2 text-sm text-ink-gray-7 px-4.5"
|
||||
class="flex items-center gap-x-5 w-full border-t py-2 text-sm text-ink-gray-7 px-4.5"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<MoveUp
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
@@ -45,7 +45,7 @@
|
||||
{{ __('to navigate') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<CornerDownLeft
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
@@ -53,7 +53,7 @@
|
||||
{{ __('to select') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<span class="bg-surface-gray-2 p-1 rounded-sm"> esc </span>
|
||||
<span>
|
||||
{{ __('to close') }}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
:class="{ 'bg-surface-gray-2': item.isActive }"
|
||||
@click="emit('navigateTo', item.route)"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex items-center text-ink-gray-7">
|
||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<Calendar class="h-4 w-4 stroke-1.5 me-2" />
|
||||
<span>
|
||||
{{ getFormattedDateRange(props.startDate, props.endDate) }}
|
||||
</span>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="pb-5 float-right">
|
||||
<div class="pb-5 float-end">
|
||||
<Button variant="solid" @click="sendMail(close)">
|
||||
{{ __('Send') }}
|
||||
</Button>
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
nullable
|
||||
v-slot="{ open: isComboboxOpen }"
|
||||
>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<Popover
|
||||
class="w-full"
|
||||
v-model:show="showOptions"
|
||||
:matchTargetWidth="true"
|
||||
>
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full">
|
||||
@@ -61,7 +65,7 @@
|
||||
placeholder="Search"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
class="absolute end-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="selectedValue = null"
|
||||
>
|
||||
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
|
||||
@@ -92,7 +96,8 @@
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base',
|
||||
'flex items-center rounded px-2.5 text-base py-1.5',
|
||||
optionLines(option).secondary ? '' : 'h-7',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
@@ -104,16 +109,20 @@
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div
|
||||
class="flex flex-col px-1"
|
||||
:class="
|
||||
optionLines(option).secondary ? 'gap-0.5' : ''
|
||||
"
|
||||
>
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value == option.label && option.description
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
{{ optionLines(option).primary }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
<div
|
||||
v-if="optionLines(option).secondary"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ optionLines(option).secondary }}
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
@@ -245,6 +254,17 @@ function filterOptions(options) {
|
||||
})
|
||||
}
|
||||
|
||||
function optionLines(option) {
|
||||
const primary = option.label
|
||||
let secondary = null
|
||||
if (option.description && option.description !== primary) {
|
||||
secondary = option.description
|
||||
} else if (option.value && option.value !== primary) {
|
||||
secondary = option.value
|
||||
}
|
||||
return { primary, secondary }
|
||||
}
|
||||
|
||||
function displayValue(option) {
|
||||
if (typeof option === 'string') {
|
||||
let allOptions = groups.value.flatMap((group) => group.items)
|
||||
@@ -288,9 +308,9 @@ const inputClasses = computed(() => {
|
||||
let variant = props.disabled ? 'disabled' : props.variant
|
||||
let variantClasses = {
|
||||
subtle:
|
||||
'border border-outline-gray-modals bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
'border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3',
|
||||
outline:
|
||||
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3',
|
||||
disabled: [
|
||||
'border bg-surface-menu-bar placeholder-ink-gray-3',
|
||||
props.variant === 'outline'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="overflow-visible border border-outline-gray-modals rounded-md">
|
||||
<div class="overflow-x-auto">
|
||||
<div
|
||||
class="grid items-center space-x-4 p-2 border-b border-outline-gray-modals"
|
||||
class="grid items-center gap-x-4 p-2 border-b border-outline-gray-modals"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<div
|
||||
@@ -21,7 +21,7 @@
|
||||
<div
|
||||
v-for="(row, rowIndex) in rows"
|
||||
:key="rowIndex"
|
||||
class="grid items-center space-x-4 p-2"
|
||||
class="grid items-center gap-x-4 p-2"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<template v-for="key in Object.keys(row)" :key="key">
|
||||
@@ -47,7 +47,7 @@
|
||||
<div
|
||||
v-if="menuOpenIndex === rowIndex"
|
||||
ref="menuRef"
|
||||
class="absolute right-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
|
||||
class="absolute end-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
|
||||
:class="
|
||||
rowIndex == (rows?.length ?? 0) - 1
|
||||
? 'bottom-full mb-1'
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
<button
|
||||
@click="deleteRow(rowIndex)"
|
||||
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
|
||||
class="flex items-center gap-x-2 w-full text-start px-3 py-2 text-sm text-ink-red-3"
|
||||
>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
@click="openPopover(togglePopover)"
|
||||
class="flex w-full items-center space-x-2 focus:outline-none bg-surface-gray-2 rounded h-7 py-1.5 px-2 hover:bg-surface-gray-3 focus:bg-surface-white border border-gray-100 hover:border-outline-gray-modals focus:border-outline-gray-4"
|
||||
class="flex w-full items-center gap-x-2 focus:outline-none bg-surface-gray-2 rounded h-7 py-1.5 px-2 hover:bg-surface-gray-3 focus:bg-surface-white border border-gray-100 hover:border-outline-gray-modals focus:border-outline-gray-4"
|
||||
>
|
||||
<component
|
||||
v-if="selectedIcon"
|
||||
|
||||
@@ -30,28 +30,48 @@
|
||||
</template>
|
||||
|
||||
<template #footer="{ value, close }">
|
||||
<div v-if="attrs.onCreate">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate(value, close)"
|
||||
<div v-if="creating" class="flex items-center gap-1">
|
||||
<button
|
||||
class="p-1 rounded hover:bg-surface-gray-3 text-ink-gray-5"
|
||||
@click="creating = false"
|
||||
:aria-label="__('Cancel')"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
<ArrowLeft class="size-4 stroke-1.5" />
|
||||
</button>
|
||||
<FormControl
|
||||
v-model="newItemName"
|
||||
class="flex-1 min-w-0"
|
||||
size="sm"
|
||||
:placeholder="__(props.inlineCreatePlaceholder)"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
:disabled="!newItemName.trim()"
|
||||
@click="submitCreate"
|
||||
:aria-label="__('Create')"
|
||||
>
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div v-else class="flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Clear')"
|
||||
@click="() => clearValue(close)"
|
||||
:aria-label="__('Clear')"
|
||||
>
|
||||
{{ __('Clear') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="props.onCreate"
|
||||
variant="ghost"
|
||||
@click="handleCreate(close)"
|
||||
:aria-label="__('Create New')"
|
||||
>
|
||||
<template #prefix>
|
||||
<X class="h-4 w-4 stroke-1.5" />
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Create New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,8 +83,8 @@
|
||||
<script setup>
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { createResource, Button, FormControl } from 'frappe-ui'
|
||||
import { Plus, ArrowLeft } from 'lucide-vue-next'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
@@ -85,11 +105,25 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inlineCreate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inlineCreatePlaceholder: {
|
||||
type: String,
|
||||
default: 'Enter...',
|
||||
},
|
||||
onCreate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
const attrs = useAttrs()
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
const creating = ref(false)
|
||||
const newItemName = ref('')
|
||||
|
||||
const value = computed({
|
||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||
@@ -105,6 +139,26 @@ const autocomplete = ref(null)
|
||||
const text = ref('')
|
||||
const settingsStore = useSettings()
|
||||
|
||||
function handleCreate(close) {
|
||||
if (props.inlineCreate) {
|
||||
creating.value = true
|
||||
return
|
||||
}
|
||||
if (props.onCreate) {
|
||||
props.onCreate(null, close)
|
||||
}
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
if (!newItemName.value.trim() || !props.onCreate) return
|
||||
const value = newItemName.value.trim()
|
||||
props.onCreate(value, () => {
|
||||
creating.value = false
|
||||
newItemName.value = ''
|
||||
reload()
|
||||
})
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => autocomplete.value?.query,
|
||||
(val) => {
|
||||
@@ -140,7 +194,7 @@ const options = createResource({
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
filters: JSON.stringify(props.filters),
|
||||
},
|
||||
transform: (data) => {
|
||||
return data.map((option) => {
|
||||
@@ -153,12 +207,12 @@ const options = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const reload = (val) => {
|
||||
const reload = (val = '') => {
|
||||
options.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
filters: JSON.stringify(props.filters),
|
||||
},
|
||||
})
|
||||
options.reload()
|
||||
@@ -178,4 +232,6 @@ const labelClasses = computed(() => {
|
||||
'text-ink-gray-5',
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({ reload })
|
||||
</script>
|
||||
|
||||
@@ -6,18 +6,34 @@
|
||||
</label>
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
|
||||
<div class="relative w-full">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full focus-visible:!ring-0"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5 w-full rounded-lg border border-[--surface-gray-2] bg-surface-gray-2 px-2 py-1.5 cursor-text transition-colors focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3"
|
||||
@click="focusInput"
|
||||
>
|
||||
<button
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 bg-surface-white border border-outline-gray-2 text-ink-gray-7 ps-2 pe-1.5 py-0.5 rounded text-base leading-5"
|
||||
@click.stop="removeValue(value)"
|
||||
>
|
||||
<span>{{ value }}</span>
|
||||
<X class="size-3.5 stroke-1.5 shrink-0" />
|
||||
</button>
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="flex-1 min-w-[4rem] border-none outline-none bg-transparent p-0 text-base focus:ring-0"
|
||||
type="text"
|
||||
:placeholder="!values?.length ? __('Search...') : ''"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
</div>
|
||||
<ComboboxButton ref="trigger" class="hidden" />
|
||||
<ComboboxOptions
|
||||
v-show="open"
|
||||
@@ -26,7 +42,7 @@
|
||||
>
|
||||
<div
|
||||
class="flex-1 my-1 overflow-y-auto px-1.5"
|
||||
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
|
||||
:class="options.length ? 'min-h-[6rem]' : 'min-h-[1rem]'"
|
||||
>
|
||||
<template v-if="options.length">
|
||||
<ComboboxOption
|
||||
@@ -80,21 +96,6 @@
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
|
||||
<!-- Selected values -->
|
||||
<div v-if="values?.length" class="grid grid-cols-2 gap-2 mt-1">
|
||||
<div
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||
>
|
||||
<span>{{ value }}</span>
|
||||
<X
|
||||
class="size-4 stroke-1.5 cursor-pointer"
|
||||
@click="removeValue(value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -106,7 +107,7 @@ import {
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { createResource, Button, toast } from 'frappe-ui'
|
||||
import { ref, computed, useAttrs, watch } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { X, Plus } from 'lucide-vue-next'
|
||||
@@ -115,7 +116,9 @@ const props = defineProps({
|
||||
label: String,
|
||||
size: { type: String, default: 'sm' },
|
||||
doctype: { type: String, required: true },
|
||||
filters: { type: Object, default: () => ({}) },
|
||||
filters: { type: [Object, Array], default: () => ({}) },
|
||||
url: { type: String, default: 'frappe.desk.search.search_link' },
|
||||
searchParams: { type: Object, default: () => ({}) },
|
||||
validate: Function,
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
@@ -124,22 +127,18 @@ const props = defineProps({
|
||||
required: Boolean,
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
const values = defineModel({ default: () => [] })
|
||||
const attrs = useAttrs()
|
||||
const trigger = ref(null)
|
||||
const query = ref('')
|
||||
const text = ref('')
|
||||
const selectedValue = ref(null)
|
||||
const error = ref(null)
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
watch(selectedValue, (val) => {
|
||||
if (!val?.value) return
|
||||
query.value = ''
|
||||
addValue(val.value)
|
||||
selectedValue.value = null
|
||||
emit('update:modelValue', values.value)
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
@@ -153,14 +152,27 @@ watchDebounced(
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
const filterOptions = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
method: 'POST',
|
||||
auto: true,
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
// Refetch when filters or searchParams change
|
||||
watch(
|
||||
() => [props.filters, props.searchParams],
|
||||
() => {
|
||||
reload(text.value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function getParams(txt) {
|
||||
return {
|
||||
txt,
|
||||
doctype: props.doctype,
|
||||
filters: JSON.stringify(props.filters),
|
||||
...props.searchParams,
|
||||
}
|
||||
}
|
||||
|
||||
const filterOptions = createResource({
|
||||
url: props.url,
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
@@ -170,10 +182,7 @@ const options = computed(() => {
|
||||
|
||||
function reload(val) {
|
||||
filterOptions.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
},
|
||||
params: getParams(val),
|
||||
})
|
||||
filterOptions.reload()
|
||||
}
|
||||
@@ -186,34 +195,30 @@ function onFocus() {
|
||||
}
|
||||
|
||||
function addValue(value) {
|
||||
error.value = null
|
||||
|
||||
if (!value) return
|
||||
|
||||
const splitValues = value.split(',')
|
||||
let newValues = [...(values.value || [])]
|
||||
|
||||
splitValues.forEach((val) => {
|
||||
val = val.trim()
|
||||
|
||||
if (!val) return
|
||||
if (values.value?.includes(val)) return
|
||||
if (newValues.includes(val)) return
|
||||
|
||||
if (props.validate && !props.validate(val)) {
|
||||
error.value = props.errorMessage(val)
|
||||
toast.error(props.errorMessage(val))
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.value) values.value = [val]
|
||||
else values.value.push(val)
|
||||
newValues.push(val)
|
||||
})
|
||||
|
||||
values.value = newValues
|
||||
}
|
||||
|
||||
function removeValue(value) {
|
||||
let indexToRemove = values.value.indexOf(value)
|
||||
if (indexToRemove > -1) {
|
||||
values.value.splice(indexToRemove, 1)
|
||||
}
|
||||
emit('update:modelValue', values.value)
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
}
|
||||
|
||||
const labelClasses = computed(() => [
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
@mouseleave="hoveredRating = 0"
|
||||
>
|
||||
<Star
|
||||
class="fill-gray-400 text-gray-50 stroke-1 mr-1 cursor-pointer"
|
||||
class="fill-gray-400 text-gray-50 stroke-1 me-1 cursor-pointer"
|
||||
:class="iconClasses(index)"
|
||||
@click="markRating(index)"
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
:fileTypes="[fileType]"
|
||||
:validateFile="(file: File) => validateFile(file, true, type)"
|
||||
@success="(file: File) => saveFile(file)"
|
||||
@failure="onUploadFailure"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="flex items-center">
|
||||
@@ -18,9 +19,9 @@
|
||||
class="size-5 stroke-1 text-ink-gray-7"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
<div class="ms-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `${__('Uploading')} ${progress}%` : __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
|
||||
{{ __(description) }}
|
||||
@@ -45,7 +46,7 @@
|
||||
<source :src="modelValue" />
|
||||
{{ __('Your browser does not support the video tag.') }}
|
||||
</video>
|
||||
<div class="ml-4">
|
||||
<div class="ms-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
@@ -62,7 +63,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { validateFile } from '@/utils'
|
||||
import { Button, FileUploader } from 'frappe-ui'
|
||||
import { Button, FileUploader, toast } from 'frappe-ui'
|
||||
import { Image, Video } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -100,4 +101,14 @@ const saveFile = (file: any) => {
|
||||
const removeImage = () => {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
const onUploadFailure = (error: any) => {
|
||||
let message = __('Error Uploading File')
|
||||
if (error?._server_messages) {
|
||||
message = JSON.parse(JSON.parse(error._server_messages)[0]).message
|
||||
} else if (error?.exc) {
|
||||
message = JSON.parse(error.exc)[0].split('\n').slice(-2, -1)[0]
|
||||
}
|
||||
toast.error(message)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="course.title"
|
||||
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9"
|
||||
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9 bg-surface-cards"
|
||||
style="min-height: 350px"
|
||||
>
|
||||
<div
|
||||
@@ -10,7 +10,7 @@
|
||||
course.image
|
||||
? { backgroundImage: `url('${encodeURI(course.image)}')` }
|
||||
: {
|
||||
backgroundImage: getGradientColor(),
|
||||
backgroundImage: gradientColor,
|
||||
backgroundBlendMode: 'screen',
|
||||
}
|
||||
"
|
||||
@@ -18,7 +18,7 @@
|
||||
<!-- <div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
|
||||
<div
|
||||
v-if="course.featured"
|
||||
class="flex items-center space-x-1 text-xs text-ink-amber-3 bg-surface-white border border-outline-amber-1 px-2 py-0.5 rounded-md mr-1 mb-1"
|
||||
class="flex items-center gap-x-1 text-xs text-ink-amber-3 bg-surface-white border border-outline-amber-1 px-2 py-0.5 rounded-md me-1 mb-1"
|
||||
>
|
||||
<Star class="size-3 stroke-2" />
|
||||
<span>
|
||||
@@ -28,7 +28,7 @@
|
||||
<div
|
||||
v-if="course.tags"
|
||||
v-for="tag in course.tags?.split(', ')"
|
||||
class="text-xs border bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 mr-1"
|
||||
class="text-xs border bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 me-1"
|
||||
>
|
||||
{{ tag }}
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@
|
||||
<div v-if="course.lessons">
|
||||
<Tooltip :text="__('Lessons')">
|
||||
<span class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 me-1" />
|
||||
{{ course.lessons }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -61,7 +61,7 @@
|
||||
<div v-if="course.enrollments">
|
||||
<Tooltip :text="__('Enrolled Students')">
|
||||
<span class="flex items-center">
|
||||
<Users class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
<Users class="h-4 w-4 stroke-1.5 me-1" />
|
||||
{{ formatAmount(course.enrollments) }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -70,7 +70,7 @@
|
||||
<div v-if="course.rating">
|
||||
<Tooltip :text="__('Average Rating')">
|
||||
<span class="flex items-center">
|
||||
<Star class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
<Star class="h-4 w-4 stroke-1.5 me-1" />
|
||||
{{ course.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -105,7 +105,7 @@
|
||||
<div class="flex items-center justify-between mt-auto">
|
||||
<div class="flex avatar-group overlap">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
class="h-6 me-1"
|
||||
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
|
||||
>
|
||||
<UserAvatar
|
||||
@@ -116,7 +116,7 @@
|
||||
<CourseInstructors :instructors="course.instructors" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<div v-if="course.paid_course" class="font-semibold">
|
||||
{{ course.price }}
|
||||
</div>
|
||||
@@ -137,6 +137,8 @@ import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import { formatAmount } from '@/utils'
|
||||
import { theme } from '@/utils/theme'
|
||||
import { computed, watch } from 'vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
@@ -151,12 +153,12 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const getGradientColor = () => {
|
||||
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||
const gradientColor = computed(() => {
|
||||
let themeMode = theme.value === 'dark' ? 'darkMode' : 'lightMode'
|
||||
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||
let colorMap = colors[theme][color]
|
||||
let colorMap = colors[themeMode][color]
|
||||
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.course-card-pills {
|
||||
@@ -182,7 +184,7 @@ const getGradientColor = () => {
|
||||
}
|
||||
|
||||
.avatar-group.overlap .avatar + .avatar {
|
||||
margin-left: calc(-8px);
|
||||
margin-inline-start: calc(-8px);
|
||||
}
|
||||
|
||||
.short-introduction {
|
||||
|
||||
@@ -96,14 +96,14 @@
|
||||
</div>
|
||||
<div class="flex items-center text-ink-gray-9">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ course.data.lessons }}
|
||||
{{ course.data.lessons > 1 ? __('lessons') : __('lesson') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center text-ink-gray-9">
|
||||
<Users class="h-4 w-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ formatAmount(course.data.enrollments) }}
|
||||
{{
|
||||
course.data.enrollments > 1
|
||||
@@ -117,7 +117,7 @@
|
||||
class="flex items-center text-ink-gray-9"
|
||||
>
|
||||
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ course.data.rating }} {{ __('average rating') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -126,7 +126,7 @@
|
||||
class="flex items-center font-semibold text-ink-gray-9"
|
||||
>
|
||||
<GraduationCap class="h-4 w-4 stroke-2" />
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ __('Certificate of Completion') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -135,7 +135,7 @@
|
||||
class="flex items-center font-semibold text-ink-gray-9"
|
||||
>
|
||||
<GraduationCap class="h-4 w-4 stroke-2" />
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ __('Paid Certificate after Evaluation') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="">
|
||||
<div
|
||||
v-if="title && (outline.data?.length || allowEdit)"
|
||||
class="flex items-center justify-between space-x-2 mb-4 px-2"
|
||||
class="flex items-center justify-between gap-x-2 mb-4 px-2"
|
||||
:class="{
|
||||
'sticky top-0 z-10 bg-surface-white border-b px-3 py-2.5 sm:px-5':
|
||||
allowEdit,
|
||||
@@ -46,20 +46,20 @@
|
||||
>
|
||||
<ChevronRight
|
||||
:class="{
|
||||
'rotate-90 transform duration-200': open,
|
||||
'duration-200': !open,
|
||||
'rotate-90': open,
|
||||
'rtl:rotate-180': !open,
|
||||
hidden: chapter.is_scorm_package,
|
||||
open: index == 1,
|
||||
}"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1 transform duration-200"
|
||||
/>
|
||||
<div
|
||||
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
|
||||
class="text-base text-start text-ink-gray-9 font-medium leading-5 ms-2"
|
||||
@click="redirectToChapter(chapter)"
|
||||
>
|
||||
{{ chapter.title }}
|
||||
</div>
|
||||
<div class="flex ml-auto space-x-4">
|
||||
<div class="flex ms-auto gap-x-4">
|
||||
<Tooltip :text="__('Edit Chapter')" placement="bottom">
|
||||
<FilePenLine
|
||||
v-if="allowEdit"
|
||||
@@ -75,6 +75,12 @@
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Check
|
||||
v-if="
|
||||
chapter.is_scorm_package && isScormChapterComplete(chapter)
|
||||
"
|
||||
class="h-4 w-4 text-green-700"
|
||||
/>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel v-if="!chapter.is_scorm_package">
|
||||
<Draggable
|
||||
@@ -88,7 +94,7 @@
|
||||
>
|
||||
<template #item="{ element: lesson }">
|
||||
<div
|
||||
class="outline-lesson pl-8 py-2 pr-4 text-ink-gray-9"
|
||||
class="outline-lesson ps-8 py-2 pe-4 text-ink-gray-9"
|
||||
:class="
|
||||
isActiveLesson(lesson.number) ? 'bg-surface-gray-3' : ''
|
||||
"
|
||||
@@ -106,23 +112,23 @@
|
||||
<div class="flex items-center text-sm leading-5 group">
|
||||
<MonitorPlay
|
||||
v-if="lesson.icon === 'icon-youtube'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
class="h-4 w-4 stroke-1 me-2"
|
||||
/>
|
||||
<HelpCircle
|
||||
v-else-if="lesson.icon === 'icon-quiz'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
class="h-4 w-4 stroke-1 me-2"
|
||||
/>
|
||||
<NotebookPen
|
||||
v-else-if="lesson.icon === 'icon-assignment'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
class="h-4 w-4 stroke-1 me-2"
|
||||
/>
|
||||
<SquareCode
|
||||
v-else-if="lesson.icon === 'icon-code'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
class="h-4 w-4 stroke-1 me-2"
|
||||
/>
|
||||
<FileText
|
||||
v-else-if="lesson.icon === 'icon-list'"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1 me-2"
|
||||
/>
|
||||
{{ lesson.title }}
|
||||
<Trash2
|
||||
@@ -130,18 +136,18 @@
|
||||
@click.prevent="
|
||||
trashLesson(lesson.name, chapter.name)
|
||||
"
|
||||
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
|
||||
class="h-4 w-4 text-ink-red-3 ms-auto invisible group-hover:visible"
|
||||
/>
|
||||
<Check
|
||||
v-if="lesson.is_complete"
|
||||
class="h-4 w-4 text-green-700 ml-2"
|
||||
class="h-4 w-4 text-green-700 ms-2"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
||||
<div v-if="allowEdit" class="flex mt-2 mb-4 ps-8">
|
||||
<router-link
|
||||
v-if="!chapter.is_scorm_package"
|
||||
:to="{
|
||||
@@ -189,7 +195,6 @@ import {
|
||||
Plus,
|
||||
SquareCode,
|
||||
Trash2,
|
||||
Notebook,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||
@@ -402,6 +407,10 @@ const redirectToChapter = (chapter) => {
|
||||
})
|
||||
}
|
||||
|
||||
const isScormChapterComplete = (chapter) => {
|
||||
return chapter.lessons?.length && chapter.lessons.every((l) => l.is_complete)
|
||||
}
|
||||
|
||||
const isActiveLesson = (lessonNumber) => {
|
||||
return (
|
||||
route.params.chapterNumber == lessonNumber.split('-')[0] &&
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<Button
|
||||
v-if="membership && !hasReviewed.data"
|
||||
@click="openReviewModal()"
|
||||
class="float-right"
|
||||
class="float-end"
|
||||
>
|
||||
{{ __('Write a Review') }}
|
||||
</Button>
|
||||
@@ -28,14 +28,14 @@
|
||||
params: { username: review.owner_details.username },
|
||||
}"
|
||||
>
|
||||
<span class="text-lg font-medium mr-4 text-ink-gray-7">
|
||||
<span class="text-lg font-medium me-4 text-ink-gray-7">
|
||||
{{ review.owner_details.full_name }}
|
||||
</span>
|
||||
</router-link>
|
||||
<span class="text-ink-gray-7">
|
||||
{{ review.creation }}
|
||||
</span>
|
||||
<div class="flex mt-2 space-x-1">
|
||||
<div class="flex mt-2 gap-x-1">
|
||||
<Star
|
||||
v-for="index in 5"
|
||||
class="size-4 text-transparent rounded-sm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex h-screen w-screen">
|
||||
<div class="h-full border-r bg-surface-menu-bar">
|
||||
<div class="h-full border-e bg-surface-menu-bar">
|
||||
<AppSidebar />
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col h-full overflow-auto bg-surface-white">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<ChevronLeft class="w-5 h-5 stroke-1.5 text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
<span class="text-lg font-semibold ml-2 text-ink-gray-9">
|
||||
<span class="text-lg font-semibold ms-2 text-ink-gray-9">
|
||||
{{ topic.title }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -18,11 +18,11 @@
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center text-ink-gray-5">
|
||||
<UserAvatar :user="reply.user" class="mr-2" />
|
||||
<UserAvatar :user="reply.user" class="me-2" />
|
||||
<span>
|
||||
{{ reply.user.full_name }}
|
||||
</span>
|
||||
<span class="text-sm ml-2">
|
||||
<span class="text-sm ms-2">
|
||||
{{ timeAgo(reply.creation) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<Button
|
||||
v-if="!singleThread && !readOnlyMode"
|
||||
class="float-right"
|
||||
class="float-end"
|
||||
@click="openTopicModal()"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -21,7 +21,7 @@
|
||||
class="flex items-center cursor-pointer py-5 w-full"
|
||||
:class="{ 'border-b': index + 1 != topics.data.length }"
|
||||
>
|
||||
<UserAvatar :user="topic.user" size="2xl" class="mr-4" />
|
||||
<UserAvatar :user="topic.user" size="2xl" class="me-4" />
|
||||
<div>
|
||||
<div class="text-lg font-semibold mb-1 text-ink-gray-7">
|
||||
{{ topic.title }}
|
||||
@@ -30,7 +30,7 @@
|
||||
<span>
|
||||
{{ topic.user.full_name }}
|
||||
</span>
|
||||
<span class="text-sm ml-3">
|
||||
<span class="text-sm ms-3">
|
||||
{{ timeAgo(topic.creation) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
||||
>
|
||||
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 mr-2" />
|
||||
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 me-2" />
|
||||
<div class="mt-2">
|
||||
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||
{{ __(emptyStateTitle) }}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<span class="inline-flex items-baseline">
|
||||
<FeatherIcon
|
||||
name="x"
|
||||
class="ml-auto h-4 w-4 text-gray-700"
|
||||
class="ms-auto h-4 w-4 text-gray-700"
|
||||
@click="iosInstallMessage = false"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3"
|
||||
>
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<div class="flex gap-x-4 mb-4">
|
||||
<div class="flex flex-col space-y-2 flex-1 break-all">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ job.company_name }}
|
||||
@@ -10,7 +10,7 @@
|
||||
<span class="font-medium text-ink-gray-7 leading-5">
|
||||
{{ job.job_title }}
|
||||
</span>
|
||||
<div class="flex items-center space-x-1 text-sm text-ink-gray-7">
|
||||
<div class="flex items-center gap-x-1 text-sm text-ink-gray-7">
|
||||
<MapPin class="size-3" />
|
||||
<span>
|
||||
{{ job.location }}{{ job.country ? `, ${job.country}` : '' }}
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="job.applicants"
|
||||
class="flex items-center space-x-1 text-sm text-ink-gray-7"
|
||||
class="flex items-center gap-x-1 text-sm text-ink-gray-7"
|
||||
>
|
||||
<User class="size-3" />
|
||||
<span>
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
<!-- <img :src="job.company_logo" alt="Company Logo" class="size-8 rounded-full object-contain bg-white" /> -->
|
||||
</div>
|
||||
<div class="space-x-2 mt-auto">
|
||||
<div class="flex gap-x-2 items-center mt-auto">
|
||||
<Badge>
|
||||
{{ job.type }}
|
||||
</Badge>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
<div v-else v-html="markdown.render(block)"></div>
|
||||
<div v-else v-html="renderSafe(block)"></div>
|
||||
</div>
|
||||
<div v-if="quizId">
|
||||
<Quiz :quiz="quizId" />
|
||||
@@ -66,6 +66,7 @@
|
||||
<script setup>
|
||||
import Quiz from '@/components/QuizBlock.vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useScreenSize } from '@/utils/composables'
|
||||
|
||||
const screenSize = useScreenSize()
|
||||
@@ -75,6 +76,8 @@ const markdown = new MarkdownIt({
|
||||
linkify: true,
|
||||
})
|
||||
|
||||
const renderSafe = (block) => DOMPurify.sanitize(markdown.render(block))
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-5 text-ink-gray-9">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center text-sm font-medium space-x-2">
|
||||
<div class="flex items-center text-sm font-medium gap-x-2">
|
||||
<span>
|
||||
{{ __('What are Instructor Notes?') }}
|
||||
</span>
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<div class="space-y-2" v-for="(item, key) in contentMap" :key="key">
|
||||
<div
|
||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||
class="flex items-center text-sm font-medium gap-x-2 cursor-pointer"
|
||||
@click="openHelpDialog(key)"
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
<div class="relative z-20">
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
class="fixed bottom-16 right-2 w-[80%] rounded-md bg-surface-white text-base p-5 space-y-4 shadow-md"
|
||||
class="fixed bottom-16 end-2 w-[80%] rounded-md bg-surface-white text-base p-5 space-y-4 shadow-md"
|
||||
v-if="showMenu"
|
||||
ref="menu"
|
||||
>
|
||||
<div
|
||||
v-for="link in otherLinks"
|
||||
:key="link.label"
|
||||
class="flex items-center space-x-2 cursor-pointer"
|
||||
class="flex items-center gap-x-2 cursor-pointer"
|
||||
@click="handleClick(link)"
|
||||
>
|
||||
<component
|
||||
@@ -28,7 +28,7 @@
|
||||
<!-- Fixed menu -->
|
||||
<div
|
||||
v-if="sidebarSettings.data"
|
||||
class="fixed bottom-0 left-0 w-full flex items-center justify-around border-t border-outline-gray-2 bg-surface-white standalone:pb-4 z-10"
|
||||
class="fixed bottom-0 start-0 w-full flex items-center justify-around border-t border-outline-gray-2 bg-surface-white standalone:pb-4 z-10"
|
||||
>
|
||||
<button
|
||||
v-for="tab in sidebarLinks"
|
||||
@@ -57,7 +57,7 @@
|
||||
import { getSidebarLinks } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { call } from 'frappe-ui'
|
||||
import { watch, ref, onMounted } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { usersStore } from '@/stores/user'
|
||||
@@ -68,26 +68,13 @@ let { isLoggedIn } = sessionStore()
|
||||
const { sidebarSettings } = useSettings()
|
||||
const router = useRouter()
|
||||
let { userResource } = usersStore()
|
||||
const sidebarLinks = ref(getSidebarLinks())
|
||||
const sidebarLinks = ref([])
|
||||
const otherLinks = ref([])
|
||||
const showMenu = ref(false)
|
||||
const menu = ref(null)
|
||||
const isModerator = ref(false)
|
||||
const isInstructor = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
sidebarSettings.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
destructureSidebarLinks()
|
||||
filterLinksToShow(data)
|
||||
addOtherLinks()
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const handleOutsideClick = (e) => {
|
||||
if (menu.value && !menu.value.contains(e.target)) {
|
||||
showMenu.value = false
|
||||
@@ -126,65 +113,57 @@ const filterLinksToShow = (data) => {
|
||||
|
||||
const addOtherLinks = () => {
|
||||
if (user) {
|
||||
otherLinks.value.push({
|
||||
label: 'Notifications',
|
||||
icon: 'Bell',
|
||||
to: 'Notifications',
|
||||
})
|
||||
otherLinks.value.push({
|
||||
label: 'Profile',
|
||||
icon: 'UserRound',
|
||||
})
|
||||
otherLinks.value.push({
|
||||
label: 'Log out',
|
||||
icon: 'LogOut',
|
||||
})
|
||||
addLink('Notifications', 'Bell', 'Notifications')
|
||||
addLink('Profile', 'UserRound')
|
||||
addLink('Log out', 'LogOut')
|
||||
} else {
|
||||
otherLinks.value.push({
|
||||
label: 'Log in',
|
||||
icon: 'LogIn',
|
||||
})
|
||||
addLink('Log in', 'LogIn')
|
||||
}
|
||||
}
|
||||
|
||||
watch(userResource, () => {
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
addPrograms()
|
||||
if (isModerator.value || isInstructor.value) {
|
||||
addProgrammingExercises()
|
||||
addQuizzes()
|
||||
addAssignments()
|
||||
const addLink = (label, icon, to = '') => {
|
||||
if (otherLinks.value.some((link) => link.label === label)) return
|
||||
otherLinks.value.push({
|
||||
label: label,
|
||||
icon: icon,
|
||||
to: to,
|
||||
})
|
||||
}
|
||||
|
||||
const updateSidebarLinks = () => {
|
||||
sidebarLinks.value = getSidebarLinks(true)
|
||||
destructureSidebarLinks()
|
||||
sidebarSettings.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess: async (data) => {
|
||||
filterLinksToShow(data)
|
||||
await addPrograms()
|
||||
if (isModerator.value || isInstructor.value) {
|
||||
addQuizzes()
|
||||
addAssignments()
|
||||
addProgrammingExercises()
|
||||
}
|
||||
addOtherLinks()
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const addQuizzes = () => {
|
||||
otherLinks.value.push({
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
})
|
||||
addLink('Quizzes', 'CircleHelp', 'Quizzes')
|
||||
}
|
||||
|
||||
const addAssignments = () => {
|
||||
otherLinks.value.push({
|
||||
label: 'Assignments',
|
||||
icon: 'Pencil',
|
||||
to: 'Assignments',
|
||||
})
|
||||
addLink('Assignments', 'Pencil', 'Assignments')
|
||||
}
|
||||
|
||||
const addProgrammingExercises = () => {
|
||||
otherLinks.value.push({
|
||||
label: 'Programming Exercises',
|
||||
icon: 'Code',
|
||||
to: 'ProgrammingExercises',
|
||||
})
|
||||
addLink('Programming Exercises', 'Code', 'ProgrammingExercises')
|
||||
}
|
||||
|
||||
const addPrograms = async () => {
|
||||
if (sidebarLinks.value.some((link) => link.label === 'Programs')) return
|
||||
let canAddProgram = await checkIfCanAddProgram()
|
||||
if (!canAddProgram) return
|
||||
let activeFor = ['Programs', 'ProgramDetail']
|
||||
@@ -198,7 +177,21 @@ const addPrograms = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
userResource,
|
||||
async () => {
|
||||
await userResource.promise
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
}
|
||||
updateSidebarLinks()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const checkIfCanAddProgram = async () => {
|
||||
if (!userResource.data) return false
|
||||
if (isModerator.value || isInstructor.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add Existing User as Evaluator'),
|
||||
size: 'md',
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
loading: submitting,
|
||||
onClick: ({ close }: any) => addEvaluator(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link doctype="User" v-model="selectedUser" :label="__('Select User')" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, toast } from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const selectedUser = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
added: []
|
||||
}>()
|
||||
|
||||
watch(show, (isOpen) => {
|
||||
if (isOpen) {
|
||||
selectedUser.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
const addEvaluator = async (close?: () => void) => {
|
||||
if (!selectedUser.value?.trim()) {
|
||||
toast.error(__('Please select a user'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await call('lms.lms.api.save_role', {
|
||||
user: selectedUser.value,
|
||||
role: 'Batch Evaluator',
|
||||
value: 1,
|
||||
})
|
||||
toast.success(__('Evaluator added successfully'))
|
||||
emit('added')
|
||||
close?.()
|
||||
} catch (err: any) {
|
||||
toast.error(cleanError(err.messages?.[0]) || __('Unable to add evaluator'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-2 mt-5">
|
||||
<div class="flex justify-end gap-x-2 mt-5">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'AssignmentSubmissionList',
|
||||
@@ -72,7 +72,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { escapeHTML, sanitizeHTML } from '@/utils'
|
||||
import { sanitizeHTML } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel()
|
||||
@@ -133,7 +133,7 @@ watch(show, (newVal) => {
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
assignment.title = escapeHTML(assignment.title.trim())
|
||||
assignment.title = sanitizeHTML(assignment.title.trim())
|
||||
assignment.question = sanitizeHTML(assignment.question)
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
</FileUploader>
|
||||
<div v-else class="">
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<div class="border rounded-md p-2 me-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
@@ -64,7 +64,7 @@
|
||||
</div>
|
||||
<X
|
||||
@click="() => (chapter.scorm_package = null)"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ms-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="absolute left-1/2 mt-3 w-96 max-w-lg -translate-x-1/2 transform rounded-lg bg-surface-white px-4 sm:px-0 lg:max-w-3xl"
|
||||
class="absolute start-1/2 mt-3 w-96 max-w-lg -translate-x-1/2 transform rounded-lg bg-surface-white px-4 sm:px-0 lg:max-w-3xl"
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div class="flex items-center justify-center space-x-2">
|
||||
<div class="flex items-center justify-center gap-x-2">
|
||||
<TextInput
|
||||
type="text"
|
||||
placeholder="search by keyword"
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<div class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||
{{ __('Edit Profile') }}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Badge v-if="isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<div class="pb-5 float-right">
|
||||
<div class="pb-5 float-end">
|
||||
<Button variant="solid" @click="saveProfile()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
|
||||
@@ -34,10 +34,11 @@
|
||||
:required="true"
|
||||
:placeholder="__('Your enrollment in {{ batch_name }} is confirmed')"
|
||||
/>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
:description="__('Use HTML content for the email response')"
|
||||
:label="__('Use HTML')"
|
||||
v-model="template.use_html"
|
||||
type="checkbox"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="template.use_html"
|
||||
@@ -75,7 +76,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { call, Dialog, FormControl, TextEditor, toast, Switch } from 'frappe-ui'
|
||||
import { reactive, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
|
||||
@@ -88,6 +89,7 @@ const props = defineProps({
|
||||
|
||||
const show = defineModel()
|
||||
const emailTemplates = defineModel('emailTemplates')
|
||||
const emit = defineEmits(['created'])
|
||||
const template = reactive({
|
||||
name: '',
|
||||
subject: '',
|
||||
@@ -113,6 +115,7 @@ const createNewTemplate = (close) => {
|
||||
{
|
||||
onSuccess() {
|
||||
emailTemplates.value.reload()
|
||||
emit('created', template.name)
|
||||
refreshForm(close)
|
||||
toast.success(__('Email Template created successfully'))
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<div v-for="row in slots.data" class="space-y-2">
|
||||
<div class="flex items-center text-ink-gray-7 space-x-2">
|
||||
<div class="flex items-center text-ink-gray-7 gap-x-2">
|
||||
<Calendar class="size-3" />
|
||||
<div class="text-ink-gray-9">
|
||||
{{ dayjs(row.date).format('DD MMMM YYYY') }}
|
||||
@@ -66,18 +66,12 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
call,
|
||||
createResource,
|
||||
dayjs,
|
||||
Dialog,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { call, createResource, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { ref, watch, inject } from 'vue'
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils/'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
const show = defineModel()
|
||||
const evaluations = defineModel('reloadEvals')
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<div class="flex flex-col space-y-4 text-sm text-ink-gray-8">
|
||||
<Tooltip :text="__('Email ID')">
|
||||
<div class="flex items-center space-x-2 w-fit">
|
||||
<div class="flex items-center gap-x-2 w-fit">
|
||||
<User class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ event.member }}
|
||||
@@ -23,7 +23,7 @@
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Course')">
|
||||
<div
|
||||
class="flex space-x-2 w-fit cursor-pointer"
|
||||
class="flex gap-x-2 w-fit cursor-pointer"
|
||||
@click="openLink('course', event.course)"
|
||||
>
|
||||
<BookOpen class="h-4 w-4 stroke-1.5" />
|
||||
@@ -34,7 +34,7 @@
|
||||
</Tooltip>
|
||||
<Tooltip v-if="event.batch_title" :text="__('Batch')">
|
||||
<div
|
||||
class="flex space-x-2 w-fit cursor-pointer"
|
||||
class="flex gap-x-2 w-fit cursor-pointer"
|
||||
@click="openLink('batch', event.batch_name)"
|
||||
>
|
||||
<Users class="h-4 w-4 stroke-1.5" />
|
||||
@@ -44,7 +44,7 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Date')">
|
||||
<div class="flex items-center space-x-2 w-fit">
|
||||
<div class="flex items-center gap-x-2 w-fit">
|
||||
<Calendar class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(event.date).format('DD MMM YYYY') }}
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Time')">
|
||||
<div class="flex items-center space-x-2 w-fit">
|
||||
<div class="flex items-center gap-x-2 w-fit">
|
||||
<Clock class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ formatTime(event.start_time) }} -
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 mt-auto">
|
||||
<div class="flex items-center gap-x-2 mt-auto">
|
||||
<Button
|
||||
v-if="certificate.name"
|
||||
@click="openCertificate(certificate)"
|
||||
@@ -86,7 +86,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-l w-1/2">
|
||||
<Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-s w-1/2">
|
||||
<template #tab-panel="{ tab }">
|
||||
<div
|
||||
v-if="tab.label == 'Evaluation'"
|
||||
@@ -122,10 +122,13 @@
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="flex flex-col space-y-4 p-5">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="certificate.published"
|
||||
:label="__('Published')"
|
||||
:description="
|
||||
__('Make this certificate visible to the participant.')
|
||||
"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
<Link
|
||||
@@ -169,6 +172,7 @@ import {
|
||||
Button,
|
||||
FormControl,
|
||||
createResource,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
Textarea,
|
||||
@@ -243,7 +247,7 @@ const evaluationResource = createResource({
|
||||
member: props.event.member,
|
||||
course: props.event.course,
|
||||
batch_name: props.event.batch_name,
|
||||
date: props.event.date,
|
||||
date_value: props.event.date,
|
||||
start_time: props.event.start_time,
|
||||
end_time: props.event.end_time,
|
||||
status: evaluation.status,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '4xl',
|
||||
size: '5xl',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
@@ -19,10 +19,17 @@
|
||||
rowHeight: 'h-16',
|
||||
selectable: false,
|
||||
}"
|
||||
class="border rounded-lg py-2 px-3"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
></ListHeader>
|
||||
class="mb-2 grid items-center rounded bg-surface-white border-b rounded-none !px-0"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in feedbackColumns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
@@ -41,7 +48,7 @@
|
||||
class="flex"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
size="xl"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,9 +70,11 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
ListView,
|
||||
Avatar,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
@@ -89,27 +98,43 @@ const feedbackColumns = computed(() => {
|
||||
label: 'Member',
|
||||
key: 'member_name',
|
||||
width: '10rem',
|
||||
align: 'left',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: 'Feedback',
|
||||
key: 'feedback',
|
||||
width: '15rem',
|
||||
align: 'left',
|
||||
icon: 'message-square',
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
key: 'content',
|
||||
width: '9rem',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'book',
|
||||
},
|
||||
{
|
||||
label: 'Instructors',
|
||||
key: 'instructors',
|
||||
width: '9rem',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'users',
|
||||
},
|
||||
{
|
||||
label: 'Value',
|
||||
key: 'value',
|
||||
width: '9rem',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'dollar-sign',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.feedback-list > button > div {
|
||||
padding: 0.2rem 0;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
</FileUploader>
|
||||
</div>
|
||||
<div v-else class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<div class="border rounded-md p-2 me-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
@click="redirectToProfile(participant.member_username)"
|
||||
class="grid grid-cols-2 items-center w-full text-base w-fit py-2"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Avatar
|
||||
:image="participant.member_image"
|
||||
:label="participant.member_name"
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-20 text-right">
|
||||
<div class="grid grid-cols-3 gap-20 text-end">
|
||||
<div>
|
||||
{{ dayjs(participant.joined_at).format('HH:mm a') }}
|
||||
</div>
|
||||
|
||||
@@ -29,14 +29,12 @@
|
||||
:label="__('Date')"
|
||||
:required="true"
|
||||
/>
|
||||
<Tooltip :text="__('Duration of the live class in minutes')">
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="liveClass.duration"
|
||||
:label="__('Duration')"
|
||||
:required="true"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="liveClass.duration"
|
||||
:label="__('Duration (in minutes)')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<Tooltip
|
||||
@@ -186,6 +184,7 @@ const submitLiveClass = (close) => {
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add New Member'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
loading: submitting,
|
||||
onClick: ({ close }: any) => addMember(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="member.email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
:required="true"
|
||||
@keyup.enter="addMember()"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<FormControl
|
||||
v-model="member.first_name"
|
||||
:label="__('First Name')"
|
||||
placeholder="Jane"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="member.last_name"
|
||||
:label="__('Last Name')"
|
||||
placeholder="Doe"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __('Roles') }}
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-x-6 gap-y-3">
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Student')"
|
||||
v-model="roles.lms_student"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Course Creator')"
|
||||
v-model="roles.course_creator"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Evaluator')"
|
||||
v-model="roles.batch_evaluator"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Moderator')"
|
||||
v-model="roles.moderator"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, toast, Switch } from 'frappe-ui'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const submitting = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
defaultRoles?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [user: any]
|
||||
}>()
|
||||
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
moderator: 'Moderator',
|
||||
course_creator: 'Course Creator',
|
||||
batch_evaluator: 'Batch Evaluator',
|
||||
lms_student: 'LMS Student',
|
||||
}
|
||||
|
||||
const member = reactive({
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
})
|
||||
|
||||
const roles = reactive({
|
||||
moderator: false,
|
||||
course_creator: false,
|
||||
batch_evaluator: false,
|
||||
lms_student: false,
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
member.email = ''
|
||||
member.first_name = ''
|
||||
member.last_name = ''
|
||||
applyDefaultRoles()
|
||||
}
|
||||
|
||||
const applyDefaultRoles = () => {
|
||||
roles.moderator = props.defaultRoles?.includes('moderator') ?? false
|
||||
roles.course_creator = props.defaultRoles?.includes('course_creator') ?? false
|
||||
roles.batch_evaluator =
|
||||
props.defaultRoles?.includes('batch_evaluator') ?? false
|
||||
roles.lms_student = props.defaultRoles?.includes('lms_student') ?? false
|
||||
}
|
||||
|
||||
watch(show, (isOpen) => {
|
||||
if (isOpen) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
const assignRoles = async (userEmail: string) => {
|
||||
const selectedRoles = Object.entries(roles).filter(([_, checked]) => checked)
|
||||
|
||||
for (const [key, _] of selectedRoles) {
|
||||
await call('lms.lms.api.save_role', {
|
||||
user: userEmail,
|
||||
role: ROLE_MAP[key],
|
||||
value: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addMember = async (close?: () => void) => {
|
||||
if (!member.email?.trim()) {
|
||||
toast.error(__('Email is required'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const user = await call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
email: member.email.trim(),
|
||||
first_name: member.first_name.trim() || undefined,
|
||||
last_name: member.last_name.trim() || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
await assignRoles(user.name)
|
||||
|
||||
toast.success(__('Member added successfully'))
|
||||
emit('created', user)
|
||||
resetForm()
|
||||
close?.()
|
||||
} catch (err: any) {
|
||||
toast.error(cleanError(err.messages?.[0]) || __('Unable to add member'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -72,10 +72,11 @@
|
||||
:label="__('Explanation')"
|
||||
v-model="question[`explanation_${n}`]"
|
||||
/>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Correct Answer')"
|
||||
:description="__('Mark this option as a correct answer.')"
|
||||
v-model="question[`is_correct_${n}`]"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +105,7 @@
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-end space-x-2 mt-5">
|
||||
<div class="flex items-center justify-end gap-x-2 mt-5">
|
||||
<Button variant="solid" @click="submitQuestion()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
|
||||
@@ -44,14 +44,14 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
class="h-4 w-4 stroke-1.5 ms-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<!-- <FormControl
|
||||
v-model="searchText"
|
||||
:placeholder="__('Search by Member')"
|
||||
class="mt-2 mr-5 w-[25%]"
|
||||
class="mt-2 me-5 w-[25%]"
|
||||
/> -->
|
||||
</div>
|
||||
<div
|
||||
@@ -50,7 +50,7 @@
|
||||
}"
|
||||
>
|
||||
<div class="grid grid-cols-[70%,30%] items-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Avatar
|
||||
:image="row.member_image"
|
||||
:label="row.member_name"
|
||||
|
||||
@@ -18,10 +18,13 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="mb-4">
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="account.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
:description="
|
||||
__('Activate this Zoom account for scheduling meetings.')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
@@ -61,7 +64,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { inject, reactive, watch } from 'vue'
|
||||
import { User } from '@/components/Settings/types'
|
||||
import { openSettings, cleanError } from '@/utils'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium text-ink-gray-9">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:me-2"
|
||||
></span>
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
:style="{
|
||||
display: top > 0 ? 'block' : 'none',
|
||||
top: top + 'px',
|
||||
left: left + 'px',
|
||||
insetInlineStart: left + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="space-y-2 py-2">
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="">
|
||||
<div
|
||||
v-for="color in colors"
|
||||
class="flex items-center space-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
|
||||
class="flex items-center gap-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
|
||||
@click="saveHighLight(color)"
|
||||
>
|
||||
<span
|
||||
@@ -32,7 +32,7 @@
|
||||
<div class="border-t">
|
||||
<div
|
||||
@click="addToNotes()"
|
||||
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
class="flex items-center gap-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
>
|
||||
<NotepadText class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
@@ -42,7 +42,7 @@
|
||||
<div
|
||||
v-if="highlightExists()"
|
||||
@click="deleteHighlight"
|
||||
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
class="flex items-center gap-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
>
|
||||
<Trash2 class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<slot name="prefix" />
|
||||
<div class="font-semibold text-ink-gray-9 text-2xl">
|
||||
{{ value }}
|
||||
|
||||
@@ -1,59 +1,75 @@
|
||||
<template>
|
||||
<div v-if="quiz.data">
|
||||
<div
|
||||
class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 leading-5"
|
||||
class="bg-surface-blue-2 text-ink-blue-3 space-y-2 p-3 mb-4 rounded-lg leading-5"
|
||||
>
|
||||
<div v-if="inVideo">
|
||||
{{ __('You will have to complete the quiz to continue the video') }}
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
__('This quiz consists of {0} questions.').format(questions.length)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data?.duration" class="leading-5">
|
||||
<div class="font-medium">
|
||||
{{
|
||||
__(
|
||||
'Please ensure that you complete all the questions in {0} minutes.'
|
||||
).format(quiz.data.duration)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data?.duration" class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
|
||||
{{
|
||||
__(
|
||||
'You will have to get {0}% correct answers in order to pass the quiz.'
|
||||
).format(quiz.data.passing_percentage)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.max_attempts" class="leading-5">
|
||||
{{
|
||||
__('You can attempt this quiz {0}.').format(
|
||||
quiz.data.max_attempts == 1
|
||||
? '1 time'
|
||||
: `${quiz.data.max_attempts} times`
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.enable_negative_marking" class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
|
||||
).format(
|
||||
quiz.data.marks_to_cut,
|
||||
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
|
||||
'Please read the following instructions carefully before starting the quiz'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<ol class="list-decimal list-inside space-y-2">
|
||||
<li v-if="inVideo">
|
||||
{{ __('You will have to complete the quiz to continue the video') }}
|
||||
</li>
|
||||
<li>
|
||||
{{
|
||||
__(
|
||||
'Do not refresh the page or close this window. If you do, the quiz will be submitted automatically.'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li>
|
||||
{{
|
||||
__('This quiz consists of {0} questions.').format(questions.length)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data?.duration">
|
||||
{{
|
||||
__(
|
||||
'Please ensure that you complete all the questions in {0} minutes.'
|
||||
).format(quiz.data.duration)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data?.duration">
|
||||
{{
|
||||
__(
|
||||
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data.passing_percentage">
|
||||
{{
|
||||
__(
|
||||
'You will have to get {0}% correct answers in order to pass the quiz.'
|
||||
).format(quiz.data.passing_percentage)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data.max_attempts">
|
||||
{{
|
||||
__('You can attempt this quiz {0}.').format(
|
||||
quiz.data.max_attempts == 1
|
||||
? '1 time'
|
||||
: `${quiz.data.max_attempts} times`
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data.enable_negative_marking">
|
||||
{{
|
||||
__(
|
||||
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
|
||||
).format(
|
||||
quiz.data.marks_to_cut,
|
||||
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
||||
<div v-if="quiz.data.duration" class="flex flex-col gap-x-1 my-4 px-2">
|
||||
<div class="mb-2">
|
||||
<span class="text-ink-gray-9"> {{ __('Time') }}: </span>
|
||||
<span class="font-semibold text-ink-gray-9">
|
||||
@@ -68,7 +84,7 @@
|
||||
<div class="font-semibold text-lg text-ink-gray-9">
|
||||
{{ quiz.data.title }}
|
||||
</div>
|
||||
<div class="flex items-center justify-center space-x-2 mt-4">
|
||||
<div class="flex items-center justify-center gap-x-2 mt-4">
|
||||
<Button
|
||||
v-if="
|
||||
!quiz.data.max_attempts ||
|
||||
@@ -104,16 +120,12 @@
|
||||
<div v-for="(question, qtidx) in questions">
|
||||
<div
|
||||
v-if="qtidx == activeQuestion - 1 && questionDetails.data"
|
||||
class="border rounded-md p-5"
|
||||
class="border rounded-lg p-5"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
<span class="mr-2">
|
||||
{{ __('Question {0}').format(activeQuestion) }}:
|
||||
</span>
|
||||
<span>
|
||||
{{ getInstructions(questionDetails.data) }}
|
||||
</span>
|
||||
{{ __('Question {0}').format(activeQuestion) }} -
|
||||
{{ getInstructions(questionDetails.data) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-9 text-sm font-semibold item-left">
|
||||
{{ question.marks }}
|
||||
@@ -135,6 +147,7 @@
|
||||
:name="encodeURIComponent(questionDetails.data.question)"
|
||||
class="w-3.5 h-3.5 text-ink-gray-9 focus:ring-outline-gray-modals"
|
||||
@change="markAnswer(index)"
|
||||
:checked="selectedOptions[index - 1]"
|
||||
/>
|
||||
|
||||
<input
|
||||
@@ -143,6 +156,7 @@
|
||||
:name="encodeURIComponent(questionDetails.data.question)"
|
||||
class="w-3.5 h-3.5 text-ink-gray-9 rounded-sm focus:ring-outline-gray-modals"
|
||||
@change="markAnswer(index)"
|
||||
:checked="selectedOptions[index - 1]"
|
||||
/>
|
||||
<div
|
||||
v-else-if="quiz.data.show_answers"
|
||||
@@ -165,7 +179,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="ml-2 text-ink-gray-9"
|
||||
class="ms-2 text-ink-gray-9"
|
||||
v-html="questionDetails.data[`option_${index}`]"
|
||||
>
|
||||
</span>
|
||||
@@ -188,12 +202,12 @@
|
||||
<div v-if="showAnswers.length">
|
||||
<Badge v-if="showAnswers[0]" :label="__('Correct')" theme="green">
|
||||
<template #prefix>
|
||||
<CheckCircle class="w-4 h-4 text-ink-green-2 mr-1" />
|
||||
<CheckCircle class="w-4 h-4 text-ink-green-2 me-1" />
|
||||
</template>
|
||||
</Badge>
|
||||
<Badge v-else theme="red" :label="__('Incorrect')">
|
||||
<template #prefix>
|
||||
<XCircle class="w-4 h-4 text-ink-red-3 mr-1" />
|
||||
<XCircle class="w-4 h-4 text-ink-red-3 me-1" />
|
||||
</template>
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -208,14 +222,56 @@
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{
|
||||
__('Question {0} of {1}').format(
|
||||
activeQuestion,
|
||||
questions.length
|
||||
)
|
||||
}}
|
||||
<div class="flex items-center justify-between mt-8">
|
||||
<Checkbox
|
||||
v-if="!quiz.data.show_answers"
|
||||
:label="__('Mark for review')"
|
||||
:model-value="reviewQuestions.includes(activeQuestion) ? 1 : 0"
|
||||
@change="markForReview($event, activeQuestion)"
|
||||
/>
|
||||
<div
|
||||
v-if="!quiz.data.show_answers"
|
||||
class="flex items-center gap-x-2"
|
||||
>
|
||||
<Button
|
||||
@click="switchQuestion(activeQuestion - 1)"
|
||||
:disabled="activeQuestion == 1"
|
||||
class="rounded-full"
|
||||
>
|
||||
<template #icon>
|
||||
<ChevronLeft class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<span
|
||||
v-for="item in paginationWindow"
|
||||
:key="item"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-sm"
|
||||
:class="{
|
||||
'cursor-pointer': item !== '...',
|
||||
'bg-surface-gray-4 border border-outline-gray-5 font-medium':
|
||||
activeQuestion == item,
|
||||
'text-ink-gray-5': item === '...',
|
||||
'bg-surface-blue-3 text-ink-white':
|
||||
attemptedQuestions.includes(item) && activeQuestion != item,
|
||||
'bg-surface-gray-3 text-ink-gray-6':
|
||||
activeQuestion != item &&
|
||||
item !== '...' &&
|
||||
!attemptedQuestions.includes(item),
|
||||
}"
|
||||
@click="item !== '...' && switchQuestion(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
@click="switchQuestion(activeQuestion + 1)"
|
||||
:disabled="activeQuestion == questions.length"
|
||||
class="rounded-full"
|
||||
>
|
||||
<template #icon>
|
||||
<ChevronRight class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-if="
|
||||
@@ -223,6 +279,7 @@
|
||||
!showAnswers.length &&
|
||||
questionDetails.data.type != 'Open Ended'
|
||||
"
|
||||
class="ms-auto"
|
||||
@click="checkAnswer()"
|
||||
>
|
||||
<span>
|
||||
@@ -230,14 +287,22 @@
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="activeQuestion != questions.length"
|
||||
v-else-if="
|
||||
activeQuestion != questions.length && quiz.data.show_answers
|
||||
"
|
||||
@click="nextQuestion()"
|
||||
class="ms-auto"
|
||||
>
|
||||
<span>
|
||||
{{ __('Next') }}
|
||||
</span>
|
||||
</Button>
|
||||
<Button v-else @click="submitQuiz()">
|
||||
<Button
|
||||
variant="solid"
|
||||
v-else
|
||||
@click="handleSubmitClick()"
|
||||
class="ms-auto"
|
||||
>
|
||||
<span>
|
||||
{{ __('Submit') }}
|
||||
</span>
|
||||
@@ -245,8 +310,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="reviewQuestions.length" class="border rounded-lg p-4 mt-4">
|
||||
<div class="font-semibold">
|
||||
{{ __('Questions marked for review') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-x-2 mt-2">
|
||||
<div
|
||||
v-for="index in reviewQuestions"
|
||||
@click="switchQuestion(index)"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-sm cursor-pointer bg-surface-gray-3"
|
||||
>
|
||||
{{ index }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="border rounded-md p-20 text-center space-y-2">
|
||||
<div v-else class="border rounded-lg p-20 text-center space-y-2">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Quiz Summary') }}
|
||||
</div>
|
||||
@@ -271,7 +350,7 @@
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<div class="flex gap-x-2">
|
||||
<Button
|
||||
@click="resetQuiz()"
|
||||
class="mt-2"
|
||||
@@ -310,30 +389,96 @@
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showSubmissionConfirmation"
|
||||
:options="{
|
||||
title: __('Are you sure you want to submit the quiz?'),
|
||||
actions: [
|
||||
{
|
||||
size: 'sm',
|
||||
label: __('Submit'),
|
||||
variant: 'solid',
|
||||
onClick() {
|
||||
submitQuiz()
|
||||
showSubmissionConfirmation = false
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="border border-outline-gray-modals rounded-lg text-base">
|
||||
<div class="divide-y divide-outline-gray-modals">
|
||||
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
|
||||
<div class="p-2">
|
||||
{{ __('Total Questions') }}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{{ questions.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
|
||||
<div class="p-2">
|
||||
{{ __('Attempted Questions') }}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{{ attemptedQuestions.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
|
||||
<div class="p-2">
|
||||
{{ __('Unattempted Questions') }}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{{ questions.length - attemptedQuestions.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
call,
|
||||
Checkbox,
|
||||
createResource,
|
||||
Dialog,
|
||||
ListView,
|
||||
TextEditor,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch, reactive, inject, computed } from 'vue'
|
||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
reactive,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import {
|
||||
CheckCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
XCircle,
|
||||
MinusCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import { timeAgo } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const activeQuestion = ref(0)
|
||||
const currentQuestion = ref('')
|
||||
const selectedOptions = reactive([0, 0, 0, 0])
|
||||
const selectedOptions = ref([0, 0, 0, 0])
|
||||
const showAnswers = reactive([])
|
||||
let questions = reactive([])
|
||||
const attemptedQuestions = ref([])
|
||||
const reviewQuestions = ref([])
|
||||
const showSubmissionConfirmation = ref(false)
|
||||
const possibleAnswer = ref(null)
|
||||
const timer = ref(0)
|
||||
let timerInterval = null
|
||||
@@ -353,6 +498,40 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('pagehide', handlePageHide)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('pagehide', handlePageHide)
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
const handlePageHide = () => {
|
||||
if (activeQuestion.value > 0 && !quizSubmission.data) {
|
||||
const params = new URLSearchParams({
|
||||
quiz: quiz.data.name,
|
||||
results: localStorage.getItem(quiz.data.title),
|
||||
})
|
||||
|
||||
navigator.sendBeacon(
|
||||
'/api/method/lms.lms.doctype.lms_quiz.lms_quiz.submit_quiz?' +
|
||||
params.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBeforeUnload = (event) => {
|
||||
if (activeQuestion.value > 0 && !quizSubmission.data) {
|
||||
if (attemptedQuestions.value.length) {
|
||||
switchQuestion(activeQuestion.value)
|
||||
}
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
}
|
||||
|
||||
const quiz = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
@@ -486,10 +665,58 @@ const questionDetails = createResource({
|
||||
watch(activeQuestion, (value) => {
|
||||
if (value > 0) {
|
||||
currentQuestion.value = quiz.data.questions[value - 1].question
|
||||
questionDetails.reload()
|
||||
questionDetails.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
if (!quiz.data.show_answers) {
|
||||
loadSavedAnswers()
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const switchQuestion = (questionNumber) => {
|
||||
let answers = getAnswers()
|
||||
if (answers.length) {
|
||||
if (!attemptedQuestions.value.includes(activeQuestion.value)) {
|
||||
attemptedQuestions.value.push(activeQuestion.value)
|
||||
}
|
||||
addToLocalStorage()
|
||||
resetQuestion()
|
||||
}
|
||||
|
||||
if (questionNumber < 1 || questionNumber > questions.length) return
|
||||
activeQuestion.value = questionNumber
|
||||
}
|
||||
|
||||
const loadSavedAnswers = () => {
|
||||
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
||||
if (quizData) {
|
||||
let localQuestion = quizData.find(
|
||||
(q) => q.question_name == currentQuestion.value
|
||||
)
|
||||
if (localQuestion) {
|
||||
let localAnswers = localQuestion.answer
|
||||
if (localAnswers.length) {
|
||||
if (questionDetails.data.type == 'Choices') {
|
||||
localAnswers.forEach((answer) => {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
if (questionDetails.data[`option_${i}`] == answer) {
|
||||
selectedOptions.value[i - 1] = 1
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
possibleAnswer.value = localAnswers[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.quizName,
|
||||
(newName) => {
|
||||
@@ -507,17 +734,20 @@ const startQuiz = () => {
|
||||
|
||||
const markAnswer = (index) => {
|
||||
if (!questionDetails.data.multiple)
|
||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||
selectedOptions[index - 1] = selectedOptions[index - 1] ? 0 : 1
|
||||
selectedOptions.value.splice(
|
||||
0,
|
||||
selectedOptions.value.length,
|
||||
...[0, 0, 0, 0]
|
||||
)
|
||||
selectedOptions.value[index - 1] = selectedOptions.value[index - 1] ? 0 : 1
|
||||
}
|
||||
|
||||
const getAnswers = () => {
|
||||
let answers = []
|
||||
const type = questionDetails.data.type
|
||||
|
||||
if (type == 'Choices') {
|
||||
selectedOptions.forEach((value, index) => {
|
||||
if (selectedOptions[index])
|
||||
selectedOptions.value.forEach((value, index) => {
|
||||
if (selectedOptions.value[index])
|
||||
answers.push(questionDetails.data[`option_${index + 1}`])
|
||||
})
|
||||
} else {
|
||||
@@ -545,7 +775,7 @@ const checkAnswer = () => {
|
||||
onSuccess(data) {
|
||||
let type = questionDetails.data.type
|
||||
if (type == 'Choices') {
|
||||
selectedOptions.forEach((option, index) => {
|
||||
selectedOptions.value.forEach((option, index) => {
|
||||
if (option) {
|
||||
showAnswers[index] = option && data[index]
|
||||
} else if (data[index] == 2) {
|
||||
@@ -571,12 +801,13 @@ const addToLocalStorage = () => {
|
||||
question_name: currentQuestion.value,
|
||||
answer: getAnswers(),
|
||||
}
|
||||
|
||||
if (quizData) {
|
||||
let existingQuestion = quizData.find(
|
||||
(q) => q.question_name == questionData.question_name
|
||||
)
|
||||
if (!existingQuestion) {
|
||||
if (existingQuestion) {
|
||||
existingQuestion.answer = questionData.answer
|
||||
} else {
|
||||
quizData.push(questionData)
|
||||
}
|
||||
} else {
|
||||
@@ -586,18 +817,15 @@ const addToLocalStorage = () => {
|
||||
}
|
||||
|
||||
const nextQuestion = () => {
|
||||
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
|
||||
checkAnswer()
|
||||
} else {
|
||||
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
|
||||
resetQuestion()
|
||||
}
|
||||
if (!quiz.data.show_answers) return
|
||||
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
|
||||
resetQuestion()
|
||||
}
|
||||
|
||||
const resetQuestion = () => {
|
||||
if (activeQuestion.value == quiz.data.questions.length) return
|
||||
activeQuestion.value = activeQuestion.value + 1
|
||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||
selectedOptions.value.splice(0, selectedOptions.value.length, ...[0, 0, 0, 0])
|
||||
showAnswers.length = 0
|
||||
possibleAnswer.value = null
|
||||
}
|
||||
@@ -605,7 +833,6 @@ const resetQuestion = () => {
|
||||
const submitQuiz = () => {
|
||||
if (!quiz.data.show_answers) {
|
||||
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
|
||||
else checkAnswer()
|
||||
setTimeout(() => {
|
||||
createSubmission()
|
||||
}, 500)
|
||||
@@ -639,8 +866,10 @@ const createSubmission = () => {
|
||||
|
||||
const resetQuiz = () => {
|
||||
activeQuestion.value = 0
|
||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||
selectedOptions.value.splice(0, selectedOptions.value.length, ...[0, 0, 0, 0])
|
||||
showAnswers.length = 0
|
||||
possibleAnswer.value = null
|
||||
attemptedQuestions.value = []
|
||||
quizSubmission.reset()
|
||||
populateQuestions()
|
||||
setupTimer()
|
||||
@@ -669,6 +898,53 @@ const markLessonProgress = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitClick = () => {
|
||||
if (!quiz.data.show_answers) {
|
||||
if (attemptedQuestions.value.length) {
|
||||
switchQuestion(activeQuestion.value)
|
||||
}
|
||||
showSubmissionConfirmation.value = true
|
||||
} else {
|
||||
submitQuiz()
|
||||
}
|
||||
}
|
||||
|
||||
const paginationWindow = computed(() => {
|
||||
const total = questions.length
|
||||
const current = activeQuestion.value
|
||||
const pages = []
|
||||
const size = 5
|
||||
|
||||
let start = Math.floor((current - 1) / size) * size + 1
|
||||
let end = Math.min(start + size - 1, total)
|
||||
|
||||
if (start > 1) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (end < total) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const markForReview = (event, questionNumber) => {
|
||||
if (event.target.checked) {
|
||||
if (!reviewQuestions.value.includes(questionNumber)) {
|
||||
reviewQuestions.value.push(questionNumber)
|
||||
}
|
||||
} else {
|
||||
reviewQuestions.value = reviewQuestions.value.filter(
|
||||
(num) => num !== questionNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getSubmissionColumns = () => {
|
||||
return [
|
||||
{
|
||||
@@ -697,8 +973,3 @@ const getSubmissionColumns = () => {
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
p {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="text-base">
|
||||
<div class="flex items-center justify-between space-x-2 mb-5">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center justify-between gap-x-2 mb-5">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-5 cursor-pointer"
|
||||
@click="
|
||||
@@ -34,7 +34,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
|
||||
@@ -9,11 +9,7 @@
|
||||
<template #body-content>
|
||||
<div class="grid grid-cols-2 gap-x-5">
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="badge.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
/>
|
||||
<Switch size="sm" v-model="badge.enabled" :label="__('Enabled')" />
|
||||
<FormControl
|
||||
v-model="badge.title"
|
||||
:label="__('Title')"
|
||||
@@ -41,10 +37,11 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="badge.grant_only_once"
|
||||
:label="__('Grant Only Once')"
|
||||
type="checkbox"
|
||||
:description="__('Each user can only receive this badge one time.')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="badge.event"
|
||||
@@ -73,7 +70,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="pb-5 float-right">
|
||||
<div class="pb-5 float-end">
|
||||
<Button variant="solid" @click="saveBadge(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
@@ -82,7 +79,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { Button, call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
import type { Badges, Badge } from '@/components/Settings/types'
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="badges.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="badges.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="badges.data"
|
||||
@@ -32,7 +32,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns" :key="item.key">
|
||||
<template #prefix="{ item }">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Badge
|
||||
v-if="isDirty"
|
||||
:label="__('Not Saved')"
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
<div class="flex items-center gap-x-5">
|
||||
<div
|
||||
class="flex items-center space-x-1 text-ink-amber-3 border border-outline-amber-1 bg-surface-amber-1 rounded-lg px-2 py-1"
|
||||
class="flex items-center gap-x-1 text-ink-amber-3 border border-outline-amber-1 bg-surface-amber-1 rounded-lg px-2 py-1"
|
||||
v-if="saving"
|
||||
>
|
||||
<LoadingIndicator class="size-2" />
|
||||
@@ -29,10 +29,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showForm"
|
||||
class="flex items-center justify-between my-4 space-x-2"
|
||||
>
|
||||
<div v-if="showForm" class="flex items-center justify-between my-4 gap-x-2">
|
||||
<FormControl
|
||||
ref="categoryInput"
|
||||
v-model="category"
|
||||
@@ -44,7 +41,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="overflow-y-auto">
|
||||
<div class="divide-y divide-outline-gray-modals space-y-2">
|
||||
<div
|
||||
v-for="(cat, index) in categories.data"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col text-base h-full">
|
||||
<div class="flex items-center space-x-2 mb-8 -ml-1.5">
|
||||
<div class="flex items-center gap-x-2 mb-8 -ms-1.5">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="emit('updateStep', 'list')"
|
||||
@@ -11,10 +11,11 @@
|
||||
</div>
|
||||
<div class="space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="data.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
:description="__('Allow this coupon to be used for discounts.')"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
@@ -73,7 +74,7 @@
|
||||
<CouponItems ref="couponItems" :data="data" :coupons="coupons" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto space-x-2 ml-auto">
|
||||
<div class="mt-auto flex gap-x-2 items-center ms-auto">
|
||||
<Button variant="solid" @click="saveCoupon()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
@@ -81,7 +82,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl, toast } from 'frappe-ui'
|
||||
import { Button, FormControl, toast, Switch } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import type { Coupon, Coupons } from './types'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative overflow-x-auto border rounded-md">
|
||||
<table class="w-full text-sm text-left text-ink-gray-5">
|
||||
<table class="w-full text-sm text-start text-ink-gray-5">
|
||||
<thead class="text-xs text-ink-gray-7 uppercase bg-surface-gray-2">
|
||||
<tr>
|
||||
<td scope="col" class="px-6 py-2">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="coupons.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="coupons.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="coupons.data"
|
||||
@@ -31,7 +31,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{{ __(description) }}
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
<div class="flex items-center gap-x-5">
|
||||
<Button @click="openTemplateForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
@@ -18,7 +18,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="emailTemplates.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="emailTemplates.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="emailTemplates.data"
|
||||
@@ -31,14 +31,14 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
class="h-4 w-4 stroke-1.5 ms-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
|
||||
@@ -9,18 +9,50 @@
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<Button @click="() => (showForm = !showForm)">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
<div class="flex item-center gap-x-2">
|
||||
<Dropdown
|
||||
placement="right"
|
||||
side="bottom"
|
||||
:options="[
|
||||
{
|
||||
label: __('New Evaluator'),
|
||||
icon: 'user-plus',
|
||||
onClick() {
|
||||
showNewEvaluator = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Existing User'),
|
||||
icon: 'user-check',
|
||||
onClick() {
|
||||
showExistingUser = true
|
||||
},
|
||||
},
|
||||
]"
|
||||
>
|
||||
<template v-slot="{ open }">
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
<template #suffix>
|
||||
<ChevronDown
|
||||
:class="[
|
||||
'w-4 h-4 stroke-1.5 ms-1 transform transition-transform',
|
||||
open ? 'rotate-180' : '',
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pb-5">
|
||||
<FormControl
|
||||
v-if="evaluators.data?.length > 0 || search"
|
||||
v-model="search"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
@@ -40,7 +72,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between group py-3">
|
||||
<div
|
||||
class="flex items-center space-x-3"
|
||||
class="flex items-center gap-x-3"
|
||||
@click="openProfile(evaluator.username)"
|
||||
>
|
||||
<Avatar
|
||||
@@ -70,11 +102,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="evaluators.length && hasNextPage"
|
||||
class="flex justify-center mt-4"
|
||||
>
|
||||
<Button @click="evaluators.reload()">
|
||||
<div v-if="evaluators.hasNextPage" class="flex justify-center mt-4">
|
||||
<Button @click="evaluators.next()">
|
||||
<template #prefix>
|
||||
<RefreshCw class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
@@ -84,33 +113,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
title: __('Add Evaluator'),
|
||||
actions: [{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick({ close }: any) {
|
||||
addEvaluator(close)
|
||||
},
|
||||
}]
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div v-if="showForm" class="flex items-center">
|
||||
<FormControl
|
||||
v-model="email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
class="w-full"
|
||||
@keydown.enter="addEvaluator"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<AddEvaluatorModal v-model="showExistingUser" @added="evaluators.reload()" />
|
||||
<NewMemberModal
|
||||
v-model="showNewEvaluator"
|
||||
:defaultRoles="['batch_evaluator']"
|
||||
@created="onMemberCreated"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
@@ -118,18 +126,20 @@ import {
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
|
||||
import { Plus, Search, Trash2, RefreshCw, ChevronDown } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
import AddEvaluatorModal from '@/components/Modals/AddEvaluatorModal.vue'
|
||||
|
||||
const show = defineModel('show')
|
||||
const search = ref('')
|
||||
const showForm = ref(false)
|
||||
const email = ref('')
|
||||
const show = defineModel('show')
|
||||
const showExistingUser = ref(false)
|
||||
const showNewEvaluator = ref(false)
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
@@ -150,20 +160,8 @@ const evaluators = createListResource({
|
||||
orderBy: 'creation desc',
|
||||
})
|
||||
|
||||
const addEvaluator = (close: () => void) => {
|
||||
call('lms.lms.api.add_an_evaluator', {
|
||||
email: email.value,
|
||||
})
|
||||
.then(() => {
|
||||
email.value = ''
|
||||
evaluators.reload()
|
||||
toast.success(__('Evaluator added successfully'))
|
||||
close()
|
||||
})
|
||||
.catch((error: any) => {
|
||||
toast.error(__(error.messages[0] || error.messages))
|
||||
console.error('Error adding evaluator:', error)
|
||||
})
|
||||
const onMemberCreated = () => {
|
||||
evaluators.reload()
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
|
||||
@@ -20,10 +20,13 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="mb-4">
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="account.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
:description="
|
||||
__('Activate this Google Meet account for scheduling meetings.')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
@@ -51,7 +54,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { inject, reactive, watch } from 'vue'
|
||||
import { User } from '@/components/Settings/types'
|
||||
import { openSettings, cleanError } from '@/utils'
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
<div class="flex items-center gap-x-5">
|
||||
<Button @click="openForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
@@ -18,7 +18,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="googleMeetAccounts.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="googleMeetAccounts.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="googleMeetAccounts.data"
|
||||
@@ -31,7 +31,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<Button @click="() => (showForm = !showForm)">
|
||||
<div class="flex item-center gap-x-2">
|
||||
<Button @click="showNewMember = true">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -31,7 +31,7 @@
|
||||
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<div class="overflow-y-scroll max-h-[60vh]">
|
||||
<div class="overflow-y-auto max-h-[60vh]">
|
||||
<ul class="divide-y divide-outline-gray-modals">
|
||||
<li
|
||||
v-for="member in memberList"
|
||||
@@ -39,7 +39,7 @@
|
||||
>
|
||||
<div
|
||||
@click="openProfile(member.username)"
|
||||
class="flex items-center space-x-3 col-span-2"
|
||||
class="flex items-center gap-x-3 col-span-2"
|
||||
>
|
||||
<Avatar
|
||||
:image="member.user_image"
|
||||
@@ -58,7 +58,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center text-ink-gray-9 space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
|
||||
class="flex items-center text-ink-gray-9 gap-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
|
||||
v-if="member.role && member.role !== 'LMS Student'"
|
||||
>
|
||||
<Shield class="size-4 stroke-1.5" />
|
||||
@@ -82,56 +82,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
title: __('Add a new member'),
|
||||
size: 'lg',
|
||||
actions: [{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick({ close }: any) {
|
||||
addMember(close)
|
||||
}
|
||||
}]
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="member.email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="member.first_name"
|
||||
:label="__('First Name')"
|
||||
placeholder="Jane"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<NewMemberModal v-model="showNewMember" @created="onMemberCreated" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { Avatar, Button, createResource, FormControl } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import { ref, watch, inject } from 'vue'
|
||||
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import type { User } from '@/components/Settings/types'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
|
||||
type Member = {
|
||||
username: string
|
||||
@@ -147,16 +107,11 @@ const search = ref('')
|
||||
const start = ref(0)
|
||||
const memberList = ref<Member[]>([])
|
||||
const hasNextPage = ref(false)
|
||||
const showForm = ref(false)
|
||||
const showNewMember = ref(false)
|
||||
const user = inject<User | null>('$user')
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const member = reactive({
|
||||
email: '',
|
||||
first_name: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
@@ -194,30 +149,12 @@ const openProfile = (username: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
const addMember = (close: () => void) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
first_name: member.first_name,
|
||||
email: member.email,
|
||||
},
|
||||
})
|
||||
.then((data: Member) => {
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
capture('user_added')
|
||||
show.value = false
|
||||
router.push({
|
||||
name: 'ProfileRoles',
|
||||
params: {
|
||||
username: data.username,
|
||||
},
|
||||
})
|
||||
close()
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error(err)
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
})
|
||||
const onMemberCreated = (data: any) => {
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
capture('user_added')
|
||||
memberList.value = []
|
||||
start.value = 0
|
||||
members.reload()
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}"
|
||||
>
|
||||
<template #body-header>
|
||||
<div class="text-lg font-semibold">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{
|
||||
gatewayID === 'new'
|
||||
? __('New Payment Gateway')
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="pb-5 float-right">
|
||||
<div class="pb-5 float-end">
|
||||
<Button variant="solid" @click="saveSettings(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="paymentGateways.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="paymentGateways.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="paymentGateways.data"
|
||||
@@ -30,7 +30,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'flex justify-between space-x-8 w-full': section.columns.length > 1,
|
||||
'flex justify-between gap-x-8 w-full': section.columns.length > 1,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
@@ -64,7 +64,7 @@
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex items-center text-sm space-x-2">
|
||||
<div class="flex items-center text-sm gap-x-2">
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-outline-gray-modals bg-surface-gray-2"
|
||||
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
|
||||
@@ -91,7 +91,7 @@
|
||||
</div>
|
||||
<X
|
||||
@click="data[field.name] = null"
|
||||
class="border text-ink-gray-7 border-outline-gray-modals rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
class="border text-ink-gray-7 border-outline-gray-modals rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ms-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<div
|
||||
v-if="activeTab && data.doc"
|
||||
:key="activeTab.label"
|
||||
class="flex flex-1 flex-col p-8 bg-surface-modal overflow-x-auto"
|
||||
class="flex flex-1 flex-col p-8 bg-surface-modal overflow-x-auto overflow-y-auto"
|
||||
>
|
||||
<component
|
||||
v-if="activeTab.template"
|
||||
@@ -42,8 +42,8 @@
|
||||
...(activeTab.label == 'Branding'
|
||||
? { sections: activeTab.sections }
|
||||
: {}),
|
||||
...(activeTab.label == 'Evaluators' ||
|
||||
activeTab.label == 'Members' ||
|
||||
...(activeTab.label == 'Members' ||
|
||||
activeTab.label == 'Evaluators' ||
|
||||
activeTab.label == 'Transactions'
|
||||
? { 'onUpdate:show': (val) => (show = val), show }
|
||||
: {}),
|
||||
@@ -331,29 +331,62 @@ const tabsStructure = computed(() => {
|
||||
doctype: 'Currency',
|
||||
},
|
||||
{
|
||||
label: 'Payment Gateway',
|
||||
name: 'payment_gateway',
|
||||
type: 'Link',
|
||||
doctype: 'Payment Gateway',
|
||||
label: 'Show USD equivalent amount',
|
||||
name: 'show_usd_equivalent',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, it shows the USD equivalent amount for all transactions based on the current exchange rate.',
|
||||
},
|
||||
{
|
||||
label: 'Apply rounding on equivalent',
|
||||
name: 'apply_rounding',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, it applies rounding on the USD equivalent amount.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Payment Gateway',
|
||||
name: 'payment_gateway',
|
||||
type: 'Link',
|
||||
doctype: 'Payment Gateway',
|
||||
},
|
||||
{
|
||||
label: 'Apply GST for India',
|
||||
name: 'apply_gst',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, GST will be applied to the price for students from India.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Payment Reminders',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Show USD equivalent amount',
|
||||
name: 'show_usd_equivalent',
|
||||
label: 'Send payment reminders for batch',
|
||||
name: 'send_payment_reminders_for_batch',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, it sends payment reminders to students who left the payment incomplete for a batch.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Apply rounding on equivalent',
|
||||
name: 'apply_rounding',
|
||||
label: 'Send payment reminders for course',
|
||||
name: 'send_payment_reminders_for_course',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, it sends payment reminders to students who left the payment incomplete for a course.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full text-base">
|
||||
<div class="flex items-center justify-between mb-10 -ml-1.5">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center justify-between mb-10 -ms-1.5">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="emit('updateStep', 'list')"
|
||||
@@ -10,7 +10,7 @@
|
||||
{{ __('Transaction Details') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Button
|
||||
v-if="
|
||||
transactionData?.payment_for_document_type &&
|
||||
@@ -85,7 +85,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold mt-10">
|
||||
<div class="font-semibold mt-10 text-ink-gray-9">
|
||||
{{ __('Payment Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
@@ -109,7 +109,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="transactionData.coupon">
|
||||
<div class="font-semibold mt-10">
|
||||
<div class="font-semibold mt-10 text-ink-gray-9">
|
||||
{{ __('Coupon Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
@@ -140,7 +140,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold mt-10">
|
||||
<div class="font-semibold mt-10 text-ink-gray-9">
|
||||
{{ __('Billing Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
@@ -175,7 +175,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl, toast } from 'frappe-ui'
|
||||
import { Button, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-5 mb-4">
|
||||
<div class="flex items-center gap-x-5 mb-4">
|
||||
<FormControl
|
||||
v-model="billingName"
|
||||
:placeholder="__('Filter by Billing Name')"
|
||||
@@ -39,21 +39,21 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="transactions.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="transactions.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="transactions.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: false,
|
||||
onRowClick: (row: { [key: string]: any }) => {
|
||||
openForm(row)
|
||||
},
|
||||
}"
|
||||
showTooltip: false,
|
||||
selectable: false,
|
||||
onRowClick: (row: { [key: string]: any }) => {
|
||||
openForm(row)
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
@@ -116,6 +116,7 @@ import {
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
FormControl,
|
||||
Switch,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{{ __(description || '') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
<div class="flex items-center gap-x-5">
|
||||
<Button @click="openForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
@@ -18,7 +18,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="zoomAccounts.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="zoomAccounts.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="zoomAccounts.data"
|
||||
@@ -31,7 +31,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out border-r bg-surface-menu-bar"
|
||||
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out border-e bg-surface-menu-bar overflow-x-hidden"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col overflow-hidden"
|
||||
class="flex flex-col overflow-y-auto"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
|
||||
>
|
||||
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
|
||||
@@ -31,21 +31,24 @@
|
||||
class="mt-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between pr-2 cursor-pointer"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'pl-3' : 'pl-4'"
|
||||
class="flex items-center justify-between pe-2 cursor-pointer"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'ps-3' : 'ps-4'"
|
||||
@click="toggleWebPages"
|
||||
>
|
||||
<div
|
||||
v-if="!sidebarStore.isSidebarCollapsed"
|
||||
class="flex items-center text-sm text-ink-gray-5 my-1"
|
||||
class="flex items-center text-ink-gray-5 my-1"
|
||||
>
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<ChevronsRight
|
||||
<ChevronRight
|
||||
class="h-4 w-4 stroke-1.5 text-ink-gray-9 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': !sidebarStore.isWebpagesCollapsed }"
|
||||
:class="{
|
||||
'rotate-90': sidebarStore.isWebpagesCollapsed,
|
||||
'rtl:rotate-180': !sidebarStore.isWebpagesCollapsed,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ __('More') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -159,12 +162,8 @@
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center flex-1"
|
||||
:class="
|
||||
sidebarStore.isSidebarCollapsed
|
||||
? 'flex-col space-y-3'
|
||||
: 'flex-row space-x-3'
|
||||
"
|
||||
class="flex items-center flex-1 gap-3"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'flex-col' : 'flex-row'"
|
||||
>
|
||||
<Tooltip v-if="readOnlyMode && sidebarStore.isSidebarCollapsed">
|
||||
<CircleAlert
|
||||
@@ -182,10 +181,13 @@
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Powered by Learning')">
|
||||
<Zap
|
||||
<Tooltip
|
||||
v-if="showAppointmentIcon"
|
||||
:text="__('Book a free onboarding session with the Frappe team')"
|
||||
>
|
||||
<Phone
|
||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="redirectToWebsite()"
|
||||
@click="redirectToAppointmentScreen()"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="showOnboarding" :text="__('Help')">
|
||||
@@ -199,6 +201,12 @@
|
||||
"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Powered by Frappe Learning')">
|
||||
<Zap
|
||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="redirectToWebsite()"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip
|
||||
:text="
|
||||
@@ -207,8 +215,11 @@
|
||||
>
|
||||
<CollapseSidebar
|
||||
class="size-4 text-ink-gray-7 duration-300 stroke-1.5 ease-in-out cursor-pointer"
|
||||
:class="{
|
||||
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
|
||||
:style="{
|
||||
transform:
|
||||
isRtl !== sidebarStore.isSidebarCollapsed
|
||||
? 'rotateY(180deg)'
|
||||
: '',
|
||||
}"
|
||||
@click="toggleSidebar()"
|
||||
/>
|
||||
@@ -216,6 +227,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<HelpModal
|
||||
data-testid="onboarding-help-modal"
|
||||
v-if="showOnboarding && showHelpModal"
|
||||
v-model="showHelpModal"
|
||||
v-model:articles="articles"
|
||||
@@ -267,10 +279,11 @@ import {
|
||||
CircleAlert,
|
||||
ChevronRight,
|
||||
ChevronsRight,
|
||||
Plus,
|
||||
CircleHelp,
|
||||
FolderTree,
|
||||
FileText,
|
||||
Phone,
|
||||
Plus,
|
||||
User,
|
||||
UserPlus,
|
||||
Users,
|
||||
@@ -313,6 +326,7 @@ const router = useRouter()
|
||||
let onboardingDetails
|
||||
let isOnboardingStepsCompleted = false
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const isRtl = document.documentElement.dir === 'rtl'
|
||||
const iconProps = {
|
||||
strokeWidth: 1.5,
|
||||
width: 16,
|
||||
@@ -660,6 +674,7 @@ watch(settingsStore.settings, () => {
|
||||
const updateSidebarLinks = () => {
|
||||
sidebarLinks.value = getSidebarLinks()
|
||||
updateSidebarLinksVisibility()
|
||||
updateUnreadCount()
|
||||
}
|
||||
|
||||
const redirectToWebsite = () => {
|
||||
@@ -678,6 +693,36 @@ const profileIsComplete = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const showAppointmentIcon = computed(() => {
|
||||
let isTrialPlan = userResource.data?.site_info?.plan?.is_trial_plan
|
||||
let trialEndDate = calculateTrialEndDays(
|
||||
userResource.data?.site_info?.trial_end_date
|
||||
)
|
||||
return (
|
||||
userResource.data?.is_system_manager &&
|
||||
userResource.data?.is_fc_site &&
|
||||
isTrialPlan &&
|
||||
trialEndDate > 0
|
||||
)
|
||||
})
|
||||
|
||||
const calculateTrialEndDays = (trialEndDate) => {
|
||||
if (!trialEndDate) return 0
|
||||
|
||||
trialEndDate = new Date(trialEndDate)
|
||||
const today = new Date()
|
||||
const diffTime = trialEndDate - today
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
return diffDays
|
||||
}
|
||||
|
||||
const redirectToAppointmentScreen = () => {
|
||||
window.open(
|
||||
'https://calendar.google.com/calendar/u/0/appointments/schedules/AcZssZ0c7Z3XIpW1WgbeIuktSaoX6qudoYuSdRbIlJty5TW7p4IZaOk5viHQGwTNi6HpNVqzOZOTHcle',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
socket.off('publish_lms_notifications')
|
||||
})
|
||||
|
||||
@@ -25,18 +25,18 @@
|
||||
class="flex-shrink-0 text-sm duration-300 ease-in-out"
|
||||
:class="
|
||||
isCollapsed
|
||||
? 'ml-0 w-0 overflow-hidden opacity-0'
|
||||
: 'ml-2 w-auto opacity-100'
|
||||
? 'ms-0 w-0 overflow-hidden opacity-0'
|
||||
: 'ms-2 w-auto opacity-100'
|
||||
"
|
||||
>
|
||||
{{ __(link.label) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="link.count && !isCollapsed"
|
||||
class="!ml-auto block text-xs text-ink-gray-5"
|
||||
class="!ms-auto block text-xs text-ink-gray-5"
|
||||
:class="
|
||||
isCollapsed && link.count > 9
|
||||
? 'absolute top-[2px] right-0 bg-surface-white'
|
||||
? 'absolute top-[2px] end-0 bg-surface-white'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
@@ -44,7 +44,7 @@
|
||||
</span>
|
||||
<div
|
||||
v-if="showControls && !isCollapsed"
|
||||
class="flex items-center space-x-2 !ml-auto block text-xs text-ink-gray-5 group-hover:visible invisible"
|
||||
class="flex items-center gap-x-2 !ms-auto block text-xs text-ink-gray-5 group-hover:visible invisible"
|
||||
>
|
||||
<component
|
||||
:is="icons['Edit']"
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
/>
|
||||
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
|
||||
<div
|
||||
class="flex flex-1 flex-col text-left duration-300 ease-in-out"
|
||||
class="flex flex-1 flex-col text-start duration-300 ease-in-out"
|
||||
:class="
|
||||
isCollapsed
|
||||
? 'opacity-0 ml-0 w-0 overflow-hidden'
|
||||
: 'opacity-100 ml-2 w-auto'
|
||||
? 'opacity-0 ms-0 w-0 overflow-hidden'
|
||||
: 'opacity-100 ms-2 w-auto'
|
||||
"
|
||||
>
|
||||
<div class="text-base font-medium text-ink-gray-9 leading-none">
|
||||
@@ -47,8 +47,8 @@
|
||||
class="duration-300 ease-in-out"
|
||||
:class="
|
||||
isCollapsed
|
||||
? 'opacity-0 ml-0 w-0 overflow-hidden'
|
||||
: 'opacity-100 ml-2 w-auto'
|
||||
? 'opacity-0 ms-0 w-0 overflow-hidden'
|
||||
: 'opacity-100 ms-2 w-auto'
|
||||
"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4 text-ink-gray-7" />
|
||||
@@ -68,6 +68,7 @@ import { sessionStore } from '@/stores/session'
|
||||
import { call, Dropdown, toast } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import { applyTheme, toggleTheme, theme } from '@/utils/theme'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { markRaw, watch, ref, onMounted, computed } from 'vue'
|
||||
@@ -94,7 +95,6 @@ let { userResource } = usersStore()
|
||||
const settingsStore = useSettings()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
const showSettingsModal = ref(false)
|
||||
const theme = ref('light')
|
||||
const frappeCloudBaseEndpoint = 'https://frappecloud.com'
|
||||
const $dialog = createDialog
|
||||
|
||||
@@ -106,9 +106,8 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
theme.value = localStorage.getItem('theme') || 'light'
|
||||
if (['light', 'dark'].includes(theme.value)) {
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
applyTheme(theme.value)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -119,13 +118,6 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const toggleTheme = () => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme')
|
||||
theme.value = currentTheme === 'dark' ? 'light' : 'dark'
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
localStorage.setItem('theme', theme.value)
|
||||
}
|
||||
|
||||
const userDropdownOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
{{ tags }}
|
||||
<div
|
||||
v-for="tag in tags?.split(', ')"
|
||||
class="flex items-center bg-surface-gray-2 p-2 rounded-md mr-2"
|
||||
class="flex items-center bg-surface-gray-2 p-2 rounded-md me-2"
|
||||
>
|
||||
{{ tag }}
|
||||
<X
|
||||
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
|
||||
class="stroke-1.5 w-3 h-3 ms-2 cursor-pointer"
|
||||
@click="removeTag(tag)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="absolute left-1/2 mt-3 max-w-sm -translate-x-1/2 transform rounded-lg bg-surface-white px-4 sm:px-0 lg:max-w-3xl"
|
||||
class="absolute start-1/2 mt-3 max-w-sm -translate-x-1/2 transform rounded-lg bg-surface-white px-4 sm:px-0 lg:max-w-3xl"
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<div class="flex-1">
|
||||
<TextInput
|
||||
type="text"
|
||||
|
||||
@@ -64,27 +64,27 @@
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="flex items-center mb-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ dayjs(evl.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="flex items-center mb-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ formatTime(evl.start_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<GraduationCap class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ evl.evaluator_name }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="evl.google_meet_link"
|
||||
class="flex items-center justify-between space-x-2 mt-4"
|
||||
class="flex items-center justify-between gap-x-2 mt-4"
|
||||
>
|
||||
<Button @click="openEvalCall(evl)" class="w-full">
|
||||
<template #prefix>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
)
|
||||
}}
|
||||
|
||||
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
|
||||
<div v-for="(quiz, index) in quizzes" class="ps-3 mt-1">
|
||||
<span>
|
||||
{{ index + 1 }}. <span class="font-semibold"> {{ quiz.quiz }} </span>
|
||||
</span>
|
||||
@@ -36,7 +36,7 @@
|
||||
@click="playVideo"
|
||||
>
|
||||
<div
|
||||
class="rounded-full p-4 pl-4.5"
|
||||
class="rounded-full p-4 ps-4.5"
|
||||
style="
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
|
||||
class="flex items-center gap-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 start-0 end-0 mx-auto rounded-md"
|
||||
:class="{
|
||||
'invisible group-hover:visible': playing,
|
||||
}"
|
||||
@@ -76,7 +76,7 @@
|
||||
class="duration-slider h-1"
|
||||
/>
|
||||
<!-- QUIZ MARKERS -->
|
||||
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
|
||||
<div class="absolute top-0 start-0 w-full h-full pointer-events-none">
|
||||
<div
|
||||
v-for="(quiz, index) in quizzes"
|
||||
:key="index"
|
||||
@@ -89,6 +89,11 @@
|
||||
<span class="text-sm font-medium">
|
||||
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
|
||||
</span>
|
||||
|
||||
<Dropdown :options="dropdownOptions">
|
||||
<Button>{{ playbackSpeedLabel }}</Button>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="toggleMute"
|
||||
@@ -151,9 +156,9 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
import { Button, Dialog } from 'frappe-ui'
|
||||
import { Button, Dialog, Dropdown } from 'frappe-ui'
|
||||
import { formatSeconds, formatTimestamp } from '@/utils'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import Play from '@/components/Icons/Play.vue'
|
||||
@@ -173,6 +178,16 @@ const currentQuiz = ref(null)
|
||||
const nextQuiz = ref({})
|
||||
const { settings } = useSettings()
|
||||
|
||||
// Speed control states
|
||||
const playbackSpeed = ref(1)
|
||||
const playbackSpeedLabel = ref('1x')
|
||||
const playbackSpeeds = [
|
||||
{ label: '0.5x', value: 0.5 },
|
||||
{ label: '1x', value: 1 },
|
||||
{ label: '1.5x', value: 1.5 },
|
||||
{ label: '2x', value: 2 },
|
||||
]
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
type: String,
|
||||
@@ -199,6 +214,9 @@ const props = defineProps({
|
||||
onMounted(() => {
|
||||
updateCurrentTime()
|
||||
updateNextQuiz()
|
||||
if (videoRef.value) {
|
||||
videoRef.value.playbackRate = 1
|
||||
}
|
||||
})
|
||||
|
||||
const updateCurrentTime = () => {
|
||||
@@ -318,9 +336,25 @@ const toggleFullscreen = () => {
|
||||
const getQuizMarkerStyle = (time) => {
|
||||
const percentage = ((time - 5) / Math.ceil(duration.value)) * 100
|
||||
return {
|
||||
left: `${percentage}%`,
|
||||
insetInlineStart: `${percentage}%`,
|
||||
}
|
||||
}
|
||||
|
||||
const setPlaybackSpeed = (speed, label) => {
|
||||
playbackSpeed.value = speed
|
||||
playbackSpeedLabel.value = label
|
||||
if (videoRef.value) {
|
||||
videoRef.value.playbackRate = speed
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownOptions = computed(() =>
|
||||
playbackSpeeds.map((speed) => ({
|
||||
label: speed.label,
|
||||
active: playbackSpeed.value === speed.value,
|
||||
onClick: () => setPlaybackSpeed(speed.value, speed.label),
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
rowKey="name"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in submissionColumns" />
|
||||
</ListHeader>
|
||||
|
||||
@@ -20,18 +20,17 @@
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div v-if="assignmentCount" class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('{0} Assignments').format(assignmentCount) }}
|
||||
<div class="py-5">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center space-y-4 md:space-y-0 justify-between mb-5 mx-5"
|
||||
>
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('{0} Assignments').format(assignments.data?.length) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="assignments.data?.length || assignmentCount > 0"
|
||||
class="grid grid-cols-2 gap-5"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="titleFilter"
|
||||
:placeholder="__('Search by title')"
|
||||
:placeholder="__('Search by Title')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="typeFilter"
|
||||
@@ -48,23 +47,77 @@
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: false,
|
||||
selectable: true,
|
||||
onRowClick: (row) => {
|
||||
if (readOnlyMode) return
|
||||
assignmentID = row.name
|
||||
showAssignmentForm = true
|
||||
},
|
||||
}"
|
||||
class="h-[71vh] lg:h-[79vh] px-5"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center rounded bg-surface-white border-b rounded-none p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in assignmentColumns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
v-for="row in assignments.data"
|
||||
:row="row"
|
||||
class="hover:bg-surface-gray-2"
|
||||
>
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'show_answers'">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="row[column.key]"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'modified'"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner class="bottom-50">
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteAssignment(selections, unselectAll)"
|
||||
>
|
||||
<FeatherIcon name="trash-2" class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<EmptyState v-else type="Assignments" />
|
||||
<div
|
||||
v-if="assignments.data && assignments.hasNextPage"
|
||||
class="flex justify-center my-5"
|
||||
>
|
||||
<Button @click="assignments.next()">
|
||||
<div v-else class="h-[53vh]">
|
||||
<EmptyState type="Assignments" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-x-3 pt-3 border-t px-5">
|
||||
<Button v-if="assignments.hasNextPage" @click="assignments.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
<div v-if="assignments.hasNextPage" class="h-8 border-s"></div>
|
||||
<div class="text-ink-gray-5">
|
||||
{{ assignments.data?.length }} {{ __('of') }}
|
||||
{{ totalAssignments.data }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AssignmentForm
|
||||
@@ -79,8 +132,17 @@ import {
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
createResource,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
FeatherIcon,
|
||||
toast,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
@@ -96,7 +158,6 @@ const titleFilter = ref('')
|
||||
const typeFilter = ref('')
|
||||
const showAssignmentForm = ref(false)
|
||||
const assignmentID = ref('new')
|
||||
const assignmentCount = ref(0)
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -110,7 +171,6 @@ onMounted(() => {
|
||||
assignmentID.value = 'new'
|
||||
showAssignmentForm.value = true
|
||||
}
|
||||
getAssignmentCount()
|
||||
titleFilter.value = router.currentRoute.value.query.title
|
||||
typeFilter.value = router.currentRoute.value.query.type
|
||||
})
|
||||
@@ -123,6 +183,10 @@ watch([titleFilter, typeFilter], () => {
|
||||
},
|
||||
})
|
||||
reloadAssignments()
|
||||
totalAssignments.update({
|
||||
filters: assignmentFilter.value,
|
||||
})
|
||||
totalAssignments.reload()
|
||||
})
|
||||
|
||||
const reloadAssignments = () => {
|
||||
@@ -137,7 +201,7 @@ const assignmentFilter = computed(() => {
|
||||
if (titleFilter.value) {
|
||||
filters.title = ['like', `%${titleFilter.value}%`]
|
||||
}
|
||||
if (typeFilter.value) {
|
||||
if (typeFilter.value && typeFilter.value.trim() !== '') {
|
||||
filters.type = typeFilter.value
|
||||
}
|
||||
return filters
|
||||
@@ -145,51 +209,60 @@ const assignmentFilter = computed(() => {
|
||||
|
||||
const assignments = createListResource({
|
||||
doctype: 'LMS Assignment',
|
||||
fields: ['name', 'title', 'type', 'creation', 'question', 'course'],
|
||||
fields: ['name', 'title', 'type', 'modified', 'question', 'course'],
|
||||
orderBy: 'modified desc',
|
||||
cache: ['assignments'],
|
||||
transform(data) {
|
||||
return data.map((row) => {
|
||||
return {
|
||||
...row,
|
||||
creation: dayjs(row.creation).fromNow(),
|
||||
modified: dayjs(row.modified).format('DD MMM YYYY'),
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const totalAssignments = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
params: {
|
||||
doctype: 'LMS Assignment',
|
||||
filters: assignmentFilter.value,
|
||||
},
|
||||
auto: true,
|
||||
cache: ['assignments_count', user.data?.name],
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
})
|
||||
|
||||
const assignmentColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Title'),
|
||||
key: 'title',
|
||||
width: 2,
|
||||
width: 1,
|
||||
icon: 'file-text',
|
||||
},
|
||||
{
|
||||
label: __('Type'),
|
||||
key: 'type',
|
||||
width: 1,
|
||||
align: 'left',
|
||||
icon: 'tag',
|
||||
},
|
||||
{
|
||||
label: __('Created'),
|
||||
key: 'creation',
|
||||
label: __('Updated On'),
|
||||
key: 'modified',
|
||||
width: 1,
|
||||
align: 'right',
|
||||
icon: 'clock',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const getAssignmentCount = () => {
|
||||
call('frappe.client.get_count', {
|
||||
doctype: 'LMS Assignment',
|
||||
}).then((data) => {
|
||||
assignmentCount.value = data
|
||||
})
|
||||
}
|
||||
|
||||
const assignmentTypes = computed(() => {
|
||||
let types = ['', 'Document', 'Image', 'PDF', 'URL', 'Text']
|
||||
let types = [' ', 'Document', 'Image', 'PDF', 'URL', 'Text']
|
||||
return types.map((type) => {
|
||||
return {
|
||||
label: __(type),
|
||||
@@ -198,6 +271,14 @@ const assignmentTypes = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const deleteAssignment = (selections, unselectAll) => {
|
||||
Array.from(selections).forEach(async (assignmentName) => {
|
||||
await assignments.delete.submit(assignmentName)
|
||||
})
|
||||
unselectAll()
|
||||
toast.success(__('Assignments deleted successfully'))
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Assignments'),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
class="sticky top-0 z-10 border-b flex items-center justify-between bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div v-if="tabIndex == 5 && isAdmin" class="flex items-center space-x-2">
|
||||
<div v-if="tabIndex == 5 && isAdmin" class="flex items-center gap-x-2">
|
||||
<Badge v-if="childRef?.isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="batchDetail.doc.published"
|
||||
type="checkbox"
|
||||
:label="__('Published')"
|
||||
:description="__('Make the batch visible to all users.')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.title"
|
||||
@@ -43,10 +44,13 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="batchDetail.doc.allow_self_enrollment"
|
||||
type="checkbox"
|
||||
:label="__('Allow Self Enrollment')"
|
||||
:description="
|
||||
__('Allow users to enroll in this batch on their own.')
|
||||
"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.start_time"
|
||||
@@ -72,10 +76,11 @@
|
||||
/>
|
||||
|
||||
<Link
|
||||
v-model="batchDetail.doc.category"
|
||||
doctype="LMS Category"
|
||||
:label="__('Category')"
|
||||
v-model="batchDetail.doc.category"
|
||||
:onCreate="(value, close) => openSettings('Categories', close)"
|
||||
:inlineCreate="true"
|
||||
:onCreate="createCategory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,10 +92,11 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 items-start">
|
||||
<div class="flex flex-col space-y-5">
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="batchDetail.doc.evaluation"
|
||||
type="checkbox"
|
||||
:label="__('Evaluation')"
|
||||
:description="__('Enable evaluations for batch participants.')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="batchDetail.doc.evaluation"
|
||||
@@ -101,10 +107,11 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="batchDetail.doc.certification"
|
||||
type="checkbox"
|
||||
:label="__('Certification')"
|
||||
:description="__('Issue certificates to batch participants.')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,11 +121,12 @@
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="Course Evaluator"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:onCreate="(close) => openSettings('Evaluators', close)"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
:onCreate="() => (showMemberModal = true)"
|
||||
url="lms.lms.api.search_users_by_role"
|
||||
:searchParams="{ roles: JSON.stringify(['Batch Evaluator']) }"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.description"
|
||||
@@ -155,12 +163,14 @@
|
||||
class="mb-4"
|
||||
/>
|
||||
<Link
|
||||
ref="emailTemplateLinkRef"
|
||||
doctype="Email Template"
|
||||
:label="__('Enrollment Confirmation Email Template')"
|
||||
v-model="batchDetail.doc.confirmation_email_template"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Email Templates', close)
|
||||
if (close) close()
|
||||
showEmailTemplateModal = true
|
||||
}
|
||||
"
|
||||
/>
|
||||
@@ -214,10 +224,11 @@
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Pricing') }}
|
||||
</div>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="batchDetail.doc.paid_batch"
|
||||
type="checkbox"
|
||||
:label="__('Paid Batch')"
|
||||
:description="__('Charge a fee for batch enrollment.')"
|
||||
/>
|
||||
<div
|
||||
v-if="batchDetail.doc.paid_batch"
|
||||
@@ -264,7 +275,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l min-w-0">
|
||||
<div class="border-s min-w-0">
|
||||
<div class="border-b p-4">
|
||||
<BatchCourses :batch="batch" />
|
||||
</div>
|
||||
@@ -274,6 +285,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NewMemberModal
|
||||
v-model="showMemberModal"
|
||||
:defaultRoles="['batch_evaluator']"
|
||||
@created="onInstructorCreated"
|
||||
/>
|
||||
<EmailTemplateModal
|
||||
v-model="showEmailTemplateModal"
|
||||
v-model:emailTemplates="emailTemplates"
|
||||
templateID="new"
|
||||
@created="onEmailTemplateCreated"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -290,37 +312,65 @@ import {
|
||||
} from 'vue'
|
||||
import {
|
||||
FormControl,
|
||||
Switch,
|
||||
TextEditor,
|
||||
createDocumentResource,
|
||||
toast,
|
||||
call,
|
||||
createListResource,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
escapeHTML,
|
||||
createLMSCategory,
|
||||
getMetaInfo,
|
||||
openSettings,
|
||||
sanitizeHTML,
|
||||
updateMetaInfo,
|
||||
} from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import BatchCourses from '@/pages/Batches/components/BatchCourses.vue'
|
||||
import Assessments from '@/pages/Batches/components/Assessments.vue'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const instructors = ref([])
|
||||
const app = getCurrentInstance()
|
||||
const { capture } = useTelemetry()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
const isDirty = ref(false)
|
||||
const originalDoc = ref(null)
|
||||
const showMemberModal = ref(false)
|
||||
const showEmailTemplateModal = ref(false)
|
||||
const emailTemplateLinkRef = ref(null)
|
||||
|
||||
const emailTemplates = createListResource({
|
||||
doctype: 'Email Template',
|
||||
fields: ['name', 'subject', 'use_html', 'response', 'response_html'],
|
||||
auto: true,
|
||||
orderBy: 'modified desc',
|
||||
cache: 'email-templates',
|
||||
})
|
||||
|
||||
const onEmailTemplateCreated = (name) => {
|
||||
batchDetail.doc.confirmation_email_template = name
|
||||
emailTemplateLinkRef.value?.reload()
|
||||
}
|
||||
|
||||
const createCategory = (name, done) => {
|
||||
createLMSCategory(name).then((categoryName) => {
|
||||
if (!categoryName) return
|
||||
batchDetail.doc.category = categoryName
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
const onInstructorCreated = (user) => {
|
||||
instructors.value = [...instructors.value, user.name]
|
||||
}
|
||||
|
||||
const meta = reactive({
|
||||
description: '',
|
||||
@@ -364,9 +414,16 @@ watch(
|
||||
() => batchDetail.doc,
|
||||
() => {
|
||||
if (!batchDetail.doc) return
|
||||
getMetaInfo('batches', batchDetail.doc?.name, meta)
|
||||
|
||||
if (originalDoc.value) {
|
||||
isDirty.value =
|
||||
JSON.stringify(batchDetail.doc) !== JSON.stringify(originalDoc.value)
|
||||
}
|
||||
|
||||
updateBatchData()
|
||||
}
|
||||
getMetaInfo('batches', batchDetail.doc?.name, meta)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const updateBatchData = () => {
|
||||
@@ -400,22 +457,7 @@ const formatTime = (timeStr) => {
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const validateFields = () => {
|
||||
batchDetail.doc.description = sanitizeHTML(batchDetail.doc.description)
|
||||
batchDetail.doc.batch_details = sanitizeHTML(batchDetail.doc.batch_details)
|
||||
|
||||
Object.keys(batchDetail.doc).forEach((key) => {
|
||||
if (
|
||||
!['description', 'batch_details'].includes(key) &&
|
||||
typeof batchDetail.doc[key] === 'string'
|
||||
) {
|
||||
batchDetail.doc[key] = escapeHTML(batchDetail.doc[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const submitBatch = () => {
|
||||
validateFields()
|
||||
updateBatch()
|
||||
}
|
||||
|
||||
@@ -444,17 +486,6 @@ const updateBatch = () => {
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => batchDetail.doc,
|
||||
() => {
|
||||
if (originalDoc.value) {
|
||||
isDirty.value =
|
||||
JSON.stringify(batchDetail.doc) !== JSON.stringify(originalDoc.value)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const deleteBatch = () => {
|
||||
$dialog({
|
||||
title: __('Confirm your action to delete'),
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
<div class="flex avatar-group overlap">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
class="h-6 me-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<template #suffix>
|
||||
<ChevronDown
|
||||
:class="[
|
||||
'w-4 h-4 stroke-1.5 ml-1 transform transition-transform',
|
||||
'w-4 h-4 stroke-1.5 ms-1 transform transition-transform',
|
||||
open ? 'rotate-180' : '',
|
||||
]"
|
||||
/>
|
||||
@@ -51,7 +51,7 @@
|
||||
{{ __('All Batches') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col space-y-3 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
||||
class="flex flex-col space-y-3 lg:space-y-0 lg:flex-row lg:items-center lg:gap-x-4"
|
||||
>
|
||||
<TabButtons
|
||||
v-if="user.data"
|
||||
@@ -78,12 +78,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
v-model="certification"
|
||||
:label="__('Certification')"
|
||||
type="checkbox"
|
||||
@change="updateBatches()"
|
||||
/>
|
||||
<Tooltip :text="__('Only show batches that offer a certificate')">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="certification"
|
||||
:label="__('Certification')"
|
||||
@change="updateBatches()"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -122,6 +124,7 @@ import {
|
||||
Dropdown,
|
||||
FormControl,
|
||||
Select,
|
||||
Tooltip,
|
||||
TabButtons,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-5 items-start">
|
||||
<div class="border rounded-lg py-3 px-4 order-2 lg:order-1">
|
||||
<div class="flex items-center justify-between space-x-2 mb-3">
|
||||
<div class="flex items-center justify-between gap-x-2 mb-3">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by name')"
|
||||
@@ -62,7 +62,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-white border-b rounded-none p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-white border-b rounded-none p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
@@ -91,7 +91,7 @@
|
||||
<!-- <ProgressBar
|
||||
v-else-if="column.key == 'progress'"
|
||||
:progress="Math.ceil(row[column.key])"
|
||||
class="!mx-0 !mr-4"
|
||||
class="!mx-0 !me-4"
|
||||
/> -->
|
||||
</template>
|
||||
<div v-if="column.key == 'creation'">
|
||||
@@ -174,7 +174,6 @@ import {
|
||||
AxisChart,
|
||||
createResource,
|
||||
createListResource,
|
||||
dayjs,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
@@ -185,7 +184,8 @@ import {
|
||||
Avatar,
|
||||
Button,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import type dayjsType from 'dayjs'
|
||||
import { formatAmount } from '@/utils'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import BatchFeedback from '@/pages/Batches/components/BatchFeedback.vue'
|
||||
@@ -193,6 +193,7 @@ import BatchStudentProgress from '@/pages/Batches/components/BatchStudentProgres
|
||||
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||
|
||||
const dayjs = inject<typeof dayjsType>('$dayjs')!
|
||||
const searchFilter = ref<string | null>(null)
|
||||
const showEnrollmentModal = ref<boolean>(false)
|
||||
const showProgressModal = ref<boolean>(false)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Avatar :label="comm.sender_full_name" size="lg" />
|
||||
<div class="ml-2 text-ink-gray-7">
|
||||
<div class="ms-2 text-ink-gray-7">
|
||||
{{ comm.sender_full_name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
|
||||
</ListHeaderItem>
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
class="text-sm text-ink-gray-7"
|
||||
/>
|
||||
<div class="flex items-center text-sm text-ink-gray-7">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-ink-gray-7" />
|
||||
<span>
|
||||
<Clock class="h-4 w-4 stroke-1.5 me-2 text-ink-gray-7" />
|
||||
<span dir="ltr">
|
||||
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@
|
||||
v-if="batch.timezone"
|
||||
class="flex items-center text-sm text-ink-gray-7"
|
||||
>
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-ink-gray-5" />
|
||||
<Globe class="h-4 w-4 stroke-1.5 me-2 text-ink-gray-5" />
|
||||
<span>
|
||||
{{ batch.timezone }}
|
||||
</span>
|
||||
@@ -59,7 +59,7 @@
|
||||
class="flex avatar-group overlap mt-4"
|
||||
>
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
class="h-6 me-1"
|
||||
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
|
||||
>
|
||||
<UserAvatar
|
||||
@@ -108,6 +108,6 @@ const props = defineProps({
|
||||
}
|
||||
|
||||
.avatar-group.overlap .avatar + .avatar {
|
||||
margin-left: calc(-8px);
|
||||
margin-inline-start: calc(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
|
||||
</ListHeaderItem>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
@@ -65,7 +65,7 @@
|
||||
<Assessments :batch="batch.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l h-[88vh] divide-y">
|
||||
<div class="border-s h-[88vh] divide-y">
|
||||
<div v-if="batch.data?.evaluation" class="p-4 mb-5">
|
||||
<UpcomingEvaluations
|
||||
:batch="batch.data.name"
|
||||
|
||||
@@ -183,9 +183,3 @@ const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.feedback-list > button > div {
|
||||
align-items: start;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user