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

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

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: |

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

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 }}

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

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

4
.gitignore vendored
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
frappe-ui lms/www/_lms.html
frappe-ui
frappe-semgrep-rules

30
.mergify.yml Normal file
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 }}"

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
codecov.yml Normal file
View File

@@ -0,0 +1,2 @@
ignore:
- "**/test_helper.py"

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");
}); });
}); });

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]")

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
} }

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']

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"
} }
} }

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>

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");
}

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>

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()" :onCreate="(value, close) => redirectToForm()"
/> />
<Link <div v-else class="space-y-4">
v-else <Link
v-model="assignment" v-if="filterAssignmentsByCourse"
doctype="LMS Assignment" v-model="assignment"
:label="__('Select an assignment')" doctype="LMS Assignment"
:onCreate="(value, close) => redirectToForm()" :filters="{
/> course: route.params.courseName,
}"
placeholder=" "
:label="__('Select an Assignment')"
:onCreate="(value, close) => redirectToForm()"
/>
<Link
v-else
v-model="assignment"
doctype="LMS Assignment"
placeholder=" "
:label="__('Select an Assignment')"
: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>

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">
<FileText class="h-5 w-5 stroke-1.5" />
</div>
<a <a
:href="submissionFile.file_url" :href="attachment"
target="_blank" target="_blank"
class="flex flex-col cursor-pointer !no-underline" class="cursor-pointer !no-underline text-sm leading-5"
> >
<span class="text-sm leading-5"> <div class="flex items-center">
{{ submissionFile.file_name }} <div class="border rounded-md p-2 mr-2">
</span> <FileText class="h-5 w-5 stroke-1.5" />
<span class="text-sm text-ink-gray-5 mt-1"> </div>
{{ getFileSize(submissionFile.file_size) }} <span>
</span> {{ attachment.split('/').pop() }}
</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,129 +269,98 @@ 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({ if (submissionResource.doc.answer) {
image: submissionResource.doc.assignment_attachment, answer.value = submissionResource.doc.answer
})
}
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
if (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
}
} }
}) if (submissionResource.doc.assignment_attachment) {
attachment.value = submissionResource.doc.assignment_attachment
watch(submissionFile, () => { }
if (props.submissionName == 'new' && submissionFile.value) { if (submissionResource.doc.comments) {
isDirty.value = true comments.value = submissionResource.doc.comments
} }
}) })
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,
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'))
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
query: { fromLesson: router.currentRoute.value.query.fromLesson },
})
} else {
markLessonProgress()
router.go()
}
isDirty.value = false
submissionResource.name = data.name
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) { onSuccess(data) {
toast.success(__('Assignment submitted successfully')) isDirty.value = false
if (router.currentRoute.value.name == 'AssignmentSubmission') { toast.success(__('Changes saved successfully'))
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
query: { fromLesson: router.currentRoute.value.query.fromLesson },
})
} else {
markLessonProgress()
router.go()
}
submissionResource.name = data.name
submissionResource.reload()
}, },
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(() => {

View File

@@ -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>

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>

View File

@@ -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) {
course: props.courseName, return {
course: props.courseName,
}
}, },
auto: user.data ? true : false, auto: user.data ? true : false,
cache: ['certificationData', user.data?.name],
}) })
const downloadCertificate = () => { const downloadCertificate = () => {

View File

@@ -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>

View File

@@ -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>

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,

View File

@@ -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: [

View File

@@ -3,59 +3,67 @@
<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 <div class="overflow-x-auto">
class="grid items-center space-x-4 p-2 border-b"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<div <div
v-for="(column, index) in columns" class="grid items-center space-x-4 p-2 border-b border-outline-gray-modals"
:key="index" :style="{ gridTemplateColumns: getGridTemplateColumns() }"
class="text-sm text-ink-gray-5"
> >
{{ column }}
</div>
<div></div>
</div>
<div
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="grid items-center space-x-4 p-2"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<template v-for="key in Object.keys(row)" :key="key">
<input
v-if="showKey(key)"
v-model="row[key]"
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-sm text-sm focus:outline-none"
/>
</template>
<div class="relative" ref="menuRef">
<Button
variant="ghost"
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
>
<template #icon>
<Ellipsis
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
/>
</template>
</Button>
<div <div
v-if="menuOpenIndex === rowIndex" v-for="(column, index) in columns"
class="absolute right-[30px] top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm" :key="index"
class="text-sm text-ink-gray-5"
> >
<button {{ column }}
@click="deleteRow(rowIndex)" </div>
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3" <div></div>
</div>
<div
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="grid items-center space-x-4 p-2"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<template v-for="key in Object.keys(row)" :key="key">
<input
v-if="showKey(key)"
v-model="row[key]"
class="py-1.5 px-2 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>
<div class="relative">
<Button
variant="ghost"
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
> >
<Trash2 class="size-4 stroke-1.5" /> <template #icon>
<span> <Ellipsis
{{ __('Delete') }} class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
</span> />
</button> </template>
</Button>
<div
v-if="menuOpenIndex === rowIndex"
ref="menuRef"
class="absolute right-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
:class="
rowIndex == (rows?.length ?? 0) - 1
? 'bottom-full mb-1'
: 'top-full mt-1'
"
>
<button
@click="deleteRow(rowIndex)"
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
>
<Trash2 class="size-4 stroke-1.5" />
<span>
{{ __('Delete') }}
</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -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, () => {

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`]

View File

@@ -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'])

View File

@@ -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">

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],

View File

@@ -1,160 +1,145 @@
<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"> <ComboboxInput
<template #target="{ togglePopover }"> ref="search"
<ComboboxInput class="form-input w-full focus-visible:!ring-0"
ref="search" type="text"
class="search-input form-input w-full focus-visible:!ring-0" @change="
type="text" (e) => {
:value="query" query = e.target.value
@change=" }
(e) => { "
query = e.target.value autocomplete="off"
showOptions = true @focus="onFocus"
} />
" <ComboboxButton ref="trigger" class="hidden" />
autocomplete="off" <ComboboxOptions
@focus="() => togglePopover()" v-show="open"
@keydown.delete.capture.stop="removeLastValue" 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"
</template> >
<template #body="{ isOpen, close }"> <div
<div v-show="isOpen"> class="flex-1 my-1 overflow-y-auto px-1.5"
<div :class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2" >
<template v-if="options.length">
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
> >
<ComboboxOptions <li
class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5" :class="[
static 'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-2': active },
]"
> >
<ComboboxOption <div class="flex flex-col gap-1 p-1">
v-for="option in options" <div class="text-base font-medium text-ink-gray-8">
:key="option.value" {{
:value="option" option.value === option.label
v-slot="{ active }" ? option.description
> : option.label
<li }}
:class="[ </div>
'flex cursor-pointer items-center rounded px-2 py-1 text-base', <div class="text-sm text-ink-gray-5">
{ 'bg-surface-gray-2': active }, {{ option.value }}
]" </div>
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{ option.description }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
<div class="h-10"></div>
<div
v-if="attrs.onCreate"
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(close)"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div> </div>
</ComboboxOptions> </li>
</div> </ComboboxOption>
</template>
<div v-else class="text-ink-gray-7 px-4 py-2">
{{ __('No results found') }}
</div> </div>
</template> </div>
</Popover>
</Combobox> <div
</div> v-if="attrs.onCreate"
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1"> class="p-1 bg-surface-white border-t rounded-b-lg"
>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate()"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</ComboboxOptions>
</div>
</Combobox>
<!-- 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) => {
query.value = '' if (!val?.value) return
if (val) { query.value = ''
showOptions.value = false addValue(val.value)
} selectedValue.value = null
val?.value && addValue(val.value) emit('update:modelValue', values.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) {
const splitValues = value.split(',') if (!value) return
splitValues.forEach((value) => {
value = value.trim() const splitValues = value.split(',')
if (value) {
// check if value is not already in the values array splitValues.forEach((val) => {
if (!values.value?.includes(value)) { val = val.trim()
// check if value is valid
if (value && props.validate && !props.validate(value)) { if (!val) return
error.value = props.errorMessage(value) if (values.value?.includes(val)) return
return
} if (props.validate && !props.validate(val)) {
// add value to values array error.value = props.errorMessage(val)
if (!values.value) { return
values.value = [value] }
} else {
values.value.push(value) if (!values.value) values.value = [val]
} else values.value.push(val)
value = value.replace(value, '') })
} }
}
}) function removeValue(value) {
!error.value && (value = '') let indexToRemove = values.value.indexOf(value)
if (indexToRemove > -1) {
values.value.splice(indexToRemove, 1)
} }
emit('update:modelValue', values.value)
} }
const removeValue = (value) => { const labelClasses = computed(() => [
values.value = values.value.filter((v) => v !== value) { sm: 'text-xs', md: 'text-base' }[props.size || 'sm'],
} 'text-ink-gray-5',
])
const removeLastValue = () => {
if (query.value) return
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',
]
})
</script> </script>

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)
} }

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>

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', {
course: props.course.data.name, doc: {
doctype: 'LMS Enrollment',
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>

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>

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,11 +46,11 @@
" "
/> />
</div> </div>
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
{{ review.review }}
</div>
</div> </div>
</div> </div>
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
{{ review.review }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -80,7 +80,7 @@ const props = defineProps({
required: true, required: true,
}, },
membership: { membership: {
type: Object, type: Object || null,
required: false, required: false,
}, },
}) })

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>

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( if (!newReply.value) {
{}, toast.error(__('Reply cannot be empty.'))
{ return
validate() { }
if (!newReply.value) { call('frappe.client.insert', {
return 'Reply cannot be empty' doc: {
}
},
onSuccess() {
newReply.value = ''
replies.reload()
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const editReplyResource = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Discussion Reply', doctype: 'Discussion Reply',
name: values.name, reply: newReply.value,
fieldname: 'reply', topic: props.topic.name,
value: values.reply, },
} })
}, .then((data) => {
}) newReply.value = ''
replies.reload()
capture('discussion_reply_created')
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const postEdited = (reply) => { const postEdited = (reply) => {
editReplyResource.submit( if (!reply.reply) {
{ toast.error(__('Reply cannot be empty.'))
name: reply.name, return
reply: reply.reply, }
}, call('frappe.client.set_value', {
{ doctype: 'Discussion Reply',
validate() { name: reply.name,
if (!reply.reply) { fieldname: 'reply',
return 'Reply cannot be empty' value: reply.reply,
} })
}, .then(() => {
onSuccess() { reply.editable = false
reply.editable = false replies.reload()
replies.reload() })
}, .catch((err) => {
} toast.error(err.messages?.[0] || err)
) console.error(err)
})
} }
const deleteReplyResource = createResource({
url: 'frappe.client.delete',
makeParams(values) {
return {
doctype: 'Discussion Reply',
name: values.name,
}
},
})
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(() => {

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()

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>

View File

@@ -76,12 +76,20 @@ const isModerator = ref(false)
const isInstructor = ref(false) const isInstructor = ref(false)
onMounted(() => { onMounted(() => {
// Вызываем addSideBar только если userResource уже загружен sidebarSettings.reload(
if (userResource.data) { {},
addSideBar() {
} onSuccess(data) {
addOtherLinks() if (userResource.data) {
filterLinksToShow(data) addSideBar()
} else {
destructureSidebarLinks()
}
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])) {

View File

@@ -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'

View File

@@ -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,35 +132,49 @@ 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') {
assignments.value.insert.submit( createAssignment()
{
...assignment,
},
{
onSuccess() {
show.value = false
toast.success(__('Assignment created successfully'))
},
}
)
} else { } else {
assignments.value.setValue.submit( updateAssignment()
{
...assignment,
name: props.assignmentID,
},
{
onSuccess() {
show.value = false
toast.success(__('Assignment updated successfully'))
},
}
)
} }
} }
const createAssignment = () => {
assignments.value.insert.submit(
{
...assignment,
},
{
onSuccess() {
show.value = false
toast.success(__('Assignment created successfully'))
},
}
)
}
const updateAssignment = () => {
assignments.value.setValue.submit(
{
...assignment,
name: props.assignmentID,
},
{
onSuccess() {
show.value = false
toast.success(__('Assignment updated successfully'))
},
}
)
}
const assignmentOptions = computed(() => { const assignmentOptions = computed(() => {
return [ return [
{ label: 'PDF', value: 'PDF' }, { label: 'PDF', value: 'PDF' },

View File

@@ -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({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Batch Course',
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'courses',
course: course.value,
evaluator: evaluator.value,
},
}
},
})
const addCourse = (close) => { const addCourse = (close) => {
createBatchCourse.submit( courses.value.insert.submit(
{}, {
course: course.value,
evaluator: evaluator.value,
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'courses',
},
{ {
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)
}, },
} }
) )

View File

@@ -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>

View File

@@ -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({

View File

@@ -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>

View File

@@ -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({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Topic',
reference_doctype: props.doctype,
reference_docname: props.docname,
title: topic.title,
},
}
},
})
const replyResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Reply',
topic: values.topic,
reply: topic.reply,
},
}
},
})
const submitTopic = (close) => { const submitTopic = (close) => {
topicResource.submit( if (!topic.title) {
{}, toast.error(__('Title cannot be empty.'))
{ return
validate() { }
if (!topic.title) { if (!topic.reply) {
return 'Title cannot be empty.' toast.error(__('Details cannot be empty.'))
} return
if (!topic.reply) { }
return 'Reply cannot be empty.' call('frappe.client.insert', {
} doc: {
}, doctype: 'Discussion Topic',
onSuccess(data) { reference_doctype: props.doctype,
replyResource.submit( reference_docname: props.docname,
{ title: topic.title,
topic: data.name, },
}, })
{ .then((data) => {
onSuccess() { createReply(data.name, close)
topic.title = '' })
topic.reply = '' .catch((err) => {
topics.value.reload() toast.error(err.messages?.[0] || err)
close() console.error(err)
}, })
} }
)
}, const createReply = (topicName, close) => {
onError(err) { call('frappe.client.insert', {
toast.error(err.messages?.[0] || err) doc: {
}, doctype: 'Discussion Reply',
} topic: topicName,
) reply: topic.reply,
},
})
.then((data) => {
topic.title = ''
topic.reply = ''
topics.value.reload()
capture('discussion_topic_created')
close()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
} }
</script> </script>

View File

@@ -1,91 +1,85 @@
<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-header>
<div class="flex items-center justify-between mb-5">
<div class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ __('Edit Profile') }}
</div>
<div class="space-x-2">
<Badge v-if="isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<div class="pb-5 float-right">
<Button variant="solid" @click="saveProfile()">
{{ __('Save') }}
</Button>
</div>
</div>
</div>
</template>
<template #body-content> <template #body-content>
<div class="grid grid-cols-2 gap-5"> <div class="text-base">
<div class="space-y-4"> <div class="grid grid-cols-2 gap-10">
<!-- <Uploader <div class="space-y-4">
v-model="profile.image.file_url" <div class="space-y-4">
label="Profile Image" <Uploader
description="Your profile image to help others recognize you." v-model="profile.image"
/> --> :label="__('Profile Image')"
<div> :required="true"
<div class="text-xs text-ink-gray-5 mb-1"> shape="circle"
{{ __('Profile Image') }} />
</div>
<FileUploader
v-if="!profile.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? `Uploading ${progress}%`
: 'Upload a profile image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="profile.image.file_url"
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
/>
<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"
</div> :label="__('LinkedIn ID')"
/>
<FormControl v-model="profile.github" :label="__('GitHub ID')" />
<FormControl
v-model="profile.twitter"
:label="__('Twitter ID')"
/>
</div> </div>
</div> </div>
<FormControl v-model="profile.first_name" :label="__('First Name')" /> <div class="space-y-4">
<FormControl v-model="profile.last_name" :label="__('Last Name')" /> <FormControl
<FormControl v-model="profile.headline" :label="__('Headline')" /> v-model="profile.open_to"
<Link type="select"
:label="__('Language')" :options="[' ', 'Work', 'Hiring']"
v-model="profile.language" :label="__('Open to')"
doctype="Language" :placeholder="__('Looking for new work or hiring talent?')"
/>
</div>
<div>
<div class="mb-4">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Bio') }}
</div>
<TextEditor
:fixedMenu="true"
@change="(val) => (profile.bio = val)"
: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"
/> />
<Link
:label="__('Language')"
v-model="profile.language"
doctype="Language"
/>
<div>
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Bio') }}
</div>
<TextEditor
:fixedMenu="true"
@change="(val) => (profile.bio = val)"
:content="profile.bio"
: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>
@@ -94,22 +88,22 @@
</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) {
const saveImage = (file) => { if (newVal[key] !== props.profile.data[key]) {
profile.image = file isDirty.value = true
} return
}
const removeImage = () => { }
profile.image = null if (profile.image !== props.profile.data.user_image) {
} isDirty.value = true
return
}
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
} }
} }

View File

@@ -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>

View File

@@ -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,64 +14,71 @@
}" }"
> >
<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> <FormControl
<div class="mb-1.5 text-sm text-ink-gray-5"> v-model="evaluation.course"
{{ __('Course') }} type="select"
:label="__('Course')"
:options="getCourses()"
/>
<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>
<Select v-model="evaluation.course" :options="getCourses()" /> <div class="space-y-5">
</div> <div v-for="row in slots.data" class="space-y-2">
<div> <div class="flex items-center text-ink-gray-7 space-x-2">
<div class="mb-1.5 text-sm text-ink-gray-5"> <Calendar class="size-3" />
{{ __('Date') }} <div class="text-ink-gray-9">
</div> {{ dayjs(row.date).format('DD MMMM YYYY') }}
<FormControl </div>
type="date" <div>&middot;</div>
v-model="evaluation.date" <div class="text-ink-gray-5">
:min=" {{ row.day }}
dayjs() </div>
.add(dayjs.duration({ days: 1 })) </div>
.format('YYYY-MM-DD') <div class="grid grid-cols-3 gap-2">
" <div
/> v-for="slot in row.slots"
</div> 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"
<div v-if="slots.data?.length"> @click="saveSlot(slot, row)"
<div class="mb-1.5 text-sm text-ink-gray-5"> :class="{
{{ __('Select a slot') }} 'border-outline-gray-4 text-ink-gray-9':
</div> evaluation.date == row.date &&
<div class="grid grid-cols-2 gap-2"> evaluation.start_time == slot.start_time,
<div v-for="slot in slots.data"> }"
<div >
class="text-base text-center border rounded-md text-ink-gray-8 bg-surface-gray-3 p-2 cursor-pointer" {{ formatTime(slot.start_time) }} -
@click="saveSlot(slot)" {{ formatTime(slot.end_time) }}
:class="{ </div>
'border-outline-gray-4':
evaluation.start_time == slot.start_time,
}"
>
{{ formatTime(slot.start_time) }} -
{{ formatTime(slot.end_time) }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div <div v-else-if="!evaluation.course" class="text-ink-gray-7">
v-else-if="evaluation.course && evaluation.date" {{ __('Please select a course to view available slots.') }}
class="text-sm italic text-ink-red-4" </div>
> <div v-else class="text-ink-red-3">
{{ __('No slots available for this date.') }} {{ __('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,48 +107,28 @@ const evaluation = reactive({
member: user.data.name, member: user.data.name,
}) })
const createEvaluation = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Certificate Request',
batch_name: values.batch,
...values,
},
}
},
})
function submitEvaluation(close) { function submitEvaluation(close) {
createEvaluation.submit(evaluation, { if (!evaluation.value.date || !evaluation.value.start_time) {
validate() { toast.warning(__('Please select a slot for your evaluation.'), {
if (!evaluation.course) { duration: 10,
return 'Please select a course.' })
} return
if (!evaluation.date) { }
return 'Please select a date.' call('frappe.client.insert', {
} doc: {
if (!evaluation.start_time) { doctype: 'LMS Certificate Request',
return 'Please select a slot.' batch_name: evaluation.value.batch,
} ...evaluation.value,
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()
close()
},
onError(err) {
toast.warning(__(err.messages?.[0] || err))
}, },
}) })
.then(() => {
evaluations.value.reload()
close()
})
.catch((err) => {
console.log(err.messages?.[0] || err)
toast.warning(__(err.messages?.[0] || err), { duration: 20 })
})
} }
const getCourses = () => { const getCourses = () => {
@@ -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>

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 [
{ {

View File

@@ -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

View File

@@ -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,
}, },
} }

View File

@@ -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()

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,15 +16,17 @@
}" }"
> >
<template #body-content> <template #body-content>
<Link <div class="text-base">
v-model="page.webpage" <Link
doctype="Web Page" v-model="page.webpage"
:label="__('Web Page')" doctype="Web Page"
:filters="{ :label="__('Web Page')"
published: 1, :filters="{
}" published: 1,
/> }"
<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>

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">

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