feat: record lesson progress

This commit is contained in:
Jannat Patel
2024-11-15 19:14:34 +05:30
parent 73754bd104
commit cf014bca3c
16 changed files with 491 additions and 109 deletions

View File

@@ -1,18 +1,18 @@
<template>
<div
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
:class="isSidebarCollapsed ? 'w-14' : 'w-56'"
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
>
<div
class="flex flex-col overflow-hidden"
:class="isSidebarCollapsed ? 'items-center' : ''"
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
>
<UserDropdown :isCollapsed="isSidebarCollapsed" />
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
<div class="flex flex-col" v-if="sidebarSettings.data">
<SidebarLink
v-for="link in sidebarLinks"
:link="link"
:isCollapsed="isSidebarCollapsed"
:isCollapsed="sidebarStore.isSidebarCollapsed"
class="mx-2 my-0.5"
/>
</div>
@@ -22,11 +22,11 @@
>
<div
class="flex items-center justify-between pr-2 cursor-pointer"
:class="isSidebarCollapsed ? 'pl-3' : 'pl-4'"
:class="sidebarStore.isSidebarCollapsed ? 'pl-3' : 'pl-4'"
@click="showWebPages = !showWebPages"
>
<div
v-if="!isSidebarCollapsed"
v-if="!sidebarStore.isSidebarCollapsed"
class="flex items-center text-sm text-gray-600 my-1"
>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
@@ -53,7 +53,7 @@
<SidebarLink
v-for="link in sidebarSettings.data.web_pages"
:link="link"
:isCollapsed="isSidebarCollapsed"
:isCollapsed="sidebarStore.isSidebarCollapsed"
class="mx-2 my-0.5"
:showControls="isModerator ? true : false"
@openModal="openPageModal"
@@ -64,17 +64,19 @@
</div>
<SidebarLink
:link="{
label: isSidebarCollapsed ? 'Expand' : 'Collapse',
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
}"
:isCollapsed="isSidebarCollapsed"
@click="isSidebarCollapsed = !isSidebarCollapsed"
:isCollapsed="sidebarStore.isSidebarCollapsed"
@click="toggleSidebar()"
class="m-2"
>
<template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<CollapseSidebar
class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
:class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }"
:class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
/>
</span>
</template>
@@ -96,12 +98,14 @@ import { ref, onMounted, inject, watch } from 'vue'
import { getSidebarLinks } from '../utils'
import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar'
import { ChevronRight, Plus } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue'
const { user, sidebarSettings } = sessionStore()
const { userResource } = usersStore()
let sidebarStore = useSidebar()
const socket = inject('$socket')
const unreadCount = ref(0)
const sidebarLinks = ref(getSidebarLinks())
@@ -214,5 +218,8 @@ watch(userResource, () => {
}
})
let isSidebarCollapsed = ref(getSidebarFromStorage())
const toggleSidebar = () => {
console.log(sidebarStore.isSidebarCollapsed)
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
}
</script>

View File

