Compare commits
1 Commits
v2.15.0
...
feat-reply
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a35638d289 |
40
.github/helper/update_pot_file.sh
vendored
40
.github/helper/update_pot_file.sh
vendored
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
cd ~ || exit
|
|
||||||
|
|
||||||
echo "Setting Up Bench..."
|
|
||||||
|
|
||||||
pip install frappe-bench
|
|
||||||
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch "${BASE_BRANCH}"
|
|
||||||
cd ./frappe-bench || exit
|
|
||||||
|
|
||||||
echo "Get LMS..."
|
|
||||||
bench get-app --skip-assets lms "${GITHUB_WORKSPACE}"
|
|
||||||
|
|
||||||
echo "Generating POT file..."
|
|
||||||
bench generate-pot-file --app lms
|
|
||||||
|
|
||||||
cd ./apps/lms || exit
|
|
||||||
|
|
||||||
echo "Configuring git user..."
|
|
||||||
git config user.email "developers@erpnext.com"
|
|
||||||
git config user.name "frappe-pr-bot"
|
|
||||||
|
|
||||||
echo "Setting the correct git remote..."
|
|
||||||
# Here, the git remote is a local file path by default. Let's change it to the upstream repo.
|
|
||||||
git remote set-url upstream https://github.com/frappe/lms.git
|
|
||||||
|
|
||||||
echo "Creating a new branch..."
|
|
||||||
isodate=$(date -u +"%Y-%m-%d")
|
|
||||||
branch_name="pot_${BASE_BRANCH}_${isodate}"
|
|
||||||
git checkout -b "${branch_name}"
|
|
||||||
|
|
||||||
echo "Commiting changes..."
|
|
||||||
git add lms/locale/main.pot
|
|
||||||
git commit -m "chore: update POT file"
|
|
||||||
|
|
||||||
gh auth setup-git
|
|
||||||
git push -u upstream "${branch_name}"
|
|
||||||
|
|
||||||
echo "Creating a PR..."
|
|
||||||
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/lms
|
|
||||||
34
.github/workflows/generate-pot-file.yml
vendored
34
.github/workflows/generate-pot-file.yml
vendored
@@ -1,34 +0,0 @@
|
|||||||
name: Regenerate POT file (translatable strings)
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "00 16 * * 5"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
regenerate-pot-file:
|
|
||||||
name: Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
branch: ["develop"]
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ matrix.branch }}
|
|
||||||
|
|
||||||
- name: Setup Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Run script to update POT file
|
|
||||||
run: |
|
|
||||||
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
BASE_BRANCH: ${{ matrix.branch }}
|
|
||||||
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
@@ -30,4 +30,4 @@ jobs:
|
|||||||
run: pip install semgrep
|
run: pip install semgrep
|
||||||
|
|
||||||
- name: Run Semgrep rules
|
- name: Run Semgrep rules
|
||||||
run: semgrep ci --config ./frappe-semgrep-rules/rules
|
run: semgrep ci --config ./frappe-semgrep-rules/rules
|
||||||
|
|||||||
27
.github/workflows/make_release_pr.yml
vendored
27
.github/workflows/make_release_pr.yml
vendored
@@ -1,27 +0,0 @@
|
|||||||
name: Create weekly release
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# 13:00 UTC -> 7pm IST on every Wednesday
|
|
||||||
- cron: '30 4 * * 3'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
name: Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: octokit/request-action@v2.x
|
|
||||||
with:
|
|
||||||
route: POST /repos/{owner}/{repo}/pulls
|
|
||||||
owner: frappe
|
|
||||||
repo: lms
|
|
||||||
title: |-
|
|
||||||
"chore: merge 'develop' into 'main'"
|
|
||||||
body: "Automated weekly release"
|
|
||||||
base: main
|
|
||||||
head: develop
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
32
.github/workflows/on_release.yml
vendored
32
.github/workflows/on_release.yml
vendored
@@ -1,32 +0,0 @@
|
|||||||
name: Generate Semantic Release
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
name: Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Entire Repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: Setup dependencies
|
|
||||||
run: |
|
|
||||||
npm install @semantic-release/git @semantic-release/exec --no-save
|
|
||||||
- name: Create Release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
GIT_AUTHOR_NAME: "Frappe PR Bot"
|
|
||||||
GIT_AUTHOR_EMAIL: "developers@frappe.io"
|
|
||||||
GIT_COMMITTER_NAME: "Frappe PR Bot"
|
|
||||||
GIT_COMMITTER_EMAIL: "developers@frappe.io"
|
|
||||||
run: npx semantic-release
|
|
||||||
39
.github/workflows/release_notes.yml
vendored
39
.github/workflows/release_notes.yml
vendored
@@ -1,39 +0,0 @@
|
|||||||
# This action:
|
|
||||||
#
|
|
||||||
# 1. Generates release notes using github API.
|
|
||||||
# 2. Strips unnecessary info like chore/style etc from notes.
|
|
||||||
# 3. Updates release info.
|
|
||||||
|
|
||||||
name: 'Release Notes'
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag_name:
|
|
||||||
description: 'Tag of release like v2.0.0'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
release:
|
|
||||||
types: [released]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
regen-notes:
|
|
||||||
name: 'Regenerate release notes'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Update notes
|
|
||||||
run: |
|
|
||||||
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/generate-notes -f tag_name=$RELEASE_TAG \
|
|
||||||
| jq -r '.body' \
|
|
||||||
| sed -E '/^\* (chore|ci|test|docs|style)/d' \
|
|
||||||
| sed -E 's/by @mergify //'
|
|
||||||
)
|
|
||||||
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/tags/$RELEASE_TAG | jq -r '.id')
|
|
||||||
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/$RELEASE_ID -f body="$NEW_NOTES"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
RELEASE_TAG: ${{ github.event.inputs.tag_name || github.event.release.tag_name }}
|
|
||||||
1
.github/workflows/ui-tests.yml
vendored
1
.github/workflows/ui-tests.yml
vendored
@@ -99,7 +99,6 @@ jobs:
|
|||||||
cd ~/frappe-bench/
|
cd ~/frappe-bench/
|
||||||
bench --site lms.test execute frappe.utils.install.complete_setup_wizard
|
bench --site lms.test execute frappe.utils.install.complete_setup_wizard
|
||||||
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
|
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
|
||||||
bench --site lms.test set-password frappe@example.com admin
|
|
||||||
|
|
||||||
- name: cypress pre-requisites
|
- name: cypress pre-requisites
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,7 +9,4 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
lms/public/frontend
|
|
||||||
lms/www/lms.html
|
|
||||||
frappe-ui
|
|
||||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
|||||||
[submodule "frappe-ui"]
|
|
||||||
path = frappe-ui
|
|
||||||
url = https://github.com/frappe/frappe-ui
|
|
||||||
@@ -32,7 +32,7 @@ repos:
|
|||||||
rev: v2.7.1
|
rev: v2.7.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or: [javascript, vue]
|
types_or: [javascript]
|
||||||
# Ignore any files that might contain jinja / bundles
|
# Ignore any files that might contain jinja / bundles
|
||||||
exclude: |
|
exclude: |
|
||||||
(?x)^(
|
(?x)^(
|
||||||
|
|||||||
21
.releaserc
21
.releaserc
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"branches": ["develop"],
|
|
||||||
"plugins": [
|
|
||||||
"@semantic-release/commit-analyzer", {
|
|
||||||
"preset": "angular"
|
|
||||||
},
|
|
||||||
"@semantic-release/release-notes-generator",
|
|
||||||
[
|
|
||||||
"@semantic-release/exec", {
|
|
||||||
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" lms/__init__.py'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"@semantic-release/git", {
|
|
||||||
"assets": ["lms/__init__.py"],
|
|
||||||
"message": "chore(release): Bumped to Version ${nextRelease.version}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@semantic-release/github"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
10
README.md
10
README.md
@@ -1,6 +1,6 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://www.frappelms.com/">
|
<a href="https://www.frappelms.com/">
|
||||||
<img src="https://frappe.io/files/lms.png" alt="Frappe LMS" width="50px" height="50px">
|
<img src="https://frappelms.com/files/lms-logo-medium.png" alt="Frappe LMS" width="120px" height="25px">
|
||||||
</a>
|
</a>
|
||||||
<p align="center">Easy to use, open source, learning management system.</p>
|
<p align="center">Easy to use, open source, learning management system.</p>
|
||||||
</p>
|
</p>
|
||||||
@@ -75,13 +75,7 @@ cd apps/lms/docker
|
|||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
Wait for some time until the setup script creates a site. After that, you can access `http://localhost:8000` in your browser and the app's login screen should appear.
|
Wait for some time until the setup script creates a site. After that, you can access `http://localhost:8000` in your browser and the app's login screen should show up.
|
||||||
You'll have to go through the setup wizard to set up the website the first time you access it. Log in using the following credentials to complete the setup wizard.
|
|
||||||
|
|
||||||
```
|
|
||||||
Username: Administrator
|
|
||||||
password: admin
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frappe Bench
|
### Frappe Bench
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
The Frappe team and community take security issues seriously. To report a security issue, please go through the information mentioned [here](https://frappe.io/security).
|
|
||||||
|
|
||||||
We appreciate your efforts to responsibly disclose your findings. We'll endeavor to respond quickly and will keep you updated throughout the process.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
files:
|
|
||||||
- source: /lms/locale/main.pot
|
|
||||||
translation: /lms/locale/%two_letters_code%.po
|
|
||||||
pull_request_title: "chore: sync translations from crowdin"
|
|
||||||
pull_request_labels:
|
|
||||||
- translation
|
|
||||||
commit_message: "chore: %language% translations"
|
|
||||||
append_commit_message: false
|
|
||||||
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
|||||||
openMode: 0,
|
openMode: 0,
|
||||||
},
|
},
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: "http://lms1:8000",
|
baseUrl: "http://dd1:8000",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,159 +1,133 @@
|
|||||||
describe("Course Creation", () => {
|
describe("Course Creation", () => {
|
||||||
it("creates a new course", () => {
|
it("creates a new course", () => {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.wait(1000);
|
cy.visit("/courses");
|
||||||
cy.visit("/lms/courses");
|
|
||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("header").children().last().children().last().click();
|
cy.get("a.btn").contains("Create a Course").click();
|
||||||
|
cy.wait(1000);
|
||||||
|
cy.url().should("include", "/courses/new-course/edit");
|
||||||
|
cy.get("#title").type("Test Course");
|
||||||
|
cy.get("#intro").type("Test Course Short Introduction");
|
||||||
|
cy.get("#description").type("Test Course Description");
|
||||||
|
cy.get("#video-link").type("-LPmw2Znl2c");
|
||||||
|
cy.get("#tags-input").type("Test");
|
||||||
|
cy.get("#published").check();
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.url().should("include", "/courses/new/edit");
|
|
||||||
|
|
||||||
cy.get("label").contains("Title").type("Test Course");
|
|
||||||
cy.get("label")
|
|
||||||
.contains("Short Introduction")
|
|
||||||
.type("Test Course Short Introduction to test the UI");
|
|
||||||
cy.get("div[contenteditable=true").invoke(
|
|
||||||
"text",
|
|
||||||
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.fixture("profile.png", "base64").then((fileContent) => {
|
|
||||||
cy.get('input[type="file"]').attachFile({
|
|
||||||
fileContent,
|
|
||||||
fileName: "profile.png",
|
|
||||||
mimeType: "image/png",
|
|
||||||
encoding: "base64",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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 */
|
|
||||||
cy.get("label")
|
|
||||||
.contains("Instructors")
|
|
||||||
.parent()
|
|
||||||
.within(() => {
|
|
||||||
cy.get("input").click().type("frappe");
|
|
||||||
cy.get("input")
|
|
||||||
.invoke("attr", "aria-controls")
|
|
||||||
.as("instructor_list_id");
|
|
||||||
});
|
|
||||||
cy.get("@instructor_list_id").then((instructor_list_id) => {
|
|
||||||
cy.get(`[id^=${instructor_list_id}`)
|
|
||||||
.should("be.visible")
|
|
||||||
.within(() => {
|
|
||||||
cy.get("[id^=headlessui-combobox-option-").first().click();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.get("label").contains("Published").click();
|
|
||||||
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.link("Course Outline").click();
|
||||||
|
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.get("[id^=headlessui-dialog-panel-")
|
cy.get(".edit-header .btn-add-chapter").click();
|
||||||
.should("be.visible")
|
cy.wait(500);
|
||||||
.within(() => {
|
cy.get("#chapter-title").type("Test Chapter");
|
||||||
cy.get("label").contains("Title").type("Test Chapter");
|
cy.get("#chapter-description").type("Test Chapter Description");
|
||||||
cy.button("Create").click();
|
cy.button("Save").click();
|
||||||
});
|
|
||||||
|
|
||||||
// Add Lesson
|
// Add Lesson
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.button("Add Lesson").click();
|
cy.link("Add Lesson").click();
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.url().should("include", "/learn/1-1/edit");
|
cy.get("#lesson-title").type("Test Lesson");
|
||||||
|
|
||||||
|
// Content
|
||||||
|
cy.get(".collapse-section.collapsed:first").click();
|
||||||
|
cy.get("#lesson-content .ce-block")
|
||||||
|
.click()
|
||||||
|
.type(
|
||||||
|
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now. {enter}"
|
||||||
|
);
|
||||||
|
cy.get("#lesson-content .ce-toolbar__plus").click();
|
||||||
|
cy.get('#lesson-content [data-item-name="youtube"]').click();
|
||||||
|
cy.get('input[data-fieldname="youtube"]').type("GoDtyItReto");
|
||||||
|
cy.button("Insert").click();
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
|
|
||||||
cy.get("label").contains("Title").type("Test Lesson");
|
|
||||||
|
|
||||||
cy.get("#content .ce-block").type(
|
|
||||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
|
||||||
);
|
|
||||||
cy.button("Save").click();
|
cy.button("Save").click();
|
||||||
|
|
||||||
// View Course
|
// View Course
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.visit("/lms");
|
cy.visit("/courses");
|
||||||
cy.wait(500);
|
cy.get(".course-card-title:first").contains("Test Course");
|
||||||
cy.url().should("include", "/lms/courses");
|
cy.get(".course-card:first").click();
|
||||||
cy.get(".grid a:first").within(() => {
|
cy.url().should("include", "/courses/test-course");
|
||||||
cy.get("div").contains("Test Course");
|
cy.get("#title").contains("Test Course");
|
||||||
cy.get("div").contains(
|
cy.get(".preview-video").should(
|
||||||
"Test Course Short Introduction to test the UI"
|
|
||||||
);
|
|
||||||
cy.get(".course-image")
|
|
||||||
.invoke("css", "background-image")
|
|
||||||
.should("include", "/files/profile");
|
|
||||||
});
|
|
||||||
cy.get(".grid a:first").click();
|
|
||||||
cy.url().should("include", "/lms/courses/test-course");
|
|
||||||
cy.get("div").contains("Test Course");
|
|
||||||
cy.get("div").contains("Test Course Short Introduction to test the UI");
|
|
||||||
cy.get("div").contains("Learning");
|
|
||||||
cy.get("div").contains("Frappe");
|
|
||||||
cy.get("div").contains("ERPNext");
|
|
||||||
cy.get("iframe").should(
|
|
||||||
"have.attr",
|
"have.attr",
|
||||||
"src",
|
"src",
|
||||||
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
||||||
);
|
);
|
||||||
|
cy.get("#intro").contains("Test Course Short Introduction");
|
||||||
|
|
||||||
// View Chapter
|
// View Chapter
|
||||||
cy.get("div").contains("Test Chapter");
|
cy.get(".chapter-title-main:first").contains("Test Chapter");
|
||||||
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
|
cy.get(".chapter-description:first").contains(
|
||||||
cy.get("div").contains("Test Lesson").click();
|
"Test Chapter Description"
|
||||||
});
|
);
|
||||||
cy.wait(3000);
|
cy.get(".lesson-info:first").contains("Test Lesson");
|
||||||
|
cy.get(".lesson-info:first").click();
|
||||||
|
|
||||||
// View Lesson
|
// View Lesson
|
||||||
cy.url().should("include", "/learn/1-1");
|
cy.wait(1000);
|
||||||
cy.get("div").contains("Test Lesson");
|
cy.url().should("include", "learn/1.1");
|
||||||
|
cy.get("#title").contains("Test Lesson");
|
||||||
cy.get("div").contains(
|
cy.get(".lesson-video iframe").should(
|
||||||
|
"have.attr",
|
||||||
|
"src",
|
||||||
|
"https://www.youtube.com/embed/GoDtyItReto"
|
||||||
|
);
|
||||||
|
cy.get(".lesson-content-card").contains(
|
||||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add Discussion
|
// Add Discussion
|
||||||
cy.button("New Question").click();
|
cy.get(".reply").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.get("[id^=headlessui-dialog-panel-").within(() => {
|
cy.get(".discussion-modal").should("be.visible");
|
||||||
cy.get("label").contains("Title").type("Test Discussion");
|
|
||||||
cy.get("div[contenteditable=true]").invoke(
|
// Enter title
|
||||||
"text",
|
cy.get(".modal .topic-title")
|
||||||
"This is a test discussion. This will check if the UI is working properly."
|
.type("Discussion from tests")
|
||||||
|
.should("have.value", "Discussion from tests");
|
||||||
|
|
||||||
|
// Enter comment
|
||||||
|
cy.get(".modal .discussions-comment").type(
|
||||||
|
"This is a discussion from the cypress ui tests."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
cy.get(".modal .submit-discussion").click();
|
||||||
|
cy.wait(2000);
|
||||||
|
|
||||||
|
// Check if discussion is added to page and content is visible
|
||||||
|
cy.get(".sidebar-parent:first .discussion-topic-title").should(
|
||||||
|
"have.text",
|
||||||
|
"Discussion from tests"
|
||||||
|
);
|
||||||
|
cy.get(".sidebar-parent:first .discussion-topic-title").click();
|
||||||
|
cy.get(".discussion-on-page:visible").should("have.class", "show");
|
||||||
|
cy.get(
|
||||||
|
".discussion-on-page:visible .reply-card .reply-text .ql-editor p"
|
||||||
|
).should(
|
||||||
|
"have.text",
|
||||||
|
"This is a discussion from the cypress ui tests."
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.get(".discussion-form:visible .discussions-comment").type(
|
||||||
|
"This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page."
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.get(".discussion-form:visible .submit-discussion").click();
|
||||||
|
cy.wait(3000);
|
||||||
|
cy.get(".discussion-on-page:visible").should("have.class", "show");
|
||||||
|
cy.get(".discussion-on-page:visible")
|
||||||
|
.children(".reply-card")
|
||||||
|
.eq(1)
|
||||||
|
.find(".reply-text")
|
||||||
|
.should(
|
||||||
|
"have.text",
|
||||||
|
"This is a discussion from the cypress ui tests. This comment was entered through the commentbox on the page.\n"
|
||||||
);
|
);
|
||||||
cy.button("Post").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// View Discussion
|
|
||||||
cy.wait(500);
|
|
||||||
cy.get("div").contains("Test Discussion").click();
|
|
||||||
cy.get("div[contenteditable=true").invoke(
|
|
||||||
"text",
|
|
||||||
"This is a test comment. This will check if the UI is working properly."
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get("div").contains(
|
|
||||||
"This is a test comment. This will check if the UI is working properly."
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -24,8 +24,6 @@
|
|||||||
// -- This will overwrite an existing command --
|
// -- This will overwrite an existing command --
|
||||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
import "cypress-file-upload";
|
|
||||||
|
|
||||||
Cypress.Commands.add("login", (email, password) => {
|
Cypress.Commands.add("login", (email, password) => {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
email = Cypress.config("testUser") || "Administrator";
|
email = Cypress.config("testUser") || "Administrator";
|
||||||
@@ -55,13 +53,3 @@ Cypress.Commands.add("iconButton", (text) => {
|
|||||||
Cypress.Commands.add("dialog", (selector) => {
|
Cypress.Commands.add("dialog", (selector) => {
|
||||||
return cy.get(`[role=dialog] ${selector}`);
|
return cy.get(`[role=dialog] ${selector}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
|
|
||||||
cy.wrap(subject).then(($element) => {
|
|
||||||
const element = $element[0];
|
|
||||||
element.focus();
|
|
||||||
element.textContent = text;
|
|
||||||
const event = new Event("paste", { bubbles: true });
|
|
||||||
element.dispatchEvent(event);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
$ git clone https://github.com/frappe/lms.git
|
$ git clone https://github.com/frappe/lms.git
|
||||||
|
|
||||||
$ cd lms
|
$ cd lms
|
||||||
|
|
||||||
$ cd docker
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 2:** Run docker-compose
|
**Step 2:** Run docker-compose
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ bench new-site lms.localhost \
|
|||||||
bench --site lms.localhost install-app lms
|
bench --site lms.localhost install-app lms
|
||||||
bench --site lms.localhost set-config developer_mode 1
|
bench --site lms.localhost set-config developer_mode 1
|
||||||
bench --site lms.localhost clear-cache
|
bench --site lms.localhost clear-cache
|
||||||
|
bench --site lms.localhost set-config mute_emails 1
|
||||||
bench use lms.localhost
|
bench use lms.localhost
|
||||||
|
|
||||||
bench start
|
bench start
|
||||||
|
|||||||
Submodule frappe-ui deleted from 8cd9b06a5e
5
frontend/.gitignore
vendored
5
frontend/.gitignore
vendored
@@ -1,5 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.DS_Store
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Frappe UI Starter
|
|
||||||
|
|
||||||
This template should help get you started developing custom frontend for Frappe
|
|
||||||
apps with Vue 3 and the Frappe UI package.
|
|
||||||
|
|
||||||
This boilerplate sets up Vue 3, Vue Router, TailwindCSS, and Frappe UI out of
|
|
||||||
the box.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
This template is meant to be cloned inside an existing Frappe App. Assuming your
|
|
||||||
apps name is `todo`. Clone this template in the root folder of your app using `degit`.
|
|
||||||
|
|
||||||
```
|
|
||||||
cd apps/todo
|
|
||||||
npx degit netchampfaris/frappe-ui-starter frontend
|
|
||||||
cd frontend
|
|
||||||
yarn
|
|
||||||
yarn dev
|
|
||||||
```
|
|
||||||
|
|
||||||
In a development environment, you need to put the below key-value pair in your `site_config.json` file:
|
|
||||||
|
|
||||||
```
|
|
||||||
"ignore_csrf": 1
|
|
||||||
```
|
|
||||||
|
|
||||||
This will prevent `CSRFToken` errors while using the vite dev server. In production environment, the `csrf_token` is attached to the `window` object in `index.html` for you.
|
|
||||||
|
|
||||||
The Vite dev server will start on the port `8080`. This can be changed from `vite.config.js`.
|
|
||||||
The development server is configured to proxy your frappe app (usually running on port `8000`). If you have a site named `todo.test`, open `http://todo.test:8080` in your browser. If you see a button named "Click to send 'ping' request", congratulations!
|
|
||||||
|
|
||||||
If you notice the browser URL is `/frontend`, this is the base URL where your frontend app will run in production.
|
|
||||||
To change this, open `src/router.js` and change the base URL passed to `createWebHistory`.
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- [Vue 3](https://v3.vuejs.org/guide/introduction.html)
|
|
||||||
- [Vue Router](https://next.router.vuejs.org/guide/)
|
|
||||||
- [Frappe UI](https://github.com/frappe/frappe-ui)
|
|
||||||
- [TailwindCSS](https://tailwindcss.com/docs/utility-first)
|
|
||||||
- [Vite](https://vitejs.dev/guide/)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" href="/favicon.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Frappe Learning</title>
|
|
||||||
<meta name="title" content="{{ meta.title }}" />
|
|
||||||
<meta name="image" content="{{ meta.image }}" />
|
|
||||||
<meta name="description" content="{{ meta.description }}" />
|
|
||||||
<meta name="keywords" content="{{ meta.keywords }}" />
|
|
||||||
<meta property="og:title" content="{{ meta.title }}" />
|
|
||||||
<meta property="og:image" content="{{ meta.image }}" />
|
|
||||||
<meta property="og:description" content="{{ meta.description }}" />
|
|
||||||
<meta name="twitter:title" content="{{ meta.title }}" />
|
|
||||||
<meta name="twitter:image" content="{{ meta.image }}" />
|
|
||||||
<meta name="twitter:description" content="{{ meta.description }}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app">
|
|
||||||
<div id="seo-content">
|
|
||||||
<h1>{{ meta.title }}</h1>
|
|
||||||
<p>
|
|
||||||
{{ meta.description }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
The content here is just for seo purposes. The actual content will be loaded in a few seconds.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Seo checks if a page has more than 300 words. So, here are some more words to make it more than 300 words.
|
|
||||||
Page descriptions are the HTML meta tags that provide a brief summary of a web page.
|
|
||||||
Search engines use meta descriptions to help identify the page's topic - they don't use them to rank the page, but they do use them to determine whether or not to display the page in search results.
|
|
||||||
Meta descriptions are important because they're often the first thing people see when they're deciding which search result to click on.
|
|
||||||
They're also important because they can help improve your click-through rate (CTR) from search results.
|
|
||||||
A good meta description can entice people to click on your page instead of someone else's.
|
|
||||||
</p>
|
|
||||||
<a href="{{ meta.link }}">Know More</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="modals"></div>
|
|
||||||
<div id="popovers"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.csrf_token = '{{ csrf_token }}'
|
|
||||||
document.getElementById('seo-content').style.display = 'none';
|
|
||||||
</script>
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "frappe-ui-frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"serve": "vite preview",
|
|
||||||
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry",
|
|
||||||
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@editorjs/checklist": "^1.6.0",
|
|
||||||
"@editorjs/code": "^2.9.0",
|
|
||||||
"@editorjs/editorjs": "^2.29.0",
|
|
||||||
"@editorjs/embed": "^2.7.0",
|
|
||||||
"@editorjs/header": "^2.8.1",
|
|
||||||
"@editorjs/inline-code": "^1.5.0",
|
|
||||||
"@editorjs/nested-list": "^1.4.2",
|
|
||||||
"@editorjs/paragraph": "^2.11.3",
|
|
||||||
"@editorjs/simple-image": "^1.6.0",
|
|
||||||
"@editorjs/table": "^2.4.2",
|
|
||||||
"ace-builds": "^1.36.2",
|
|
||||||
"chart.js": "^4.4.1",
|
|
||||||
"codemirror-editor-vue3": "^2.8.0",
|
|
||||||
"dayjs": "^1.11.6",
|
|
||||||
"feather-icons": "^4.28.0",
|
|
||||||
"frappe-ui": "^0.1.72",
|
|
||||||
"lucide-vue-next": "^0.383.0",
|
|
||||||
"markdown-it": "^14.0.0",
|
|
||||||
"pinia": "^2.0.33",
|
|
||||||
"socket.io-client": "^4.7.2",
|
|
||||||
"tailwindcss": "^3.3.3",
|
|
||||||
"vue": "^3.4.23",
|
|
||||||
"vue-chartjs": "^5.3.0",
|
|
||||||
"vue-draggable-next": "^2.2.1",
|
|
||||||
"vue-router": "^4.0.12",
|
|
||||||
"vuedraggable": "4.1.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-vue": "^5.0.3",
|
|
||||||
"autoprefixer": "^10.4.2",
|
|
||||||
"postcss": "^8.4.5",
|
|
||||||
"vite": "^5.0.11"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 440 B |
@@ -1,38 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Layout>
|
|
||||||
<router-view />
|
|
||||||
</Layout>
|
|
||||||
<Dialogs />
|
|
||||||
<Toasts />
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { Toasts } from 'frappe-ui'
|
|
||||||
import { Dialogs } from '@/utils/dialogs'
|
|
||||||
import { computed, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { useScreenSize } from './utils/composables'
|
|
||||||
import DesktopLayout from './components/DesktopLayout.vue'
|
|
||||||
import MobileLayout from './components/MobileLayout.vue'
|
|
||||||
import { stopSession } from '@/telemetry'
|
|
||||||
import { init as initTelemetry } from '@/telemetry'
|
|
||||||
import { usersStore } from '@/stores/user'
|
|
||||||
|
|
||||||
const screenSize = useScreenSize()
|
|
||||||
let { userResource } = usersStore()
|
|
||||||
|
|
||||||
const Layout = computed(() => {
|
|
||||||
if (screenSize.width < 640) {
|
|
||||||
return MobileLayout
|
|
||||||
} else {
|
|
||||||
return DesktopLayout
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!userResource.data) return
|
|
||||||
await initTelemetry()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopSession()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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");
|
|
||||||
}
|
|
||||||
@@ -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">
|
|
||||||
{{ comm.sender_full_name }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
{{ timeAgo(comm.communication_date) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="prose prose-sm bg-gray-50 !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-gray-600">
|
|
||||||
{{ __('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>
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
|
|
||||||
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-col overflow-hidden"
|
|
||||||
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
|
|
||||||
>
|
|
||||||
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
|
|
||||||
<div class="flex flex-col" v-if="sidebarSettings.data">
|
|
||||||
<SidebarLink
|
|
||||||
v-for="link in sidebarLinks"
|
|
||||||
:link="link"
|
|
||||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
|
||||||
class="mx-2 my-0.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="sidebarSettings.data?.web_pages?.length || isModerator"
|
|
||||||
class="mt-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between pr-2 cursor-pointer"
|
|
||||||
:class="sidebarStore.isSidebarCollapsed ? 'pl-3' : 'pl-4'"
|
|
||||||
@click="showWebPages = !showWebPages"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!sidebarStore.isSidebarCollapsed"
|
|
||||||
class="flex items-center text-sm text-gray-600 my-1"
|
|
||||||
>
|
|
||||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
|
||||||
<ChevronRight
|
|
||||||
class="h-4 w-4 stroke-1.5 text-gray-900 transition-all duration-300 ease-in-out"
|
|
||||||
:class="{ 'rotate-90': showWebPages }"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span class="ml-2">
|
|
||||||
{{ __('More') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
|
|
||||||
<template #icon>
|
|
||||||
<Plus class="h-4 w-4 text-gray-700 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="sidebarSettings.data?.web_pages?.length"
|
|
||||||
class="flex flex-col transition-all duration-300 ease-in-out"
|
|
||||||
:class="showWebPages ? 'block' : 'hidden'"
|
|
||||||
>
|
|
||||||
<SidebarLink
|
|
||||||
v-for="link in sidebarSettings.data.web_pages"
|
|
||||||
:link="link"
|
|
||||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
|
||||||
class="mx-2 my-0.5"
|
|
||||||
:showControls="isModerator ? true : false"
|
|
||||||
@openModal="openPageModal"
|
|
||||||
@deletePage="deletePage"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SidebarLink
|
|
||||||
:link="{
|
|
||||||
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
|
|
||||||
}"
|
|
||||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
|
||||||
@click="toggleSidebar()"
|
|
||||||
class="m-2"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
|
||||||
<CollapseSidebar
|
|
||||||
class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
|
|
||||||
:class="{
|
|
||||||
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</SidebarLink>
|
|
||||||
</div>
|
|
||||||
<PageModal
|
|
||||||
v-model="showPageModal"
|
|
||||||
v-model:reloadSidebar="sidebarSettings"
|
|
||||||
:page="pageToEdit"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import UserDropdown from '@/components/UserDropdown.vue'
|
|
||||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
|
||||||
import { useStorage } from '@vueuse/core'
|
|
||||||
import { ref, onMounted, inject, watch } from 'vue'
|
|
||||||
import { getSidebarLinks } from '../utils'
|
|
||||||
import { usersStore } from '@/stores/user'
|
|
||||||
import { sessionStore } from '@/stores/session'
|
|
||||||
import { useSidebar } from '@/stores/sidebar'
|
|
||||||
import { useSettings } from '@/stores/settings'
|
|
||||||
import { ChevronRight, Plus } from 'lucide-vue-next'
|
|
||||||
import { createResource, Button } from 'frappe-ui'
|
|
||||||
import PageModal from '@/components/Modals/PageModal.vue'
|
|
||||||
|
|
||||||
const { user, sidebarSettings } = sessionStore()
|
|
||||||
const { userResource } = usersStore()
|
|
||||||
let sidebarStore = useSidebar()
|
|
||||||
const socket = inject('$socket')
|
|
||||||
const unreadCount = ref(0)
|
|
||||||
const sidebarLinks = ref(getSidebarLinks())
|
|
||||||
const showPageModal = ref(false)
|
|
||||||
const isModerator = ref(false)
|
|
||||||
const isInstructor = ref(false)
|
|
||||||
const pageToEdit = ref(null)
|
|
||||||
const showWebPages = ref(false)
|
|
||||||
const settingsStore = useSettings()
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
socket.on('publish_lms_notifications', (data) => {
|
|
||||||
unreadNotifications.reload()
|
|
||||||
})
|
|
||||||
addNotifications()
|
|
||||||
sidebarSettings.reload(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
Object.keys(data).forEach((key) => {
|
|
||||||
if (!parseInt(data[key])) {
|
|
||||||
sidebarLinks.value = sidebarLinks.value.filter(
|
|
||||||
(link) => link.label.toLowerCase().split(' ').join('_') !== key
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const unreadNotifications = createResource({
|
|
||||||
cache: 'Unread Notifications Count',
|
|
||||||
url: 'frappe.client.get_count',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'Notification Log',
|
|
||||||
filters: {
|
|
||||||
for_user: user,
|
|
||||||
read: 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess(data) {
|
|
||||||
unreadCount.value = data
|
|
||||||
sidebarLinks.value = sidebarLinks.value.map((link) => {
|
|
||||||
if (link.label === 'Notifications') {
|
|
||||||
link.count = data
|
|
||||||
}
|
|
||||||
return link
|
|
||||||
})
|
|
||||||
},
|
|
||||||
auto: user ? true : false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const addNotifications = () => {
|
|
||||||
if (user) {
|
|
||||||
sidebarLinks.value.push({
|
|
||||||
label: 'Notifications',
|
|
||||||
icon: 'Bell',
|
|
||||||
to: 'Notifications',
|
|
||||||
activeFor: ['Notifications'],
|
|
||||||
count: unreadCount.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addQuizzes = () => {
|
|
||||||
if (isInstructor.value || isModerator.value) {
|
|
||||||
sidebarLinks.value.push({
|
|
||||||
label: 'Quizzes',
|
|
||||||
icon: 'CircleHelp',
|
|
||||||
to: 'Quizzes',
|
|
||||||
activeFor: ['Quizzes', 'QuizForm'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addPrograms = () => {
|
|
||||||
let activeFor = ['Programs', 'ProgramForm']
|
|
||||||
let index = 1
|
|
||||||
let canAddProgram = false
|
|
||||||
|
|
||||||
if (
|
|
||||||
!isInstructor.value &&
|
|
||||||
!isModerator.value &&
|
|
||||||
settingsStore.learningPaths.data
|
|
||||||
) {
|
|
||||||
sidebarLinks.value = sidebarLinks.value.filter(
|
|
||||||
(link) => link.label !== 'Courses'
|
|
||||||
)
|
|
||||||
activeFor.push('CourseDetail')
|
|
||||||
activeFor.push('Lesson')
|
|
||||||
index = 0
|
|
||||||
canAddProgram = true
|
|
||||||
} else if (isInstructor.value || isModerator.value) {
|
|
||||||
canAddProgram = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canAddProgram) {
|
|
||||||
sidebarLinks.value.splice(index, 0, {
|
|
||||||
label: 'Programs',
|
|
||||||
icon: 'Route',
|
|
||||||
to: 'Programs',
|
|
||||||
activeFor: activeFor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openPageModal = (link) => {
|
|
||||||
showPageModal.value = true
|
|
||||||
pageToEdit.value = link
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletePage = (link) => {
|
|
||||||
createResource({
|
|
||||||
url: 'lms.lms.api.delete_sidebar_item',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
webpage: link.web_page,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}).submit(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
sidebarSettings.reload()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSidebarFromStorage = () => {
|
|
||||||
return useStorage('sidebar_is_collapsed', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(userResource, () => {
|
|
||||||
if (userResource.data) {
|
|
||||||
isModerator.value = userResource.data.is_moderator
|
|
||||||
isInstructor.value = userResource.data.is_instructor
|
|
||||||
addQuizzes()
|
|
||||||
addPrograms()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
|
||||||
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Popover placement="right-start" class="flex w-full">
|
|
||||||
<template #target="{ togglePopover }">
|
|
||||||
<button
|
|
||||||
:class="[
|
|
||||||
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-gray-800 hover:bg-gray-100',
|
|
||||||
]"
|
|
||||||
@click.prevent="togglePopover()"
|
|
||||||
>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<LayoutGrid class="size-4 stroke-1.5" />
|
|
||||||
<span class="whitespace-nowrap">
|
|
||||||
{{ __('Apps') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ChevronRight class="h-4 w-4 stroke-1.5" />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template #body>
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
|
|
||||||
>
|
|
||||||
<div v-for="app in apps.data" key="name">
|
|
||||||
<a
|
|
||||||
:href="app.route"
|
|
||||||
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-3 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<img class="size-8" :src="app.logo" />
|
|
||||||
<div class="text-sm" @click="app.onClick">
|
|
||||||
{{ app.title }}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Popover>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { Popover, createResource } from 'frappe-ui'
|
|
||||||
import { LayoutGrid, ChevronRight } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const apps = createResource({
|
|
||||||
url: 'frappe.apps.get_apps',
|
|
||||||
cache: 'apps',
|
|
||||||
auto: true,
|
|
||||||
transform: (data) => {
|
|
||||||
let _apps = [
|
|
||||||
{
|
|
||||||
name: 'frappe',
|
|
||||||
logo: '/assets/lms/images/desk.png',
|
|
||||||
title: __('Desk'),
|
|
||||||
route: '/app',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
data.map((app) => {
|
|
||||||
if (app.name === 'lms') return
|
|
||||||
_apps.push({
|
|
||||||
name: app.name,
|
|
||||||
logo: app.logo,
|
|
||||||
title: __(app.title),
|
|
||||||
route: app.route,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return _apps
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="text-lg font-semibold mb-4">
|
|
||||||
{{ __('Assessments') }}
|
|
||||||
</div>
|
|
||||||
<Button v-if="canSeeAddButton()" @click="showModal = true">
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="h-4 w-4" />
|
|
||||||
</template>
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div v-if="assessments.data?.length">
|
|
||||||
<ListView
|
|
||||||
:columns="getAssessmentColumns()"
|
|
||||||
:rows="assessments.data"
|
|
||||||
row-key="name"
|
|
||||||
:options="{
|
|
||||||
showTooltip: false,
|
|
||||||
getRowRoute: (row) => getRowRoute(row),
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<ListHeader
|
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
|
||||||
>
|
|
||||||
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
|
|
||||||
<template #prefix="{ item }">
|
|
||||||
<component
|
|
||||||
v-if="item.icon"
|
|
||||||
:is="item.icon"
|
|
||||||
class="h-4 w-4 stroke-1.5 ml-4"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</ListHeaderItem>
|
|
||||||
</ListHeader>
|
|
||||||
<ListRows>
|
|
||||||
<ListRow :row="row" v-for="row in assessments.data">
|
|
||||||
<template #default="{ column, item }">
|
|
||||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
|
||||||
<div>
|
|
||||||
{{ row[column.key] }}
|
|
||||||
</div>
|
|
||||||
</ListRowItem>
|
|
||||||
</template>
|
|
||||||
</ListRow>
|
|
||||||
</ListRows>
|
|
||||||
<ListSelectBanner>
|
|
||||||
<template #actions="{ unselectAll, selections }">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
@click="removeAssessments(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-gray-600">
|
|
||||||
{{ __('No Assessments') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AssessmentModal
|
|
||||||
v-model="showModal"
|
|
||||||
v-model:assessments="assessments"
|
|
||||||
:batch="props.batch"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
ListView,
|
|
||||||
ListRow,
|
|
||||||
ListRows,
|
|
||||||
ListHeader,
|
|
||||||
ListHeaderItem,
|
|
||||||
ListRowItem,
|
|
||||||
ListSelectBanner,
|
|
||||||
createResource,
|
|
||||||
Button,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { inject, ref } from 'vue'
|
|
||||||
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
|
|
||||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
|
||||||
const showModal = ref(false)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
batch: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
rows: {
|
|
||||||
type: Array,
|
|
||||||
},
|
|
||||||
columns: {
|
|
||||||
type: Array,
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({
|
|
||||||
selectable: true,
|
|
||||||
totalCount: 0,
|
|
||||||
rowCount: 0,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const assessments = createResource({
|
|
||||||
url: 'lms.lms.utils.get_assessments',
|
|
||||||
params: {
|
|
||||||
batch: props.batch,
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteAssessments = createResource({
|
|
||||||
url: 'lms.lms.api.delete_documents',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Assessment',
|
|
||||||
documents: values.assessments,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const removeAssessments = (selections, unselectAll) => {
|
|
||||||
deleteAssessments.submit(
|
|
||||||
{ assessments: Array.from(selections) },
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
assessments.reload()
|
|
||||||
unselectAll()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRowRoute = (row) => {
|
|
||||||
if (row.assessment_type == 'LMS Assignment') {
|
|
||||||
if (row.submission) {
|
|
||||||
return {
|
|
||||||
name: 'AssignmentSubmission',
|
|
||||||
params: {
|
|
||||||
assignmentName: row.assessment_name,
|
|
||||||
submissionName: row.submission.name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
name: 'AssignmentSubmission',
|
|
||||||
params: {
|
|
||||||
assignmentName: row.assessment_name,
|
|
||||||
submissionName: 'new',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
name: 'QuizPage',
|
|
||||||
params: {
|
|
||||||
quizID: row.assessment_name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSeeAddButton = () => {
|
|
||||||
return user.data?.is_moderator || user.data?.is_evaluator
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAssessmentColumns = () => {
|
|
||||||
let columns = [
|
|
||||||
{
|
|
||||||
label: 'Assessment',
|
|
||||||
key: 'title',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Type',
|
|
||||||
key: 'assessment_type',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (!user.data?.is_moderator) {
|
|
||||||
columns.push({
|
|
||||||
label: 'Status/Score',
|
|
||||||
key: 'status',
|
|
||||||
align: 'center',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return columns
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<!-- <audio width="100%" controls controlsList="nodownload" class="mb-4">
|
|
||||||
<source :src="encodeURI(file)" type="audio/mp3" />
|
|
||||||
</audio> -->
|
|
||||||
<audio @ended="handleAudioEnd" controlsList="nodownload" class="mb-4">
|
|
||||||
<source :src="encodeURI(file)" type="audio/mp3" />
|
|
||||||
</audio>
|
|
||||||
<div class="flex items-center space-x-2 shadow rounded-lg p-1 w-1/2">
|
|
||||||
<Button variant="ghost" @click="togglePlay">
|
|
||||||
<template #icon>
|
|
||||||
<Play v-if="!isPlaying" class="w-4 h-4 text-gray-900" />
|
|
||||||
<Pause v-else class="w-4 h-4 text-gray-900" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
:max="duration"
|
|
||||||
step="0.1"
|
|
||||||
v-model="currentTime"
|
|
||||||
@input="changeCurrentTime"
|
|
||||||
class="duration-slider w-full h-1"
|
|
||||||
/>
|
|
||||||
<span class="text-xs text-gray-900 font-medium">
|
|
||||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
|
||||||
</span>
|
|
||||||
<Button variant="ghost" @click="toggleMute">
|
|
||||||
<template #icon>
|
|
||||||
<Volume2 v-if="!isMuted" class="w-4 h-4 text-gray-900" />
|
|
||||||
<VolumeX v-else class="w-4 h-4 text-gray-900" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, watch } from 'vue'
|
|
||||||
import { Play, Pause, Volume2, VolumeX } from 'lucide-vue-next'
|
|
||||||
import { Button } from 'frappe-ui'
|
|
||||||
|
|
||||||
const isPlaying = ref(false)
|
|
||||||
const audio = ref(null)
|
|
||||||
let isMuted = ref(false)
|
|
||||||
let currentTime = ref(0)
|
|
||||||
let duration = ref(0)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
file: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
audio.value = document.querySelector('audio')
|
|
||||||
audio.value.onloadedmetadata = () => {
|
|
||||||
duration.value = audio.value.duration
|
|
||||||
}
|
|
||||||
audio.value.ontimeupdate = () => {
|
|
||||||
currentTime.value = audio.value.currentTime
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
const togglePlay = () => {
|
|
||||||
if (audio.value.paused) {
|
|
||||||
audio.value.play()
|
|
||||||
isPlaying.value = true
|
|
||||||
} else {
|
|
||||||
audio.value.pause()
|
|
||||||
isPlaying.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleMute = () => {
|
|
||||||
audio.value.muted = !audio.value.muted
|
|
||||||
isMuted.value = audio.value.muted
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeCurrentTime = () => {
|
|
||||||
audio.value.currentTime = currentTime.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAudioEnd = () => {
|
|
||||||
isPlaying.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTime = (time) => {
|
|
||||||
const minutes = Math.floor(time / 60)
|
|
||||||
const seconds = Math.floor(time % 60)
|
|
||||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(isPlaying, (newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
audio.value.play()
|
|
||||||
} else {
|
|
||||||
audio.value.pause()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.duration-slider {
|
|
||||||
flex: 1;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
background-color: theme('colors.gray.400');
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration-slider::-webkit-slider-thumb {
|
|
||||||
height: 10px;
|
|
||||||
width: 10px;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
background-color: theme('colors.gray.900');
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
|
||||||
input[type='range'] {
|
|
||||||
overflow: hidden;
|
|
||||||
width: 150px;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type='range']::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: -150px 0 0 150px theme('colors.gray.900');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex flex-col shadow hover:bg-gray-100 rounded-md p-4 h-full"
|
|
||||||
style="min-height: 150px"
|
|
||||||
>
|
|
||||||
<div class="text-lg leading-5 font-semibold mb-2">
|
|
||||||
{{ batch.title }}
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
v-if="batch.seat_count && batch.seats_left > 0"
|
|
||||||
theme="green"
|
|
||||||
class="self-start mb-2"
|
|
||||||
>
|
|
||||||
{{ batch.seats_left }}
|
|
||||||
<span v-if="batch.seats_left > 1">{{ __('Seats Left') }}</span
|
|
||||||
><span v-else-if="batch.seats_left == 1">{{ __('Seat Left') }}</span>
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
|
||||||
theme="red"
|
|
||||||
class="self-start mb-2"
|
|
||||||
>
|
|
||||||
{{ __('Sold Out') }}
|
|
||||||
</Badge>
|
|
||||||
<div class="short-introduction text-sm text-gray-700">
|
|
||||||
{{ batch.description }}
|
|
||||||
</div>
|
|
||||||
<div v-if="batch.amount" class="font-semibold mb-4">
|
|
||||||
{{ batch.price }}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col space-y-2 mt-auto">
|
|
||||||
<DateRange
|
|
||||||
:startDate="batch.start_date"
|
|
||||||
:endDate="batch.end_date"
|
|
||||||
class="text-sm text-gray-700"
|
|
||||||
/>
|
|
||||||
<div class="flex items-center text-sm text-gray-700">
|
|
||||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
|
||||||
<span>
|
|
||||||
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="batch.timezone"
|
|
||||||
class="flex items-center text-sm text-gray-700"
|
|
||||||
>
|
|
||||||
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-600" />
|
|
||||||
<span>
|
|
||||||
{{ batch.timezone }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="batch.instructors?.length"
|
|
||||||
class="flex avatar-group overlap mt-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="h-6 mr-1"
|
|
||||||
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
v-for="instructor in batch.instructors"
|
|
||||||
:user="instructor"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<CourseInstructors :instructors="batch.instructors" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { Badge } from 'frappe-ui'
|
|
||||||
import { formatTime } from '../utils'
|
|
||||||
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
|
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
batch: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</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 1rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-group {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-group .avatar {
|
|
||||||
transition: margin 0.1s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-group.overlap .avatar + .avatar {
|
|
||||||
margin-left: calc(-8px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div class="text-xl font-semibold">
|
|
||||||
{{ __('Courses') }}
|
|
||||||
</div>
|
|
||||||
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="h-4 w-4" />
|
|
||||||
</template>
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div v-if="courses.data?.length">
|
|
||||||
<ListView
|
|
||||||
:columns="getCoursesColumns()"
|
|
||||||
:rows="courses.data"
|
|
||||||
row-key="batch_course"
|
|
||||||
:options="{
|
|
||||||
showTooltip: false,
|
|
||||||
getRowRoute: (row) => ({
|
|
||||||
name: 'CourseDetail',
|
|
||||||
params: { courseName: row.name },
|
|
||||||
}),
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<ListHeader
|
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
|
||||||
>
|
|
||||||
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
|
|
||||||
<template #prefix="{ item }">
|
|
||||||
<component
|
|
||||||
v-if="item.icon"
|
|
||||||
:is="item.icon"
|
|
||||||
class="h-4 w-4 stroke-1.5 ml-4"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</ListHeaderItem>
|
|
||||||
</ListHeader>
|
|
||||||
<ListRows>
|
|
||||||
<ListRow :row="row" v-for="row in courses.data">
|
|
||||||
<template #default="{ column, item }">
|
|
||||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
|
||||||
<div>
|
|
||||||
{{ row[column.key] }}
|
|
||||||
</div>
|
|
||||||
</ListRowItem>
|
|
||||||
</template>
|
|
||||||
</ListRow>
|
|
||||||
</ListRows>
|
|
||||||
<ListSelectBanner>
|
|
||||||
<template #actions="{ unselectAll, selections }">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
@click="removeCourses(selections, unselectAll)"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ListSelectBanner>
|
|
||||||
</ListView>
|
|
||||||
</div>
|
|
||||||
<BatchCourseModal
|
|
||||||
v-model="showCourseModal"
|
|
||||||
:batch="batch"
|
|
||||||
v-model:courses="courses"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { ref, inject } from 'vue'
|
|
||||||
import BatchCourseModal from '@/components/Modals/BatchCourseModal.vue'
|
|
||||||
import {
|
|
||||||
createResource,
|
|
||||||
Button,
|
|
||||||
ListHeader,
|
|
||||||
ListHeaderItem,
|
|
||||||
ListSelectBanner,
|
|
||||||
ListRow,
|
|
||||||
ListRows,
|
|
||||||
ListView,
|
|
||||||
ListRowItem,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
|
||||||
import { showToast } from '@/utils'
|
|
||||||
|
|
||||||
const showCourseModal = ref(false)
|
|
||||||
const user = inject('$user')
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
batch: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const courses = createResource({
|
|
||||||
url: 'lms.lms.utils.get_batch_courses',
|
|
||||||
params: {
|
|
||||||
batch: props.batch,
|
|
||||||
},
|
|
||||||
cache: ['batchCourses', props.batchName],
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const openCourseModal = () => {
|
|
||||||
showCourseModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCoursesColumns = () => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Title',
|
|
||||||
key: 'title',
|
|
||||||
width: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Lessons',
|
|
||||||
key: 'lesson_count',
|
|
||||||
align: 'right',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Enrollments',
|
|
||||||
align: 'right',
|
|
||||||
key: 'enrollment_count',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteCourses = createResource({
|
|
||||||
url: 'lms.lms.api.delete_documents',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'Batch Course',
|
|
||||||
documents: values.courses,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const removeCourses = (selections, unselectAll) => {
|
|
||||||
deleteCourses.submit(
|
|
||||||
{
|
|
||||||
courses: Array.from(selections),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
courses.reload()
|
|
||||||
showToast(__('Success'), __('Courses deleted successfully'), 'check')
|
|
||||||
unselectAll()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSeeAddButton = () => {
|
|
||||||
return user.data?.is_moderator || user.data?.is_evaluator
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<UpcomingEvaluations
|
|
||||||
:batch="batch.data.name"
|
|
||||||
:endDate="batch.data.evaluation_end_date"
|
|
||||||
:courses="batch.data.courses"
|
|
||||||
:isStudent="isStudent"
|
|
||||||
/>
|
|
||||||
<Assessments :batch="batch.data.name" />
|
|
||||||
</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>
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="batch.data" class="shadow rounded-md p-5 lg:w-72">
|
|
||||||
<Badge
|
|
||||||
v-if="batch.data.seat_count && seats_left > 0"
|
|
||||||
theme="green"
|
|
||||||
class="self-start mb-2 float-right"
|
|
||||||
>
|
|
||||||
{{ seats_left }} <span v-if="seats_left > 1">{{ __('Seats Left') }}</span
|
|
||||||
><span v-else-if="seats_left == 1">{{ __('Seat Left') }}</span>
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-else-if="batch.data.seat_count && seats_left <= 0"
|
|
||||||
theme="red"
|
|
||||||
class="self-start mb-2 float-right"
|
|
||||||
>
|
|
||||||
{{ __('Sold Out') }}
|
|
||||||
</Badge>
|
|
||||||
<div v-if="batch.data.amount" class="text-lg font-semibold mb-3">
|
|
||||||
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center mb-3">
|
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
|
||||||
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
|
||||||
</div>
|
|
||||||
<DateRange
|
|
||||||
:startDate="batch.data.start_date"
|
|
||||||
:endDate="batch.data.end_date"
|
|
||||||
class="mb-3"
|
|
||||||
/>
|
|
||||||
<div class="flex items-center mb-3">
|
|
||||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
|
||||||
<span>
|
|
||||||
{{ formatTime(batch.data.start_time) }} -
|
|
||||||
{{ formatTime(batch.data.end_time) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="batch.data.timezone" class="flex items-center">
|
|
||||||
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
|
||||||
<span>
|
|
||||||
{{ batch.data.timezone }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<router-link
|
|
||||||
v-if="isModerator || isStudent"
|
|
||||||
:to="{
|
|
||||||
name: 'Batch',
|
|
||||||
params: {
|
|
||||||
batchName: batch.data.name,
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button variant="solid" class="w-full mt-4">
|
|
||||||
<span>
|
|
||||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'Billing',
|
|
||||||
params: {
|
|
||||||
type: 'batch',
|
|
||||||
name: batch.data.name,
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
v-else-if="batch.data.paid_batch && batch.data.seats_left"
|
|
||||||
>
|
|
||||||
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
|
||||||
<span>
|
|
||||||
{{ __('Register Now') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
class="w-full mt-2"
|
|
||||||
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
|
|
||||||
@click="enrollInBatch()"
|
|
||||||
>
|
|
||||||
{{ __('Enroll Now') }}
|
|
||||||
</Button>
|
|
||||||
<router-link
|
|
||||||
v-if="isModerator"
|
|
||||||
:to="{
|
|
||||||
name: 'BatchForm',
|
|
||||||
params: {
|
|
||||||
batchName: batch.data.name,
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button class="w-full mt-2">
|
|
||||||
<span>
|
|
||||||
{{ __('Edit') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { inject, computed } from 'vue'
|
|
||||||
import { Badge, Button, createResource } from 'frappe-ui'
|
|
||||||
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
|
||||||
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
|
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const user = inject('$user')
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
batch: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const enroll = createResource({
|
|
||||||
url: 'lms.lms.utils.enroll_in_batch',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
batch: props.batch.data.name,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const enrollInBatch = () => {
|
|
||||||
if (!user.data) {
|
|
||||||
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
|
|
||||||
}
|
|
||||||
enroll.submit(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
showToast(
|
|
||||||
__('Success'),
|
|
||||||
__('You have been enrolled in this batch'),
|
|
||||||
'check'
|
|
||||||
)
|
|
||||||
router.push({
|
|
||||||
name: 'Batch',
|
|
||||||
params: {
|
|
||||||
batchName: props.batch.data.name,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const seats_left = computed(() => {
|
|
||||||
if (props.batch.data?.seat_count) {
|
|
||||||
return props.batch.data?.seat_count - props.batch.data?.students?.length
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
const isStudent = computed(() => {
|
|
||||||
return props.batch.data?.students?.includes(user.data?.name)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isModerator = computed(() => {
|
|
||||||
return user.data?.is_moderator
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Button class="float-right mb-3" @click="openStudentModal()">
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="h-4 w-4" />
|
|
||||||
</template>
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
<div class="text-lg font-semibold mb-4">
|
|
||||||
{{ __('Students') }}
|
|
||||||
</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-gray-100 p-2"
|
|
||||||
>
|
|
||||||
<ListHeaderItem :item="item" v-for="item in getStudentColumns()">
|
|
||||||
<template #prefix="{ item }">
|
|
||||||
<component
|
|
||||||
v-if="item.icon"
|
|
||||||
:is="item.icon"
|
|
||||||
class="h-4 w-4 stroke-1.5 ml-4"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</ListHeaderItem>
|
|
||||||
</ListHeader>
|
|
||||||
<ListRows>
|
|
||||||
<ListRow :row="row" v-for="row in students.data">
|
|
||||||
<template #default="{ column, item }">
|
|
||||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
|
||||||
<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>
|
|
||||||
{{ 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-gray-600">
|
|
||||||
{{ __('There are no students in this batch.') }}
|
|
||||||
</div>
|
|
||||||
<StudentModal
|
|
||||||
:batch="props.batch"
|
|
||||||
v-model="showStudentModal"
|
|
||||||
v-model:reloadStudents="students"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
createResource,
|
|
||||||
ListHeader,
|
|
||||||
ListHeaderItem,
|
|
||||||
ListSelectBanner,
|
|
||||||
ListRow,
|
|
||||||
ListRows,
|
|
||||||
ListView,
|
|
||||||
ListRowItem,
|
|
||||||
Avatar,
|
|
||||||
Button,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { Trash2, Plus } from 'lucide-vue-next'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
|
||||||
import { showToast } from '@/utils'
|
|
||||||
|
|
||||||
const showStudentModal = ref(false)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
batch: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const students = createResource({
|
|
||||||
url: 'lms.lms.utils.get_batch_students',
|
|
||||||
cache: ['students', props.batch],
|
|
||||||
params: {
|
|
||||||
batch: props.batch,
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const getStudentColumns = () => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Full Name',
|
|
||||||
key: 'full_name',
|
|
||||||
width: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Courses Done',
|
|
||||||
key: 'courses_completed',
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Assessments Done',
|
|
||||||
key: 'assessments_completed',
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Last Active',
|
|
||||||
key: 'last_active',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const openStudentModal = () => {
|
|
||||||
showStudentModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteStudents = createResource({
|
|
||||||
url: 'lms.lms.api.delete_documents',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'Batch Student',
|
|
||||||
documents: values.students,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const removeStudents = (selections, unselectAll) => {
|
|
||||||
deleteStudents.submit(
|
|
||||||
{
|
|
||||||
students: Array.from(selections),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
students.reload()
|
|
||||||
showToast(__('Success'), __('Students deleted successfully'), 'check')
|
|
||||||
unselectAll()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col justify-between min-h-0">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="font-semibold mb-1">
|
|
||||||
{{ __(label) }}
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
v-if="isDirty"
|
|
||||||
:label="__('Not Saved')"
|
|
||||||
variant="subtle"
|
|
||||||
theme="orange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-600">
|
|
||||||
{{ __(description) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-y-auto">
|
|
||||||
<SettingFields :fields="fields" :data="data.data" />
|
|
||||||
<div class="flex flex-row-reverse mt-auto">
|
|
||||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
|
||||||
{{ __('Update') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { createResource, Button, Badge } from 'frappe-ui'
|
|
||||||
import SettingFields from '@/components/SettingFields.vue'
|
|
||||||
import { watch, ref } from 'vue'
|
|
||||||
|
|
||||||
const isDirty = ref(false)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
fields: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const saveSettings = createResource({
|
|
||||||
url: 'frappe.client.set_value',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'Website Settings',
|
|
||||||
name: 'Website Settings',
|
|
||||||
fieldname: values.fields,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const update = () => {
|
|
||||||
let fieldsToSave = {}
|
|
||||||
let imageFields = ['favicon', 'banner_image', 'footer_logo']
|
|
||||||
props.fields.forEach((f) => {
|
|
||||||
if (imageFields.includes(f.name)) {
|
|
||||||
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
|
||||||
} else {
|
|
||||||
fieldsToSave[f.name] = f.value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
saveSettings.submit(
|
|
||||||
{
|
|
||||||
fields: fieldsToSave,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
isDirty.value = false
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(props.data, (newData) => {
|
|
||||||
if (newData && !isDirty.value) {
|
|
||||||
isDirty.value = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col min-h-0">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="text-xl font-semibold mb-1">
|
|
||||||
{{ label }}
|
|
||||||
</div>
|
|
||||||
<Button @click="() => showCategoryForm()">
|
|
||||||
<template #icon>
|
|
||||||
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
|
||||||
<X v-else class="h-3 w-3 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="showForm"
|
|
||||||
class="flex items-center justify-between my-4 space-x-2"
|
|
||||||
>
|
|
||||||
<FormControl
|
|
||||||
ref="categoryInput"
|
|
||||||
v-model="category"
|
|
||||||
:placeholder="__('Category Name')"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<Button @click="addCategory()" variant="subtle">
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-y-scroll">
|
|
||||||
<div class="text-base divide-y">
|
|
||||||
<FormControl
|
|
||||||
:value="cat.category"
|
|
||||||
type="text"
|
|
||||||
v-for="cat in categories.data"
|
|
||||||
class="form-control"
|
|
||||||
@change.stop="(e) => update(cat.name, e.target.value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
createListResource,
|
|
||||||
createResource,
|
|
||||||
debounce,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { Plus, X } from 'lucide-vue-next'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const showForm = ref(false)
|
|
||||||
const category = ref(null)
|
|
||||||
const categoryInput = ref(null)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const categories = createListResource({
|
|
||||||
doctype: 'LMS Category',
|
|
||||||
fields: ['name', 'category'],
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const newCategory = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'LMS Category',
|
|
||||||
category: category.value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const addCategory = () => {
|
|
||||||
newCategory.submit(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
categories.reload()
|
|
||||||
category.value = null
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const showCategoryForm = () => {
|
|
||||||
showForm.value = !showForm.value
|
|
||||||
setTimeout(() => {
|
|
||||||
categoryInput.value.$el.querySelector('input').focus()
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateCategory = createResource({
|
|
||||||
url: 'frappe.client.rename_doc',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Category',
|
|
||||||
old_name: values.name,
|
|
||||||
new_name: values.category,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const update = (name, value) => {
|
|
||||||
updateCategory.submit(
|
|
||||||
{
|
|
||||||
name: name,
|
|
||||||
category: value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
categories.reload()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.form-control input {
|
|
||||||
padding: 1.25rem 0;
|
|
||||||
border-color: transparent;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control input:focus {
|
|
||||||
outline: transparent;
|
|
||||||
background: white;
|
|
||||||
box-shadow: none;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control input:hover {
|
|
||||||
outline: transparent;
|
|
||||||
background: white;
|
|
||||||
box-shadow: none;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
|
||||||
<span>
|
|
||||||
{{ getFormattedDateRange(props.startDate, props.endDate) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { Calendar } from 'lucide-vue-next'
|
|
||||||
import { getFormattedDateRange } from '@/utils'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
startDate: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
|
|
||||||
<Popover class="w-full" v-model:show="showOptions">
|
|
||||||
<template #target="{ open: openPopover, togglePopover }">
|
|
||||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
|
||||||
<div class="w-full">
|
|
||||||
<button
|
|
||||||
class="flex w-full items-center justify-between focus:outline-none"
|
|
||||||
:class="inputClasses"
|
|
||||||
@click="() => togglePopover()"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<slot name="prefix" />
|
|
||||||
<span
|
|
||||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
|
||||||
v-if="selectedValue"
|
|
||||||
>
|
|
||||||
{{ displayValue(selectedValue) }}
|
|
||||||
</span>
|
|
||||||
<span class="text-base leading-5 text-gray-500" v-else>
|
|
||||||
{{ placeholder || '' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</slot>
|
|
||||||
</template>
|
|
||||||
<template #body="{ isOpen }">
|
|
||||||
<div v-show="isOpen">
|
|
||||||
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
|
||||||
<div class="relative px-1.5 pt-0.5">
|
|
||||||
<ComboboxInput
|
|
||||||
ref="search"
|
|
||||||
class="form-input w-full"
|
|
||||||
type="text"
|
|
||||||
@change="
|
|
||||||
(e) => {
|
|
||||||
query = e.target.value
|
|
||||||
}
|
|
||||||
"
|
|
||||||
:value="query"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="Search"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
|
||||||
@click="selectedValue = null"
|
|
||||||
>
|
|
||||||
<X class="h-4 w-4 stroke-1.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<ComboboxOptions
|
|
||||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
|
||||||
static
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mt-1.5"
|
|
||||||
v-for="group in groups"
|
|
||||||
:key="group.key"
|
|
||||||
v-show="group.items.length > 0"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="group.group && !group.hideLabel"
|
|
||||||
class="px-2.5 py-1.5 text-sm font-medium text-gray-500"
|
|
||||||
>
|
|
||||||
{{ group.group }}
|
|
||||||
</div>
|
|
||||||
<ComboboxOption
|
|
||||||
as="template"
|
|
||||||
v-for="option in group.items"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option"
|
|
||||||
v-slot="{ active, selected }"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
:class="[
|
|
||||||
'flex items-center rounded px-2.5 py-2 text-base',
|
|
||||||
{ 'bg-gray-100': active },
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<slot
|
|
||||||
name="item-prefix"
|
|
||||||
v-bind="{ active, selected, option }"
|
|
||||||
/>
|
|
||||||
<slot
|
|
||||||
name="item-label"
|
|
||||||
v-bind="{ active, selected, option }"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col space-y-1">
|
|
||||||
<div>
|
|
||||||
{{ option.label }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="option.description"
|
|
||||||
class="text-xs text-gray-700"
|
|
||||||
v-html="option.description"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</slot>
|
|
||||||
</li>
|
|
||||||
</ComboboxOption>
|
|
||||||
</div>
|
|
||||||
<li
|
|
||||||
v-if="groups.length == 0"
|
|
||||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-gray-600"
|
|
||||||
>
|
|
||||||
No results found
|
|
||||||
</li>
|
|
||||||
</ComboboxOptions>
|
|
||||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
|
||||||
<slot
|
|
||||||
name="footer"
|
|
||||||
v-bind="{ value: search?.el._value, close }"
|
|
||||||
></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Popover>
|
|
||||||
</Combobox>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
Combobox,
|
|
||||||
ComboboxInput,
|
|
||||||
ComboboxOptions,
|
|
||||||
ComboboxOption,
|
|
||||||
} from '@headlessui/vue'
|
|
||||||
import { Popover, Button } from 'frappe-ui'
|
|
||||||
import { ChevronDown, X } from 'lucide-vue-next'
|
|
||||||
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
type: String,
|
|
||||||
default: 'md',
|
|
||||||
},
|
|
||||||
variant: {
|
|
||||||
type: String,
|
|
||||||
default: 'subtle',
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
filterable: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
|
||||||
|
|
||||||
const query = ref('')
|
|
||||||
const showOptions = ref(false)
|
|
||||||
const search = ref(null)
|
|
||||||
|
|
||||||
const attrs = useAttrs()
|
|
||||||
const slots = useSlots()
|
|
||||||
|
|
||||||
const valuePropPassed = computed(() => 'value' in attrs)
|
|
||||||
|
|
||||||
const selectedValue = computed({
|
|
||||||
get() {
|
|
||||||
return valuePropPassed.value ? attrs.value : props.modelValue
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
query.value = ''
|
|
||||||
if (val) {
|
|
||||||
showOptions.value = false
|
|
||||||
}
|
|
||||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
showOptions.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = computed(() => {
|
|
||||||
if (!props.options || props.options.length == 0) return []
|
|
||||||
|
|
||||||
let groups = props.options[0]?.group
|
|
||||||
? props.options
|
|
||||||
: [{ group: '', items: props.options }]
|
|
||||||
|
|
||||||
return groups
|
|
||||||
.map((group, i) => {
|
|
||||||
return {
|
|
||||||
key: i,
|
|
||||||
group: group.group,
|
|
||||||
hideLabel: group.hideLabel || false,
|
|
||||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((group) => group.items.length > 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
function filterOptions(options) {
|
|
||||||
if (!query.value) {
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
return options.filter((option) => {
|
|
||||||
let searchTexts = [option.label, option.value]
|
|
||||||
return searchTexts.some((text) =>
|
|
||||||
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayValue(option) {
|
|
||||||
if (typeof option === 'string') {
|
|
||||||
let allOptions = groups.value.flatMap((group) => group.items)
|
|
||||||
let selectedOption = allOptions.find((o) => o.value === option)
|
|
||||||
return selectedOption?.label || option
|
|
||||||
}
|
|
||||||
return option?.label
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(query, (q) => {
|
|
||||||
emit('update:query', q)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(showOptions, (val) => {
|
|
||||||
if (val) {
|
|
||||||
nextTick(() => {
|
|
||||||
search.value.el.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const textColor = computed(() => {
|
|
||||||
return props.disabled ? 'text-gray-600' : 'text-gray-800'
|
|
||||||
})
|
|
||||||
|
|
||||||
const inputClasses = computed(() => {
|
|
||||||
let sizeClasses = {
|
|
||||||
sm: 'text-base rounded h-7',
|
|
||||||
md: 'text-base rounded h-8',
|
|
||||||
lg: 'text-lg rounded-md h-10',
|
|
||||||
xl: 'text-xl rounded-md h-10',
|
|
||||||
}[props.size]
|
|
||||||
|
|
||||||
let paddingClasses = {
|
|
||||||
sm: 'py-1.5 px-2',
|
|
||||||
md: 'py-1.5 px-2.5',
|
|
||||||
lg: 'py-1.5 px-3',
|
|
||||||
xl: 'py-1.5 px-3',
|
|
||||||
}[props.size]
|
|
||||||
|
|
||||||
let variant = props.disabled ? 'disabled' : props.variant
|
|
||||||
let variantClasses = {
|
|
||||||
subtle:
|
|
||||||
'border border-gray-100 bg-gray-100 placeholder-gray-500 hover:border-gray-200 hover:bg-gray-200 focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
|
|
||||||
outline:
|
|
||||||
'border border-gray-300 bg-white placeholder-gray-500 hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
|
|
||||||
disabled: [
|
|
||||||
'border bg-gray-50 placeholder-gray-400',
|
|
||||||
props.variant === 'outline' ? 'border-gray-300' : 'border-transparent',
|
|
||||||
],
|
|
||||||
}[variant]
|
|
||||||
|
|
||||||
return [
|
|
||||||
sizeClasses,
|
|
||||||
paddingClasses,
|
|
||||||
variantClasses,
|
|
||||||
textColor.value,
|
|
||||||
'transition-colors w-full',
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
defineExpose({ query })
|
|
||||||
</script>
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="editor flex flex-col gap-1"
|
|
||||||
:style="{
|
|
||||||
height: height,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span class="text-xs" v-if="label">
|
|
||||||
{{ label }}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
ref="editor"
|
|
||||||
class="h-auto flex-1 overflow-hidden overscroll-none !rounded border border-outline-gray-2 bg-surface-gray-2 dark:bg-gray-900"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="mt-1 text-xs text-gray-600"
|
|
||||||
v-show="description"
|
|
||||||
v-html="description"
|
|
||||||
></span>
|
|
||||||
<Button
|
|
||||||
v-if="showSaveButton"
|
|
||||||
@click="emit('save', aceEditor?.getValue())"
|
|
||||||
class="mt-3"
|
|
||||||
>
|
|
||||||
{{ __('Save') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useDark } from '@vueuse/core'
|
|
||||||
import ace from 'ace-builds'
|
|
||||||
import 'ace-builds/src-min-noconflict/ext-searchbox'
|
|
||||||
import 'ace-builds/src-min-noconflict/theme-chrome'
|
|
||||||
import 'ace-builds/src-min-noconflict/theme-twilight'
|
|
||||||
import { PropType, onMounted, ref, watch } from 'vue'
|
|
||||||
import { Button } from 'frappe-ui'
|
|
||||||
|
|
||||||
const isDark = useDark({
|
|
||||||
attribute: 'data-theme',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: [Object, String, Array],
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: String as PropType<'JSON' | 'HTML' | 'Python' | 'JavaScript' | 'CSS'>,
|
|
||||||
default: 'JSON',
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
readonly: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
type: String,
|
|
||||||
default: '250px',
|
|
||||||
},
|
|
||||||
showLineNumbers: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
autofocus: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
showSaveButton: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['save', 'update:modelValue'])
|
|
||||||
const editor = ref<HTMLElement | null>(null)
|
|
||||||
let aceEditor = null as ace.Ace.Editor | null
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
setupEditor()
|
|
||||||
})
|
|
||||||
|
|
||||||
const setupEditor = () => {
|
|
||||||
aceEditor = ace.edit(editor.value as HTMLElement)
|
|
||||||
resetEditor(props.modelValue as string, true)
|
|
||||||
aceEditor.setReadOnly(props.readonly)
|
|
||||||
aceEditor.setOptions({
|
|
||||||
fontSize: '12px',
|
|
||||||
useWorker: false,
|
|
||||||
showGutter: props.showLineNumbers,
|
|
||||||
wrap: props.showLineNumbers,
|
|
||||||
})
|
|
||||||
if (props.type === 'CSS') {
|
|
||||||
import('ace-builds/src-noconflict/mode-css').then(() => {
|
|
||||||
aceEditor?.session.setMode('ace/mode/css')
|
|
||||||
})
|
|
||||||
} else if (props.type === 'JavaScript') {
|
|
||||||
import('ace-builds/src-noconflict/mode-javascript').then(() => {
|
|
||||||
aceEditor?.session.setMode('ace/mode/javascript')
|
|
||||||
})
|
|
||||||
} else if (props.type === 'Python') {
|
|
||||||
import('ace-builds/src-noconflict/mode-python').then(() => {
|
|
||||||
aceEditor?.session.setMode('ace/mode/python')
|
|
||||||
})
|
|
||||||
} else if (props.type === 'JSON') {
|
|
||||||
import('ace-builds/src-noconflict/mode-json').then(() => {
|
|
||||||
aceEditor?.session.setMode('ace/mode/json')
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
import('ace-builds/src-noconflict/mode-html').then(() => {
|
|
||||||
aceEditor?.session.setMode('ace/mode/html')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
aceEditor.on('blur', () => {
|
|
||||||
try {
|
|
||||||
let value = aceEditor?.getValue() || ''
|
|
||||||
if (props.type === 'JSON') {
|
|
||||||
value = JSON.parse(value)
|
|
||||||
}
|
|
||||||
if (value === props.modelValue) return
|
|
||||||
if (!props.showSaveButton && !props.readonly) {
|
|
||||||
emit('update:modelValue', value)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getModelValue = () => {
|
|
||||||
let value = props.modelValue || ''
|
|
||||||
try {
|
|
||||||
if (props.type === 'JSON' || typeof value === 'object') {
|
|
||||||
value = JSON.stringify(value, null, 2)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
return value as string
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetEditor(value: string, resetHistory = false) {
|
|
||||||
value = getModelValue()
|
|
||||||
aceEditor?.setValue(value)
|
|
||||||
aceEditor?.clearSelection()
|
|
||||||
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
|
|
||||||
props.autofocus && aceEditor?.focus()
|
|
||||||
if (resetHistory) {
|
|
||||||
aceEditor?.session.getUndoManager().reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(isDark, () => {
|
|
||||||
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.type,
|
|
||||||
() => {
|
|
||||||
setupEditor()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
() => {
|
|
||||||
resetEditor(props.modelValue as string)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
defineExpose({ resetEditor })
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
.editor .ace_editor {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 5px;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
|
||||||
.editor :deep(.ace_scrollbar-h) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.editor :deep(.ace_search) {
|
|
||||||
@apply dark:bg-gray-800 dark:text-gray-200;
|
|
||||||
@apply dark:border-gray-800;
|
|
||||||
}
|
|
||||||
.editor :deep(.ace_searchbtn) {
|
|
||||||
@apply dark:bg-gray-800 dark:text-gray-200;
|
|
||||||
@apply dark:border-gray-800;
|
|
||||||
}
|
|
||||||
.editor :deep(.ace_button) {
|
|
||||||
@apply dark:bg-gray-800 dark:text-gray-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor :deep(.ace_search_field) {
|
|
||||||
@apply dark:bg-gray-900 dark:text-gray-200;
|
|
||||||
@apply dark:border-gray-800;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<label class="block text-xs text-gray-600">
|
|
||||||
{{ label }}
|
|
||||||
</label>
|
|
||||||
<div class="w-full">
|
|
||||||
<Popover>
|
|
||||||
<template #target="{ togglePopover }">
|
|
||||||
<button
|
|
||||||
@click="openPopover(togglePopover)"
|
|
||||||
class="flex w-full items-center space-x-2 focus:outline-none bg-gray-100 rounded h-7 py-1.5 px-2 hover:bg-gray-200 focus:bg-white border border-gray-100 hover:border-gray-200 focus:border-gray-500"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
v-if="selectedIcon"
|
|
||||||
class="w-4 h-4 text-gray-700 stroke-1.5"
|
|
||||||
:is="icons[selectedIcon]"
|
|
||||||
/>
|
|
||||||
<component
|
|
||||||
v-else
|
|
||||||
class="w-4 h-4 text-gray-700 stroke-1.5"
|
|
||||||
:is="icons.Folder"
|
|
||||||
/>
|
|
||||||
<span v-if="selectedIcon">
|
|
||||||
{{ selectedIcon }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-gray-600">
|
|
||||||
{{ __('Choose an icon') }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template #body-main="{ close, isOpen }" class="w-full">
|
|
||||||
<div class="p-3 max-h-56 overflow-auto w-full">
|
|
||||||
<FormControl
|
|
||||||
ref="search"
|
|
||||||
v-model="iconQuery"
|
|
||||||
:placeholder="__('Search for an icon')"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
<div class="grid grid-cols-10 gap-4 mt-4">
|
|
||||||
<div v-for="(iconComponent, iconName) in filteredIcons">
|
|
||||||
<component
|
|
||||||
:is="iconComponent"
|
|
||||||
class="h-4 w-4 stroke-1.5 text-gray-700 cursor-pointer"
|
|
||||||
@click="setIcon(iconName, close)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { FormControl, Popover } from 'frappe-ui'
|
|
||||||
import * as icons from 'lucide-vue-next'
|
|
||||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
|
||||||
|
|
||||||
const iconQuery = ref('')
|
|
||||||
const selectedIcon = ref('')
|
|
||||||
const search = ref(null)
|
|
||||||
const emit = defineEmits(['update:modelValue', 'change'])
|
|
||||||
|
|
||||||
const iconArray = ref(
|
|
||||||
Object.keys(icons)
|
|
||||||
.sort(() => 0.5 - Math.random())
|
|
||||||
.slice(0, 100)
|
|
||||||
.reduce((result, key) => {
|
|
||||||
result[key] = icons[key]
|
|
||||||
return result
|
|
||||||
}, {})
|
|
||||||
)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: 'Icon',
|
|
||||||
},
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
selectedIcon.value = props.modelValue
|
|
||||||
})
|
|
||||||
|
|
||||||
const setIcon = (icon, close) => {
|
|
||||||
emit('update:modelValue', icon)
|
|
||||||
selectedIcon.value = icon
|
|
||||||
iconQuery.value = ''
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredIcons = computed(() => {
|
|
||||||
if (!iconQuery.value) {
|
|
||||||
return iconArray.value
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(icons)
|
|
||||||
.filter((icon) =>
|
|
||||||
icon.toLowerCase().includes(iconQuery.value.toLowerCase())
|
|
||||||
)
|
|
||||||
.reduce((result, key) => {
|
|
||||||
result[key] = icons[key]
|
|
||||||
return result
|
|
||||||
}, {})
|
|
||||||
})
|
|
||||||
|
|
||||||
const openPopover = (togglePopover) => {
|
|
||||||
togglePopover()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<label class="block" :class="labelClasses" v-if="attrs.label">
|
|
||||||
{{ attrs.label }}
|
|
||||||
<span class="text-red-500" v-if="attrs.required">*</span>
|
|
||||||
</label>
|
|
||||||
<Autocomplete
|
|
||||||
ref="autocomplete"
|
|
||||||
:options="options.data"
|
|
||||||
v-model="value"
|
|
||||||
:size="attrs.size || 'sm'"
|
|
||||||
:variant="attrs.variant"
|
|
||||||
:placeholder="attrs.placeholder"
|
|
||||||
:filterable="false"
|
|
||||||
>
|
|
||||||
<template #target="{ open, togglePopover }">
|
|
||||||
<slot name="target" v-bind="{ open, togglePopover }" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #prefix>
|
|
||||||
<slot name="prefix" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #item-prefix="{ active, selected, option }">
|
|
||||||
<slot name="item-prefix" v-bind="{ active, selected, option }" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #item-label="{ active, selected, option }">
|
|
||||||
<slot name="item-label" v-bind="{ active, selected, option }" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-if="attrs.onCreate" #footer="{ value, close }">
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="w-full !justify-start"
|
|
||||||
label="Create New"
|
|
||||||
@click="attrs.onCreate(value, close)"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Autocomplete>
|
|
||||||
<p v-if="description" class="text-sm text-gray-600">{{ description }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
|
||||||
import { watchDebounced } from '@vueuse/core'
|
|
||||||
import { createResource, Button } from 'frappe-ui'
|
|
||||||
import { Plus } from 'lucide-vue-next'
|
|
||||||
import { useAttrs, computed, ref } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
doctype: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
filters: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'change'])
|
|
||||||
|
|
||||||
const attrs = useAttrs()
|
|
||||||
|
|
||||||
const valuePropPassed = computed(() => 'value' in attrs)
|
|
||||||
|
|
||||||
const value = computed({
|
|
||||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
|
||||||
set: (val) => {
|
|
||||||
return (
|
|
||||||
val?.value &&
|
|
||||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const autocomplete = ref(null)
|
|
||||||
const text = ref('')
|
|
||||||
|
|
||||||
watchDebounced(
|
|
||||||
() => autocomplete.value?.query,
|
|
||||||
(val) => {
|
|
||||||
val = val || ''
|
|
||||||
if (text.value === val) return
|
|
||||||
text.value = val
|
|
||||||
reload(val)
|
|
||||||
},
|
|
||||||
{ debounce: 300, immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watchDebounced(
|
|
||||||
() => props.doctype,
|
|
||||||
() => reload(''),
|
|
||||||
{ debounce: 300, immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const options = createResource({
|
|
||||||
url: 'frappe.desk.search.search_link',
|
|
||||||
cache: [props.doctype, text.value],
|
|
||||||
method: 'POST',
|
|
||||||
auto: true,
|
|
||||||
params: {
|
|
||||||
txt: text.value,
|
|
||||||
doctype: props.doctype,
|
|
||||||
filters: props.filters,
|
|
||||||
},
|
|
||||||
transform: (data) => {
|
|
||||||
return data.map((option) => {
|
|
||||||
return {
|
|
||||||
label: option.label || option.value,
|
|
||||||
value: option.value,
|
|
||||||
description: option.description,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function reload(val) {
|
|
||||||
options.update({
|
|
||||||
params: {
|
|
||||||
txt: val,
|
|
||||||
doctype: props.doctype,
|
|
||||||
filters: props.filters,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
options.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelClasses = computed(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
sm: 'text-xs',
|
|
||||||
md: 'text-base',
|
|
||||||
}[attrs.size || 'sm'],
|
|
||||||
'text-gray-600',
|
|
||||||
]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<label class="block mb-1" :class="labelClasses" v-if="label">
|
|
||||||
{{ label }}
|
|
||||||
<span class="text-red-500" v-if="required">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="grid grid-cols-3 gap-1">
|
|
||||||
<Button
|
|
||||||
ref="emails"
|
|
||||||
v-for="value in values"
|
|
||||||
:key="value"
|
|
||||||
:label="value"
|
|
||||||
theme="gray"
|
|
||||||
variant="subtle"
|
|
||||||
class="rounded-md"
|
|
||||||
@keydown.delete.capture.stop="removeLastValue"
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
<div class="">
|
|
||||||
<Combobox v-model="selectedValue" nullable>
|
|
||||||
<Popover class="w-full" v-model:show="showOptions">
|
|
||||||
<template #target="{ togglePopover }">
|
|
||||||
<ComboboxInput
|
|
||||||
ref="search"
|
|
||||||
class="search-input form-input w-full focus-visible:!ring-0"
|
|
||||||
type="text"
|
|
||||||
:value="query"
|
|
||||||
@change="
|
|
||||||
(e) => {
|
|
||||||
query = e.target.value
|
|
||||||
showOptions = true
|
|
||||||
}
|
|
||||||
"
|
|
||||||
autocomplete="off"
|
|
||||||
@focus="() => togglePopover()"
|
|
||||||
@keydown.delete.capture.stop="removeLastValue"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template #body="{ isOpen }">
|
|
||||||
<div v-show="isOpen">
|
|
||||||
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
|
||||||
<ComboboxOptions
|
|
||||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
|
||||||
static
|
|
||||||
>
|
|
||||||
<ComboboxOption
|
|
||||||
v-for="option in options"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option"
|
|
||||||
v-slot="{ active }"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
:class="[
|
|
||||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
|
||||||
{ 'bg-gray-100': active },
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-1 p-1">
|
|
||||||
<div class="text-base font-medium">
|
|
||||||
{{ option.description }}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-600">
|
|
||||||
{{ option.value }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ComboboxOption>
|
|
||||||
</ComboboxOptions>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Popover>
|
|
||||||
</Combobox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
Combobox,
|
|
||||||
ComboboxInput,
|
|
||||||
ComboboxOptions,
|
|
||||||
ComboboxOption,
|
|
||||||
} from '@headlessui/vue'
|
|
||||||
import { createResource, Popover, Button } from 'frappe-ui'
|
|
||||||
import { ref, computed, nextTick } from 'vue'
|
|
||||||
import { watchDebounced } from '@vueuse/core'
|
|
||||||
import { X } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
type: String,
|
|
||||||
default: 'sm',
|
|
||||||
},
|
|
||||||
doctype: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
filters: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
validate: {
|
|
||||||
type: Function,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
errorMessage: {
|
|
||||||
type: Function,
|
|
||||||
default: (value) => `${value} is an Invalid value`,
|
|
||||||
},
|
|
||||||
required: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const values = defineModel()
|
|
||||||
|
|
||||||
const emails = ref([])
|
|
||||||
const search = ref(null)
|
|
||||||
const error = ref(null)
|
|
||||||
const query = ref('')
|
|
||||||
const text = ref('')
|
|
||||||
const showOptions = ref(false)
|
|
||||||
|
|
||||||
const selectedValue = computed({
|
|
||||||
get: () => query.value || '',
|
|
||||||
set: (val) => {
|
|
||||||
query.value = ''
|
|
||||||
if (val) {
|
|
||||||
showOptions.value = false
|
|
||||||
}
|
|
||||||
val?.value && addValue(val.value)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
watchDebounced(
|
|
||||||
query,
|
|
||||||
(val) => {
|
|
||||||
val = val || ''
|
|
||||||
if (text.value === val) return
|
|
||||||
text.value = val
|
|
||||||
reload(val)
|
|
||||||
},
|
|
||||||
{ debounce: 300, immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const filterOptions = createResource({
|
|
||||||
url: 'frappe.desk.search.search_link',
|
|
||||||
method: 'POST',
|
|
||||||
cache: [text.value, props.doctype],
|
|
||||||
auto: true,
|
|
||||||
params: {
|
|
||||||
txt: text.value,
|
|
||||||
doctype: props.doctype,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const options = computed(() => {
|
|
||||||
return filterOptions.data || []
|
|
||||||
})
|
|
||||||
|
|
||||||
function reload(val) {
|
|
||||||
filterOptions.update({
|
|
||||||
params: {
|
|
||||||
txt: val,
|
|
||||||
doctype: props.doctype,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
filterOptions.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
const addValue = (value) => {
|
|
||||||
error.value = null
|
|
||||||
if (value) {
|
|
||||||
const splitValues = value.split(',')
|
|
||||||
splitValues.forEach((value) => {
|
|
||||||
value = value.trim()
|
|
||||||
if (value) {
|
|
||||||
// check if value is not already in the values array
|
|
||||||
if (!values.value?.includes(value)) {
|
|
||||||
// check if value is valid
|
|
||||||
if (value && props.validate && !props.validate(value)) {
|
|
||||||
error.value = props.errorMessage(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// add value to values array
|
|
||||||
if (!values.value) {
|
|
||||||
values.value = [value]
|
|
||||||
} else {
|
|
||||||
values.value.push(value)
|
|
||||||
}
|
|
||||||
value = value.replace(value, '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
!error.value && (value = '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeValue = (value) => {
|
|
||||||
values.value = values.value.filter((v) => v !== value)
|
|
||||||
}
|
|
||||||
|
|
||||||
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-gray-600',
|
|
||||||
]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label class="block text-xs text-gray-600" v-if="props.label">
|
|
||||||
{{ props.label }}
|
|
||||||
</label>
|
|
||||||
<div class="flex text-center">
|
|
||||||
<div
|
|
||||||
v-for="index in 5"
|
|
||||||
@mouseover="hoveredRating = index"
|
|
||||||
@mouseleave="hoveredRating = 0"
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
class="fill-gray-400 text-gray-50 stroke-1 mr-1 cursor-pointer"
|
|
||||||
:class="iconClasses(index)"
|
|
||||||
@click="markRating(index)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { Star } from 'lucide-vue-next'
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
modelValue: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
type: String,
|
|
||||||
default: 'md',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const iconClasses = (index) => {
|
|
||||||
let classes = [
|
|
||||||
{
|
|
||||||
sm: 'size-4',
|
|
||||||
md: 'size-5',
|
|
||||||
lg: 'size-6',
|
|
||||||
xl: 'size-7',
|
|
||||||
}[props.size],
|
|
||||||
]
|
|
||||||
if (index <= hoveredRating.value && index > rating.value) {
|
|
||||||
classes.push('fill-yellow-200')
|
|
||||||
} else if (index <= rating.value) {
|
|
||||||
classes.push('fill-yellow-500')
|
|
||||||
}
|
|
||||||
return classes.join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
|
||||||
const rating = ref(props.modelValue)
|
|
||||||
const hoveredRating = ref(0)
|
|
||||||
|
|
||||||
let emitChange = (value) => {
|
|
||||||
emit('update:modelValue', value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function markRating(index) {
|
|
||||||
emitChange(index)
|
|
||||||
rating.value = index
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(newVal) => {
|
|
||||||
rating.value = newVal
|
|
||||||
}
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="course.title"
|
|
||||||
class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
|
|
||||||
style="min-height: 350px"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="course-image"
|
|
||||||
:class="{ 'default-image': !course.image }"
|
|
||||||
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center flex-wrap space-x-1 relative top-4 px-2 w-fit"
|
|
||||||
>
|
|
||||||
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
|
|
||||||
{{ __('Featured') }}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant="subtle"
|
|
||||||
theme="gray"
|
|
||||||
size="md"
|
|
||||||
v-for="tag in course.tags"
|
|
||||||
>
|
|
||||||
{{ tag }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div v-if="!course.image" class="image-placeholder">
|
|
||||||
{{ course.title[0] }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col flex-auto p-4">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div v-if="course.lessons">
|
|
||||||
<Tooltip :text="__('Lessons')">
|
|
||||||
<span class="flex items-center">
|
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
|
||||||
{{ course.lessons }}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="course.enrollments">
|
|
||||||
<Tooltip :text="__('Enrolled Students')">
|
|
||||||
<span class="flex items-center">
|
|
||||||
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
|
||||||
{{ course.enrollments }}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="course.rating">
|
|
||||||
<Tooltip :text="__('Average Rating')">
|
|
||||||
<span class="flex items-center">
|
|
||||||
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
|
||||||
{{ course.rating }}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="course.status != 'Approved'">
|
|
||||||
<Badge
|
|
||||||
variant="solid"
|
|
||||||
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{{ course.status }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xl font-semibold leading-6">
|
|
||||||
{{ course.title }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="short-introduction text-gray-700 text-sm">
|
|
||||||
{{ course.short_introduction }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
v-if="user && course.membership"
|
|
||||||
:progress="course.membership.progress"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="user && course.membership" class="text-sm mb-4">
|
|
||||||
{{ Math.ceil(course.membership.progress) }}% completed
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between mt-auto">
|
|
||||||
<div class="flex avatar-group overlap">
|
|
||||||
<div
|
|
||||||
class="h-6 mr-1"
|
|
||||||
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
v-for="instructor in course.instructors"
|
|
||||||
:user="instructor"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<CourseInstructors :instructors="course.instructors" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="font-semibold">
|
|
||||||
{{ course.price }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
import { sessionStore } from '@/stores/session'
|
|
||||||
import { Badge, Tooltip } from 'frappe-ui'
|
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
|
||||||
|
|
||||||
const { user } = sessionStore()
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
course: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.course-image {
|
|
||||||
height: 168px;
|
|
||||||
width: 100%;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-card-pills {
|
|
||||||
background: #ffffff;
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
padding: 3.5px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 0.011em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 600;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.default-image {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
background-color: theme('colors.green.100');
|
|
||||||
color: theme('colors.green.600');
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-group {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-group .avatar {
|
|
||||||
transition: margin 0.1s ease-in-out;
|
|
||||||
}
|
|
||||||
.image-placeholder {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
font-size: 5rem;
|
|
||||||
color: theme('colors.gray.700');
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.avatar-group.overlap .avatar + .avatar {
|
|
||||||
margin-left: calc(-8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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.25rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="shadow rounded-md min-w-80">
|
|
||||||
<iframe
|
|
||||||
v-if="course.data.video_link"
|
|
||||||
:src="video_link"
|
|
||||||
class="rounded-t-md min-h-56 w-full"
|
|
||||||
/>
|
|
||||||
<div class="p-5">
|
|
||||||
<div v-if="course.data.price" class="text-2xl font-semibold mb-3">
|
|
||||||
{{ course.data.price }}
|
|
||||||
</div>
|
|
||||||
<router-link
|
|
||||||
v-if="course.data.membership"
|
|
||||||
:to="{
|
|
||||||
name: 'Lesson',
|
|
||||||
params: {
|
|
||||||
courseName: course.name,
|
|
||||||
chapterNumber: course.data.current_lesson
|
|
||||||
? course.data.current_lesson.split('-')[0]
|
|
||||||
: 1,
|
|
||||||
lessonNumber: course.data.current_lesson
|
|
||||||
? course.data.current_lesson.split('-')[1]
|
|
||||||
: 1,
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button variant="solid" size="md" class="w-full">
|
|
||||||
<span>
|
|
||||||
{{ __('Continue Learning') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
v-else-if="course.data.paid_course"
|
|
||||||
:to="{
|
|
||||||
name: 'Billing',
|
|
||||||
params: {
|
|
||||||
type: 'course',
|
|
||||||
name: course.data.name,
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button variant="solid" size="md" class="w-full">
|
|
||||||
<span>
|
|
||||||
{{ __('Buy this course') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
<div
|
|
||||||
v-else-if="course.data.disable_self_learning"
|
|
||||||
class="bg-blue-100 text-blue-900 text-sm rounded-md py-1 px-3"
|
|
||||||
>
|
|
||||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
v-else
|
|
||||||
@click="enrollStudent()"
|
|
||||||
variant="solid"
|
|
||||||
class="w-full"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{{ __('Start Learning') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
v-if="canGetCertificate"
|
|
||||||
@click="fetchCertificate()"
|
|
||||||
variant="subtle"
|
|
||||||
class="w-full mt-2"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
{{ __('Get Certificate') }}
|
|
||||||
</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">
|
|
||||||
<span>
|
|
||||||
{{ __('Edit') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
<div class="mt-8 mb-4 font-medium">
|
|
||||||
{{ __('This course has:') }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center mb-3">
|
|
||||||
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
|
|
||||||
<span class="ml-2">
|
|
||||||
{{ course.data.lessons }} {{ __('Lessons') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center mb-3">
|
|
||||||
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
|
|
||||||
<span class="ml-2">
|
|
||||||
{{ formatAmount(course.data.enrollments) }}
|
|
||||||
{{ __('Enrolled Students') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
|
|
||||||
<span class="ml-2"> {{ course.data.rating }} {{ __('Rating') }} </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
|
||||||
import { computed, inject } from 'vue'
|
|
||||||
import { Button, createResource } from 'frappe-ui'
|
|
||||||
import { showToast, formatAmount } from '@/utils/'
|
|
||||||
import { capture } from '@/telemetry'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const user = inject('$user')
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
course: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const video_link = computed(() => {
|
|
||||||
if (props.course.data.video_link) {
|
|
||||||
return 'https://www.youtube.com/embed/' + props.course.data.video_link
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
function enrollStudent() {
|
|
||||||
if (!user.data) {
|
|
||||||
showToast(
|
|
||||||
__('Please Login'),
|
|
||||||
__('You need to login first to enroll for this course'),
|
|
||||||
'alert-circle'
|
|
||||||
)
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
|
||||||
}, 2000)
|
|
||||||
} else {
|
|
||||||
const enrollStudentResource = createResource({
|
|
||||||
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
|
||||||
})
|
|
||||||
enrollStudentResource
|
|
||||||
.submit({
|
|
||||||
course: props.course.data.name,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
capture('enrolled_in_course', {
|
|
||||||
course: props.course.data.name,
|
|
||||||
})
|
|
||||||
showToast(
|
|
||||||
__('Success'),
|
|
||||||
__('You have been enrolled in this course'),
|
|
||||||
'check'
|
|
||||||
)
|
|
||||||
setTimeout(() => {
|
|
||||||
router.push({
|
|
||||||
name: 'Lesson',
|
|
||||||
params: {
|
|
||||||
courseName: props.course.data.name,
|
|
||||||
chapterNumber: 1,
|
|
||||||
lessonNumber: 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}, 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const is_instructor = () => {
|
|
||||||
let user_is_instructor = false
|
|
||||||
props.course.data.instructors.forEach((instructor) => {
|
|
||||||
if (!user_is_instructor && instructor.name == user.data?.name) {
|
|
||||||
user_is_instructor = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return user_is_instructor
|
|
||||||
}
|
|
||||||
|
|
||||||
const canGetCertificate = computed(() => {
|
|
||||||
if (
|
|
||||||
props.course.data?.enable_certification &&
|
|
||||||
props.course.data?.membership?.progress == 100
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
const certificate = createResource({
|
|
||||||
url: 'lms.lms.doctype.lms_certificate.lms_certificate.create_certificate',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
course: values.course,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess(data) {
|
|
||||||
window.open(
|
|
||||||
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
|
||||||
data.name
|
|
||||||
}&format=${encodeURIComponent(data.template)}`,
|
|
||||||
'_blank'
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchCertificate = () => {
|
|
||||||
certificate.submit({
|
|
||||||
course: props.course.data?.name,
|
|
||||||
member: user.data?.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<template>
|
|
||||||
<span v-if="instructors?.length == 1">
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'Profile',
|
|
||||||
params: { username: instructors[0].username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ instructors[0].full_name }}
|
|
||||||
</router-link>
|
|
||||||
</span>
|
|
||||||
<span v-if="instructors?.length == 2">
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'Profile',
|
|
||||||
params: { username: instructors[0].username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ instructors[0].first_name }}
|
|
||||||
</router-link>
|
|
||||||
and
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'Profile',
|
|
||||||
params: { username: instructors[1].username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ instructors[1].first_name }}
|
|
||||||
</router-link>
|
|
||||||
</span>
|
|
||||||
<span v-if="instructors?.length > 2">
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'Profile',
|
|
||||||
params: { username: instructors[0].username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ instructors[0].first_name }}
|
|
||||||
</router-link>
|
|
||||||
and {{ instructors?.length - 1 }} others
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
instructors: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="text-base">
|
|
||||||
<div
|
|
||||||
v-if="title && (outline.data?.length || allowEdit)"
|
|
||||||
class="grid grid-cols-[70%,30%] mb-4 px-2"
|
|
||||||
>
|
|
||||||
<div class="font-semibold text-lg leading-5">
|
|
||||||
{{ __(title) }}
|
|
||||||
</div>
|
|
||||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
|
||||||
{{ __('Add Chapter') }}
|
|
||||||
</Button>
|
|
||||||
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
|
|
||||||
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
|
|
||||||
</span> -->
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
:class="{
|
|
||||||
'shadow rounded-md py-2 px-2': showOutline && outline.data?.length,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Disclosure
|
|
||||||
v-slot="{ open }"
|
|
||||||
v-for="(chapter, index) in outline.data"
|
|
||||||
:key="chapter.name"
|
|
||||||
:defaultOpen="openChapterDetail(chapter.idx)"
|
|
||||||
>
|
|
||||||
<DisclosureButton ref="" class="flex items-center w-full p-2 group">
|
|
||||||
<ChevronRight
|
|
||||||
:class="{
|
|
||||||
'rotate-90 transform duration-200': open,
|
|
||||||
'duration-200': !open,
|
|
||||||
hidden: chapter.is_scorm_package,
|
|
||||||
open: index == 1,
|
|
||||||
}"
|
|
||||||
class="h-4 w-4 text-gray-900 stroke-1"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="text-base text-left font-medium leading-5 ml-2"
|
|
||||||
@click="redirectToChapter(chapter)"
|
|
||||||
>
|
|
||||||
{{ chapter.title }}
|
|
||||||
</div>
|
|
||||||
<div class="flex ml-auto space-x-4">
|
|
||||||
<Tooltip :text="__('Edit Chapter')" placement="bottom">
|
|
||||||
<FilePenLine
|
|
||||||
v-if="allowEdit"
|
|
||||||
@click.prevent="openChapterModal(chapter)"
|
|
||||||
class="h-4 w-4 text-gray-900 invisible group-hover:visible"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip :text="__('Delete Chapter')" placement="bottom">
|
|
||||||
<Trash2
|
|
||||||
v-if="allowEdit"
|
|
||||||
@click.prevent="trashChapter(chapter.name)"
|
|
||||||
class="h-4 w-4 text-red-500 invisible group-hover:visible"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</DisclosureButton>
|
|
||||||
<DisclosurePanel v-if="!chapter.is_scorm_package">
|
|
||||||
<Draggable
|
|
||||||
v-if="!chapter.is_scorm_package"
|
|
||||||
:list="chapter.lessons"
|
|
||||||
:disabled="!allowEdit"
|
|
||||||
item-key="name"
|
|
||||||
group="items"
|
|
||||||
@end="updateOutline"
|
|
||||||
:data-chapter="chapter.name"
|
|
||||||
>
|
|
||||||
<template #item="{ element: lesson }">
|
|
||||||
<div class="outline-lesson pl-8 py-2 pr-4">
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: allowEdit ? 'LessonForm' : 'Lesson',
|
|
||||||
params: {
|
|
||||||
courseName: courseName,
|
|
||||||
chapterNumber: lesson.number.split('.')[0],
|
|
||||||
lessonNumber: lesson.number.split('.')[1],
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="flex items-center text-sm leading-5 group">
|
|
||||||
<MonitorPlay
|
|
||||||
v-if="lesson.icon === 'icon-youtube'"
|
|
||||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
|
||||||
/>
|
|
||||||
<HelpCircle
|
|
||||||
v-else-if="lesson.icon === 'icon-quiz'"
|
|
||||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
|
||||||
/>
|
|
||||||
<FileText
|
|
||||||
v-else-if="lesson.icon === 'icon-list'"
|
|
||||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
|
||||||
/>
|
|
||||||
{{ lesson.title }}
|
|
||||||
<Trash2
|
|
||||||
v-if="allowEdit"
|
|
||||||
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
|
||||||
class="h-4 w-4 text-red-500 ml-auto invisible group-hover:visible"
|
|
||||||
/>
|
|
||||||
<Check
|
|
||||||
v-if="lesson.is_complete"
|
|
||||||
class="h-4 w-4 text-green-700 ml-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Draggable>
|
|
||||||
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
|
||||||
<router-link
|
|
||||||
v-if="!chapter.is_scorm_package"
|
|
||||||
:to="{
|
|
||||||
name: 'LessonForm',
|
|
||||||
params: {
|
|
||||||
courseName: courseName,
|
|
||||||
chapterNumber: chapter.idx,
|
|
||||||
lessonNumber: chapter.lessons.length + 1,
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
{{ __('Add Lesson') }}
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</DisclosurePanel>
|
|
||||||
</Disclosure>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChapterModal
|
|
||||||
v-model="showChapterModal"
|
|
||||||
v-model:outline="outline"
|
|
||||||
:course="courseName"
|
|
||||||
:chapterDetail="getCurrentChapter()"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { Button, createResource, Tooltip } from 'frappe-ui'
|
|
||||||
import { getCurrentInstance, inject, ref } from 'vue'
|
|
||||||
import Draggable from 'vuedraggable'
|
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
|
||||||
import {
|
|
||||||
Check,
|
|
||||||
ChevronRight,
|
|
||||||
FileText,
|
|
||||||
FilePenLine,
|
|
||||||
HelpCircle,
|
|
||||||
MonitorPlay,
|
|
||||||
Trash2,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
|
||||||
import { showToast } from '@/utils'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const user = inject('$user')
|
|
||||||
const showChapterModal = ref(false)
|
|
||||||
const currentChapter = ref(null)
|
|
||||||
const app = getCurrentInstance()
|
|
||||||
const { $dialog } = app.appContext.config.globalProperties
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
courseName: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
showOutline: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
allowEdit: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
getProgress: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const outline = createResource({
|
|
||||||
url: 'lms.lms.utils.get_course_outline',
|
|
||||||
cache: ['course_outline', props.courseName],
|
|
||||||
params: {
|
|
||||||
course: props.courseName,
|
|
||||||
progress: props.getProgress,
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteLesson = createResource({
|
|
||||||
url: 'lms.lms.api.delete_lesson',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
lesson: values.lesson,
|
|
||||||
chapter: values.chapter,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
outline.reload()
|
|
||||||
showToast('Success', 'Lesson deleted successfully', 'check')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateLessonIndex = createResource({
|
|
||||||
url: 'lms.lms.api.update_lesson_index',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
lesson: values.lesson,
|
|
||||||
sourceChapter: values.sourceChapter,
|
|
||||||
targetChapter: values.targetChapter,
|
|
||||||
idx: values.idx,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
showToast('Success', 'Lesson moved successfully', 'check')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const trashLesson = (lessonName, chapterName) => {
|
|
||||||
$dialog({
|
|
||||||
title: __('Delete this lesson?'),
|
|
||||||
message: __(
|
|
||||||
'Deleting this lesson will permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: __('Delete'),
|
|
||||||
theme: 'red',
|
|
||||||
variant: 'solid',
|
|
||||||
onClick(close) {
|
|
||||||
deleteLesson.submit({
|
|
||||||
lesson: lessonName,
|
|
||||||
chapter: chapterName,
|
|
||||||
})
|
|
||||||
close()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openChapterDetail = (index) => {
|
|
||||||
return index == route.params.chapterNumber || index == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const openChapterModal = (chapter = null) => {
|
|
||||||
currentChapter.value = chapter
|
|
||||||
showChapterModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCurrentChapter = () => {
|
|
||||||
return currentChapter.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateOutline = (e) => {
|
|
||||||
updateLessonIndex.submit({
|
|
||||||
lesson: e.item.__draggable_context.element.name,
|
|
||||||
sourceChapter: e.from.dataset.chapter,
|
|
||||||
targetChapter: e.to.dataset.chapter,
|
|
||||||
idx: e.newIndex,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteChapter = createResource({
|
|
||||||
url: 'lms.lms.api.delete_chapter',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
chapter: values.chapter,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
outline.reload()
|
|
||||||
showToast('Success', 'Chapter deleted successfully', 'check')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const trashChapter = (chapterName) => {
|
|
||||||
$dialog({
|
|
||||||
title: __('Delete this chapter?'),
|
|
||||||
message: __(
|
|
||||||
'Deleting this chapter will also delete all its lessons and permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: __('Delete'),
|
|
||||||
theme: 'red',
|
|
||||||
variant: 'solid',
|
|
||||||
onClick(close) {
|
|
||||||
deleteChapter.submit({ chapter: chapterName })
|
|
||||||
close()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectToChapter = (chapter) => {
|
|
||||||
if (!chapter.is_scorm_package) return
|
|
||||||
event.preventDefault()
|
|
||||||
if (props.allowEdit) return
|
|
||||||
if (!user.data) {
|
|
||||||
showToast(
|
|
||||||
__('You are not enrolled'),
|
|
||||||
__('Please enroll for this course to view this lesson'),
|
|
||||||
'alert-circle'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push({
|
|
||||||
name: 'SCORMChapter',
|
|
||||||
params: {
|
|
||||||
courseName: props.courseName,
|
|
||||||
chapterName: chapter.name,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.outline-lesson:has(.router-link-active) {
|
|
||||||
background-color: theme('colors.gray.100');
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="reviews.data?.length || membership" class="mt-20 mb-10">
|
|
||||||
<Button
|
|
||||||
v-if="membership && !hasReviewed.data"
|
|
||||||
@click="openReviewModal()"
|
|
||||||
class="float-right"
|
|
||||||
>
|
|
||||||
{{ __('Write a Review') }}
|
|
||||||
</Button>
|
|
||||||
<div class="flex items-center font-semibold text-2xl">
|
|
||||||
{{ __('Student Reviews') }}
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-8 mt-10">
|
|
||||||
<div v-for="(review, index) in reviews.data">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'Profile',
|
|
||||||
params: { username: review.owner_details.username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
|
||||||
</router-link>
|
|
||||||
<div class="mx-4">
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'Profile',
|
|
||||||
params: { username: review.owner_details.username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span class="text-lg font-medium mr-4">
|
|
||||||
{{ review.owner_details.full_name }}
|
|
||||||
</span>
|
|
||||||
</router-link>
|
|
||||||
<span>
|
|
||||||
{{ review.creation }}
|
|
||||||
</span>
|
|
||||||
<div class="flex mt-2">
|
|
||||||
<Star
|
|
||||||
v-for="index in 5"
|
|
||||||
class="h-5 w-5 text-gray-100 bg-gray-200 rounded-sm mr-2"
|
|
||||||
:class="
|
|
||||||
index <= Math.ceil(review.rating)
|
|
||||||
? 'fill-orange-500'
|
|
||||||
: 'fill-gray-600'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="review.review" class="mt-4 leading-5">
|
|
||||||
{{ review.review }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ReviewModal
|
|
||||||
v-model="showReviewModal"
|
|
||||||
v-model:reloadReviews="reviews"
|
|
||||||
v-model:hasReviewed="hasReviewed"
|
|
||||||
:courseName="courseName"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { Star } from 'lucide-vue-next'
|
|
||||||
import { createResource, Button } from 'frappe-ui'
|
|
||||||
import { computed, ref, inject } from 'vue'
|
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
import ReviewModal from '@/components/Modals/ReviewModal.vue'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
courseName: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
avg_rating: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
membership: {
|
|
||||||
type: Object,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasReviewed = createResource({
|
|
||||||
url: 'frappe.client.get_count',
|
|
||||||
cache: ['eligible_to_review', props.courseName, props.membership?.member],
|
|
||||||
params: {
|
|
||||||
doctype: 'LMS Course Review',
|
|
||||||
filters: {
|
|
||||||
course: props.courseName,
|
|
||||||
owner: props.membership?.member,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
auto: user.data?.name ? true : false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const reviews = createResource({
|
|
||||||
url: 'lms.lms.utils.get_reviews',
|
|
||||||
cache: ['course_reviews', props.courseName],
|
|
||||||
params: {
|
|
||||||
course: props.courseName,
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const showReviewModal = ref(false)
|
|
||||||
|
|
||||||
function openReviewModal() {
|
|
||||||
showReviewModal.value = true
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="course">
|
|
||||||
<div class="text-xl font-semibold">
|
|
||||||
{{ course.title }}
|
|
||||||
</div>
|
|
||||||
<div v-if="course.chapters.length">
|
|
||||||
{{ course.chapters }}
|
|
||||||
</div>
|
|
||||||
<div v-else class="border bg-white rounded-md p-5 text-center mt-4">
|
|
||||||
<div>
|
|
||||||
{{
|
|
||||||
__(
|
|
||||||
'There are no chapters in this course. Create and manage chapters from here.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<Button class="mt-4">
|
|
||||||
{{ __('Add Chapter') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
course: {
|
|
||||||
type: Object,
|
|
||||||
default: {},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative flex h-full flex-col">
|
|
||||||
<div class="h-full flex-1">
|
|
||||||
<div class="flex h-screen text-base">
|
|
||||||
<div
|
|
||||||
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
|
||||||
>
|
|
||||||
<AppSidebar />
|
|
||||||
</div>
|
|
||||||
<div class="w-full overflow-auto" id="scrollContainer">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import AppSidebar from './AppSidebar.vue'
|
|
||||||
</script>
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="mt-6">
|
|
||||||
<div v-if="!singleThread" class="flex items-center mb-5">
|
|
||||||
<Button variant="outline" @click="showTopics = true">
|
|
||||||
<template #icon>
|
|
||||||
<ChevronLeft class="w-5 h-5 stroke-1.5 text-gray-700" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
<span class="text-lg font-semibold ml-2">
|
|
||||||
{{ topic.title }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-for="(reply, index) in replies.data">
|
|
||||||
<div
|
|
||||||
class="py-3"
|
|
||||||
:class="{ 'border-b': index + 1 != replies.data.length }"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<UserAvatar :user="reply.user" class="mr-2" />
|
|
||||||
<span>
|
|
||||||
{{ reply.user.full_name }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm ml-2">
|
|
||||||
{{ timeAgo(reply.creation) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Dropdown
|
|
||||||
v-if="user.data.name == reply.owner && !reply.editable"
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
label: 'Edit',
|
|
||||||
onClick() {
|
|
||||||
reply.editable = true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Delete',
|
|
||||||
onClick() {
|
|
||||||
deleteReply(reply)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<template v-slot="{ open }">
|
|
||||||
<MoreHorizontal class="w-4 h-4 stroke-1.5 cursor-pointer" />
|
|
||||||
</template>
|
|
||||||
</Dropdown>
|
|
||||||
<div v-if="reply.editable">
|
|
||||||
<Button variant="ghost" @click="postEdited(reply)">
|
|
||||||
{{ __('Post') }}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" @click="reply.editable = false">
|
|
||||||
{{ __('Discard') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TextEditor
|
|
||||||
:content="reply.reply"
|
|
||||||
@change="(val) => (reply.reply = val)"
|
|
||||||
:editable="reply.editable || false"
|
|
||||||
:fixedMenu="reply.editable || false"
|
|
||||||
:editorClass="
|
|
||||||
reply.editable
|
|
||||||
? 'ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none'
|
|
||||||
: 'prose-sm'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextEditor
|
|
||||||
class="mt-5"
|
|
||||||
:content="newReply"
|
|
||||||
:mentions="mentionUsers"
|
|
||||||
@change="(val) => (newReply = val)"
|
|
||||||
placeholder="Type your reply here..."
|
|
||||||
:fixedMenu="true"
|
|
||||||
editorClass="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none border border-gray-300 rounded-b-md min-h-[7rem] py-1 px-2"
|
|
||||||
/>
|
|
||||||
<div class="flex justify-between mt-2">
|
|
||||||
<span> </span>
|
|
||||||
<Button @click="postReply()">
|
|
||||||
<span>
|
|
||||||
{{ __('Post') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
|
|
||||||
import { timeAgo } from '../utils'
|
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
|
||||||
import { ref, inject, onMounted, computed } from 'vue'
|
|
||||||
import { createToast } from '../utils'
|
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
|
||||||
const newReply = ref('')
|
|
||||||
const socket = inject('$socket')
|
|
||||||
const user = inject('$user')
|
|
||||||
const allUsers = inject('$allUsers')
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
topic: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
singleThread: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
socket.on('publish_message', (data) => {
|
|
||||||
replies.reload()
|
|
||||||
})
|
|
||||||
socket.on('update_message', (data) => {
|
|
||||||
replies.reload()
|
|
||||||
})
|
|
||||||
socket.on('delete_message', (data) => {
|
|
||||||
replies.reload()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const replies = createResource({
|
|
||||||
url: 'lms.lms.utils.get_discussion_replies',
|
|
||||||
cache: ['replies', props.topic],
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
topic: props.topic.name,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const newReplyResource = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'Discussion Reply',
|
|
||||||
reply: newReply.value,
|
|
||||||
topic: props.topic.name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const mentionUsers = computed(() => {
|
|
||||||
let users = Object.values(allUsers.data).map((user) => {
|
|
||||||
return {
|
|
||||||
value: user.name,
|
|
||||||
label: user.full_name,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return users
|
|
||||||
})
|
|
||||||
|
|
||||||
const postReply = () => {
|
|
||||||
newReplyResource.submit(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
validate() {
|
|
||||||
if (!newReply.value) {
|
|
||||||
return 'Reply cannot be empty'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
newReply.value = ''
|
|
||||||
replies.reload()
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
createToast({
|
|
||||||
title: 'Error',
|
|
||||||
text: err.messages?.[0] || err,
|
|
||||||
icon: 'x',
|
|
||||||
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
|
||||||
position: 'top-center',
|
|
||||||
timeout: 10,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const editReplyResource = createResource({
|
|
||||||
url: 'frappe.client.set_value',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'Discussion Reply',
|
|
||||||
name: values.name,
|
|
||||||
fieldname: 'reply',
|
|
||||||
value: values.reply,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const postEdited = (reply) => {
|
|
||||||
editReplyResource.submit(
|
|
||||||
{
|
|
||||||
name: reply.name,
|
|
||||||
reply: reply.reply,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
validate() {
|
|
||||||
if (!reply.reply) {
|
|
||||||
return 'Reply cannot be empty'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
reply.editable = false
|
|
||||||
replies.reload()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteReplyResource = createResource({
|
|
||||||
url: 'frappe.client.delete',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'Discussion Reply',
|
|
||||||
name: values.name,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteReply = (reply) => {
|
|
||||||
deleteReplyResource.submit(
|
|
||||||
{
|
|
||||||
name: reply.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
replies.reload()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user