Compare commits

..

1 Commits

Author SHA1 Message Date
Hussain Nagaria
d9bf4e2c58 fix: title and tab copy 2024-04-16 14:42:17 +05:30
466 changed files with 12539 additions and 161498 deletions

BIN
.github/batch.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 912 KiB

View File

@@ -5,7 +5,7 @@ echo "Setting Up System Dependencies..."
sudo apt update sudo apt update
sudo apt remove mysql-server mysql-client sudo apt remove mysql-server mysql-client
sudo apt-get install libcups2-dev redis-server mariadb-client sudo apt install libcups2-dev redis-server mariadb-client-10.6
install_wkhtmltopdf() { install_wkhtmltopdf() {
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb

View File

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

BIN
.github/hero.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

BIN
.github/lms-logo.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

BIN
.github/quiz.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1,64 +0,0 @@
name: Build Container Image
on:
workflow_dispatch:
push:
branches:
- main
tags:
- "*"
jobs:
build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
arch: [amd64, arm64]
permissions:
packages: write
steps:
- name: Checkout Entire Repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/${{ matrix.arch }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set Branch
run: |
export APPS_JSON='[{"url": "https://github.com/frappe/lms","branch": "main"}]'
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
- name: Set Image Tag
run: |
echo "IMAGE_TAG=stable" >> $GITHUB_ENV
- uses: actions/checkout@v4
with:
repository: frappe/frappe_docker
path: builds
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: builds
file: builds/images/layered/Containerfile
tags: >
ghcr.io/${{ github.repository }}:${{ github.ref_name }},
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
build-args: |
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"

View File

@@ -39,7 +39,7 @@ jobs:
node-version: '18' node-version: '18'
check-latest: true check-latest: true
- name: setup cache for bench - name: setup cache for bench
uses: actions/cache@v4 uses: actions/cache@v2
with: with:
path: ~/bench-cache path: ~/bench-cache
key: ${{ runner.os }} key: ${{ runner.os }}

View File

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

View File

@@ -7,27 +7,8 @@ on:
branches: [ main ] branches: [ main ]
jobs: jobs:
commit-lint:
name: 'Semantic Commits'
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 200
- uses: actions/setup-node@v4
with:
node-version: 20
check-latest: true
- name: Check commit titles
run: |
npm install @commitlint/cli @commitlint/config-conventional
npx commitlint --verbose --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }}
linters: linters:
name: Semgrep Rules name: Semantic Commits
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
@@ -39,17 +20,8 @@ jobs:
with: with:
python-version: '3.10' python-version: '3.10'
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- name: Install and Run Pre-commit - name: Install and Run Pre-commit
uses: pre-commit/action@v3.0.1 uses: pre-commit/action@v2.0.3
- name: Download Semgrep rules - name: Download Semgrep rules
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules

View File

@@ -1,26 +0,0 @@
name: Create weekly release
on:
schedule:
- cron: '30 4 15 * *'
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 }}

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ jobs:
services: services:
mariadb: mariadb:
image: mariadb:10.8 image: mariadb:10.6
env: env:
MARIADB_ROOT_PASSWORD: 123 MARIADB_ROOT_PASSWORD: 123
ports: ports:
@@ -58,7 +58,7 @@ jobs:
echo "127.0.0.1 lms.test" | sudo tee -a /etc/hosts echo "127.0.0.1 lms.test" | sudo tee -a /etc/hosts
- name: Cache pip - name: Cache pip
uses: actions/cache@v4 uses: actions/cache@v3
with: with:
path: ~/.cache/pip path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }} key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
@@ -70,7 +70,7 @@ jobs:
id: yarn-cache-dir-path id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4 - uses: actions/cache@v3
id: yarn-cache id: yarn-cache
with: with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }} path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -79,7 +79,7 @@ jobs:
${{ runner.os }}-yarn-ui- ${{ runner.os }}-yarn-ui-
- name: Cache cypress binary - name: Cache cypress binary
uses: actions/cache@v4 uses: actions/cache@v3
with: with:
path: ~/.cache/Cypress path: ~/.cache/Cypress
key: ${{ runner.os }}-cypress key: ${{ runner.os }}-cypress
@@ -99,13 +99,11 @@ 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
bench --site lms.test execute lms.lms.utils.persona_captured
- name: cypress pre-requisites - name: cypress pre-requisites
run: | run: |
cd ~/frappe-bench/apps/lms cd ~/frappe-bench/apps/lms
yarn add cypress@^10 --no-lockfile -W yarn add cypress@^10 --no-lockfile
- name: UI Tests - name: UI Tests
run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless

1
.gitignore vendored
View File

@@ -12,4 +12,3 @@ node_modules
package-lock.json package-lock.json
lms/public/frontend lms/public/frontend
lms/www/lms.html lms/www/lms.html
frappe-ui

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "frappe-ui"]
path = frappe-ui
url = https://github.com/frappe/frappe-ui

View File

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

223
README.md
View File

