Merge pull request #1085 from pateljannat/new-onboarding
feat: onboarding
This commit is contained in:
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
||||
openMode: 0,
|
||||
},
|
||||
e2e: {
|
||||
baseUrl: "http://test_site_ui:8000",
|
||||
baseUrl: "http://test:8000",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ describe("Course Creation", () => {
|
||||
cy.visit("/lms/courses");
|
||||
|
||||
// Create a course
|
||||
cy.get("a").contains("New").click();
|
||||
cy.get("header").children().last().children().last().click();
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "/courses/new/edit");
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("Course Creation", () => {
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.get("label").contains("Title").type("Test Chapter");
|
||||
cy.button("Add Chapter").click();
|
||||
cy.button("Create").click();
|
||||
});
|
||||
|
||||
// Add Lesson
|
||||
|
||||
@@ -30,29 +30,29 @@
|
||||
</div>
|
||||
<div class="flex flex-col flex-auto p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div v-if="course.lesson_count">
|
||||
<div v-if="course.lessons">
|
||||
<Tooltip :text="__('Lessons')">
|
||||
<span class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ course.lesson_count }}
|
||||
{{ course.lessons }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.enrollment_count">
|
||||
<div v-if="course.enrollments">
|
||||
<Tooltip :text="__('Enrolled Students')">
|
||||
<span class="flex items-center">
|
||||
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ course.enrollment_count }}
|
||||
{{ course.enrollments }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.avg_rating">
|
||||
<div v-if="course.rating">
|
||||
<Tooltip :text="__('Average Rating')">
|
||||
<span class="flex items-center">
|
||||
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ course.avg_rating }}
|
||||
{{ course.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -93,21 +93,19 @@
|
||||
<div class="flex items-center mb-3">
|
||||
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.lesson_count }} {{ __('Lessons') }}
|
||||
{{ course.data.lessons }} {{ __('Lessons') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-3">
|
||||
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.enrollment_count_formatted }}
|
||||
{{ formatAmount(course.data.enrollments) }}
|
||||
{{ __('Enrolled Students') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.avg_rating }} {{ __('Rating') }}
|
||||
</span>
|
||||
<span class="ml-2"> {{ course.data.rating }} {{ __('Rating') }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,7 +114,7 @@
|
||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||
import { computed, inject } from 'vue'
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { showToast } from '@/utils/'
|
||||
import { showToast, formatAmount } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
avg_rating: {
|
||||
type: Number,
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
membership: {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||
class="flex text-sm font-medium space-x-2 cursor-pointer"
|
||||
@click="openHelpDialog('upload')"
|
||||
>
|
||||
<span class="leading-5">
|
||||
@@ -56,6 +56,21 @@
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center text-sm font-medium space-x-2">
|
||||
<span>
|
||||
{{ __('What does include in preview mean?') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mb-1 leading-5">
|
||||
{{
|
||||
__(
|
||||
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ExplanationVideos v-model="showExplanation" :type="type" />
|
||||
</template>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add Chapter'),
|
||||
title: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
|
||||
label: chapterDetail ? __('Edit') : __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: (close) =>
|
||||
chapterDetail ? editChapter(close) : addChapter(close),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="quiz.data">
|
||||
<div
|
||||
class="bg-blue-100 space-y-1 py-2 px-2 rounded-md text-sm text-blue-800"
|
||||
class="bg-blue-100 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-blue-800"
|
||||
>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
|
||||
@@ -32,57 +32,65 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div>
|
||||
<FileUploader
|
||||
v-if="!batch.image"
|
||||
class="mt-4"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
{{ __('Meta Image') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mb-2">
|
||||
{{ __('Meta Image') }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!batch.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
<div class="border rounded-md w-fit py-5 px-20">
|
||||
<Image class="size-5 stroke-1 text-gray-700" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
{{ batch.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(batch.image.file_size) }}
|
||||
</span>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-gray-600 text-sm">
|
||||
{{
|
||||
__(
|
||||
'Appears when the batch URL is shared on any online platform'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img :src="batch.image.file_url" class="border rounded-md w-40" />
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-gray-600 text-sm">
|
||||
{{
|
||||
__(
|
||||
'Appears when the batch URL is shared on any online platform'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
/>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Description')"
|
||||
type="textarea"
|
||||
class="my-4"
|
||||
:placeholder="__('Short description of the batch')"
|
||||
/>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 mb-1">
|
||||
@@ -133,6 +141,7 @@
|
||||
v-model="batch.timezone"
|
||||
:label="__('Timezone')"
|
||||
type="text"
|
||||
:placeholder="__('Example: IST (+5:30)')"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
@@ -149,6 +158,7 @@
|
||||
:label="__('Seat Count')"
|
||||
type="number"
|
||||
class="mb-4"
|
||||
:placeholder="__('Number of seats available')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.evaluation_end_date"
|
||||
@@ -228,11 +238,11 @@ import {
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getFileSize, showToast } from '../utils'
|
||||
import { X, FileText } from 'lucide-vue-next'
|
||||
import { showToast } from '../utils'
|
||||
import { Image } from 'lucide-vue-next'
|
||||
import { capture } from '@/telemetry'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
{{ __('Loading Batches...') }}
|
||||
</div>
|
||||
<Tabs
|
||||
v-if="hasBatches"
|
||||
v-model="tabIndex"
|
||||
:tabs="makeTabs"
|
||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||
@@ -79,24 +80,63 @@
|
||||
<BatchCard :batch="batch" />
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center mt-4">
|
||||
<div>
|
||||
{{ __('No {0} batches found').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-5 italic text-gray-500">
|
||||
{{ __('No {0} batches').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</template>
|
||||
</Tabs>
|
||||
<div
|
||||
v-else-if="
|
||||
!batches.loading &&
|
||||
!hasBatches &&
|
||||
(user.data?.is_instructor || user.data?.is_moderator)
|
||||
"
|
||||
class="grid grid-cols-3 p-5"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: {
|
||||
batchName: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="bg-gray-50 py-32 px-5 rounded-md">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<Plus
|
||||
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
|
||||
/>
|
||||
<div class="font-medium">
|
||||
{{ __('Create a Batch') }}
|
||||
</div>
|
||||
<span class="text-gray-700 text-sm leading-4">
|
||||
{{ __('You can link courses and assessments to it.') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!batches.loading && !hasBatches"
|
||||
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
|
||||
<div class="text-xl font-medium">
|
||||
{{ __('No batches found') }}
|
||||
</div>
|
||||
<div>
|
||||
{{
|
||||
__(
|
||||
'There are no batches available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createListResource,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
@@ -104,13 +144,14 @@ import {
|
||||
Badge,
|
||||
Select,
|
||||
} from 'frappe-ui'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { BookOpen, Plus } from 'lucide-vue-next'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import { inject, ref, computed, onMounted, watch } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const currentCategory = ref(null)
|
||||
const hasBatches = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
@@ -119,10 +160,10 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const batches = createListResource({
|
||||
const batches = createResource({
|
||||
doctype: 'LMS Batch',
|
||||
url: 'lms.lms.utils.get_batches',
|
||||
cache: ['batches', user?.data?.email],
|
||||
cache: ['batches', user.data?.email],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
@@ -183,6 +224,14 @@ const addToTabs = (label) => {
|
||||
})
|
||||
}
|
||||
|
||||
watch(batches, () => {
|
||||
Object.keys(batches.data).forEach((key) => {
|
||||
if (batches.data[key].length) {
|
||||
hasBatches.value = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentCategory.value,
|
||||
() => {
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Tooltip
|
||||
v-if="course.data.avg_rating"
|
||||
v-if="course.data.rating"
|
||||
:text="__('Average Rating')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Star class="h-5 w-5 text-gray-100 fill-orange-500" />
|
||||
<span class="ml-1">
|
||||
{{ course.data.avg_rating }}
|
||||
{{ course.data.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="course.data.avg_rating" class="mx-3">·</span>
|
||||
<span v-if="course.data.rating" class="mx-3">·</span>
|
||||
<Tooltip
|
||||
v-if="course.data.enrollment_count"
|
||||
:text="__('Enrolled Students')"
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
<CourseReviews
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.avg_rating"
|
||||
:avg_rating="course.data.rating"
|
||||
:membership="course.data.membership"
|
||||
/>
|
||||
</div>
|
||||
@@ -116,7 +116,7 @@ const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { course: course?.data?.name } },
|
||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
@@ -27,10 +27,15 @@
|
||||
<FormControl
|
||||
v-model="course.short_introduction"
|
||||
:label="__('Short Introduction')"
|
||||
:placeholder="
|
||||
__(
|
||||
'A one line introduction to the course that appears on the course card'
|
||||
)
|
||||
"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Course Description') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
@@ -41,49 +46,61 @@
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!course.course_image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-gray-600 mb-2">
|
||||
{{ __('Course Image') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
<FileUploader
|
||||
v-if="!course.course_image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md w-fit py-5 px-20">
|
||||
<Image class="size-5 stroke-1 text-gray-700" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-gray-600 text-sm">
|
||||
{{
|
||||
__('Appears on the course card in the course list')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="course.course_image.file_url"
|
||||
class="border rounded-md w-40"
|
||||
/>
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-gray-600 text-sm">
|
||||
{{ __('Appears on the course card in the course list') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
{{ course.course_image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(course.course_image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.video_link"
|
||||
:label="__('Preview Video')"
|
||||
:placeholder="
|
||||
__(
|
||||
'Paste the youtube link of a short video introducing the course'
|
||||
)
|
||||
"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
@@ -104,6 +121,8 @@
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="newTag"
|
||||
:placeholder="__('Keywords for the course')"
|
||||
class="w-52"
|
||||
@keyup.enter="updateTags()"
|
||||
id="tags"
|
||||
/>
|
||||
@@ -130,7 +149,7 @@
|
||||
<div class="grid grid-cols-3 gap-10 mb-4">
|
||||
<div
|
||||
v-if="user.data?.is_moderator"
|
||||
class="flex flex-col space-y-3"
|
||||
class="flex flex-col space-y-4"
|
||||
>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
@@ -231,7 +250,7 @@ import {
|
||||
updateDocumentTitle,
|
||||
} from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { FileText, Image, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
||||
/>
|
||||
<div class="flex space-x-2 justify-end">
|
||||
<div class="w-46 md:w-44">
|
||||
<div class="w-40 md:w-44">
|
||||
<FormControl
|
||||
v-if="categories.data?.length"
|
||||
type="select"
|
||||
@@ -48,6 +48,7 @@
|
||||
</header>
|
||||
<div class="">
|
||||
<Tabs
|
||||
v-if="hasCourses"
|
||||
v-model="tabIndex"
|
||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||
:tabs="makeTabs"
|
||||
@@ -101,18 +102,57 @@
|
||||
<CourseCard :course="course" />
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center mt-4">
|
||||
<div>
|
||||
{{ __('No {0} courses found').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-5 italic text-gray-500">
|
||||
{{ __('No {0} courses').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</template>
|
||||
</Tabs>
|
||||
<div
|
||||
v-else-if="
|
||||
!courses.loading &&
|
||||
(user.data?.is_moderator || user.data?.is_instructor)
|
||||
"
|
||||
class="grid grid-cols-3 p-5"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
courseName: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="bg-gray-50 py-32 px-5 rounded-md">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<Plus
|
||||
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
|
||||
/>
|
||||
<div class="font-medium">
|
||||
{{ __('Create a Course') }}
|
||||
</div>
|
||||
<span class="text-gray-700 text-sm leading-4">
|
||||
{{ __('You can add chapters and lessons to it.') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!courses.loading && !hasCourses"
|
||||
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
|
||||
<div class="text-xl font-medium">
|
||||
{{ __('No courses found') }}
|
||||
</div>
|
||||
<div>
|
||||
{{
|
||||
__(
|
||||
'There are no courses available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -127,13 +167,14 @@ import {
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import { Plus, Search } from 'lucide-vue-next'
|
||||
import { BookOpen, Plus, Search } from 'lucide-vue-next'
|
||||
import { ref, computed, inject, onMounted, watch } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const searchQuery = ref('')
|
||||
const currentCategory = ref(null)
|
||||
const hasCourses = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
@@ -223,6 +264,16 @@ const categories = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
watch(courses, () => {
|
||||
if (courses.data) {
|
||||
Object.keys(courses.data).forEach((section) => {
|
||||
if (courses.data[section].length) {
|
||||
hasCourses.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentCategory.value,
|
||||
() => {
|
||||
|
||||
@@ -301,14 +301,14 @@ const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: lesson?.data?.course_title,
|
||||
route: { name: 'CourseDetail', params: { course: props.courseName } },
|
||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||
})
|
||||
items.push({
|
||||
label: lesson?.data?.title,
|
||||
route: {
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
course: props.courseName,
|
||||
courseName: props.courseName,
|
||||
chapterNumber: props.chapterNumber,
|
||||
lessonNumber: props.lessonNumber,
|
||||
},
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
|
||||
import { Breadcrumbs, Button, createResource, FormControl } from 'frappe-ui'
|
||||
import {
|
||||
computed,
|
||||
reactive,
|
||||
|
||||
@@ -57,6 +57,15 @@ export function formatNumberIntoCurrency(number, currency) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// create a function that formats numbers in thousands to k
|
||||
|
||||
export function formatAmount(amount) {
|
||||
if (amount > 999) {
|
||||
return (amount / 1000).toFixed(1) + 'k'
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
export function convertToTitleCase(str) {
|
||||
if (!str) {
|
||||
return ''
|
||||
|
||||
@@ -51,7 +51,7 @@ export class Quiz {
|
||||
app.mount(this.wrapper)
|
||||
return
|
||||
}
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center mb-2'>
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-gray-50 mb-2'>
|
||||
<span class="font-medium">
|
||||
Quiz: ${quiz}
|
||||
</span>
|
||||
|
||||
@@ -110,7 +110,8 @@ doc_events = {
|
||||
# ---------------
|
||||
scheduler_events = {
|
||||
"hourly": [
|
||||
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals"
|
||||
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
|
||||
"lms.lms.api.update_course_statistics",
|
||||
],
|
||||
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ from frappe.translate import get_all_translations
|
||||
from frappe import _
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Count
|
||||
from frappe.utils import time_diff, now_datetime, get_datetime
|
||||
from frappe.utils import time_diff, now_datetime, get_datetime, flt
|
||||
from typing import Optional
|
||||
from lms.lms.utils import get_average_rating, get_lesson_count
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -760,3 +761,23 @@ def get_payment_gateway_details(payment_gateway):
|
||||
"doctype": doctype,
|
||||
"docname": docname,
|
||||
}
|
||||
|
||||
|
||||
def update_course_statistics():
|
||||
courses = frappe.get_all("LMS Course", fields=["name"])
|
||||
|
||||
for course in courses:
|
||||
lessons = get_lesson_count(course.name)
|
||||
|
||||
enrollments = frappe.db.count(
|
||||
"LMS Enrollment", {"course": course.name, "member_type": "Student"}
|
||||
)
|
||||
|
||||
avg_rating = get_average_rating(course.name) or 0
|
||||
avg_rating = flt(avg_rating, frappe.get_system_settings("float_precision") or 3)
|
||||
|
||||
frappe.db.set_value(
|
||||
"LMS Course",
|
||||
course.name,
|
||||
{"lessons": lessons, "enrollments": enrollments, "rating": avg_rating},
|
||||
)
|
||||
|
||||
@@ -9,9 +9,8 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"course",
|
||||
"title",
|
||||
"column_break_3",
|
||||
"description",
|
||||
"title",
|
||||
"section_break_5",
|
||||
"lessons"
|
||||
],
|
||||
@@ -35,11 +34,6 @@
|
||||
"fieldname": "column_break_3",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Description"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_5",
|
||||
"fieldtype": "Section Break"
|
||||
@@ -59,7 +53,7 @@
|
||||
"link_fieldname": "chapter"
|
||||
}
|
||||
],
|
||||
"modified": "2023-09-29 17:03:58.013819",
|
||||
"modified": "2024-10-29 16:54:20.904683",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "Course Chapter",
|
||||
|
||||
@@ -48,7 +48,12 @@
|
||||
"certification_section",
|
||||
"enable_certification",
|
||||
"column_break_rxww",
|
||||
"expiry"
|
||||
"expiry",
|
||||
"tab_4_tab",
|
||||
"statistics_section",
|
||||
"enrollments",
|
||||
"lessons",
|
||||
"rating"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -249,6 +254,36 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Category",
|
||||
"options": "LMS Category"
|
||||
},
|
||||
{
|
||||
"fieldname": "tab_4_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Statistics"
|
||||
},
|
||||
{
|
||||
"fieldname": "statistics_section",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enrollments",
|
||||
"fieldtype": "Data",
|
||||
"label": "Enrollments",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "lessons",
|
||||
"fieldtype": "Data",
|
||||
"label": "Lessons",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "rating",
|
||||
"fieldtype": "Data",
|
||||
"label": "Rating",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"is_published_field": "published",
|
||||
@@ -275,7 +310,7 @@
|
||||
}
|
||||
],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2024-09-21 10:23:58.633912",
|
||||
"modified": "2024-10-30 23:08:31.842860",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Course",
|
||||
|
||||
@@ -187,192 +187,3 @@ def reindex_exercises(doc):
|
||||
course = frappe.get_doc("LMS Course", course_data["name"])
|
||||
course.reindex_exercises()
|
||||
frappe.msgprint("All exercises in this course have been re-indexed.")
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def search_course(text):
|
||||
courses = frappe.get_all(
|
||||
"LMS Course",
|
||||
filters={"published": True},
|
||||
or_filters={
|
||||
"title": ["like", f"%{text}%"],
|
||||
"tags": ["like", f"%{text}%"],
|
||||
"short_introduction": ["like", f"%{text}%"],
|
||||
"description": ["like", f"%{text}%"],
|
||||
},
|
||||
fields=["name", "title"],
|
||||
)
|
||||
return courses
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def submit_for_review(course):
|
||||
chapters = frappe.get_all("Chapter Reference", {"parent": course})
|
||||
if not len(chapters):
|
||||
return "No Chp"
|
||||
frappe.db.set_value("LMS Course", course, "status", "Under Review")
|
||||
return "OK"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_course(
|
||||
tags,
|
||||
title,
|
||||
short_introduction,
|
||||
video_link,
|
||||
description,
|
||||
course,
|
||||
published,
|
||||
upcoming,
|
||||
image=None,
|
||||
paid_course=False,
|
||||
course_price=None,
|
||||
currency=None,
|
||||
):
|
||||
if not can_create_courses(course):
|
||||
return
|
||||
|
||||
if course:
|
||||
doc = frappe.get_doc("LMS Course", course)
|
||||
else:
|
||||
doc = frappe.get_doc({"doctype": "LMS Course"})
|
||||
|
||||
doc.update(
|
||||
{
|
||||
"title": title,
|
||||
"short_introduction": short_introduction,
|
||||
"video_link": video_link,
|
||||
"image": image,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"published": cint(published),
|
||||
"upcoming": cint(upcoming),
|
||||
"paid_course": cint(paid_course),
|
||||
"course_price": course_price,
|
||||
"currency": currency,
|
||||
}
|
||||
)
|
||||
doc.save(ignore_permissions=True)
|
||||
return doc.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_chapter(course, title, chapter_description, idx, chapter):
|
||||
if chapter:
|
||||
doc = frappe.get_doc("Course Chapter", chapter)
|
||||
else:
|
||||
doc = frappe.get_doc({"doctype": "Course Chapter"})
|
||||
|
||||
doc.update({"course": course, "title": title, "description": chapter_description})
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
if chapter:
|
||||
chapter_reference = frappe.get_doc("Chapter Reference", {"chapter": chapter})
|
||||
else:
|
||||
chapter_reference = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Chapter Reference",
|
||||
"parent": course,
|
||||
"parenttype": "LMS Course",
|
||||
"parentfield": "chapters",
|
||||
"idx": idx,
|
||||
}
|
||||
)
|
||||
|
||||
chapter_reference.update({"chapter": doc.name})
|
||||
chapter_reference.save(ignore_permissions=True)
|
||||
|
||||
return doc.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_lesson(
|
||||
title,
|
||||
body,
|
||||
chapter,
|
||||
preview,
|
||||
idx,
|
||||
lesson,
|
||||
instructor_notes=None,
|
||||
youtube=None,
|
||||
quiz_id=None,
|
||||
question=None,
|
||||
file_type=None,
|
||||
):
|
||||
if lesson:
|
||||
doc = frappe.get_doc("Course Lesson", lesson)
|
||||
else:
|
||||
doc = frappe.get_doc({"doctype": "Course Lesson"})
|
||||
|
||||
doc.update(
|
||||
{
|
||||
"chapter": chapter,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"instructor_notes": instructor_notes,
|
||||
"include_in_preview": preview,
|
||||
"youtube": youtube,
|
||||
"quiz_id": quiz_id,
|
||||
"question": question,
|
||||
"file_type": file_type,
|
||||
}
|
||||
)
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
if lesson:
|
||||
lesson_reference = frappe.get_doc("Lesson Reference", {"lesson": lesson})
|
||||
else:
|
||||
lesson_reference = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Lesson Reference",
|
||||
"parent": chapter,
|
||||
"parenttype": "Course Chapter",
|
||||
"parentfield": "lessons",
|
||||
"idx": idx,
|
||||
}
|
||||
)
|
||||
|
||||
lesson_reference.update({"lesson": doc.name})
|
||||
lesson_reference.save(ignore_permissions=True)
|
||||
|
||||
return doc.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def reorder_lesson(old_chapter, old_lesson_array, new_chapter, new_lesson_array):
|
||||
if old_chapter == new_chapter:
|
||||
sort_lessons(new_chapter, new_lesson_array)
|
||||
else:
|
||||
sort_lessons(old_chapter, old_lesson_array)
|
||||
sort_lessons(new_chapter, new_lesson_array)
|
||||
|
||||
|
||||
def sort_lessons(chapter, lesson_array):
|
||||
lesson_array = json.loads(lesson_array)
|
||||
for les in lesson_array:
|
||||
ref = frappe.get_all("Lesson Reference", {"lesson": les}, ["name", "idx"])
|
||||
if ref:
|
||||
frappe.db.set_value(
|
||||
"Lesson Reference",
|
||||
ref[0].name,
|
||||
{
|
||||
"parent": chapter,
|
||||
"idx": lesson_array.index(les) + 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def reorder_chapter(chapter_array):
|
||||
chapter_array = json.loads(chapter_array)
|
||||
|
||||
for chap in chapter_array:
|
||||
ref = frappe.get_all("Chapter Reference", {"chapter": chap}, ["name", "idx"])
|
||||
if ref:
|
||||
frappe.db.set_value(
|
||||
"Chapter Reference",
|
||||
ref[0].name,
|
||||
{
|
||||
"idx": chapter_array.index(chap) + 1,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -75,7 +75,8 @@
|
||||
"in_standard_filter": 1,
|
||||
"label": "Course",
|
||||
"options": "LMS Course",
|
||||
"reqd": 1
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "current_lesson",
|
||||
@@ -126,7 +127,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-05-14 14:50:08.405033",
|
||||
"modified": "2024-10-30 12:44:16.103598",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Enrollment",
|
||||
|
||||
@@ -109,7 +109,7 @@ def get_chapters(course):
|
||||
chapter_details = frappe.db.get_value(
|
||||
"Course Chapter",
|
||||
{"name": chapter.chapter},
|
||||
["name", "title", "description"],
|
||||
["name", "title"],
|
||||
as_dict=True,
|
||||
)
|
||||
chapter.update(chapter_details)
|
||||
@@ -157,11 +157,12 @@ def get_lesson_details(chapter, progress=False):
|
||||
"file_type",
|
||||
"instructor_notes",
|
||||
"course",
|
||||
"content",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
lesson_details.number = f"{chapter.idx}.{row.idx}"
|
||||
lesson_details.icon = get_lesson_icon(lesson_details.body)
|
||||
lesson_details.icon = get_lesson_icon(lesson_details.body, lesson_details.content)
|
||||
|
||||
if progress:
|
||||
lesson_details.is_complete = get_progress(lesson_details.course, lesson_details.name)
|
||||
@@ -170,20 +171,38 @@ def get_lesson_details(chapter, progress=False):
|
||||
return lessons
|
||||
|
||||
|
||||
def get_lesson_icon(content):
|
||||
icon = None
|
||||
macros = find_macros(content)
|
||||
def get_lesson_icon(body, content):
|
||||
if content:
|
||||
content = json.loads(content)
|
||||
|
||||
for block in content.get("blocks"):
|
||||
if block.get("type") == "upload" and block.get("data").get("file_type").lower() in [
|
||||
"mp4",
|
||||
"webm",
|
||||
"ogg",
|
||||
"mov",
|
||||
]:
|
||||
return "icon-youtube"
|
||||
|
||||
if block.get("type") == "embed" and block.get("data").get("service") in [
|
||||
"youtube",
|
||||
"vimeo",
|
||||
]:
|
||||
return "icon-youtube"
|
||||
|
||||
if block.get("type") == "quiz":
|
||||
return "icon-quiz"
|
||||
|
||||
return "icon-list"
|
||||
|
||||
macros = find_macros(body)
|
||||
for macro in macros:
|
||||
if macro[0] == "YouTubeVideo" or macro[0] == "Video":
|
||||
icon = "icon-youtube"
|
||||
return "icon-youtube"
|
||||
elif macro[0] == "Quiz":
|
||||
icon = "icon-quiz"
|
||||
return "icon-quiz"
|
||||
|
||||
if not icon:
|
||||
icon = "icon-list"
|
||||
|
||||
return icon
|
||||
return "icon-list"
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
@@ -1027,23 +1046,13 @@ def get_course_details(course):
|
||||
"currency",
|
||||
"amount_usd",
|
||||
"enable_certification",
|
||||
"lessons",
|
||||
"enrollments",
|
||||
"rating",
|
||||
],
|
||||
as_dict=1,
|
||||
)
|
||||
course_details.tags = course_details.tags.split(",") if course_details.tags else []
|
||||
course_details.lesson_count = get_lesson_count(course_details.name)
|
||||
|
||||
course_details.enrollment_count = frappe.db.count(
|
||||
"LMS Enrollment", {"course": course_details.name, "member_type": "Student"}
|
||||
)
|
||||
course_details.enrollment_count_formatted = format_number(
|
||||
course_details.enrollment_count
|
||||
)
|
||||
|
||||
avg_rating = get_average_rating(course_details.name) or 0
|
||||
course_details.avg_rating = flt(
|
||||
avg_rating, frappe.get_system_settings("float_precision") or 3
|
||||
)
|
||||
|
||||
course_details.instructors = get_instructors(course_details.name)
|
||||
if course_details.paid_course:
|
||||
@@ -1092,14 +1101,14 @@ def get_categorized_courses(courses):
|
||||
):
|
||||
new.append(course)
|
||||
|
||||
if course.membership and course.published:
|
||||
if course.membership:
|
||||
enrolled.append(course)
|
||||
elif course.is_instructor:
|
||||
created.append(course)
|
||||
|
||||
categories = [live, enrolled, created]
|
||||
for category in categories:
|
||||
category.sort(key=lambda x: x.enrollment_count, reverse=True)
|
||||
category.sort(key=lambda x: x.enrollments, reverse=True)
|
||||
|
||||
live.sort(key=lambda x: x.featured, reverse=True)
|
||||
|
||||
@@ -1124,7 +1133,7 @@ def get_course_outline(course, progress=False):
|
||||
chapter_details = frappe.db.get_value(
|
||||
"Course Chapter",
|
||||
chapter.chapter,
|
||||
["name", "title", "description"],
|
||||
["name", "title"],
|
||||
as_dict=True,
|
||||
)
|
||||
chapter_details["idx"] = chapter.idx
|
||||
|
||||
@@ -90,4 +90,5 @@ lms.patches.v1_0.set_published_on
|
||||
lms.patches.v2_0.fix_progress_percentage
|
||||
lms.patches.v2_0.add_discussion_topic_titles
|
||||
lms.patches.v2_0.sidebar_settings
|
||||
lms.patches.v2_0.delete_certificate_request_notification #18-09-2024
|
||||
lms.patches.v2_0.delete_certificate_request_notification #18-09-2024
|
||||
lms.patches.v2_0.add_course_statistics #21-10-2024
|
||||
6
lms/patches/v2_0/add_course_statistics.py
Normal file
6
lms/patches/v2_0/add_course_statistics.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import frappe
|
||||
from lms.lms.api import update_course_statistics
|
||||
|
||||
|
||||
def execute():
|
||||
update_course_statistics()
|
||||
Reference in New Issue
Block a user