feat: new course form slow

This commit is contained in:
Jannat Patel
2026-01-29 10:50:28 +05:30
parent b50d584a5b
commit 98c5318b66
18 changed files with 336 additions and 154 deletions

View File

@@ -19,12 +19,14 @@
showOptions = true
}
"
@click="(e) => {
showOptions = true
nextTick(() => {
setFocus()
})
}"
@click="
(e) => {
showOptions = true
nextTick(() => {
setFocus()
})
}
"
@focus="
() => {
if (!filterOptions.data || filterOptions.data.length === 0) {
@@ -60,7 +62,11 @@
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{ option.description }}
{{
option.value == option.label
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
@@ -159,6 +165,7 @@ const error = ref(null)
const query = ref('')
const text = ref('')
const showOptions = ref(false)
const emit = defineEmits(['update:modelValue'])
const selectedValue = computed({
get: () => query.value || '',
@@ -166,6 +173,7 @@ const selectedValue = computed({
query.value = ''
val?.value && addValue(val.value)
showOptions.value = false
emit('update:modelValue', values.value)
},
})
@@ -237,6 +245,7 @@ const addValue = (value) => {
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value)
emit('update:modelValue', values.value)
}
function setFocus() {

View File

@@ -37,7 +37,7 @@
<CertificationLinks :courseName="course.data.name" class="w-full" />
</div>
<router-link
v-else-if="course.data.paid_course"
v-else-if="course.data.paid_course && !isAdmin"
:to="{
name: 'Billing',
params: {

View File

@@ -15,7 +15,10 @@
{{ __(title) }}
</div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
{{ __('Add Chapter') }}
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('Add') }}
</Button>
</div>
<div
@@ -174,6 +177,7 @@ import {
FilePenLine,
HelpCircle,
MonitorPlay,
Plus,
Trash2,
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'

View File

@@ -23,10 +23,8 @@
(value, close) => {
close()
router.push({
name: 'CourseForm',
params: {
courseName: 'new',
},
name: 'Courses',
query: { newCourse: '1' },
})
}
"

View File

@@ -1,5 +1,5 @@
<template>
<div class="border rounded-md p-3 space-y-2">
<div class="border rounded-lg p-3 space-y-2">
<div class="text-ink-gray-5">
{{ __(title) }}
</div>

View File

@@ -8,22 +8,24 @@
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold text-ink-gray-9">
{{ __('Settings') }}
</h1>
<div v-for="tab in tabs" :key="tab.label">
<div
v-if="!tab.hideLabel"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
>
<span>{{ __(tab.label) }}</span>
</div>
<nav class="space-y-1">
<div v-for="item in tab.items" @click="activeTab = item">
<SidebarLink
:link="item"
:key="item.label"
:activeTab="activeTab?.label"
/>
<div class="space-y-6">
<div v-for="tab in tabs" :key="tab.label">
<div
v-if="!tab.hideLabel"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
>
<span>{{ __(tab.label) }}</span>
</div>
</nav>
<nav class="space-y-1">
<div v-for="item in tab.items" @click="activeTab = item">
<SidebarLink
:link="item"
:key="item.label"
:activeTab="activeTab?.label"
/>
</div>
</nav>
</div>
</div>
</div>
<div

View File

@@ -405,9 +405,13 @@ const steps = reactive([
minimize.value = true
let course = await getFirstCourse()
if (course) {
router.push({ name: 'CourseForm', params: { courseName: course } })
router.push({
name: 'CourseDetail',
params: { courseName: course },
hash: '#settings',
})
} else {
router.push({ name: 'CourseForm' })
router.push({ name: 'Courses', query: { newCourse: '1' } })
}
},
},
@@ -422,11 +426,12 @@ const steps = reactive([
let course = await getFirstCourse()
if (course) {
router.push({
name: 'CourseForm',
name: 'CourseDetail',
params: { courseName: course },
hash: '#settings',
})
} else {
router.push({ name: 'Courses' })
router.push({ name: 'Courses', query: { newCourse: '1' } })
}
},
},

View File

@@ -1,7 +1,10 @@
<template>
<div class="p-5">
<div class="grid grid-cols-3 gap-5 mb-5">
<NumberChartGraph :title="__('Enrolled')" :value="memberCount || 0" />
<div class="grid grid-cols-4 gap-5 mb-5">
<NumberChartGraph
:title="__('Enrolled')"
:value="formatAmount(course.data?.enrollments)"
/>
<NumberChartGraph
:title="__('Average Completion Rate')"
:value="averageCompletionRate"
@@ -14,17 +17,18 @@
<Star class="size-5 text-transparent fill-amber-500" />
</template>
</NumberChartGraph>
<NumberChartGraph :title="__('Lessons')" :value="course.data?.lessons" />
</div>
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
<div class="border rounded-md py-3 px-4">
<div class="flex items-center justify-between mb-4">
<div class="border rounded-lg py-3 px-4">
<div class="flex items-center justify-between mb-3">
<div class="text-lg font-semibold">
{{ __('Students') }}
</div>
<div class="flex items-center space-x-2">
<FormControl
v-model="searchFilter"
:placeholder="__('Search by Member')"
:placeholder="__('Search by name')"
type="text"
/>
<Button @click="showEnrollmentModal = true">
@@ -49,7 +53,7 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-white p-2"
class="mb-2 grid items-center space-x-4 rounded bg-surface-white border-b rounded-none p-2"
>
<ListHeaderItem
:item="item"
@@ -115,67 +119,82 @@
</div>
</div>
</div>
<div class="border rounded-md p-4">
<div class="text-ink-gray-5 mb-4">
{{ __('Progress Summary') }}
</div>
<div class="grid grid-cols-[2fr_1fr] items-center justify-between">
<div class="flex flex-col space-y-3 flex-1 text-xs">
<div
class="flex items-center"
v-for="row in chartDetails.data?.progress_distribution"
>
<div class="space-y-5">
<div class="border rounded-lg p-4">
<div class="text-ink-gray-5 mb-4">
{{ __('Progress Summary') }}
</div>
<div class="grid grid-cols-[2fr_1fr] items-center justify-between">
<div class="flex flex-col space-y-4 flex-1 text-sm">
<div
class="size-2 rounded"
:style="{
backgroundColor:
colors[theme][
row.name.startsWith('Just')
? 'red'
: row.name.startsWith('In')
? 'amber'
: 'green'
][400],
}"
></div>
<div class="ml-2">
{{ row.name }}
</div>
<div class="ml-auto">
{{ Math.round((row.value / course.data?.enrollments) * 100) }}%
class="flex items-center"
v-for="row in chartDetails.data?.progress_distribution"
>
<div
class="size-2 rounded"
:style="{
backgroundColor:
colors[theme][
row.name.startsWith('Just')
? 'red'
: row.name.startsWith('In')
? 'amber'
: 'green'
][400],
}"
></div>
<Tooltip :text="row.name.split('(')[1].replace(')', '')">
<div class="ml-2">
{{ row.name.split('(')[0] }}
</div>
</Tooltip>
<div class="ml-auto">
{{
Math.round((row.value / course.data?.enrollments) * 100)
}}%
</div>
</div>
</div>
</div>
<ECharts
class="w-40 h-20"
:options="{
color: progressColors,
series: [
{
type: 'pie',
radius: ['50%', '70%'],
center: ['50%', '50%'],
label: {
show: false,
},
labelLine: {
show: false,
},
emphasis: {
<ECharts
class="w-40 h-20"
:options="{
color: progressColors,
series: [
{
type: 'pie',
radius: ['50%', '70%'],
center: ['50%', '50%'],
label: {
show: false,
},
scale: false,
labelLine: {
show: false,
},
emphasis: {
label: {
show: false,
},
scale: false,
},
legend: {
show: false,
},
data: chartDetails.data?.progress_distribution || [],
},
legend: {
show: false,
},
data: chartDetails.data?.progress_distribution || [],
},
],
showInlineLabels: false,
}"
/>
],
showInlineLabels: false,
}"
/>
</div>
</div>
{{ lessonProgress.data }}
<div v-if="lessonProgress.data?.length" class="border rounded-lg p-4">
<div class="text-ink-gray-5 mb-4">
{{ __('Lesson Completion') }}
</div>
<!-- <div v-for="progress in lessonProgress.data">
{{ progress }}
</div> -->
</div>
</div>
</div>
@@ -201,9 +220,11 @@ import {
ListRows,
ListRow,
ListRowItem,
Tooltip,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { Plus, Star } from 'lucide-vue-next'
import { formatAmount } from '@/utils'
import colors from '@/utils/frappe-ui-colors.json'
import CourseEnrollmentModal from '@/pages/Courses/CourseEnrollmentModal.vue'
import NumberChartGraph from '@/components/NumberChartGraph.vue'
@@ -215,7 +236,6 @@ const props = defineProps<{
const showEnrollmentModal = ref(false)
const searchFilter = ref<string | null>(null)
const memberCount = ref<number>(props.course.data?.enrollments || 0)
const theme = ref<'darkMode' | 'lightMode'>(
localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
)
@@ -252,6 +272,25 @@ const progressList = createListResource({
auto: true,
})
const lessonProgress = createResource({
url: 'lms.lms.api.get_lesson_completion_stats',
params: {
course: props.course.data?.name,
},
auto: true,
})
/* const lessonProgress = createListResource({
doctype: 'LMS Course Progress',
filters: {
course: props.course.data?.name,
status: 'Complete',
},
fields: ['lesson', `count(name) as completed_count`],
groupBy: 'lesson',
auto: true,
}) */
watch([searchFilter], () => {
let filterApplied = false
let filters: Filters = {
@@ -266,16 +305,7 @@ watch([searchFilter], () => {
progressList.update({
filters: filters,
})
progressList.reload(
{},
{
onSuccess(data: any[]) {
memberCount.value = filterApplied
? data.length
: props.course.data?.enrollments || 0
},
}
)
progressList.reload()
})
const averageCompletionRate = computed(() => {

View File

@@ -5,13 +5,16 @@
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<div v-if="tabIndex == 2" class="flex items-center space-x-2">
<Button>
<Badge v-if="childRef?.isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<Button @click="childRef.trashCourse()">
<template #icon>
<Trash2 class="w-4 h-4 stroke-1.5" />
</template>
</Button>
<Button variant="solid">
{{ __("Save") }}
<Button variant="solid" @click="childRef.submitCourse()">
{{ __('Save') }}
</Button>
</div>
</header>
@@ -19,14 +22,21 @@
<div v-else>
<Tabs :tabs="tabs" v-model="tabIndex">
<template #tab-panel="{ tab }">
<component :is="tab.component" :course="course" />
<component :is="tab.component" :course="course" ref="childRef" />
</template>
</Tabs>
</div>
</div>
</template>
<script setup>
import { Button, createResource, Breadcrumbs, Tabs, usePageMeta } from 'frappe-ui'
import {
Badge,
Button,
createResource,
Breadcrumbs,
Tabs,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, markRaw, onMounted, ref, watch } from 'vue'
import { sessionStore } from '@/stores/session'
import { useRouter, useRoute } from 'vue-router'
@@ -40,6 +50,7 @@ const router = useRouter()
const route = useRoute()
const user = inject('$user')
const tabIndex = ref(0)
const childRef = ref(null)
const props = defineProps({
courseName: {

View File

@@ -1,7 +1,7 @@
<template>
<div class="pl-5">
<div class="grid grid-cols-1 md:grid-cols-[70%,30%] ">
<div v-if="courseResource.doc" class="max-h-[88vh] overflow-y-auto">
<div class="grid grid-cols-1 md:grid-cols-[70%,30%] overflow-hidden">
<div v-if="courseResource.doc" class="h-[88vh] overflow-y-auto">
<div class="my-5">
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
@@ -12,12 +12,14 @@
v-model="courseResource.doc.title"
:label="__('Title')"
:required="true"
@input="makeFormDirty()"
/>
<Link
doctype="LMS Category"
v-model="courseResource.doc.category"
:label="__('Category')"
:onCreate="(value, close) => openSettings('Categories', close)"
@update:modelValue="makeFormDirty()"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
@@ -28,6 +30,7 @@
:filters="{ ignore_user_type: 1 }"
:onCreate="(close) => openSettings('Members', close)"
:required="true"
@update:modelValue="makeFormDirty()"
/>
<div>
<div class="text-xs text-ink-gray-5">
@@ -62,6 +65,7 @@
v-model="courseResource.doc.image"
:label="__('Course Image')"
:required="false"
@update:modelValue="makeFormDirty()"
/>
<ColorSwatches
@@ -69,6 +73,7 @@
:label="__('Color')"
:description="__('Choose a color for the course card')"
class="w-full"
@update:modelValue="makeFormDirty()"
/>
</div>
</div>
@@ -86,11 +91,13 @@
type="checkbox"
v-model="courseResource.doc.published"
:label="__('Published')"
@change="makeFormDirty()"
/>
<FormControl
v-model="courseResource.doc.published_on"
:label="__('Published On')"
type="date"
@change="makeFormDirty()"
/>
</div>
<div class="flex flex-col space-y-5">
@@ -98,16 +105,19 @@
type="checkbox"
v-model="courseResource.doc.upcoming"
:label="__('Upcoming')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="courseResource.doc.featured"
:label="__('Featured')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="courseResource.doc.disable_self_learning"
:label="__('Disable Self Enrollment')"
@change="makeFormDirty()"
/>
</div>
</div>
@@ -128,6 +138,7 @@
)
"
:required="true"
@change="makeFormDirty()"
/>
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
@@ -136,7 +147,12 @@
</div>
<TextEditor
:content="courseResource.doc.description"
@change="(val) => (courseResource.doc.description = val)"
@change="
(val) => {
courseResource.doc.description = val
makeFormDirty()
}
"
: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]"
@@ -151,6 +167,7 @@
'Paste the youtube link of a short video introducing the course'
)
"
@input="makeFormDirty()"
/>
<MultiSelect
@@ -161,11 +178,12 @@
:onCreate="
(close) => {
router.push({
name: 'CourseForm',
params: { courseName: 'new' },
name: 'Courses',
query: { newCourse: '1' },
})
}
"
@update:modelValue="makeFormDirty()"
/>
</div>
@@ -178,25 +196,35 @@
type="checkbox"
v-model="courseResource.doc.paid_course"
:label="__('Paid Course')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="courseResource.doc.enable_certification"
:label="__('Completion Certificate')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="courseResource.doc.paid_certificate"
:label="__('Paid Certificate')"
@change="makeFormDirty()"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-5">
<FormControl
v-if="courseResource.doc.paid_course || courseResource.doc.paid_certificate"
v-if="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
v-model="courseResource.doc.course_price"
:label="__('Amount')"
:required="courseResource.doc.paid_course || courseResource.doc.paid_certificate"
:required="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
@input="makeFormDirty()"
/>
<Link
v-if="courseResource.doc.paid_certificate"
@@ -207,16 +235,24 @@
:onCreate="
(value, close) => openSettings('Evaluators', close)
"
@update:modelValue="makeFormDirty()"
/>
</div>
<div class="space-y-5">
<Link
v-if="courseResource.doc.paid_course || courseResource.doc.paid_certificate"
v-if="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
doctype="Currency"
v-model="courseResource.doc.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
:required="courseResource.doc.paid_course || courseResource.doc.paid_certificate"
:required="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
@update:modelValue="makeFormDirty()"
/>
<FormControl
v-if="courseResource.doc.paid_certificate"
@@ -224,6 +260,7 @@
:label="__('Timezone')"
:required="courseResource.doc.paid_certificate"
:placeholder="__('e.g. IST, UTC, GMT...')"
@input="makeFormDirty()"
/>
</div>
</div>
@@ -239,6 +276,7 @@
:label="__('Meta Description')"
type="textarea"
:rows="7"
@input="makeFormDirty()"
/>
<FormControl
v-model="meta.keywords"
@@ -246,12 +284,13 @@
type="textarea"
:rows="7"
:placeholder="__('Comma separated keywords for SEO')"
@input="makeFormDirty()"
/>
</div>
</div>
</div>
</div>
<div class="border-l">
<div class="border-l h-[88vh] overflow-y-auto">
<CourseOutline
v-if="courseResource.doc"
:courseName="courseResource.doc.name"
@@ -276,7 +315,6 @@ import {
inject,
onMounted,
onBeforeUnmount,
computed,
ref,
reactive,
watch,
@@ -306,6 +344,7 @@ const instructors = ref([])
const related_courses = ref([])
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const isDirty = ref(false)
const props = defineProps({
course: {
@@ -365,7 +404,6 @@ const updateCourseData = (data) => {
let key = checkboxes[idx]
data[key] = data[key] ? true : false
}
}
const submitCourse = () => {
@@ -384,24 +422,29 @@ const validateFields = () => {
}
const updateCourse = () => {
courseResource.setValue.submit({
...courseResource.doc,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
}, {
onSuccess() {
updateMetaInfo('courses', courseResource.doc?.name, meta)
toast.success(__('Course updated successfully'))
courseResource.setValue.submit(
{
...courseResource.doc,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
})
{
onSuccess() {
updateMetaInfo('courses', courseResource.doc?.name, meta)
toast.success(__('Course updated successfully'))
isDirty.value = false
courseResource.reload()
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
}
const keyboardShortcut = (e) => {
@@ -458,6 +501,7 @@ const updateTags = () => {
? `${courseResource.doc.tags}, ${newTag.value}`
: newTag.value
newTag.value = ''
makeFormDirty()
}
}
@@ -467,6 +511,7 @@ const removeTag = (tag) => {
.filter((t) => t !== tag)
.join(', ')
newTag.value = ''
makeFormDirty()
}
const check_permission = () => {
@@ -484,10 +529,20 @@ const check_permission = () => {
}
}
const makeFormDirty = () => {
isDirty.value = true
}
usePageMeta(() => {
return {
title: courseResource.doc?.title,
icon: brand.favicon,
}
})
defineExpose({
submitCourse,
trashCourse,
isDirty,
})
</script>

View File

@@ -106,7 +106,11 @@
</Button>
</div>
</div>
<NewCourseModal v-if="showCourseModal" v-model="showCourseModal" :courses="courses" />
<NewCourseModal
v-if="showCourseModal"
v-model="showCourseModal"
:courses="courses"
/>
</template>
<script setup>
import {
@@ -133,7 +137,12 @@ const user = inject('$user')
const dayjs = inject('$dayjs')
const start = ref(0)
const pageLength = ref(30)
const categories = ref([])
const categories = ref([
{
label: '',
value: null,
},
])
const currentCategory = ref(null)
const title = ref('')
const certification = ref(false)
@@ -148,12 +157,6 @@ onMounted(() => {
setFiltersFromQuery()
updateCourses()
getCourseCount()
categories.value = [
{
label: '',
value: null,
},
]
})
const setFiltersFromQuery = () => {
@@ -161,6 +164,9 @@ const setFiltersFromQuery = () => {
title.value = queries.get('title') || ''
currentCategory.value = queries.get('category') || null
certification.value = queries.get('certification') || false
if (queries.get('newCourse') == '1') {
showCourseModal.value = true
}
}
const courses = createListResource({

View File

@@ -74,7 +74,7 @@
}}
</div>
<router-link
:to="{ name: 'CourseForm', params: { courseName: 'new' } }"
:to="{ name: 'Courses', query: { newCourse: '1' } }"
class="mt-4"
>
<Button>

View File

@@ -471,7 +471,11 @@ const breadcrumbs = computed(() => {
},
{
label: lessonDetails.data?.course_title,
route: { name: 'CourseForm', params: { courseName: props.courseName } },
route: {
name: 'CourseDetail',
params: { courseName: props.courseName },
hash: '#settings',
},
},
]

View File

@@ -465,7 +465,6 @@ const getSidebarItems = () => {
'Courses',
'CourseDetail',
'Lesson',
'CourseForm',
'LessonForm',
],
},