@@ -1,174 +1,109 @@
<div align="center" markdown="1"> <p align="center">
<a href="https://www.frappelms.com/">
<img src="https://frappe.io/files/lms.png" alt="Frappe LMS" width="50px" height="50px">
</a>
<p align="center">Easy to use, open source, learning management system.</p>
</p>
<img src=".github/lms-logo.png" alt="Frappe Learning logo" width="80" height="80"/>
<h1>Frappe Learning</h1>
**Easy to use, open source, Learning Management System** &nbsp;
![Tests](https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/vandxn/main&style=flat&logo=cypress) <p align="center">
<a href="https://www.producthunt.com/posts/frappe-lms?utm_source=badge-top-post-topic-badge&utm_medium=badge&utm_souce=badge-frappe&#0045;lms" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-topic-badge.svg?post_id=396079&theme=dark&period=weekly&topic_id=204" alt="Frappe&#0032;LMS - Easy&#0032;to&#0032;use&#0044;&#0032;100&#0037;&#0032;open&#0032;source&#0032;learning&#0032;management&#0032;system | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</p>
<div align="center" style="max-height: 40px;">
<a href="https://frappecloud.com/lms/signup">
<img src=".github/try-on-f-cloud.svg" height="40">
</a>
</div> </div>
&nbsp;
<div align="center"> <p align="center">
<img src=".github/hero.png?v=5" alt="Hero Image" width="72%" /> <a href="https://dashboard.cypress.io/projects/vandxn/runs">
</div> <img alt="cypress" src="https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/vandxn/main&style=flat&logo=cypress">
<br /> </a>
<div align="center"> <a href="https://github.com/frappe/lms/blob/main/LICENSE">
<a href="https://frappe.io/learning">Website</a> <img alt="license" src="https://img.shields.io/badge/license-AGPLv3-blue">
- </a>
<a href="https://docs.frappe.io/learning">Documentation</a> </p>
</div>
## Frappe Learning <img width="1402" alt="Lesson" src="https://frappelms.com/files/banner.png">
Frappe Learning is an easy-to-use learning system that helps you bring structure to your content.
### Motivation
In 2021, we were looking for a Learning Management System to launch [Mon.School](https://mon.school) for FOSS United. We checked out Moodle, but it didnt feel right. The forms were unnecessarily lengthy and the UI was confusing. It shouldn't be this hard to create a course right? So I started making a learning system for Mon.School which soon became a product in itself. The aim is to have a simple platform that anyone can use to launch a course of their own and make knowledge sharing easier.
### Key Features
- **Structured Learning**: Design a course with a 3-level hierarchy, where your courses have chapters and you can group your lessons within these chapters. This ensures that the context of the lesson is set by the chapter.
- **Live Classes**: Group learners into batches based on courses and duration. You can then create Zoom live class for these batches right from the app. Learners get to see the list of live classes they have to take as a part of this batch.
- **Quizzes and Assignments**: Create quizzes where questions can have single-choice, multiple-choice options, or can be open ended. Instructors can also add assignments which learners can submit as PDF's or Documents.
- **Getting Certified**: Once a learner has completed the course or batch, you can grant them a certificate. The app provides an inbuilt certificate template. You can use this or else create a template of your own and use that instead.
<details> <details>
<summary>View Screenshots</summary> <summary>Show more screenshots</summary>
<img width="1520" alt="ss1" src="https://user-images.githubusercontent.com/31363128/210056046-584bc8aa-d28c-4514-b031-73817012837d.png">
<img width="830" alt="ss2" src="https://user-images.githubusercontent.com/31363128/210056097-36849182-6db0-43a2-8c62-5333cd2aedf4.png">
![Batch](.github/batch.png) <img width="941" alt="ss3" src="https://user-images.githubusercontent.com/31363128/210056134-01a7c429-1ef4-434e-9d43-128dda35d7e5.png">
<div align="center">
<sub>
Create batches to group your learners
</sub>
</div>
<br>
![Quiz](.github/quiz.png)
<div align="center">
<sub>
Evaluate their knowledge by quizzes
</sub>
</div>
<br>
![Cerficicate](.github/certificate.png)
<div align="center">
<sub>
Autenticate their work with certification
</sub>
</div>
</details> </details>
Frappe LMS is an easy-to-use, open-source learning management system. You can use it to create and share online courses. The app has a clear UI that helps students focus only on what's important and assists in distraction-free learning.
### Under the Hood You can create courses and lessons through simple forms. Lessons can be in the form of text, videos, quizzes or a combination of all these. You can keep your students engaged with quizzes to help revise and test the concepts learned. Course Instructors and Students can reach out to each other through the discussions section available for each lesson and get queries resolved.
- [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework. ## Features
- Create online courses. 📚
- Add detailed descriptions and preview videos to the course. 🎬
- Add videos, quizzes, and assignments to your lessons and make them interesting and interactive 📝
- Discussions section below each lesson where instructors and students can interact with each other. 💬
- Create batches to group your students based on courses and track their progress 🏛
- Statistics dashboard that provides all important numbers at a glimpse. 📈
- Job Board where users can post and look for jobs. 💼
- People directory with each person's profile page 👨‍👩‍👧‍👦
- Set cover image, profile photo, short bio, and other professional information. 🦹🏼‍♀️
- Simple layout that optimizes readability 🤓
- Delightful user experience in overall usage ✨
- [**Frappe UI**](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface. ## Tech Stack
## Production Setup Frappe LMS is built on [Frappe Framework](https://frappeframework.com) which is a batteries-included python web framework.
These are some of the tools it's built on:
- [Python](https://www.python.org)
- [Redis](https://redis.io/)
- [MariaDB](https://mariadb.org/)
- [Socket.io](https://socket.io/)
### Managed Hosting ## Local Setup
You can try [Frappe Cloud](https://frappecloud.com), a simple, user-friendly and sophisticated [open-source](https://github.com/frappe/press) platform to host Frappe applications with peace of mind.
It takes care of installation, setup, upgrades, monitoring, maintenance and support of your Frappe deployments. It is a fully featured developer platform with an ability to manage and control multiple Frappe deployments.
<div>
<a href="https://frappecloud.com/lms/signup" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/try-on-fc-white.png">
<img src="https://frappe.io/files/try-on-fc-black.png" alt="Try on Frappe Cloud" height="28" />
</picture>
</a>
</div>
### Self Hosting
Follow these steps to set up Frappe Learning in production:
**Step 1**: Download the easy install script
```bash
wget https://frappe.io/easy-install.py
```
**Step 2**: Run the deployment command
```bash
python3 ./easy-install.py deploy \
--project=learning_prod_setup \
--email=your_email.example.com \
--image=ghcr.io/frappe/lms \
--version=stable \
--app=lms \
--sitename subdomain.domain.tld
```
Replace the following parameters with your values:
- `your_email.example.com`: Your email address
- `subdomain.domain.tld`: Your domain name where Learning will be hosted
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
## Development Setup
### Docker ### Docker
You need Docker, docker-compose, and git setup on your machine. Refer to [Docker documentation](https://docs.docker.com/). After that, run the following commands:
```
git clone https://github.com/frappe/lms
cd apps/lms/docker
docker-compose up
```
You need Docker, docker-compose and git setup on your machine. Refer [Docker documentation](https://docs.docker.com/). After that, follow below steps: 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.
**Step 1**: Setup folder and download the required files ### Frappe Bench
mkdir frappe-learning Currently, this app depends on the `develop` branch of [frappe](https://github.com/frappe/frappe).
cd frappe-learning
# Download the docker-compose file 1. Setup frappe-bench by following [this guide](https://frappeframework.com/docs/v14/user/en/installation)
wget -O docker-compose.yml https://raw.githubusercontent.com/frappe/lms/develop/docker/docker-compose.yml 1. In the frappe-bench directory, run `bench start` and keep it running. Open a new terminal session and cd into the `frappe-bench` directory.
1. Run the following commands:
```sh
bench new-site lms.test
bench get-app lms
bench --site lms.test install-app lms
bench --site lms.test add-to-hosts
# Download the setup script 1. Now, you can access the site at `http://lms.test:8000`
wget -O init.sh https://raw.githubusercontent.com/frappe/lms/develop/docker/init.sh
**Step 2**: Run the container and daemonize it
docker compose up -d ## Deployment
Frappe LMS is an app built on top of the Frappe Framework. So, you can follow any deployment guide for hosting a Frappe Framework-based site.
**Step 3**: The site [http://lms.localhost:8000/lms](http://lms.localhost:8000/lms) should now be available. The default credentials are: ### Managed Hosting
- Username: Administrator Frappe LMS can be deployed in a few clicks on [Frappe Cloud](https://frappecloud.com/marketplace/apps/lms).
- Password: admin
### Local ### Self-hosting
If you want to self-host, you can follow official [Frappe Bench Installation](https://github.com/frappe/bench#installation) instructions.
To setup the repository locally follow the steps mentioned below: ## Bugs and Feature Requests
If you find any bugs or have a feature idea for the app, feel free to report them here on [GitHub Issues](https://github.com/frappe/lms/issues). Make sure you share enough information (app screenshots, browser console screenshots, stack traces, etc) for project maintainers.
1. Install bench and setup a `frappe-bench` directory by following the [Installation Steps](https://frappeframework.com/docs/user/en/installation) ## License
1. Start the server by running `bench start` Distributed under [GNU AFFERO GENERAL PUBLIC LICENSE](license.txt)
1. In a separate terminal window, create a new site by running `bench new-site learning.test`
1. Map your site to localhost with the command `bench --site learning.test add-to-hosts`
1. Get the Learning app. Run `bench get-app https://github.com/frappe/lms`
1. Run `bench --site learning.test install-app lms`.
1. Now open the URL `http://learning.test:8000/lms` in your browser, you should see the app running
## Learn and connect
- [Telegram Public Group](https://t.me/frappelms)
- [Discuss Forum](https://discuss.frappe.io/c/lms/70)
- [Documentation](https://docs.frappe.io/learning)
- [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A)
<br>
<br>
<div align="center" style="padding-top: 0.75rem;">
<a href="https://frappe.io" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/Frappe-white.png">
<img src="https://frappe.io/files/Frappe-black.png" alt="Frappe Technologies" height="28"/>
</picture>
</a>
</div>

View File

@@ -1,26 +0,0 @@
export default {
parserPreset: "conventional-changelog-conventionalcommits",
rules: {
"subject-empty": [2, "never"],
"type-case": [2, "always", "lower-case"],
"type-empty": [2, "never"],
"type-enum": [
2,
"always",
[
"build",
"chore",
"ci",
"docs",
"feat",
"fix",
"perf",
"refactor",
"revert",
"style",
"test",
"deprecate", // deprecation decision
],
],
},
};

View File

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

View File

@@ -1,6 +1,6 @@
import { defineConfig } from "cypress"; const { defineConfig } = require("cypress");
export default defineConfig({ module.exports = defineConfig({
projectId: "vandxn", projectId: "vandxn",
adminPassword: "admin", adminPassword: "admin",
testUser: "frappe@example.com", testUser: "frappe@example.com",
@@ -13,6 +13,6 @@ export default defineConfig({
openMode: 0, openMode: 0,
}, },
e2e: { e2e: {
baseUrl: "http://pertest:8000", baseUrl: "http://pyp:8000",
}, },
}); });

View File

@@ -1,180 +0,0 @@
describe("Batch Creation", () => {
it("creates a new batch", () => {
cy.login();
cy.wait(500);
cy.visit("/lms/batches");
cy.closeOnboardingModal();
// Open Settings
cy.get("span").contains("Learning").click();
cy.get("span").contains("Settings").click();
// Add a new member
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("span")
.contains(/^Members$/)
.click();
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("button")
.contains("New")
.click();
const dateNow = Date.now();
const randomEmail = `testuser_${dateNow}@example.com`;
const randomName = `Test User ${dateNow}`;
cy.get("input[placeholder='Email']").type(randomEmail);
cy.get("input[placeholder='First Name']").type(randomName);
cy.get("button").contains("Add").click();
// Add evaluator
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("span")
.contains(/^Evaluators$/)
.click();
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("button")
.contains("New")
.click();
const randomEvaluator = `evaluator${dateNow}@example.com`;
cy.get("input[placeholder='Email']").type(randomEvaluator);
cy.get("button").contains("Add").click();
cy.get("div").contains(randomEvaluator).should("be.visible").click();
cy.visit("/lms/batches");
cy.closeOnboardingModal();
// Create a batch
cy.get("button").contains("New").click();
cy.wait(500);
cy.url().should("include", "/batches/new/edit");
cy.get("label").contains("Title").type("Test Batch");
cy.get("label").contains("Start Date").type("2030-10-01");
cy.get("label").contains("End Date").type("2030-10-31");
cy.get("label").contains("Start Time").type("10:00");
cy.get("label").contains("End Time").type("11:00");
cy.get("label").contains("Timezone").type("IST");
cy.get("label").contains("Seat Count").type("10");
cy.get("label").contains("Published").click();
cy.get("label")
.contains("Short Description")
.type("Test Batch Short Description to test the UI");
cy.get("div[contenteditable=true").invoke(
"text",
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
/* Instructor */
cy.get("label")
.contains("Instructors")
.parent()
.within(() => {
cy.get("input").click().type("evaluator");
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.button("Save").click();
cy.wait(1000);
let batchName;
cy.url().then((url) => {
console.log(url);
batchName = url.split("/").pop();
cy.wrap(batchName).as("batchName");
});
cy.wait(500);
// View Batch
cy.wait(1000);
cy.visit("/lms/batches");
cy.closeOnboardingModal();
cy.url().should("include", "/lms/batches");
cy.get('[id^="headlessui-radiogroup-v-"]')
.find("span")
.contains("Upcoming")
.should("be.visible")
.click();
cy.get("@batchName").then((batchName) => {
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
.should("be.visible");
cy.get("span")
.contains("01 Oct 2030 - 31 Oct 2030")
.should("be.visible");
cy.get("span")
.contains("10:00 AM - 11:00 AM")
.should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
});
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
});
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
.should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("span")
.contains("01 Oct 2030 - 31 Oct 2030")
.should("be.visible");
cy.get("span").contains("10:00 AM - 11:00 AM").should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
cy.get("p")
.contains(
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
)
.should("be.visible");
cy.get("button").contains("Manage Batch").click();
/* Add student to batch */
cy.get("button").contains("Add").click();
cy.get('div[id^="headlessui-dialog-panel-v-"]')
.first()
.find("button")
.eq(1)
.click();
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
cy.get("div").contains(randomEmail).click();
cy.get("button").contains("Submit").click();
// Verify Seat Count
cy.get("span").contains("Details").click();
cy.get("div")
.contains("9")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
});
});

View File

@@ -1,166 +1,133 @@
describe("Course Creation", () => { describe("Course Creation", () => {
it("creates a new course", () => { it("creates a new course", () => {
cy.login(); cy.login();
cy.wait(500); cy.visit("/courses");
cy.visit("/lms/courses");
// Close onboarding modal
cy.closeOnboardingModal();
// Create a course // Create a course
cy.get("button").contains("New").click(); cy.get("a.btn").contains("Create a Course").click();
cy.wait(500); cy.wait(1000);
cy.url().should("include", "/courses/new/edit"); cy.url().should("include", "/courses/new-course/edit");
cy.get("#title").type("Test Course");
cy.get("label").contains("Title").type("Test Course"); cy.get("#intro").type("Test Course Short Introduction");
cy.get("label") cy.get("#description").type("Test Course Description");
.contains("Short Introduction") cy.get("#video-link").type("-LPmw2Znl2c");
.type("Test Course Short Introduction to test the UI"); cy.get("#tags-input").type("Test");
cy.get("div[contenteditable=true").invoke( cy.get("#published").check();
"text", cy.wait(1000);
"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("div")
.contains("Course Image")
.siblings("div")
.children('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.url().should("include", "/learn/1-1/edit");
cy.wait(1000); cy.wait(1000);
cy.get("#lesson-title").type("Test Lesson");
cy.get("label").contains("Title").type("Test Lesson"); // Content
cy.get("#content .ce-block").type( cy.get(".collapse-section.collapsed:first").click();
"{enter}This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now." 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.button("Save").click(); cy.button("Save").click();
// View Course // View Course
cy.wait(1000); cy.wait(1000);
cy.visit("/lms"); cy.visit("/courses");
cy.closeOnboardingModal(); cy.get(".course-card-title:first").contains("Test Course");
cy.get(".course-card:first").click();
cy.url().should("include", "/lms/courses"); cy.url().should("include", "/courses/test-course");
cy.get(".grid a:first").within(() => { cy.get("#title").contains("Test Course");
cy.get("div").contains("Test Course"); cy.get(".preview-video").should(
cy.get("div").contains(
"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

View File

@@ -24,9 +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";
import "cypress-real-events";
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";
@@ -38,9 +35,6 @@ Cypress.Commands.add("login", (email, password) => {
url: "/api/method/login", url: "/api/method/login",
method: "POST", method: "POST",
body: { usr: email, pwd: password }, body: { usr: email, pwd: password },
timeout: 60000,
retryOnStatusCodeFailure: true,
retryOnNetworkFailure: true,
}); });
}); });
@@ -59,28 +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);
});
});
Cypress.Commands.add("closeOnboardingModal", () => {
cy.wait(500);
cy.get("body").then(($body) => {
// Check if any element with class including 'z-50' exists
if ($body.find('[class*="z-50"]').length > 0) {
cy.get('[class*="z-50"]')
.find('button:has(svg[class*="feather-x"])')
.realClick();
cy.wait(1000);
} else {
cy.log("Onboarding modal not found, skipping close.");
}
});
});

View File

@@ -2,7 +2,7 @@ version: "3.7"
name: lms name: lms
services: services:
mariadb: mariadb:
image: mariadb:10.8 image: mariadb:10.6
command: command:
- --character-set-server=utf8mb4 - --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci - --collation-server=utf8mb4_unicode_ci

View File

@@ -16,9 +16,9 @@ cd frappe-bench
# Use containers instead of localhost # Use containers instead of localhost
bench set-mariadb-host mariadb bench set-mariadb-host mariadb
bench set-redis-cache-host redis://redis:6379 bench set-redis-cache-host redis:6379
bench set-redis-queue-host redis://redis:6379 bench set-redis-queue-host redis:6379
bench set-redis-socketio-host redis://redis:6379 bench set-redis-socketio-host redis:6379
# Remove redis, watch from Procfile # Remove redis, watch from Procfile
sed -i '/redis/d' ./Procfile sed -i '/redis/d' ./Procfile
@@ -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 updated: fd5252663b...c5faaae38e

View File

@@ -1,106 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
Apps: typeof import('./src/components/Apps.vue')['default']
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
Assessments: typeof import('./src/components/Assessments.vue')['default']
Assignment: typeof import('./src/components/Assignment.vue')['default']
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
BatchDashboard: typeof import('./src/components/BatchDashboard.vue')['default']
BatchFeedback: typeof import('./src/components/BatchFeedback.vue')['default']
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
DesktopLayout: typeof import('./src/components/DesktopLayout.vue')['default']
DiscussionModal: typeof import('./src/components/Modals/DiscussionModal.vue')['default']
DiscussionReplies: typeof import('./src/components/DiscussionReplies.vue')['default']
Discussions: typeof import('./src/components/Discussions.vue')['default']
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplates: typeof import('./src/components/Settings/EmailTemplates.vue')['default']
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
Evaluators: typeof import('./src/components/Settings/Evaluators.vue')['default']
Event: typeof import('./src/components/Modals/Event.vue')['default']
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
JobCard: typeof import('./src/components/JobCard.vue')['default']
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default']
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
Members: typeof import('./src/components/Settings/Members.vue')['default']
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
Play: typeof import('./src/components/Icons/Play.vue')['default']
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
Question: typeof import('./src/components/Modals/Question.vue')['default']
Quiz: typeof import('./src/components/Quiz.vue')['default']
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
RelatedCourses: typeof import('./src/components/RelatedCourses.vue')['default']
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
Settings: typeof import('./src/components/Settings/Settings.vue')['default']
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
Tags: typeof import('./src/components/Tags.vue')['default']
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
}
}

View File

@@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="{{ favicon }}" /> <link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ title }}</title> <title>Frappe Learning</title>
<meta name="title" content="{{ meta.title }}" /> <meta name="title" content="{{ meta.title }}" />
<meta name="image" content="{{ meta.image }}" /> <meta name="image" content="{{ meta.image }}" />
<meta name="description" content="{{ meta.description }}" /> <meta name="description" content="{{ meta.description }}" />
@@ -23,10 +23,25 @@
<p> <p>
{{ meta.description }} {{ meta.description }}
</p> </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> <a href="{{ meta.link }}">Know More</a>
</div> </div>
</div> </div>
<div id="modals"></div>
<div id="popovers"></div>
<script> <script>
window.csrf_token = '{{ csrf_token }}'
document.getElementById('seo-content').style.display = 'none'; document.getElementById('seo-content').style.display = 'none';
</script> </script>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>

View File

@@ -2,7 +2,6 @@
"name": "frappe-ui-frontend", "name": "frappe-ui-frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"serve": "vite preview", "serve": "vite preview",
@@ -11,37 +10,24 @@
}, },
"dependencies": { "dependencies": {
"@editorjs/checklist": "^1.6.0", "@editorjs/checklist": "^1.6.0",
"@editorjs/code": "^2.9.0",
"@editorjs/editorjs": "^2.29.0", "@editorjs/editorjs": "^2.29.0",
"@editorjs/embed": "^2.7.0", "@editorjs/embed": "^2.7.0",
"@editorjs/header": "^2.8.1", "@editorjs/header": "^2.8.1",
"@editorjs/inline-code": "^1.5.0", "@editorjs/image": "^2.9.0",
"@editorjs/nested-list": "^1.4.2", "@editorjs/nested-list": "^1.4.2",
"@editorjs/paragraph": "^2.11.3", "@editorjs/paragraph": "^2.11.3",
"@editorjs/simple-image": "^1.6.0",
"@editorjs/table": "^2.4.2",
"@vueuse/router": "^12.7.0",
"ace-builds": "^1.36.2",
"apexcharts": "^4.3.0",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"frappe-ui": "^0.1.147", "frappe-ui": "^0.1.50",
"highlight.js": "^11.11.1", "lucide-vue-next": "^0.309.0",
"lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"plyr": "^3.7.8",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"tailwindcss": "3.4.15", "tailwindcss": "^3.3.3",
"typescript": "^5.7.2", "vue": "^3.2.25",
"vue": "^3.4.23",
"vue-chartjs": "^5.3.0", "vue-chartjs": "^5.3.0",
"vue-draggable-next": "^2.2.1", "vue-router": "^4.0.12"
"vue-router": "^4.0.12",
"vue3-apexcharts": "^1.8.0",
"vuedraggable": "4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "^5.0.3",

View File

@@ -1,4 +1,4 @@
export default { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +0,0 @@
<svg width="80" height="79" viewBox="0 0 80 79" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M57.1285 0.580383H22.8514C10.2309 0.580383 0 10.5649 0 22.8815V56.3332C0 68.6497 10.2309 78.6343 22.8514 78.6343H57.1285C69.749 78.6343 79.9799 68.6497 79.9799 56.3332V22.8815C79.9799 10.5649 69.749 0.580383 57.1285 0.580383Z" fill="#0E7159"/>
<path d="M62.8434 23.6906L60.7869 23.1052C53.6744 21.0702 45.9048 22.4641 39.992 26.8128C35.8502 23.7742 30.7943 22.1854 25.7099 22.2133H17.1406V27.8163H25.7099C29.6232 27.8163 33.508 29.015 36.6787 31.3845L39.992 33.8377L43.3056 31.3845C47.2475 28.4575 52.3032 27.2588 57.1306 28.0393V50.647C51.1035 49.9223 44.9051 51.4834 39.992 55.0795C35.8502 52.0688 30.8515 50.4798 25.7671 50.4798C24.7959 50.4798 23.8247 50.5355 22.8535 50.647V35.0642H17.1406V57.0588H62.8434V23.7185V23.6906Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 856 B

View File

@@ -1,57 +1,25 @@
<template> <template>
<FrappeUIProvider> <Layout>
<Layout> <router-view />
<div class="text-base"> </Layout>
<router-view /> <Dialogs />
</div> <Toasts />
</Layout>
<Dialogs />
</FrappeUIProvider>
</template> </template>
<script setup> <script setup>
import { FrappeUIProvider } from 'frappe-ui' import { Toasts } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs' import { Dialogs } from '@/utils/dialogs'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, defineAsyncComponent } from 'vue'
import { useScreenSize } from './utils/composables' import { useScreenSize } from './utils/composables'
import DesktopLayout from './components/DesktopLayout.vue' import DesktopLayout from './components/DesktopLayout.vue'
import MobileLayout from './components/MobileLayout.vue' import MobileLayout from './components/MobileLayout.vue'
import NoSidebarLayout from './components/NoSidebarLayout.vue'
import { usersStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { posthogSettings } from '@/telemetry'
const screenSize = useScreenSize() const screenSize = useScreenSize()
const router = useRouter()
const noSidebar = ref(false)
const { userResource } = usersStore()
router.beforeEach((to, from, next) => {
if (to.query.fromLesson || to.path === '/persona') {
noSidebar.value = true
} else {
noSidebar.value = false
}
next()
})
const Layout = computed(() => { const Layout = computed(() => {
if (noSidebar.value) {
return NoSidebarLayout
}
if (screenSize.width < 640) { if (screenSize.width < 640) {
return MobileLayout return MobileLayout
} } else {
return DesktopLayout
return DesktopLayout
})
onUnmounted(() => {
noSidebar.value = false
})
watch(userResource, () => {
if (userResource.data) {
posthogSettings.reload()
} }
}) })
</script> </script>

View File

@@ -5,7 +5,7 @@
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center"> <div class="flex items-center">
<Avatar :label="comm.sender_full_name" size="lg" /> <Avatar :label="comm.sender_full_name" size="lg" />
<div class="ml-2 text-ink-gray-7"> <div class="ml-2">
{{ comm.sender_full_name }} {{ comm.sender_full_name }}
</div> </div>
</div> </div>
@@ -14,18 +14,18 @@
</div> </div>
</div> </div>
<div <div
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md" class="prose prose-sm bg-gray-50 !min-w-full px-4 py-2 rounded-md"
v-html="comm.content" v-html="comm.content"
></div> ></div>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="text-sm italic text-ink-gray-5"> <div v-else class="text-sm italic text-gray-600">
{{ __('No announcements') }} {{ __('No announcements') }}
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, Avatar } from 'frappe-ui' import { createListResource, Avatar } from 'frappe-ui'
import { timeAgo } from '@/utils' import { timeAgo } from '@/utils'
const props = defineProps({ const props = defineProps({
@@ -35,15 +35,24 @@ const props = defineProps({
}, },
}) })
const communications = createResource({ const communications = createListResource({
url: 'lms.lms.api.get_announcements', doctype: 'Communication',
makeParams(value) { fields: [
return { 'subject',
batch: props.batch, 'content',
} 'recipients',
'cc',
'communication_date',
'sender',
'sender_full_name',
],
filters: {
reference_doctype: 'LMS Batch',
reference_name: props.batch,
}, },
orderBy: 'communication_date desc',
auto: true, auto: true,
cache: ['announcement', props.batch], cache: ['batch', props.batch],
}) })
</script> </script>
<style> <style>

View File

@@ -1,642 +1,55 @@
<template> <template>
<div <div
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out border-r bg-surface-menu-bar" class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'" :class="isSidebarCollapsed ? 'w-14' : 'w-56'"
> >
<div <div
class="flex flex-col overflow-hidden" class="flex flex-col overflow-hidden"
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''" :class="isSidebarCollapsed ? 'items-center' : ''"
> >
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" /> <UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
<div class="flex flex-col" v-if="sidebarSettings.data"> <div class="flex flex-col overflow-y-auto">
<SidebarLink <SidebarLink
v-for="link in sidebarLinks" v-for="link in links"
:link="link" :link="link"
:isCollapsed="sidebarStore.isSidebarCollapsed" :isCollapsed="isSidebarCollapsed"
class="mx-2 my-0.5" class="mx-2 my-0.5"
/> />
</div> </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="toggleWebPages"
>
<div
v-if="!sidebarStore.isSidebarCollapsed"
class="flex items-center text-sm text-ink-gray-5 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-ink-gray-9 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': !sidebarStore.isWebpagesCollapsed }"
/>
</span>
<span class="ml-2">
{{ __('More') }}
</span>
</div>
<Button
v-if="isModerator && !readOnlyMode"
variant="ghost"
@click="openPageModal()"
>
<template #icon>
<Plus class="h-4 w-4 text-ink-gray-7 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="!sidebarStore.isWebpagesCollapsed ? '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> </div>
<div class="m-2 flex flex-col gap-1"> <SidebarLink
<div :link="{
v-if="readOnlyMode && !sidebarStore.isSidebarCollapsed" label: isSidebarCollapsed ? 'Expand' : 'Collapse',
class="z-10 m-2 bg-surface-modal py-2.5 px-3 text-xs text-ink-gray-7 leading-5 rounded-md" }"
> :isCollapsed="isSidebarCollapsed"
{{ @click="isSidebarCollapsed = !isSidebarCollapsed"
__( class="m-2"
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.' >
) <template #icon>
}} <span class="grid h-5 w-6 flex-shrink-0 place-items-center">
</div>
<TrialBanner
v-if="
userResource.data?.is_system_manager && userResource.data?.is_fc_site
"
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
/>
<GettingStartedBanner
v-if="showOnboarding && !isOnboardingStepsCompleted"
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
appName="learning"
/>
<div
class="flex items-center mt-4"
:class="
sidebarStore.isSidebarCollapsed ? 'flex-col space-y-3' : 'flex-row'
"
>
<div
class="flex items-center flex-1"
:class="
sidebarStore.isSidebarCollapsed
? 'flex-col space-y-3'
: 'flex-row space-x-3'
"
>
<Tooltip v-if="readOnlyMode && sidebarStore.isSidebarCollapsed">
<CircleAlert
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
/>
<template #body>
<div
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-center text-p-xs text-ink-white shadow-xl"
>
{{
__(
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
)
}}
</div>
</template>
</Tooltip>
<Tooltip :text="__('Powered by Learning')">
<Zap
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="redirectToWebsite()"
/>
</Tooltip>
<Tooltip v-if="showOnboarding" :text="__('Help')">
<CircleHelp
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="
() => {
showHelpModal = minimize ? true : !showHelpModal
minimize = !showHelpModal
}
"
/>
</Tooltip>
</div>
<Tooltip
:text="
sidebarStore.isSidebarCollapsed ? __('Expand') : __('Collapse')
"
>
<CollapseSidebar <CollapseSidebar
class="size-4 text-ink-gray-7 duration-300 stroke-1.5 ease-in-out cursor-pointer" class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
:class="{ :class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }"
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
@click="toggleSidebar()"
/> />
</Tooltip> </span>
</div> </template>
</div> </SidebarLink>
<HelpModal
v-if="showOnboarding && showHelpModal"
v-model="showHelpModal"
v-model:articles="articles"
appName="learning"
title="Frappe Learning"
:logo="LMSLogo"
:afterSkip="(step) => capture('onboarding_step_skipped_' + step)"
:afterSkipAll="() => capture('onboarding_steps_skipped')"
:afterReset="(step) => capture('onboarding_step_reset_' + step)"
:afterResetAll="() => capture('onboarding_steps_reset')"
docsLink="https://docs.frappe.io/learning"
/>
<IntermediateStepModal
v-model="showIntermediateModal"
:currentStep="currentStep"
/>
</div> </div>
<PageModal
v-model="showPageModal"
v-model:reloadSidebar="sidebarSettings"
:page="pageToEdit"
/>
</template> </template>
<script setup> <script setup>
import UserDropdown from '@/components/UserDropdown.vue' import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue' import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import { import { useStorage } from '@vueuse/core'
ref, import { ref } from 'vue'
onMounted, import { getSidebarLinks } from '../utils'
inject,
watch,
reactive,
markRaw,
h,
onUnmounted,
} 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 { Button, createResource, Tooltip } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue'
import { capture } from '@/telemetry'
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { useRouter } from 'vue-router'
import InviteIcon from './Icons/InviteIcon.vue'
import {
BookOpen,
CircleAlert,
ChevronRight,
Plus,
CircleHelp,
FolderTree,
FileText,
UserPlus,
Users,
BookText,
Zap,
} from 'lucide-vue-next'
import {
TrialBanner,
HelpModal,
GettingStartedBanner,
useOnboarding,
showHelpModal,
minimize,
IntermediateStepModal,
} from 'frappe-ui/frappe'
const { user, sidebarSettings } = sessionStore() const links = getSidebarLinks()
const { userResource } = usersStore()
let sidebarStore = useSidebar() const getSidebarFromStorage = () => {
const socket = inject('$socket') return useStorage('sidebar_is_collapsed', false)
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 settingsStore = useSettings()
const showOnboarding = ref(false)
const showIntermediateModal = ref(false)
const currentStep = ref({})
const router = useRouter()
let onboardingDetails
let isOnboardingStepsCompleted = false
const readOnlyMode = window.read_only_mode
const iconProps = {
strokeWidth: 1.5,
width: 16,
height: 16,
} }
onMounted(() => { let isSidebarCollapsed = ref(getSidebarFromStorage())
addNotifications()
setSidebarLinks()
setUpOnboarding()
socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload()
})
})
const setSidebarLinks = () => {
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',
'QuizSubmissionList',
'QuizSubmission',
],
})
}
}
const addAssignments = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
activeFor: [
'Assignments',
'AssignmentForm',
'AssignmentSubmissionList',
'AssignmentSubmission',
],
})
}
}
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 toggleSidebar = () => {
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
localStorage.setItem(
'isSidebarCollapsed',
JSON.stringify(sidebarStore.isSidebarCollapsed)
)
}
const toggleWebPages = () => {
sidebarStore.isWebpagesCollapsed = !sidebarStore.isWebpagesCollapsed
localStorage.setItem(
'isWebpagesCollapsed',
JSON.stringify(sidebarStore.isWebpagesCollapsed)
)
}
const getFirstCourse = async () => {
let firstCourse = localStorage.getItem('firstCourse')
if (firstCourse) return firstCourse
return await call('lms.lms.onboarding.get_first_course')
}
const getFirstBatch = async () => {
let firstBatch = localStorage.getItem('firstBatch')
if (firstBatch) return firstBatch
return await call('lms.lms.onboarding.get_first_batch')
}
const steps = reactive([
{
name: 'create_first_course',
title: __('Create your first course'),
icon: markRaw(h(BookOpen, iconProps)),
completed: false,
onClick: () => {
minimize.value = true
router.push({
name: 'Courses',
})
},
},
{
name: 'create_first_chapter',
title: __('Add your first chapter'),
icon: markRaw(h(FolderTree, iconProps)),
completed: false,
dependsOn: 'create_first_course',
onClick: async () => {
minimize.value = true
let course = await getFirstCourse()
if (course) {
router.push({ name: 'CourseForm', params: { courseName: course } })
} else {
router.push({ name: 'CourseForm' })
}
},
},
{
name: 'create_first_lesson',
title: __('Add your first lesson'),
icon: markRaw(h(FileText, iconProps)),
completed: false,
dependsOn: 'create_first_chapter',
onClick: async () => {
minimize.value = true
let course = await getFirstCourse()
if (course) {
router.push({
name: 'CourseForm',
params: { courseName: course },
})
} else {
router.push({ name: 'Courses' })
}
},
},
{
name: 'create_first_quiz',
title: __('Create your first quiz'),
icon: markRaw(h(CircleHelp, iconProps)),
completed: false,
dependsOn: 'create_first_course',
onClick: () => {
minimize.value = true
router.push({ name: 'Quizzes' })
},
},
{
name: 'invite_students',
title: __('Invite your team and students'),
icon: markRaw(h(InviteIcon, iconProps)),
completed: false,
onClick: () => {
minimize.value = true
settingsStore.activeTab = 'Members'
settingsStore.isSettingsOpen = true
},
},
{
name: 'create_first_batch',
title: __('Create your first batch'),
icon: markRaw(h(Users, iconProps)),
completed: false,
onClick: () => {
minimize.value = true
router.push({ name: 'Batches' })
},
},
{
name: 'add_batch_student',
title: __('Add students to your batch'),
icon: markRaw(h(UserPlus, iconProps)),
completed: false,
dependsOn: 'create_first_batch',
onClick: async () => {
minimize.value = true
let batch = await getFirstBatch()
if (batch) {
router.push({
name: 'Batch',
params: {
batchName: batch,
},
})
} else {
router.push({ name: 'Batch' })
}
},
},
{
name: 'add_batch_course',
title: __('Add courses to your batch'),
icon: markRaw(h(BookText, iconProps)),
completed: false,
dependsOn: 'create_first_batch',
onClick: async () => {
minimize.value = true
let batch = await getFirstBatch()
if (batch) {
router.push({
name: 'Batch',
params: {
batchName: batch,
},
hash: '#courses',
})
} else {
router.push({ name: 'Batch' })
}
},
},
])
const articles = ref([
{
title: __('Introduction'),
opened: false,
subArticles: [
{ name: 'introduction', title: __('Introduction') },
{ name: 'setting-up', title: __('Setting up') },
],
},
{
title: __('Creating a course'),
opened: false,
subArticles: [
{ name: 'create-a-course', title: __('Create a course') },
{ name: 'add-a-chapter', title: __('Add a chapter') },
{ name: 'add-a-lesson', title: __('Add a lesson') },
],
},
{
title: __('Creating a batch'),
opened: false,
subArticles: [
{ name: 'create-a-batch', title: __('Create a batch') },
{ name: 'create-a-live-class', title: __('Create a live class') },
],
},
{
title: __('Assessments'),
opened: false,
subArticles: [
{ name: 'quizzes', title: __('Quizzes') },
{ name: 'assignments', title: __('Assignments') },
],
},
{
title: __('Certification'),
opened: false,
subArticles: [
{ name: 'issue-a-certificate', title: __('Issue a Certificate') },
{
name: 'custom-certificate-templates',
title: __('Custom Certificate Templates'),
},
],
},
{
title: __('Monetization'),
opened: false,
subArticles: [
{
name: 'setting-up-payment-gateway',
title: __('Setting up payment gateway'),
},
],
},
{
title: __('Settings'),
opened: false,
subArticles: [{ name: 'roles', title: __('Roles') }],
},
])
const setUpOnboarding = () => {
if (userResource.data?.is_system_manager) {
onboardingDetails = useOnboarding('learning')
onboardingDetails.setUp(steps)
isOnboardingStepsCompleted = onboardingDetails.isOnboardingStepsCompleted
showOnboarding.value = true
}
}
watch(userResource, () => {
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addPrograms()
addQuizzes()
addAssignments()
setUpOnboarding()
}
})
const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
</script> </script>

View File

@@ -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-ink-gray-7 hover:bg-surface-gray-2',
]"
@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-surface-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-surface-gray-2"
>
<img class="size-8" :src="app.logo" />
<div class="text-sm text-ink-gray-7" @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>

View File

@@ -1,75 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-4">
<div v-if="type == 'quiz'" class="text-lg font-semibold">
{{ __('Add a quiz to your lesson') }}
</div>
<div v-else class="text-lg font-semibold">
{{ __('Add an assignment to your lesson') }}
</div>
<div>
<Link
v-if="type == 'quiz'"
v-model="quiz"
doctype="LMS Quiz"
:label="__('Select a quiz')"
:onCreate="(value, close) => redirectToForm()"
/>
<Link
v-else
v-model="assignment"
doctype="LMS Assignment"
:label="__('Select an assignment')"
:onCreate="(value, close) => redirectToForm()"
/>
</div>
<div class="flex justify-end space-x-2">
<Button variant="solid" @click="addAssessment()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Button } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const quiz = ref(null)
const assignment = ref(null)
const props = defineProps({
type: {
type: String,
required: true,
},
onAddition: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
show.value = true
})
const addAssessment = () => {
props.onAddition(props.type == 'quiz' ? quiz.value : assignment.value)
show.value = false
}
const redirectToForm = () => {
if (props.type == 'quiz') window.open('/lms/quizzes/new', '_blank')
else window.open('/lms/assignments/new', '_blank')
}
</script>

View File

@@ -1,106 +1,49 @@
<template> <template>
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="text-lg font-semibold mb-4">
<div class="text-lg font-semibold text-ink-gray-9"> {{ __('Assessments') }}
{{ __('Assessments') }}
</div>
<Button v-if="canAddAssessments()" @click="showModal = true">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div> </div>
<div v-if="assessments.data?.length" class="text-sm"> <div v-if="assessments.data?.length">
<ListView <ListView
:columns="getAssessmentColumns()" :columns="getAssessmentColumns()"
:rows="assessments.data" :rows="assessments.data"
row-key="name" row-key="name"
:options="{ :options="{
selectable: false,
showTooltip: false, showTooltip: false,
getRowRoute: (row) => getRowRoute(row), getRowRoute: (row) => {
selectable: user.data?.is_student ? false : true, 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',
},
}
}
},
}" }"
> >
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 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 v-if="column.key == 'assessment_type'">
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
</div>
<div v-else-if="column.key == 'title'">
{{ row[column.key] }}
</div>
<div v-else-if="isNaN(row[column.key])">
<Badge :theme="getStatusTheme(row[column.key])">
{{ row[column.key] }}
</Badge>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAssessments(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView> </ListView>
</div> </div>
<div v-else class="text-sm italic text-ink-gray-5"> <div v-else class="text-sm italic text-gray-600">
{{ __('No Assessments') }} {{ __('No Assessments') }}
</div> </div>
</div> </div>
<AssessmentModal
v-model="showModal"
v-model:assessments="assessments"
:batch="props.batch"
/>
</template> </template>
<script setup> <script setup>
import { import { ListView, createResource } from 'frappe-ui'
ListView, import { inject } from 'vue'
ListRow,
ListRows,
ListHeader,
ListHeaderItem,
ListRowItem,
ListSelectBanner,
createResource,
Button,
Badge,
} 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 user = inject('$user')
const showModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -131,94 +74,25 @@ const assessments = createResource({
auto: true, 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: {
assignmentID: row.assessment_name,
submissionName: row.submission.name,
},
}
} else {
return {
name: 'AssignmentSubmission',
params: {
assignmentID: row.assessment_name,
submissionName: 'new',
},
}
}
} else {
return {
name: 'QuizPage',
params: {
quizID: row.assessment_name,
},
}
}
}
const canAddAssessments = () => {
if (readOnlyMode) return false
return user.data?.is_moderator || user.data?.is_evaluator
}
const getAssessmentColumns = () => { const getAssessmentColumns = () => {
let columns = [ let columns = [
{ {
label: 'Assessment', label: 'Assessment',
key: 'title', key: 'title',
width: '25rem',
}, },
{ {
label: 'Type', label: 'Type',
key: 'assessment_type', key: 'assessment_type',
width: '15rem',
}, },
] ]
if (!user.data?.is_moderator) { if (!user.data?.is_moderator) {
columns.push({ columns.push({
label: 'Status/Percentage', label: 'Status/Score',
key: 'status', key: 'status',
align: 'left', align: 'center',
width: '10rem',
}) })
} }
return columns return columns
} }
const getStatusTheme = (status) => {
if (status === 'Pass') {
return 'green'
} else if (status === 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script> </script>

View File

@@ -1,477 +0,0 @@
<template>
<div
v-if="assignment.data"
class="grid grid-cols-2 h-full"
:class="{ 'border rounded-lg overflow-auto': !showTitle }"
>
<div
class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]"
:class="{ 'h-full': !showTitle }"
>
<div v-if="showTitle" class="text-lg font-semibold mb-5 text-ink-gray-9">
<div v-if="submissionName === 'new'">
{{ __('Submission by') }} {{ user.data?.full_name }}
</div>
<div v-else>
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
</div>
</div>
<div class="text-sm text-ink-gray-7 font-medium mb-2">
{{ __('Question') }}:
</div>
<div
v-html="assignment.data.question"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
></div>
</div>
<div class="flex flex-col">
<div class="p-5">
<div class="flex items-center justify-between mb-4">
<div class="font-semibold text-ink-gray-9">
{{ __('Submission') }}
</div>
<div class="flex items-center space-x-2">
<Badge v-if="isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<Badge
v-else-if="submissionResource.doc?.status"
:theme="statusTheme"
size="lg"
>
{{ submissionResource.doc?.status }}
</Badge>
<Button variant="solid" @click="submitAssignment()">
{{ __('Save') }}
</Button>
</div>
</div>
<div
v-if="
submissionName != 'new' &&
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
submissionResource.doc?.owner == user.data?.name
"
class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm mb-4"
>
{{ __("You've successfully submitted the assignment.") }}
{{
__(
"Once the moderator grades your submission, you'll find the details here."
)
}}
{{ __('Feel free to make edits to your submission if needed.') }}
</div>
<div v-if="showUploader()">
<div class="text-xs text-ink-gray-5 mt-1 mb-2">
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
</div>
<FileUploader
v-if="!submissionFile"
:fileTypes="getType()"
:validateFile="validateFile"
@success="(file) => saveSubmission(file)"
>
<template #default="{ uploading, progress, openFileSelector }">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? __('Uploading {0}%').format(progress)
: __('Upload File')
}}
</Button>
</template>
</FileUploader>
<div v-else>
<div class="flex text-ink-gray-7">
<div class="border self-start rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5" />
</div>
<a
:href="submissionFile.file_url"
target="_blank"
class="flex flex-col cursor-pointer !no-underline"
>
<span class="text-sm leading-5">
{{ submissionFile.file_name }}
</span>
<span class="text-sm text-ink-gray-5 mt-1">
{{ getFileSize(submissionFile.file_size) }}
</span>
</a>
<X
v-if="canModifyAssignment"
@click="removeSubmission()"
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
<div v-else-if="assignment.data.type == 'URL'">
<div class="text-xs text-ink-gray-5 mb-1">
{{ __('Enter a URL') }}
</div>
<FormControl
v-model="answer"
type="text"
:readonly="!canModifyAssignment"
/>
</div>
<div v-else>
<div class="text-sm mb-2 text-ink-gray-7">
{{ __('Write your answer here') }}
</div>
<TextEditor
:content="answer"
@change="(val) => (answer = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<div
v-if="
user.data?.name == submissionResource.doc?.owner &&
submissionResource.doc?.comments
"
class="mt-8 p-3 bg-surface-blue-2 rounded-md"
>
<div class="text-sm text-ink-gray-5 font-medium mb-2">
{{ __('Comments by Evaluator') }}:
</div>
<div
class="leading-5 text-ink-gray-9"
v-html="submissionResource.doc.comments"
></div>
</div>
<!-- Grading -->
<div v-if="canGradeSubmission" class="mt-8 space-y-4">
<div class="font-semibold mb-2 text-ink-gray-9">
{{ __('Grading') }}
</div>
<FormControl
v-if="submissionResource.doc"
v-model="submissionResource.doc.status"
:label="__('Grade')"
type="select"
:options="submissionStatusOptions"
/>
<div>
<div class="text-sm text-ink-gray-5 mb-1">
{{ __('Comments') }}
</div>
<TextEditor
:content="comments"
@change="
(val) => {
comments = val
isDirty = true
}
"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
Badge,
Button,
call,
createResource,
createDocumentResource,
FileUploader,
FormControl,
TextEditor,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { getFileSize } from '@/utils'
import { useRouter } from 'vue-router'
const submissionFile = ref(null)
const answer = ref(null)
const comments = ref(null)
const router = useRouter()
const user = inject('$user')
const isDirty = ref(false)
const props = defineProps({
assignmentID: {
type: String,
required: true,
},
submissionName: {
type: String,
default: 'new',
},
showTitle: {
type: Boolean,
default: true,
},
})
onMounted(() => {
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
submitAssignment()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const assignment = createResource({
url: 'frappe.client.get',
params: {
doctype: 'LMS Assignment',
name: props.assignmentID,
},
auto: true,
onSuccess(data) {
if (props.submissionName != 'new') {
submissionResource.reload()
}
},
})
const newSubmission = createResource({
url: 'frappe.client.insert',
makeParams(values) {
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentID,
member: user.data?.name,
}
if (showUploader()) {
doc.assignment_attachment = submissionFile.value.file_url
} else {
doc.answer = answer.value
}
return {
doc: doc,
}
},
})
const imageResource = createResource({
url: 'lms.lms.api.get_file_info',
makeParams(values) {
return {
file_url: values.image,
}
},
auto: false,
onSuccess(data) {
submissionFile.value = data
},
})
const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission',
name: props.submissionName,
onError(err) {
toast.error(err.messages?.[0] || err)
},
auto: false,
cache: [user.data?.name, props.assignmentID],
})
watch(submissionResource, () => {
if (submissionResource.doc) {
if (submissionResource.doc.assignment_attachment) {
imageResource.reload({
image: submissionResource.doc.assignment_attachment,
})
}
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
if (submissionResource.doc.comments) {
comments.value = submissionResource.doc.comments
}
if (submissionResource.isDirty) {
isDirty.value = true
} else if (showUploader() && !submissionFile.value) {
isDirty.value = true
} else if (!showUploader() && !answer.value) {
isDirty.value = true
} else {
isDirty.value = false
}
}
})
watch(submissionFile, () => {
if (props.submissionName == 'new' && submissionFile.value) {
isDirty.value = true
}
})
const submitAssignment = () => {
if (props.submissionName != 'new') {
let evaluator =
submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name
: null
submissionResource.setValue.submit(
{
...submissionResource.doc,
assignment_attachment: submissionFile.value?.file_url,
evaluator: evaluator,
comments: comments.value,
answer: answer.value,
},
{
onSuccess(data) {
toast.success(__('Changes saved successfully'))
},
}
)
} else {
addNewSubmission()
}
}
const addNewSubmission = () => {
newSubmission.submit(
{},
{
onSuccess(data) {
toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
query: { fromLesson: router.currentRoute.value.query.fromLesson },
})
} else {
markLessonProgress()
router.go()
}
submissionResource.name = data.name
submissionResource.reload()
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const saveSubmission = (file) => {
isDirty.value = true
submissionFile.value = file
}
const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') {
let courseName = router.currentRoute.value.params.courseName
let chapterNumber = router.currentRoute.value.params.chapterNumber
let lessonNumber = router.currentRoute.value.params.lessonNumber
call('lms.lms.api.mark_lesson_progress', {
course: courseName,
chapter_number: chapterNumber,
lesson_number: lessonNumber,
})
}
}
const getType = () => {
const type = assignment.data?.type
if (type == 'Image') {
return ['image/*']
} else if (type == 'Document') {
return [
'.doc',
'.docx',
'.xml',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]
} else if (type == 'PDF') {
return ['.pdf']
}
}
const validateFile = (file) => {
let type = assignment.data?.type
let extension = file.name.split('.').pop().toLowerCase()
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
} else if (
type == 'Document' &&
!['doc', 'docx', 'xml'].includes(extension)
) {
return 'Only document file is allowed.'
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
return 'Only PDF file is allowed.'
}
}
const removeSubmission = () => {
isDirty.value = true
submissionFile.value = null
}
const canGradeSubmission = computed(() => {
return (
(user.data?.is_moderator ||
user.data?.is_evaluator ||
user.data?.is_instructor) &&
props.submissionName != 'new' &&
router.currentRoute.value.name == 'AssignmentSubmission'
)
})
const canModifyAssignment = computed(() => {
return (
!submissionResource.doc ||
(submissionResource.doc?.owner == user.data?.name &&
submissionResource.doc?.status == 'Not Graded')
)
})
const submissionStatusOptions = computed(() => {
return [
{ label: 'Not Graded', value: 'Not Graded' },
{ label: 'Pass', value: 'Pass' },
{ label: 'Fail', value: 'Fail' },
]
})
const statusTheme = computed(() => {
if (!submissionResource.doc) {
return 'orange'
} else if (submissionResource.doc.status == 'Pass') {
return 'green'
} else if (submissionResource.doc.status == 'Not Graded') {
return 'blue'
} else {
return 'red'
}
})
const showUploader = () => {
return ['PDF', 'Image', 'Document'].includes(assignment.data?.type)
}
</script>

