feat: course creation resources
This commit is contained in:
@@ -57,16 +57,21 @@
|
|||||||
{{ __('Start Learning') }}
|
{{ __('Start Learning') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<router-link
|
||||||
v-if="user?.data?.is_moderator"
|
v-if="user?.data?.is_moderator || is_instructor()"
|
||||||
variant="subtle"
|
:to="{
|
||||||
class="w-full"
|
name: 'CreateCourse',
|
||||||
size="md"
|
params: {
|
||||||
|
courseName: course.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
|
<Button variant="subtle" class="w-full mt-2" size="md">
|
||||||
<span>
|
<span>
|
||||||
{{ __('Edit') }}
|
{{ __('Edit') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
</router-link>
|
||||||
<div class="mt-8 mb-4 font-medium">
|
<div class="mt-8 mb-4 font-medium">
|
||||||
{{ __('This course has:') }}
|
{{ __('This course has:') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -154,4 +159,6 @@ function enrollStudent() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const is_instructor = () => {}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-base">
|
<div class="text-base">
|
||||||
<div v-if="showHeader" class="flex justify-between mb-4">
|
<div
|
||||||
|
v-if="showHeader && outline.data?.length"
|
||||||
|
class="flex justify-between mb-4"
|
||||||
|
>
|
||||||
<div class="text-2xl font-semibold">
|
<div class="text-2xl font-semibold">
|
||||||
{{ __('Course Content') }}
|
{{ __('Course Content') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -8,7 +11,11 @@
|
|||||||
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
|
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
|
||||||
</span> -->
|
</span> -->
|
||||||
</div>
|
</div>
|
||||||
<div :class="{ 'shadow rounded-md pt-2 px-2': showOutline }">
|
<div
|
||||||
|
:class="{
|
||||||
|
'shadow rounded-md pt-2 px-2': showOutline && outline.data?.length,
|
||||||
|
}"
|
||||||
|
>
|
||||||
<Disclosure
|
<Disclosure
|
||||||
v-slot="{ open }"
|
v-slot="{ open }"
|
||||||
v-for="(chapter, index) in outline.data"
|
v-for="(chapter, index) in outline.data"
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const { logout, user } = sessionStore()
|
const { logout, user } = sessionStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
console.log(user)
|
|
||||||
const userDropdownOptions = [
|
const userDropdownOptions = [
|
||||||
{
|
{
|
||||||
icon: User,
|
icon: User,
|
||||||
|
|||||||
@@ -5,26 +5,33 @@
|
|||||||
<header
|
<header
|
||||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
>
|
>
|
||||||
<Breadcrumbs
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
class="h-7"
|
<div class="flex items-center">
|
||||||
:items="[
|
<router-link
|
||||||
{
|
v-if="courseResource.doc"
|
||||||
label: __('Courses'),
|
:to="{
|
||||||
route: { name: 'Courses' },
|
name: 'CourseDetail',
|
||||||
},
|
params: { courseName: courseResource.doc.name },
|
||||||
{
|
}"
|
||||||
label: __('New Course'),
|
>
|
||||||
route: { name: 'CreateCourse', params: { courseName: 'new' } },
|
<Button>
|
||||||
},
|
<span>
|
||||||
]"
|
{{ __('View Course') }}
|
||||||
/>
|
</span>
|
||||||
<Button variant="solid" @click="submitCourse()">
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button variant="solid" @click="submitCourse()" class="ml-2">
|
||||||
<span>
|
<span>
|
||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="container mt-5">
|
<div class="mt-5 mb-10">
|
||||||
|
<div class="container mb-5">
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('Course Details') }}
|
||||||
|
</div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="course.title"
|
v-model="course.title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
@@ -47,46 +54,80 @@
|
|||||||
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
|
||||||
v-model="course.video_link"
|
|
||||||
:label="__('Preview Video')"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
<FileUploader
|
<FileUploader
|
||||||
v-if="!course.image"
|
v-if="!image"
|
||||||
:fileTypes="['image/*']"
|
:fileTypes="['image/*']"
|
||||||
:validateFile="validateFile"
|
:validateFile="validateFile"
|
||||||
@success="
|
@success="
|
||||||
(file) => {
|
(file) => {
|
||||||
console.log(file)
|
image = file
|
||||||
course.image = file
|
|
||||||
console.log(course.image)
|
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
<template
|
||||||
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
|
{{
|
||||||
|
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
||||||
|
}}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</FileUploader>
|
</FileUploader>
|
||||||
<div v-else class="flex items-center">
|
<div v-else class="mb-4">
|
||||||
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
|
{{ __('Course Image') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
<div class="border rounded-md p-2 mr-2">
|
<div class="border rounded-md p-2 mr-2">
|
||||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span>
|
<span>
|
||||||
{{ course.image }}
|
{{ image.file_name }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-gray-500 mt-1">
|
<span class="text-sm text-gray-500 mt-1">
|
||||||
{{ getFileSize(course.image) }}
|
{{ getFileSize(image.file_size) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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.tags" :label="__('Tags')" class="mb-4" />
|
</div>
|
||||||
<div class="flex items-center mb-4">
|
<FormControl
|
||||||
|
v-model="course.video_link"
|
||||||
|
:label="__('Preview Video')"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 text-sm text-gray-700">
|
||||||
|
{{ __('Tags') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
v-if="tags"
|
||||||
|
v-for="tag in tags?.split(', ')"
|
||||||
|
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
<X
|
||||||
|
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
|
||||||
|
@click="removeTag(tag)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormControl v-model="newTag" @keyup.enter="updateTags()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container border-t">
|
||||||
|
<div class="text-lg font-semibold mt-5 mb-4">
|
||||||
|
{{ __('Course Settings') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="course.published"
|
v-model="course.published"
|
||||||
@@ -96,8 +137,17 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="course.upcoming"
|
v-model="course.upcoming"
|
||||||
:label="__('Upcoming')"
|
:label="__('Upcoming')"
|
||||||
class="ml-20"
|
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="checkbox"
|
||||||
|
v-model="course.disable_self_learning"
|
||||||
|
:label="__('Disable Self Enrollment')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container border-t">
|
||||||
|
<div class="text-lg font-semibold mt-5 mb-4">
|
||||||
|
{{ __('Course Pricing') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
@@ -119,7 +169,29 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50"></div>
|
</div>
|
||||||
|
<div class="bg-gray-50 px-5 pt-5">
|
||||||
|
<div v-if="courseResource.doc">
|
||||||
|
<div class="text-xl font-semibold">
|
||||||
|
{{ course.title }}
|
||||||
|
</div>
|
||||||
|
<div v-if="courseResource.doc.chapters.length">
|
||||||
|
{{ courseResource.chapters }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="border bg-white rounded-md p-5 text-center mt-4">
|
||||||
|
<div>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'There are no chapters in this course. Create and manage chapters from here.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<Button class="mt-4">
|
||||||
|
{{ __('Add Chapter') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -129,71 +201,184 @@ import {
|
|||||||
TextEditor,
|
TextEditor,
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
|
createDocumentResource,
|
||||||
FormControl,
|
FormControl,
|
||||||
FileUploader,
|
FileUploader,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { reactive, inject, onMounted } from 'vue'
|
import { inject, onMounted, computed, ref } from 'vue'
|
||||||
import { convertToTitleCase, createToast, getFileSize } from '../utils'
|
import { convertToTitleCase, createToast, getFileSize } from '../utils'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { FileText } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const tags = ref('')
|
||||||
|
const newTag = ref('')
|
||||||
|
const image = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
courseName: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
let crumbs = [
|
||||||
|
{
|
||||||
|
label: 'Courses',
|
||||||
|
route: { name: 'Courses' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (courseResource.doc) {
|
||||||
|
crumbs.push({
|
||||||
|
label: courseResource.doc?.title,
|
||||||
|
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
crumbs.push({
|
||||||
|
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
|
||||||
|
route: { name: 'CreateCourse', params: { courseName: props.courseName } },
|
||||||
|
})
|
||||||
|
return crumbs
|
||||||
|
})
|
||||||
|
|
||||||
|
const courseResource = createDocumentResource({
|
||||||
|
doctype: 'LMS Course',
|
||||||
|
name: props.courseName,
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data) {
|
||||||
|
imageResource.reload({ image: data.image })
|
||||||
|
tags.value = data.tags
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageResource = createResource({
|
||||||
|
url: 'lms.lms.api.get_file_info',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
file_url: values.image,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data) {
|
||||||
|
image.value = data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
if (props.courseName !== 'new') {
|
||||||
|
courseResource.reload()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const course = reactive({
|
const course = computed(() => {
|
||||||
title: '',
|
return {
|
||||||
short_introduction: '',
|
title: courseResource.doc?.title || '',
|
||||||
description: '',
|
short_introduction: courseResource.doc?.short_introduction || '',
|
||||||
video_link: '',
|
description: courseResource.doc?.description || '',
|
||||||
tags: '',
|
video_link: courseResource.doc?.video_link || '',
|
||||||
published: false,
|
course_image: courseResource.doc?.image || null,
|
||||||
upcoming: false,
|
tags: tags.value,
|
||||||
image: null,
|
published: courseResource.doc?.published ? true : false,
|
||||||
paid_course: false,
|
upcoming: courseResource.doc?.upcoming ? true : false,
|
||||||
course_price: null,
|
disable_self_learning: courseResource.doc?.disable_self_learning
|
||||||
currency: '',
|
? true
|
||||||
|
: false,
|
||||||
|
course_image: image.value,
|
||||||
|
paid_course: courseResource.doc?.paid_course ? true : false,
|
||||||
|
course_price: courseResource.doc?.course_price || '',
|
||||||
|
currency: courseResource.doc?.currency || '',
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const courseResource = createResource({
|
const courseCreationResource = createResource({
|
||||||
url: 'frappe.client.insert',
|
url: 'frappe.client.insert',
|
||||||
makeParams() {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
...course,
|
image: image.value.file_url,
|
||||||
|
...values,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitCourse = () => {
|
const submitCourse = () => {
|
||||||
courseResource.submit(
|
if (courseResource.doc) {
|
||||||
{},
|
courseResource.setValue.submit(
|
||||||
|
{
|
||||||
|
image: image.value?.file_url || null,
|
||||||
|
...course.value,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
validate() {
|
validate() {
|
||||||
|
return validateMandatoryFields()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast(err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
courseCreationResource.submit(course.value, {
|
||||||
|
validate() {
|
||||||
|
return validateMandatoryFields()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast(err)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateMandatoryFields = () => {
|
||||||
const mandatory_fields = [
|
const mandatory_fields = [
|
||||||
'title',
|
'title',
|
||||||
'short_introduction',
|
'short_introduction',
|
||||||
'description',
|
'description',
|
||||||
'video_link',
|
'video_link',
|
||||||
'image',
|
'course_image',
|
||||||
]
|
]
|
||||||
for (const field of mandatory_fields) {
|
for (const field of mandatory_fields) {
|
||||||
if (!course[field]) {
|
if (!course.value[field]) {
|
||||||
let fieldLabel = convertToTitleCase(field.split('_').join(' '))
|
let fieldLabel = convertToTitleCase(field.split('_').join(' '))
|
||||||
return `${fieldLabel} is mandatory`
|
return `${fieldLabel} is mandatory`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (course.paid_course && (!course.course_price || !course.currency)) {
|
if (
|
||||||
|
course.value.paid_course &&
|
||||||
|
(!course.value.course_price || !course.value.currency)
|
||||||
|
) {
|
||||||
return 'Course price and currency are mandatory for paid courses'
|
return 'Course price and currency are mandatory for paid courses'
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
onError(err) {
|
|
||||||
|
const validateFile = (file) => {
|
||||||
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
|
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||||
|
return 'Only image file is allowed.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTags = () => {
|
||||||
|
if (newTag.value) {
|
||||||
|
tags.value = tags.value ? `${tags.value}, ${newTag.value}` : newTag.value
|
||||||
|
newTag.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTag = (tag) => {
|
||||||
|
tags.value = tags.value
|
||||||
|
?.split(', ')
|
||||||
|
.filter((t) => t !== tag)
|
||||||
|
.join(', ')
|
||||||
|
newTag.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const showToast = (err) => {
|
||||||
createToast({
|
createToast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
text: err.messages?.[0] || err,
|
text: err.messages?.[0] || err,
|
||||||
@@ -202,16 +387,10 @@ const submitCourse = () => {
|
|||||||
position: 'top-center',
|
position: 'top-center',
|
||||||
timeout: 10,
|
timeout: 10,
|
||||||
})
|
})
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateFile = (file) => {
|
const removeImage = () => {
|
||||||
console.log(file)
|
image.value = null
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
course.value.course_image = null
|
||||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
|
||||||
return 'Only image file is allowed.'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -267,3 +267,12 @@ def get_chart_details():
|
|||||||
)
|
)
|
||||||
details.lesson_completions = frappe.db.count("LMS Course Progress")
|
details.lesson_completions = frappe.db.count("LMS Course Progress")
|
||||||
return details
|
return details
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_file_info(file_url):
|
||||||
|
"""Get file info for the given file URL."""
|
||||||
|
file_info = frappe.db.get_value(
|
||||||
|
"File", {"file_url": file_url}, ["file_name", "file_size", "file_url"], as_dict=1
|
||||||
|
)
|
||||||
|
return file_info
|
||||||
|
|||||||
@@ -82,5 +82,5 @@ lms.patches.v1_0.create_batch_source
|
|||||||
[post_model_sync]
|
[post_model_sync]
|
||||||
lms.patches.v1_0.batch_tabs_settings
|
lms.patches.v1_0.batch_tabs_settings
|
||||||
execute:frappe.delete_doc("Notification", "Assignment Submission Notification")
|
execute:frappe.delete_doc("Notification", "Assignment Submission Notification")
|
||||||
lms.patches.v1_0.change_jobs_url #17-01-2024
|
lms.patches.v1_0.change_jobs_url #19-01-2024
|
||||||
lms.patches.v1_0.custom_perm_for_discussions #14-01-2024
|
lms.patches.v1_0.custom_perm_for_discussions #14-01-2024
|
||||||
Reference in New Issue
Block a user