Merge v2.46.0 into develop — resolve all conflicts

- yarn.lock, components.d.ts, ru.po: accepted upstream v2.46.0
- AppSidebar.vue: kept custom sidebar links (MyPoints, LeaderBoard,
  ChatGPT, MyChild, Profile) + adopted async watch from v2.46.0
- MobileLayout.vue: merged onMounted with sidebarSettings.reload +
  custom addSideBar() for role-based mobile links
- EditProfile.vue: adopted new Dialog options structure (size only)
- Courses/Courses.vue: unified tab values to lowercase (enrolled,
  upcoming, new, created, unpublished)
- CourseDetail.vue (old): removed — logic migrated to Courses/CourseDetail.vue;
  transferred custom tag flex-wrap styling to CourseOverview.vue
- LessonForm.vue: kept Rutube video support
- App.vue: clean (no conflict markers)
- user.py: merged imports + kept custom sign_up params (phone, user_role)
- utils.py: kept render_html (Rutube), is_mentor, is_eligible_to_review;
  added type hints for get_course_progress from v2.46.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicolai
2026-03-09 18:25:58 +03:00
430 changed files with 64129 additions and 47136 deletions
+36 -5
View File
@@ -3,10 +3,15 @@ on:
push: push:
branches: branches:
- main - main
- develop
- main-hotfix
pull_request: {} pull_request: {}
jobs: jobs:
tests: tests:
name: Server Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
services: services:
redis-cache: redis-cache:
image: redis:alpine image: redis:alpine
@@ -30,13 +35,13 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: setup python - name: setup python
uses: actions/setup-python@v2 uses: actions/setup-python@v6
with: with:
python-version: '3.10' python-version: '3.14'
- name: setup node - name: setup node
uses: actions/setup-node@v2 uses: actions/setup-node@v6
with: with:
node-version: '18' node-version: '24'
check-latest: true check-latest: true
- name: setup cache for bench - name: setup cache for bench
uses: actions/cache@v4 uses: actions/cache@v4
@@ -69,6 +74,9 @@ jobs:
- name: setup requirements - name: setup requirements
working-directory: /home/runner/frappe-bench working-directory: /home/runner/frappe-bench
run: bench setup requirements --dev run: bench setup requirements --dev
- name: block endpoints
working-directory: /home/runner/frappe-bench
run: bench --site frappe.local set-config block_endpoints 1
- name: allow tests - name: allow tests
working-directory: /home/runner/frappe-bench working-directory: /home/runner/frappe-bench
run: bench --site frappe.local set-config allow_tests true run: bench --site frappe.local set-config allow_tests true
@@ -77,4 +85,27 @@ jobs:
run: bench --site frappe.local build run: bench --site frappe.local build
- name: run tests - name: run tests
working-directory: /home/runner/frappe-bench working-directory: /home/runner/frappe-bench
run: bench --site frappe.local run-tests --app lms run: bench --site frappe.local run-tests --app lms --coverage
- name: Upload coverage data
uses: actions/upload-artifact@v4
with:
path: /home/runner/frappe-bench/sites/coverage.xml
coverage:
name: Coverage Wrap Up
needs: tests
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v3
- name: Download artifacts
uses: actions/download-artifact@v4
- name: Upload coverage data
uses: codecov/codecov-action@v5
with:
name: Server
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
verbose: true
+7 -2
View File
@@ -22,9 +22,14 @@ jobs:
ref: ${{ matrix.branch }} ref: ${{ matrix.branch }}
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.14"
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Run script to update POT file - name: Run script to update POT file
run: | run: |
+4 -4
View File
@@ -16,9 +16,9 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 200 fetch-depth: 200
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
node-version: 20 node-version: 24
check-latest: true check-latest: true
- name: Check commit titles - name: Check commit titles
@@ -35,9 +35,9 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v6
with: with:
python-version: '3.10' python-version: '3.14'
- name: Cache pip - name: Cache pip
uses: actions/cache@v4 uses: actions/cache@v4
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
owner: frappe owner: frappe
repo: lms repo: lms
title: |- title: |-
"chore: merge 'develop' into 'main'" "chore: merge 'main-hotfix' into 'main'"
body: "Automated weekly release" body: "Automated weekly release"
base: main base: main
head: develop head: main-hotfix
env: env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+2 -2
View File
@@ -15,9 +15,9 @@ jobs:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: 20 node-version: 24
- name: Setup dependencies - name: Setup dependencies
run: | run: |
npm install @semantic-release/git @semantic-release/exec --no-save npm install @semantic-release/git @semantic-release/exec --no-save
+8 -5
View File
@@ -4,7 +4,10 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
push: push:
branches: [ main ] branches:
- main
- develop
- main-hotfix
permissions: permissions:
# Do not change this as GITHUB_TOKEN is being used by roulette # Do not change this as GITHUB_TOKEN is being used by roulette
@@ -36,9 +39,9 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v4 uses: actions/setup-python@v6
with: with:
python-version: '3.11' python-version: '3.14'
- name: Check for valid Python & Merge Conflicts - name: Check for valid Python & Merge Conflicts
run: | run: |
@@ -48,9 +51,9 @@ jobs:
exit 1 exit 1
fi fi
- uses: actions/setup-node@v3 - uses: actions/setup-node@v6
with: with:
node-version: 18 node-version: 24
check-latest: true check-latest: true
- name: Add to Hosts - name: Add to Hosts
+2
View File
@@ -12,4 +12,6 @@ node_modules
package-lock.json package-lock.json
lms/public/frontend lms/public/frontend
lms/www/lms.html lms/www/lms.html
lms/www/_lms.html
frappe-ui frappe-ui
frappe-semgrep-rules
+30
View File
@@ -0,0 +1,30 @@
pull_request_rules:
- name: backport to develop
conditions:
- label="backport develop"
actions:
backport:
branches:
- develop
assignees:
- "{{ author }}"
- name: backport to main-hotfix
conditions:
- label="backport main-hotfix"
actions:
backport:
branches:
- main-hotfix
assignees:
- "{{ author }}"
- name: backport to main
conditions:
- label="backport main"
actions:
backport:
branches:
- main
assignees:
- "{{ author }}"
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"branches": ["develop"], "branches": ["main"],
"plugins": [ "plugins": [
"@semantic-release/commit-analyzer", { "@semantic-release/commit-analyzer", {
"preset": "angular" "preset": "angular"
+2
View File
@@ -0,0 +1,2 @@
ignore:
- "**/test_helper.py"
+24 -20
View File
@@ -27,6 +27,10 @@ describe("Batch Creation", () => {
cy.get("input[placeholder='Jane']").type(randomName); cy.get("input[placeholder='Jane']").type(randomName);
cy.get("button").contains("Add").click(); cy.get("button").contains("Add").click();
// Open Settings
cy.get("span").contains("Learning").click();
cy.get("span").contains("Settings").click();
// Add evaluator // Add evaluator
cy.get("[data-dismissable-layer]") cy.get("[data-dismissable-layer]")
.find("span") .find("span")
@@ -48,26 +52,23 @@ describe("Batch Creation", () => {
// Create a batch // Create a batch
cy.get("button").contains("Create").click(); cy.get("button").contains("Create").click();
cy.get("span").contains("New Batch").click();
cy.wait(500); cy.wait(500);
cy.url().should("include", "/batches/new/edit");
cy.get("label").contains("Title").type("Test Batch"); cy.get("label").contains("Title").type("Test Batch");
cy.get("label").contains("Start Date").type("2030-10-01"); cy.get("label").contains("Start Date").type("2030-10-01");
cy.get("label").contains("End Date").type("2030-10-31"); cy.get("label").contains("End Date").type("2030-10-31");
cy.get("label").contains("Start Time").type("10:00"); cy.get("label").contains("Start Time").type("10:00");
cy.get("label").contains("End Time").type("11:00"); cy.get("label").contains("End Time").type("11:00");
cy.get("label").contains("Timezone").type("IST"); cy.get("label").contains("Timezone").type("IST");
cy.get("label").contains("Seat Count").type("10"); cy.get("label").contains("Seat Count").type("10");
cy.get("label").contains("Published").click();
cy.get("label") cy.get("label")
.contains("Short Description") .contains("Description")
.type("Test Batch Short Description to test the UI"); .type("Test Batch Short Description to test the UI");
cy.get("div[contenteditable=true").invoke( cy.get("div[contenteditable=true").invoke(
"text", "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." "Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
); );
/* Instructor */ /* Instructor */
cy.get("label") cy.get("label")
.contains("Instructors") .contains("Instructors")
@@ -85,13 +86,14 @@ describe("Batch Creation", () => {
cy.get("[id^=headlessui-combobox-option-").first().click(); cy.get("[id^=headlessui-combobox-option-").first().click();
}); });
}); });
cy.button("Save").click();
cy.get("label").contains("Published").click();
cy.button("Save").click(); cy.button("Save").click();
cy.wait(1000); cy.wait(1000);
let batchName; let batchName;
cy.url().then((url) => { cy.url().then((url) => {
console.log(url); console.log(url);
batchName = url.split("/").pop(); batchName = url.split("/").pop().split("#")[0];
cy.wrap(batchName).as("batchName"); cy.wrap(batchName).as("batchName");
}); });
cy.wait(500); cy.wait(500);
@@ -110,7 +112,7 @@ describe("Batch Creation", () => {
.click(); .click();
cy.get("@batchName").then((batchName) => { cy.get("@batchName").then((batchName) => {
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => { cy.get(`a[href='/lms/batches/${batchName}'`).within(() => {
cy.get("div").contains("Test Batch").should("be.visible"); cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div") cy.get("div")
.contains("Test Batch Short Description to test the UI") .contains("Test Batch Short Description to test the UI")
@@ -123,14 +125,11 @@ describe("Batch Creation", () => {
.should("be.visible"); .should("be.visible");
cy.get("span").contains("IST").should("be.visible"); cy.get("span").contains("IST").should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible"); cy.get("a").contains("Evaluator").should("be.visible");
cy.get("div") cy.contains("div:visible", "10 Seats Left").should(
.contains("10") "be.visible"
.should("be.visible") );
.get("span")
.contains("Seats Left")
.should("be.visible");
}); });
cy.get(`a[href='/lms/batches/details/${batchName}'`).click(); cy.get(`a[href='/lms/batches/${batchName}'`).click();
}); });
cy.get("div").contains("Test Batch").should("be.visible"); cy.get("div").contains("Test Batch").should("be.visible");
@@ -152,17 +151,22 @@ describe("Batch Creation", () => {
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now." "Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
) )
.should("be.visible"); .should("be.visible");
cy.get("button:visible").contains("Manage Batch").click(); cy.get("button:visible").contains("Dashboard").click();
/* Add student to batch */ /* Add student to batch */
cy.get("button").contains("Add").click(); cy.get("button").contains("Enroll").click();
cy.get('div[role="dialog"]').first().find("button").eq(1).click(); cy.get('div[role="dialog"]')
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail); .first()
.find("div[label='Student']")
.find("div")
.first()
.click();
cy.get("input[placeholder='Search']").type(randomEmail);
cy.get("div").contains(randomEmail).click(); cy.get("div").contains(randomEmail).click();
cy.get("button").contains("Submit").click(); cy.get("button").contains("Submit").click();
// Verify Seat Count // Verify Seat Count
cy.get("span").contains("Details").click(); cy.get("button:visible").contains("Overview").click();
cy.contains("div:visible", "9 Seats Left").should("be.visible"); cy.contains("div:visible", "9 Seats Left").should("be.visible");
}); });
}); });
+19 -17
View File
@@ -9,8 +9,8 @@ describe("Course Creation", () => {
// Create a course // Create a course
cy.get("button").contains("Create").click(); cy.get("button").contains("Create").click();
cy.get("span").contains("New Course").click();
cy.wait(500); cy.wait(500);
cy.url().should("include", "/courses/new/edit");
cy.get("label").contains("Title").type("Test Course"); cy.get("label").contains("Title").type("Test Course");
cy.get("label") cy.get("label")
@@ -34,27 +34,13 @@ describe("Course Creation", () => {
}); });
}); });
cy.get("label")
.contains("Preview Video")
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
cy.get("label")
.contains("Category")
.parent()
.within(() => {
cy.get("button").click();
});
cy.get("[id^=headlessui-combobox-option-")
.should("be.visible")
.first()
.click();
/* Instructor */ /* Instructor */
cy.get("label") cy.get("label")
.contains("Instructors") .contains("Instructors")
.parent() .parent()
.within(() => { .within(() => {
cy.get("input").click().type("frappe"); cy.get("input").click().type("frappe");
cy.wait(500);
cy.get("input") cy.get("input")
.invoke("attr", "aria-controls") .invoke("attr", "aria-controls")
.as("instructor_list_id"); .as("instructor_list_id");
@@ -67,13 +53,29 @@ describe("Course Creation", () => {
}); });
}); });
cy.button("Save").last().click();
// Edit Course Details
cy.wait(500);
cy.get("label")
.contains("Preview Video")
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
cy.get("label")
.contains("Category")
.parent()
.within(() => {
cy.get("button").click();
});
cy.get("div").contains("Business").click();
cy.get("label").contains("Published").click(); cy.get("label").contains("Published").click();
cy.get("label").contains("Published On").type("2021-01-01"); cy.get("label").contains("Published On").type("2021-01-01");
cy.button("Save").click(); cy.button("Save").click();
// Add Chapter // Add Chapter
cy.wait(1000); cy.wait(1000);
cy.button("Add Chapter").click(); cy.button("Add").click();
cy.wait(1000); cy.wait(1000);
cy.get("[data-dismissable-layer]") cy.get("[data-dismissable-layer]")
+3 -1
View File
@@ -6,5 +6,7 @@
// biome-ignore lint: disable // biome-ignore lint: disable
export {} export {}
declare global { declare global {
const LucideGithub: typeof import('~icons/lucide/github').default
const LucideLinkedin: typeof import('~icons/lucide/linkedin').default
const LucideTwitter: typeof import('~icons/lucide/twitter').default
} }
+15 -19
View File
@@ -8,13 +8,10 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
Annoucements: typeof import('./src/components/Annoucements.vue')['default'] Apps: typeof import('./src/components/Sidebar/Apps.vue')['default']
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default'] AppSidebar: typeof import('./src/components/Sidebar/AppSidebar.vue')['default']
Apps: typeof import('./src/components/Apps.vue')['default']
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default'] AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default'] AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
Assessments: typeof import('./src/components/Assessments.vue')['default']
Assignment: typeof import('./src/components/Assignment.vue')['default'] Assignment: typeof import('./src/components/Assignment.vue')['default']
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default'] AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default'] AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
@@ -23,16 +20,8 @@ declare module 'vue' {
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default'] BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default'] BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
Badges: typeof import('./src/components/Settings/Badges.vue')['default'] Badges: typeof import('./src/components/Settings/Badges.vue')['default']
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default'] BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
BatchDashboard: typeof import('./src/components/BatchDashboard.vue')['default']
BatchFeedback: typeof import('./src/components/BatchFeedback.vue')['default']
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default'] BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
Categories: typeof import('./src/components/Settings/Categories.vue')['default'] Categories: typeof import('./src/components/Settings/Categories.vue')['default']
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default'] CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default'] ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
@@ -41,12 +30,18 @@ declare module 'vue' {
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default'] CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default'] CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default'] ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
CommandPalette: typeof import('./src/components/CommandPalette/CommandPalette.vue')['default']
CommandPaletteGroup: typeof import('./src/components/CommandPalette/CommandPaletteGroup.vue')['default']
Configuration: typeof import('./src/components/Sidebar/Configuration.vue')['default']
ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default'] ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default']
CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default']
CouponItems: typeof import('./src/components/Settings/Coupons/CouponItems.vue')['default']
CouponList: typeof import('./src/components/Settings/Coupons/CouponList.vue')['default']
Coupons: typeof import('./src/components/Settings/Coupons/Coupons.vue')['default']
CourseCard: typeof import('./src/components/CourseCard.vue')['default'] CourseCard: typeof import('./src/components/CourseCard.vue')['default']
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default'] CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default'] CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default'] CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.vue')['default']
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default'] CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default'] CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
DateRange: typeof import('./src/components/Common/DateRange.vue')['default'] DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
@@ -75,7 +70,6 @@ declare module 'vue' {
LessonContent: typeof import('./src/components/LessonContent.vue')['default'] LessonContent: typeof import('./src/components/LessonContent.vue')['default']
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default'] LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default'] Link: typeof import('./src/components/Controls/Link.vue')['default']
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default'] LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default'] LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default'] LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
@@ -86,6 +80,7 @@ declare module 'vue' {
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default'] NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
Notes: typeof import('./src/components/Notes/Notes.vue')['default'] Notes: typeof import('./src/components/Notes/Notes.vue')['default']
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default'] NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
NumberChartGraph: typeof import('./src/components/NumberChartGraph.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default'] PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentGatewayDetails: typeof import('./src/components/Settings/PaymentGatewayDetails.vue')['default'] PaymentGatewayDetails: typeof import('./src/components/Settings/PaymentGatewayDetails.vue')['default']
PaymentGateways: typeof import('./src/components/Settings/PaymentGateways.vue')['default'] PaymentGateways: typeof import('./src/components/Settings/PaymentGateways.vue')['default']
@@ -103,18 +98,19 @@ declare module 'vue' {
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default'] SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default'] SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
Settings: typeof import('./src/components/Settings/Settings.vue')['default'] Settings: typeof import('./src/components/Settings/Settings.vue')['default']
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default'] SidebarLink: typeof import('./src/components/Sidebar/SidebarLink.vue')['default']
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default'] StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default'] StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
Tags: typeof import('./src/components/Tags.vue')['default'] Tags: typeof import('./src/components/Tags.vue')['default']
TransactionDetails: typeof import('./src/components/Settings/TransactionDetails.vue')['default'] TransactionDetails: typeof import('./src/components/Settings/Transactions/TransactionDetails.vue')['default']
Transactions: typeof import('./src/components/Settings/Transactions.vue')['default'] TransactionList: typeof import('./src/components/Settings/Transactions/TransactionList.vue')['default']
Transactions: typeof import('./src/components/Settings/Transactions/Transactions.vue')['default']
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default'] UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default'] UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default'] Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default'] UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default'] UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default'] UserDropdown: typeof import('./src/components/Sidebar/UserDropdown.vue')['default']
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default'] VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default'] VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default'] ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
+47 -45
View File
@@ -6,55 +6,57 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"serve": "vite preview", "serve": "vite preview",
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry", "build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry && yarn copy-colors-json",
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html" "copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/_lms.html",
"copy-colors-json": "cp node_modules/frappe-ui/tailwind/colors.json src/utils/frappe-ui-colors.json"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "6.4.9",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "6.2.4",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "6.0.1",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "6.2.1",
"@editorjs/checklist": "^1.6.0", "@editorjs/checklist": "1.6.0",
"@editorjs/code": "^2.9.0", "@editorjs/code": "2.9.0",
"@editorjs/editorjs": "^2.29.0", "@editorjs/editorjs": "2.29.0",
"@editorjs/embed": "^2.7.0", "@editorjs/embed": "2.7.0",
"@editorjs/header": "^2.8.1", "@editorjs/header": "2.8.1",
"@editorjs/inline-code": "^1.5.0", "@editorjs/inline-code": "1.5.0",
"@editorjs/nested-list": "^1.4.2", "@editorjs/nested-list": "1.4.2",
"@editorjs/paragraph": "^2.11.3", "@editorjs/paragraph": "2.11.3",
"@editorjs/simple-image": "^1.6.0", "@editorjs/simple-image": "1.6.0",
"@editorjs/table": "^2.4.2", "@editorjs/table": "2.4.2",
"@vueuse/router": "^12.7.0", "@vueuse/core": "^14.1.0",
"ace-builds": "^1.36.2", "ace-builds": "1.36.2",
"apexcharts": "^4.3.0", "apexcharts": "4.3.0",
"chart.js": "^4.4.1", "chart.js": "4.4.1",
"codemirror": "^6.0.1", "codemirror": "6.0.1",
"dayjs": "^1.11.6", "dayjs": "1.11.10",
"dompurify": "^3.2.6", "dompurify": "3.2.6",
"feather-icons": "^4.28.0", "feather-icons": "4.28.0",
"frappe-ui": "^0.1.201", "frappe-ui": "^0.1.261",
"highlight.js": "^11.11.1", "highlight.js": "11.11.1",
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "14.0.0",
"pinia": "^2.0.33", "pinia": "2.0.33",
"plyr": "^3.7.8", "plyr": "3.7.8",
"socket.io-client": "^4.7.2", "socket.io-client": "4.7.2",
"tailwindcss": "3.4.15", "thememirror": "2.0.1",
"thememirror": "^2.0.1", "typescript": "5.7.2",
"typescript": "^5.7.2", "vue": "^3.5.27",
"vue": "^3.4.23", "vue-chartjs": "5.3.0",
"vue-chartjs": "^5.3.0", "vue-codemirror": "6.1.1",
"vue-codemirror": "^6.1.1", "vue-draggable-next": "2.2.1",
"vue-draggable-next": "^2.2.1", "vue-router": "^4.6.4",
"vue-router": "^4.0.12", "vue3-apexcharts": "1.8.0",
"vue3-apexcharts": "^1.8.0",
"vuedraggable": "4.1.0" "vuedraggable": "4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "5.0.3",
"autoprefixer": "^10.4.2", "autoprefixer": "10.4.2",
"postcss": "^8.4.5", "postcss": "8.4.5",
"vite": "^5.0.11", "tailwindcss": "^3.4.15",
"vite-plugin-pwa": "^1.0.2" "unplugin-auto-import": "^20.3.0",
"vite": "5.0.11",
"vite-plugin-pwa": "^1.2.0"
} }
} }
+4 -11
View File
@@ -3,18 +3,17 @@
<Layout class="isolate text-base"> <Layout class="isolate text-base">
<router-view /> <router-view />
</Layout> </Layout>
<!--<InstallPrompt v-if="isMobile" />--> <InstallPrompt v-if="isMobile && !settings.data?.disable_pwa" />
<Dialogs /> <Dialogs />
</FrappeUIProvider> </FrappeUIProvider>
</template> </template>
<script setup> <script setup>
import { FrappeUIProvider } from 'frappe-ui' import { FrappeUIProvider } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs' import { Dialogs } from '@/utils/dialogs'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { useScreenSize } from './utils/composables' import { useScreenSize } from './utils/composables'
import { usersStore } from '@/stores/user' import { useSettings } from '@/stores/settings'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { posthogSettings } from '@/telemetry'
import DesktopLayout from './components/DesktopLayout.vue' import DesktopLayout from './components/DesktopLayout.vue'
import MobileLayout from './components/MobileLayout.vue' import MobileLayout from './components/MobileLayout.vue'
import NoSidebarLayout from './components/NoSidebarLayout.vue' import NoSidebarLayout from './components/NoSidebarLayout.vue'
@@ -23,7 +22,7 @@ import InstallPrompt from './components/InstallPrompt.vue'
const { isMobile } = useScreenSize() const { isMobile } = useScreenSize()
const router = useRouter() const router = useRouter()
const noSidebar = ref(false) const noSidebar = ref(false)
const { userResource } = usersStore() const { settings } = useSettings()
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (to.query.fromLesson || to.path === '/persona') { if (to.query.fromLesson || to.path === '/persona') {
@@ -47,10 +46,4 @@ const Layout = computed(() => {
onUnmounted(() => { onUnmounted(() => {
noSidebar.value = false noSidebar.value = false
}) })
watch(userResource, () => {
if (userResource.data) {
posthogSettings.reload()
}
})
</script> </script>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-152
View File
@@ -1,152 +0,0 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
url("Inter-Thin.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 100;
font-display: swap;
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
url("Inter-ThinItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 200;
font-display: swap;
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
url("Inter-ExtraLight.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 200;
font-display: swap;
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
url("Inter-Light.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 300;
font-display: swap;
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
url("Inter-LightItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
url("Inter-Regular.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
url("Inter-Italic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
url("Inter-Medium.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 500;
font-display: swap;
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
url("Inter-MediumItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
url("Inter-SemiBold.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
url("Inter-Bold.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
url("Inter-BoldItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
url("Inter-ExtraBold.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 800;
font-display: swap;
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
url("Inter-Black.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 900;
font-display: swap;
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
url("Inter-BlackItalic.woff?v=3.12") format("woff");
}
-53
View File
@@ -1,53 +0,0 @@
<template>
<div v-if="communications.data?.length">
<div v-for="comm in communications.data">
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<Avatar :label="comm.sender_full_name" size="lg" />
<div class="ml-2 text-ink-gray-7">
{{ comm.sender_full_name }}
</div>
</div>
<div class="text-sm">
{{ timeAgo(comm.communication_date) }}
</div>
</div>
<div
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
v-html="comm.content"
></div>
</div>
</div>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No announcements') }}
</div>
</template>
<script setup>
import { createResource, Avatar } from 'frappe-ui'
import { timeAgo } from '@/utils'
const props = defineProps({
batch: {
type: String,
required: true,
},
})
const communications = createResource({
url: 'lms.lms.api.get_announcements',
makeParams(value) {
return {
batch: props.batch,
}
},
auto: true,
cache: ['announcement', props.batch],
})
</script>
<style>
.prose-sm p {
margin: 0 0 0.5rem;
}
</style>
+32 -5
View File
@@ -26,28 +26,52 @@
v-model="quiz" v-model="quiz"
doctype="LMS Quiz" doctype="LMS Quiz"
:label="__('Select a quiz')" :label="__('Select a quiz')"
placeholder=" "
:onCreate="(value, close) => redirectToForm()"
/>
<div v-else class="space-y-4">
<Link
v-if="filterAssignmentsByCourse"
v-model="assignment"
doctype="LMS Assignment"
:filters="{
course: route.params.courseName,
}"
placeholder=" "
:label="__('Select an Assignment')"
:onCreate="(value, close) => redirectToForm()" :onCreate="(value, close) => redirectToForm()"
/> />
<Link <Link
v-else v-else
v-model="assignment" v-model="assignment"
doctype="LMS Assignment" doctype="LMS Assignment"
:label="__('Select an assignment')" placeholder=" "
:label="__('Select an Assignment')"
:onCreate="(value, close) => redirectToForm()" :onCreate="(value, close) => redirectToForm()"
/> />
<FormControl
type="checkbox"
:label="__('Filter assignments by course')"
v-model="filterAssignmentsByCourse"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog } from 'frappe-ui' import { Dialog, FormControl } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue' import { nextTick, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getLmsRoute } from '@/utils/basePath'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
const show = ref(false) const show = ref(false)
const quiz = ref(null) const quiz = ref(null)
const assignment = ref(null) const assignment = ref(null)
const filterAssignmentsByCourse = ref(false)
const route = useRoute()
const props = defineProps({ const props = defineProps({
type: { type: {
@@ -71,7 +95,10 @@ const addAssessment = () => {
} }
const redirectToForm = () => { const redirectToForm = () => {
if (props.type == 'quiz') window.open('/lms/quizzes/new', '_blank') if (props.type == 'quiz') {
else window.open('/lms/assignments/new', '_blank') window.open(getLmsRoute('quizzes?new=true'), '_blank')
} else {
window.open(getLmsRoute('assignments?new=true'), '_blank')
}
} }
</script> </script>
+96 -128
View File
@@ -16,8 +16,8 @@
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }} {{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
</div> </div>
</div> </div>
<div class="text-sm text-ink-gray-7 font-medium mb-2"> <div class="text-ink-gray-9 font-semibold mb-5">
{{ __('Question') }}: {{ __('Assignment Question') }}
</div> </div>
<div <div
v-html="assignment.data.question" v-html="assignment.data.question"
@@ -25,9 +25,9 @@
></div> ></div>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col overflow-y-auto">
<div class="p-5"> <div class="p-5 space-y-5">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between">
<div class="font-semibold text-ink-gray-9"> <div class="font-semibold text-ink-gray-9">
{{ __('Submission') }} {{ __('Submission') }}
</div> </div>
@@ -42,7 +42,11 @@
> >
{{ submissionResource.doc?.status }} {{ submissionResource.doc?.status }}
</Badge> </Badge>
<Button variant="solid" @click="submitAssignment()"> <Button
v-if="canModifyAssignment"
variant="solid"
@click="submitAssignment()"
>
{{ __('Save') }} {{ __('Save') }}
</Button> </Button>
</div> </div>
@@ -53,7 +57,7 @@
!['Pass', 'Fail'].includes(submissionResource.doc?.status) && !['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
submissionResource.doc?.owner == user.data?.name submissionResource.doc?.owner == user.data?.name
" "
class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm mb-4" class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm"
> >
{{ __("You've successfully submitted the assignment.") }} {{ __("You've successfully submitted the assignment.") }}
{{ {{
@@ -63,17 +67,24 @@
}} }}
{{ __('Feel free to make edits to your submission if needed.') }} {{ __('Feel free to make edits to your submission if needed.') }}
</div> </div>
<div v-if="showUploader()"> <div v-if="showUploader()" class="border rounded-lg p-3">
<div class="text-xs text-ink-gray-5 mt-1 mb-2"> <div class="font-semibold mb-2">
{{ __('Add your assignment as {0}').format(assignment.data.type) }} {{ __('Upload Assignment') }}
</div>
<div class="text-ink-gray-5 text-sm mt-1 mb-4">
{{
__('You can only upload {0} files').format(assignment.data.type)
}}
</div> </div>
<FileUploader <FileUploader
v-if="!submissionFile" v-if="!attachment"
:fileTypes="getType()" :fileTypes="getType()"
:uploadArgs="{ :uploadArgs="{
private: true, private: true,
}" }"
:validateFile="validateFile" :validateFile="
(file) => validateFile(file, assignment.data.type.toLowerCase())
"
@success="(file) => saveSubmission(file)" @success="(file) => saveSubmission(file)"
> >
<template #default="{ uploading, progress, openFileSelector }"> <template #default="{ uploading, progress, openFileSelector }">
@@ -87,21 +98,20 @@
</template> </template>
</FileUploader> </FileUploader>
<div v-else> <div v-else>
<div class="flex text-ink-gray-7"> <div class="flex items-center text-ink-gray-7">
<div class="border self-start rounded-md p-2 mr-2"> <a
:href="attachment"
target="_blank"
class="cursor-pointer !no-underline text-sm leading-5"
>
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5" /> <FileText class="h-5 w-5 stroke-1.5" />
</div> </div>
<a <span>
:href="submissionFile.file_url" {{ attachment.split('/').pop() }}
target="_blank"
class="flex flex-col cursor-pointer !no-underline"
>
<span class="text-sm leading-5">
{{ submissionFile.file_name }}
</span>
<span class="text-sm text-ink-gray-5 mt-1">
{{ getFileSize(submissionFile.file_size) }}
</span> </span>
</div>
</a> </a>
<X <X
v-if="canModifyAssignment" v-if="canModifyAssignment"
@@ -130,10 +140,11 @@
@change="(val) => (answer = val)" @change="(val) => (answer = val)"
:editable="true" :editable="true"
:fixedMenu="true" :fixedMenu="true"
:readonly="!canModifyAssignment"
:uploadArgs="{ :uploadArgs="{
private: true, private: true,
}" }"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]" editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/> />
</div> </div>
@@ -142,13 +153,13 @@
user.data?.name == submissionResource.doc?.owner && user.data?.name == submissionResource.doc?.owner &&
submissionResource.doc?.comments submissionResource.doc?.comments
" "
class="mt-8 p-3 bg-surface-blue-2 rounded-md" class="mt-8 p-3 border rounded-lg bg-surface-gray-2"
> >
<div class="text-sm text-ink-gray-5 font-medium mb-2"> <div class="text-ink-gray-5 mb-4">
{{ __('Comments by Evaluator') }}: {{ __('Comments by Evaluator') }}
</div> </div>
<div <div
class="leading-5 text-ink-gray-9" class="leading-6 text-ink-gray-9"
v-html="submissionResource.doc.comments" v-html="submissionResource.doc.comments"
></div> ></div>
</div> </div>
@@ -179,7 +190,10 @@
" "
:editable="true" :editable="true"
:fixedMenu="true" :fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]" :uploadArgs="{
private: true,
}"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/> />
</div> </div>
</div> </div>
@@ -201,11 +215,11 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue' import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'
import { getFileSize } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { validateFile } from '@/utils'
const submissionFile = ref(null)
const answer = ref(null) const answer = ref(null)
const attachment = ref(null)
const comments = ref(null) const comments = ref(null)
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
@@ -255,110 +269,52 @@ const assignment = createResource({
}, },
}) })
const newSubmission = createResource({
url: 'frappe.client.insert',
makeParams(values) {
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentID,
member: user.data?.name,
}
if (showUploader()) {
doc.assignment_attachment = submissionFile.value.file_url
} else {
doc.answer = answer.value
}
return {
doc: doc,
}
},
})
const imageResource = createResource({
url: 'lms.lms.api.get_file_info',
makeParams(values) {
return {
file_url: values.image,
}
},
auto: false,
onSuccess(data) {
submissionFile.value = data
},
})
const submissionResource = createDocumentResource({ const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission', doctype: 'LMS Assignment Submission',
name: props.submissionName, name: props.submissionName,
auto: false,
onError(err) { onError(err) {
toast.error(err.messages?.[0] || err) toast.error(err.messages?.[0] || err)
}, },
auto: false,
cache: [user.data?.name, props.assignmentID],
}) })
watch(submissionResource, () => { watch(submissionResource, () => {
if (submissionResource.doc) { if (!submissionResource.doc) return
if (submissionResource.doc.assignment_attachment) { console.log(submissionResource.doc)
imageResource.reload({
image: submissionResource.doc.assignment_attachment,
})
}
if (submissionResource.doc.answer) { if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer answer.value = submissionResource.doc.answer
} }
if (submissionResource.doc.assignment_attachment) {
attachment.value = submissionResource.doc.assignment_attachment
}
if (submissionResource.doc.comments) { if (submissionResource.doc.comments) {
comments.value = submissionResource.doc.comments comments.value = submissionResource.doc.comments
} }
if (submissionResource.isDirty) {
isDirty.value = true
} else if (showUploader() && !submissionFile.value) {
isDirty.value = true
} else if (!showUploader() && !answer.value) {
isDirty.value = true
} else {
isDirty.value = false
}
}
})
watch(submissionFile, () => {
if (props.submissionName == 'new' && submissionFile.value) {
isDirty.value = true
}
}) })
const submitAssignment = () => { const submitAssignment = () => {
if (props.submissionName != 'new') { if (props.submissionName != 'new') {
let evaluator = updateSubmission()
submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name
: null
submissionResource.setValue.submit(
{
...submissionResource.doc,
assignment_attachment: submissionFile.value?.file_url,
evaluator: evaluator,
comments: comments.value,
answer: answer.value,
},
{
onSuccess(data) {
toast.success(__('Changes saved successfully'))
},
}
)
} else { } else {
addNewSubmission() addNewSubmission()
} }
} }
const addNewSubmission = () => { const addNewSubmission = () => {
newSubmission.submit( let doc = {
{}, doctype: 'LMS Assignment Submission',
{ assignment: props.assignmentID,
onSuccess(data) { member: user.data?.name,
}
if (!showUploader()) {
doc.answer = answer.value
} else {
doc.assignment_attachment = attachment.value
}
call('frappe.client.insert', {
doc: doc,
})
.then((data) => {
toast.success(__('Assignment submitted successfully')) toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') { if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({ router.push({
@@ -373,11 +329,38 @@ const addNewSubmission = () => {
markLessonProgress() markLessonProgress()
router.go() router.go()
} }
isDirty.value = false
submissionResource.name = data.name submissionResource.name = data.name
submissionResource.reload() submissionResource.reload()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const updateSubmission = () => {
let evaluator =
submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name
: null
submissionResource.setValue.submit(
{
...submissionResource.doc,
evaluator: evaluator,
comments: comments.value,
answer: answer.value,
assignment_attachment: attachment.value,
},
{
onSuccess(data) {
isDirty.value = false
toast.success(__('Changes saved successfully'))
}, },
onError(err) { onError(err) {
toast.error(err.messages?.[0] || err) toast.error(err.messages?.[0] || err)
console.error(err)
}, },
} }
) )
@@ -385,7 +368,7 @@ const addNewSubmission = () => {
const saveSubmission = (file) => { const saveSubmission = (file) => {
isDirty.value = true isDirty.value = true
submissionFile.value = file attachment.value = file.file_url
} }
const markLessonProgress = () => { const markLessonProgress = () => {
@@ -419,24 +402,9 @@ const getType = () => {
} }
} }
const validateFile = (file) => {
let type = assignment.data?.type
let extension = file.name.split('.').pop().toLowerCase()
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
} else if (
type == 'Document' &&
!['doc', 'docx', 'xml'].includes(extension)
) {
return 'Only document file is allowed.'
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
return 'Only PDF file is allowed.'
}
}
const removeSubmission = () => { const removeSubmission = () => {
isDirty.value = true isDirty.value = true
submissionFile.value = null submissionResource.doc.assignment_attachment = ''
} }
const canGradeSubmission = computed(() => { const canGradeSubmission = computed(() => {
@@ -1,26 +0,0 @@
<template>
<div class="space-y-10">
<UpcomingEvaluations
:batch="batch.data.name"
:endDate="batch.data.evaluation_end_date"
:courses="batch.data.courses"
/>
<Assessments :batch="batch.data.name" />
<!-- <StudentHeatmap /> -->
</div>
</template>
<script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/components/Assessments.vue'
const props = defineProps({
batch: {
type: Object,
default: null,
},
isStudent: {
type: Boolean,
default: false,
},
})
</script>
-354
View File
@@ -1,354 +0,0 @@
<template>
<div v-if="batch.data" class="">
<div class="w-full flex items-center justify-between pb-4">
<div class="font-medium text-ink-gray-7">
{{ __('Statistics') }}
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
<NumberChart
class="border rounded-md"
:config="{ title: __('Students'), value: students.data?.length || 0 }"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Certified'),
value: certificationCount.data || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Courses'),
value: batch.data.courses?.length || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{ title: __('Assessments'), value: assessmentCount || 0 }"
/>
</div>
<AxisChart
v-if="showProgressChart"
:config="{
data: chartData,
title: __('Batch Summary'),
subtitle: __('Progress of students in courses and assessments'),
xAxis: {
key: 'task',
title: 'Tasks',
type: 'category',
},
yAxis: {
title: __('Number of Students'),
echartOptions: {
minInterval: 1,
},
},
swapXY: true,
series: [
{
name: 'value',
type: 'bar',
},
],
}"
/>
</div>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-ink-gray-7 font-medium">
{{ __('Students') }}
</div>
<Button v-if="!readOnlyMode" @click="openStudentModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="students.data?.length">
<ListView
:columns="getStudentColumns()"
:rows="students.data"
row-key="name"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem
:item="item"
v-for="item in getStudentColumns()"
:title="item.label"
>
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in students.data"
class="group cursor-pointer"
@click="openStudentProgressModal(row)"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'full_name'">
<Avatar
class="flex items-center"
:image="row['user_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div
v-if="column.key == 'progress'"
class="flex items-center space-x-4 w-full"
>
<ProgressBar :progress="row[column.key]" size="sm" />
<div class="text-xs">{{ row[column.key] }}%</div>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeStudents(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('There are no students in this batch.') }}
</div>
</div>
<StudentModal
:batch="props.batch.data.name"
v-model="showStudentModal"
v-model:reloadStudents="students"
v-model:batchModal="props.batch"
/>
<BatchStudentProgress
:student="selectedStudent"
v-model="showStudentProgressModal"
/>
</template>
<script setup>
import {
Avatar,
AxisChart,
Button,
createResource,
FeatherIcon,
ListHeader,
ListHeaderItem,
ListSelectBanner,
ListRow,
ListRows,
ListView,
ListRowItem,
NumberChart,
toast,
} from 'frappe-ui'
import {
BookOpen,
GraduationCap,
Plus,
ShieldCheck,
Trash2,
User,
} from 'lucide-vue-next'
import { ref, watch } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
import ApexChart from 'vue3-apexcharts'
import { theme } from '@/utils/theme'
const showStudentModal = ref(false)
const showStudentProgressModal = ref(false)
const selectedStudent = ref(null)
const chartData = ref(null)
const showProgressChart = ref(false)
const assessmentCount = ref(0)
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
type: Object,
default: null,
},
})
const students = createResource({
url: 'lms.lms.utils.get_batch_students',
params: {
batch: props.batch?.data?.name,
},
auto: true,
onSuccess(data) {
chartData.value = getChartData()
showProgressChart.value =
data.length &&
(props.batch?.data?.courses?.length || assessmentCount.value)
},
})
const getStudentColumns = () => {
let columns = [
{
label: 'Full Name',
key: 'full_name',
width: '20rem',
icon: 'user',
},
{
label: 'Progress',
key: 'progress',
width: '15rem',
icon: 'activity',
},
{
label: 'Last Active',
key: 'last_active',
width: '10rem',
align: 'center',
icon: 'clock',
},
]
return columns
}
const openStudentModal = () => {
showStudentModal.value = true
}
const openStudentProgressModal = (row) => {
showStudentProgressModal.value = true
selectedStudent.value = row
}
const deleteStudents = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Batch Enrollment',
documents: values.students,
}
},
})
const removeStudents = (selections, unselectAll) => {
deleteStudents.submit(
{
students: Array.from(selections),
},
{
onSuccess(data) {
students.reload()
props.batch.reload()
toast.success(__('Students deleted successfully'))
unselectAll()
},
}
)
}
const getChartData = () => {
let tasks = []
let data = []
students.data.forEach((row) => {
tasks = countAssessments(row, tasks)
tasks = countCourses(row, tasks)
})
tasks.forEach((task) => {
data.push({
task: task.label,
value: task.value,
})
})
return data
}
const countAssessments = (row, tasks) => {
Object.keys(row.assessments).forEach((assessment) => {
if (row.assessments[assessment].result === 'Pass') {
tasks.filter((task) => task.label === assessment).length
? tasks.filter((task) => task.label === assessment)[0].value++
: tasks.push({
value: 1,
label: assessment,
})
}
})
return tasks
}
const countCourses = (row, tasks) => {
Object.keys(row.courses).forEach((course) => {
if (row.courses[course] === 100) {
tasks.filter((task) => task.label === course).length
? tasks.filter((task) => task.label === course)[0].value++
: tasks.push({
value: 1,
label: course,
})
}
})
return tasks
}
watch(students, () => {
if (students.data?.length) {
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
}
})
const certificationCount = createResource({
url: 'frappe.client.get_count',
params: {
doctype: 'LMS Certificate',
filters: {
batch_name: props.batch?.data?.name,
},
},
auto: true,
})
</script>
@@ -68,11 +68,12 @@ const props = defineProps({
const certification = createResource({ const certification = createResource({
url: 'lms.lms.api.get_certification_details', url: 'lms.lms.api.get_certification_details',
params: { makeParams(values) {
return {
course: props.courseName, course: props.courseName,
}
}, },
auto: user.data ? true : false, auto: user.data ? true : false,
cache: ['certificationData', user.data?.name],
}) })
const downloadCertificate = () => { const downloadCertificate = () => {
@@ -0,0 +1,272 @@
<template>
<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">
<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"
@input="onInput"
v-model="query"
autocomplete="off"
/>
</div>
<div class="max-h-96 overflow-auto mb-2">
<div v-if="query.length" class="mt-5 space-y-5">
<CommandPaletteGroup
:list="searchResults"
@navigateTo="navigateTo"
/>
</div>
<div v-else class="mt-5 space-y-5">
<CommandPaletteGroup
:list="jumpToOptions"
@navigateTo="navigateTo"
/>
</div>
</div>
<div
class="flex items-center space-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">
<MoveUp
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
/>
<MoveDown
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
/>
<span>
{{ __('to navigate') }}
</span>
</div>
<div class="flex items-center space-x-2">
<CornerDownLeft
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
/>
<span>
{{ __('to select') }}
</span>
</div>
<div class="flex items-center space-x-2">
<span class="bg-surface-gray-2 p-1 rounded-sm"> esc </span>
<span>
{{ __('to close') }}
</span>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { createResource, debounce, Dialog } from 'frappe-ui'
import { nextTick, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
BookOpen,
Briefcase,
CornerDownLeft,
FileSearch,
MoveUp,
MoveDown,
Search,
Users,
} from 'lucide-vue-next'
import CommandPaletteGroup from './CommandPaletteGroup.vue'
const show = defineModel<boolean>({ required: true, default: false })
const router = useRouter()
const query = ref<string>('')
const searchResults = ref<Array<any>>([])
const search = createResource({
url: 'lms.command_palette.search_sqlite',
makeParams: () => ({
query: query.value,
}),
onSuccess() {
generateSearchResults()
},
})
const debouncedSearch = debounce(() => {
if (query.value.length > 2) {
search.reload()
}
}, 500)
const onInput = () => {
debouncedSearch()
}
const generateSearchResults = () => {
search.data?.forEach((type: any) => {
let result: { title: string; items: any[] } = { title: '', items: [] }
result.title = type.title
type.items.forEach((item: any) => {
let paramName = item.doctype === 'LMS Course' ? 'courseName' : 'batchName'
item.route = {
name: item.doctype === 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
params: {
[paramName]: item.name,
},
}
item.isActive = false
})
result.items = type.items
searchResults.value.push(result)
})
}
const appendSearchPage = () => {
let searchPage: { title: string; items: Array<any> } = {
title: '',
items: [],
}
searchPage.title = __('Jump to')
searchPage.items = [
{
title: __('Search for ') + `"${query.value}"`,
route: {
name: 'Search',
query: {
q: query.value,
},
},
icon: FileSearch,
isActive: true,
},
]
searchResults.value = [searchPage]
}
watch(
query,
() => {
appendSearchPage()
},
{ immediate: true }
)
watch(show, () => {
if (!show.value) {
query.value = ''
searchResults.value = []
}
})
onMounted(() => {
addKeyboardShortcuts()
})
const addKeyboardShortcuts = () => {
window.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'ArrowUp' && show.value) {
e.preventDefault()
shortcutForArrowKey(-1)
} else if (e.key === 'ArrowDown' && show.value) {
shortcutForArrowKey(1)
} else if (e.key === 'Enter' && show.value) {
shortcutForEnter()
} else if (e.key === 'Escape' && show.value) {
show.value = false
}
})
}
const shortcutForArrowKey = (direction: number) => {
let currentList = query.value.length
? searchResults.value
: jumpToOptions.value
let allItems = currentList.flatMap((result: any) => result.items)
let indexOfActive = allItems.findIndex((option: any) => option.isActive)
let newIndex = indexOfActive + direction
if (newIndex < 0) newIndex = allItems.length - 1
if (newIndex >= allItems.length) newIndex = 0
allItems[indexOfActive].isActive = false
allItems[newIndex].isActive = true
nextTick(scrollActiveItemIntoView)
}
const scrollActiveItemIntoView = () => {
const activeItem = document.querySelector(
'.hover\\:bg-surface-gray-2.bg-surface-gray-2'
) as HTMLElement
if (activeItem) {
activeItem.scrollIntoView({ block: 'nearest' })
}
}
const shortcutForEnter = () => {
let currentList = query.value.length
? searchResults.value
: jumpToOptions.value
let allItems = currentList.flatMap((result: any) => result.items)
let activeOption = allItems.find((option) => option.isActive)
if (activeOption) {
navigateTo(activeOption.route)
}
}
const navigateTo = (route: {
name: string
params?: Record<string, any>
query?: Record<string, any>
}) => {
show.value = false
query.value = ''
router.replace({ name: route.name, params: route.params, query: route.query })
}
const jumpToOptions = ref([
{
title: __('Jump to'),
items: [
{
title: 'Advanced Search',
icon: Search,
route: {
name: 'Search',
},
isActive: true,
},
{
title: 'Courses',
icon: BookOpen,
route: {
name: 'Courses',
},
isActive: false,
},
{
title: 'Batches',
icon: Users,
route: {
name: 'Batches',
},
isActive: false,
},
{
title: 'Jobs',
icon: Briefcase,
route: {
name: 'Jobs',
},
isActive: false,
},
],
},
])
</script>
<style>
mark {
background-color: theme('colors.amber.100');
font-weight: 500;
}
</style>
@@ -0,0 +1,45 @@
<template>
<div v-for="result in list" class="px-2.5 space-y-2">
<div class="text-ink-gray-5 px-2">
{{ result.title }}
</div>
<div class="">
<div
v-for="item in result.items"
class="flex items-center justify-between p-2 rounded hover:bg-surface-gray-2 cursor-pointer"
:class="{ 'bg-surface-gray-2': item.isActive }"
@click="emit('navigateTo', item.route)"
>
<div class="flex items-center space-x-3">
<component
v-if="item.icon"
:is="item.icon"
class="size-4 stroke-1.5 text-ink-gray-6"
/>
<div v-html="item.title"></div>
</div>
<div v-if="item.modified" class="text-ink-gray-5">
{{ dayjs.unix(item.modified).fromNow(true) }}
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { inject } from 'vue'
const dayjs = inject<any>('$dayjs')
const emit = defineEmits(['navigateTo'])
const props = defineProps<{
list: Array<{
title: string
items: Array<{
title: string
icon?: any
isActive?: boolean
modified?: string
}>
}>
}>()
</script>
+1 -1
View File
@@ -48,7 +48,7 @@ const settingsStore = useSettings()
const sendMail = (close: Function) => { const sendMail = (close: Function) => {
call('frappe.core.doctype.communication.email.make', { call('frappe.core.doctype.communication.email.make', {
recipients: settingsStore.contactUsEmail?.data, recipients: settingsStore.settings?.data?.contact_us_email,
subject: subject.value, subject: subject.value,
content: message.value, content: message.value,
send_email: true, send_email: true,
@@ -16,13 +16,18 @@
<button <button
class="flex w-full items-center justify-between focus:outline-none" class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses" :class="inputClasses"
@click="() => togglePopover()" @click="
() => {
showOptions = !showOptions
togglePopover()
}
"
:disabled="attrs.readonly" :disabled="attrs.readonly"
> >
<div class="flex items-center"> <div class="flex items-center w-[90%]">
<slot name="prefix" /> <slot name="prefix" />
<span <span
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5" class="block truncate text-base leading-5"
v-if="selectedValue" v-if="selectedValue"
> >
{{ displayValue(selectedValue) }} {{ displayValue(selectedValue) }}
@@ -99,18 +104,17 @@
name="item-label" name="item-label"
v-bind="{ active, selected, option }" v-bind="{ active, selected, option }"
> >
<div class="flex flex-col space-y-1 text-ink-gray-8"> <div class="flex flex-col gap-1 p-1">
<div> <div class="text-base font-medium text-ink-gray-8">
{{ option.label }} {{
option.value == option.label && option.description
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div> </div>
<div
v-if="
option.description &&
option.description != option.label
"
class="text-xs text-ink-gray-7"
v-html="option.description"
></div>
</div> </div>
</slot> </slot>
</li> </li>
@@ -120,7 +124,7 @@
v-if="groups.length == 0" v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5" class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
> >
No results found {{ __('No results found') }}
</li> </li>
</ComboboxOptions> </ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5"> <div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
@@ -284,7 +288,7 @@ const inputClasses = computed(() => {
let variant = props.disabled ? 'disabled' : props.variant let variant = props.disabled ? 'disabled' : props.variant
let variantClasses = { let variantClasses = {
subtle: subtle:
'border border-gray-100 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-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',
outline: 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:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
disabled: [ disabled: [
+36 -14
View File
@@ -3,9 +3,10 @@
<div class="text-xs text-ink-gray-5 mb-2"> <div class="text-xs text-ink-gray-5 mb-2">
{{ label }} {{ label }}
</div> </div>
<div class="overflow-x-auto border rounded-md"> <div class="overflow-visible border border-outline-gray-modals rounded-md">
<div class="overflow-x-auto">
<div <div
class="grid items-center space-x-4 p-2 border-b" class="grid items-center space-x-4 p-2 border-b border-outline-gray-modals"
:style="{ gridTemplateColumns: getGridTemplateColumns() }" :style="{ gridTemplateColumns: getGridTemplateColumns() }"
> >
<div <div
@@ -27,11 +28,11 @@
<input <input
v-if="showKey(key)" v-if="showKey(key)"
v-model="row[key]" v-model="row[key]"
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-sm text-sm focus:outline-none" class="py-1.5 px-2 w-full border-none bg-transparent text-ink-gray-8 focus:ring-0 focus:border focus:border-outline-gray-3 focus:bg-surface-gray-2 rounded-md text-sm focus:outline-none"
/> />
</template> </template>
<div class="relative" ref="menuRef"> <div class="relative">
<Button <Button
variant="ghost" variant="ghost"
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)" @click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
@@ -45,7 +46,13 @@
<div <div
v-if="menuOpenIndex === rowIndex" v-if="menuOpenIndex === rowIndex"
class="absolute right-[30px] top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm" ref="menuRef"
class="absolute right-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'
: 'top-full mt-1'
"
> >
<button <button
@click="deleteRow(rowIndex)" @click="deleteRow(rowIndex)"
@@ -60,6 +67,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="mt-2"> <div class="mt-2">
<Button @click="addRow"> <Button @click="addRow">
@@ -73,17 +81,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { nextTick, ref, watch } from 'vue'
import { Button } from 'frappe-ui' import { Button } from 'frappe-ui'
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next' import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core' import { onClickOutside } from '@vueuse/core'
const rows = defineModel<Cell[][]>() const rows = defineModel<Record<string, string>[]>()
const menuRef = ref(null) const menuRef = ref(null)
const menuOpenIndex = ref<number | null>(null) const menuOpenIndex = ref<number | null>(null)
const menuTopPosition = ref<string>('') const menuTopPosition = ref<string>('')
const menuLeftPosition = ref('0px')
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: Cell[][]): void (e: 'update:modelValue', value: Record<string, string>[]): void
}>() }>()
type Cell = { type Cell = {
@@ -93,19 +103,19 @@ type Cell = {
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue?: Cell[][] modelValue?: Record<string, string>[]
columns?: string[] columns?: string[]
label?: string label?: string
}>(), }>(),
{ {
columns: [], columns: () => [] as string[],
} }
) )
const columns = ref(props.columns) const columns = ref(props.columns)
watch(rows, () => { watch(rows, () => {
if (rows.value?.length < 1) { if (rows.value && rows.value.length < 1) {
addRow() addRow()
} }
}) })
@@ -119,12 +129,25 @@ const addRow = () => {
newRow[column.toLowerCase().split(' ').join('_')] = '' newRow[column.toLowerCase().split(' ').join('_')] = ''
}) })
rows.value.push(newRow) rows.value.push(newRow)
focusNewRowInput()
emit('update:modelValue', rows.value) emit('update:modelValue', rows.value)
} }
const focusNewRowInput = () => {
nextTick(() => {
const rowElements = document.querySelectorAll('.overflow-x-auto .grid')[
rows.value!.length
]
const firstInput = rowElements.querySelector('input')
if (firstInput) {
;(firstInput as HTMLInputElement).focus()
}
})
}
const deleteRow = (index: number) => { const deleteRow = (index: number) => {
rows.value.splice(index, 1) rows.value?.splice(index, 1)
emit('update:modelValue', rows.value) emit('update:modelValue', rows.value ?? [])
} }
const getGridTemplateColumns = () => { const getGridTemplateColumns = () => {
@@ -133,7 +156,6 @@ const getGridTemplateColumns = () => {
const toggleMenu = (index: number, event: MouseEvent) => { const toggleMenu = (index: number, event: MouseEvent) => {
menuOpenIndex.value = menuOpenIndex.value === index ? null : index menuOpenIndex.value = menuOpenIndex.value === index ? null : index
menuTopPosition.value = `${event.clientY + 10}px`
} }
onClickOutside(menuRef, () => { onClickOutside(menuRef, () => {
+1 -1
View File
@@ -107,7 +107,7 @@ async function setLanguageExtension() {
if (!languageImport) return if (!languageImport) return
const module = await languageImport() const module = await languageImport()
languageExtension.value = (module as any)[props.language]() languageExtension.value = (module as any)[props.language]?.()
if (props.completions) { if (props.completions) {
const languageData = (module as any)[`${props.language}Language`] const languageData = (module as any)[`${props.language}Language`]
@@ -21,8 +21,10 @@
:style=" :style="
modelValue modelValue
? { ? {
backgroundColor: backgroundColor: getColor(
theme.backgroundColor[modelValue.toLowerCase()][400], modelValue.toLowerCase(),
400
),
} }
: {} : {}
" "
@@ -55,8 +57,7 @@
:key="color" :key="color"
class="size-5 rounded-full cursor-pointer" class="size-5 rounded-full cursor-pointer"
:style="{ :style="{
backgroundColor: backgroundColor: getColor(color.toLowerCase(), 400),
theme.backgroundColor[color.toLowerCase()][400],
}" }"
@click=" @click="
(e) => { (e) => {
@@ -79,7 +80,7 @@
import { Button, FormControl, Popover } from 'frappe-ui' import { Button, FormControl, Popover } from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { Palette, X } from 'lucide-vue-next' import { Palette, X } from 'lucide-vue-next'
import { theme } from '@/utils/theme' import { getColor } from '@/utils'
const emit = defineEmits(['update:modelValue', 'change']) const emit = defineEmits(['update:modelValue', 'change'])
@@ -20,7 +20,7 @@
class="w-4 h-4 text-ink-gray-7 stroke-1.5" class="w-4 h-4 text-ink-gray-7 stroke-1.5"
:is="icons.Folder" :is="icons.Folder"
/> />
<span v-if="selectedIcon"> <span v-if="selectedIcon" class="text-ink-gray-7">
{{ selectedIcon }} {{ selectedIcon }}
</span> </span>
<span v-else class="text-ink-gray-5"> <span v-else class="text-ink-gray-5">
+13 -2
View File
@@ -11,7 +11,6 @@
:size="attrs.size || 'sm'" :size="attrs.size || 'sm'"
:variant="attrs.variant" :variant="attrs.variant"
:placeholder="attrs.placeholder" :placeholder="attrs.placeholder"
:filterable="false"
:readonly="attrs.readonly" :readonly="attrs.readonly"
> >
<template #target="{ open, togglePopover }"> <template #target="{ open, togglePopover }">
@@ -67,6 +66,7 @@ import { watchDebounced } from '@vueuse/core'
import { createResource, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next' import { Plus, X } from 'lucide-vue-next'
import { useAttrs, computed, ref } from 'vue' import { useAttrs, computed, ref } from 'vue'
import { useSettings } from '@/stores/settings'
const props = defineProps({ const props = defineProps({
doctype: { doctype: {
@@ -96,13 +96,14 @@ const value = computed({
set: (val) => { set: (val) => {
return ( return (
val?.value && val?.value &&
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value) emit(valuePropPassed.value ? 'change' : 'update:modelValue', val.value)
) )
}, },
}) })
const autocomplete = ref(null) const autocomplete = ref(null)
const text = ref('') const text = ref('')
const settingsStore = useSettings()
watchDebounced( watchDebounced(
() => autocomplete.value?.query, () => autocomplete.value?.query,
@@ -121,6 +122,16 @@ watchDebounced(
{ debounce: 300, immediate: true } { debounce: 300, immediate: true }
) )
watchDebounced(
() => settingsStore.isSettingsOpen,
(isOpen, wasOpen) => {
if (wasOpen && !isOpen) {
reload('')
}
},
{ debounce: 200 }
)
const options = createResource({ const options = createResource({
url: 'frappe.desk.search.search_link', url: 'frappe.desk.search.search_link',
cache: [props.doctype, text.value], cache: [props.doctype, text.value],
+87 -126
View File
@@ -1,38 +1,34 @@
<template> <template>
<div> <div>
<label class="block mb-1" :class="labelClasses" v-if="label"> <label v-if="label" class="block mb-1" :class="labelClasses">
{{ label }} {{ label }}
<span class="text-ink-red-3" v-if="required">*</span> <span v-if="required" class="text-ink-red-3">*</span>
</label> </label>
<div class="w-full"> <Combobox v-model="selectedValue" nullable v-slot="{ open }">
<Combobox v-model="selectedValue" nullable> <div class="relative w-full">
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput <ComboboxInput
ref="search" ref="search"
class="search-input form-input w-full focus-visible:!ring-0" class="form-input w-full focus-visible:!ring-0"
type="text" type="text"
:value="query"
@change=" @change="
(e) => { (e) => {
query = e.target.value query = e.target.value
showOptions = true
} }
" "
autocomplete="off" autocomplete="off"
@focus="() => togglePopover()" @focus="onFocus"
@keydown.delete.capture.stop="removeLastValue"
/> />
</template> <ComboboxButton ref="trigger" class="hidden" />
<template #body="{ isOpen, close }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
>
<ComboboxOptions <ComboboxOptions
class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5" v-show="open"
static static
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal border-2 border-outline-gray-modals max-h-[13rem] flex flex-col"
> >
<div
class="flex-1 my-1 overflow-y-auto px-1.5"
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
>
<template v-if="options.length">
<ComboboxOption <ComboboxOption
v-for="option in options" v-for="option in options"
:key="option.value" :key="option.value"
@@ -47,7 +43,11 @@
> >
<div class="flex flex-col gap-1 p-1"> <div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8"> <div class="text-base font-medium text-ink-gray-8">
{{ option.description }} {{
option.value === option.label
? option.description
: option.label
}}
</div> </div>
<div class="text-sm text-ink-gray-5"> <div class="text-sm text-ink-gray-5">
{{ option.value }} {{ option.value }}
@@ -55,16 +55,22 @@
</div> </div>
</li> </li>
</ComboboxOption> </ComboboxOption>
<div class="h-10"></div> </template>
<div v-else class="text-ink-gray-7 px-4 py-2">
{{ __('No results found') }}
</div>
</div>
<div <div
v-if="attrs.onCreate" v-if="attrs.onCreate"
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t" class="p-1 bg-surface-white border-t rounded-b-lg"
> >
<Button <Button
variant="ghost" variant="ghost"
class="w-full !justify-start" class="w-full !justify-start"
:label="__('Create New')" :label="__('Create New')"
@click="attrs.onCreate(close)" @click="attrs.onCreate()"
> >
<template #prefix> <template #prefix>
<Plus class="h-4 w-4 stroke-1.5" /> <Plus class="h-4 w-4 stroke-1.5" />
@@ -73,88 +79,67 @@
</div> </div>
</ComboboxOptions> </ComboboxOptions>
</div> </div>
</div>
</template>
</Popover>
</Combobox> </Combobox>
</div>
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1"> <!-- Selected values -->
<div v-if="values?.length" class="grid grid-cols-2 gap-2 mt-1">
<div <div
v-for="value in values" v-for="value in values"
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 word-wrap p-2 rounded-md mr-2" :key="value"
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
> >
<span class="break-all"> <span>{{ value }}</span>
{{ value }}
</span>
<X <X
class="size-4 stroke-1.5 cursor-pointer" class="size-4 stroke-1.5 cursor-pointer"
@click="removeValue(value)" @click="removeValue(value)"
/> />
</div> </div>
</div> </div>
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { import {
Combobox, Combobox,
ComboboxButton,
ComboboxInput, ComboboxInput,
ComboboxOptions, ComboboxOptions,
ComboboxOption, ComboboxOption,
} from '@headlessui/vue' } from '@headlessui/vue'
import { createResource, Popover, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import { ref, computed, nextTick, useAttrs } from 'vue' import { ref, computed, useAttrs, watch } from 'vue'
import { watchDebounced } from '@vueuse/core' import { watchDebounced } from '@vueuse/core'
import { X, Plus } from 'lucide-vue-next' import { X, Plus } from 'lucide-vue-next'
const props = defineProps({ const props = defineProps({
label: { label: String,
type: String, size: { type: String, default: 'sm' },
}, doctype: { type: String, required: true },
size: { filters: { type: Object, default: () => ({}) },
type: String, validate: Function,
default: 'sm',
},
doctype: {
type: String,
required: true,
},
filters: {
type: Object,
default: () => ({}),
},
validate: {
type: Function,
default: null,
},
errorMessage: { errorMessage: {
type: Function, type: Function,
default: (value) => `${value} is an Invalid value`, default: (value) => `${value} is an Invalid value`,
}, },
required: { required: Boolean,
type: Boolean,
},
}) })
const values = defineModel() const values = defineModel()
const attrs = useAttrs() const attrs = useAttrs()
const emails = ref([]) const trigger = ref(null)
const search = ref(null)
const error = ref(null)
const query = ref('') const query = ref('')
const text = ref('') const text = ref('')
const showOptions = ref(false) const selectedValue = ref(null)
const error = ref(null)
const selectedValue = computed({ const emit = defineEmits(['update:modelValue'])
get: () => query.value || '',
set: (val) => { watch(selectedValue, (val) => {
if (!val?.value) return
query.value = '' query.value = ''
if (val) { addValue(val.value)
showOptions.value = false selectedValue.value = null
} emit('update:modelValue', values.value)
val?.value && addValue(val.value)
},
}) })
watchDebounced( watchDebounced(
@@ -171,7 +156,6 @@ watchDebounced(
const filterOptions = createResource({ const filterOptions = createResource({
url: 'frappe.desk.search.search_link', url: 'frappe.desk.search.search_link',
method: 'POST', method: 'POST',
cache: [text.value, props.doctype],
auto: true, auto: true,
params: { params: {
txt: text.value, txt: text.value,
@@ -180,7 +164,8 @@ const filterOptions = createResource({
}) })
const options = computed(() => { const options = computed(() => {
return filterOptions.data || [] const allOptions = filterOptions.data || []
return allOptions.filter((option) => !values.value?.includes(option.value))
}) })
function reload(val) { function reload(val) {
@@ -193,70 +178,46 @@ function reload(val) {
filterOptions.reload() filterOptions.reload()
} }
const addValue = (value) => { function onFocus() {
if (!filterOptions.data?.length) {
reload('')
}
trigger.value?.$el.click()
}
function addValue(value) {
error.value = null error.value = null
if (value) {
if (!value) return
const splitValues = value.split(',') const splitValues = value.split(',')
splitValues.forEach((value) => {
value = value.trim() splitValues.forEach((val) => {
if (value) { val = val.trim()
// check if value is not already in the values array
if (!values.value?.includes(value)) { if (!val) return
// check if value is valid if (values.value?.includes(val)) return
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value) if (props.validate && !props.validate(val)) {
error.value = props.errorMessage(val)
return return
} }
// add value to values array
if (!values.value) { if (!values.value) values.value = [val]
values.value = [value] else values.value.push(val)
} else {
values.value.push(value)
}
value = value.replace(value, '')
}
}
}) })
!error.value && (value = '')
}
} }
const removeValue = (value) => { function removeValue(value) {
values.value = values.value.filter((v) => v !== value) let indexToRemove = values.value.indexOf(value)
if (indexToRemove > -1) {
values.value.splice(indexToRemove, 1)
}
emit('update:modelValue', values.value)
} }
const removeLastValue = () => { const labelClasses = computed(() => [
if (query.value) return { sm: 'text-xs', md: 'text-base' }[props.size || 'sm'],
let emailRef = emails.value[emails.value.length - 1]?.$el
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value.$el.focus()
}
defineExpose({ setFocus })
const labelClasses = computed(() => {
return [
{
sm: 'text-xs',
md: 'text-base',
}[props.size || 'sm'],
'text-ink-gray-5', 'text-ink-gray-5',
] ])
})
</script> </script>
+36 -9
View File
@@ -2,18 +2,21 @@
<div class="mb-4"> <div class="mb-4">
<div v-if="label" class="text-xs text-ink-gray-5 mb-2"> <div v-if="label" class="text-xs text-ink-gray-5 mb-2">
{{ __(label) }} {{ __(label) }}
<span class="text-ink-red-3">*</span> <span v-if="required" class="text-ink-red-3">*</span>
</div> </div>
<FileUploader <FileUploader
v-if="!modelValue" v-if="!modelValue"
:fileTypes="['image/*']" :fileTypes="[fileType]"
:validateFile="validateFile" :validateFile="(file: File) => validateFile(file, true, type)"
@success="(file: File) => saveImage(file)" @success="(file: File) => saveFile(file)"
> >
<template v-slot="{ file, progress, uploading, openFileSelector }"> <template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center"> <div class="flex items-center">
<div class="border rounded-md w-fit py-7 px-20"> <div class="border rounded-md w-fit py-7 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" /> <component
:is="props.type === 'image' ? Image : Video"
class="size-5 stroke-1 text-ink-gray-7"
/>
</div> </div>
<div class="ml-4"> <div class="ml-4">
<Button @click="openFileSelector"> <Button @click="openFileSelector">
@@ -28,7 +31,20 @@
</FileUploader> </FileUploader>
<div v-else class="mb-4"> <div v-else class="mb-4">
<div class="flex items-center"> <div class="flex items-center">
<img :src="modelValue" class="border rounded-md w-44 h-auto" /> <img
v-if="type == 'image'"
:src="modelValue"
:class="[
'border object-cover',
shape === 'circle'
? 'w-20 h-20 rounded-full'
: 'w-44 h-auto min-h-20 max-h-32 rounded-md',
]"
/>
<video v-else controls class="border rounded-md w-44 h-auto">
<source :src="modelValue" />
{{ __('Your browser does not support the video tag.') }}
</video>
<div class="ml-4"> <div class="ml-4">
<Button @click="removeImage()"> <Button @click="removeImage()">
{{ __('Remove') }} {{ __('Remove') }}
@@ -47,7 +63,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { validateFile } from '@/utils' import { validateFile } from '@/utils'
import { Button, FileUploader } from 'frappe-ui' import { Button, FileUploader } from 'frappe-ui'
import { Image } from 'lucide-vue-next' import { Image, Video } from 'lucide-vue-next'
import { computed } from 'vue'
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: string): void (e: 'update:modelValue', value: string): void
@@ -55,18 +72,28 @@ const emit = defineEmits<{
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue: string modelValue: string | null
label?: string label?: string
description?: string description?: string
type?: 'image' | 'video'
required?: boolean
shape?: 'square' | 'circle'
}>(), }>(),
{ {
modelValue: '', modelValue: '',
label: '', label: '',
description: '', description: '',
type: 'image',
required: true,
shape: 'square',
} }
) )
const saveImage = (file: any) => { const fileType = computed(() => {
return props.type === 'image' ? 'image/*' : 'video/*'
})
const saveFile = (file: any) => {
emit('update:modelValue', file.file_url) emit('update:modelValue', file.file_url)
} }
+3 -12
View File
@@ -136,11 +136,11 @@
import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next' import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Tooltip } from 'frappe-ui' import { Tooltip } from 'frappe-ui'
import { theme } from '@/utils/theme'
import { formatAmount } from '@/utils' import { formatAmount } from '@/utils'
import CourseInstructors from '@/components/CourseInstructors.vue' import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import ProgressBar from '@/components/ProgressBar.vue' import ProgressBar from '@/components/ProgressBar.vue'
import colors from '@/utils/frappe-ui-colors.json'
const { user } = sessionStore() const { user } = sessionStore()
@@ -152,19 +152,10 @@ const props = defineProps({
}) })
const getGradientColor = () => { const getGradientColor = () => {
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
let color = props.course.card_gradient?.toLowerCase() || 'blue' let color = props.course.card_gradient?.toLowerCase() || 'blue'
let colorMap = theme.backgroundColor[color] let colorMap = colors[theme][color]
return `linear-gradient(to top right, black, ${colorMap[400]})` return `linear-gradient(to top right, black, ${colorMap[400]})`
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */
/* return `radial-gradient(ellipse at 30% 70%, black 50%, ${colorMap[500]} 100%)` */
/* return `radial-gradient(ellipse at 80% 20%, ${colorMap[100]} 0%, ${colorMap[300]} 50%, ${colorMap[500]} 100%)` */
/* return `conic-gradient(from 180deg at 50% 50%, ${colorMap[100]} 0%, ${colorMap[200]} 50%, ${colorMap[400]} 100%)` */
/* return `linear-gradient(135deg, ${colorMap[100]}, ${colorMap[300]}), linear-gradient(120deg, rgba(255,255,255,0.4) 0%, transparent 60%) ` */
/* return `radial-gradient(circle at 20% 30%, ${colorMap[100]} 0%, transparent 40%),
radial-gradient(circle at 80% 40%, ${colorMap[200]} 0%, transparent 50%),
linear-gradient(135deg, ${colorMap[300]} 0%, ${colorMap[400]} 100%);` */
} }
</script> </script>
<style> <style>
+16 -47
View File
@@ -37,7 +37,7 @@
<CertificationLinks :courseName="course.data.name" class="w-full" /> <CertificationLinks :courseName="course.data.name" class="w-full" />
</div> </div>
<router-link <router-link
v-else-if="course.data.paid_course" v-else-if="course.data.paid_course && !isAdmin"
:to="{ :to="{
name: 'Billing', name: 'Billing',
params: { params: {
@@ -56,14 +56,15 @@
</Button> </Button>
</router-link> </router-link>
<Badge <Badge
v-else-if="course.data.disable_self_learning" v-else-if="course.data.disable_self_learning && !isAdmin"
theme="blue" theme="blue"
size="lg" size="lg"
class="mb-4"
> >
{{ __('Contact the Administrator to enroll for this course.') }} {{ __('Contact the Administrator to enroll for this course') }}
</Badge> </Badge>
<Button <Button
v-else-if="!user.data?.is_moderator && !is_instructor()" v-else-if="!isAdmin"
@click="enrollStudent()" @click="enrollStudent()"
variant="solid" variant="solid"
class="w-full" class="w-full"
@@ -88,35 +89,6 @@
</template> </template>
{{ __('Get Certificate') }} {{ __('Get Certificate') }}
</Button> </Button>
<Button
v-if="user.data?.is_moderator || is_instructor()"
class="w-full mt-2"
size="md"
@click="showProgressSummary"
>
<template #prefix>
<TrendingUp class="size-4 stroke-1.5" />
{{ __('Progress Summary') }}
</template>
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
name: 'CourseForm',
params: {
courseName: course.data.name,
},
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div <div
@@ -168,12 +140,6 @@
</div> </div>
</div> </div>
</div> </div>
<CourseProgressSummary
v-if="user.data?.is_moderator || is_instructor()"
v-model="showProgressModal"
:courseName="course.data.name"
:enrollments="course.data.enrollments"
/>
</template> </template>
<script setup> <script setup>
import { import {
@@ -189,15 +155,14 @@ import {
import { computed, inject, ref } from 'vue' import { computed, inject, ref } from 'vue'
import { Badge, Button, call, createResource, toast } from 'frappe-ui' import { Badge, Button, call, createResource, toast } from 'frappe-ui'
import { formatAmount } from '@/utils/' import { formatAmount } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue' import CertificationLinks from '@/components/CertificationLinks.vue'
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue' import { useTelemetry } from 'frappe-ui/frappe'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const showProgressModal = ref(false)
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
const { capture } = useTelemetry()
const props = defineProps({ const props = defineProps({
course: { course: {
@@ -215,13 +180,17 @@ const video_link = computed(() => {
function enrollStudent() { function enrollStudent() {
if (!user.data) { if (!user.data) {
toast.success(__('You need to login first to enroll for this course')) toast.warning(__('You need to login first to enroll for this course'))
setTimeout(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 500) }, 500)
} else { } else {
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', { call('frappe.client.insert', {
doc: {
doctype: 'LMS Enrollment',
course: props.course.data.name, course: props.course.data.name,
member: user.data.name,
},
}) })
.then(() => { .then(() => {
capture('enrolled_in_course', { capture('enrolled_in_course', {
@@ -290,7 +259,7 @@ const fetchCertificate = () => {
}) })
} }
const showProgressSummary = () => { const isAdmin = computed(() => {
showProgressModal.value = true return user.data?.is_moderator || is_instructor()
} })
</script> </script>
+20 -5
View File
@@ -15,7 +15,10 @@
{{ __(title) }} {{ __(title) }}
</div> </div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()"> <Button size="sm" v-if="allowEdit" @click="openChapterModal()">
{{ __('Add Chapter') }} <template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('Add') }}
</Button> </Button>
</div> </div>
<div <div
@@ -95,8 +98,8 @@
name: allowEdit ? 'LessonForm' : 'Lesson', name: allowEdit ? 'LessonForm' : 'Lesson',
params: { params: {
courseName: courseName, courseName: courseName,
chapterNumber: lesson.number.split('.')[0], chapterNumber: lesson.number.split('-')[0],
lessonNumber: lesson.number.split('.')[1], lessonNumber: lesson.number.split('-')[1],
}, },
}" }"
> >
@@ -109,6 +112,14 @@
v-else-if="lesson.icon === 'icon-quiz'" v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 stroke-1 mr-2" class="h-4 w-4 stroke-1 mr-2"
/> />
<NotebookPen
v-else-if="lesson.icon === 'icon-assignment'"
class="h-4 w-4 stroke-1 mr-2"
/>
<SquareCode
v-else-if="lesson.icon === 'icon-code'"
class="h-4 w-4 stroke-1 mr-2"
/>
<FileText <FileText
v-else-if="lesson.icon === 'icon-list'" 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 mr-2"
@@ -174,7 +185,11 @@ import {
FilePenLine, FilePenLine,
HelpCircle, HelpCircle,
MonitorPlay, MonitorPlay,
NotebookPen,
Plus,
SquareCode,
Trash2, Trash2,
Notebook,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue' import ChapterModal from '@/components/Modals/ChapterModal.vue'
@@ -389,8 +404,8 @@ const redirectToChapter = (chapter) => {
const isActiveLesson = (lessonNumber) => { const isActiveLesson = (lessonNumber) => {
return ( return (
route.params.chapterNumber == lessonNumber.split('.')[0] && route.params.chapterNumber == lessonNumber.split('-')[0] &&
route.params.lessonNumber == lessonNumber.split('.')[1] route.params.lessonNumber == lessonNumber.split('-')[1]
) )
} }
</script> </script>
+4 -4
View File
@@ -12,7 +12,7 @@
</div> </div>
<div class="grid gap-8 mt-10"> <div class="grid gap-8 mt-10">
<div v-for="(review, index) in reviews.data"> <div v-for="(review, index) in reviews.data">
<div class="flex items-center"> <div class="flex">
<router-link <router-link
:to="{ :to="{
name: 'Profile', name: 'Profile',
@@ -46,14 +46,14 @@
" "
/> />
</div> </div>
</div>
</div>
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7"> <div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
{{ review.review }} {{ review.review }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<ReviewModal <ReviewModal
v-model="showReviewModal" v-model="showReviewModal"
v-model:reloadReviews="reviews" v-model:reloadReviews="reviews"
@@ -80,7 +80,7 @@ const props = defineProps({
required: true, required: true,
}, },
membership: { membership: {
type: Object, type: Object || null,
required: false, required: false,
}, },
}) })
+1 -1
View File
@@ -9,5 +9,5 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import AppSidebar from './AppSidebar.vue' import AppSidebar from '@/components/Sidebar/AppSidebar.vue'
</script> </script>
+48 -69
View File
@@ -93,11 +93,19 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui' import {
call,
createResource,
TextEditor,
Button,
Dropdown,
toast,
} from 'frappe-ui'
import { timeAgo } from '@/utils' import { timeAgo } from '@/utils'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next' import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted, onUnmounted } from 'vue' import { ref, inject, onMounted, onUnmounted } from 'vue'
import { useTelemetry } from 'frappe-ui/frappe'
const showTopics = defineModel('showTopics') const showTopics = defineModel('showTopics')
const newReply = ref('') const newReply = ref('')
@@ -107,6 +115,7 @@ const allUsers = inject('$allUsers')
const mentionUsers = ref([]) const mentionUsers = ref([])
const renderEditor = ref(false) const renderEditor = ref(false)
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
const { capture } = useTelemetry()
const props = defineProps({ const props = defineProps({
topic: { topic: {
@@ -143,19 +152,6 @@ const replies = createResource({
auto: true, auto: true,
}) })
const newReplyResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Reply',
reply: newReply.value,
topic: props.topic.name,
},
}
},
})
const fetchMentionUsers = () => { const fetchMentionUsers = () => {
if (user.data?.is_student) { if (user.data?.is_student) {
renderEditor.value = true renderEditor.value = true
@@ -178,78 +174,61 @@ const fetchMentionUsers = () => {
} }
const postReply = () => { const postReply = () => {
newReplyResource.submit(
{},
{
validate() {
if (!newReply.value) { if (!newReply.value) {
return 'Reply cannot be empty' toast.error(__('Reply cannot be empty.'))
return
} }
call('frappe.client.insert', {
doc: {
doctype: 'Discussion Reply',
reply: newReply.value,
topic: props.topic.name,
}, },
onSuccess() { })
.then((data) => {
newReply.value = '' newReply.value = ''
replies.reload() replies.reload()
}, capture('discussion_reply_created')
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const editReplyResource = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Discussion Reply',
name: values.name,
fieldname: 'reply',
value: values.reply,
}
},
}) })
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const postEdited = (reply) => { const postEdited = (reply) => {
editReplyResource.submit(
{
name: reply.name,
reply: reply.reply,
},
{
validate() {
if (!reply.reply) { if (!reply.reply) {
return 'Reply cannot be empty' toast.error(__('Reply cannot be empty.'))
return
} }
}, call('frappe.client.set_value', {
onSuccess() { doctype: 'Discussion Reply',
name: reply.name,
fieldname: 'reply',
value: reply.reply,
})
.then(() => {
reply.editable = false reply.editable = false
replies.reload() replies.reload()
},
}
)
}
const deleteReplyResource = createResource({
url: 'frappe.client.delete',
makeParams(values) {
return {
doctype: 'Discussion Reply',
name: values.name,
}
},
}) })
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const deleteReply = (reply) => { const deleteReply = (reply) => {
deleteReplyResource.submit( call('frappe.client.delete', {
{ doctype: 'Discussion Reply',
name: reply.name, name: reply.name,
}, })
{ .then(() => {
onSuccess() {
replies.reload() replies.reload()
}, })
} .catch((err) => {
) toast.error(err.messages?.[0] || err)
console.error(err)
})
} }
onUnmounted(() => { onUnmounted(() => {
+8 -1
View File
@@ -76,7 +76,14 @@ const isIos = () => {
const isInStandaloneMode = () => const isInStandaloneMode = () =>
'standalone' in window.navigator && window.navigator.standalone 'standalone' in window.navigator && window.navigator.standalone
if (isIos() && !isInStandaloneMode()) iosInstallMessage.value = true if (
isIos() &&
!isInStandaloneMode() &&
localStorage.getItem('learningIosInstallPromptShown') !== 'true'
) {
iosInstallMessage.value = true
localStorage.setItem('learningIosInstallPromptShown', 'true')
}
window.addEventListener('beforeinstallprompt', (e) => { window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault() e.preventDefault()
-217
View File
@@ -1,217 +0,0 @@
<template>
<div
v-if="hasPermission() && !props.zoomAccount"
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3"
>
<AlertCircle class="size-4 stroke-1.5" />
<span>
{{ __('Please add a zoom account to the batch to create live classes.') }}
</span>
</div>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Live Class') }}
</div>
<Button v-if="canCreateClass()" @click="openLiveClassModal">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
<span>
{{ __('Add') }}
</span>
</Button>
</div>
<div
v-if="liveClasses.data?.length"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
>
<div
v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
:class="{
'cursor-pointer': hasPermission() && cls.attendees > 0,
}"
@click="
() => {
openAttendanceModal(cls)
}
"
>
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
{{ cls.title }}
</div>
<div class="short-introduction">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
<div v-else class="text-sm italic text-ink-gray-5 mt-2">
{{ __('No live classes scheduled') }}
</div>
<LiveClassModal
:batch="props.batch"
:zoomAccount="props.zoomAccount"
v-model="showLiveClassModal"
v-model:reloadLiveClasses="liveClasses"
/>
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" />
</template>
<script setup>
import { createListResource, Button, Tooltip } from 'frappe-ui'
import {
Plus,
Clock,
Calendar,
Video,
Monitor,
Info,
AlertCircle,
} from 'lucide-vue-next'
import { inject, ref } from 'vue'
import { formatTime } from '@/utils/'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
const user = inject('$user')
const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode
const showAttendance = ref(false)
const attendanceFor = ref(null)
const props = defineProps({
batch: {
type: String,
required: true,
},
zoomAccount: String,
})
const liveClasses = createListResource({
doctype: 'LMS Live Class',
filters: {
batch_name: props.batch,
},
fields: [
'title',
'description',
'time',
'date',
'duration',
'attendees',
'start_url',
'join_url',
'owner',
],
orderBy: 'date',
auto: true,
})
const openLiveClassModal = () => {
showLiveClassModal.value = true
}
const canCreateClass = () => {
if (readOnlyMode) return false
if (!props.zoomAccount) return false
return hasPermission()
}
const hasPermission = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
const canAccessClass = (cls) => {
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
if (hasClassEnded(cls)) return false
return true
}
const getClassStart = (cls) => {
return new Date(`${cls.date}T${cls.time}`)
}
const getClassEnd = (cls) => {
const classStart = getClassStart(cls)
return new Date(classStart.getTime() + cls.duration * 60000)
}
const hasClassEnded = (cls) => {
const classEnd = getClassEnd(cls)
const now = new Date()
return now > classEnd
}
const openAttendanceModal = (cls) => {
if (!hasPermission()) return
if (cls.attendees <= 0) return
showAttendance.value = true
attendanceFor.value = cls
}
</script>
<style>
.short-introduction {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin: 0.25rem 0 1.5rem;
line-height: 1.5;
}
</style>
+20 -2
View File
@@ -76,12 +76,20 @@ const isModerator = ref(false)
const isInstructor = ref(false) const isInstructor = ref(false)
onMounted(() => { onMounted(() => {
// Вызываем addSideBar только если userResource уже загружен sidebarSettings.reload(
{},
{
onSuccess(data) {
if (userResource.data) { if (userResource.data) {
addSideBar() addSideBar()
} else {
destructureSidebarLinks()
} }
addOtherLinks()
filterLinksToShow(data) filterLinksToShow(data)
addOtherLinks()
},
}
)
}) })
const handleOutsideClick = (e) => { const handleOutsideClick = (e) => {
@@ -100,6 +108,16 @@ watch(showMenu, (val) => {
} }
}) })
const destructureSidebarLinks = () => {
let links = []
sidebarLinks.value.forEach((link) => {
link.items?.forEach((item) => {
links.push(item)
})
})
sidebarLinks.value = links
}
const filterLinksToShow = (data) => { const filterLinksToShow = (data) => {
Object.keys(data).forEach((key) => { Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) { if (!parseInt(data[key])) {
@@ -20,11 +20,15 @@
:options="assessmentTypes" :options="assessmentTypes"
v-model="assessmentType" v-model="assessmentType"
:label="__('Type')" :label="__('Type')"
placeholder=" "
@update:modelValue="() => (assessment = null)"
/> />
<Link <Link
v-if="assessmentType"
v-model="assessment" v-model="assessment"
:doctype="assessmentType" :doctype="assessmentType"
:label="__('Assessment')" :label="__('Assessment')"
placeholder=" "
:onCreate=" :onCreate="
(value, close) => { (value, close) => {
close() close()
@@ -49,7 +53,7 @@
</template> </template>
<script setup> <script setup>
import { Dialog, FormControl, createResource, toast } from 'frappe-ui' import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue' import { Link } from 'frappe-ui/frappe'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -27,6 +27,12 @@
:label="__('Submission Type')" :label="__('Submission Type')"
:required="true" :required="true"
/> />
<Link
v-model="assignment.course"
:label="__('Course')"
doctype="LMS Course"
placeholder=" "
/>
<div> <div>
<div class="text-xs text-ink-gray-5 mb-2"> <div class="text-xs text-ink-gray-5 mb-2">
{{ __('Question') }} {{ __('Question') }}
@@ -37,7 +43,7 @@
@change="(val) => (assignment.question = val)" @change="(val) => (assignment.question = val)"
:editable="true" :editable="true"
:fixedMenu="true" :fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto" editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
/> />
</div> </div>
</div> </div>
@@ -66,6 +72,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui' import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { computed, reactive, watch } from 'vue' import { computed, reactive, watch } from 'vue'
import { escapeHTML, sanitizeHTML } from '@/utils'
import { Link } from 'frappe-ui/frappe'
const show = defineModel() const show = defineModel()
const assignments = defineModel<Assignments>('assignments') const assignments = defineModel<Assignments>('assignments')
@@ -74,6 +82,7 @@ interface Assignment {
title: string title: string
type: string type: string
question: string question: string
course?: string
} }
interface Assignments { interface Assignments {
@@ -88,6 +97,7 @@ const assignment = reactive({
title: '', title: '',
type: '', type: '',
question: '', question: '',
course: '',
}) })
const props = defineProps({ const props = defineProps({
@@ -106,6 +116,7 @@ watch(
assignment.title = row.title assignment.title = row.title
assignment.type = row.type assignment.type = row.type
assignment.question = row.question assignment.question = row.question
assignment.course = row.course || ''
} }
}) })
} }
@@ -121,8 +132,21 @@ watch(show, (newVal) => {
} }
}) })
const validateFields = () => {
assignment.title = escapeHTML(assignment.title.trim())
assignment.question = sanitizeHTML(assignment.question)
}
const saveAssignment = () => { const saveAssignment = () => {
validateFields()
if (props.assignmentID == 'new') { if (props.assignmentID == 'new') {
createAssignment()
} else {
updateAssignment()
}
}
const createAssignment = () => {
assignments.value.insert.submit( assignments.value.insert.submit(
{ {
...assignment, ...assignment,
@@ -134,7 +158,9 @@ const saveAssignment = () => {
}, },
} }
) )
} else { }
const updateAssignment = () => {
assignments.value.setValue.submit( assignments.value.setValue.submit(
{ {
...assignment, ...assignment,
@@ -148,7 +174,6 @@ const saveAssignment = () => {
} }
) )
} }
}
const assignmentOptions = computed(() => { const assignmentOptions = computed(() => {
return [ return [
@@ -2,8 +2,8 @@
<Dialog <Dialog
v-model="show" v-model="show"
:options="{ :options="{
title: __('Add a course'), title: __('Add a course to the batch'),
size: 'sm', size: 'lg',
actions: [ actions: [
{ {
label: __('Submit'), label: __('Submit'),
@@ -19,14 +19,13 @@
v-model="course" v-model="course"
:label="__('Course')" :label="__('Course')"
:required="true" :required="true"
:filters="{ published: 1 }"
:onCreate=" :onCreate="
(value, close) => { (value, close) => {
close() close()
router.push({ router.push({
name: 'CourseForm', name: 'Courses',
params: { query: { newCourse: '1' },
courseName: 'new',
},
}) })
} }
" "
@@ -42,7 +41,7 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, createResource, toast } from 'frappe-ui' import { Dialog, toast } from 'frappe-ui'
import { ref, inject } from 'vue' import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
@@ -64,37 +63,28 @@ const props = defineProps({
}, },
}) })
const createBatchCourse = createResource({ const addCourse = (close) => {
url: 'frappe.client.insert', courses.value.insert.submit(
makeParams(values) { {
return { course: course.value,
doc: { evaluator: evaluator.value,
doctype: 'Batch Course',
parent: props.batch, parent: props.batch,
parenttype: 'LMS Batch', parenttype: 'LMS Batch',
parentfield: 'courses', parentfield: 'courses',
course: course.value,
evaluator: evaluator.value,
}, },
}
},
})
const addCourse = (close) => {
createBatchCourse.submit(
{},
{ {
onSuccess() { onSuccess() {
if (user.data?.is_system_manager) if (user.data?.is_system_manager)
updateOnboardingStep('add_batch_course') updateOnboardingStep('add_batch_course')
close() close()
courses.value.reload()
course.value = null course.value = null
evaluator.value = null evaluator.value = null
toast.success(__('Course added to batch successfully'))
}, },
onError(err) { onError(err) {
toast.error(err.messages?.[0] || err) toast.error(err.messages?.[0] || err)
console.log(err)
}, },
} }
) )
@@ -1,146 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-10 text-base">
<div class="flex items-center space-x-2">
<Avatar :image="student.user_image" size="3xl" />
<div class="space-y-1">
<div class="flex items-center space-x-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ student.full_name }}
</div>
<Badge
v-if="
Object.keys(student.assessments).length ||
Object.keys(student.courses).length
"
:theme="student.progress === 100 ? 'green' : 'red'"
>
{{ student.progress }}% {{ __('Complete') }}
</Badge>
</div>
<div class="text-sm text-ink-gray-7">
{{ student.email }}
</div>
</div>
</div>
<div class="space-y-8">
<!-- Assessments -->
<div
v-if="Object.keys(student.assessments).length"
class="space-y-2 text-sm"
>
<div
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
>
<span class="flex-1">
{{ __('Assessment') }}
</span>
<span>
{{ __('Percentage/Status') }}
</span>
</div>
<router-link
v-for="assessment in Object.keys(student.assessments)"
class="flex items-center text-ink-gray-7 font-medium"
:to="{
name:
student.assessments[assessment].type == 'LMS Assignment'
? 'AssignmentSubmission'
: '',
params:
student.assessments[assessment].type == 'LMS Assignment'
? {
assignmentID:
student.assessments[assessment].assessment,
submissionName:
student.assessments[assessment].submission,
}
: {},
}"
>
<span class="flex-1">
{{ assessment }}
</span>
<span v-if="isAssignment(student.assessments[assessment].status)">
<Badge
:theme="
getStatusTheme(student.assessments[assessment].status)
"
>
{{ student.assessments[assessment].status }}
</Badge>
</span>
<span v-else>
{{ student.assessments[assessment].status }}
</span>
</router-link>
</div>
<!-- Courses -->
<div
v-if="Object.keys(student.courses).length"
class="space-y-2 text-sm"
>
<div
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
>
<span class="flex-1">
{{ __('Courses') }}
</span>
<span>
{{ __('Progress') }}
</span>
</div>
<div
v-for="course in Object.keys(student.courses)"
class="flex items-center text-ink-gray-7 font-medium"
>
<span class="flex-1">
{{ course }}
</span>
<span>
{{ Math.floor(student.courses[course]) }}
</span>
</div>
</div>
</div>
<!-- Heatmap -->
<StudentHeatmap :member="student.email" :days="120" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { Avatar, Badge, Dialog } from 'frappe-ui'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const show = defineModel()
const props = defineProps({
student: {
type: Object,
default: null,
},
})
const isAssignment = (value) => {
return isNaN(value)
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
return 'green'
} else if (status == 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script>
@@ -80,13 +80,13 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import { reactive, watch, inject } from 'vue' import { reactive, watch, inject } from 'vue'
import { getFileSize } from '@/utils/' import { getFileSize } from '@/utils/'
import { capture } from '@/telemetry'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
const show = defineModel() const show = defineModel()
const outline = defineModel('outline') const outline = defineModel('outline')
const user = inject('$user') const user = inject('$user')
const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning') const { updateOnboardingStep } = useOnboarding('learning')
const props = defineProps({ const props = defineProps({
@@ -1,231 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Course Progress Summary'),
size: '5xl',
}"
>
<template #body-content>
<div
class="flex flex-col-reverse md:flex-row justify-between md:space-x-10 text-base mt-10"
>
<div class="w-full">
<div class="flex items-center justify-between space-x-5 mb-4">
<FormControl
v-model="searchFilter"
:placeholder="__('Search by Member')"
type="text"
class="w-full"
/>
</div>
<div class="max-h-[70vh] overflow-y-auto">
<ListView
v-if="progressList.loading || progressList.data?.length"
:columns="progressColumns"
:rows="progressList.data"
rowKey="name"
:options="{
selectable: false,
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem
:item="item"
v-for="item in progressColumns"
:key="item.key"
>
<template #prefix="{ item }">
<FeatherIcon
:name="item.icon?.toString()"
class="h-4 w-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in progressList.data">
<router-link
:to="{
name: 'Profile',
params: { username: row.member_username },
}"
>
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
</ListRows>
</ListView>
<div
v-if="progressList.data && progressList.hasNextPage"
class="flex justify-center my-5"
>
<Button @click="progressList.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
<div class="mb-4 self-start w-full space-y-5">
<div
class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-4"
>
<NumberChart
class="border rounded-md w-full"
:config="{
title: __('Enrollments'),
value: memberCount || 0,
}"
/>
<NumberChart
class="border rounded-md w-full"
:config="{
title: __('Average Progress %'),
value: chartDetails.data?.average_progress || 0,
}"
/>
</div>
<DonutChart
:config="{
data: chartDetails.data?.progress_distribution || [],
title: __('Progress Distribution'),
categoryColumn: 'category',
valueColumn: 'count',
colors: [
theme.colors.red['400'],
theme.colors.amber['400'],
theme.colors.pink['400'],
theme.colors.blue['400'],
theme.colors.green['400'],
],
}"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
createListResource,
createResource,
Dialog,
DonutChart,
FeatherIcon,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
NumberChart,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { theme } from '@/utils/theme'
const show = defineModel<boolean>({ default: false })
const searchFilter = ref<string | null>(null)
type Filters = {
course: string | undefined
member_name?: string[]
}
const props = defineProps<{
courseName?: string
enrollments?: number
}>()
const memberCount = ref<number>(props.enrollments || 0)
const chartDetails = createResource({
url: 'lms.lms.api.get_course_progress_distribution',
params: {
course: props.courseName,
},
auto: true,
})
const progressList = createListResource({
doctype: 'LMS Enrollment',
filters: {
course: props.courseName,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'progress',
],
pageLength: 50,
auto: true,
})
watch([searchFilter], () => {
let filterApplied = false
let filters: Filters = {
course: props.courseName,
}
if (searchFilter.value) {
filters.member_name = ['like', `%${searchFilter.value}%`]
filterApplied = true
}
progressList.update({
filters: filters,
})
progressList.reload(
{},
{
onSuccess(data: any[]) {
memberCount.value = filterApplied ? data.length : props.enrollments || 0
},
}
)
})
const progressColumns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
width: '60%',
icon: 'user',
},
{
label: __('Progress'),
key: 'progress',
align: 'right',
icon: 'trending-up',
},
]
})
</script>
@@ -26,7 +26,7 @@
@change="(val) => (topic.reply = val)" @change="(val) => (topic.reply = val)"
:editable="true" :editable="true"
:fixedMenu="true" :fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]" editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/> />
</div> </div>
</div> </div>
@@ -34,17 +34,13 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
Dialog,
FormControl,
TextEditor,
createResource,
toast,
} from 'frappe-ui'
import { reactive } from 'vue' import { reactive } from 'vue'
import { singularize } from '@/utils' import { singularize } from '@/utils'
import { useTelemetry } from 'frappe-ui/frappe'
const topics = defineModel('reloadTopics') const topics = defineModel('reloadTopics')
const { capture } = useTelemetry()
const props = defineProps({ const props = defineProps({
title: { title: {
@@ -66,64 +62,50 @@ const topic = reactive({
reply: '', reply: '',
}) })
const topicResource = createResource({ const submitTopic = (close) => {
url: 'frappe.client.insert', if (!topic.title) {
makeParams(values) { toast.error(__('Title cannot be empty.'))
return { return
}
if (!topic.reply) {
toast.error(__('Details cannot be empty.'))
return
}
call('frappe.client.insert', {
doc: { doc: {
doctype: 'Discussion Topic', doctype: 'Discussion Topic',
reference_doctype: props.doctype, reference_doctype: props.doctype,
reference_docname: props.docname, reference_docname: props.docname,
title: topic.title, title: topic.title,
}, },
}
},
}) })
.then((data) => {
createReply(data.name, close)
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const replyResource = createResource({ const createReply = (topicName, close) => {
url: 'frappe.client.insert', call('frappe.client.insert', {
makeParams(values) {
return {
doc: { doc: {
doctype: 'Discussion Reply', doctype: 'Discussion Reply',
topic: values.topic, topic: topicName,
reply: topic.reply, reply: topic.reply,
}, },
}
},
}) })
.then((data) => {
const submitTopic = (close) => {
topicResource.submit(
{},
{
validate() {
if (!topic.title) {
return 'Title cannot be empty.'
}
if (!topic.reply) {
return 'Reply cannot be empty.'
}
},
onSuccess(data) {
replyResource.submit(
{
topic: data.name,
},
{
onSuccess() {
topic.title = '' topic.title = ''
topic.reply = '' topic.reply = ''
topics.value.reload() topics.value.reload()
capture('discussion_topic_created')
close() close()
}, })
} .catch((err) => {
)
},
onError(err) {
toast.error(err.messages?.[0] || err) toast.error(err.messages?.[0] || err)
}, console.error(err)
} })
)
} }
</script> </script>
+92 -111
View File
@@ -1,82 +1,74 @@
<template> <template>
<Dialog <Dialog
v-model="show"
:options="{ :options="{
title: __('Edit your profile'),
size: '3xl', size: '3xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: (close) => saveProfile(close),
},
],
}" }"
> >
<template #body-content> <template #body-header>
<div class="grid grid-cols-2 gap-5"> <div class="flex items-center justify-between mb-5">
<div class="space-y-4"> <div class="text-2xl font-semibold leading-6 text-ink-gray-9">
<!-- <Uploader {{ __('Edit Profile') }}
v-model="profile.image.file_url"
label="Profile Image"
description="Your profile image to help others recognize you."
/> -->
<div>
<div class="text-xs text-ink-gray-5 mb-1">
{{ __('Profile Image') }}
</div> </div>
<FileUploader <div class="space-x-2">
v-if="!profile.image" <Badge v-if="isDirty" theme="orange">
:fileTypes="['image/*']" {{ __('Not Saved') }}
:validateFile="validateFile" </Badge>
@success="(file) => saveImage(file)" <div class="pb-5 float-right">
> <Button variant="solid" @click="saveProfile()">
<template {{ __('Save') }}
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? `Uploading ${progress}%`
: 'Upload a profile image'
}}
</Button> </Button>
</div> </div>
</div>
</div>
</template> </template>
</FileUploader> <template #body-content>
<div v-else class="mb-4"> <div class="text-base">
<div class="flex items-center"> <div class="grid grid-cols-2 gap-10">
<img <div class="space-y-4">
:src="profile.image.file_url" <div class="space-y-4">
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover" <Uploader
v-model="profile.image"
:label="__('Profile Image')"
:required="true"
shape="circle"
/> />
<div class="text-base flex flex-col ml-2"> <FormControl
<span> v-model="profile.first_name"
{{ profile.image.file_name }} :label="__('First Name')"
</span> />
<span class="text-sm text-ink-gray-4 mt-1"> <FormControl
{{ getFileSize(profile.image.file_size) }} v-model="profile.last_name"
</span> :label="__('Last Name')"
</div> />
<X <FormControl v-model="profile.headline" :label="__('Headline')" />
@click="removeImage()"
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4" <FormControl
v-model="profile.linkedin"
:label="__('LinkedIn ID')"
/>
<FormControl v-model="profile.github" :label="__('GitHub ID')" />
<FormControl
v-model="profile.twitter"
:label="__('Twitter ID')"
/> />
</div> </div>
</div> </div>
</div> <div class="space-y-4">
<FormControl v-model="profile.first_name" :label="__('First Name')" /> <FormControl
<FormControl v-model="profile.last_name" :label="__('Last Name')" /> v-model="profile.open_to"
<FormControl v-model="profile.headline" :label="__('Headline')" /> type="select"
:options="[' ', 'Work', 'Hiring']"
:label="__('Open to')"
:placeholder="__('Looking for new work or hiring talent?')"
/>
<Link <Link
:label="__('Language')" :label="__('Language')"
v-model="profile.language" v-model="profile.language"
doctype="Language" doctype="Language"
/> />
</div>
<div> <div>
<div class="mb-4">
<div class="mb-1.5 text-sm text-ink-gray-5"> <div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Bio') }} {{ __('Bio') }}
</div> </div>
@@ -84,32 +76,34 @@
:fixedMenu="true" :fixedMenu="true"
@change="(val) => (profile.bio = val)" @change="(val) => (profile.bio = val)"
:content="profile.bio" :content="profile.bio"
editorClass="prose-sm py-2 px-2 min-h-[200px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3" :rows="15"
editorClass="prose-sm py-2 px-2 min-h-[280px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
/> />
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { import {
Dialog, Badge,
FormControl,
FileUploader,
Button, Button,
createResource, createResource,
Dialog,
FormControl,
TextEditor, TextEditor,
toast, toast,
} from 'frappe-ui' } from 'frappe-ui'
import { ref, reactive, watch } from 'vue' import { ref, reactive, watch } from 'vue'
import { X } from 'lucide-vue-next' import { sanitizeHTML } from '@/utils'
import { getFileSize, decodeEntities } from '@/utils'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import DOMPurify from 'dompurify'
const show = defineModel()
const reloadProfile = defineModel('reloadProfile') const reloadProfile = defineModel('reloadProfile')
const hasLanguageChanged = ref(false) const hasLanguageChanged = ref(false)
const isDirty = ref(false)
const props = defineProps({ const props = defineProps({
profile: { profile: {
@@ -124,19 +118,10 @@ const profile = reactive({
headline: '', headline: '',
bio: '', bio: '',
image: '', image: '',
}) open_to: '',
linkedin: '',
const imageResource = createResource({ github: '',
url: 'lms.lms.api.get_file_info', twitter: '',
makeParams(values) {
return {
file_url: values.image,
}
},
auto: false,
onSuccess(data) {
profile.image = data
},
}) })
const updateProfile = createResource({ const updateProfile = createResource({
@@ -146,7 +131,7 @@ const updateProfile = createResource({
doctype: 'User', doctype: 'User',
name: props.profile.data.name, name: props.profile.data.name,
fieldname: { fieldname: {
user_image: profile.image.file_url, user_image: profile.image || null,
...profile, ...profile,
}, },
} }
@@ -156,28 +141,13 @@ const updateProfile = createResource({
}, },
}) })
const saveProfile = (close) => { const saveProfile = () => {
profile.bio = DOMPurify.sanitize(decodeEntities(profile.bio), { profile.bio = sanitizeHTML(profile.bio)
ALLOWED_TAGS: [
'b',
'i',
'em',
'strong',
'a',
'p',
'br',
'ul',
'ol',
'li',
'img',
],
ALLOWED_ATTR: ['href', 'target', 'src'],
})
updateProfile.submit( updateProfile.submit(
{}, {},
{ {
onSuccess() { onSuccess() {
close() show.value = false
reloadProfile.value.reload() reloadProfile.value.reload()
if (hasLanguageChanged.value) { if (hasLanguageChanged.value) {
hasLanguageChanged.value = false hasLanguageChanged.value = false
@@ -191,20 +161,26 @@ const saveProfile = (close) => {
) )
} }
const validateFile = (file) => { watch(
let extension = file.name.split('.').pop().toLowerCase() () => profile,
if (!['jpg', 'jpeg', 'png'].includes(extension)) { (newVal) => {
return 'Only image file is allowed.' if (!props.profile.data) return
let keys = Object.keys(newVal)
keys.splice(keys.indexOf('image'), 1)
for (let key of keys) {
if (newVal[key] !== props.profile.data[key]) {
isDirty.value = true
return
} }
} }
if (profile.image !== props.profile.data.user_image) {
const saveImage = (file) => { isDirty.value = true
profile.image = file return
}
const removeImage = () => {
profile.image = null
} }
isDirty.value = false
},
{ deep: true }
)
watch( watch(
() => props.profile.data, () => props.profile.data,
@@ -215,15 +191,20 @@ watch(
profile.headline = newVal.headline profile.headline = newVal.headline
profile.language = newVal.language profile.language = newVal.language
profile.bio = newVal.bio profile.bio = newVal.bio
if (newVal.user_image) imageResource.submit({ image: newVal.user_image }) profile.open_to = newVal.open_to
profile.linkedin = newVal.linkedin
profile.github = newVal.github
profile.twitter = newVal.twitter
profile.image = newVal.user_image
isDirty.value = false
} }
} }
) )
watch( watch(
() => profile.language, () => profile.language,
(newVal, oldVal) => { () => {
if (newVal !== oldVal) { if (profile.language !== props.profile.data.language) {
hasLanguageChanged.value = true hasLanguageChanged.value = true
} }
} }
@@ -67,7 +67,7 @@
'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nFrappe Learning' 'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nFrappe Learning'
) )
" "
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto" editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
/> />
</div> </div>
</div> </div>
@@ -2,7 +2,7 @@
<Dialog <Dialog
v-model="show" v-model="show"
:options="{ :options="{
title: __('Schedule Evaluation'), title: __('Schedule your evaluation'),
size: 'xl', size: 'xl',
actions: [ actions: [
{ {
@@ -14,38 +14,37 @@
}" }"
> >
<template #body-content> <template #body-content>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4 text-base max-h-[60vh]">
<div>
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Course') }}
</div>
<Select v-model="evaluation.course" :options="getCourses()" />
</div>
<div>
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Date') }}
</div>
<FormControl <FormControl
type="date" v-model="evaluation.course"
v-model="evaluation.date" type="select"
:min=" :label="__('Course')"
dayjs() :options="getCourses()"
.add(dayjs.duration({ days: 1 }))
.format('YYYY-MM-DD')
"
/> />
<div v-if="slots.data?.length" class="space-y-4 overflow-y-auto mt-4">
<div class="text-ink-gray-9 font-medium">
{{ __('Available Slots') }}
</div> </div>
<div v-if="slots.data?.length"> <div class="space-y-5">
<div class="mb-1.5 text-sm text-ink-gray-5"> <div v-for="row in slots.data" class="space-y-2">
{{ __('Select a slot') }} <div class="flex items-center text-ink-gray-7 space-x-2">
<Calendar class="size-3" />
<div class="text-ink-gray-9">
{{ dayjs(row.date).format('DD MMMM YYYY') }}
</div> </div>
<div class="grid grid-cols-2 gap-2"> <div>&middot;</div>
<div v-for="slot in slots.data"> <div class="text-ink-gray-5">
{{ row.day }}
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<div <div
class="text-base text-center border rounded-md text-ink-gray-8 bg-surface-gray-3 p-2 cursor-pointer" v-for="slot in row.slots"
@click="saveSlot(slot)" class="text-base text-center border rounded-md text-ink-gray-8 p-2 cursor-pointer text-ink-gray-7 hover:bg-surface-gray-2 hover:border-outline-gray-3"
@click="saveSlot(slot, row)"
:class="{ :class="{
'border-outline-gray-4': 'border-outline-gray-4 text-ink-gray-9':
evaluation.date == row.date &&
evaluation.start_time == slot.start_time, evaluation.start_time == slot.start_time,
}" }"
> >
@@ -55,23 +54,31 @@
</div> </div>
</div> </div>
</div> </div>
<div </div>
v-else-if="evaluation.course && evaluation.date" <div v-else-if="!evaluation.course" class="text-ink-gray-7">
class="text-sm italic text-ink-red-4" {{ __('Please select a course to view available slots.') }}
> </div>
{{ __('No slots available for this date.') }} <div v-else class="text-ink-red-3">
{{ __('No slots available for the selected course.') }}
</div> </div>
</div> </div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, createResource, Select, FormControl, toast } from 'frappe-ui' import {
import { reactive, watch, inject } from 'vue' call,
createResource,
dayjs,
Dialog,
FormControl,
toast,
} from 'frappe-ui'
import { ref, watch, inject } from 'vue'
import { Calendar } from 'lucide-vue-next'
import { formatTime } from '@/utils/' import { formatTime } from '@/utils/'
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs')
const show = defineModel() const show = defineModel()
const evaluations = defineModel('reloadEvals') const evaluations = defineModel('reloadEvals')
@@ -90,7 +97,7 @@ const props = defineProps({
}, },
}) })
const evaluation = reactive({ const evaluation = ref({
course: '', course: '',
date: '', date: '',
start_time: '', start_time: '',
@@ -100,47 +107,27 @@ const evaluation = reactive({
member: user.data.name, member: user.data.name,
}) })
const createEvaluation = createResource({ function submitEvaluation(close) {
url: 'frappe.client.insert', if (!evaluation.value.date || !evaluation.value.start_time) {
makeParams(values) { toast.warning(__('Please select a slot for your evaluation.'), {
return { duration: 10,
})
return
}
call('frappe.client.insert', {
doc: { doc: {
doctype: 'LMS Certificate Request', doctype: 'LMS Certificate Request',
batch_name: values.batch, batch_name: evaluation.value.batch,
...values, ...evaluation.value,
},
}
}, },
}) })
.then(() => {
function submitEvaluation(close) {
createEvaluation.submit(evaluation, {
validate() {
if (!evaluation.course) {
return 'Please select a course.'
}
if (!evaluation.date) {
return 'Please select a date.'
}
if (!evaluation.start_time) {
return 'Please select a slot.'
}
if (dayjs(evaluation.date).isBefore(dayjs(), 'day')) {
return 'Please select a future date.'
}
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
return `Please select a date before the end date ${dayjs(
props.endDate
).format('DD MMMM YYYY')}.`
}
},
onSuccess() {
evaluations.value.reload() evaluations.value.reload()
close() close()
}, })
onError(err) { .catch((err) => {
toast.warning(__(err.messages?.[0] || err)) console.log(err.messages?.[0] || err)
}, toast.warning(__(err.messages?.[0] || err), { duration: 20 })
}) })
} }
@@ -156,7 +143,7 @@ const getCourses = () => {
} }
if (courses.length === 1) { if (courses.length === 1) {
evaluation.course = courses[0].value evaluation.value.course = courses[0].value
} }
return courses return courses
@@ -167,34 +154,22 @@ const slots = createResource({
makeParams(values) { makeParams(values) {
return { return {
course: values.course, course: values.course,
date: values.date,
batch: props.batch, batch: props.batch,
} }
}, },
}) })
watch( watch(
() => evaluation.date, () => evaluation.value.course,
(date) => {
evaluation.start_time = ''
if (date && evaluation.course) {
slots.submit(evaluation)
}
}
)
watch(
() => evaluation.course,
(course) => { (course) => {
evaluation.date = '' slots.reload(evaluation.value)
evaluation.start_time = ''
slots.reset()
} }
) )
const saveSlot = (slot) => { const saveSlot = (slot, row) => {
evaluation.start_time = slot.start_time evaluation.value.start_time = slot.start_time
evaluation.end_time = slot.end_time evaluation.value.end_time = slot.end_time
evaluation.day = slot.day evaluation.value.date = row.date
evaluation.value.day = row.day
} }
</script> </script>
+21 -6
View File
@@ -22,7 +22,10 @@
</div> </div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Course')"> <Tooltip :text="__('Course')">
<div class="flex items-center space-x-2 w-fit"> <div
class="flex space-x-2 w-fit cursor-pointer"
@click="openLink('course', event.course)"
>
<BookOpen class="h-4 w-4 stroke-1.5" /> <BookOpen class="h-4 w-4 stroke-1.5" />
<span> <span>
{{ event.course_title }} {{ event.course_title }}
@@ -30,7 +33,10 @@
</div> </div>
</Tooltip> </Tooltip>
<Tooltip v-if="event.batch_title" :text="__('Batch')"> <Tooltip v-if="event.batch_title" :text="__('Batch')">
<div class="flex items-center space-x-2 w-fit"> <div
class="flex space-x-2 w-fit cursor-pointer"
@click="openLink('batch', event.batch_name)"
>
<Users class="h-4 w-4 stroke-1.5" /> <Users class="h-4 w-4 stroke-1.5" />
<span> <span>
{{ event.batch_title }} {{ event.batch_title }}
@@ -189,6 +195,8 @@ const user = inject('$user')
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const tabIndex = ref(0) const tabIndex = ref(0)
const showCertification = ref(false) const showCertification = ref(false)
const evaluation = reactive({})
const certificate = reactive({})
const props = defineProps({ const props = defineProps({
event: { event: {
@@ -197,9 +205,6 @@ const props = defineProps({
}, },
}) })
const evaluation = reactive({})
const certificate = reactive({})
watch(user, () => { watch(user, () => {
if (userIsEvaluator()) { if (userIsEvaluator()) {
defaultTemplate.reload() defaultTemplate.reload()
@@ -335,7 +340,7 @@ const certificateDetails = createResource({
} }
}, },
onError(err) { onError(err) {
certificate.template = defaultTemplate.data.value certificate.template = defaultTemplate.data?.value
}, },
auto: false, auto: false,
}) })
@@ -378,6 +383,16 @@ const openCertificate = (certificate) => {
) )
} }
const openLink = (type, name) => {
let url = ''
if (type === 'course') {
url = `/lms/courses/${name}`
} else if (type === 'batch') {
url = `/lms/batches/${name}#students`
}
window.open(url, '_blank')
}
const statusOptions = computed(() => { const statusOptions = computed(() => {
return [ return [
{ {
@@ -7,7 +7,7 @@
> >
<template #body> <template #body>
<div class="p-5 min-h-[300px]"> <div class="p-5 min-h-[300px]">
<div class="text-lg font-semibold mb-4"> <div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Training Feedback') }} {{ __('Training Feedback') }}
</div> </div>
<ListView <ListView
@@ -17,7 +17,7 @@
}" }"
> >
<template #body-content> <template #body-content>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4 text-base">
<p class="text-ink-gray-9"> <p class="text-ink-gray-9">
{{ {{
__( __(
@@ -29,6 +29,7 @@
<FileUploader <FileUploader
:fileTypes="['.pdf']" :fileTypes="['.pdf']"
:validateFile="validateFile" :validateFile="validateFile"
:uploadArgs="{ private: 1 }"
@success=" @success="
(file) => { (file) => {
resume = file resume = file
@@ -38,6 +39,9 @@
<template v-slot="{ file, progress, uploading, openFileSelector }"> <template v-slot="{ file, progress, uploading, openFileSelector }">
<div class=""> <div class="">
<Button @click="openFileSelector" :loading="uploading"> <Button @click="openFileSelector" :loading="uploading">
<template #prefix>
<Upload class="size-4 stroke-1.5" />
</template>
{{ {{
uploading ? `Uploading ${progress}%` : 'Upload your resume' uploading ? `Uploading ${progress}%` : 'Upload your resume'
}} }}
@@ -65,7 +69,7 @@
</template> </template>
<script setup> <script setup>
import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui' import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui'
import { FileText } from 'lucide-vue-next' import { FileText, Upload } from 'lucide-vue-next'
import { ref, inject } from 'vue' import { ref, inject } from 'vue'
import { getFileSize } from '@/utils/' import { getFileSize } from '@/utils/'
@@ -95,7 +99,7 @@ const jobApplication = createResource({
doc: { doc: {
doctype: 'LMS Job Application', doctype: 'LMS Job Application',
user: user.data?.name, user: user.data?.name,
resume: resume.value?.file_name, resume: resume.value?.file_url,
job: props.job, job: props.job,
}, },
} }
@@ -84,16 +84,10 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { import { Dialog, createResource, Tooltip, FormControl, toast } from 'frappe-ui'
Dialog,
createResource,
Tooltip,
FormControl,
Autocomplete,
toast,
} from 'frappe-ui'
import { reactive, inject, onMounted } from 'vue' import { reactive, inject, onMounted } from 'vue'
import { getTimezones, getUserTimezone } from '@/utils/' import { getTimezones, getUserTimezone } from '@/utils/'
import Autocomplete from '@/components/Controls/Autocomplete.vue'
const liveClasses = defineModel('reloadLiveClasses') const liveClasses = defineModel('reloadLiveClasses')
const show = defineModel() const show = defineModel()
+2 -1
View File
@@ -1,7 +1,6 @@
<template> <template>
<Dialog <Dialog
v-model="show" v-model="show"
class="text-base"
:options="{ :options="{
title: __('Add web page to sidebar'), title: __('Add web page to sidebar'),
size: 'lg', size: 'lg',
@@ -17,6 +16,7 @@
}" }"
> >
<template #body-content> <template #body-content>
<div class="text-base">
<Link <Link
v-model="page.webpage" v-model="page.webpage"
doctype="Web Page" doctype="Web Page"
@@ -26,6 +26,7 @@
}" }"
/> />
<IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" /> <IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" />
</div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
+1 -1
View File
@@ -31,7 +31,7 @@
@change="(val) => (question.question = val)" @change="(val) => (question.question = val)"
:editable="true" :editable="true"
:fixedMenu="true" :fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]" editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/> />
</div> </div>
<div class="grid grid-cols-2 gap-8 mt-4"> <div class="grid grid-cols-2 gap-8 mt-4">
+38 -28
View File
@@ -2,8 +2,8 @@
<Dialog <Dialog
v-model="show" v-model="show"
:options="{ :options="{
title: __('Add a Student'), title: __('Enroll a Student'),
size: 'sm', size: 'lg',
actions: [ actions: [
{ {
label: 'Submit', label: 'Submit',
@@ -18,10 +18,25 @@
<Link <Link
doctype="User" doctype="User"
v-model="student" v-model="student"
:filters="{ ignore_user_type: 1 }" placeholder=" "
:label="__('Student')"
:onCreate=" :onCreate="
(value, close) => { () => {
openSettings('Members', close) openSettings('Members')
show = false
}
"
:required="true"
/>
<Link
doctype="LMS Payment"
v-model="payment"
placeholder=" "
:label="__('Payment')"
:onCreate="
() => {
openSettings('Transactions')
show = false
} }
" "
/> />
@@ -30,54 +45,49 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, createResource, toast } from 'frappe-ui' import { call, Dialog, toast } from 'frappe-ui'
import { ref, inject } from 'vue' import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import { openSettings } from '@/utils' import { openSettings } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const students = defineModel('reloadStudents') const student = ref(null)
const batchModal = defineModel('batchModal') const payment = ref(null)
const student = ref()
const user = inject('$user') const user = inject('$user')
const { updateOnboardingStep } = useOnboarding('learning') const { updateOnboardingStep } = useOnboarding('learning')
const show = defineModel() const show = defineModel()
const props = defineProps({ const props = defineProps({
batch: { batch: {
type: String, type: Object,
default: null,
},
students: {
type: Object,
default: null, default: null,
}, },
}) })
const studentResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Batch Enrollment',
batch: props.batch,
member: student.value,
},
}
},
})
const addStudent = (close) => { const addStudent = (close) => {
studentResource.submit( props.students.insert.submit(
{}, {
member: student.value,
payment: payment.value,
batch: props.batch.data?.name,
},
{ {
onSuccess() { onSuccess() {
if (user.data?.is_system_manager) if (user.data?.is_system_manager)
updateOnboardingStep('add_batch_student') updateOnboardingStep('add_batch_student')
students.value.reload()
batchModal.value.reload()
student.value = null student.value = null
payment.value = null
props.batch.reload()
close() close()
}, },
onError(err) { onError(err) {
toast.error(err.messages?.[0] || err) toast.error(err.messages?.[0] || err)
console.error(err)
}, },
} }
) )

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