View File

@@ -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-ink-gray-9" />
<Pause v-else class="w-4 h-4 text-ink-gray-9" />
</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-ink-gray-9 font-medium">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
<Button variant="ghost" @click="toggleMute">
<template #icon>
<Volume2 v-if="!isMuted" class="w-4 h-4 text-ink-gray-9" />
<VolumeX v-else class="w-4 h-4 text-ink-gray-9" />
</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>

View File

@@ -1,81 +1,59 @@
<template> <template>
<div <div
class="flex flex-col border hover:border-outline-gray-3 rounded-md p-4 h-full" class="flex flex-col border border-gray-200 rounded-md p-4 h-full"
style="min-height: 150px" style="min-height: 150px"
> >
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9"> <Badge
{{ batch.title }}
</div>
<div
v-if="batch.seat_count && batch.seats_left > 0" v-if="batch.seat_count && batch.seats_left > 0"
class="text-xs bg-green-100 text-green-700 self-start px-2 py-0.5 rounded-md" theme="green"
class="self-start mb-2"
> >
{{ batch.seats_left }} {{ batch.seats_left }} {{ __('Seat Left') }}
<span v-if="batch.seats_left > 1"> </Badge>
{{ __('Seats Left') }} <Badge
</span>
<span v-else-if="batch.seats_left == 1">
{{ __('Seat Left') }}
</span>
</div>
<div
v-else-if="batch.seat_count && batch.seats_left <= 0" v-else-if="batch.seat_count && batch.seats_left <= 0"
class="text-xs bg-red-100 text-red-700 self-start px-2 py-0.5 rounded-md" theme="red"
class="self-start mb-2"
> >
{{ __('Sold Out') }} {{ __('Sold Out') }}
</Badge>
<div class="text-xl font-semibold mb-1">
{{ batch.title }}
</div> </div>
<div class="short-introduction text-sm text-ink-gray-7"> <div class="short-introduction">
{{ batch.description }} {{ batch.description }}
</div> </div>
<div v-if="batch.amount" class="font-semibold text-ink-gray-9 mb-4"> <div class="mt-auto">
{{ batch.price }} <div v-if="batch.amount" class="font-semibold text-lg mb-4">
</div> {{ batch.price }}
<div class="flex flex-col space-y-2 mt-auto"> </div>
<DateRange <div class="flex items-center mb-3">
:startDate="batch.start_date" <BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
:endDate="batch.end_date" <span> {{ batch.courses.length }} {{ __('Courses') }} </span>
class="text-sm text-ink-gray-7" </div>
/> <div class="flex items-center mb-3">
<div class="flex items-center text-sm text-ink-gray-7"> <Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-ink-gray-7" /> <span>
{{ dayjs(batch.start_date).format('DD MMM YYYY') }} -
{{ dayjs(batch.end_date).format('DD MMM YYYY') }}
</span>
</div>
<div class="flex items-center">
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span> <span>
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }} {{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
</span> </span>
</div> </div>
<div
v-if="batch.timezone"
class="flex items-center text-sm text-ink-gray-7"
>
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-ink-gray-5" />
<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>
</div> </div>
</template> </template>
<script setup> <script setup>
import { formatTime } from '@/utils' import { Calendar, Clock, BookOpen } from 'lucide-vue-next'
import { Clock, Globe } from 'lucide-vue-next' import { inject } from 'vue'
import DateRange from '@/components/Common/DateRange.vue' import { Badge } from 'frappe-ui'
import CourseInstructors from '@/components/CourseInstructors.vue' import { formatTime } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue'
const dayjs = inject('$dayjs')
const props = defineProps({ const props = defineProps({
batch: { batch: {
type: Object, type: Object,
@@ -91,20 +69,7 @@ const props = defineProps({
text-overflow: ellipsis; text-overflow: ellipsis;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
margin: 0.25rem 0 1rem; margin: 0.25rem 0 1.25rem;
line-height: 1.5; 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> </style>

View File

@@ -1,32 +1,29 @@
<template> <template>
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold text-ink-gray-9"> <div class="text-xl font-semibold">
{{ __('Courses') }} {{ __('Courses') }}
</div> </div>
<Button v-if="canSeeAddButton()" @click="openCourseModal()"> <Button
v-if="user.data?.is_moderator"
variant="solid"
@click="openCourseModal()"
>
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
{{ __('Add') }} {{ __('Add Course') }}
</Button> </Button>
</div> </div>
<div v-if="courses.data?.length"> <div v-if="courses.data?.length">
<ListView <ListView
:columns="getCoursesColumns()" :columns="getCoursesColumns()"
:rows="courses.data" :rows="courses.data"
row-key="batch_course" row-key="name"
:options="{ :options="{ showTooltip: false }"
showTooltip: false,
selectable: user.data?.is_student ? false : true,
getRowRoute: (row) => ({
name: 'CourseDetail',
params: { courseName: row.name },
}),
}"
> >
<ListHeader <ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2" class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
> >
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()"> <ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
<template #prefix="{ item }"> <template #prefix="{ item }">
@@ -52,10 +49,7 @@
<ListSelectBanner> <ListSelectBanner>
<template #actions="{ unselectAll, selections }"> <template #actions="{ unselectAll, selections }">
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button variant="ghost" @click="removeCourses(selections)">
variant="ghost"
@click="removeCourses(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" /> <Trash2 class="h-4 w-4 stroke-1.5" />
</Button> </Button>
</div> </div>
@@ -63,9 +57,6 @@
</ListSelectBanner> </ListSelectBanner>
</ListView> </ListView>
</div> </div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No courses added') }}
</div>
<BatchCourseModal <BatchCourseModal
v-model="showCourseModal" v-model="showCourseModal"
:batch="batch" :batch="batch"
@@ -86,10 +77,8 @@ import {
ListRows, ListRows,
ListView, ListView,
ListRowItem, ListRowItem,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next' import { Plus, Trash2 } from 'lucide-vue-next'
const readOnlyMode = window.read_only_mode
const showCourseModal = ref(false) const showCourseModal = ref(false)
const user = inject('$user') const user = inject('$user')
@@ -106,6 +95,7 @@ const courses = createResource({
params: { params: {
batch: props.batch, batch: props.batch,
}, },
cache: ['batchCourses', props.batchName],
auto: true, auto: true,
}) })
@@ -118,50 +108,34 @@ const getCoursesColumns = () => {
{ {
label: 'Title', label: 'Title',
key: 'title', key: 'title',
width: 2,
}, },
{ {
label: 'Lessons', label: 'Lessons',
key: 'lessons', key: 'lesson_count',
align: 'right',
}, },
{ {
label: 'Enrollments', label: 'Enrollments',
align: 'right', key: 'enrollment_count',
key: 'enrollments',
}, },
] ]
} }
const deleteCourses = createResource({ const removeCourse = createResource({
url: 'lms.lms.api.delete_documents', url: 'frappe.client.delete',
makeParams(values) { makeParams(values) {
return { return {
doctype: 'Batch Course', doctype: 'Batch Course',
documents: values.courses, name: values.course,
} }
}, },
}) })
const removeCourses = (selections, unselectAll) => { const removeCourses = (selections) => {
deleteCourses.submit( console.log(selections)
{ selections.forEach(async (course) => {
courses: Array.from(selections), removeCourse.submit({ course })
}, await setTimeout(1000)
{ })
onSuccess(data) { courses.reload()
courses.reload()
toast.success(__('Courses deleted successfully'))
unselectAll()
},
}
)
}
const canSeeAddButton = () => {
if (readOnlyMode) {
return false
}
return user.data?.is_moderator || user.data?.is_evaluator
} }
</script> </script>

View File

@@ -1,18 +1,17 @@
<template> <template>
<div class="space-y-10"> <div>
<UpcomingEvaluations <UpcomingEvaluations
:batch="batch.data.name" :batch="batch.data.name"
:endDate="batch.data.evaluation_end_date" :endDate="batch.data.evaluation_end_date"
:courses="batch.data.courses" :courses="batch.data.courses"
:isStudent="isStudent"
/> />
<Assessments :batch="batch.data.name" /> <Assessments :batch="batch.data.name" />
<StudentHeatmap />
</div> </div>
</template> </template>
<script setup> <script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue' import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/components/Assessments.vue' import Assessments from '@/components/Assessments.vue'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const props = defineProps({ const props = defineProps({
batch: { batch: {

View File

@@ -1,172 +0,0 @@
<template>
<div v-if="user.data?.is_student">
<div>
<div class="leading-5 mb-4">
<div v-if="readOnly">
{{ __('Thank you for providing your feedback.') }}
<span
@click="showFeedbackForm = !showFeedbackForm"
class="underline cursor-pointer"
>{{ __('Click here') }}</span
>
{{ __('to view your feedback.') }}
</div>
<div v-else>
{{ __('Help us improve by providing your feedback.') }}
</div>
</div>
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="feedback[key]"
:label="__(convertToTitleCase(key))"
:readonly="readOnly"
/>
</div>
<FormControl
v-model="feedback.feedback"
type="textarea"
:label="__('Feedback')"
:rows="9"
:readonly="readOnly"
/>
<Button v-if="!readOnly" @click="submitFeedback">
{{ __('Submit Feedback') }}
</Button>
</div>
</div>
</div>
<div v-else-if="feedbackList.data?.length">
<div class="leading-5 text-sm mb-2 mt-5">
{{ __('Average Feedback Received') }}
</div>
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="average[key]"
:label="__(convertToTitleCase(key))"
:readonly="true"
/>
</div>
<Button variant="outline" class="mt-5" @click="showAllFeedback = true">
{{ __('View all feedback') }}
</Button>
</div>
<div v-else class="text-ink-gray-7 mt-5 leading-5">
{{ __('No feedback received yet.') }}
</div>
<FeedbackModal
v-if="feedbackList.data?.length"
v-model="showAllFeedback"
:feedbackList="feedbackList.data"
/>
</template>
<script setup>
import { inject, onMounted, reactive, ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils'
import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
const user = inject('$user')
const ratingKeys = ['content', 'instructors', 'value']
const readOnly = ref(false)
const average = reactive({})
const feedback = reactive({})
const showFeedbackForm = ref(true)
const showAllFeedback = ref(false)
const props = defineProps({
batch: {
type: String,
required: true,
},
})
onMounted(() => {
let filters = {
batch: props.batch,
}
if (user.data?.is_student) {
filters['member'] = user.data?.name
}
feedbackList.update({
filters: filters,
})
feedbackList.reload()
})
const feedbackList = createListResource({
doctype: 'LMS Batch Feedback',
filters: {
batch: props.batch,
},
fields: [
'content',
'instructors',
'value',
'feedback',
'name',
'member',
'member_name',
'member_image',
],
cache: ['feedbackList', props.batch, user.data?.name],
})
watch(
() => feedbackList.data,
() => {
if (feedbackList.data.length) {
let data = feedbackList.data
readOnly.value = true
showFeedbackForm.value = false
ratingKeys.forEach((key) => {
average[key] = 0
})
data.forEach((row) => {
Object.keys(row).forEach((key) => {
if (ratingKeys.includes(key)) row[key] = row[key] * 5
feedback[key] = row[key]
})
ratingKeys.forEach((key) => {
average[key] += row[key]
})
})
Object.keys(average).forEach((key) => {
average[key] = average[key] / data.length
})
}
}
)
const submitFeedback = () => {
ratingKeys.forEach((key) => {
feedback[key] = feedback[key] / 5
})
feedbackList.insert.submit(
{
member: user.data?.name,
batch: props.batch,
...feedback,
},
{
onSuccess: () => {
feedbackList.reload()
showFeedbackForm.value = false
},
}
)
}
</script>
<style>
.feedback-list > button > div {
align-items: start;
padding: 0.15rem 0;
}
</style>

View File

@@ -1,136 +1,103 @@
<template> <template>
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72"> <div v-if="batch.data" class="shadow rounded-md p-5" style="width: 300px">
<div <Badge
v-if="batch.data.seat_count && seats_left > 0" v-if="batch.data.seat_count && seats_left > 0"
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md" theme="green"
:class=" class="self-start mb-2 float-right"
batch.data.amount || batch.data.courses.length
? 'float-right'
: 'w-fit mb-4'
"
> >
{{ seats_left }} {{ seats_left }} {{ __('Seat Left') }}
<span v-if="seats_left > 1"> </Badge>
{{ __('Seats Left') }} <Badge
</span>
<span v-else-if="seats_left == 1">
{{ __('Seat Left') }}
</span>
</div>
<div
v-else-if="batch.data.seat_count && seats_left <= 0" v-else-if="batch.data.seat_count && seats_left <= 0"
class="text-xs bg-red-100 text-red-700 float-right px-2 py-0.5 rounded-md" theme="red"
class="self-start mb-2 float-right"
> >
{{ __('Sold Out') }} {{ __('Sold Out') }}
</div> </Badge>
<div <div v-if="batch.data.amount" class="text-lg font-semibold mb-3">
v-if="batch.data.amount"
class="text-lg font-semibold mb-3 text-ink-gray-9"
>
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }} {{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</div> </div>
<div <div class="flex items-center mb-3">
v-if="batch.data.courses.length" <BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
class="flex items-center mb-3 text-ink-gray-7"
>
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span> <span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</div> </div>
<DateRange <div class="flex items-center mb-3">
:startDate="batch.data.start_date" <Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
:endDate="batch.data.end_date" <span>
class="mb-3" {{ dayjs(batch.data.start_date).format('DD MMM YYYY') }} -
/> {{ dayjs(batch.data.end_date).format('DD MMM YYYY') }}
<div class="flex items-center mb-3 text-ink-gray-7"> </span>
<Clock class="h-4 w-4 stroke-1.5 mr-2" /> </div>
<div class="flex items-center">
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span> <span>
{{ formatTime(batch.data.start_time) }} - {{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }} {{ formatTime(batch.data.end_time) }}
</span> </span>
</div> </div>
<div v-if="batch.data.timezone" class="flex items-center text-ink-gray-7"> <router-link
<Globe class="h-4 w-4 stroke-1.5 mr-2" /> v-if="user?.data?.is_moderator"
<span> :to="{
{{ batch.data.timezone }} name: 'Batch',
</span> params: {
</div> batchName: batch.data.name,
<div v-if="!readOnlyMode"> },
<router-link }"
v-if="isModerator || isStudent" >
:to="{ <Button variant="solid" class="w-full mt-4">
name: 'Batch', <span>
params: { {{ __('Manage Batch') }}
batchName: batch.data.name, </span>
},
}"
>
<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 > 0 &&
batch.data.accept_enrollments
"
>
<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 &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
{{ __('Enroll Now') }}
</Button> </Button>
<router-link </router-link>
v-if="isModerator" <router-link
:to="{ :to="{
name: 'BatchForm', name: 'Billing',
params: { params: {
batchName: batch.data.name, type: 'batch',
}, name: batch.data.name,
}" },
> }"
<Button class="w-full mt-2"> v-else-if="batch.data.paid_batch"
<span> >
{{ __('Edit') }} <Button class="w-full mt-4" variant="solid">
</span> <span>
</Button> {{ __('Register Now') }}
</router-link> </span>
</div> </Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="batch.data.allow_self_enrollment"
>
{{ __('Enroll Now') }}
</Button>
<router-link
v-if="user?.data?.is_moderator"
:to="{
name: 'BatchCreation',
params: {
batchName: batch.data.name,
},
}"
>
<Button class="w-full mt-2">
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div> </div>
</template> </template>
<script setup> <script setup>
import { inject, computed } from 'vue'
import { Badge, Button, createResource, toast } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime } from '@/utils' import { formatNumberIntoCurrency, formatTime } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue' import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { inject, computed } from 'vue'
import { Badge, Button } from 'frappe-ui'
const router = useRouter() const dayjs = inject('$dayjs')
const user = inject('$user') const user = inject('$user')
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -139,47 +106,10 @@ const props = defineProps({
}, },
}) })
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) {
toast.success(__('You have been enrolled in this batch'))
router.push({
name: 'Batch',
params: {
batchName: props.batch.data.name,
},
})
},
}
)
}
const seats_left = computed(() => { const seats_left = computed(() => {
if (props.batch.data?.seat_count) { if (props.batch.data?.seat_count) {
return props.batch.data?.seat_count - props.batch.data?.students?.length return props.batch.data?.seat_count - props.batch.data?.students?.length
} }
return null return null
}) })
const isStudent = computed(() => {
return props.batch.data?.students?.includes(user.data?.name)
})
const isModerator = computed(() => {
return user.data?.is_moderator
})
</script> </script>

