Merge pull request #1636 from frappe/develop

chore: merge 'develop' into 'main'
This commit is contained in:
Jannat Patel
2025-07-14 12:06:42 +05:30
committed by GitHub
142 changed files with 58941 additions and 13121 deletions

View File

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

View File

@@ -118,6 +118,10 @@ Replace the following parameters with your values:
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
**Note:** To avoid a `404 Page Not Found` error:
- If hosting on a **public server**, make sure your DNS **A record** points to your server's IP.
- If hosting **locally**, map your domain to `127.0.0.1` in your `/etc/hosts` file:
## Development Setup
### Docker

View File

@@ -23,8 +23,8 @@ describe("Batch Creation", () => {
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("input[placeholder='jane@doe.com']").type(randomEmail);
cy.get("input[placeholder='Jane']").type(randomName);
cy.get("button").contains("Add").click();
// Add evaluator
@@ -39,7 +39,7 @@ describe("Batch Creation", () => {
.click();
const randomEvaluator = `evaluator${dateNow}@example.com`;
cy.get("input[placeholder='Email']").type(randomEvaluator);
cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
cy.get("button").contains("Add").click();
cy.get("div").contains(randomEvaluator).should("be.visible").click();
@@ -47,7 +47,7 @@ describe("Batch Creation", () => {
cy.closeOnboardingModal();
// Create a batch
cy.get("button").contains("New").click();
cy.get("button").contains("Create").click();
cy.wait(500);
cy.url().should("include", "/batches/new/edit");
cy.get("label").contains("Title").type("Test Batch");

View File

@@ -8,7 +8,7 @@ describe("Course Creation", () => {
cy.closeOnboardingModal();
// Create a course
cy.get("button").contains("New").click();
cy.get("button").contains("Create").click();
cy.wait(500);
cy.url().should("include", "/courses/new/edit");

View File

@@ -19,6 +19,10 @@ declare module 'vue' {
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']
BadgeAssignmentForm: typeof import('./src/components/Settings/BadgeAssignmentForm.vue')['default']
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
@@ -32,12 +36,15 @@ declare module 'vue' {
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']
ChildTable: typeof import('./src/components/Controls/ChildTable.vue')['default']
Code: typeof import('./src/components/Controls/Code.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']
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.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']
@@ -96,10 +103,12 @@ declare module 'vue' {
Tags: typeof import('./src/components/Tags.vue')['default']
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
Uploader: typeof import('./src/components/Controls/Uploader.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']
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
}

View File

@@ -10,6 +10,10 @@
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-python": "^6.2.1",
"@editorjs/checklist": "^1.6.0",
"@editorjs/code": "^2.9.0",
"@editorjs/editorjs": "^2.29.0",
@@ -24,10 +28,10 @@
"ace-builds": "^1.36.2",
"apexcharts": "^4.3.0",
"chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0",
"codemirror": "^6.0.1",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.147",
"frappe-ui": "^0.1.163",
"highlight.js": "^11.11.1",
"lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0",
@@ -35,9 +39,11 @@
"plyr": "^3.7.8",
"socket.io-client": "^4.7.2",
"tailwindcss": "3.4.15",
"thememirror": "^2.0.1",
"typescript": "^5.7.2",
"vue": "^3.4.23",
"vue-chartjs": "^5.3.0",
"vue-codemirror": "^6.1.1",
"vue-draggable-next": "^2.2.1",
"vue-router": "^4.0.12",
"vue3-apexcharts": "^1.8.0",

BIN
frontend/public/Remove.mp4 Normal file

Binary file not shown.

View File

@@ -225,7 +225,7 @@ import {
IntermediateStepModal,
} from 'frappe-ui/frappe'
const { user, sidebarSettings } = sessionStore()
const { user } = sessionStore()
const { userResource } = usersStore()
let sidebarStore = useSidebar()
const socket = inject('$socket')
@@ -236,6 +236,7 @@ const isModerator = ref(false)
const isInstructor = ref(false)
const pageToEdit = ref(null)
const settingsStore = useSettings()
const { sidebarSettings } = settingsStore
const showOnboarding = ref(false)
const showIntermediateModal = ref(false)
const currentStep = ref({})
@@ -313,7 +314,7 @@ const addNotifications = () => {
const addQuizzes = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
sidebarLinks.value.splice(4, 0, {
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
@@ -329,7 +330,7 @@ const addQuizzes = () => {
const addAssignments = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
sidebarLinks.value.splice(5, 0, {
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',

View File

@@ -2,17 +2,24 @@
<Dialog
v-model="show"
:options="{
title:
type == 'quiz'
? __('Add a quiz to your lesson')
: __('Add an assignment to your lesson'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: () => {
addAssessment()
},
},
],
}"
>
<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>
<template #body-content>
<div class="">
<div>
<Link
v-if="type == 'quiz'"
@@ -29,17 +36,12 @@
: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 { Dialog } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'

View File

@@ -40,7 +40,7 @@
<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' }}
{{ getAssessmentTypeLabel(row[column.key]) }}
</div>
<div v-else-if="column.key == 'title'">
{{ row[column.key] }}
@@ -172,6 +172,24 @@ const getRowRoute = (row) => {
},
}
}
} else if (row.assessment_type == 'LMS Programming Exercise') {
if (row.submission) {
return {
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment_name,
submissionID: row.submission.name,
},
}
} else {
return {
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment_name,
submissionID: 'new',
},
}
}
} else {
return {
name: 'QuizPage',
@@ -213,7 +231,7 @@ const getAssessmentColumns = () => {
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
if (status === 'Pass' || status === 'Passed') {
return 'green'
} else if (status === 'Not Graded') {
return 'orange'
@@ -221,4 +239,14 @@ const getStatusTheme = (status) => {
return 'red'
}
}
const getAssessmentTypeLabel = (type) => {
if (type == 'LMS Assignment') {
return __('Assignment')
} else if (type == 'LMS Quiz') {
return __('Quiz')
} else if (type == 'LMS Programming Exercise') {
return __('Programming Exercise')
}
}
</script>

View File

@@ -6,13 +6,12 @@
:courses="batch.data.courses"
/>
<Assessments :batch="batch.data.name" />
<StudentHeatmap />
<!-- <StudentHeatmap /> -->
</div>
</template>
<script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/components/Assessments.vue'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const props = defineProps({
batch: {

View File

@@ -65,6 +65,10 @@
}"
>
<Button variant="solid" class="w-full mt-4">
<template #prefix>
<Settings v-if="isModerator" class="size-4 stroke-1.5" />
<LogIn v-else class="size-4 stroke-1.5" />
</template>
<span>
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
</span>
@@ -85,6 +89,9 @@
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Register Now') }}
</span>
@@ -100,6 +107,9 @@
"
@click="enrollInBatch()"
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Enroll Now') }}
</Button>
<router-link
@@ -112,6 +122,9 @@
}"
>
<Button class="w-full mt-2">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
@@ -122,8 +135,17 @@
</template>
<script setup>
import { inject, computed } from 'vue'
import { Badge, Button, createResource, toast } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { Button, createResource, toast } from 'frappe-ui'
import {
BookOpen,
Clock,
CreditCard,
Globe,
GraduationCap,
LogIn,
Pencil,
Settings,
} from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router'

View File

@@ -1,127 +1,140 @@
<template>
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full">
<button
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="() => togglePopover()"
>
<div class="flex items-center">
<slot name="prefix" />
<span
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
v-if="selectedValue"
>
{{ displayValue(selectedValue) }}
</span>
<span class="text-base leading-5 text-ink-gray-4" v-else>
{{ placeholder || '' }}
</span>
</div>
<ChevronDown class="h-4 w-4 stroke-1.5" />
</button>
</div>
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2">
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="
(e) => {
query = e.target.value
}
"
:value="query"
autocomplete="off"
placeholder="Search"
/>
<div>
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
{{ __(label) }}
<span class="text-ink-red-3" v-if="attrs.required">*</span>
</div>
<Combobox
v-model="selectedValue"
nullable
v-slot="{ open: isComboboxOpen }"
>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full">
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="() => togglePopover()"
:disabled="attrs.readonly"
>
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
<div class="flex items-center">
<slot name="prefix" />
<span
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
v-if="selectedValue"
>
{{ displayValue(selectedValue) }}
</span>
<span class="text-base leading-5 text-ink-gray-4" v-else>
{{ placeholder || '' }}
</span>
</div>
<ChevronDown class="h-4 w-4 stroke-1.5" />
</button>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
>
<div
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="
(e) => {
query = e.target.value
}
"
:value="query"
autocomplete="off"
placeholder="Search"
/>
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
>
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
</button>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
>
{{ group.group }}
</div>
<ComboboxOption
as="template"
v-for="option in group.items"
:key="option.value"
:value="option"
v-slot="{ active, selected }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
{ 'bg-surface-gray-2': active },
]"
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
>
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
{{ group.group }}
</div>
<ComboboxOption
as="template"
v-for="option in group.items"
:key="option.value"
:value="option"
v-slot="{ active, selected }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<div class="flex flex-col space-y-1 text-ink-gray-8">
<div>
{{ option.label }}
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
>
<div class="flex flex-col space-y-1 text-ink-gray-8">
<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>
<div
v-if="
option.description &&
option.description != option.label
"
class="text-xs text-ink-gray-7"
v-html="option.description"
></div>
</div>
</slot>
</li>
</ComboboxOption>
</slot>
</li>
</ComboboxOption>
</div>
<li
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
>
No results found
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{ value: search?.el._value, close }"
></slot>
</div>
<li
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
>
No results found
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{ value: search?.el._value, close }"
></slot>
</div>
</div>
</div>
</template>
</Popover>
</Combobox>
</template>
</Popover>
</Combobox>
</div>
</template>
<script setup>
@@ -148,6 +161,10 @@ const props = defineProps({
type: String,
default: 'md',
},
label: {
type: String,
default: '',
},
variant: {
type: String,
default: 'subtle',

View File

@@ -0,0 +1,149 @@
<template>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ label }}
</div>
<div class="overflow-x-auto border rounded-md">
<div
class="grid items-center space-x-4 p-2 border-b"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<div
v-for="(column, index) in columns"
:key="index"
class="text-sm text-ink-gray-5"
>
{{ column }}
</div>
<div></div>
</div>
<div
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="grid items-center space-x-4 p-2"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<template v-for="key in Object.keys(row)" :key="key">
<input
v-if="showKey(key)"
v-model="row[key]"
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-sm text-sm focus:outline-none"
/>
</template>
<div class="relative" ref="menuRef">
<Button
variant="ghost"
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
>
<template #icon>
<Ellipsis
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
/>
</template>
</Button>
<div
v-if="menuOpenIndex === rowIndex"
class="absolute right-[30px] top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
>
<button
@click="deleteRow(rowIndex)"
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
>
<Trash2 class="size-4 stroke-1.5" />
<span>
{{ __('Delete') }}
</span>
</button>
</div>
</div>
</div>
</div>
<div class="mt-2">
<Button @click="addRow">
<template #prefix>
<Plus class="size-4 text-ink-gray-7" />
</template>
{{ __('Add Row') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Button } from 'frappe-ui'
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
const rows = defineModel<Cell[][]>()
const menuRef = ref(null)
const menuOpenIndex = ref<number | null>(null)
const menuTopPosition = ref<string>('')
const emit = defineEmits<{
(e: 'update:modelValue', value: Cell[][]): void
}>()
type Cell = {
value: string
editable?: boolean
}
const props = withDefaults(
defineProps<{
modelValue?: Cell[][]
columns?: string[]
label?: string
}>(),
{
columns: [],
}
)
const columns = ref(props.columns)
watch(rows, () => {
if (rows.value?.length < 1) {
addRow()
}
})
const addRow = () => {
if (!rows.value) {
rows.value = []
}
let newRow: { [key: string]: string } = {}
columns.value.forEach((column: any) => {
newRow[column.toLowerCase().split(' ').join('_')] = ''
})
rows.value.push(newRow)
emit('update:modelValue', rows.value)
}
const deleteRow = (index: number) => {
rows.value.splice(index, 1)
emit('update:modelValue', rows.value)
}
const getGridTemplateColumns = () => {
return [...Array(columns.value.length).fill('1fr'), '0.25fr'].join(' ')
}
const toggleMenu = (index: number, event: MouseEvent) => {
menuOpenIndex.value = menuOpenIndex.value === index ? null : index
menuTopPosition.value = `${event.clientY + 10}px`
}
onClickOutside(menuRef, () => {
menuOpenIndex.value = null
})
const showKey = (key: string) => {
let columnsLower = columns.value.map((col) =>
col.toLowerCase().split(' ').join('_')
)
return columnsLower.includes(key)
}
</script>

View File

@@ -0,0 +1,162 @@
<template>
<div class="flex w-full flex-col gap-1.5">
<div v-if="label" class="text-xs text-ink-gray-5">
{{ __(label) }}
</div>
<codemirror
v-model="code"
:extensions="extensions"
:tab-size="2"
:autofocus="autofocus"
:indent-with-tab="true"
:style="{ height: height, maxHeight: maxHeight }"
:disabled="readonly"
@blur="emitEditorValue"
:class="{
'border border-outline-gray-1': showBorder,
}"
/>
<Button
v-if="showSaveButton"
@click="emit('save', code)"
class="mt-3 w-full text-base"
>
{{ __('Save') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed, watch } from 'vue'
import { Button } from 'frappe-ui'
import { Codemirror } from 'vue-codemirror'
import { autocompletion, closeBrackets } from '@codemirror/autocomplete'
import { LanguageSupport } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import { tomorrow } from 'thememirror'
const props = withDefaults(
defineProps<{
language: 'json' | 'javascript' | 'html' | 'css' | 'python'
modelValue: string | object | Array<string | object> | null
height?: string
maxHeight?: string
autofocus?: boolean
showSaveButton?: boolean
showLineNumbers?: boolean
completions?: Function | null
label?: string
showBorder?: boolean
required?: boolean
readonly?: boolean
}>(),
{
language: 'javascript',
modelValue: null,
height: 'auto',
maxHeight: '250px',
showLineNumbers: true,
completions: null,
}
)
const emit = defineEmits(['update:modelValue', 'save'])
const code = ref<string>('')
watch(
() => props.modelValue,
(newVal) => {
code.value =
typeof newVal === 'string' ? newVal : JSON.stringify(newVal, null, 2)
},
{ immediate: true }
)
watch(code, (val) => {
emit('update:modelValue', val)
})
const errorMessage = ref('')
const emitEditorValue = () => {
try {
errorMessage.value = ''
let value = code.value || ''
if (!props.showSaveButton && !props.readonly) {
emit('update:modelValue', value)
}
} catch (e) {
console.error('Error while parsing JSON for editor', e)
errorMessage.value = `Invalid object/JSON: ${e.message}`
}
}
const languageExtension = ref<LanguageSupport>()
const autocompleteExtension = ref()
async function setLanguageExtension() {
const importMap = {
json: () => import('@codemirror/lang-json'),
javascript: () => import('@codemirror/lang-javascript'),
html: () => import('@codemirror/lang-html'),
css: () => import('@codemirror/lang-css'),
python: () => import('@codemirror/lang-python'),
}
const languageImport = importMap[props.language]
if (!languageImport) return
const module = await languageImport()
languageExtension.value = (module as any)[props.language]()
if (props.completions) {
const languageData = (module as any)[`${props.language}Language`]
autocompleteExtension.value = languageData.data.of({
autocomplete: props.completions,
})
}
}
onMounted(async () => {
await setLanguageExtension()
})
watch(
() => props.language,
async () => {
await setLanguageExtension()
},
{ immediate: true }
)
const extensions = computed(() => {
const baseExtensions = [
closeBrackets(),
tomorrow,
EditorView.theme({
'&': {
fontFamily: 'monospace',
fontSize: '12px',
},
'.cm-gutters': {
display: props.showLineNumbers ? 'flex' : 'none',
},
}),
]
if (languageExtension.value) {
baseExtensions.push(languageExtension.value)
}
if (autocompleteExtension.value) {
baseExtensions.push(autocompleteExtension.value)
}
const autocompletionOptions = {
activateOnTyping: true,
maxRenderedOptions: 10,
closeOnBlur: false,
icons: false,
optionClass: () => 'flex h-7 !px-2 items-center rounded !text-gray-600',
}
baseExtensions.push(autocompletion(autocompletionOptions))
return baseExtensions
})
</script>

View File

@@ -5,7 +5,7 @@
height: height,
}"
>
<span class="text-xs text-ink-gray-7" v-if="label">
<span class="text-xs text-ink-gray-7 mb-1" v-if="label">
{{ label }}
</span>
<div

View File

@@ -12,6 +12,7 @@
:variant="attrs.variant"
:placeholder="attrs.placeholder"
:filterable="false"
:readonly="attrs.readonly"
>
<template #target="{ open, togglePopover }">
<slot name="target" v-bind="{ open, togglePopover }" />

View File

@@ -0,0 +1,76 @@
<template>
<div class="mb-4">
<div v-if="label" class="text-xs text-ink-gray-5 mb-2">
{{ __(label) }}
<span class="text-ink-red-3">*</span>
</div>
<FileUploader
v-if="!modelValue"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file: File) => saveImage(file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center">
<div class="border rounded-md w-fit py-7 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
{{ __(description) }}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img :src="modelValue" class="border rounded-md w-44 h-auto" />
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div
v-if="description"
class="mt-2 text-ink-gray-5 text-sm leading-5"
>
{{ __(description) }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { validateFile } from '@/utils'
import { Button, FileUploader } from 'frappe-ui'
import { Image } from 'lucide-vue-next'
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const props = withDefaults(
defineProps<{
modelValue: string
label?: string
description?: string
}>(),
{
modelValue: '',
label: '',
description: '',
}
)
const saveImage = (file: any) => {
emit('update:modelValue', file.file_url)
}
const removeImage = () => {
emit('update:modelValue', '')
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="border-2 rounded-md min-w-80">
<div class="border-2 rounded-md min-w-80 max-w-sm">
<iframe
v-if="course.data.video_link"
:src="video_link"
@@ -26,6 +26,9 @@
}"
>
<Button variant="solid" size="md" class="w-full">
<template #prefix>
<BookText class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Continue Learning') }}
</span>
@@ -44,6 +47,9 @@
}"
>
<Button variant="solid" size="md" class="w-full">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Buy this course') }}
</span>
@@ -57,12 +63,15 @@
{{ __('Contact the Administrator to enroll for this course.') }}
</Badge>
<Button
v-else
v-else-if="!user.data?.is_moderator && !is_instructor()"
@click="enrollStudent()"
variant="solid"
class="w-full"
size="md"
>
<template #prefix>
<BookText class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Start Learning') }}
</span>
@@ -74,8 +83,22 @@
class="w-full mt-2"
size="md"
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certificate') }}
</Button>
<Button
v-if="user.data?.is_moderator || is_instructor()"
class="w-full mt-2"
size="md"
@click="showProgressSummary"
>
<template #prefix>
<TrendingUp class="size-4 stroke-1.5" />
{{ __('Progress Summary') }}
</template>
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
@@ -86,6 +109,9 @@
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
@@ -142,18 +168,34 @@
</div>
</div>
</div>
<CourseProgressSummary
v-model="showProgressModal"
:courseName="course.data.name"
:enrollments="course.data.enrollments"
/>
</template>
<script setup>
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue'
import {
BookOpen,
BookText,
CreditCard,
GraduationCap,
Pencil,
Star,
TrendingUp,
Users,
} from 'lucide-vue-next'
import { computed, inject, ref } from 'vue'
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
import { formatAmount } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue'
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
const router = useRouter()
const user = inject('$user')
const showProgressModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
@@ -246,4 +288,8 @@ const fetchCertificate = () => {
member: user.data?.name,
})
}
const showProgressSummary = () => {
showProgressModal.value = true
}
</script>

View File

@@ -23,119 +23,135 @@
'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length,
}"
>
<Disclosure
v-slot="{ open }"
v-for="(chapter, index) in outline.data"
:key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)"
<Draggable
:list="outline.data"
:disabled="!allowEdit"
item-key="name"
group="chapters"
@end="updateChapterOrder"
>
<DisclosureButton ref="" class="flex items-center w-full p-2 group">
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
'duration-200': !open,
hidden: chapter.is_scorm_package,
open: index == 1,
}"
class="h-4 w-4 text-ink-gray-9 stroke-1"
/>
<div
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
@click="redirectToChapter(chapter)"
>
{{ chapter.title }}
</div>
<div class="flex ml-auto space-x-4">
<Tooltip :text="__('Edit Chapter')" placement="bottom">
<FilePenLine
v-if="allowEdit"
@click.prevent="openChapterModal(chapter)"
class="h-4 w-4 text-ink-gray-9 invisible group-hover:visible"
/>
</Tooltip>
<Tooltip :text="__('Delete Chapter')" placement="bottom">
<Trash2
v-if="allowEdit"
@click.prevent="trashChapter(chapter.name)"
class="h-4 w-4 text-ink-red-3 invisible group-hover:visible"
/>
</Tooltip>
</div>
</DisclosureButton>
<DisclosurePanel v-if="!chapter.is_scorm_package">
<Draggable
v-if="!chapter.is_scorm_package"
:list="chapter.lessons"
:disabled="!allowEdit"
item-key="name"
group="items"
@end="updateOutline"
:data-chapter="chapter.name"
>
<template #item="{ element: lesson }">
<div
class="outline-lesson pl-8 py-2 pr-4 text-ink-gray-9"
:class="
isActiveLesson(lesson.number) ? 'bg-surface-gray-3' : ''
"
<template #item="{ element: chapter, index }">
<div class="chapter-item">
<Disclosure
v-slot="{ open }"
:key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)"
>
<DisclosureButton
ref=""
class="flex items-center w-full p-2 group"
>
<router-link
:to="{
name: allowEdit ? 'LessonForm' : 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
lessonNumber: lesson.number.split('.')[1],
},
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
'duration-200': !open,
hidden: chapter.is_scorm_package,
open: index == 1,
}"
class="h-4 w-4 text-ink-gray-9 stroke-1"
/>
<div
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
@click="redirectToChapter(chapter)"
>
<div class="flex items-center text-sm leading-5 group">
<MonitorPlay
v-if="lesson.icon === 'icon-youtube'"
class="h-4 w-4 stroke-1 mr-2"
{{ chapter.title }}
</div>
<div class="flex ml-auto space-x-4">
<Tooltip :text="__('Edit Chapter')" placement="bottom">
<FilePenLine
v-if="allowEdit"
@click.prevent="openChapterModal(chapter)"
class="h-4 w-4 text-ink-gray-9 invisible group-hover:visible"
/>
<HelpCircle
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
/>
{{ lesson.title }}
</Tooltip>
<Tooltip :text="__('Delete Chapter')" placement="bottom">
<Trash2
v-if="allowEdit"
@click.prevent="trashLesson(lesson.name, chapter.name)"
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
@click.prevent="trashChapter(chapter.name)"
class="h-4 w-4 text-ink-red-3 invisible group-hover:visible"
/>
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-700 ml-2"
/>
</div>
</router-link>
</div>
</template>
</Draggable>
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<router-link
v-if="!chapter.is_scorm_package"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: chapter.idx,
lessonNumber: chapter.lessons.length + 1,
},
}"
>
<Button>
{{ __('Add Lesson') }}
</Button>
</router-link>
</Tooltip>
</div>
</DisclosureButton>
<DisclosurePanel v-if="!chapter.is_scorm_package">
<Draggable
v-if="!chapter.is_scorm_package"
:list="chapter.lessons"
:disabled="!allowEdit"
item-key="name"
group="items"
@end="updateOutline"
:data-chapter="chapter.name"
>
<template #item="{ element: lesson }">
<div
class="outline-lesson pl-8 py-2 pr-4 text-ink-gray-9"
:class="
isActiveLesson(lesson.number) ? 'bg-surface-gray-3' : ''
"
>
<router-link
:to="{
name: allowEdit ? 'LessonForm' : 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
lessonNumber: lesson.number.split('.')[1],
},
}"
>
<div class="flex items-center text-sm leading-5 group">
<MonitorPlay
v-if="lesson.icon === 'icon-youtube'"
class="h-4 w-4 stroke-1 mr-2"
/>
<HelpCircle
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
/>
{{ lesson.title }}
<Trash2
v-if="allowEdit"
@click.prevent="
trashLesson(lesson.name, chapter.name)
"
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
/>
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-700 ml-2"
/>
</div>
</router-link>
</div>
</template>
</Draggable>
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<router-link
v-if="!chapter.is_scorm_package"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: chapter.idx,
lessonNumber: chapter.lessons.length + 1,
},
}"
>
<Button>
{{ __('Add Lesson') }}
</Button>
</router-link>
</div>
</DisclosurePanel>
</Disclosure>
</div>
</DisclosurePanel>
</Disclosure>
</template>
</Draggable>
</div>
</div>
<ChapterModal
@@ -242,6 +258,20 @@ const updateLessonIndex = createResource({
},
})
const updateChapterIndex = createResource({
url: 'lms.lms.api.update_chapter_index',
makeParams(values) {
return {
chapter: values.chapter,
course: values.course,
idx: values.idx,
}
},
onSuccess() {
toast.success(__('Chapter moved successfully'))
},
})
const trashLesson = (lessonName, chapterName) => {
$dialog({
title: __('Delete this lesson?'),
@@ -287,6 +317,14 @@ const updateOutline = (e) => {
})
}
const updateChapterOrder = (e) => {
updateChapterIndex.submit({
chapter: e.item.__draggable_context.element.name,
course: props.courseName,
idx: e.newIndex,
})
}
const deleteChapter = createResource({
url: 'lms.lms.api.delete_chapter',
makeParams(values) {

View File

@@ -15,60 +15,18 @@
</div>
</div>
<div class="space-y-2">
<div class="space-y-2" v-for="(item, key) in contentMap" :key="key">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('quiz')"
@click="openHelpDialog(key)"
>
<span>
{{ __('How to add a Quiz?') }}
{{ __(item.title) }}
</span>
<Info class="w-3 h-3 text-ink-gray-7" />
</div>
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
{{
__(
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('upload')"
>
<span class="leading-5">
{{ __(contentMap['upload']) }}
</span>
<Info class="w-3 h-3 text-ink-gray-7" />
</div>
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
{{
__(
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('youtube')"
>
<span>
{{ __(contentMap['youtube']) }}
</span>
<Info class="w-3 h-3 text-ink-gray-7" />
</div>
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
{{
__(
'Copy the URL of the video from YouTube and paste it in the editor.'
)
}}
{{ __(item.description) }}
</div>
</div>
</div>
@@ -83,14 +41,31 @@ const showExplanation = ref(false)
const type = ref(null)
const title = ref(null)
const contentMap = {
quiz: 'How to add a Quiz?',
upload: 'How to upload content from your system?',
youtube: 'How to add a YouTube Video?',
quiz: {
title: 'How to add a Quiz?',
description:
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.',
},
upload: {
title: 'How to upload content from your system?',
description:
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.',
},
youtube: {
title: 'How to add a YouTube Video?',
description:
'Copy the URL of the video from YouTube and paste it in the editor.',
},
remove: {
title: 'How to remove an embed?',
description:
'To remove an embed like YouTube or Vimeo, put your cursor on the line below the embed, then drag your mouse cursor upwards to select the embed. Once the embed is selected press BackSpace.',
},
}
const openHelpDialog = (contentType) => {
type.value = contentType
title.value = contentMap[contentType]
title.value = contentMap[contentType].title
showExplanation.value = true
}
</script>

View File

@@ -58,11 +58,13 @@ import { getSidebarLinks } from '@/utils'
import { useRouter } from 'vue-router'
import { watch, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session'
import { useSettings } from '@/stores/settings'
import { usersStore } from '@/stores/user'
import * as icons from 'lucide-vue-next'
const { logout, user, sidebarSettings } = sessionStore()
const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore()
const { sidebarSettings } = useSettings()
const router = useRouter()
let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks())

View File

@@ -99,6 +99,7 @@ const assessmentTypes = computed(() => {
return [
{ label: 'Quiz', value: 'LMS Quiz' },
{ label: 'Assignment', value: 'LMS Assignment' },
{ label: 'Programming Exercise', value: 'LMS Programming Exercise' },
]
})
</script>

View File

@@ -0,0 +1,230 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Course Progress Summary'),
size: '5xl',
}"
>
<template #body-content>
<div class="flex justify-between space-x-10 text-base mt-10">
<div class="w-full">
<div class="flex items-center justify-between space-x-5 mb-4">
<!-- <div class="text-xl font-semibold text-ink-gray-6">
{{ __('{0} Members').format(memberCount) }}
</div> -->
<FormControl
v-model="searchFilter"
:placeholder="__('Search by Member Name')"
type="text"
class="w-full"
/>
</div>
<div class="max-h-[70vh] overflow-y-auto">
<ListView
v-if="progressList.loading || progressList.data?.length"
:columns="progressColumns"
:rows="progressList.data"
rowKey="name"
:options="{
selectable: false,
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem
:item="item"
v-for="item in progressColumns"
:key="item.key"
>
<template #prefix="{ item }">
<FeatherIcon
:name="item.icon?.toString()"
class="h-4 w-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in progressList.data">
<router-link
:to="{
name: 'Profile',
params: { username: row.member_username },
}"
>
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
</ListRows>
</ListView>
<div
v-if="progressList.data && progressList.hasNextPage"
class="flex justify-center my-5"
>
<Button @click="progressList.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
<div class="mb-4 self-start w-full space-y-5">
<div class="flex items-center space-x-4">
<NumberChart
class="border rounded-md w-full"
:config="{
title: __('Enrollments'),
value: memberCount || 0,
}"
/>
<NumberChart
class="border rounded-md w-full"
:config="{
title: __('Average Progress %'),
value: chartDetails.data?.average_progress || 0,
}"
/>
</div>
<DonutChart
:config="{
data: chartDetails.data?.progress_distribution || [],
title: __('Progress Distribution'),
categoryColumn: 'category',
valueColumn: 'count',
colors: [
theme.colors.red['400'],
theme.colors.amber['400'],
theme.colors.pink['400'],
theme.colors.blue['400'],
theme.colors.green['400'],
],
}"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
createListResource,
createResource,
Dialog,
DonutChart,
FeatherIcon,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
NumberChart,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { theme } from '@/utils/theme'
const show = defineModel<boolean | undefined>()
const searchFilter = ref<string | null>(null)
const props = defineProps<{
courseName?: string
enrollments?: number
}>()
const memberCount = ref<number>(props.enrollments || 0)
const chartDetails = createResource({
url: 'lms.lms.api.get_course_progress_distribution',
params: {
course: props.courseName,
},
auto: true,
})
const progressList = createListResource({
doctype: 'LMS Enrollment',
filters: {
course: props.courseName,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'progress',
],
pageLength: 50,
auto: true,
})
watch([searchFilter], () => {
let filterApplied = false
type Filters = {
course: string | undefined
member_name?: string[]
}
let filters: Filters = {
course: props.courseName,
}
if (searchFilter.value) {
filters.member_name = ['like', `%${searchFilter.value}%`]
filterApplied = true
}
progressList.update({
filters: filters,
})
progressList.reload(
{},
{
onSuccess(data: any[]) {
memberCount.value = filterApplied ? data.length : props.enrollments || 0
},
}
)
})
const progressColumns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
width: '60%',
icon: 'user',
},
{
label: __('Progress'),
key: 'progress',
width: '30%',
align: 'right',
icon: 'trending-up',
},
]
})
</script>

View File

@@ -255,6 +255,9 @@ const saveEvaluation = () => {
}
toast.success(__('Evaluation saved successfully'))
},
onError(err) {
toast.warning(__(err.messages?.[0] || err))
},
}
)
}
@@ -277,6 +280,9 @@ const certificateResource = createResource({
onSuccess(data) {
certificate.name = data
},
onError(err) {
toast.warning(__(err.messages?.[0] || err))
},
})
const certificateDetails = createResource({
@@ -310,6 +316,9 @@ const saveCertificate = () => {
onSuccess: () => {
toast.success(__('Certificate saved successfully'))
},
onError(err) {
toast.error(__(err.messages?.[0] || err))
},
}
)
}