@@ -140,7 +140,7 @@ function enrollStudent() {
showToast(
__('Please Login'),
__('You need to login first to enroll for this course'),
'circle-warn'
'alert-circle'
)
setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}`

View File

@@ -16,7 +16,7 @@
</div>
<div
:class="{
'shadow rounded-md pt-2 px-2': showOutline && outline.data?.length,
'shadow rounded-md py-2 px-2': showOutline && outline.data?.length,
}"
>
<Disclosure
@@ -25,21 +25,42 @@
:key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)"
>
<DisclosureButton ref="" class="flex w-full p-2">
<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-gray-900 stroke-1 mr-2"
class="h-4 w-4 text-gray-900 stroke-1"
/>
<div class="text-base text-left font-medium leading-5">
<div
class="text-base text-left 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-gray-900 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-red-500 invisible group-hover:visible"
/>
</Tooltip>
</div>
</DisclosureButton>
<DisclosurePanel>
<DisclosurePanel v-if="!chapter.is_scorm_package">
<Draggable
v-if="!chapter.is_scorm_package"
:list="chapter.lessons"
:disabled="!allowEdit"
item-key="name"
@@ -103,9 +124,6 @@
{{ __('Add Lesson') }}
</Button>
</router-link>
<Button class="ml-2" @click="openChapterModal(chapter)">
{{ __('Edit Chapter') }}
</Button>
</div>
</DisclosurePanel>
</Disclosure>
@@ -119,24 +137,26 @@
/>
</template>
<script setup>
import { Button, createResource } from 'frappe-ui'
import { ref, getCurrentInstance } from 'vue'
import { Button, createResource, Tooltip } from 'frappe-ui'
import { getCurrentInstance, inject, ref } from 'vue'
import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import {
ChevronRight,
MonitorPlay,
HelpCircle,
FileText,
Check,
ChevronRight,
FileText,
FilePenLine,
HelpCircle,
MonitorPlay,
Trash2,
} from 'lucide-vue-next'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue'
import { showToast } from '@/utils'
const route = useRoute()
const expandAll = ref(true)
const router = useRouter()
const user = inject('$user')
const showChapterModal = ref(false)
const currentChapter = ref(null)
const app = getCurrentInstance()
@@ -206,8 +226,10 @@ const updateLessonIndex = createResource({
const trashLesson = (lessonName, chapterName) => {
$dialog({
title: __('Delete Lesson'),
message: __('Are you sure you want to delete this lesson?'),
title: __('Delete this lesson?'),
message: __(
'Deleting this lesson will permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
@@ -246,6 +268,61 @@ const updateOutline = (e) => {
idx: e.newIndex,
})
}
const deleteChapter = createResource({
url: 'lms.lms.api.delete_chapter',
makeParams(values) {
return {
chapter: values.chapter,
}
},
onSuccess() {
outline.reload()
showToast('Success', 'Chapter deleted successfully', 'check')
},
})
const trashChapter = (chapterName) => {
$dialog({
title: __('Delete this chapter?'),
message: __(
'Deleting this chapter will also delete all its lessons and permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(close) {
deleteChapter.submit({ chapter: chapterName })
close()
},
},
],
})
}
const redirectToChapter = (chapter) => {
event.preventDefault()
if (props.allowEdit) return
if (!chapter.is_scorm_package) return
if (!user.data) {
showToast(
__('You are not enrolled'),
__('Please enroll for this course to view this lesson'),
'alert-circle'
)
return
}
router.push({
name: 'SCORMChapter',
params: {
courseName: props.courseName,
chapterName: chapter.name,
},
})
}
</script>
<style>
.outline-lesson:has(.router-link-active) {

View File

@@ -15,8 +15,13 @@
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl ref="chapterInput" label="Title" v-model="chapter.title" />
<div class="space-y-4 text-base">
<FormControl
ref="chapterInput"
label="Title"
v-model="chapter.title"
:required="true"
/>
<FormControl
:label="__('Is SCORM Package')"
v-model="chapter.is_scorm_package"
@@ -97,25 +102,14 @@ const chapter = reactive({
})
const chapterResource = createResource({
url: 'lms.lms.api.add_chapter',
url: 'lms.lms.api.upsert_chapter',
makeParams(values) {
return {
title: chapter.title,
course: props.course,
is_scorm_package: chapter.is_scorm_package,
scorm_package: chapter.scorm_package,
}
},
})
const chapterEditResource = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Course Chapter',
name: props.chapterDetail?.name,
fieldname: 'title',
value: chapter.title,
}
},
})
@@ -140,12 +134,7 @@ const addChapter = async (close) => {
{},
{
validate() {
if (!chapter.title) {
return __('Title is required')
}
if (chapter.is_scorm_package && !chapter.scorm_package) {
return __('Please upload a SCORM package')
}
return validateChapter()
},
onSuccess: (data) => {
capture('chapter_created')
@@ -175,6 +164,15 @@ const addChapter = async (close) => {
)
}
const validateChapter = () => {
if (!chapter.title) {
return __('Title is required')
}
if (chapter.is_scorm_package && !chapter.scorm_package) {
return __('Please upload a SCORM package')
}
}
const cleanChapter = () => {
chapter.title = ''
chapter.is_scorm_package = 0
@@ -182,7 +180,7 @@ const cleanChapter = () => {
}
const editChapter = (close) => {
chapterEditResource.submit(
chapterResource.submit(
{},
{
validate() {
@@ -206,6 +204,8 @@ watch(
() => props.chapterDetail,
(newChapter) => {
chapter.title = newChapter?.title
chapter.is_scorm_package = newChapter?.is_scorm_package
chapter.scorm_package = newChapter?.scorm_package
}
)

View File

@@ -70,7 +70,11 @@
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
></div>
<div class="mt-10">
<CourseOutline :courseName="course.data.name" :showOutline="true" />
<CourseOutline
:title="__('Course Outline')"
:courseName="course.data.name"
:showOutline="true"
/>
</div>
<CourseReviews
:courseName="course.data.name"

View File

@@ -244,7 +244,10 @@ const lesson = createResource({
auto: true,
onSuccess(data) {
if (Object.keys(data).length === 0) {
router.push({ name: 'Courses' })
router.push({
name: 'CourseDetail',
params: { courseName: props.courseName },
})
return
}
lessonProgress.value = data.membership?.progress

View File

@@ -0,0 +1,205 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
</header>
<div
v-if="
readyToRender &&
(enrollment.data?.length ||
user.data?.is_moderator ||
user.data?.is_instructor)
"
>
<iframe :src="chapter.doc.launch_file" class="w-full h-screen" />
</div>
<div v-else-if="!enrollment.data?.length">
<div class="text-center pt-10 px-5 md:px-0 pb-10">
<div class="text-center">
<div class="mb-4">
{{
__(
'You are not enrolled in this course. Please enroll to access this lesson.'
)
}}
</div>
<Button variant="solid" @click="enrollStudent()">
{{ __('Start Learning') }}
</Button>
</div>
</div>
</div>
</template>
<script setup>
import {
Breadcrumbs,
Button,
call,
createDocumentResource,
createListResource,
createResource,
} from 'frappe-ui'
import { computed, inject, onBeforeMount, ref } from 'vue'
import { useSidebar } from '@/stores/sidebar'
import { updateDocumentTitle } from '@/utils'
const sidebarStore = useSidebar()
const user = inject('$user')
const readyToRender = ref(false)
const props = defineProps({
courseName: {
type: String,
required: true,
},
chapterName: {
type: String,
required: true,
},
})
onBeforeMount(() => {
sidebarStore.isSidebarCollapsed = true
window.API_1484_11 = {
Initialize: () => 'true',
Terminate: () => 'true',
GetValue: (key) => {
console.log(`GetValue called for key: ${key}`)
return getDataFromLMS(key)
},
SetValue: (key, value) => {
console.log(`SetValue called for key: ${key} to value: ${value}`)
saveDataToLMS(key, value)
return 'true'
},
Commit: () => 'true',
GetLastError: () => '0',
GetErrorString: () => '',
GetDiagnostic: () => '',
}
window.API = {
LMSInitialize: () => 'true',
LMSFinish: () => 'true',
LMSGetValue: (key) => {
console.log(`GET: ${key}`)
return getDataFromLMS(key)
},
LMSSetValue: (key, value) => {
console.log(`SET: ${key} to value: ${value}`)
saveDataToLMS(key, value)
return 'true'
},
LMSCommit: () => 'true',
LMSGetLastError: () => '0',
LMSGetErrorString: () => '',
LMSGetDiagnostic: () => '',
}
console.log(window.API_1484_11)
})
const getDataFromLMS = (key) => {
if (key == 'cmi.core.lesson_status') {
if (progress.data?.status == 'Complete') {
return 'passed'
}
return 'incomplete'
}
return ''
}
const saveDataToLMS = (key, value) => {
if (key == 'cmi.core.lesson_status' && value == 'passed') {
saveProgress()
}
}
const enrollment = createListResource({
doctype: 'LMS Enrollment',
fields: ['member', 'course'],
filters: {
course: props.courseName,
member: user.data?.name,
},
auto: true,
cache: ['enrollments', props.courseName, user.data?.name],
})
const chapter = createDocumentResource({
doctype: 'Course Chapter',
name: props.chapterName,
auto: true,
cache: ['chapter', props.chapterName],
onSuccess(data) {
progress.submit()
},
})
const saveProgress = () => {
call('lms.lms.doctype.course_lesson.course_lesson.save_progress', {
lesson: chapter.doc.lessons[0].lesson,
course: props.courseName,
})
}
const progress = createResource({
url: 'frappe.client.get_value',
makeParams(values) {
return {
doctype: 'LMS Course Progress',
fieldname: 'status',
filters: {
member: user.data?.name,
lesson: chapter.doc.lessons[0].lesson,
chapter: chapter.doc.name,
course: chapter.doc?.course,
},
}
},
onSuccess(data) {
readyToRender.value = true
},
})
const enrollStudent = () => {
enrollment.insert.submit(
{
course: props.courseName,
member: user.data?.name,
},
{
onSuccess(data) {
window.location.reload()
},
}
)
}
const breadcrumbs = computed(() => {
return [
{
label: 'Courses',
route: { name: 'Courses' },
},
{
label: chapter.doc?.course_title,
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
},
{
label: chapter.doc?.title,
},
]
})
const pageMeta = computed(() => {
return {
title: chapter?.doc?.title,
description: __('This is a chapter in the course {0}').format(
chapter?.doc?.course_title
),
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -27,6 +27,12 @@ const routes = [
component: () => import('@/pages/Lesson.vue'),
props: true,
},
{
path: '/courses/:courseName/learn/:chapterName',
name: 'SCORMChapter',
component: () => import('@/pages/SCORMChapter.vue'),
props: true,
},
{
path: '/batches',
name: 'Batches',

View File

@@ -0,0 +1,10 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSidebar = defineStore('sidebar', () => {
const isSidebarCollapsed = ref(false)
return {
isSidebarCollapsed,
}
})

View File

@@ -93,7 +93,7 @@ export function showToast(title, text, icon, iconClasses = null) {
if (!iconClasses) {
if (icon == 'check') {
iconClasses = 'bg-green-600 text-white rounded-md p-px'
} else if (icon == 'circle-warn') {
} else if (icon == 'alert-circle') {
iconClasses = 'bg-yellow-600 text-white rounded-md p-px'
} else {
iconClasses = 'bg-red-600 text-white rounded-md p-px'