View File

@@ -1,179 +1,77 @@
<template> <template>
<div v-if="batch.data" class=""> <Button class="float-right mb-3" variant="solid" @click="openStudentModal()">
<div class="w-full flex items-center justify-between pb-4"> <template #prefix>
<div class="font-medium text-ink-gray-7"> <Plus class="h-4 w-4" />
{{ __('Statistics') }} </template>
</div> {{ __('Add Student') }}
</div> </Button>
<div class="grid grid-cols-4 gap-5 mb-8"> <div class="text-lg font-semibold mb-4">
<NumberChart {{ __('Students') }}
class="border rounded-md"
:config="{ title: __('Students'), value: students.data?.length || 0 }"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Certified'),
value: certificationCount.data || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Courses'),
value: batch.data.courses?.length || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{ title: __('Assessments'), value: assessmentCount || 0 }"
/>
</div>
<AxisChart
v-if="showProgressChart"
:config="{
data: chartData,
title: __('Batch Summary'),
subtitle: __('Progress of students in courses and assessments'),
xAxis: {
key: 'task',
title: 'Tasks',
type: 'category',
},
yAxis: {
title: __('Number of Students'),
echartOptions: {
minInterval: 1,
},
},
swapXY: true,
series: [
{
name: 'value',
type: 'bar',
},
],
}"
/>
</div> </div>
<div v-if="students.data?.length">
<div> <ListView
<div class="flex items-center justify-between mb-4"> :columns="getStudentColumns()"
<div class="text-ink-gray-7 font-medium"> :rows="students.data"
{{ __('Students') }} row-key="name"
</div> :options="{ showTooltip: false }"
<Button v-if="!readOnlyMode" @click="openStudentModal()"> >
<template #prefix> <ListHeader
<Plus class="h-4 w-4" /> class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="students.data?.length">
<ListView
:columns="getStudentColumns()"
:rows="students.data"
row-key="name"
:options="{
showTooltip: false,
}"
> >
<ListHeader <ListHeaderItem :item="item" v-for="item in getStudentColumns()">
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2" <template #prefix="{ item }">
> <component
<ListHeaderItem v-if="item.icon"
:item="item" :is="item.icon"
v-for="item in getStudentColumns()" class="h-4 w-4 stroke-1.5 ml-4"
:title="item.label" />
>
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in students.data"
class="group cursor-pointer"
@click="openStudentProgressModal(row)"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'full_name'">
<Avatar
class="flex items-center"
:image="row['user_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div
v-if="column.key == 'progress'"
class="flex items-center space-x-4 w-full"
>
<ProgressBar :progress="row[column.key]" size="sm" />
<div class="text-xs">{{ row[column.key] }}%</div>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeStudents(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template> </template>
</ListSelectBanner> </ListHeaderItem>
</ListView> </ListHeader>
</div> <ListRows>
<div v-else class="text-sm italic text-ink-gray-5"> <ListRow :row="row" v-for="row in students.data">
{{ __('There are no students in this batch.') }} <template #default="{ column, item }">
</div> <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)">
<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> </div>
<StudentModal <StudentModal
:batch="props.batch.data.name" :batch="props.batch"
v-model="showStudentModal" v-model="showStudentModal"
v-model:reloadStudents="students" v-model:reloadStudents="students"
v-model:batchModal="props.batch"
/>
<BatchStudentProgress
:student="selectedStudent"
v-model="showStudentProgressModal"
/> />
</template> </template>
<script setup> <script setup>
import { import {
Avatar,
AxisChart,
Button,
createResource, createResource,
FeatherIcon,
ListHeader, ListHeader,
ListHeaderItem, ListHeaderItem,
ListSelectBanner, ListSelectBanner,
@@ -181,174 +79,73 @@ import {
ListRows, ListRows,
ListView, ListView,
ListRowItem, ListRowItem,
NumberChart, Avatar,
toast, Button,
} from 'frappe-ui' } from 'frappe-ui'
import { import { Trash2, Plus } from 'lucide-vue-next'
BookOpen, import { ref } from 'vue'
GraduationCap,
Plus,
ShieldCheck,
Trash2,
User,
} from 'lucide-vue-next'
import { ref, watch } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue' import StudentModal from '@/components/Modals/StudentModal.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
import ApexChart from 'vue3-apexcharts'
import { theme } from '@/utils/theme'
const showStudentModal = ref(false) const showStudentModal = ref(false)
const showStudentProgressModal = ref(false)
const selectedStudent = ref(null)
const chartData = ref(null)
const showProgressChart = ref(false)
const assessmentCount = ref(0)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {
type: Object, type: String,
default: null, default: null,
}, },
}) })
const students = createResource({ const students = createResource({
url: 'lms.lms.utils.get_batch_students', url: 'lms.lms.utils.get_batch_students',
cache: ['students', props.batch],
params: { params: {
batch: props.batch?.data?.name, batch: props.batch,
}, },
auto: true, auto: true,
onSuccess(data) {
chartData.value = getChartData()
showProgressChart.value =
data.length &&
(props.batch?.data?.courses?.length || assessmentCount.value)
},
}) })
const getStudentColumns = () => { const getStudentColumns = () => {
let columns = [ return [
{ {
label: 'Full Name', label: 'Full Name',
key: 'full_name', key: 'full_name',
width: '20rem',
icon: 'user',
}, },
{ {
label: 'Progress', label: 'Courses Done',
key: 'progress', key: 'courses_completed',
width: '15rem', align: 'center',
icon: 'activity', },
{
label: 'Assessments Done',
key: 'assessments_completed',
align: 'center',
}, },
{ {
label: 'Last Active', label: 'Last Active',
key: 'last_active', key: 'last_active',
width: '10rem',
align: 'center',
icon: 'clock',
}, },
] ]
return columns
} }
const openStudentModal = () => { const openStudentModal = () => {
showStudentModal.value = true showStudentModal.value = true
} }
const openStudentProgressModal = (row) => { const removeStudent = createResource({
showStudentProgressModal.value = true url: 'frappe.client.delete',
selectedStudent.value = row
}
const deleteStudents = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) { makeParams(values) {
return { return {
doctype: 'LMS Batch Enrollment', doctype: 'Batch Student',
documents: values.students, name: values.student,
} }
}, },
}) })
const removeStudents = (selections, unselectAll) => { const removeStudents = (selections) => {
deleteStudents.submit( selections.forEach(async (student) => {
{ removeStudent.submit({ student })
students: Array.from(selections), await setTimeout(1000)
},
{
onSuccess(data) {
students.reload()
props.batch.reload()
toast.success(__('Students deleted successfully'))
unselectAll()
},
}
)
}
const getChartData = () => {
let tasks = []
let data = []
students.data.forEach((row) => {
tasks = countAssessments(row, tasks)
tasks = countCourses(row, tasks)
}) })
students.reload()
tasks.forEach((task) => {
data.push({
task: task.label,
value: task.value,
})
})
return data
} }
const countAssessments = (row, tasks) => {
Object.keys(row.assessments).forEach((assessment) => {
if (row.assessments[assessment].result === 'Pass') {
tasks.filter((task) => task.label === assessment).length
? tasks.filter((task) => task.label === assessment)[0].value++
: tasks.push({
value: 1,
label: assessment,
})
}
})
return tasks
}
const countCourses = (row, tasks) => {
Object.keys(row.courses).forEach((course) => {
if (row.courses[course] === 100) {
tasks.filter((task) => task.label === course).length
? tasks.filter((task) => task.label === course)[0].value++
: tasks.push({
value: 1,
label: course,
})
}
})
return tasks
}
watch(students, () => {
if (students.data?.length) {
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
}
})
const certificationCount = createResource({
url: 'frappe.client.get_count',
params: {
doctype: 'LMS Certificate',
filters: {
batch_name: props.batch?.data?.name,
},
},
auto: true,
})
</script> </script>