View File

@@ -35,5 +35,6 @@ const file = computed(() => {
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
if (props.type == 'remove') return '/assets/lms/frontend/Remove.mp4'
})
</script>

View File

@@ -2,7 +2,7 @@
<Dialog
v-model="show"
:options="{
size: '3xl',
size: '5xl',
}"
>
<template #body>
@@ -21,7 +21,7 @@
class="!p-0"
/>
</div>
<div v-if="!chooseFromExisting || editMode" class="space-y-2">
<div v-if="!chooseFromExisting || editMode">
<div>
<label class="block text-xs text-ink-gray-5 mb-1">
{{ __('Question') }}
@@ -34,7 +34,7 @@
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 class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-8 mt-4">
<FormControl
v-model="question.marks"
:label="__('Marks')"
@@ -51,7 +51,7 @@
</div>
<div
v-if="question.type == 'Choices'"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-10"
>
{{ __('Options') }}
</div>
@@ -61,7 +61,10 @@
>
{{ __('Possibilities') }}
</div>
<div v-if="question.type == 'Choices'" class="grid grid-cols-2 gap-4">
<div
v-if="question.type == 'Choices'"
class="grid grid-cols-2 gap-x-8 gap-y-4"
>
<div v-for="n in 4" class="space-y-4 py-2">
<FormControl
:label="__('Option') + ' ' + n"
@@ -81,7 +84,7 @@
</div>
<div
v-else-if="question.type == 'User Input'"
class="grid grid-cols-2 gap-4 py-2"
class="grid grid-cols-2 gap-x-8 gap-y-4 py-2"
>
<div v-for="n in 4">
<FormControl
@@ -106,7 +109,7 @@
</div>
<div class="flex items-center justify-end space-x-2 mt-5">
<Button variant="solid" @click="submitQuestion()">
{{ __('Submit') }}
{{ __('Save') }}
</Button>
</div>
</div>
@@ -217,7 +220,7 @@ const questionRow = createResource({
return {
doc: {
doctype: 'LMS Quiz Question',
parent: quiz.value.data.name,
parent: quiz.value.doc.name,
parentfield: 'questions',
parenttype: 'LMS Quiz',
...values,

View File

@@ -0,0 +1,216 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
title: __('Video Statistics for {0}').format(lessonTitle),
}"
>
<template #body-content>
<div class="text-base">
<TabButtons
v-if="tabs.length > 1"
:buttons="tabs"
v-model="currentTab"
class="w-fit"
/>
<div v-if="currentTab" class="mt-8">
<div class="grid grid-cols-[55%,40%] gap-5">
<div class="space-y-5 border rounded-md p-2 pt-4">
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
<div class="px-4">
{{ __('Member') }}
</div>
<div class="text-center">
{{ __('Watch Time') }}
</div>
</div>
<div
v-for="row in currentTabData"
class="hover:bg-surface-gray-1 cursor-pointer rounded-md py-1 px-2"
>
<router-link
:to="{
name: 'Profile',
params: { username: row.member_username },
}"
>
<div class="grid grid-cols-[70%,30%] items-center">
<div class="flex items-center space-x-2">
<Avatar
:image="row.member_image"
:label="row.member_name"
size="xl"
/>
<div class="space-y-1">
<div class="font-medium">
{{ row.member_name }}
</div>
<div class="text-sm text-ink-gray-6">
{{ row.member }}
</div>
</div>
</div>
<div class="text-center text-sm">
{{ parseFloat(row.watch_time).toFixed(2) }}
</div>
</div>
</router-link>
</div>
</div>
<div class="space-y-5">
<NumberChart
class="border rounded-md"
:config="{
title: __('Average Watch Time (seconds)'),
value: averageWatchTime,
}"
/>
<div v-if="isPlyrSource">
<div class="video-player" :src="currentTab"></div>
</div>
<VideoBlock v-else :file="currentTab" />
</div>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
createListResource,
Dialog,
NumberChart,
TabButtons,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { enablePlyr } from '@/utils'
import VideoBlock from '@/components/VideoBlock.vue'
const show = defineModel<boolean | undefined>()
const currentTab = ref<string>('')
const props = defineProps<{
lessonName?: string
lessonTitle?: string
}>()
const statistics = createListResource({
doctype: 'LMS Video Watch Duration',
filters: {
lesson: props.lessonName,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'source',
'watch_time',
],
cache: ['videoStatistics', props.lessonName],
onSuccess() {
currentTab.value = Object.keys(statisticsData.value)[0]
},
})
watch(
() => props.lessonName,
() => {
if (props.lessonName) {
statistics.filters.lesson = props.lessonName
statistics.reload()
}
}
)
watch(show, () => {
if (show.value) {
enablePlyr()
}
})
const statisticsData = computed(() => {
const grouped = <Record<string, any[]>>{}
statistics.data.forEach((item: { source: string }) => {
if (!grouped[item.source]) {
grouped[item.source] = []
}
grouped[item.source].push(item)
})
return grouped
})
const averageWatchTime = computed(() => {
let totalWatchTime = 0
currentTabData.value.forEach((item: { watch_time: string }) => {
totalWatchTime += parseFloat(item.watch_time)
})
return totalWatchTime / currentTabData.value.length
})
const currentTabData = computed(() => {
return statisticsData.value[currentTab.value] || []
})
const isPlyrSource = computed(() => {
return (
currentTab.value.includes('youtube') || currentTab.value.includes('vimeo')
)
})
const provider = computed(() => {
if (currentTab.value.includes('youtube')) {
return 'youtube'
} else if (currentTab.value.includes('vimeo')) {
return 'vimeo'
}
return ''
})
const embedURL = computed(() => {
if (isPlyrSource.value) {
return currentTab.value.replace('watch?v=', 'embed/')
}
return ''
})
const tabs = computed(() => {
return Object.keys(statisticsData.value).map((source, index) => ({
label: __(`Video ${index + 1}`),
value: source,
}))
})
</script>
<style>
.plyr__volume input[type='range'] {
display: none;
}
.plyr__control--overlaid {
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.5) 50%
);
}
.plyr__control:hover {
background: none;
}
.plyr--video {
border: 1px solid theme('colors.gray.200');
border-radius: 8px;
}
:root {
--plyr-range-fill-background: white;
--plyr-video-control-background-hover: transparent;
}
</style>

View File

@@ -41,7 +41,7 @@
v-model="account.member"
:label="__('Member')"
doctype="Course Evaluator"
:onCreate="(value, close) => openSettings('Members', close)"
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
:required="true"
/>
<FormControl
@@ -86,6 +86,12 @@ interface ZoomAccounts {
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
setValue: {
submit: (
data: ZoomAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
}
const show = defineModel('show')
@@ -137,7 +143,7 @@ watch(show, (val) => {
}
})
const saveAccount = (close) => {
const saveAccount = (close: () => void) => {
if (props.accountID == 'new') {
createAccount(close)
} else {
@@ -145,7 +151,7 @@ const saveAccount = (close) => {
}
}
const createAccount = (close) => {
const createAccount = (close: () => void) => {
zoomAccounts.value?.insert.submit(
{
account_name: account.name,
@@ -167,7 +173,7 @@ const createAccount = (close) => {
)
}
const updateAccount = async (close) => {
const updateAccount = async (close: () => void) => {
if (props.accountID != account.name) {
await renameDoc()
}
@@ -182,11 +188,12 @@ const renameDoc = async () => {
})
}
const setValue = (close) => {
const setValue = (close: () => void) => {
zoomAccounts.value?.setValue.submit(
{
...account,
name: account.name,
account_name: props.accountID,
},
{
onSuccess() {
@@ -194,7 +201,7 @@ const setValue = (close) => {
close()
toast.success(__('Zoom Account updated successfully'))
},
onError(err) {
onError(err: any) {
close()
toast.error(
cleanError(err.messages[0]) || __('Error updating Zoom Account')

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="quiz.data">
<div
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3 leading-5"
class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 leading-5"
>
<div v-if="inVideo">
{{ __('You will have to complete the quiz to continue the video') }}
@@ -41,6 +41,16 @@
)
}}
</div>
<div v-if="quiz.data.enable_negative_marking" class="leading-5">
{{
__(
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
).format(
quiz.data.marks_to_cut,
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
)
}}
</div>
</div>
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
@@ -638,6 +648,8 @@ const getInstructions = (question) => {
const markLessonProgress = () => {
let pathname = window.location.pathname.split('/')
if (!pathname.includes('courses'))
pathname = window.parent.location.pathname.split('/')
if (pathname[2] != 'courses') return
let lessonIndex = pathname.pop().split('-')

View File

@@ -0,0 +1,142 @@
<template>
<Dialog
v-model="show"
:options="{
title:
props.badgeAssignmentID === 'new'
? __('Assign a Badge')
: __('Edit Badge Assignment'),
size: 'sm',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveBadgeAssignment(close)
},
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<Link
doctype="User"
v-model="badgeAssignment.member"
:label="__('Member')"
:required="true"
/>
<Link
doctype="LMS Badge"
v-model="badgeAssignment.badge"
:label="__('Badge')"
:required="true"
/>
<div>
<label class="text-xs text-ink-gray-5 mb-1">
{{ __('Issued On') }}
<span class="text-ink-red-3">*</span>
</label>
<DatePicker
v-model="badgeAssignment.issued_on"
:placeholder="__('Select Date')"
:required="true"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Dialog, DatePicker, toast } from 'frappe-ui'
import type {
BadgeAssignments,
BadgeAssignment,
} from '@/components/Settings/types'
import { ref, watch } from 'vue'
import { cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const show = defineModel<boolean>({ required: true, default: false })
const defaultBadgeAssignment = {
name: '',
badge: '',
member: '',
issued_on: '',
member_name: '',
member_username: '',
member_image: '',
}
const badgeAssignments = defineModel<BadgeAssignments>('badgeAssignments')
const badgeAssignment = ref<BadgeAssignment>(defaultBadgeAssignment)
const props = defineProps<{
badgeAssignmentID: string
badge: string | null
}>()
watch(
() => props.badgeAssignmentID,
(newID) => {
if (newID === 'new') {
badgeAssignment.value = {
...defaultBadgeAssignment,
badge: props.badge || '',
}
} else {
const assignment = badgeAssignments.value?.data?.find(
(assignment) => assignment.name === newID
)
if (assignment) {
badgeAssignment.value = { ...assignment }
}
}
}
)
const saveBadgeAssignment = (close: () => void) => {
if (props.badgeAssignmentID === 'new') {
createBadgeAssignment(close)
} else {
updateBadgeAssignment(close)
}
}
const updateBadgeAssignment = async (close: () => void) => {
badgeAssignments.value?.setValue.submit(
{
...badgeAssignment.value,
},
{
onSuccess: () => {
toast.success(__('Badge assignment updated successfully'))
close()
},
onError: (error) => {
toast.error(
__('Failed to update badge assignment: ') + cleanError(error)
)
},
}
)
}
const createBadgeAssignment = (close: () => void) => {
badgeAssignments.value?.insert.submit(
{
...badgeAssignment.value,
},
{
onSuccess: () => {
toast.success(__('Badge assignment created successfully'))
close()
},
onError: (error) => {
toast.error(
__('Failed to create badge assignment: ') + cleanError(error)
)
},
}
)
}
</script>

View File

@@ -0,0 +1,192 @@
<template>
<div class="text-base">
<div class="flex items-center justify-between space-x-2 mb-5">
<div class="flex items-center space-x-2">
<ChevronLeft
class="size-5 stroke-1.5 text-ink-gray-5 cursor-pointer"
@click="
() => {
show = false
}
"
/>
<div class="text-xl font-semibold text-ink-gray-9">
{{ props.badgeName }}
</div>
</div>
<Button @click="openForm('new')">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
<div v-if="assignments.data?.length">
<ListView
:rows="assignments.data"
:columns="columns"
rowKey="name"
:options="{
showTooltip: false,
onRowClick: (row: BadgeAssignment) => {
openForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<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 assignments.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteBadgeAssignment(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="flex flex-col items-center justify-center mt-44">
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
{{ __('No Assignments') }}
</div>
<div
class="leading-5 text-base w-2/5 text-base text-center text-ink-gray-7"
>
{{ __('This badge has not been assigned to any students yet') }}
</div>
</div>
</div>
<BadgeAssignmentForm
v-model="showForm"
:badgeAssignmentID="currentAssignmentID"
:badge="props.badgeName"
v-model:badgeAssignments="assignments"
/>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
createListResource,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { ChevronLeft, GraduationCap, Plus, Trash2 } from 'lucide-vue-next'
import { computed, inject, ref } from 'vue'
import type { BadgeAssignment } from '@/components/Settings/types'
import BadgeAssignmentForm from '@/components/Settings/BadgeAssignmentForm.vue'
const show = defineModel<boolean>()
const dayjs = inject('$dayjs') as any
const showForm = ref(false)
const currentAssignmentID = ref<string>('')
const props = defineProps<{
badgeName: string | null
}>()
const assignments = createListResource({
doctype: 'LMS Badge Assignment',
fields: [
'name',
'member',
'member_name',
'member_username',
'member_image',
'issued_on',
'badge',
],
filters: {
badge: props.badgeName,
},
order_by: 'issued_on desc',
transform(data: BadgeAssignment[]) {
return data.map((item: BadgeAssignment) => {
return {
...item,
issued_on: item.issued_on
? dayjs(item.issued_on).format('DD MMM YYYY')
: null,
}
})
},
auto: true,
})
const openForm = (assignmentID: string) => {
currentAssignmentID.value = assignmentID
showForm.value = true
}
const deleteBadgeAssignment = (
selections: Set<string>,
unselectAll: () => void
) => {
Array.from(selections).forEach(async (assignment: string) => {
await assignments.delete.submit(assignment)
})
unselectAll()
toast.success(__('Badge assignments deleted successfully'))
}
const columns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
icon: 'user',
width: '60%',
},
{
label: __('Issued On'),
key: 'issued_on',
icon: 'calendar',
align: 'center',
},
]
})
</script>

View File

@@ -0,0 +1,219 @@
<template>
<Dialog
v-model="show"
:options="{
title: badge ? __('Edit Badge') : __('Create a new Badge'),
size: '3xl',
}"
>
<template #body-content>
<div class="grid grid-cols-2 gap-x-5">
<div class="space-y-4">
<FormControl
v-model="badge.enabled"
:label="__('Enabled')"
type="checkbox"
/>
<FormControl
v-model="badge.title"
:label="__('Title')"
type="text"
:required="true"
/>
<Autocomplete
@update:modelValue="(opt: any) => (badge.reference_doctype = opt.value)"
:modelValue="badge.reference_doctype"
:options="referenceDoctypeOptions"
:required="true"
:label="__('Assign For')"
/>
<FormControl
v-model="badge.description"
:label="__('Description')"
:required="true"
type="textarea"
/>
<Uploader
v-model="badge.image"
label="Badge Image"
description="An image that represents the badge."
/>
</div>
<div class="space-y-4">
<FormControl
v-model="badge.grant_only_once"
:label="__('Grant Only Once')"
type="checkbox"
/>
<FormControl
v-model="badge.event"
:label="__('Event')"
type="select"
:options="eventOptions"
:required="true"
/>
<FormControl
v-model="badge.user_field"
:label="__('Assign To')"
type="select"
:options="userFieldOptions"
:required="true"
/>
<CodeEditor
v-model="badge.condition"
:label="__('Condition')"
type="JavaScript"
:required="true"
:showBorder="true"
height="82px"
/>
</div>
<div class="space-y-4"></div>
</div>
</template>
<template #actions="{ close }">
<div class="pb-5 float-right">
<Button variant="solid" @click="saveBadge(close)">
{{ __('Save') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { cleanError } from '@/utils'
import type { Badges, Badge } from '@/components/Settings/types'
import Autocomplete from '@/components/Controls/Autocomplete.vue'
import CodeEditor from '@/components/Controls/CodeEditor.vue'
import Uploader from '@/components/Controls/Uploader.vue'
const defaultBadge = {
name: '',
title: '',
enabled: true,
description: '',
image: '',
grant_only_once: false,
event: 'New',
reference_doctype: '',
condition: '',
user_field: 'member',
field_to_check: '',
}
const show = defineModel<boolean>({ required: true, default: false })
const badges = defineModel<Badges>('badges')
const badge = ref<Badge>(defaultBadge)
const props = defineProps<{
badgeName: string | null
}>()
watch(
() => props.badgeName,
(val) => {
if (val != 'new') {
badges.value?.data.forEach((bdg: Badge) => {
if (bdg.name === val) {
badge.value = bdg
}
})
} else {
badge.value = { ...defaultBadge }
}
}
)
const saveBadge = (close: () => void) => {
if (props.badgeName == 'new') {
createBadge(close)
} else {
updateBadge(close)
}
}
const updateBadge = async (close: () => void) => {
if (props.badgeName != badge.value?.title) {
await renameDoc()
}
setValue(close)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'LMS Badge',
old_name: props.badgeName,
new_name: badge.value?.title,
})
}
const setValue = (close: () => void) => {
badges.value?.setValue.submit(
{
...badge.value,
name: badge.value.title,
},
{
onSuccess() {
badges.value?.reload()
close()
toast.success(__('Badge updated successfully'))
},
onError(err: any) {
close()
toast.error(cleanError(err.messages[0]) || err)
},
}
)
}
const createBadge = (close: () => void) => {
badges.value?.insert.submit(
{
...badge.value,
name: badge.value.name,
},
{
onSuccess() {
badges.value?.reload()
close()
toast.success(__('Badge created successfully'))
},
onError(err) {
close()
toast.error(cleanError(err.messages[0]) || __('Error creating badge'))
},
}
)
}
const referenceDoctypeOptions = computed(() => {
return [
{ label: __('Course'), value: 'LMS Course' },
{ label: __('Batch'), value: 'LMS Batch' },
{ label: __('User'), value: 'Member' },
{ label: __('Quiz Submission'), value: 'LMS Quiz Submission' },
{ label: __('Assignment Submission'), value: 'LMS Assignment Submission' },
{
label: __('Programming Exercise Submission'),
value: 'LMS Programming Exercise Submission',
},
{ label: __('Course Enrollment'), value: 'LMS Enrollment' },
{ label: __('Batch Enrollment'), value: 'LMS Batch Enrollment' },
]
})
const eventOptions = computed(() => {
let options = ['New', 'Value Change', 'Auto Assign']
return options.map((event) => ({ label: __(event), value: event }))
})
const userFieldOptions = computed(() => {
return [
{ label: __('Member'), value: 'member' },
{ label: __('Owner'), value: 'owner' },
]
})
</script>

View File

@@ -0,0 +1,247 @@
<template>
<BadgeAssignments
v-if="showAssignments"
v-model="showAssignments"
:badgeName="showAssignmentsFor"
/>
<div v-else class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
<div v-if="badges.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="badges.data"
row-key="name"
:options="{
showTooltip: false,
selectable: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns" :key="item.key">
<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 badges.data" :key="row.name">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="green">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else-if="column.key == 'reference_doctype'">
{{
doctypeLabel[
row[column.key] as keyof typeof doctypeLabel
] || row[column.key]
}}
</div>
<FormControl
v-else-if="column.key == 'grant_only_once'"
v-model="row[column.key]"
type="checkbox"
:disabled="true"
/>
<div
v-else-if="column.key != 'action'"
class="leading-5 text-sm"
>
{{ row[column.key] }}
</div>
<Dropdown
v-else
:options="getMoreOptions(row.name)"
:button="{
icon: 'more-horizontal',
onblur: (e: Event) => {
e.stopPropagation()
},
}"
placement="right"
/>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
</div>
<BadgeForm
v-model="showForm"
:badgeName="selectedBadge"
v-model:badges="badges"
/>
</template>
<script setup lang="ts">
import {
Badge,
Button,
createListResource,
Dropdown,
FeatherIcon,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
toast,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import BadgeForm from '@/components/Settings/BadgeForm.vue'
import BadgeAssignments from '@/components/Settings/BadgeAssignments.vue'
const showForm = ref<boolean>(false)
const selectedBadge = ref<string | null>(null)
const showAssignments = ref<boolean>(false)
const showAssignmentsFor = ref<string | null>(null)
const props = defineProps<{
label: string
description: string
}>()
const badges = createListResource({
doctype: 'LMS Badge',
fields: [
'name',
'title',
'enabled',
'description',
'image',
'grant_only_once',
'event',
'reference_doctype',
'condition',
'user_field',
'field_to_check',
],
order_by: 'creation desc',
auto: true,
})
const getMoreOptions = (badgeName: string) => {
return [
{
label: __('Edit'),
icon: 'edit',
onClick() {
openForm(badgeName)
},
},
{
label: __('Assignments'),
icon: 'download',
onClick() {
showAssignmentsFor.value = badgeName
showAssignments.value = true
},
},
{
label: __('Delete'),
icon: 'trash-2',
onClick() {
deleteBadge(badgeName)
},
},
]
}
const openForm = (badgeName: string) => {
selectedBadge.value = badgeName
showForm.value = true
}
const deleteBadge = (badgeName: string) => {
badges.delete
.submit(badgeName)
.then(() => {
badges.reload()
toast.success(__('Badge deleted successfully'))
})
.catch((err: any) => {
toast.error(cleanError(err.messages[0]) || __('Error deleting badge'))
})
}
const doctypeLabel = computed(() => {
return {
'LMS Course': __('Course'),
'LMS Batch': __('Batch'),
'LMS Enrollment': __('Course Enrollment'),
'LMS Batch Enrollment': __('Batch Enrollment'),
'LMS Quiz Submission': __('Quiz Submission'),
'LMS Assignment Submission': __('Assignment Submission'),
'LMS Programming Exercise Submission': __(
'Programming Exercise Submission'
),
}
})
const columns = computed(() => {
return [
{
label: __('Badge'),
key: 'title',
icon: 'award',
align: 'left',
width: '25%',
},
{
label: __('Assigned For'),
key: 'reference_doctype',
icon: 'info',
align: 'left',
width: '35%',
},
{
label: __('Status'),
key: 'enabled',
icon: 'check-square',
align: 'left',
width: '15%',
},
{
label: __('Grant Only Once'),
key: 'grant_only_once',
icon: 'check',
align: 'center',
width: '20%',
},
{
key: 'action',
align: 'right',
},
]
})
</script>

View File

@@ -17,7 +17,7 @@
</div>
</div>
<div class="overflow-y-auto">
<SettingFields :fields="fields" :data="data.data" />
<SettingFields :fields="fields" :data="branding.data" />
</div>
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update">
@@ -38,10 +38,6 @@ const props = defineProps({
type: Array,
required: true,
},
data: {
type: Object,
required: true,
},
label: {
type: String,
required: true,
@@ -51,6 +47,12 @@ const props = defineProps({
},
})
const branding = createResource({
url: 'lms.lms.api.get_branding',
auto: true,
cache: 'brand',
})
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
@@ -64,7 +66,7 @@ const saveSettings = createResource({
const update = () => {
let fieldsToSave = {}
let imageFields = ['favicon', 'banner_image', 'footer_logo']
let imageFields = ['favicon', 'banner_image']
props.fields.forEach((f) => {
if (imageFields.includes(f.name)) {
fieldsToSave[f.name] = f.value ? f.value.file_url : null
@@ -72,6 +74,8 @@ const update = () => {
fieldsToSave[f.name] = f.value
}
})
fieldsToSave['app_logo'] = fieldsToSave['banner_image']
saveSettings.submit(
{
fields: fieldsToSave,
@@ -84,9 +88,31 @@ const update = () => {
)
}
watch(props.data, (newData) => {
if (newData && !isDirty.value) {
isDirty.value = true
}
watch(branding, (updatedDoc) => {
let textFields = []
let imageFields = []
props.fields.forEach((f) => {
if (f.type === 'Upload') {
imageFields.push(f.name)
} else {
textFields.push(f.name)
}
})
textFields.forEach((field) => {
if (updatedDoc.data[field] != updatedDoc.previousData[field]) {
isDirty.value = true
}
})
imageFields.forEach((field) => {
if (
updatedDoc.data[field] &&
updatedDoc.data[field].file_url != updatedDoc.previousData[field].file_url
) {
isDirty.value = true
}
})
})
</script>

View File

@@ -1,78 +1,129 @@
<template>
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center justify-between">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div> -->
</div>
</div>
<div class="flex item-center space-x-2">
<FormControl
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
/>
<Button @click="() => (showForm = !showForm)">
<Button variant="solid" @click="() => (showForm = !showForm)">
<template #prefix>
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
<X v-else class="size-4 stroke-1.5" />
<Plus class="size-4 stroke-1.5" />
</template>
{{ showForm ? __('Close') : __('New') }}
{{ __('New') }}
</Button>
</div>
</div>
<!-- Form to add new member -->
<div v-if="showForm" class="flex items-center space-x-2 my-4">
<div class="mt-8 pb-5">
<FormControl
v-model="email"
:placeholder="__('Email')"
type="email"
class="w-full"
@keydown.enter="addEvaluator"
/>
<Button @click="addEvaluator()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="overflow-y-scroll">
<div class="divide-y">
<div
v-for="evaluator in evaluators.data"
@click="openProfile(evaluator.username)"
class="cursor-pointer"
>
<div class="flex items-center justify-between py-3">
<div class="flex items-center space-x-3">
<Avatar
:image="evaluator.user_image"
:label="evaluator.full_name"
size="lg"
/>
<div>
<div class="text-base font-semibold text-ink-gray-9">
{{ evaluator.full_name }}
</div>
<div class="text-xs text-ink-gray-5">
{{ evaluator.evaluator }}
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
class="w-1/4 mb-4"
>
<template #prefix>
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
</template>
</FormControl>
<div class="overflow-auto h-[60vh]">
<div class="divide-y">
<div
v-for="evaluator in evaluators.data"
:key="evaluator.evaluator"
class="cursor-pointer"
>
<div class="flex items-center justify-between group py-3">
<div
class="flex items-center space-x-3"
@click="openProfile(evaluator.username)"
>
<Avatar
:image="evaluator.user_image"
:label="evaluator.full_name"
size="xl"
/>
<div class="space-y-1">
<div class="text-base font-semibold text-ink-gray-9">
{{ evaluator.full_name }}
</div>
<div class="text-xs text-ink-gray-5">
{{ evaluator.evaluator }}
</div>
</div>
</div>
<div class="invisible group-hover:visible">
<Button
variant="ghost"
@click="deleteEvaluator(evaluator.evaluator)"
>
<template #icon>
<Trash2 class="size-4 stroke-1.5 text-ink-red-3" />
</template>
</Button>
</div>
</div>
</div>
</div>
<div
v-if="evaluators.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="evaluators.reload()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
</div>
<Dialog
v-model="showForm"
:options="{
size: 'xl',
title: __('Add Evaluator'),
actions: [{
label: __('Add'),
variant: 'solid',
onClick({ close }: any) {
addEvaluator(close)
},
}]
}"
>
<template #body-content>
<div v-if="showForm" class="flex items-center">
<FormControl
v-model="email"
:label="__('Email')"
placeholder="jane@doe.com"
type="email"
class="w-full"
@keydown.enter="addEvaluator"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui'
import {
Avatar,
Button,
call,
createListResource,
Dialog,
FormControl,
toast,
} from 'frappe-ui'
import { ref, watch } from 'vue'
import { Plus, X } from 'lucide-vue-next'
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
const show = defineModel('show')
@@ -95,33 +146,39 @@ const props = defineProps({
},
})
const evaluators = createResource({
url: 'frappe.client.get_list',
makeParams: () => {
return {
doctype: 'Course Evaluator',
fields: ['evaluator', 'full_name', 'user_image', 'username'],
filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
}
},
const evaluators = createListResource({
doctype: 'Course Evaluator',
fields: ['evaluator', 'username', 'full_name', 'user_image'],
auto: true,
orderBy: 'creation desc',
})
const addEvaluator = () => {
const addEvaluator = (close: () => void) => {
call('lms.lms.api.add_an_evaluator', {
email: email.value,
}).then((data) => {
showForm.value = false
email.value = ''
evaluators.reload()
})
.then(() => {
email.value = ''
evaluators.reload()
toast.success(__('Evaluator added successfully'))
close()
})
.catch((error: any) => {
toast.error(__(error.messages[0] || error.messages))
console.error('Error adding evaluator:', error)
})
}
watch(search, () => {
evaluators.update({
filters: {
full_name: ['like', `%${search.value}%`],
},
})
evaluators.reload()
})
const openProfile = (username) => {
const openProfile = (username: string) => {
show.value = false
router.push({
name: 'Profile',
@@ -130,4 +187,18 @@ const openProfile = (username) => {
},
})
}
const deleteEvaluator = (evaluator: string) => {
call('lms.lms.api.delete_evaluator', {
evaluator: evaluator,
})
.then(() => {
toast.success(__('Evaluator deleted successfully'))
evaluators.reload()
})
.catch((error: any) => {
toast.error(__(error.messages[0] || error.messages))
console.error('Error deleting evaluator:', error)
})
}
</script>

View File

@@ -5,53 +5,37 @@
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div> -->
</div>
</div>
<div class="flex item-center space-x-2">
<FormControl
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
/>
<Button @click="() => (showForm = !showForm)">
<Button variant="solid" @click="() => (showForm = !showForm)">
<template #prefix>
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
<X v-else class="size-4 stroke-1.5" />
<Plus class="size-4 stroke-1.5" />
</template>
{{ showForm ? __('Close') : __('New') }}
{{ __('New') }}
</Button>
</div>
</div>
<!-- Form to add new member -->
<div v-if="showForm" class="flex items-center space-x-2 my-4">
<div class="mt-8 pb-10">
<FormControl
v-model="member.email"
:placeholder="__('Email')"
type="email"
class="w-full"
/>
<FormControl
v-model="member.first_name"
:placeholder="__('First Name')"
v-model="search"
:placeholder="__('Search')"
type="text"
class="w-full"
/>
<Button @click="addMember()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="mt-2 pb-10 overflow-auto">
<!-- Member list -->
<div class="overflow-y-scroll">
:debounce="300"
class="w-1/4 mb-4"
>
<template #prefix>
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
</template>
</FormControl>
<div class="overflow-y-scroll h-[60vh]">
<ul class="divide-y">
<li
v-for="member in memberList"
class="grid grid-cols-3 gap-10 py-2 cursor-pointer"
class="flex items-center justify-between py-2 cursor-pointer"
>
<div
@click="openProfile(member.username)"
@@ -60,27 +44,13 @@
<Avatar
:image="member.user_image"
:label="member.full_name"
size="lg"
size="xl"
/>
<div class="space-y-1">
<div class="flex">
<div class="text-ink-gray-9">
{{ member.full_name }}
</div>
<div
class="px-1"
v-if="member.role && getRole(member.role) !== 'Student'"
>
<Badge
:variant="'subtle'"
:ref_for="true"
theme="blue"
size="sm"
label="Badge"
>
{{ getRole(member.role) }}
</Badge>
</div>
</div>
<div class="text-sm text-ink-gray-7">
{{ member.name }}
@@ -88,43 +58,92 @@
</div>
</div>
<div
class="flex items-center justify-center text-ink-gray-7 text-sm"
class="flex items-center space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
v-if="member.role && member.role !== 'LMS Student'"
>
<div v-if="member.last_active">
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }}
</div>
<div v-else>-</div>
<Shield class="size-4 stroke-1.5" />
<span class="text-sm">
{{ getRole(member.role) }}
</span>
</div>
</li>
</ul>
</div>
<div
v-if="memberList.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="members.reload()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
<div
v-if="memberList.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="members.reload()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
</div>
<Dialog
v-model="showForm"
:options="{
title: __('Add a new member'),
size: 'lg',
actions: [{
label: __('Add'),
variant: 'solid',
onClick({ close }: any) {
addMember(close)
}
}]
}"
>
<template #body-content>
<div class="flex items-center space-x-2">
<FormControl
v-model="member.email"
:label="__('Email')"
placeholder="jane@doe.com"
type="email"
class="w-full"
/>
<FormControl
v-model="member.first_name"
:label="__('First Name')"
placeholder="Jane"
type="text"
class="w-full"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { createResource, Avatar, Button, FormControl, Badge } from 'frappe-ui'
import {
Avatar,
Badge,
Button,
createResource,
Dialog,
FormControl,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue'
import { RefreshCw, Plus, X } from 'lucide-vue-next'
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe'
import type { User } from '@/components/Settings/types'
type Member = {
username: string
full_name: string
name: string
role?: string
user_image?: string
}
const router = useRouter()
const show = defineModel('show')
const search = ref('')
const start = ref(0)
const memberList = ref([])
const memberList = ref<Member[]>([])
const hasNextPage = ref(false)
const showForm = ref(false)
const dayjs = inject('$dayjs')
@@ -158,7 +177,7 @@ const members = createResource({
start: start.value,
}
},
onSuccess(data) {
onSuccess(data: Member[]) {
memberList.value = memberList.value.concat(data)
start.value = start.value + 20
hasNextPage.value = data.length === 20
@@ -166,7 +185,7 @@ const members = createResource({
auto: true,
})
const openProfile = (username) => {
const openProfile = (username: string) => {
show.value = false
router.push({
name: 'Profile',
@@ -178,7 +197,7 @@ const openProfile = (username) => {
const newMember = createResource({
url: 'frappe.client.insert',
makeParams(values) {
makeParams() {
return {
doc: {
doctype: 'User',
@@ -188,13 +207,12 @@ const newMember = createResource({
}
},
auto: false,
onSuccess(data) {
onSuccess(data: Member) {
show.value = false
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
router.push({
name: 'Profile',
name: 'ProfileRoles',
params: {
username: data.username,
},
@@ -202,8 +220,9 @@ const newMember = createResource({
},
})
const addMember = () => {
const addMember = (close: () => void) => {
newMember.reload()
close()
}
watch(search, () => {
@@ -212,8 +231,8 @@ watch(search, () => {
members.reload()
})
const getRole = (role) => {
const map = {
const getRole = (role: string) => {
const map: Record<string, string> = {
'LMS Student': 'Student',
'Course Creator': 'Instructor',
Moderator: 'Moderator',

View File

@@ -34,32 +34,16 @@
:key="activeTab.label"
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
>
<Members
v-if="activeTab.label === 'Members'"
:label="activeTab.label"
:description="activeTab.description"
v-model:show="show"
/>
<Evaluators
v-else-if="activeTab.label === 'Evaluators'"
:label="activeTab.label"
:description="activeTab.description"
v-model:show="show"
/>
<Categories
v-else-if="activeTab.label === 'Categories'"
:label="activeTab.label"
:description="activeTab.description"
/>
<EmailTemplates
v-else-if="activeTab.label === 'Email Templates'"
:label="activeTab.label"
:description="activeTab.description"
/>
<ZoomSettings
v-else-if="activeTab.label === 'Zoom Accounts'"
:label="activeTab.label"
:description="activeTab.description"
<component
v-if="activeTab.template"
:is="activeTab.template"
v-bind="{
label: activeTab.label,
description: activeTab.description,
...(activeTab.label === 'Branding'
? { fields: activeTab.fields }
: {}),
}"
/>
<PaymentSettings
v-else-if="activeTab.label === 'Payment Gateway'"
@@ -68,13 +52,6 @@
:data="data"
:fields="activeTab.fields"
/>
<BrandSettings
v-else-if="activeTab.label === 'Branding'"
:label="activeTab.label"
:description="activeTab.description"
:fields="activeTab.fields"
:data="branding"
/>
<SettingDetails
v-else
:fields="activeTab.fields"
@@ -88,8 +65,8 @@
</Dialog>
</template>
<script setup>
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue'
import { Dialog, createDocumentResource } from 'frappe-ui'
import { computed, markRaw, ref, watch } from 'vue'
import { useSettings } from '@/stores/settings'
import SettingDetails from '@/components/Settings/SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue'
@@ -100,6 +77,7 @@ import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
import BrandSettings from '@/components/Settings/BrandSettings.vue'
import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
import Badges from '@/components/Settings/Badges.vue'
const show = defineModel()
const doctype = ref('LMS Settings')
@@ -114,12 +92,6 @@ const data = createDocumentResource({
auto: true,
})
const branding = createResource({
url: 'lms.lms.api.get_branding',
auto: true,
cache: 'brand',
})
const tabsStructure = computed(() => {
return [
{
@@ -130,6 +102,13 @@ const tabsStructure = computed(() => {
label: 'General',
icon: 'Wrench',
fields: [
{
label: 'Allow Guest Access',
name: 'allow_guest_access',
description:
'If enabled, users can access the course and batch lists without logging in.',
type: 'checkbox',
},
{
label: 'Enable Learning Paths',
name: 'enable_learning_paths',
@@ -138,11 +117,11 @@ const tabsStructure = computed(() => {
type: 'checkbox',
},
{
label: 'Allow Guest Access',
name: 'allow_guest_access',
description:
'If enabled, users can access the course and batch lists without logging in.',
label: 'Prevent Skipping Videos',
name: 'prevent_skipping_videos',
type: 'checkbox',
description:
'If enabled, users will no able to move forward in a video',
},
{
label: 'Send calendar invite for evaluations',
@@ -154,6 +133,14 @@ const tabsStructure = computed(() => {
{
type: 'Column Break',
},
{
label: 'Livecode URL',
name: 'livecode_url',
doctype: 'Livecode URL',
type: 'text',
description:
'https://docs.frappe.io/learning/falcon-self-hosting-guide',
},
{
label: 'Batch Confirmation Email Template',
name: 'batch_confirmation_template',
@@ -227,38 +214,55 @@ const tabsStructure = computed(() => {
items: [
{
label: 'Members',
description: 'Manage the members of your learning system',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'UserRoundPlus',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: 'Manage the evaluators of your learning system',
description: '',
icon: 'UserCheck',
description:
'Add new evaluators or check the slots existing evaluators',
template: markRaw(Evaluators),
},
{
label: 'Zoom Accounts',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Badges',
description:
'Create badges and assign them to students to acknowledge their achievements',
icon: 'Award',
template: markRaw(Badges),
},
{
label: 'Categories',
description: 'Double click to edit the category',
icon: 'Network',
template: markRaw(Categories),
},
{
label: 'Email Templates',
description: 'Manage the email templates for your learning system',
icon: 'MailPlus',
},
{
label: 'Zoom Accounts',
description: 'Manage the Zoom accounts for your learning system',
icon: 'Video',
template: markRaw(EmailTemplates),
},
],
},
{
label: 'Customise',
label: 'Customize',
hideLabel: false,
items: [
{
label: 'Branding',
icon: 'Blocks',
template: markRaw(BrandSettings),
fields: [
{
label: 'Brand Name',
@@ -292,6 +296,11 @@ const tabsStructure = computed(() => {
name: 'batches',
type: 'checkbox',
},
{
label: 'Programming Exercises',
name: 'programming_exercises',
type: 'checkbox',
},
{
label: 'Certified Members',
name: 'certified_members',

View File

@@ -5,9 +5,9 @@
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div> -->
</div>
</div>
<div class="flex items-center space-x-5">
<Button @click="openForm('new')">
@@ -35,10 +35,10 @@
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<component
<FeatherIcon
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
@@ -48,8 +48,18 @@
<ListRow :row="row" v-for="row in zoomAccounts.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="blue">
<Badge v-if="row[column.key]" theme="green">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
@@ -87,10 +97,12 @@
</template>
<script setup lang="ts">
import {
Avatar,
Button,
Badge,
call,
createListResource,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
@@ -122,6 +134,7 @@ const zoomAccounts = createListResource({
'enabled',
'member',
'member_name',
'member_image',
'account_id',
'client_id',
'client_secret',
@@ -170,18 +183,21 @@ const removeAccount = (selections, unselectAll) => {
const columns = computed(() => {
return [
{
label: __('Account'),
key: 'name',
},
{
label: __('Member'),
key: 'member_name',
icon: 'user',
},
{
label: __('Account Name'),
key: 'name',
icon: 'video',
},
{
label: __('Status'),
key: 'enabled',
align: 'center',
icon: 'check-square',
},
]
})

View File

@@ -13,4 +13,62 @@ export interface User {
is_instructor: boolean
is_fc_site: boolean
}
}
export interface Badge {
name: string;
title: string;
enabled: boolean;
description: string;
image: string;
grant_only_once: boolean;
event: string;
reference_doctype: string;
condition: string;
user_field: string;
field_to_check: string;
};
export interface Badges {
data: Badge[],
reload: () => void
insert: {
submit: (
data: Badge,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
setValue: {
submit: (
data: Badge,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
}
export interface BadgeAssignment {
name: string;
member: string;
member_name: string;
member_username: string;
member_image: string;
badge: string;
issued_on: string;
}
export interface BadgeAssignments {
data: BadgeAssignment[],
reload: () => void
insert: {
submit: (
data: BadgeAssignment,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
setValue: {
submit: (
data: BadgeAssignment,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
}

View File

@@ -27,9 +27,9 @@
oncontextmenu="return false"
class="rounded-md border border-gray-100 cursor-pointer"
ref="videoRef"
>
<source :src="fileURL" :type="type" />
</video>
:src="fileURL"
:type="type"
></video>
<div
v-if="!playing"
class="absolute inset-0 flex items-center justify-center cursor-pointer"
@@ -135,12 +135,17 @@
}"
>
<template #body>
<div class="p-5 text-base">
{{
__(
'Complete the upcoming quiz to continue watching the video. The quiz will open in {0} {1}.'
).format(quizLoadTimer, quizLoadTimer === 1 ? 'second' : 'seconds')
}}
<div class="flex flex-col space-y-2 p-5 text-base leading-5">
<span class="font-semibold">
{{ __('Time for a Quiz') }}
</span>
<span>
{{
__(
'Complete the upcoming quiz to continue watching the video. The quiz will open in {0} {1}.'
).format(quizLoadTimer, quizLoadTimer === 1 ? 'second' : 'seconds')
}}
</span>
</div>
</template>
</Dialog>
@@ -150,6 +155,7 @@ import { ref, onMounted, computed, watch } from 'vue'
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button, Dialog } from 'frappe-ui'
import { formatSeconds, formatTimestamp } from '@/utils'
import { useSettings } from '@/stores/settings'
import Play from '@/components/Icons/Play.vue'
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
@@ -165,6 +171,7 @@ const showQuizLoader = ref(false)
const quizLoadTimer = ref(0)
const currentQuiz = ref(null)
const nextQuiz = ref({})
const { preventSkippingVideos } = useSettings()
const props = defineProps({
file: {
@@ -176,7 +183,7 @@ const props = defineProps({
default: 'video/mp4',
},
readOnly: {
type: String,
type: Boolean,
default: true,
},
quizzes: {
@@ -185,6 +192,7 @@ const props = defineProps({
},
saveQuizzes: {
type: Function,
default: () => {},
},
})
@@ -290,6 +298,11 @@ const toggleMute = () => {
}
const changeCurrentTime = () => {
if (
preventSkippingVideos.data &&
currentTime.value > videoRef.value.currentTime
)
return
videoRef.value.currentTime = currentTime.value
updateNextQuiz()
}
@@ -303,7 +316,7 @@ const toggleFullscreen = () => {
}
const getQuizMarkerStyle = (time) => {
const percentage = ((time - 7) / Math.ceil(duration.value)) * 100
const percentage = ((time - 5) / Math.ceil(duration.value)) * 100
return {
left: `${percentage}%`,
}

11
frontend/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
export {}
declare global {
function __(text: string): string
}
declare module 'vue' {
interface ComponentCustomProperties {
__: (text: string) => string
}
}

View File

@@ -1,2 +1,3 @@
@import './assets/Inter/inter.css';
@import 'frappe-ui/src/style.css';
@import './styles/codemirror.css';

View File

@@ -145,7 +145,6 @@ const submissions = createListResource({
},
})
// watch changes in assignmentID, member, and status and if changes in any then reload submissions. Also update the url query params for the same
watch([assignmentID, member, status], () => {
router.push({
query: {

View File

@@ -16,20 +16,17 @@
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New') }}
{{ __('Create') }}
</Button>
</header>
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
<div class="flex items-center justify-between mb-5">
<div
v-if="assignmentCount"
class="text-xl font-semibold text-ink-gray-7 mb-4"
>
<div v-if="assignmentCount" class="text-lg font-semibold text-ink-gray-9">
{{ __('{0} Assignments').format(assignmentCount) }}
</div>
<div
v-if="assignments.data?.length || assigmentCount > 0"
v-if="assignments.data?.length || assignmentCount > 0"
class="grid grid-cols-2 gap-5"
>
<FormControl

View File

@@ -4,9 +4,16 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<Button variant="solid" @click="saveBatch()">
{{ __('Save') }}
</Button>
<div class="flex items-center space-x-2">
<Button v-if="batchDetail.data?.name" @click="deleteBatch">
<template #icon>
<Trash2 class="size-4 stroke-1.5" />
</template>
</Button>
<Button variant="solid" @click="saveBatch()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="py-5">
<div class="px-20 pb-5 space-y-5 border-b mb-5">
@@ -209,7 +216,10 @@
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<div
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
@click="openFileSelector"
>
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
@@ -300,10 +310,11 @@
<script setup>
import {
computed,
onMounted,
getCurrentInstance,
inject,
reactive,
onMounted,
onBeforeUnmount,
reactive,
ref,
} from 'vue'
import {
@@ -315,21 +326,30 @@ import {
createResource,
usePageMeta,
toast,
call,
Toast,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { Image } from 'lucide-vue-next'
import { Image, Trash2 } from 'lucide-vue-next'
import { capture } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue'
import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
import {
openSettings,
getMetaInfo,
updateMetaInfo,
validateFile,
} from '@/utils'
const router = useRouter()
const user = inject('$user')
const { brand } = sessionStore()
const { updateOnboardingStep } = useOnboarding('learning')
const instructors = ref([])
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({
batchName: {
@@ -531,6 +551,38 @@ const editBatchDetails = () => {
)
}
const deleteBatch = () => {
$dialog({
title: __('Confirm your action to delete'),
message: __(
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick({ close }) {
trashBatch(close)
close()
},
},
],
})
}
const trashBatch = (close) => {
call('lms.lms.api.delete_batch', {
batch: props.batchName,
}).then(() => {
toast.success(__('Batch deleted successfully'))
close()
router.push({
name: 'Batches',
})
})
}
const saveImage = (file) => {
batch.image = file
}
@@ -539,13 +591,6 @@ const removeImage = () => {
batch.image = null
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
}
}
const breadcrumbs = computed(() => {
let crumbs = [
{

View File

@@ -14,7 +14,7 @@
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('New') }}
{{ __('Create') }}
</Button>
</router-link>
</header>

View File

@@ -80,7 +80,7 @@
/>
<FormControl :label="__('City')" v-model="billingDetails.city" />
<FormControl
:label="__('State')"
:label="__('State/Province')"
v-model="billingDetails.state"
/>
</div>
@@ -303,6 +303,7 @@ const validateAddress = () => {
'Gujarat',
'Haryana',
'Himachal Pradesh',
'Jammu and Kashmir',
'Jharkhand',
'Karnataka',
'Kerala',

View File

@@ -6,7 +6,7 @@
<Breadcrumbs class="h-7" :items="breadcrumbs" />
</header>
<div class="m-5">
<div class="flex justify-between w-full">
<div class="flex justify-between w-full space-x-5">
<div class="md:w-2/3">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ course.data.title }}
@@ -66,7 +66,9 @@
{{ tag }}
</Badge>
</div>
<CourseCardOverlay :course="course" class="md:hidden mb-4" />
<div class="md:hidden mb-4">
<CourseCardOverlay :course="course" />
</div>
<div
v-html="course.data.description"
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 mt-10"
@@ -76,6 +78,7 @@
:title="__('Course Outline')"
:courseName="course.data.name"
:showOutline="true"
:getProgress="course.data.membership ? true : false"
/>
</div>
<CourseReviews

View File

@@ -50,22 +50,28 @@
<div class="mb-1.5 text-xs text-ink-gray-5">
{{ __('Tags') }}
</div>
<div class="flex items-center">
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md mr-2"
>
{{ tag }}
<X
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
@click="removeTag(tag)"
/>
<div>
<div class="flex items-center flex-wrap gap-2">
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
>
{{ tag }}
<X
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
@click="removeTag(tag)"
/>
</div>
</div>
<FormControl
v-model="newTag"
:placeholder="__('Add a keyword and then press enter')"
class="w-full"
:class="[
'w-full',
'flex-1',
{ 'mt-2': course.tags?.length },
]"
@keyup.enter="updateTags()"
id="tags"
/>
@@ -100,7 +106,10 @@
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<div
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
@click="openFileSelector"
>
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
@@ -324,7 +333,12 @@ import { useRouter } from 'vue-router'
import { capture, startRecording, stopRecording } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
import {
openSettings,
getMetaInfo,
updateMetaInfo,
validateFile,
} from '@/utils'
import Link from '@/components/Controls/Link.vue'
import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
@@ -591,13 +605,6 @@ watch(
}
)
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
return __('Only image file is allowed.')
}
}
const updateTags = () => {
if (newTag.value) {
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value

View File

@@ -14,7 +14,7 @@
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('New') }}
{{ __('Create') }}
</Button>
</router-link>
</header>
@@ -57,7 +57,7 @@
</div>
<div
v-if="courses.data?.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-5"
>
<router-link
v-for="course in courses.data"

View File

@@ -151,7 +151,7 @@ import { computed, onMounted, reactive, inject } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import { getFileSize } from '@/utils'
import { getFileSize, validateFile } from '@/utils'
const user = inject('$user')
const router = useRouter()
@@ -293,13 +293,6 @@ const removeImage = () => {
job.image = null
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
}
}
const jobTypes = computed(() => {
return [
{ label: 'Full Time', value: 'Full Time' },

View File

@@ -12,6 +12,12 @@
</template>
</Button>
</Tooltip>
<Button v-if="canSeeStats()" @click="showVideoStats()">
<template #prefix>
<TrendingUp class="size-4 stroke-1.5" />
</template>
{{ __('Video Statistics') }}
</Button>
<CertificationLinks :courseName="courseName" />
</div>
</header>
@@ -65,7 +71,7 @@
<div
class="border-r container pt-5 pb-10 px-5 h-full"
:class="{
'w-full md:w-3/4 mx-auto border-none !pt-10': zenModeEnabled,
'w-full md:w-3/5 mx-auto border-none !pt-10': zenModeEnabled,
}"
>
<div
@@ -100,26 +106,15 @@
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
</template>
</Button>
<router-link
v-if="lesson.data.prev"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.prev.split('.')[0],
lessonNumber: lesson.data.prev.split('.')[1],
},
}"
>
<Button>
<template #prefix>
<ChevronLeft class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Previous') }}
</span>
</Button>
</router-link>
<Button v-if="lesson.data.prev" @click="switchLesson('prev')">
<template #prefix>
<ChevronLeft class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Previous') }}
</span>
</Button>
<router-link
v-if="allowEdit()"
:to="{
@@ -135,26 +130,16 @@
{{ __('Edit') }}
</Button>
</router-link>
<router-link
v-if="lesson.data.next"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.next.split('.')[0],
lessonNumber: lesson.data.next.split('.')[1],
},
}"
>
<Button>
<template #suffix>
<ChevronRight class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Next') }}
</span>
</Button>
</router-link>
<Button v-if="lesson.data.next" @click="switchLesson('next')">
<template #suffix>
<ChevronRight class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Next') }}
</span>
</Button>
<router-link
v-else
:to="{
@@ -262,13 +247,19 @@
</div>
</div>
</div>
<VideoStatistics
v-model="showStatsDialog"
:lessonName="lesson.data?.name"
:lessonTitle="lesson.data?.title"
/>
</template>
<script setup>
import {
createResource,
Badge,
Breadcrumbs,
Button,
call,
createResource,
Tooltip,
usePageMeta,
} from 'frappe-ui'
@@ -292,15 +283,18 @@ import {
Focus,
Info,
MessageCircleQuestion,
TrendingUp,
} from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue'
import { getEditorTools, enablePlyr } from '@/utils'
import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar'
import EditorJS from '@editorjs/editorjs'
import LessonContent from '@/components/LessonContent.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import CertificationLinks from '@/components/CertificationLinks.vue'
import VideoStatistics from '@/components/Modals/VideoStatistics.vue'
const user = inject('$user')
const socket = inject('$socket')
@@ -312,10 +306,13 @@ const instructorEditor = ref(null)
const lessonProgress = ref(0)
const lessonContainer = ref(null)
const zenModeEnabled = ref(false)
const showStatsDialog = ref(false)
const hasQuiz = ref(false)
const discussionsContainer = ref(null)
const timer = ref(0)
const { brand } = sessionStore()
const sidebarStore = useSidebar()
const plyrSources = ref([])
let timerInterval
const props = defineProps({
@@ -335,6 +332,7 @@ const props = defineProps({
onMounted(() => {
startTimer()
sidebarStore.isSidebarCollapsed = true
document.addEventListener('fullscreenchange', attachFullscreenEvent)
socket.on('update_lesson_progress', (data) => {
if (data.course === props.courseName) {
@@ -357,6 +355,8 @@ const attachFullscreenEvent = () => {
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
sidebarStore.isSidebarCollapsed = false
trackVideoWatchDuration()
})
const lesson = createResource({
@@ -452,36 +452,157 @@ const breadcrumbs = computed(() => {
return items
})
const switchLesson = (direction) => {
trackVideoWatchDuration()
let lessonIndex =
direction === 'prev'
? lesson.data.prev.split('.')
: lesson.data.next.split('.')
router.push({
name: 'Lesson',
params: {
courseName: props.courseName,
chapterNumber: lessonIndex[0],
lessonNumber: lessonIndex[1],
},
})
}
watch(
[() => route.params.chapterNumber, () => route.params.lessonNumber],
(
async (
[newChapterNumber, newLessonNumber],
[oldChapterNumber, oldLessonNumber]
) => {
if (newChapterNumber || newLessonNumber) {
editor.value = null
instructorEditor.value = null
allowDiscussions.value = false
lesson.submit({
chapter: newChapterNumber,
lesson: newLessonNumber,
})
clearInterval(timerInterval)
timer.value = 0
plyrSources.value = []
await nextTick()
resetLessonState(newChapterNumber, newLessonNumber)
startTimer()
enablePlyr()
}
}
)
const resetLessonState = (newChapterNumber, newLessonNumber) => {
editor.value = null
instructorEditor.value = null
allowDiscussions.value = false
lesson.submit({
chapter: newChapterNumber,
lesson: newLessonNumber,
})
clearInterval(timerInterval)
timer.value = 0
}
const trackVideoWatchDuration = () => {
if (!lesson.data.membership) return
let videoDetails = getVideoDetails()
videoDetails = videoDetails.concat(getPlyrSourceDetails())
call('lms.lms.api.track_video_watch_duration', {
lesson: lesson.data.name,
videos: videoDetails,
})
}
const getVideoDetails = () => {
let details = []
const videos = document.querySelectorAll('video')
if (videos.length > 0) {
videos.forEach((video) => {
if (video.currentTime == video.duration) markProgress()
details.push({
source: video.src,
watch_time: video.currentTime,
})
})
}
return details
}
const getPlyrSourceDetails = () => {
let details = []
plyrSources.value.forEach((source) => {
if (source.currentTime == source.duration) markProgress()
let src = cleanYouTubeUrl(source.source)
details.push({
source: src,
watch_time: source.currentTime,
})
})
return details
}
const cleanYouTubeUrl = (url) => {
if (!url) return url
const urlObj = new URL(url)
urlObj.searchParams.delete('t')
return urlObj.toString()
}
watch(
() => lesson.data,
(data) => {
async (data) => {
setupLesson(data)
enablePlyr()
getPlyrSource()
if (data.icon == 'icon-youtube') clearInterval(timerInterval)
}
)
const getPlyrSource = async () => {
await nextTick()
if (plyrSources.value.length == 0) {
plyrSources.value = await enablePlyr()
}
updateVideoWatchDuration()
}
const updateVideoWatchDuration = () => {
if (lesson.data.videos && lesson.data.videos.length > 0) {
lesson.data.videos.forEach((video) => {
if (video.source.includes('youtube') || video.source.includes('vimeo')) {
updatePlyrVideoTime(video)
} else {
updateVideoTime(video)
}
})
}
}
const updatePlyrVideoTime = (video) => {
plyrSources.value.forEach((plyrSource) => {
let lastWatchedTime = 0
let isSeeking = false
plyrSource.on('ready', () => {
if (plyrSource.source === video.source) {
plyrSource.embed.seekTo(video.watch_time, true)
plyrSource.play()
plyrSource.pause()
}
})
})
}
const updateVideoTime = (video) => {
const videos = document.querySelectorAll('video')
if (videos.length > 0) {
videos.forEach((vid) => {
if (vid.src === video.source) {
let watch_time = video.watch_time < vid.duration ? video.watch_time : 0
if (vid.readyState >= 1) {
vid.currentTime = watch_time
} else {
vid.addEventListener('loadedmetadata', () => {
vid.currentTime = watch_time
})
}
}
})
}
}
const startTimer = () => {
timerInterval = setInterval(() => {
timer.value++
@@ -548,13 +669,22 @@ const enrollStudent = () => {
)
}
const canSeeStats = () => {
if (user.data?.is_moderator || user.data?.is_instructor) return true
return false
}
const showVideoStats = () => {
showStatsDialog.value = true
}
const canGoZen = () => {
if (
user.data?.is_moderator ||
user.data?.is_instructor ||
user.data?.is_evaluator
)
return false
return true
if (lesson.data?.membership) return true
return false
}

View File

@@ -210,7 +210,7 @@ const addInstructorNotes = (data) => {
const enableAutoSave = () => {
autoSaveInterval = setInterval(() => {
saveLesson({ showSuccessMessage: false })
}, 5000)
}, 10000)
}
const keyboardShortcut = (e) => {
@@ -385,8 +385,10 @@ const saveLesson = (e) => {
showSuccessMessage = true
}
editor.value.save().then((outputData) => {
outputData = removeEmptyBlocks(outputData)
lesson.content = JSON.stringify(outputData)
instructorEditor.value.save().then((outputData) => {
outputData = removeEmptyBlocks(outputData)
lesson.instructor_content = JSON.stringify(outputData)
if (lessonDetails.data?.lesson) {
editCurrentLesson()
@@ -397,6 +399,14 @@ const saveLesson = (e) => {
})
}
const removeEmptyBlocks = (outputData) => {
let blocks = outputData.blocks.filter((block) => {
return Object.keys(block.data).length > 0 || block.type == 'paragraph'
})
outputData.blocks = blocks
return outputData
}
const createNewLesson = () => {
newLessonResource.submit(
{},
@@ -653,6 +663,68 @@ iframe {
border-radius: 8px;
}
.ce-popover__container {
border-radius: 12px;
padding: 8px;
}
.cdx-search-field {
border: none;
}
.cdx-search-field__input {
font-weight: 400;
font-size: 13px;
}
.cdx-search-field__input::before {
font-weight: 400;
}
.cdx-search-field__input:focus {
--tw-ring-color: theme('colors.gray.100');
}
.ce-popover-item__title {
font-size: 13px;
font-weight: 400;
}
.ce-popover-item__icon svg {
width: 15px;
height: 15px;
}
.ce-popover--opened > .ce-popover__container {
max-height: unset;
}
.cdx-search-field__icon svg {
width: 15px;
height: 15px;
}
.cdx-search-field__icon {
margin-right: 5px;
}
.cdx-block.embed-tool {
position: relative;
display: inline-block;
width: 100%;
}
.cdx-block.embed-tool::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: transparent;
z-index: 1000;
}
:root {
--plyr-range-fill-background: white;
--plyr-video-control-background-hover: transparent;

View File

@@ -80,7 +80,7 @@
v-model="activeTab"
/>
</div>
<router-view :profile="profile" />
<router-view :profile="profile" :key="profile.data?.name" />
</div>
</div>
<EditProfile

View File

@@ -45,7 +45,7 @@
</template>
<script setup>
import { FormControl, createResource, toast } from 'frappe-ui'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils'
import { CircleAlert } from 'lucide-vue-next'
@@ -66,10 +66,9 @@ const roles = createResource({
url: 'lms.lms.utils.get_roles',
makeParams(values) {
return {
name: props.profile.data?.name,
name: values.member,
}
},
auto: true,
onSuccess(data) {
let roles = [
'moderator',
@@ -83,6 +82,16 @@ const roles = createResource({
},
})
watch(
() => props.profile,
(newValue) => {
roles.reload({
member: newValue.data?.name,
})
},
{ immediate: true }
)
const updateRole = createResource({
url: 'lms.lms.api.save_role',
makeParams(values) {
@@ -97,7 +106,10 @@ const updateRole = createResource({
const changeRole = (role) => {
updateRole.submit(
{
role: convertToTitleCase(role.split('_').join(' ')),
role:
role == 'lms_student'
? 'LMS Student'
: convertToTitleCase(role.split('_').join(' ')),
value: eval(role).value,
},
{

View File

@@ -0,0 +1,255 @@
<template>
<Dialog v-model="show" :options="{ size: '5xl' }">
<template #body-title>
<div class="text-xl font-semibold text-ink-gray-9">
{{
props.exerciseID === 'new'
? __('Create Programming Exercise')
: __('Edit Programming Exercise')
}}
</div>
</template>
<template #body-content>
<div class="grid grid-cols-2 gap-10">
<div class="space-y-4">
<FormControl
v-model="exercise.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="exercise.language"
:label="__('Language')"
type="select"
:options="languageOptions"
:required="true"
/>
<ChildTable
v-model="exercise.test_cases"
:label="__('Test Cases')"
:columns="testCaseColumns"
:required="true"
:addable="true"
:deletable="true"
:editable="true"
:placeholder="__('Add Test Case')"
/>
</div>
<div>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Problem Statement') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="exercise.problem_statement"
@change="(val: string) => (exercise.problem_statement = 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] max-h-[21rem] overflow-y-auto"
/>
</div>
</div>
</div>
</template>
<template #actions="{ close }">
<div class="flex justify-end space-x-2 group">
<Button
v-if="exerciseID != 'new'"
@click="deleteExercise(close)"
variant="outline"
theme="red"
class="invisible group-hover:visible"
>
<template #prefix>
<Trash2 class="size-4 stroke-1.5" />
</template>
{{ __('Delete') }}
</Button>
<router-link
:to="{
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: props.exerciseID,
submissionID: 'new',
},
}"
>
<Button>
<template #prefix>
<Play class="size-4 stroke-1.5" />
</template>
{{ __('Test this Exercise') }}
</Button>
</router-link>
<router-link
:to="{
name: 'ProgrammingExerciseSubmissions',
query: {
exercise: props.exerciseID,
},
}"
>
<Button>
<template #prefix>
<ClipboardList class="size-4 stroke-1.5" />
</template>
{{ __('Check Submission') }}
</Button>
</router-link>
<Button variant="solid" @click="saveExercise(close)">
{{ __('Save') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Button,
createListResource,
Dialog,
FormControl,
TextEditor,
toast,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import {
ProgrammingExercise,
ProgrammingExercises,
TestCase,
} from '@/types/programming-exercise'
import ChildTable from '@/components/Controls/ChildTable.vue'
import { ClipboardList, Play, Trash2 } from 'lucide-vue-next'
const show = defineModel()
const exercises = defineModel<ProgrammingExercises>('exercises')
const exercise = ref<ProgrammingExercise>({
title: '',
language: 'Python',
problem_statement: '',
test_cases: [],
})
const languageOptions = [
{ label: 'Python', value: 'Python' },
{ label: 'JavaScript', value: 'JavaScript' },
]
const props = withDefaults(
defineProps<{
exerciseID: string
}>(),
{
exerciseID: 'new',
}
)
watch(
() => props.exerciseID,
() => {
setExerciseData()
fetchTestCases()
}
)
const setExerciseData = () => {
let isNew = true
exercises.value?.data.forEach((ex: ProgrammingExercise) => {
if (ex.name === props.exerciseID) {
isNew = false
exercise.value = { ...ex }
}
})
if (isNew) {
exercise.value = {
title: '',
language: 'Python',
problem_statement: '',
test_cases: [],
}
}
}
const testCases = createListResource({
doctype: 'LMS Test Case',
fields: ['input', 'expected_output', 'name'],
cache: ['testCases', props.exerciseID],
parent: 'LMS Programming Exercise',
onSuccess(data: TestCase[]) {
exercise.value.test_cases = data
},
})
const fetchTestCases = () => {
testCases.update({
filters: {
parent: props.exerciseID,
parenttype: 'LMS Programming Exercise',
parentfield: 'test_cases',
},
})
testCases.reload()
}
const saveExercise = (close: () => void) => {
if (props.exerciseID == 'new') createNewExercise(close)
else updateExercise(close)
}
const createNewExercise = (close: () => void) => {
exercises.value?.insert.submit(
{
...exercise.value,
},
{
onSuccess() {
close()
exercises.value?.reload()
toast.success(__('Programming Exercise created successfully'))
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
}
)
}
const updateExercise = (close: () => void) => {
exercises.value?.setValue.submit(
{
name: props.exerciseID,
...exercise.value,
},
{
onSuccess() {
close()
exercises.value?.reload()
toast.success(__('Programming Exercise updated successfully'))
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
}
)
}
const testCaseColumns = computed(() => {
return ['Input', 'Expected Output']
})
const deleteExercise = (close: () => void) => {
if (props.exerciseID == 'new') return
exercises.value?.delete.submit(props.exerciseID, {
onSuccess() {
toast.success(__('Programming Exercise deleted successfully'))
close()
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
})
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add a programming exercise to your lesson'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: () => {
saveExercise()
},
},
],
}"
>
<template #body-content>
<div class="text-base">
<Link
v-model="exercise"
doctype="LMS Programming Exercise"
:label="__('Select a Programming Exercise')"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Dialog } from 'frappe-ui'
import { onMounted, nextTick, ref } from 'vue'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const exercise = ref(null)
const props = defineProps({
onSave: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
show.value = true
})
const saveExercise = () => {
props.onSave(exercise.value)
show.value = false
}
</script>

View File

@@ -0,0 +1,430 @@
<template>
<header
v-if="!fromLesson"
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div
v-if="falconError"
class="flex items-center justify-between p-3 text-sm bg-surface-amber-1 text-ink-amber-3"
>
<span>
{{ falconError }}
</span>
<Button v-if="user.data?.is_moderator" @click="openSettings('General')">
<template #prefix>
<Settings class="size-4 stroke-1.5" />
</template>
{{ __('Settings') }}
</Button>
</div>
<div class="grid grid-cols-2 h-[calc(100vh_-_3rem)]">
<div class="border-r py-5 px-8 h-full">
<div class="font-semibold mb-2">
{{ __('Problem Statement') }}
</div>
<div
v-html="exercise.doc?.problem_statement"
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>
<div class="flex items-center justify-between p-2 bg-surface-gray-2">
<div class="font-semibold">
{{ exercise.doc?.language }}
</div>
<div class="space-x-2">
<Badge
v-if="submission.doc?.status"
:theme="submission.doc.status == 'Passed' ? 'green' : 'red'"
>
{{ submission.doc.status }}
</Badge>
<Button
v-if="
!falconError &&
(submissionID == 'new' ||
user.data?.name == submission.doc?.owner)
"
variant="solid"
@click="submitCode"
>
<template #prefix>
<Play class="size-3" />
</template>
{{ __('Run') }}
</Button>
</div>
</div>
<div class="flex flex-col space-y-4 pt-5 border-b">
<Code
v-model="code"
:language="exercise.doc?.language.toLowerCase()"
height="400px"
maxHeight="1000px"
/>
<div class="flex flex-col space-y-1">
<span v-if="error" class="text-xs text-ink-gray-5 px-1">
{{ __('Compiler Message') }}:
</span>
<textarea
v-if="error"
v-model="errorMessage"
class="font-mono text-ink-red-3 bg-surface-gray-1 border-none text-sm h-32 leading-6"
readonly
/>
</div>
<!-- <textarea v-else v-model="output" class="bg-surface-gray-1 border-none text-sm h-28 leading-6" readonly /> -->
</div>
<div ref="testCaseSection" class="p-5">
<span class="text-lg font-semibold text-ink-gray-9">
{{ __('Test Cases') }}
</span>
<div v-if="testCases.length" class="divide-y mt-5">
<div
v-for="(testCase, index) in testCases"
:key="testCase.input"
class="py-3"
>
<div class="flex items-center mb-3">
<span class=""> {{ __('Test {0}').format(index + 1) }} - </span>
<span
class="font-semibold ml-2 mr-1"
:class="
testCase.status === 'Passed'
? 'text-ink-green-3'
: 'text-ink-red-3'
"
>
{{ testCase.status }}
</span>
<!-- <span v-if="testCase.status === 'Passed'">
<Check class="size-4 text-ink-green-3" />
</span>
<span v-else>
<X class="size-4 text-ink-red-3" />
</span> -->
</div>
<div class="flex items-center justify-between w-[60%]">
<div v-if="testCase.input" class="space-y-2">
<div class="text-xs text-ink-gray-7">
{{ __('Input') }}
</div>
<div>{{ testCase.input }}</div>
</div>
<div class="space-y-2">
<div class="text-xs text-ink-gray-7">
{{ __('Your Output') }}
</div>
<div>
{{ testCase.output }}
</div>
</div>
<div class="space-y-2">
<div class="text-xs text-ink-gray-7">
{{ __('Expected Output') }}
</div>
<div>{{ testCase.expected_output }}</div>
</div>
</div>
</div>
</div>
<div v-else class="text-sm text-ink-gray-6 mt-4">
{{ __('Please run the code to execute the test cases.') }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
Badge,
Breadcrumbs,
Button,
call,
createDocumentResource,
toast,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { Play, X, Check, Settings } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import { openSettings } from '@/utils'
const user = inject<any>('$user')
const code = ref<string | null>('')
const output = ref<string | null>(null)
const error = ref<boolean | null>(null)
const errorMessage = ref<string | null>(null)
const testCaseSection = ref<HTMLElement | null>(null)
const testCases = ref<TestCase[]>([])
const boilerplate = ref<string>('')
const { brand, livecodeURL } = sessionStore()
const router = useRouter()
const fromLesson = ref(false)
const falconURL = ref<string>('https://falcon.frappe.io/')
const falconError = ref<string | null>(null)
const props = withDefaults(
defineProps<{
exerciseID: string
submissionID?: string
}>(),
{
submissionID: 'new',
}
)
onMounted(() => {
loadFalcon()
checkIfUserIsPermitted()
checkIfInLesson()
fetchSubmission()
})
const checkIfInLesson = () => {
if (new URLSearchParams(window.location.search).get('fromLesson')) {
fromLesson.value = true
}
}
const fetchSubmission = (name: string = '') => {
if (name) {
submission.name = name
submission.reload()
} else if (props.submissionID != 'new') {
submission.reload()
}
}
const exercise = createDocumentResource({
doctype: 'LMS Programming Exercise',
name: props.exerciseID,
cache: ['programmingExercise', props.exerciseID],
auto: true,
})
const submission = createDocumentResource({
doctype: 'LMS Programming Exercise Submission',
name: props.submissionID,
auto: false,
onError(error: any) {
if (error.messages?.[0].includes('not found')) {
router.push({
name: 'ProgrammingExerciseSubmission',
params: { exerciseID: props.exerciseID, submissionID: 'new' },
})
} else {
toast.error(__(error.messages?.[0] || error))
}
},
})
watch(exercise, () => {
updateCode()
})
const updateCode = (submissionCode = '') => {
updateBoilerPlate()
if (!code.value?.includes(boilerplate.value)) {
code.value = `${boilerplate.value}${code.value}`
}
if (submissionCode && !code.value?.includes(submissionCode)) {
code.value = `${code.value}${submissionCode}`
} else if (!submissionCode && !code.value) {
code.value = boilerplate.value
}
}
const updateBoilerPlate = () => {
if (exercise.doc?.language == 'Python') {
boilerplate.value = `with open("stdin", "r") as f:\n data = f.read()\n\ninputs = data.split() if len(data) else []\n\n# inputs is a list of strings\n# write your code below\n\n`
} else if (exercise.doc?.language == 'JavaScript') {
boilerplate.value = `const fs = require('fs');\n\nlet input = fs.readFileSync('/app/stdin', 'utf8').trim();\nconst inputs = input.split("\\n");\n// inputs is an array of strings\n// write your code below\n`
}
}
const checkIfUserIsPermitted = (doc: any = null) => {
if (!user.data) {
window.location.href = `/login?redirect-to=/lms/programming-exercises/${props.exerciseID}/submission/${props.submissionID}`
}
if (!doc) return
if (
doc.owner != user.data?.name &&
!user.data?.is_instructor &&
!user.data?.is_moderator &&
!user.data.is_evaluator
) {
router.push({
name: 'ProgrammingExerciseSubmission',
params: { exerciseID: props.exerciseID, submissionID: 'new' },
})
return
}
}
const updateTestCases = (doc: any) => {
if (testCases.value.length === 0) {
testCases.value = doc.test_cases || []
}
}
watch(
() => submission.doc,
(doc) => {
if (doc) {
checkIfUserIsPermitted(doc)
updateTestCases(doc)
updateCode(doc.code)
}
},
{ immediate: true }
)
const loadFalcon = () => {
if (livecodeURL.data) {
falconURL.value = livecodeURL.data
}
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = `${falconURL.value}static/livecode.js`
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
}
const submitCode = async () => {
await runCode()
createSubmission()
}
const runCode = async () => {
if (!exercise.doc?.test_cases?.length) return
testCases.value = []
if (testCaseSection.value) {
testCaseSection.value.scrollIntoView({ behavior: 'smooth' })
}
for (const test_case of exercise.doc.test_cases) {
let result = await execute(test_case.input)
if (error.value) {
errorMessage.value = result
break
} else {
output.value = result
}
let status =
result.trim() === test_case.expected_output.trim() ? 'Passed' : 'Failed'
testCases.value.push({
input: test_case.input,
output: result,
expected_output: test_case.expected_output,
status: status,
})
}
}
const createSubmission = () => {
if (!testCases.value.length) return
let codeToSave = code.value?.replace(boilerplate.value, '') || ''
call('lms.lms.api.create_programming_exercise_submission', {
exercise: props.exerciseID,
submission: props.submissionID,
code: codeToSave,
test_cases: testCases.value,
})
.then((data: any) => {
if (props.submissionID == 'new') {
router.push({
name: 'ProgrammingExerciseSubmission',
params: { exerciseID: props.exerciseID, submissionID: data },
})
fetchSubmission(data)
} else {
fetchSubmission(props.submissionID)
}
toast.success(__('Submission saved!'))
})
.catch((error: any) => {
console.error('Error creating submission:', error)
toast.error(
__('Failed to submit. Please try again. {0}').format({ error })
)
})
}
const execute = (stdin = ''): Promise<string> => {
return new Promise((resolve, reject) => {
let outputChunks: string[] = []
let hasExited = false
let hasError = false
let session = new LiveCodeSession({
base_url: falconURL.value,
runtime: exercise.doc?.language.toLowerCase() || 'python',
code: code.value,
files: [{ filename: 'stdin', contents: stdin }],
onMessage: (msg: any) => {
console.log('msg', msg)
if (msg.msgtype === 'write' && msg.file === 'stdout') {
outputChunks.push(msg.data)
}
if (msg.msgtype === 'write' && msg.file === 'stderr') {
hasError = true
errorMessage.value = msg.data
}
if (msg.msgtype === 'exitstatus') {
hasExited = true
if (msg.exitstatus !== 0) {
error.value = true
} else {
error.value = false
}
resolve(outputChunks.join('').trim())
}
},
})
setTimeout(() => {
if (!hasExited) {
error.value = true
errorMessage.value = 'Execution timed out.'
reject('Execution timed out.')
}
}, 20000)
})
}
const breadcrumbs = computed(() => {
return [
{
label: __('Programming Exercise Submissions'),
route: { name: 'ProgrammingExerciseSubmissions' },
},
{ label: exercise.doc?.title },
]
})
usePageMeta(() => {
return {
title: __('Programming Exercise Submission'),
icon: brand.favicon,
}
})
</script>
<style>
.ProseMirror pre {
background: theme('colors.gray.200');
color: theme('colors.gray.900');
}
</style>

View File

@@ -0,0 +1,312 @@
<template>
<header
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="p-6">
<div class="flex items-center justify-between space-x-32 mb-5">
<div class="text-xl font-semibold text-ink-gray-7">
{{
submissions.data?.length
? __('{0} Submissions').format(submissions.data.length)
: __('No Submissions')
}}
</div>
<div
v-if="submissions.data?.length"
class="grid grid-cols-3 gap-5 flex-1"
>
<Link
doctype="LMS Programming Exercise"
v-model="filters.exercise"
:placeholder="__('Filter by Exercise')"
/>
<Link
doctype="User"
v-model="filters.member"
:placeholder="__('Filter by Member')"
:readonly="isStudent"
/>
<FormControl
v-model="filters.status"
type="select"
:options="[
{ label: __(''), value: '' },
{ label: __('Passed'), value: 'Passed' },
{ label: __('Failed'), value: 'Failed' },
]"
:placeholder="__('Filter by Status')"
/>
</div>
</div>
<ListView
v-if="submissions.loading || submissions.data?.length"
:columns="submissionColumns"
:rows="submissions.data"
rowKey="name"
:options="{
selectable: true,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem
:item="item"
v-for="item in submissionColumns"
:key="item.key"
>
<template #prefix="{ item }">
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<router-link
v-for="row in submissions.data"
:to="{
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.exercise,
submissionID: row.name,
},
}"
>
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="column.key == 'status'">
<Badge
:theme="row[column.key] === 'Passed' ? 'green' : 'red'"
>
{{ row[column.key] }}
</Badge>
</div>
<div
v-else-if="column.key == 'modified'"
class="text-sm text-ink-gray-5"
>
{{ row[column.key] }}
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteExercises(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
<EmptyState v-else type="Programming Exercise Submissions" />
<div
v-if="submissions.data && submissions.hasNextPage"
class="flex justify-center my-5"
>
<Button @click="submissions.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import {
Avatar,
Badge,
Breadcrumbs,
Button,
createListResource,
FeatherIcon,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
usePageMeta,
toast,
} from 'frappe-ui'
import type {
ProgrammingExerciseSubmission,
Filters,
} from '@/pages/ProgrammingExercises/types'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import { Trash2 } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
import EmptyState from '@/components/EmptyState.vue'
const { brand } = sessionStore()
const dayjs = inject('$dayjs') as any
const user = inject('$user') as any
const filterFields = ['exercise', 'member', 'status']
const filters = ref<Filters>({
exercise: '',
member: '',
status: '',
})
const router = useRouter()
onMounted(() => {
setFiltersFromRoute()
fetchBasedOnRole()
})
const setFiltersFromRoute = () => {
filterFields.forEach((field) => {
if (router.currentRoute.value.query[field]) {
filters.value[field as keyof Filters] = router.currentRoute.value.query[
field
] as string
}
})
}
const fetchBasedOnRole = () => {
if (isStudent.value) {
filters.value['member'] = user.data?.name
} else {
submissions.reload()
}
}
const submissions = createListResource({
doctype: 'LMS Programming Exercise Submission',
fields: [
'name',
'exercise',
'exercise_title',
'member_name',
'member_image',
'status',
'modified',
],
orderBy: 'modified desc',
transform(data: ProgrammingExercise[]) {
return data.map((submission: ProgrammingExerciseSubmission) => {
return {
...submission,
modified: dayjs(submission.modified).fromNow(),
}
})
},
})
watch(filters.value, () => {
let filtersToApply: Record<string, any> = {}
filterFields.forEach((field) => {
if (filters.value[field as keyof Filters]) {
filtersToApply[field] = filters.value[field as keyof Filters]
router.push({
query: {
...router.currentRoute.value.query,
[field]: filters.value[field as keyof Filters],
},
})
} else {
delete filtersToApply[field]
const query = { ...router.currentRoute.value.query }
delete query[field]
router.push({
query,
})
}
})
submissions.update({
filters: {
...filtersToApply,
},
})
submissions.reload()
})
const deleteExercises = (selections: Set<string>, unselectAll: () => void) => {
Array.from(selections).forEach(async (submission: string) => {
await submissions.delete.submit(submission)
})
unselectAll()
toast.success(__('Submissions deleted successfully'))
}
const isStudent = computed(() => {
return (
!user.data?.is_instructor &&
!user.data?.is_moderator &&
!user.data?.is_evaluator
)
})
const submissionColumns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
width: '30%',
icon: 'user',
},
{
label: __('Exercise'),
key: 'exercise_title',
width: '30%',
icon: 'code',
},
{
label: __('Status'),
key: 'status',
width: '20%',
icon: 'check-circle',
},
{
label: __('Modified'),
key: 'modified',
width: '15%',
icon: 'clock',
align: 'right',
},
]
})
const breadcrumbs = computed(() => {
return [
{
label: __('Programming Exercise Submissions'),
route: {
name: 'ProgrammingExerciseSubmissions',
},
},
]
})
usePageMeta(() => {
return {
title: __('Programming Exercises'),
icon: brand.favicon,
}
})
</script>

View File

@@ -0,0 +1,171 @@
<template>
<header
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div class="space-x-2">
<router-link
:to="{
name: 'ProgrammingExerciseSubmissions',
}"
>
<Button>
<template #prefix>
<ClipboardList class="size-4 stroke-1.5" />
</template>
{{ __('Check All Submissions') }}
</Button>
</router-link>
<Button
v-if="!readOnlyMode"
variant="solid"
@click="
() => {
exerciseID = 'new'
showForm = true
}
"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('Create') }}
</Button>
</div>
</header>
<div class="md:w-4/5 md:mx-auto p-5">
<div class="flex items-center justify-between mb-5">
<div v-if="exerciseCount" class="text-lg font-semibold text-ink-gray-9">
{{ __('{0} Exercises').format(exerciseCount) }}
</div>
<div
v-if="exercises.data?.length || exerciseCount > 0"
class="grid grid-cols-2 gap-5"
>
<!-- <FormControl
v-model="titleFilter"
:placeholder="__('Search by title')"
/>
<FormControl
v-model="typeFilter"
type="select"
:options="assignmentTypes"
:placeholder="__('Type')"
/> -->
</div>
</div>
<div
v-if="exercises.data?.length"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div
v-for="exercise in exercises.data"
:key="exercise.name"
@click="
() => {
exerciseID = exercise.name
showForm = true
}
"
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3 space-y-2 cursor-pointer"
>
<div class="text-lg font-semibold text-ink-gray-9">
{{ exercise.title }}
</div>
<div class="text-sm text-ink-gray-7">
{{ exercise.language }}
</div>
</div>
</div>
<EmptyState v-else type="Programming Exercises" />
<div
v-if="exercises.data && exercises.hasNextPage"
class="flex justify-center my-5"
>
<Button @click="exercises.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
<ProgrammingExerciseForm
v-model="showForm"
:exerciseID="exerciseID"
v-model:exercises="exercises"
/>
</template>
<script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue'
import {
Breadcrumbs,
Button,
call,
createListResource,
usePageMeta,
} from 'frappe-ui'
import { ClipboardList, Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import ProgrammingExerciseForm from '@/pages/ProgrammingExercises/ProgrammingExerciseForm.vue'
const exerciseCount = ref<number>(0)
const readOnlyMode = window.read_only_mode
const { brand } = sessionStore()
const showForm = ref<boolean>(false)
const exerciseID = ref<string | null>('new')
const user = inject<any>('$user')
const router = useRouter()
onMounted(() => {
validatePermissions()
getExerciseCount()
})
const validatePermissions = () => {
if (
!user.data?.is_instructor &&
!user.data?.is_moderator &&
!user.data?.is_evaluator
) {
router.push({
name: 'ProgrammingExerciseSubmissions',
})
}
}
const getExerciseCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Programming Exercise',
})
.then((count: number) => {
exerciseCount.value = count
})
.catch((error: any) => {
console.error('Error fetching exercise count:', error)
})
}
const exercises = createListResource({
doctype: 'LMS Programming Exercise',
cache: ['programmingExercises'],
fields: ['name', 'title', 'language', 'problem_statement'],
auto: true,
orderBy: 'modified desc',
})
usePageMeta(() => {
return {
title: __('Programming Exercises'),
icon: brand.favicon,
}
})
const breadcrumbs = computed(() => {
return [
{
label: __('Programming Exercises'),
route: { name: 'ProgrammingExercises' },
},
]
})
</script>

View File

@@ -0,0 +1,47 @@
interface ProgrammingExercise {
name: string;
title: string;
language: 'Python' | 'JavaScript';
test_cases_count: number;
problem_statement: string;
test_cases: [TestCase];
}
interface TestCase {
name: string;
input: string;
expected_output: string;
output: string;
status: 'Passed' | 'Failed';
}
type Filters = {
exercise?: string,
member?: string,
status?: string
}
type ProgrammingExercises = {
data: ProgrammingExercise[]
reload: () => void
hasNextPage: boolean
next: () => void
setValue: {
submit: (
data: ProgrammingExercise,
options?: { onSuccess?: () => void }
) => void
}
insert: {
submit: (
data: ProgrammingExercise,
options?: { onSuccess?: () => void }
) => void
}
delete: {
submit: (
name: string,
options?: { onSuccess?: () => void }
) => void
}
}

View File

@@ -4,30 +4,39 @@
>
<Breadcrumbs :items="breadcrumbs" />
<div v-if="!readOnlyMode" class="space-x-2">
<Badge v-if="quizDetails.isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<router-link
v-if="quizDetails.data?.name"
v-if="quizDetails.doc?.name"
:to="{
name: 'QuizPage',
params: {
quizID: quizDetails.data.name,
quizID: quizDetails.doc.name,
},
}"
>
<Button>
{{ __('Open') }}
<template #prefix>
<ListChecks class="size-4 stroke-1.5" />
</template>
{{ __('Test Quiz') }}
</Button>
</router-link>
<router-link
v-if="quizDetails.data?.name"
v-if="quizDetails.doc?.name"
:to="{
name: 'QuizSubmissionList',
params: {
quizID: quizDetails.data.name,
quizID: quizDetails.doc.name,
},
}"
>
<Button>
{{ __('Submission List') }}
<template #prefix>
<ClipboardList class="size-4 stroke-1.5" />
</template>
{{ __('Check Submissions') }}
</Button>
</router-link>
<Button variant="solid" @click="submitQuiz()">
@@ -35,144 +44,152 @@
</Button>
</div>
</header>
<div class="w-3/4 mx-auto py-5">
<!-- Details -->
<div class="mb-8">
<div class="font-semibold text-ink-gray-9 mb-4">
<div v-if="quizDetails.doc" class="py-5">
<div class="px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Details') }}
</div>
<FormControl
v-model="quiz.title"
:label="
quizDetails.data?.name
? __('Title')
: __('Enter a title and save the quiz to proceed')
"
:required="true"
/>
<div v-if="quizDetails.data?.name">
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
<div class="grid grid-cols-2 gap-5">
<div class="space-y-5">
<FormControl
v-model="quizDetails.doc.title"
:label="__('Title')"
:required="true"
/>
<FormControl
type="number"
v-model="quiz.max_attempts"
v-model="quizDetails.doc.max_attempts"
:label="__('Maximum Attempts')"
/>
<FormControl
type="number"
v-model="quiz.duration"
v-model="quizDetails.doc.duration"
:label="__('Duration (in minutes)')"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="quiz.total_marks"
v-model="quizDetails.doc.total_marks"
:label="__('Total Marks')"
disabled
/>
<FormControl
v-model="quiz.passing_percentage"
v-model="quizDetails.doc.passing_percentage"
:label="__('Passing Percentage')"
:required="true"
/>
</div>
<!-- Settings -->
<div class="mb-8">
<div class="font-semibold text-ink-gray-9 mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-5 my-4">
<FormControl
v-model="quiz.show_answers"
type="checkbox"
:label="__('Show Answers')"
/>
<FormControl
v-model="quiz.show_submission_history"
type="checkbox"
:label="__('Show Submission History')"
/>
</div>
</div>
<div class="mb-8">
<div class="font-semibold text-ink-gray-9 mb-4">
{{ __('Shuffle Settings') }}
</div>
<div class="grid grid-cols-3">
<FormControl
v-model="quiz.shuffle_questions"
type="checkbox"
:label="__('Shuffle Questions')"
/>
<FormControl
v-if="quiz.shuffle_questions"
v-model="quiz.limit_questions_to"
:label="__('Limit Questions To')"
/>
</div>
</div>
<!-- Questions -->
<div>
<div class="flex items-center justify-between mb-4">
<div class="font-semibold text-ink-gray-9">
{{ __('Questions') }}
</div>
<Button v-if="!readOnlyMode" @click="openQuestionModal()">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New Question') }}
</Button>
</div>
<ListView
:columns="questionColumns"
:rows="quiz.questions"
row-key="name"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in questionColumns" />
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-slot="{ idx, column, item }"
v-for="row in quiz.questions"
@click="openQuestionModal(row)"
class="cursor-pointer"
>
<ListRowItem :item="item">
<div
v-if="column.key == 'question_detail'"
class="text-xs truncate h-4"
v-html="item"
></div>
<div v-else class="text-xs">
{{ item }}
</div>
</ListRowItem>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteQuestions(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
</div>
<div class="px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-5">
<div class="flex flex-col space-y-10">
<FormControl
v-model="quizDetails.doc.show_answers"
type="checkbox"
:label="__('Show Answers')"
/>
<FormControl
v-model="quizDetails.doc.show_submission_history"
type="checkbox"
:label="__('Show Submission History')"
/>
</div>
<div class="flex flex-col space-y-5">
<FormControl
v-model="quizDetails.doc.shuffle_questions"
type="checkbox"
:label="__('Shuffle Questions')"
/>
<FormControl
v-if="quizDetails.doc.shuffle_questions"
v-model="quizDetails.doc.limit_questions_to"
:label="__('Limit Questions To')"
/>
</div>
<div class="flex flex-col space-y-5">
<FormControl
v-model="quizDetails.doc.enable_negative_marking"
type="checkbox"
:label="__('Enable Negative Marking')"
/>
<FormControl
v-if="quizDetails.doc.enable_negative_marking"
v-model="quizDetails.doc.marks_to_cut"
:label="__('Marks to Cut')"
/>
</div>
</div>
</div>
<div class="px-20 pb-5 space-y-5 mb-5">
<div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Questions') }}
</div>
<Button v-if="!readOnlyMode" @click="openQuestionModal()">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New Question') }}
</Button>
</div>
<ListView
v-if="questions.length"
:columns="questionColumns"
:rows="questions"
row-key="name"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in questionColumns" />
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-slot="{ idx, column, item }"
v-for="row in questions"
@click="openQuestionModal(row)"
class="cursor-pointer"
>
<ListRowItem :item="item">
<div
v-if="column.key == 'question_detail'"
class="text-xs truncate h-4"
v-html="item"
></div>
<div v-else class="text-xs">
{{ item }}
</div>
</ListRowItem>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteQuestions(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
<div v-else class="text-ink-gray-6 text-sm">
{{ __('No questions added yet') }}
</div>
</div>
</div>
<Question
v-model="showQuestionModal"
:questionDetail="currentQuestion"
@@ -199,6 +216,8 @@ import {
Button,
usePageMeta,
toast,
createDocumentResource,
Badge,
} from 'frappe-ui'
import {
computed,
@@ -210,8 +229,7 @@ import {
watch,
} from 'vue'
import { sessionStore } from '../stores/session'
import { Plus, Trash2 } from 'lucide-vue-next'
import { updateDocumentTitle } from '@/utils'
import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import Question from '@/components/Modals/Question.vue'
@@ -233,18 +251,7 @@ const props = defineProps({
},
})
const quiz = reactive({
title: '',
total_marks: 0,
passing_percentage: 0,
max_attempts: 0,
duration: 0,
limit_questions_to: 0,
show_answers: true,
show_submission_history: false,
shuffle_questions: false,
questions: [],
})
const questions = ref([])
onMounted(() => {
if (
@@ -280,86 +287,26 @@ watch(
}
)
const quizDetails = createResource({
url: 'frappe.client.get',
makeParams(values) {
return { doctype: 'LMS Quiz', name: props.quizID }
},
const quizDetails = createDocumentResource({
doctype: 'LMS Quiz',
name: props.quizID,
auto: false,
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (Object.hasOwn(quiz, key)) quiz[key] = data[key]
})
let checkboxes = [
'show_answers',
'show_submission_history',
'shuffle_questions',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
quiz[key] = quiz[key] ? true : false
}
},
})
const quizCreate = createResource({
url: 'frappe.client.insert',
auto: false,
makeParams(values) {
return {
doc: {
doctype: 'LMS Quiz',
...quiz,
},
}
},
})
const quizUpdate = createResource({
url: 'frappe.client.set_value',
auto: false,
makeParams(values) {
return {
doctype: 'LMS Quiz',
name: values.quizID,
fieldname: {
total_marks: calculateTotalMarks(),
...quiz,
},
onSuccess(doc) {
if (doc.questions && doc.questions.length > 0) {
questions.value = doc.questions.map((question) => question)
}
},
})
const submitQuiz = () => {
if (quizDetails.data?.name) updateQuiz()
else createQuiz()
}
const createQuiz = () => {
quizCreate.submit(
{},
quizDetails.setValue.submit(
{
...quizDetails.doc,
total_marks: calculateTotalMarks(),
},
{
onSuccess(data) {
toast.success(__('Quiz created successfully'))
router.push({
name: 'QuizForm',
params: { quizID: data.name },
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const updateQuiz = () => {
quizUpdate.submit(
{ quizID: quizDetails.data?.name },
{
onSuccess(data) {
quiz.total_marks = data.total_marks
quizDetails.doc.total_marks = data.total_marks
toast.success(__('Quiz updated successfully'))
},
onError(err) {
@@ -371,9 +318,15 @@ const updateQuiz = () => {
const calculateTotalMarks = () => {
let totalMarks = 0
if (quiz.limit_questions_to && quiz.questions.length > 0)
return quiz.questions[0].marks * quiz.limit_questions_to
quiz.questions.forEach((question) => {
if (
quizDetails.doc?.limit_questions_to &&
quizDetails.doc?.questions.length > 0
)
return (
quizDetails.doc.questions[0].marks * quizDetails.doc.limit_questions_to
)
quizDetails.doc?.questions.forEach((question) => {
totalMarks += question.marks
})
return totalMarks
@@ -448,7 +401,7 @@ const breadcrumbs = computed(() => {
]
crumbs.push({
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.doc?.title,
route: { name: 'QuizForm', params: { quizID: props.quizID } },
})
return crumbs
@@ -456,7 +409,7 @@ const breadcrumbs = computed(() => {
usePageMeta(() => {
return {
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.doc?.title,
icon: brand.favicon,
}
})

View File

@@ -3,37 +3,42 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<router-link
v-if="!readOnlyMode"
:to="{
name: 'QuizForm',
params: {
quizID: 'new',
},
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New Quiz') }}
</Button>
</router-link>
<Button v-if="!readOnlyMode" variant="solid" @click="showForm = true">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('Create') }}
</Button>
</header>
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
<div v-if="quizCount" class="text-xl font-semibold text-ink-gray-7 mb-4">
{{ __('{0} Quizzes').format(quizCount) }}
<div class="py-5 mx-5">
<div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold text-ink-gray-7">
{{
quizzes.data?.length
? __('{0} Quizzes').format(quizzes.data.length)
: __('No Quizzes')
}}
</div>
<FormControl v-model="search" type="text" placeholder="Search">
<template #prefix>
<FeatherIcon name="search" class="size-4 text-ink-gray-5" />
</template>
</FormControl>
</div>
<ListView
v-if="quizzes.data?.length"
:columns="quizColumns"
:rows="quizzes.data"
row-key="name"
:options="{ showTooltip: false, selectable: false }"
:options="{ showTooltip: false, selectable: true }"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in quizColumns">
<template #prefix="{ item }">
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
@@ -46,72 +51,176 @@
},
}"
>
<ListRow :row="row" />
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'show_answers'">
<FormControl
type="checkbox"
v-model="row[column.key]"
:disabled="true"
/>
</div>
<div
v-else-if="column.key == 'modified'"
class="text-xs text-ink-gray-5"
>
{{ row[column.key] }}
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteQuiz(selections, unselectAll)"
>
<FeatherIcon name="trash-2" class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
<div class="flex justify-center my-5">
<Button v-if="quizzes.hasNextPage" @click="quizzes.next()">
<EmptyState v-else type="Quizzes" />
<div v-if="quizzes.hasNextPage" class="flex justify-center my-5">
<Button @click="quizzes.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
<EmptyState v-else type="Quizzes" />
<Dialog
v-model="showForm"
:options="{
title: __('Create a Quiz'),
size: 'sm',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick({ close }) {
insertQuiz(close)
},
},
],
}"
>
<template #body-content>
<FormControl v-model="title" :label="__('Title')" type="text" />
</template>
</Dialog>
</template>
<script setup>
import {
Breadcrumbs,
Button,
call,
createListResource,
Dialog,
FeatherIcon,
FormControl,
ListView,
ListRows,
ListRow,
ListRowItem,
ListHeader,
ListHeaderItem,
ListSelectBanner,
toast,
usePageMeta,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { computed, inject, onMounted, ref } from 'vue'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import EmptyState from '@/components/EmptyState.vue'
const { brand } = sessionStore()
const user = inject('$user')
const dayjs = inject('$dayjs')
const router = useRouter()
const quizCount = ref(0)
const search = ref('')
const readOnlyMode = window.read_only_mode
const quizFilters = ref({})
const showForm = ref(false)
const title = ref('')
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' })
} else if (!user.data?.is_moderator) {
quizFilters.value['owner'] = user.data?.name
}
getQuizCount()
})
const quizFilter = computed(() => {
if (user.data?.is_moderator) return {}
return {
owner: user.data?.name,
}
watch(search, () => {
quizFilters.value['title'] = ['like', `%${search.value}%`]
quizzes.update({
filters: quizFilters.value,
})
quizzes.reload()
})
const quizzes = createListResource({
doctype: 'LMS Quiz',
filters: quizFilter,
fields: ['name', 'title', 'passing_percentage', 'total_marks'],
filters: quizFilters,
fields: [
'name',
'title',
'passing_percentage',
'total_marks',
'show_answers',
'max_attempts',
'modified',
],
auto: true,
cache: ['quizzes', user.data?.name],
orderBy: 'modified desc',
transform(data) {
return data.map((quiz) => {
return {
...quiz,
modified: dayjs(quiz.modified).fromNow(),
}
})
},
})
const getQuizCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Quiz',
}).then((data) => {
quizCount.value = data
const insertQuiz = (close) => {
quizzes.insert.submit(
{
title: title.value,
},
{
onSuccess(data) {
toast.success(__('Quiz created successfully'))
close()
title.value = ''
router.push({
name: 'QuizForm',
params: {
quizID: data.name,
},
})
},
onError(error) {
toast.error(__('Error creating quiz: {0}', error.message))
},
}
)
}
const deleteQuiz = (selections, unselectAll) => {
Array.from(selections).forEach(async (quizName) => {
await quizzes.delete.submit(quizName)
})
unselectAll()
toast.success(__('Quizzes deleted successfully'))
}
const quizColumns = computed(() => {
@@ -120,18 +229,42 @@ const quizColumns = computed(() => {
label: __('Title'),
key: 'title',
width: 2,
icon: 'file-text',
},
{
label: __('Total Marks'),
key: 'total_marks',
width: 1,
align: 'center',
icon: 'hash',
},
{
label: __('Passing Percentage'),
key: 'passing_percentage',
width: 1,
align: 'center',
icon: 'percent',
},
{
label: __('Max Attempts'),
key: 'max_attempts',
width: 1,
align: 'center',
icon: 'repeat',
},
{
label: __('Show Answers'),
key: 'show_answers',
width: 1,
align: 'center',
icon: 'eye',
},
{
label: __('Modified'),
key: 'modified',
width: 1,
align: 'center',
icon: 'clock',
},
]
})

View File

@@ -215,6 +215,30 @@ const routes = [
name: 'PersonaForm',
component: () => import('@/pages/PersonaForm.vue'),
},
{
path: '/programming-exercises',
name: 'ProgrammingExercises',
component: () =>
import('@/pages/ProgrammingExercises/ProgrammingExercises.vue'),
},
{
path: '/programming-exercises/submissions',
name: 'ProgrammingExerciseSubmissions',
component: () =>
import(
'@/pages/ProgrammingExercises/ProgrammingExerciseSubmissions.vue'
),
props: true,
},
{
path: '/programming-exercises/:exerciseID/submission/:submissionID',
name: 'ProgrammingExerciseSubmission',
component: () =>
import(
'@/pages/ProgrammingExercises/ProgrammingExerciseSubmission.vue'
),
props: true,
},
]
let router = createRouter({

View File

@@ -54,10 +54,14 @@ export const sessionStore = defineStore('lms-session', () => {
},
})
const sidebarSettings = createResource({
url: 'lms.lms.api.get_sidebar_settings',
cache: 'Sidebar Settings',
auto: false,
const livecodeURL = createResource({
url: 'frappe.client.get_single_value',
params: {
doctype: 'LMS Settings',
field: 'livecode_url',
},
cache: 'livecodeURL',
auto: true,
})
return {
@@ -67,6 +71,6 @@ export const sessionStore = defineStore('lms-session', () => {
logout,
brand,
branding,
sidebarSettings,
livecodeURL,
}
})

View File

@@ -9,21 +9,38 @@ export const useSettings = defineStore('settings', () => {
const activeTab = ref(null)
const learningPaths = createResource({
url: 'lms.lms.api.is_learning_path_enabled',
url: 'lms.lms.api.get_lms_setting',
params: { field: 'enable_learning_paths' },
auto: true,
cache: ['learningPath'],
})
const allowGuestAccess = createResource({
url: 'lms.lms.api.is_guest_allowed',
url: 'lms.lms.api.get_lms_setting',
params: { field: 'allow_guest_access' },
auto: true,
cache: ['allowGuestAccess'],
})
const preventSkippingVideos = createResource({
url: 'lms.lms.api.get_lms_setting',
params: { field: 'prevent_skipping_videos' },
auto: true,
cache: ['preventSkippingVideos'],
})
const sidebarSettings = createResource({
url: 'lms.lms.api.get_sidebar_settings',
cache: 'Sidebar Settings',
auto: false,
})
return {
isSettingsOpen,
activeTab,
learningPaths,
allowGuestAccess,
preventSkippingVideos,
sidebarSettings,
}
})

View File

@@ -0,0 +1,65 @@
.cm-editor {
user-select: text;
padding: 0px !important;
position: relative !important;
}
.cm-gutters {
@apply !border-0 !bg-transparent !px-1.5 !text-xs !leading-6 !text-gray-500;
}
.cm-foldGutter span {
@apply !hidden !opacity-0;
}
.cm-gutterElement {
@apply !text-left;
}
.cm-activeLine {
@apply !bg-transparent;
}
.cm-activeLineGutter {
@apply !bg-transparent text-gray-600;
}
.cm-editor {
width: 100%;
user-select: text;
}
.cm-placeholder {
@apply !leading-6 !text-gray-500;
}
.cm-scroller {
@apply !font-mono !leading-6 !text-gray-600;
}
.cm-matchingBracket {
font-weight: 500 !important;
background: none !important;
border-bottom: 1px solid #000 !important;
outline: none !important;
}
.cm-focused {
outline: none !important;
}
.cm-tooltip-autocomplete {
@apply !rounded-lg !shadow-md !bg-surface-white !p-1.5 !border-none;
}
.cm-tooltip-autocomplete > ul {
font-family: 'Inter' !important;
}
.cm-tooltip-autocomplete ul li[aria-selected='true'] {
@apply !rounded !bg-gray-200/80;
color: #000 !important;
}
.cm-completionLabel {
margin-right: 1rem !important;
}
.cm-completionDetail {
margin-left: auto !important;
}
.inline-expression .cm-content {
padding: 0 !important;
line-height: 26px !important;
}
.inline-expression .cm-placeholder {
line-height: 26px !important;
}
.inline-expression .cm-gutters {
line-height: 26px !important;
}

View File

@@ -56,12 +56,18 @@ export class Assignment {
})
return
}
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-surface-menu-bar mb-2'>
<span class="font-medium">
Assignment: ${assignment}
</span>
</div>`
return
call('frappe.client.get_value', {
doctype: 'LMS Assignment',
name: assignment,
fieldname: ['title'],
}).then((data) => {
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
<span class="font-medium">
Assignment: ${data.title}
</span>
</div>`
return
})
}
renderAssignmentModal() {
@@ -79,7 +85,8 @@ export class Assignment {
app.mount(this.wrapper)
}
save(blockContent) {
save() {
if (Object.keys(this.data).length === 0) return {}
return {
assignment: this.data.assignment,
}

View File

@@ -1,7 +1,7 @@
import { watch } from 'vue'
import { call, toast } from 'frappe-ui'
import { useTimeAgo } from '@vueuse/core'
import { Quiz } from '@/utils/quiz'
import { Program } from '@/utils/program'
import { Assignment } from '@/utils/assignment'
import { Upload } from '@/utils/upload'
import { Markdown } from '@/utils/markdownParser'
@@ -103,24 +103,6 @@ export function getImgDimensions(imgSrc) {
})
}
export function updateDocumentTitle(meta) {
watch(
() => meta,
(meta) => {
if (!meta.value.title) return
if (meta.value.title && meta.value.subtitle) {
document.title = `${meta.value.title} | ${meta.value.subtitle}`
return
}
if (meta.value.title) {
document.title = `${meta.value.title}`
return
}
},
{ immediate: true, deep: true }
)
}
export function htmlToText(html) {
const div = document.createElement('div')
div.innerHTML = html
@@ -135,18 +117,26 @@ export function getEditorTools() {
placeholder: 'Header',
},
},
list: {
class: NestedList,
inlineToolbar: true,
config: {
defaultStyle: 'ordered',
},
},
table: {
class: Table,
inlineToolbar: true,
},
quiz: Quiz,
assignment: Assignment,
program: Program,
upload: Upload,
markdown: {
class: Markdown,
inlineToolbar: true,
},
image: SimpleImage,
table: {
class: Table,
inlineToolbar: true,
},
paragraph: {
class: Paragraph,
inlineToolbar: true,
@@ -160,13 +150,6 @@ export function getEditorTools() {
useDefaultTheme: 'dark',
},
},
list: {
class: NestedList,
inlineToolbar: true,
config: {
defaultStyle: 'ordered',
},
},
inlineCode: {
class: InlineCode,
shortcut: 'CMD+SHIFT+M',
@@ -437,6 +420,17 @@ export function getSidebarLinks() {
to: 'Batches',
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
},
{
label: 'Programming Exercises',
icon: 'Code',
to: 'ProgrammingExercises',
activeFor: [
'ProgrammingExercises',
'ProgrammingExerciseForm',
'ProgrammingExerciseSubmissions',
'ProgrammingExerciseSubmission',
],
},
{
label: 'Certified Members',
icon: 'GraduationCap',
@@ -503,10 +497,13 @@ export function singularize(word) {
)
}
export const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
return __('Only image file is allowed.')
export const validateFile = (file, showToast = true) => {
if (!file.type.startsWith('image/')) {
const errorMessage = __('Only image file is allowed.')
if (showToast) {
toast.error(errorMessage)
}
return errorMessage
}
}
@@ -536,45 +533,97 @@ export const canCreateCourse = () => {
)
}
export const enablePlyr = () => {
setTimeout(() => {
const videoElement = document.getElementsByClassName('video-player')
if (videoElement.length === 0) return
export const enablePlyr = async () => {
await wait(500)
Array.from(videoElement).forEach((video) => {
const src = video.getAttribute('src')
if (src) {
let videoID = src.split('/').pop()
video.setAttribute('data-plyr-embed-id', videoID)
}
new Plyr(video, {
youtube: {
noCookie: true,
},
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'fullscreen',
],
})
}, 500)
const players = []
const videoElements = document.getElementsByClassName('video-player')
if (videoElements.length === 0) return players
Array.from(videoElements).forEach((video) => {
setupPlyrForVideo(video, players)
})
return players
}
export const openSettings = (category, close) => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const setupPlyrForVideo = (video, players) => {
const src = video.getAttribute('src') || video.getAttribute('data-src')
if (src) {
const videoID = extractYouTubeId(src)
video.setAttribute('data-plyr-provider', 'youtube')
video.setAttribute('data-plyr-embed-id', videoID)
}
let controls = [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'fullscreen',
]
const player = new Plyr(video, {
youtube: { noCookie: true },
controls: controls,
listeners: {
seek: function customSeekBehavior(e) {
const current_time = player.currentTime
const newTime = getTargetTime(player, e)
if (
useSettings().preventSkippingVideos.data &&
parseFloat(newTime) > current_time
) {
e.preventDefault()
player.currentTime = current_time
return false
}
},
},
})
players.push(player)
}
const getTargetTime = (plyr, input) => {
if (
typeof input === 'object' &&
(input.type === 'input' || input.type === 'change')
) {
return (input.target.value / input.target.max) * plyr.duration
} else {
return Number(input)
}
}
const extractYouTubeId = (url) => {
try {
const parsedUrl = new URL(url)
return (
parsedUrl.searchParams.get('v') ||
parsedUrl.pathname.split('/').pop()
)
} catch {
return url.split('/').pop()
}
}
export const openSettings = (category, close = null) => {
const settingsStore = useSettings()
close()
if (close) {
close()
}
settingsStore.activeTab = category
settingsStore.isSettingsOpen = true
}
export const cleanError = (message) => {
// Remove HTML tags but keep the text within the tags
const cleanMessage = message.replace(/<[^>]+>/g, (match) => {
return match.replace(/<\/?[^>]+(>|$)/g, '')
})

View File

@@ -8,6 +8,7 @@ export class Markdown {
this.config = config || {}
this.text = data.text || ''
this.readOnly = readOnly
this.placeholder = __("Type '/' for commands or select text to format")
}
static get isReadOnlySupported() {
@@ -64,7 +65,15 @@ export class Markdown {
this.wrapper.contentEditable = true
this.wrapper.innerHTML = this.text
this.wrapper.addEventListener('focus', () =>
this._togglePlaceholder()
)
this.wrapper.addEventListener('blur', () =>
this._togglePlaceholder()
)
this.wrapper.addEventListener('input', (event) => {
this._togglePlaceholder()
let value = event.target.textContent
if (event.keyCode === 32 && value.startsWith('#')) {
this.convertToHeader(event, value)
@@ -85,6 +94,22 @@ export class Markdown {
return this.wrapper
}
_togglePlaceholder() {
const blocks = document.querySelectorAll(
'.cdx-block.ce-paragraph[data-placeholder]'
)
blocks.forEach((block) => {
if (block !== this.wrapper) {
delete block.dataset.placeholder
}
})
if (this.wrapper.innerHTML.trim() === '') {
this.wrapper.dataset.placeholder = this.placeholder
} else {
delete this.wrapper.dataset.placeholder
}
}
convertToHeader(event, value) {
event.preventDefault()
if (['#', '##', '###', '####', '#####', '######'].includes(value)) {

View File

@@ -0,0 +1,101 @@
import { createApp, h } from 'vue'
import { Code } from 'lucide-vue-next'
import translationPlugin from '@/translation'
import ProgrammingExerciseModal from '@/pages/ProgrammingExercises/ProgrammingExerciseModal.vue';
import { call } from 'frappe-ui';
import { usersStore } from '@/stores/user'
export class Program {
data: any;
api: any;
readOnly: boolean;
wrapper: HTMLDivElement;
constructor({ data, api, readOnly }: { data: any; api: any; readOnly: boolean }) {
this.data = data;
this.api = api;
this.readOnly = readOnly;
}
static get toolbox() {
const app = createApp({
render: () => h(Code, { size: 5, strokeWidth: 1.5 }),
})
const div = document.createElement('div')
app.mount(div)
return {
title: __('Programming Exercise'),
icon: div.innerHTML,
}
}
static get isReadOnlySupported() {
return true
}
render() {
this.wrapper = document.createElement('div')
if (Object.keys(this.data).length) {
this.renderExercise(this.data.exercise)
} else {
this.renderModal()
}
return this.wrapper
}
renderModal() {
if (this.readOnly) {
return
}
const app = createApp(ProgrammingExerciseModal, {
onSave: (exercise: string) => {
this.data.exercise = exercise
this.renderExercise(exercise)
},
})
app.use(translationPlugin)
app.mount(this.wrapper)
}
renderExercise(exercise: string) {
if (this.readOnly) {
const { userResource } = usersStore()
call('frappe.client.get_value', {
doctype: 'LMS Programming Exercise Submission',
filters: {
exercise: exercise,
member: userResource.data?.name,
},
fieldname: ['name'],
}).then((data: { name: string }) => {
let submission = data.name || 'new'
this.wrapper.innerHTML = `<iframe src="/lms/exercises/${exercise}/submission/${submission}?fromLesson=1" class="w-full h-[900px] border rounded-md"></iframe>`
})
return
}
call("frappe.client.get_value", {
doctype: 'LMS Programming Exercise',
name: exercise,
fieldname: "title"
}).then((data: { title: string }) => {
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
<span class="font-medium">
Programming Exercise: ${data.title}
</span>
</div>`
return
})
}
save() {
if (!this.data.exercise) return {}
return {
exercise: this.data.exercise,
}
}
}

View File

@@ -14,8 +14,7 @@ export class Quiz {
static get toolbox() {
const app = createApp({
render: () =>
h(CircleHelp, { size: 18, strokeWidth: 1.5, color: 'black' }),
render: () => h(CircleHelp, { size: 5, strokeWidth: 1.5 }),
})
const div = document.createElement('div')
@@ -46,7 +45,7 @@ export class Quiz {
this.wrapper.innerHTML = `<iframe src="/lms/quiz/${quiz}?fromLesson=1" class="w-full h-[500px]"></iframe>`
return
}
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-surface-menu-bar mb-2'>
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
<span class="font-medium">
Quiz: ${quiz}
</span>
@@ -69,7 +68,8 @@ export class Quiz {
app.mount(this.wrapper)
}
save(blockContent) {
save() {
if (Object.keys(this.data).length === 0) return {}
return {
quiz: this.data.quiz,
}

View File

@@ -10,11 +10,12 @@
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"types": ["./globals"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "src/*.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

3027
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
__version__ = "2.30.0"
__version__ = "2.31.0"

View File

@@ -1,11 +1,11 @@
import frappe
from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to
from lms.lms.api import give_dicussions_permission
from lms.lms.api import give_discussions_permission
def after_install():
create_batch_source()
give_dicussions_permission()
give_discussions_permission()
def after_sync():

View File

@@ -553,6 +553,7 @@ def get_sidebar_settings():
"jobs",
"statistics",
"notifications",
"programming_exercises",
]
for item in items:
sidebar_items[item] = lms_settings.get(item)
@@ -675,6 +676,27 @@ def update_index(lessons, chapter):
)
@frappe.whitelist()
def update_chapter_index(chapter, course, idx):
"""Update the index of a chapter within a course"""
chapters = frappe.get_all(
"Chapter Reference",
{"parent": course},
pluck="chapter",
order_by="idx",
)
if chapter in chapters:
chapters.remove(chapter)
chapters.insert(idx, chapter)
for i, chapter_name in enumerate(chapters):
frappe.db.set_value(
"Chapter Reference", {"chapter": chapter_name, "parent": course}, "idx", i + 1
)
@frappe.whitelist(allow_guest=True)
def get_categories(doctype, filters):
categoryOptions = []
@@ -695,15 +717,6 @@ def get_categories(doctype, filters):
@frappe.whitelist()
def get_members(start=0, search=""):
"""Get members for the given search term and start index.
Args: start (int): Start index for the query.
<<<<<<< HEAD
search (str): Search term to filter the results.
=======
search (str): Search term to filter the results.
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
Returns: List of members.
"""
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
or_filters = {}
@@ -722,7 +735,14 @@ def get_members(start=0, search=""):
)
for member in members:
roles = frappe.get_roles(member.name)
roles = frappe.get_all(
"Has Role",
{
"parent": member.name,
"parenttype": "User",
},
pluck="role",
)
if "Moderator" in roles:
member.role = "Moderator"
elif "Course Creator" in roles:
@@ -991,7 +1011,30 @@ def delete_course(course):
frappe.delete_doc("LMS Course", course)
def give_dicussions_permission():
@frappe.whitelist()
def delete_batch(batch):
frappe.db.delete("LMS Batch Enrollment", {"batch": batch})
frappe.db.delete("Batch Course", {"parent": batch, "parenttype": "LMS Batch"})
frappe.db.delete("LMS Assessment", {"parent": batch, "parenttype": "LMS Batch"})
frappe.db.delete("LMS Batch Timetable", {"parent": batch, "parenttype": "LMS Batch"})
frappe.db.delete("LMS Batch Feedback", {"batch": batch})
delete_batch_discussions(batch)
frappe.db.delete("LMS Batch", batch)
def delete_batch_discussions(batch):
topics = frappe.get_all(
"Discussion Topic",
{"reference_doctype": "LMS Batch", "reference_docname": batch},
pluck="name",
)
for topic in topics:
frappe.db.delete("Discussion Reply", {"topic": topic})
frappe.db.delete("Discussion Topic", topic)
def give_discussions_permission():
doctypes = ["Discussion Topic", "Discussion Reply"]
roles = ["LMS Student", "Course Creator", "Moderator", "Batch Evaluator"]
for doctype in doctypes:
@@ -1304,13 +1347,8 @@ def get_notifications(filters):
@frappe.whitelist(allow_guest=True)
def is_guest_allowed():
return frappe.get_cached_value("LMS Settings", None, "allow_guest_access")
@frappe.whitelist(allow_guest=True)
def is_learning_path_enabled():
return frappe.get_cached_value("LMS Settings", None, "enable_learning_paths")
def get_lms_setting(field):
return frappe.get_cached_value("LMS Settings", None, field)
@frappe.whitelist()
@@ -1398,6 +1436,7 @@ def save_role(user, role, value):
@frappe.whitelist()
def add_an_evaluator(email):
frappe.only_for("Moderator")
if not frappe.db.exists("User", email):
user = frappe.new_doc("User")
user.update(
@@ -1417,6 +1456,16 @@ def add_an_evaluator(email):
return evaluator
@frappe.whitelist()
def delete_evaluator(evaluator):
frappe.only_for("Moderator")
if not frappe.db.exists("Course Evaluator", evaluator):
frappe.throw(_("Evaluator does not exist."))
frappe.db.delete("Has Role", {"parent": evaluator, "role": "Batch Evaluator"})
frappe.db.delete("Course Evaluator", evaluator)
@frappe.whitelist()
def capture_user_persona(responses):
frappe.only_for("System Manager")
@@ -1493,3 +1542,159 @@ def update_meta_info(type, route, meta_tags):
print(new_tag)
new_tag.insert()
print(new_tag.as_dict())
@frappe.whitelist()
def create_programming_exercise_submission(exercise, submission, code, test_cases):
if submission == "new":
return make_new_exercise_submission(exercise, code, test_cases)
else:
update_exercise_submission(submission, code, test_cases)
def make_new_exercise_submission(exercise, code, test_cases):
submission = frappe.new_doc("LMS Programming Exercise Submission")
submission.exercise = exercise
submission.member = frappe.session.user
submission.code = code
for test_case in test_cases:
submission.append(
"test_cases",
{
"input": test_case.get("input"),
"output": test_case.get("output"),
"expected_output": test_case.get("expected_output"),
"status": test_case.get("status", test_case.get("status", "Failed")),
},
)
submission.status = get_exercise_status(test_cases)
submission.insert()
return submission.name
def update_exercise_submission(submission, code, test_cases):
update_test_cases(test_cases, submission)
status = get_exercise_status(test_cases)
frappe.db.set_value(
"LMS Programming Exercise Submission", submission, {"status": status, "code": code}
)
def get_exercise_status(test_cases):
if not test_cases:
return "Failed"
if all(row.get("status", "Failed") == "Passed" for row in test_cases):
return "Passed"
else:
return "Failed"
def update_test_cases(test_cases, submission):
frappe.db.delete("LMS Test Case Submission", {"parent": submission})
for row in test_cases:
test_case = frappe.new_doc("LMS Test Case Submission")
test_case.update(
{
"parent": submission,
"parenttype": "LMS Programming Exercise Submission",
"parentfield": "test_cases",
"input": row.get("input"),
"output": row.get("output"),
"expected_output": row.get("expected_output"),
"status": row.get("status", "Failed"),
}
)
test_case.insert()
@frappe.whitelist()
def track_video_watch_duration(lesson, videos):
"""
Track the watch duration of videos in a lesson.
"""
if not isinstance(videos, list):
videos = json.loads(videos)
for video in videos:
filters = {
"lesson": lesson,
"source": video.get("source"),
"member": frappe.session.user,
}
existing_record = frappe.db.get_value(
"LMS Video Watch Duration", filters, ["name", "watch_time"], as_dict=True
)
if existing_record and flt(existing_record.watch_time) < flt(video.get("watch_time")):
frappe.db.set_value(
"LMS Video Watch Duration",
filters,
"watch_time",
video.get("watch_time"),
)
elif not existing_record:
track_new_watch_time(lesson, video)
def track_new_watch_time(lesson, video):
doc = frappe.new_doc("LMS Video Watch Duration")
doc.lesson = lesson
doc.source = video.get("source")
doc.watch_time = video.get("watch_time")
doc.member = frappe.session.user
doc.save()
@frappe.whitelist()
def get_course_progress_distribution(course):
all_progress = frappe.get_all(
"LMS Enrollment",
{
"course": course,
},
pluck="progress",
)
average_progress = get_average_course_progress(all_progress)
progress_distribution = get_progress_distribution(all_progress)
return {
"average_progress": average_progress,
"progress_distribution": progress_distribution,
}
def get_average_course_progress(progress_list):
if not progress_list:
return 0
average_progress = sum(progress_list) / len(progress_list)
return flt(average_progress, frappe.get_system_settings("float_precision") or 3)
def get_progress_distribution(progressList):
distribution = [
{
"category": "0-20%",
"count": len([p for p in progressList if 0 <= p < 20]),
},
{
"category": "20-40%",
"count": len([p for p in progressList if 20 <= p < 40]),
},
{
"category": "40-60%",
"count": len([p for p in progressList if 40 <= p < 60]),
},
{
"category": "60-80%",
"count": len([p for p in progressList if 60 <= p < 80]),
},
{
"category": "80-100%",
"count": len([p for p in progressList if 80 <= p <= 100]),
},
]
return distribution

View File

@@ -58,8 +58,7 @@
"fetch_from": "evaluator.full_name",
"fieldname": "full_name",
"fieldtype": "Data",
"label": "Full Name",
"read_only": 1
"label": "Full Name"
},
{
"fieldname": "column_break_casg",
@@ -73,21 +72,19 @@
"fetch_from": "evaluator.user_image",
"fieldname": "user_image",
"fieldtype": "Attach Image",
"label": "User Image",
"read_only": 1
"label": "User Image"
},
{
"fetch_from": "evaluator.username",
"fieldname": "username",
"fieldtype": "Data",
"label": "Username",
"read_only": 1
"label": "Username"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-06-05 11:04:32.475711",
"modified_by": "sayali@frappe.io",
"modified": "2025-07-04 12:04:11.007945",
"modified_by": "Administrator",
"module": "LMS",
"name": "Course Evaluator",
"naming_rule": "By fieldname",

View File

@@ -146,11 +146,12 @@
"fieldtype": "Section Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2025-02-17 18:40:53.374932",
"modified_by": "Administrator",
"modified": "2025-07-14 10:24:23.526176",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Assignment Submission",
"naming_rule": "Expression (old style)",
@@ -179,8 +180,45 @@
"role": "LMS Student",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [
@@ -202,4 +240,4 @@
}
],
"title_field": "assignment_title"
}
}

View File

@@ -9,11 +9,11 @@
"enabled",
"title",
"description",
"reference_doctype",
"event",
"image",
"column_break_wgum",
"grant_only_once",
"event",
"reference_doctype",
"user_field",
"field_to_check",
"condition"
@@ -91,6 +91,7 @@
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [
{
@@ -98,7 +99,7 @@
"link_fieldname": "badge"
}
],
"modified": "2024-05-27 17:25:55.399830",
"modified": "2025-07-04 13:02:19.048994",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Badge",
@@ -127,9 +128,10 @@
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -27,17 +27,9 @@ class LMSBadge(Document):
def rule_condition_satisfied(self, doc):
doc_before_save = doc.get_doc_before_save()
if self.event == "Manual Assignment":
return False
if self.event == "New" and doc_before_save != None:
return False
if self.event == "Value Change":
field_to_check = self.field_to_check
if not field_to_check:
return False
if self.condition:
return eval_condition(doc, self.condition)

View File

@@ -7,8 +7,10 @@
"field_order": [
"member",
"member_name",
"issued_on",
"member_username",
"member_image",
"column_break_ugix",
"issued_on",
"badge",
"badge_image",
"badge_description"
@@ -65,12 +67,25 @@
"fieldtype": "Data",
"label": "Member Name",
"read_only": 1
},
{
"fetch_from": "member.username",
"fieldname": "member_username",
"fieldtype": "Data",
"label": "Member Username"
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-06 12:32:28.450028",
"modified_by": "Administrator",
"modified": "2025-07-07 20:37:22.449149",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Badge Assignment",
"owner": "Administrator",
@@ -122,9 +137,10 @@
"share": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "member"
}
}

View File

@@ -14,6 +14,7 @@
"member",
"member_name",
"member_username",
"member_image",
"certification_section",
"purchased_certificate",
"certificate",
@@ -143,13 +144,19 @@
"fieldtype": "Link",
"label": "Certificate",
"options": "LMS Certificate"
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-04-25 10:06:25.824119",
"modified_by": "sayali@frappe.io",
"modified": "2025-07-02 21:27:30.733482",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Enrollment",
"owner": "Administrator",

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe and contributors
// For license information, please see license.txt
// frappe.ui.form.on("LMS Programming Exercise", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,136 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-06-18 15:02:36.198855",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"title",
"column_break_jlzi",
"language",
"section_break_tjwv",
"problem_statement",
"section_break_ftkh",
"test_cases"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"reqd": 1
},
{
"fieldname": "problem_statement",
"fieldtype": "Text Editor",
"in_list_view": 1,
"label": "Problem Statement",
"reqd": 1
},
{
"default": "Python",
"fieldname": "language",
"fieldtype": "Select",
"label": "Language",
"options": "Python\nJavaScript",
"reqd": 1
},
{
"fieldname": "column_break_jlzi",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_tjwv",
"fieldtype": "Section Break"
},
{
"fieldname": "section_break_ftkh",
"fieldtype": "Section Break"
},
{
"fieldname": "test_cases",
"fieldtype": "Table",
"label": "Test Cases",
"options": "LMS Test Case"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [
{
"link_doctype": "LMS Programming Exercise Submission",
"link_fieldname": "exercise"
}
],
"modified": "2025-06-24 14:42:27.463492",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Programming Exercise",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title"
}

View File

@@ -0,0 +1,15 @@
# Copyright (c) 2025, Frappe and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
class LMSProgrammingExercise(Document):
def validate(self):
self.validate_test_cases()
def validate_test_cases(self):
if not self.test_cases:
frappe.throw(_("At least one test case is required for the programming exercise."))

View File

@@ -0,0 +1,30 @@
# Copyright (c) 2025, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestLMSProgrammingExercise(UnitTestCase):
"""
Unit tests for LMSProgrammingExercise.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSProgrammingExercise(IntegrationTestCase):
"""
Integration tests for LMSProgrammingExercise.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe and contributors
// For license information, please see license.txt
// frappe.ui.form.on("LMS Programming Exercise Submission", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,172 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-06-18 20:01:37.678342",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"exercise",
"exercise_title",
"status",
"column_break_jkjs",
"member",
"member_name",
"member_image",
"section_break_onmz",
"code",
"section_break_idyi",
"test_cases"
],
"fields": [
{
"fieldname": "exercise",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Exercise",
"options": "LMS Programming Exercise",
"reqd": 1
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"label": "Member Name",
"read_only": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "\nPassed\nFailed"
},
{
"fieldname": "column_break_jkjs",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_idyi",
"fieldtype": "Section Break"
},
{
"fieldname": "test_cases",
"fieldtype": "Table",
"label": "Test Cases",
"options": "LMS Test Case Submission"
},
{
"fieldname": "section_break_onmz",
"fieldtype": "Section Break"
},
{
"fieldname": "code",
"fieldtype": "Code",
"label": "Code",
"reqd": 1
},
{
"fetch_from": "exercise.title",
"fieldname": "exercise_title",
"fieldtype": "Data",
"label": "Exercise Title",
"read_only": 1
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach",
"label": "Member Image"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-06-24 14:42:08.288983",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Programming Exercise Submission",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [
{
"color": "Green",
"title": "Passed"
},
{
"color": "Red",
"title": "Failed"
}
],
"title_field": "member_name"
}

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2025, Frappe and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSProgrammingExerciseSubmission(Document):
pass

View File

@@ -0,0 +1,30 @@
# Copyright (c) 2025, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestLMSProgrammingExerciseSubmission(UnitTestCase):
"""
Unit tests for LMSProgrammingExerciseSubmission.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSProgrammingExerciseSubmission(IntegrationTestCase):
"""
Integration tests for LMSProgrammingExerciseSubmission.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -17,8 +17,10 @@
"duration",
"section_break_tzbu",
"shuffle_questions",
"column_break_clsh",
"limit_questions_to",
"column_break_clsh",
"enable_negative_marking",
"marks_to_cut",
"section_break_sbjx",
"questions",
"section_break_3",
@@ -134,6 +136,19 @@
"fieldname": "duration",
"fieldtype": "Data",
"label": "Duration (in minutes)"
},
{
"default": "0",
"fieldname": "enable_negative_marking",
"fieldtype": "Check",
"label": "Enable Negative Marking"
},
{
"default": "1",
"depends_on": "enable_negative_marking",
"fieldname": "marks_to_cut",
"fieldtype": "Int",
"label": "Marks To Cut"
}
],
"grid_page_length": 50,
@@ -144,8 +159,8 @@
"link_fieldname": "quiz"
}
],
"modified": "2025-04-07 15:03:48.525458",
"modified_by": "Administrator",
"modified": "2025-06-27 20:00:15.660323",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Quiz",
"owner": "Administrator",

View File

@@ -44,6 +44,11 @@ class LMSQuiz(Document):
frappe.throw(_("All questions should have the same marks if the limit is set."))
def calculate_total_marks(self):
if len(self.questions) == 0:
self.total_marks = 0
self.passing_percentage = 100
return
if self.limit_questions_to:
self.total_marks = sum(
question.marks for question in self.questions[: cint(self.limit_questions_to)]
@@ -102,11 +107,19 @@ def quiz_summary(quiz, results):
quiz_details = frappe.db.get_value(
"LMS Quiz",
quiz,
["total_marks", "passing_percentage", "lesson", "course"],
[
"name",
"total_marks",
"passing_percentage",
"lesson",
"course",
"enable_negative_marking",
"marks_to_cut",
],
as_dict=1,
)
data = process_results(results, quiz)
data = process_results(results, quiz_details)
results = data["results"]
score = data["score"]
is_open_ended = data["is_open_ended"]
@@ -129,14 +142,14 @@ def quiz_summary(quiz, results):
}
def process_results(results, quiz):
def process_results(results, quiz_details):
score = 0
is_open_ended = False
for result in results:
question_details = frappe.db.get_value(
"LMS Quiz Question",
{"parent": quiz, "question": result["question_name"]},
{"parent": quiz_details.name, "question": result["question_name"]},
["question", "marks", "question_detail", "type"],
as_dict=1,
)
@@ -154,7 +167,11 @@ def process_results(results, quiz):
else:
result["is_correct"] = 0
marks = question_details.marks if correct else 0
if correct:
marks = question_details.marks
else:
marks = -quiz_details.marks_to_cut if quiz_details.enable_negative_marking else 0
result["marks"] = marks
score += marks

View File

@@ -12,6 +12,8 @@
"column_break_zdel",
"allow_guest_access",
"enable_learning_paths",
"prevent_skipping_videos",
"column_break_bjis",
"unsplash_access_key",
"livecode_url",
"section_break_szgq",
@@ -36,6 +38,7 @@
"batches",
"certified_participants",
"certified_members",
"programming_exercises",
"column_break_exdz",
"jobs",
"statistics",
@@ -72,7 +75,6 @@
"default": "https://livecode.dev.fossunited.org",
"fieldname": "livecode_url",
"fieldtype": "Data",
"hidden": 1,
"label": "LiveCode URL"
},
{
@@ -405,13 +407,29 @@
"fieldname": "certified_members",
"fieldtype": "Check",
"label": "Certified Members"
},
{
"default": "0",
"fieldname": "prevent_skipping_videos",
"fieldtype": "Check",
"label": "Prevent Skipping Videos"
},
{
"fieldname": "column_break_bjis",
"fieldtype": "Column Break"
},
{
"default": "1",
"fieldname": "programming_exercises",
"fieldtype": "Check",
"label": "Programming Exercises"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-05-30 19:02:51.381668",
"modified": "2025-07-01 17:01:58.466698",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Settings",

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe and contributors
// For license information, please see license.txt
// frappe.ui.form.on("LMS Test Case", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,45 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-06-18 16:12:10.010416",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"input",
"column_break_zkvg",
"expected_output"
],
"fields": [
{
"fieldname": "input",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Input"
},
{
"fieldname": "column_break_zkvg",
"fieldtype": "Column Break"
},
{
"fieldname": "expected_output",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Expected Output",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-06-20 12:57:19.186644",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Test Case",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

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