View File

@@ -1,85 +0,0 @@
<template>
<Button
v-if="certification.data && certification.data.certificate"
@click="downloadCertificate"
class=""
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('View Certificate') }}
</Button>
<div
v-else-if="
certification.data &&
certification.data.membership &&
certification.data.paid_certificate &&
user.data?.is_student
"
>
<router-link
v-if="!certification.data.membership.purchased_certificate"
:to="{
name: 'Billing',
params: {
type: 'certificate',
name: courseName,
},
}"
>
<Button class="w-full">
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certified') }}
</Button>
</router-link>
<router-link
v-else-if="!certification.data.membership.certificate"
:to="{
name: 'CourseCertification',
params: {
courseName: courseName,
},
}"
>
<Button class="w-full">
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certified') }}
</Button>
</router-link>
</div>
</template>
<script setup>
import { Button, createResource } from 'frappe-ui'
import { inject } from 'vue'
import { GraduationCap } from 'lucide-vue-next'
const user = inject('$user')
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
const certification = createResource({
url: 'lms.lms.api.get_certification_details',
params: {
course: props.courseName,
},
auto: user.data ? true : false,
cache: ['certificationData', user.data?.name],
})
const downloadCertificate = () => {
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
certification.data.certificate.name
}&format=${encodeURIComponent(certification.data.certificate.template)}`
)
}
</script>

View File

@@ -1,22 +0,0 @@
<template>
<div class="flex items-center text-ink-gray-7">
<Calendar class="h-4 w-4 stroke-1.5 mr-2" />
<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>

View File

@@ -17,7 +17,7 @@
> >
{{ displayValue(selectedValue) }} {{ displayValue(selectedValue) }}
</span> </span>
<span class="text-base leading-5 text-ink-gray-4" v-else> <span class="text-base leading-5 text-gray-500" v-else>
{{ placeholder || '' }} {{ placeholder || '' }}
</span> </span>
</div> </div>
@@ -28,7 +28,7 @@
</template> </template>
<template #body="{ isOpen }"> <template #body="{ isOpen }">
<div v-show="isOpen"> <div v-show="isOpen">
<div class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"> <div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
<div class="relative px-1.5 pt-0.5"> <div class="relative px-1.5 pt-0.5">
<ComboboxInput <ComboboxInput
ref="search" ref="search"
@@ -47,7 +47,7 @@
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center" class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null" @click="selectedValue = null"
> >
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" /> <X class="h-4 w-4 stroke-1.5" />
</button> </button>
</div> </div>
<ComboboxOptions <ComboboxOptions
@@ -62,7 +62,7 @@
> >
<div <div
v-if="group.group && !group.hideLabel" v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4" class="px-2.5 py-1.5 text-sm font-medium text-gray-500"
> >
{{ group.group }} {{ group.group }}
</div> </div>
@@ -75,8 +75,8 @@
> >
<li <li
:class="[ :class="[
'flex items-center rounded px-2.5 py-2 text-base', 'flex items-center rounded px-2.5 py-1.5 text-base',
{ 'bg-surface-gray-2': active }, { 'bg-gray-100': active },
]" ]"
> >
<slot <slot
@@ -87,26 +87,14 @@
name="item-label" name="item-label"
v-bind="{ active, selected, option }" v-bind="{ active, selected, option }"
> >
<div class="flex flex-col space-y-1 text-ink-gray-8"> {{ option.label }}
<div>
{{ option.label }}
</div>
<div
v-if="
option.description &&
option.description != option.label
"
class="text-xs text-ink-gray-7"
v-html="option.description"
></div>
</div>
</slot> </slot>
</li> </li>
</ComboboxOption> </ComboboxOption>
</div> </div>
<li <li
v-if="groups.length == 0" v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5" class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-gray-600"
> >
No results found No results found
</li> </li>
@@ -131,7 +119,7 @@ import {
ComboboxOptions, ComboboxOptions,
ComboboxOption, ComboboxOption,
} from '@headlessui/vue' } from '@headlessui/vue'
import { Popover } from 'frappe-ui' import { Popover, Button } from 'frappe-ui'
import { ChevronDown, X } from 'lucide-vue-next' import { ChevronDown, X } from 'lucide-vue-next'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue' import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
@@ -246,7 +234,7 @@ watch(showOptions, (val) => {
}) })
const textColor = computed(() => { const textColor = computed(() => {
return props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8' return props.disabled ? 'text-gray-600' : 'text-gray-800'
}) })
const inputClasses = computed(() => { const inputClasses = computed(() => {
@@ -267,14 +255,12 @@ const inputClasses = computed(() => {
let variant = props.disabled ? 'disabled' : props.variant let variant = props.disabled ? 'disabled' : props.variant
let variantClasses = { let variantClasses = {
subtle: subtle:
'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3', 'border border-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: outline:
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3', 'border border-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: [ disabled: [
'border bg-surface-menu-bar placeholder-ink-gray-3', 'border bg-gray-50 placeholder-gray-400',
props.variant === 'outline' props.variant === 'outline' ? 'border-gray-300' : 'border-transparent',
? 'border-outline-gray-2'
: 'border-transparent',
], ],
}[variant] }[variant]

View File

@@ -1,176 +0,0 @@
<template>
<div
class="editor flex flex-col gap-1"
:style="{
height: height,
}"
>
<span class="text-xs text-ink-gray-7" 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-ink-gray-5"
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 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 = ref(false)
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(() => {
isDark.value = localStorage.getItem('theme') === 'dark'
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, () => {
console.log(isDark.value)
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>

View File

@@ -1,114 +0,0 @@
<template>
<div class="space-y-1.5">
<label class="block text-xs text-ink-gray-5">
{{ 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-surface-gray-2 rounded h-7 py-1.5 px-2 hover:bg-surface-gray-3 focus:bg-surface-white border border-gray-100 hover:border-outline-gray-modals focus:border-outline-gray-4"
>
<component
v-if="selectedIcon"
class="w-4 h-4 text-ink-gray-7 stroke-1.5"
:is="icons[selectedIcon]"
/>
<component
v-else
class="w-4 h-4 text-ink-gray-7 stroke-1.5"
:is="icons.Folder"
/>
<span v-if="selectedIcon">
{{ selectedIcon }}
</span>
<span v-else class="text-ink-gray-5">
{{ __('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-ink-gray-7 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>

View File

@@ -2,7 +2,6 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="block" :class="labelClasses" v-if="attrs.label"> <label class="block" :class="labelClasses" v-if="attrs.label">
{{ attrs.label }} {{ attrs.label }}
<span class="text-ink-red-3" v-if="attrs.required">*</span>
</label> </label>
<Autocomplete <Autocomplete
ref="autocomplete" ref="autocomplete"
@@ -29,12 +28,12 @@
<slot name="item-label" v-bind="{ active, selected, option }" /> <slot name="item-label" v-bind="{ active, selected, option }" />
</template> </template>
<template #footer="{ value, close }"> <template v-if="attrs.onCreate" #footer="{ value, close }">
<div v-if="attrs.onCreate"> <div>
<Button <Button
variant="ghost" variant="ghost"
class="w-full !justify-start" class="w-full !justify-start"
:label="__('Create New')" label="Create New"
@click="attrs.onCreate(value, close)" @click="attrs.onCreate(value, close)"
> >
<template #prefix> <template #prefix>
@@ -42,21 +41,8 @@
</template> </template>
</Button> </Button>
</div> </div>
<div>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Clear')"
@click="() => clearValue(close)"
>
<template #prefix>
<X class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</template> </template>
</Autocomplete> </Autocomplete>
<p v-if="description" class="text-sm text-ink-gray-5">{{ description }}</p>
</div> </div>
</template> </template>
@@ -64,7 +50,7 @@
import Autocomplete from '@/components/Controls/Autocomplete.vue' import Autocomplete from '@/components/Controls/Autocomplete.vue'
import { watchDebounced } from '@vueuse/core' import { watchDebounced } from '@vueuse/core'
import { createResource, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import { useAttrs, computed, ref } from 'vue' import { useAttrs, computed, ref } from 'vue'
const props = defineProps({ const props = defineProps({
@@ -80,14 +66,12 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
description: {
type: String,
default: '',
},
}) })
const emit = defineEmits(['update:modelValue', 'change']) const emit = defineEmits(['update:modelValue', 'change'])
const attrs = useAttrs() const attrs = useAttrs()
const valuePropPassed = computed(() => 'value' in attrs) const valuePropPassed = computed(() => 'value' in attrs)
const value = computed({ const value = computed({
@@ -124,7 +108,6 @@ const options = createResource({
url: 'frappe.desk.search.search_link', url: 'frappe.desk.search.search_link',
cache: [props.doctype, text.value], cache: [props.doctype, text.value],
method: 'POST', method: 'POST',
auto: true,
params: { params: {
txt: text.value, txt: text.value,
doctype: props.doctype, doctype: props.doctype,
@@ -133,15 +116,14 @@ const options = createResource({
transform: (data) => { transform: (data) => {
return data.map((option) => { return data.map((option) => {
return { return {
label: option.label || option.value, label: option.value,
value: option.value, value: option.value,
description: option.description,
} }
}) })
}, },
}) })
const reload = (val) => { function reload(val) {
options.update({ options.update({
params: { params: {
txt: val, txt: val,
@@ -152,18 +134,13 @@ const reload = (val) => {
options.reload() options.reload()
} }
const clearValue = (close) => {
emit(valuePropPassed.value ? 'change' : 'update:modelValue', '')
close()
}
const labelClasses = computed(() => { const labelClasses = computed(() => {
return [ return [
{ {
sm: 'text-xs', sm: 'text-xs',
md: 'text-base', md: 'text-base',
}[attrs.size || 'sm'], }[attrs.size || 'sm'],
'text-ink-gray-5', 'text-gray-600',
] ]
}) })
</script> </script>

View File

@@ -1,262 +0,0 @@
<template>
<div>
<label class="block mb-1" :class="labelClasses" v-if="label">
{{ label }}
<span class="text-ink-red-3" v-if="required">*</span>
</label>
<div class="w-full">
<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, close }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
>
<ComboboxOptions
class="my-1 min-h-[6rem] 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-surface-gray-2': active },
]"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{ option.description }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
<div class="h-10"></div>
<div
v-if="attrs.onCreate"
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(close)"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</ComboboxOptions>
</div>
</div>
</template>
</Popover>
</Combobox>
</div>
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1">
<div
v-for="value in values"
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 word-wrap p-2 rounded-md mr-2"
>
<span class="break-all">
{{ value }}
</span>
<X
class="size-4 stroke-1.5 cursor-pointer"
@click="removeValue(value)"
/>
</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, useAttrs } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { X, Plus } 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 attrs = useAttrs()
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-ink-gray-5',
]
})
</script>

View File

@@ -1,28 +1,18 @@
<template> <template>
<div class="space-y-1"> <div class="flex text-center">
<label class="block text-xs text-ink-gray-5" v-if="props.label"> <div v-for="index in 5">
{{ props.label }} <Star
</label> :class="index <= rating ? 'fill-orange-500' : ''"
<div class="flex text-center"> class="h-6 w-6 fill-gray-400 text-gray-50 mr-1 cursor-pointer"
<div @click="markRating(index)"
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>
</div> </div>
</template> </template>
<script setup> <script setup>
import { Star } from 'lucide-vue-next' import { Star } from 'lucide-vue-next'
import { ref, watch } from 'vue' import { ref } from 'vue'
const props = defineProps({ const props = defineProps({
id: { id: {
type: String, type: String,
@@ -32,36 +22,10 @@ const props = defineProps({
type: Number, type: Number,
default: 0, 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 emit = defineEmits(['update:modelValue'])
const rating = ref(props.modelValue) let rating = ref(props.modelValue)
const hoveredRating = ref(0)
let emitChange = (value) => { let emitChange = (value) => {
emit('update:modelValue', value) emit('update:modelValue', value)
@@ -71,11 +35,4 @@ function markRating(index) {
emitChange(index) emitChange(index)
rating.value = index rating.value = index
} }
watch(
() => props.modelValue,
(newVal) => {
rating.value = newVal
}
)
</script> </script>

View File

@@ -1,31 +1,18 @@
<template> <template>
<div <div
v-if="course.title" v-if="course.title"
class="flex flex-col h-full rounded-md border-2 overflow-auto" class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
style="min-height: 350px" style="min-height: 320px"
> >
<div <div
class="course-image" class="course-image"
:class="{ 'default-image': !course.image }" :class="{ 'default-image': !course.image }"
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }" :style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }"
> >
<div class="flex items-center flex-wrap relative top-4 px-2 w-fit"> <div class="flex relative top-4 left-4 w-fit flex-wrap">
<Badge <Badge theme="gray" size="md" class="mr-2" v-for="tag in course.tags">
v-if="course.featured"
variant="subtle"
theme="green"
size="md"
class="mb-1 mr-1"
>
{{ __('Featured') }}
</Badge>
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md mb-1 mr-1"
>
{{ tag }} {{ tag }}
</div> </Badge>
</div> </div>
<div v-if="!course.image" class="image-placeholder"> <div v-if="!course.image" class="image-placeholder">
{{ course.title[0] }} {{ course.title[0] }}
@@ -33,36 +20,36 @@
</div> </div>
<div class="flex flex-col flex-auto p-4"> <div class="flex flex-col flex-auto p-4">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div v-if="course.lessons"> <div v-if="course.lesson_count">
<Tooltip :text="__('Lessons')"> <Tooltip :text="__('Lessons')">
<span class="flex items-center text-ink-gray-7"> <span class="flex items-center">
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" /> <BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.lessons }} {{ course.lesson_count }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div v-if="course.enrollments"> <div v-if="course.enrollment_count">
<Tooltip :text="__('Enrolled Students')"> <Tooltip :text="__('Enrolled Students')">
<span class="flex items-center text-ink-gray-7"> <span class="flex items-center">
<Users class="h-4 w-4 stroke-1. mr-1" /> <Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.enrollments }} {{ course.enrollment_count }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div v-if="course.rating"> <div v-if="course.avg_rating">
<Tooltip :text="__('Average Rating')"> <Tooltip :text="__('Average Rating')">
<span class="flex items-center text-ink-gray-7"> <span class="flex items-center">
<Star class="h-4 w-4 stroke-1.5 mr-1" /> <Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.rating }} {{ course.avg_rating }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div v-if="course.status != 'Approved'"> <div v-if="course.status != 'Approved'">
<Badge <Badge
variant="subtle" variant="solid"
:theme="course.status === 'Under Review' ? 'orange' : 'blue'" :theme="course.status === 'Under Review' ? 'orange' : 'blue'"
size="sm" size="sm"
> >
@@ -71,30 +58,30 @@
</div> </div>
</div> </div>
<div class="text-xl font-semibold leading-6 text-ink-gray-9"> <div class="text-xl font-semibold leading-6">
{{ course.title }} {{ course.title }}
</div> </div>
<div class="short-introduction text-ink-gray-7 text-sm"> <div class="short-introduction">
{{ course.short_introduction }} {{ course.short_introduction }}
</div> </div>
<ProgressBar
v-if="user && course.membership"
:progress="course.membership.progress"
/>
<div <div
v-if="user && course.membership" v-if="user && course.membership"
class="text-sm text-ink-gray-7 mt-2 mb-4" class="w-full bg-gray-200 rounded-full h-1 mb-2"
> >
<div
class="bg-gray-900 h-1 rounded-full"
:style="{ width: Math.ceil(course.membership.progress) + '%' }"
></div>
</div>
<div v-if="user && course.membership" class="text-sm mb-4">
{{ Math.ceil(course.membership.progress) }}% completed {{ Math.ceil(course.membership.progress) }}% completed
</div> </div>
<div class="flex items-center justify-between mt-auto"> <div class="flex items-center justify-between mt-auto">
<div class="flex avatar-group overlap"> <div class="flex avatar-group overlap">
<div <div
class="h-6 mr-1" class="mr-1"
:class="{ 'avatar-group overlap': course.instructors.length > 1 }" :class="{ 'avatar-group overlap': course.instructors.length > 1 }"
> >
<UserAvatar <UserAvatar
@@ -102,18 +89,22 @@
:user="instructor" :user="instructor"
/> />
</div> </div>
<CourseInstructors :instructors="course.instructors" /> <span v-if="course.instructors.length == 1">
{{ course.instructors[0].full_name }}
</span>
<span v-if="course.instructors.length == 2">
{{ course.instructors[0].first_name }} and
{{ course.instructors[1].first_name }}
</span>
<span v-if="course.instructors.length > 2">
{{ course.instructors[0].first_name }} and
{{ course.instructors.length - 1 }} others
</span>
</div> </div>
<div v-if="course.paid_course" class="font-semibold"> <div class="font-semibold">
{{ course.price }} {{ course.price }}
</div> </div>
<div
v-if="course.paid_certificate || course.enable_certification"
class="text-xs text-ink-blue-3 bg-surface-blue-1 py-0.5 px-1 rounded-md"
>
{{ __('Certification') }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -123,8 +114,6 @@ import { BookOpen, Users, Star } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Badge, Tooltip } from 'frappe-ui' import { Badge, Tooltip } from 'frappe-ui'
import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue'
const { user } = sessionStore() const { user } = sessionStore()
@@ -161,8 +150,8 @@ const props = defineProps({
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
background-color: theme('colors.green.100'); background-color: theme('colors.orange.100');
color: theme('colors.green.600'); color: theme('colors.orange.600');
} }
.avatar-group { .avatar-group {

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