chore: resolved conflicts

This commit is contained in:
Jannat Patel
2026-01-30 12:30:53 +05:30
158 changed files with 14920 additions and 10459 deletions

View File

@@ -130,7 +130,6 @@
import {
Breadcrumbs,
Button,
call,
createListResource,
Dropdown,
FormControl,
@@ -185,24 +184,27 @@ const batches = createListResource({
cache: ['batches', user.data?.name],
pageLength: pageLength.value,
start: start.value,
onSuccess(data) {
let allCategories = data.map((batch) => batch.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
},
})
const setCategories = (data) => {
let allCategories = data.map((batch) => batch.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
}
const updateBatches = () => {
updateFilters()
batches.update({
filters: filters.value,
orderBy: orderBy.value,
})
batches.reload()
batches.reload().then((data) => {
setCategories(data)
})
}
const updateFilters = () => {

View File

@@ -3,7 +3,7 @@
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" />
<router-link :to="{ name: 'Batches', query: { certification: true } }">
<router-link :to="{ name: 'Courses', query: { certification: true } }">
<Button>
<template #prefix>
<GraduationCap class="h-4 w-4 stroke-1.5" />
@@ -42,8 +42,8 @@
</div>
<div class="flex items-center space-x-4">
<FormControl
v-model="openToOpportunities"
:label="__('Open to Opportunities')"
v-model="openToWork"
:label="__('Open to Work')"
type="checkbox"
@change="updateParticipants()"
/>
@@ -134,19 +134,26 @@ import {
import { computed, inject, onMounted, ref } from 'vue'
import { GraduationCap } from 'lucide-vue-next'
import { sessionStore } from '../stores/session'
import { useRouter } from 'vue-router'
import EmptyState from '@/components/EmptyState.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const filters = ref({})
const currentCategory = ref('')
const nameFilter = ref('')
const openToOpportunities = ref(false)
const openToWork = ref(false)
const hiring = ref(false)
const { brand } = sessionStore()
const memberCount = ref(0)
const dayjs = inject('$dayjs')
const user = inject('$user')
const router = useRouter()
onMounted(() => {
if (!user.data) {
router.push({ name: 'Courses' })
return
}
setFiltersFromQuery()
updateParticipants()
})
@@ -171,7 +178,7 @@ const categories = createListResource({
doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certification_categories',
cache: ['certification_categories'],
auto: true,
auto: user.data ? true : false,
transform(data) {
data.unshift({ label: __(' '), value: ' ' })
return data
@@ -197,8 +204,8 @@ const updateFilters = () => {
...(nameFilter.value && {
member_name: ['like', `%${nameFilter.value}%`],
}),
...(openToOpportunities.value && {
open_to_opportunities: true,
...(openToWork.value && {
open_to_work: true,
}),
...(hiring.value && {
hiring: true,
@@ -211,7 +218,7 @@ const setQueryParams = () => {
let filterKeys = {
category: currentCategory.value,
name: nameFilter.value,
'open-to-opportunities': openToOpportunities.value,
'open-to-work': openToWork.value,
hiring: hiring.value,
}
@@ -240,7 +247,7 @@ const setFiltersFromQuery = () => {
let queries = new URLSearchParams(location.search)
nameFilter.value = queries.get('name') || ''
currentCategory.value = queries.get('category') || ''
openToOpportunities.value = queries.get('open-to-opportunities') === 'true'
openToWork.value = queries.get('open-to-opportunities') === 'true'
hiring.value = queries.get('hiring') === 'true'
}

View File

@@ -1,194 +0,0 @@
<template>
<div v-if="course.data">
<header
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" />
</header>
<div class="m-5">
<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 }}
</div>
<div class="my-3 leading-6 text-ink-gray-7">
{{ course.data.short_introduction }}
</div>
<div class="flex items-center">
<Tooltip
v-if="parseInt(course.data.rating) > 0"
:text="__('Average Rating')"
class="flex items-center"
>
<Star class="size-4 text-transparent fill-yellow-500" />
<span class="ml-1 text-ink-gray-7">
{{ course.data.rating }}
</span>
</Tooltip>
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
>&middot;</span
>
<Tooltip
v-if="course.data.enrollment_count"
:text="__('Enrolled Students')"
class="flex items-center"
>
<Users class="h-4 w-4 text-ink-gray-7" />
<span class="ml-1">
{{ course.data.enrollment_count_formatted }}
</span>
</Tooltip>
<span v-if="course.data.enrollment_count" class="mx-3"
>&middot;</span
>
<div class="flex items-center">
<span
class="h-6 mr-1"
:class="{
'avatar-group overlap': course.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in course.data.instructors"
:user="instructor"
/>
</span>
<CourseInstructors :instructors="course.data.instructors" />
</div>
</div>
<div v-if="course.data.tags" class="flex my-4 w-fit">
<Badge
theme="gray"
size="lg"
class="mr-2 text-ink-gray-9"
v-for="tag in course.data.tags.split(', ')"
>
{{ tag }}
</Badge>
</div>
<div class="md:hidden my-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"
></div>
<div class="mt-10">
<CourseOutline
:title="__('Course Outline')"
:courseName="course.data.name"
:showOutline="true"
:getProgress="course.data.membership ? true : false"
/>
</div>
<CourseReviews
:courseName="course.data.name"
:avg_rating="course.data.rating"
:membership="course.data.membership"
/>
</div>
<div class="hidden md:block">
<CourseCardOverlay :course="course" />
</div>
</div>
<RelatedCourses :courseName="course.data.name" />
</div>
</div>
</template>
<script setup>
import {
createResource,
Breadcrumbs,
Badge,
Tooltip,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, watch } from 'vue'
import { Users, Star } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
import CourseOutline from '@/components/CourseOutline.vue'
import CourseReviews from '@/components/CourseReviews.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import RelatedCourses from '@/components/RelatedCourses.vue'
const { brand } = sessionStore()
const router = useRouter()
const user = inject('$user')
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
const course = createResource({
url: 'lms.lms.utils.get_course_details',
cache: ['course', props.courseName],
makeParams() {
return {
course: props.courseName,
}
},
auto: true,
})
watch(
() => props.courseName,
() => {
course.reload()
}
)
watch(course, () => {
if (
!isInstructor() &&
!user.data?.is_moderator &&
!course.data?.published &&
!course.data?.upcoming
) {
router.push({
name: 'Courses',
})
}
})
const isInstructor = () => {
let user_is_instructor = false
course.data?.instructors.forEach((instructor) => {
if (!user_is_instructor && instructor.name == user.data?.name) {
user_is_instructor = true
}
})
return user_is_instructor
}
const breadcrumbs = computed(() => {
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
items.push({
label: course?.data?.title,
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
})
return items
})
usePageMeta(() => {
return {
title: course?.data?.title,
icon: brand.favicon,
}
})
</script>
<style>
.avatar-group {
display: inline-flex;
align-items: center;
}
.avatar-group .avatar {
transition: margin 0.1s ease-in-out;
}
</style>

View File

@@ -38,7 +38,7 @@
import { computed, inject, onMounted, ref } from 'vue'
import { Breadcrumbs, call, createResource, usePageMeta } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { sessionStore } from '../stores/session'
import { sessionStore } from '../../stores/session'
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
const courseTitle = ref(null)

View File

@@ -0,0 +1,400 @@
<template>
<div class="p-5">
<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"
/>
<NumberChartGraph
:title="__('Average Rating')"
:value="course.data?.rating || 0"
>
<template #prefix>
<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 v-if="course.data?.enrollments" 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 name')"
type="text"
/>
<Button @click="showEnrollmentModal = true">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('Enroll') }}
</Button>
</div>
</div>
<div
v-if="progressList.loading || progressList.data?.length"
class="max-h-[63vh] overflow-y-auto"
>
<ListView
: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-white border-b rounded-none p-2"
>
<ListHeaderItem
:item="item"
v-for="item in progressColumns"
:key="item.key"
>
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in progressList.data" class="max-h-[500px]">
<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"
class="w-full"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
<ProgressBar
v-else-if="column.key == 'progress'"
:progress="Math.ceil(row[column.key])"
class="!mx-0 !mr-4"
/>
</template>
<div v-if="column.key == 'creation'">
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
</div>
<div
v-else-if="column.key == 'progress'"
class="text-xs !mx-0 w-5"
>
{{ Math.ceil(row[column.key]) }}%
</div>
<div v-else>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
</ListRows>
</ListView>
<div
v-if="progressList.data && progressList.hasNextPage"
class="flex justify-center my-3"
>
<Button @click="progressList.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
<div class="space-y-5">
<div
v-if="chartDetails.data?.average_progress > 0"
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="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>
<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: {
label: {
show: false,
},
scale: false,
},
legend: {
show: false,
},
data: chartDetails.data?.progress_distribution || [],
},
],
showInlineLabels: false,
}"
/>
</div>
</div>
<div
v-if="lessonProgress.data?.length"
class="border rounded-lg pt-4 px-4"
>
<div class="flex items-center justify-between mb-4">
<div class="text-ink-gray-5">
{{ __('Lesson Completion') }}
</div>
<Select
:options="lessonProgressSortingOptions"
@update:modelValue="(value: string) => updateLessonProgress(value)"
:placeholder="__('Sort by')"
class="!w-32"
/>
</div>
<div class="divide-y max-h-[43vh] overflow-y-auto">
<div
v-for="progress in lessonProgress.data"
class="flex justify-between text-sm py-2 my-1"
>
<div class="">
<span class="mr-3 text-xs">
{{ progress.chapter_idx }}.{{ progress.idx }}
</span>
<span>
{{ progress.title }}
</span>
</div>
<Tooltip :text="progress.completion_count">
<div>
{{
Math.ceil(
(progress.completion_count / course.data?.enrollments) *
100
)
}}%
</div>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
<CourseEnrollmentModal
v-if="showEnrollmentModal"
v-model="showEnrollmentModal"
:course="course"
/>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
createListResource,
createResource,
dayjs,
Dropdown,
ECharts,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
Select,
Tooltip,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { ChevronDown, 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'
import ProgressBar from '@/components/ProgressBar.vue'
const props = defineProps<{
course: any
}>()
const showEnrollmentModal = ref(false)
const searchFilter = ref<string | null>(null)
const theme = ref<'darkMode' | 'lightMode'>(
localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
)
type Filters = {
course: string | undefined
member_name?: string[]
}
const chartDetails = createResource({
url: 'lms.lms.api.get_course_progress_distribution',
makeParams() {
return {
course: props.course.data?.name,
}
},
auto: true,
})
const progressList = createListResource({
doctype: 'LMS Enrollment',
filters: {
course: props.course.data?.name,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'progress',
'creation',
],
pageLength: 100,
auto: true,
})
const lessonProgress = createResource({
url: 'lms.lms.api.get_lesson_completion_stats',
params: {
course: props.course.data?.name,
},
auto: true,
})
const updateLessonProgress = (value: string) => {
if (value == 'completion_rate') {
lessonProgress.data?.sort((a: any, b: any) => {
const rateA = a.completion_count / (props.course.data?.enrollments || 1)
const rateB = b.completion_count / (props.course.data?.enrollments || 1)
return rateB - rateA
})
} else if (value == 'index') {
lessonProgress.data?.sort((a: any, b: any) => {
return a.chapter_idx - b.chapter_idx || a.idx - b.idx
})
}
}
watch([searchFilter], () => {
let filterApplied = false
let filters: Filters = {
course: props.course.data?.name,
}
if (searchFilter.value) {
filters.member_name = ['like', `%${searchFilter.value}%`]
filterApplied = true
}
progressList.update({
filters: filters,
})
progressList.reload()
})
const averageCompletionRate = computed(() => {
let value = Math.ceil(chartDetails.data?.average_progress) || 0
return value + '%'
})
const progressColors = computed(() => {
let colorList = []
colorList.push(colors[theme.value]['red'][400])
colorList.push(colors[theme.value]['amber'][400])
colorList.push(colors[theme.value]['green'][400])
return colorList
})
const progressColumns = computed(() => {
return [
{
label: __('Name'),
key: 'member_name',
width: '40%',
},
{
label: __('Progress'),
key: 'progress',
width: '30%',
},
{
label: __('Start Date'),
key: 'creation',
align: 'right',
},
]
})
const lessonProgressSortingOptions = [
{
label: __('Lesson Index'),
value: 'index',
onClick() {
updateLessonProgress('index')
},
},
{
label: __('Completion Rate'),
value: 'completion_rate',
onClick() {
updateLessonProgress('completion_rate')
},
},
]
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div v-if="course.data">
<header
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" />
<div v-if="tabIndex == 2" class="flex items-center space-x-2">
<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" @click="childRef.submitCourse()">
{{ __('Save') }}
</Button>
</div>
</header>
<CourseOverview v-if="!isAdmin" :course="course" />
<div v-else>
<Tabs :tabs="tabs" v-model="tabIndex">
<template #tab-panel="{ tab }">
<component :is="tab.component" :course="course" ref="childRef" />
</template>
</Tabs>
</div>
</div>
</template>
<script setup>
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'
import { List, Settings2, Trash2, TrendingUp } from 'lucide-vue-next'
import CourseOverview from '@/pages/Courses/CourseOverview.vue'
import CourseDashboard from '@/pages/Courses/CourseDashboard.vue'
import CourseForm from '@/pages/Courses/CourseForm.vue'
const { brand } = sessionStore()
const router = useRouter()
const route = useRoute()
const user = inject('$user')
const tabIndex = ref(0)
const childRef = ref(null)
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
onMounted(() => {
updateTabIndex()
})
const updateTabIndex = () => {
const hash = route.hash
if (hash) {
tabs.value.forEach((tab, index) => {
if (tab.label?.toLowerCase() === hash.replace('#', '')) {
tabIndex.value = index
}
})
}
}
watch(tabIndex, () => {
const tab = tabs.value[tabIndex.value]
if (tab.label != route.hash.replace('#', '')) {
router.push({ ...route, hash: `#${tab.label.toLowerCase()}` })
}
})
const course = createResource({
url: 'lms.lms.utils.get_course_details',
cache: ['course', props.courseName],
makeParams() {
return {
course: props.courseName,
}
},
auto: true,
})
const tabs = ref([
{
label: __('Overview'),
component: markRaw(CourseOverview),
icon: List,
},
{
label: __('Dashboard'),
component: markRaw(CourseDashboard),
icon: TrendingUp,
},
{
label: __('Settings'),
component: markRaw(CourseForm),
icon: Settings2,
},
])
watch(
() => props.courseName,
() => {
course.reload()
}
)
watch(course, () => {
if (!isAdmin.value && !course.data?.published && !course.data?.upcoming) {
router.push({
name: 'Courses',
})
}
})
const isInstructor = () => {
let user_is_instructor = false
course.data?.instructors.forEach((instructor) => {
if (!user_is_instructor && instructor.name == user.data?.name) {
user_is_instructor = true
}
})
return user_is_instructor
}
const isAdmin = computed(() => {
return user.data?.is_moderator || isInstructor()
})
const breadcrumbs = computed(() => {
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
items.push({
label: course?.data?.title,
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
})
return items
})
usePageMeta(() => {
return {
title: course?.data?.title,
icon: brand.favicon,
}
})
</script>
<style>
.avatar-group {
display: inline-flex;
align-items: center;
}
.avatar-group .avatar {
transition: margin 0.1s ease-in-out;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Enroll a Student'),
size: 'xl',
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
type="checkbox"
:label="__('Purchased Certificate')"
v-model="purchasedCertificate"
/>
<Link
doctype="User"
:label="__('Student')"
placeholder=" "
v-model="student"
:required="true"
:allowCreate="true"
@create="
() => {
openSettings('Members')
show = false
}
"
/>
<Link
v-if="purchasedCertificate"
doctype="LMS Payment"
:label="__('Payment')"
placeholder=" "
v-model="payment"
:allowCreate="true"
@create="
() => {
openSettings('Transactions')
show = false
}
"
/>
</div>
</template>
<template #actions="{ close }">
<div class="text-right">
<Button variant="solid" @click="enrollStudent(close)">
{{ __('Enroll') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
import { Link } from 'frappe-ui/frappe'
import { ref } from 'vue'
import { openSettings } from '@/utils'
const show = defineModel<boolean>({ required: true, default: false })
const student = ref<string | null>(null)
const payment = ref<string | null>(null)
const purchasedCertificate = ref<boolean>(false)
const props = defineProps<{
course: any
}>()
const enrollStudent = (close: () => void) => {
let validationPassed = validateData()
if (!validationPassed) return
call('frappe.client.insert', {
doc: {
doctype: 'LMS Enrollment',
course: props.course.data?.name,
member: student.value,
payment: purchasedCertificate.value ? payment.value : null,
purchased_certificate: purchasedCertificate.value,
},
})
.then(() => {
toast.success(__('Student enrolled successfully'))
close()
})
.catch((err: any) => {
toast.error(__(err.messages?.[0] || err))
console.error(err)
})
}
const validateData = (): boolean => {
if (!student.value) {
toast.error(__('Please select a student to enroll.'))
return false
}
if (purchasedCertificate.value && !payment.value) {
toast.error(__('Please select a payment for the purchased certificate.'))
return false
}
return true
}
</script>

View File

@@ -1,40 +1,25 @@
<template>
<div class="h-full">
<div class="grid grid-cols-1 md:grid-cols-[70%,30%] h-full">
<div>
<header
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<div class="flex items-center mt-3 md:mt-0">
<Button v-if="courseResource.data?.name" @click="trashCourse()">
<template #icon>
<Trash2 class="w-4 h-4 stroke-1.5" />
</template>
</Button>
<Button variant="solid" @click="submitCourse()" class="ml-2">
<span>
{{ __('Save') }}
</span>
</Button>
</div>
</header>
<div class="mt-5 mb-5">
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
<div class="pl-5">
<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">
{{ __('Details') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormControl
v-model="course.title"
v-model="courseResource.doc.title"
:label="__('Title')"
:required="true"
@input="makeFormDirty()"
/>
<Link
doctype="LMS Category"
v-model="course.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">
@@ -45,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">
@@ -60,8 +46,8 @@
<div>
<div class="flex items-center flex-wrap gap-2">
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
v-if="courseResource.doc.tags"
v-for="tag in courseResource.doc.tags?.split(', ')"
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
>
{{ tag }}
@@ -76,21 +62,23 @@
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<Uploader
v-model="course.image"
v-model="courseResource.doc.image"
:label="__('Course Image')"
:required="false"
@update:modelValue="makeFormDirty()"
/>
<ColorSwatches
v-model="course.card_gradient"
v-model="courseResource.doc.card_gradient"
:label="__('Color')"
:description="__('Choose a color for the course card')"
class="w-full"
@update:modelValue="makeFormDirty()"
/>
</div>
</div>
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Settings') }}
</div>
@@ -101,41 +89,46 @@
>
<FormControl
type="checkbox"
v-model="course.published"
v-model="courseResource.doc.published"
:label="__('Published')"
@change="makeFormDirty()"
/>
<FormControl
v-model="course.published_on"
v-model="courseResource.doc.published_on"
:label="__('Published On')"
type="date"
@change="makeFormDirty()"
/>
</div>
<div class="flex flex-col space-y-5">
<FormControl
type="checkbox"
v-model="course.upcoming"
v-model="courseResource.doc.upcoming"
:label="__('Upcoming')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="course.featured"
v-model="courseResource.doc.featured"
:label="__('Featured')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="course.disable_self_learning"
v-model="courseResource.doc.disable_self_learning"
:label="__('Disable Self Enrollment')"
@change="makeFormDirty()"
/>
</div>
</div>
</div>
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('About the Course') }}
</div>
<FormControl
v-model="course.short_introduction"
v-model="courseResource.doc.short_introduction"
type="textarea"
:rows="5"
:label="__('Short Introduction')"
@@ -145,6 +138,7 @@
)
"
:required="true"
@change="makeFormDirty()"
/>
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
@@ -152,8 +146,13 @@
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="course.description"
@change="(val) => (course.description = val)"
:content="courseResource.doc.description"
@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]"
@@ -161,92 +160,113 @@
</div>
<FormControl
v-model="course.video_link"
v-model="courseResource.doc.video_link"
:label="__('Preview Video')"
:placeholder="
__(
'Paste the youtube link of a short video introducing the course'
)
"
@input="makeFormDirty()"
/>
<MultiSelect
v-model="related_courses"
doctype="LMS Course"
:label="__('Related Courses')"
:filters="{ name: ['!=', courseResource.data?.name] }"
:filters="{ name: ['!=', courseResource.doc?.name] }"
:onCreate="
(close) => {
router.push({
name: 'CourseForm',
params: { courseName: 'new' },
name: 'Courses',
query: { newCourse: '1' },
})
}
"
@update:modelValue="makeFormDirty()"
/>
</div>
<div class="px-5 md:px-10 pb-5 space-y-5 border-b">
<div class="pr-5 md:pr-10 pb-5 space-y-5 border-b">
<div class="text-lg font-semibold mt-5 text-ink-gray-9">
{{ __('Pricing and Certification') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
<FormControl
type="checkbox"
v-model="course.paid_course"
v-model="courseResource.doc.paid_course"
:label="__('Paid Course')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="course.enable_certification"
v-model="courseResource.doc.enable_certification"
:label="__('Completion Certificate')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="course.paid_certificate"
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="course.paid_course || course.paid_certificate"
v-model="course.course_price"
v-if="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
v-model="courseResource.doc.course_price"
:label="__('Amount')"
:required="course.paid_course || course.paid_certificate"
:required="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
@input="makeFormDirty()"
/>
<Link
v-if="course.paid_certificate"
v-if="courseResource.doc.paid_certificate"
doctype="Course Evaluator"
v-model="course.evaluator"
v-model="courseResource.doc.evaluator"
:label="__('Evaluator')"
:required="course.paid_certificate"
:required="courseResource.doc.paid_certificate"
:onCreate="
(value, close) => openSettings('Evaluators', close)
"
@update:modelValue="makeFormDirty()"
/>
</div>
<div class="space-y-5">
<Link
v-if="course.paid_course || course.paid_certificate"
v-if="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
doctype="Currency"
v-model="course.currency"
v-model="courseResource.doc.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
:required="course.paid_course || course.paid_certificate"
:required="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
@update:modelValue="makeFormDirty()"
/>
<FormControl
v-if="course.paid_certificate"
v-model="course.timezone"
v-if="courseResource.doc.paid_certificate"
v-model="courseResource.doc.timezone"
:label="__('Timezone')"
:required="course.paid_certificate"
:required="courseResource.doc.paid_certificate"
:placeholder="__('e.g. IST, UTC, GMT...')"
@input="makeFormDirty()"
/>
</div>
</div>
</div>
<div class="px-5 md:px-10 pb-5 space-y-5">
<div class="pr-5 md:pr-10 pb-5 space-y-5">
<div class="text-lg font-semibold mt-5 text-ink-gray-9">
{{ __('Meta Tags') }}
</div>
@@ -256,6 +276,7 @@
:label="__('Meta Description')"
type="textarea"
:rows="7"
@input="makeFormDirty()"
/>
<FormControl
v-model="meta.keywords"
@@ -263,16 +284,17 @@
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.data"
:courseName="courseResource.data.name"
:title="__('Course Outline')"
v-if="courseResource.doc"
:courseName="courseResource.doc.name"
:title="__('Chapters')"
:allowEdit="true"
/>
</div>
@@ -281,10 +303,10 @@
</template>
<script setup>
import {
Breadcrumbs,
TextEditor,
Button,
createResource,
createDocumentResource,
FormControl,
usePageMeta,
toast,
@@ -293,7 +315,6 @@ import {
inject,
onMounted,
onBeforeUnmount,
computed,
ref,
reactive,
watch,
@@ -308,8 +329,7 @@ import {
} from '@/utils'
import { Trash2, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import { sessionStore } from '../../stores/session'
import Link from '@/components/Controls/Link.vue'
import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
@@ -323,39 +343,15 @@ const router = useRouter()
const instructors = ref([])
const related_courses = ref([])
const app = getCurrentInstance()
const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
const { $dialog } = app.appContext.config.globalProperties
const isDirty = ref(false)
const props = defineProps({
courseName: {
type: String,
course: {
type: Object,
},
})
const course = reactive({
title: '',
short_introduction: '',
description: '',
video_link: '',
image: null,
card_gradient: '',
tags: '',
category: '',
published: false,
published_on: '',
featured: false,
upcoming: false,
disable_self_learning: false,
enable_certification: false,
paid_course: false,
paid_certificate: false,
course_price: '',
currency: '',
evaluator: '',
timezone: '',
})
const meta = reactive({
description: '',
keywords: '',
@@ -365,18 +361,92 @@ onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' })
}
if (props.courseName !== 'new') {
fetchCourseInfo()
} else {
capture('course_form_opened')
}
window.addEventListener('keydown', keyboardShortcut)
})
const fetchCourseInfo = () => {
courseResource.reload()
getMetaInfo('courses', props.courseName, meta)
const courseResource = createDocumentResource({
doctype: 'LMS Course',
name: props.course.data?.name,
auto: true,
})
watch(
() => courseResource.doc,
() => {
check_permission()
getMetaInfo('courses', courseResource.doc?.name, meta)
updateCourseData()
}
)
const updateCourseData = () => {
Object.keys(courseResource.doc).forEach((key) => {
if (key == 'instructors') {
instructors.value = []
courseResource.doc.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (key == 'related_courses') {
related_courses.value = []
courseResource.doc.related_courses.forEach((course) => {
related_courses.value.push(course.course)
})
}
})
let checkboxes = [
'published',
'upcoming',
'disable_self_learning',
'paid_course',
'featured',
'enable_certification',
'paid_certificate',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
courseResource.doc[key] = courseResource.doc[key] ? true : false
}
}
const submitCourse = () => {
validateFields()
updateCourse()
}
const validateFields = () => {
courseResource.doc.description = sanitizeHTML(courseResource.doc.description)
Object.keys(courseResource.doc).forEach((key) => {
if (key != 'description' && typeof courseResource.doc[key] === 'string') {
courseResource.doc[key] = escapeHTML(courseResource.doc[key])
}
})
}
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'))
isDirty.value = false
courseResource.reload()
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
}
const keyboardShortcut = (e) => {
@@ -394,151 +464,11 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const courseCreationResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Course',
image: course.image,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
...values,
},
}
},
})
const courseEditResource = createResource({
url: 'frappe.client.set_value',
auto: false,
makeParams(values) {
return {
doctype: 'LMS Course',
name: values.course,
fieldname: {
image: course.image,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
...course,
},
}
},
})
const courseResource = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Course',
name: props.courseName,
}
},
auto: false,
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (key == 'instructors') {
instructors.value = []
data.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (key == 'related_courses') {
related_courses.value = []
data.related_courses.forEach((course) => {
related_courses.value.push(course.course)
})
} else if (Object.hasOwn(course, key)) course[key] = data[key]
})
let checkboxes = [
'published',
'upcoming',
'disable_self_learning',
'paid_course',
'featured',
'enable_certification',
'paid_certificate',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
course[key] = course[key] ? true : false
}
check_permission()
},
})
const validateFields = () => {
course.description = sanitizeHTML(course.description)
Object.keys(course).forEach((key) => {
if (key != 'description' && typeof course[key] === 'string') {
course[key] = escapeHTML(course[key])
}
})
}
const submitCourse = () => {
validateFields()
if (courseResource.data) {
editCourse()
} else {
createCourse()
}
}
const createCourse = () => {
courseCreationResource.submit(course, {
onSuccess(data) {
updateMetaInfo('courses', data.name, meta)
if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_course', true, false, () => {
localStorage.setItem('firstCourse', data.name)
})
}
capture('course_created')
toast.success(__('Course created successfully'))
router.push({
name: 'CourseForm',
params: { courseName: data.name },
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
})
}
const editCourse = () => {
courseEditResource.submit(
{
course: courseResource.data.name,
},
{
onSuccess() {
updateMetaInfo('courses', props.courseName, meta)
toast.success(__('Course updated successfully'))
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const deleteCourse = createResource({
url: 'lms.lms.api.delete_course',
makeParams(values) {
return {
course: props.courseName,
course: courseResource.doc?.name,
}
},
onSuccess() {
@@ -567,28 +497,23 @@ const trashCourse = () => {
})
}
watch(
() => props.courseName !== 'new',
(newVal) => {
if (newVal) {
fetchCourseInfo()
}
}
)
const updateTags = () => {
if (newTag.value) {
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
courseResource.doc.tags = courseResource.doc.tags
? `${courseResource.doc.tags}, ${newTag.value}`
: newTag.value
newTag.value = ''
makeFormDirty()
}
}
const removeTag = (tag) => {
course.tags = course.tags
courseResource.doc.tags = courseResource.doc.tags
?.split(', ')
.filter((t) => t !== tag)
.join(', ')
newTag.value = ''
makeFormDirty()
}
const check_permission = () => {
@@ -606,30 +531,20 @@ const check_permission = () => {
}
}
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'Courses',
route: { name: 'Courses' },
},
]
if (courseResource.data) {
crumbs.push({
label: course.title,
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
})
}
crumbs.push({
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
route: { name: 'CourseForm', params: { courseName: props.courseName } },
})
return crumbs
})
const makeFormDirty = () => {
isDirty.value = true
}
usePageMeta(() => {
return {
title: courseResource.data?.title || __('New Course'),
title: courseResource.doc?.title,
icon: brand.favicon,
}
})
defineExpose({
submitCourse,
trashCourse,
isDirty,
})
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div class="p-5">
<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 }}
</div>
<div class="my-3 leading-6 text-ink-gray-7">
{{ course.data.short_introduction }}
</div>
<div class="flex items-center">
<Tooltip
v-if="parseInt(course.data.rating) > 0"
:text="__('Average Rating')"
class="flex items-center"
>
<Star class="size-4 text-transparent fill-yellow-500" />
<span class="ml-1 text-ink-gray-7">
{{ course.data.rating }}
</span>
</Tooltip>
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
>&middot;</span
>
<Tooltip
v-if="course.data.enrollment_count"
:text="__('Enrolled Students')"
class="flex items-center"
>
<Users class="h-4 w-4 text-ink-gray-7" />
<span class="ml-1">
{{ course.data.enrollment_count_formatted }}
</span>
</Tooltip>
<span v-if="course.data.enrollment_count" class="mx-3">&middot;</span>
<div class="flex items-center">
<span
class="h-6 mr-1"
:class="{
'avatar-group overlap': course.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in course.data.instructors"
:user="instructor"
/>
</span>
<CourseInstructors :instructors="course.data.instructors" />
</div>
</div>
<div v-if="course.data.tags" class="flex my-4 w-fit">
<Badge
theme="gray"
size="lg"
class="mr-2 text-ink-gray-9"
v-for="tag in course.data.tags.split(', ')"
>
{{ tag }}
</Badge>
</div>
<div class="md:hidden my-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"
></div>
<div class="mt-10">
<CourseOutline
:title="__('Course Outline')"
:courseName="course.data.name"
:showOutline="true"
:getProgress="course.data.membership ? true : false"
/>
</div>
<CourseReviews
:courseName="course.data.name"
:avg_rating="course.data.rating"
:membership="course.data.membership"
/>
</div>
<div class="hidden md:block">
<CourseCardOverlay :course="course" />
</div>
</div>
<RelatedCourses :courseName="course.data.name" />
</div>
</template>
<script setup lang="ts">
import { Star, Users } from 'lucide-vue-next'
import { Badge, Tooltip } from 'frappe-ui'
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
import CourseOutline from '@/components/CourseOutline.vue'
import CourseReviews from '@/components/CourseReviews.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import RelatedCourses from '@/components/RelatedCourses.vue'
const props = defineProps<{
course: any
}>()
</script>

View File

@@ -5,7 +5,7 @@
<Breadcrumbs :items="breadcrumbs" />
<Dropdown
placement="start"
placement="right"
side="bottom"
v-if="canCreateCourse()"
:options="[
@@ -13,10 +13,7 @@
label: __('New Course'),
icon: 'book-open',
onClick() {
router.push({
name: 'CourseForm',
params: { courseName: 'new' },
})
showCourseModal = true
},
},
{
@@ -109,6 +106,11 @@
</Button>
</div>
</div>
<NewCourseModal
v-if="showCourseModal"
v-model="showCourseModal"
:courses="courses"
/>
</template>
<script setup>
import {
@@ -128,13 +130,19 @@ import { sessionStore } from '@/stores/session'
import { canCreateCourse } from '@/utils'
import CourseCard from '@/components/CourseCard.vue'
import EmptyState from '@/components/EmptyState.vue'
import router from '../router'
import { useRouter } from 'vue-router'
import NewCourseModal from '@/pages/Courses/NewCourseModal.vue'
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)
@@ -142,17 +150,13 @@ const filters = ref({})
const currentTab = ref('Live')
const { brand } = sessionStore()
const courseCount = ref(0)
const router = useRouter()
const showCourseModal = ref(false)
onMounted(() => {
setFiltersFromQuery()
updateCourses()
getCourseCount()
categories.value = [
{
label: '',
value: null,
},
]
})
const setFiltersFromQuery = () => {
@@ -160,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({
@@ -168,9 +175,6 @@ const courses = createListResource({
cache: ['courses', user.data?.name],
pageLength: pageLength.value,
start: start.value,
onSuccess(data) {
setCategories(data)
},
})
const setCategories = (data) => {
@@ -205,7 +209,7 @@ const identifyUserPersona = async () => {
const getCourseCount = () => {
if (!user.data) return
if (!user.data.is_moderator) return
call('frappe.client.get_count', {
doctype: 'LMS Course',
}).then((data) => {
@@ -219,7 +223,9 @@ const updateCourses = () => {
courses.update({
filters: filters.value,
})
courses.reload()
courses.reload().then((data) => {
setCategories(data)
})
}
const updateFilters = () => {

View File

@@ -0,0 +1,156 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Create Course'),
size: '3xl',
}"
>
<template #body-content>
<div class="text-base">
<div class="grid grid-cols-2 gap-5 border-b mb-5">
<FormControl
v-model="course.title"
:label="__('Title')"
:required="true"
/>
<Link
doctype="LMS Category"
v-model="course.category"
:label="__('Category')"
:allowCreate="true"
@create="
() => {
openSettings('Categories')
show = false
}
"
/>
<MultiSelect
v-model="course.instructors"
doctype="User"
:label="__('Instructors')"
:filters="{ ignore_user_type: 1 }"
:onCreate="(close: () => void) => openSettings('Members', close)"
:required="true"
/>
<Uploader
v-model="course.image"
:label="__('Course Image')"
:required="false"
/>
</div>
<div class="space-y-4">
<FormControl
v-model="course.short_introduction"
:label="__('Short Introduction')"
type="textarea"
:required="true"
:rows="4"
/>
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Course Description') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="course.description"
@change="(val: string) => (course.description = 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-[10rem]"
/>
</div>
</div>
</div>
</template>
<template #actions="{ close }">
<div class="text-right">
<Button variant="solid" @click="saveCourse(close)">
{{ __('Create') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { Link, useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { openSettings } from '@/utils'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Uploader from '@/components/Controls/Uploader.vue'
const show = defineModel<boolean>({ required: true, default: false })
const router = useRouter()
const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
const user = inject<any>('$user')
const props = defineProps<{
courses: any
}>()
const course = ref({
title: '',
short_introduction: '',
description: '',
instructors: [],
category: null,
image: null,
})
const saveCourse = (close: () => void = () => {}) => {
props.courses.insert.submit(
{
...course.value,
instructors: course.value.instructors.map((instructor) => ({
instructor: instructor,
})),
},
{
onSuccess(data: any) {
toast.success(__('Course created successfully'))
close()
capture('course_created')
router.push({
name: 'CourseDetail',
params: { courseName: data.name },
hash: '#settings',
})
if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_course', true, false, () => {
localStorage.setItem('firstCourse', data.name)
})
}
},
}
)
}
const keyboardShortcut = (e: KeyboardEvent) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
e.target &&
e.target instanceof HTMLElement &&
!e.target.classList.contains('ProseMirror')
) {
saveCourse()
e.preventDefault()
}
}
onMounted(() => {
window.addEventListener('keydown', keyboardShortcut)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
watch(show, () => {
capture('course_form_opened')
})
</script>

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

@@ -86,7 +86,7 @@
<Uploader
v-model="job.company_logo"
:label="__('Company Logo')"
:required="false"
:required="true"
/>
</div>
</div>

View File

@@ -51,12 +51,12 @@
class="hidden lg:block"
@change="updateJobs"
/>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center space-x-4">
<FormControl
type="text"
:placeholder="__('Search')"
v-model="searchQuery"
class="w-full max-w-40"
class="w-full"
@input="updateJobs"
>
<template #prefix>
@@ -79,17 +79,17 @@
v-model="jobType"
type="select"
:options="jobTypes"
class="w-full"
class="w-full min-w-32"
:placeholder="__('Type')"
@change="updateJobs"
@update:modelValue="updateJobs"
/>
<FormControl
v-model="workMode"
type="select"
:options="workModes"
class="w-full"
class="w-full min-w-32"
:placeholder="__('Work Mode')"
@change="updateJobs"
@update:modelValue="updateJobs"
/>
</div>
</div>
@@ -218,13 +218,13 @@ const updateJobs = () => {
const updateFilters = () => {
filters.value.status = 'Open'
if (jobType.value) {
if (jobType.value && jobType.value !== ' ') {
filters.value.type = jobType.value
} else {
delete filters.value.type
}
if (workMode.value) {
if (workMode.value && workMode.value !== ' ') {
filters.value.work_mode = workMode.value
} else {
delete filters.value.work_mode
@@ -271,7 +271,7 @@ watch(jobs, () => {
const jobTypes = computed(() => {
return [
{ label: '', value: '' },
{ label: ' ', value: ' ' },
{ label: __('Full Time'), value: 'Full Time' },
{ label: __('Part Time'), value: 'Part Time' },
{ label: __('Contract'), value: 'Contract' },
@@ -281,7 +281,7 @@ const jobTypes = computed(() => {
const workModes = computed(() => {
return [
{ label: '', value: '' },
{ label: ' ', value: ' ' },
{ label: 'On site', value: 'On-site' },
{ label: 'Hybrid', value: 'Hybrid' },
{ label: 'Remote', value: 'Remote' },

View File

@@ -326,6 +326,7 @@
@updateNotes="updateNotes"
/>
<VideoStatistics
v-if="showStatsDialog"
v-model="showStatsDialog"
:lessonName="lesson.data?.name"
:lessonTitle="lesson.data?.title"
@@ -871,6 +872,7 @@ const scrollDiscussionsIntoView = () => {
}
const updateNotes = () => {
if (!user.data) return
notes.update({
filters: {
lesson: lesson.data?.name,

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

@@ -26,7 +26,7 @@
class="flex space-x-2 px-2 py-4"
:class="{
'cursor-pointer': log.link,
'items-center': !showDetails(log) && !isMention(log),
'items-center': !showDetails(log) && !isMentionOrComment(log),
}"
@click="navigateToPage(log)"
>
@@ -56,9 +56,9 @@
</div>
</div>
<div
v-if="isMention(log)"
v-if="isMentionOrComment(log)"
v-html="log.email_content"
class="bg-surface-gray-2 rounded-md px-3 py-2"
class="bg-surface-gray-2 rounded-md px-3 py-2 line-clamp-3 overflow-hidden"
></div>
<div
v-else-if="showDetails(log)"
@@ -185,10 +185,9 @@ const unReadNotifications = createListResource({
doctype: 'Notification Log',
url: 'lms.lms.api.get_notifications',
filters: {
for_user: user.data?.name,
read: 0,
},
auto: true,
auto: user.data ? true : false,
cache: 'Unread Notifications',
})
@@ -196,18 +195,17 @@ const readNotifications = createListResource({
doctype: 'Notification Log',
url: 'lms.lms.api.get_notifications',
filters: {
for_user: user.data?.name,
read: 1,
},
auto: true,
auto: user.data ? true : false,
cache: 'Read Notifications',
})
const markAsRead = createResource({
url: 'lms.lms.api.mark_as_read',
url: 'frappe.desk.doctype.notification_log.notification_log.mark_as_read',
makeParams(values) {
return {
name: values.name,
docname: values.name,
}
},
onSuccess(data) {
@@ -217,7 +215,7 @@ const markAsRead = createResource({
})
const markAllAsRead = createResource({
url: 'lms.lms.api.mark_all_as_read',
url: 'frappe.desk.doctype.notification_log.notification_log.mark_all_as_read',
onSuccess(data) {
unReadNotifications.reload()
readNotifications.reload()
@@ -260,7 +258,7 @@ const navigateToPage = (log) => {
}
}
const isMention = (log) => {
const isMentionOrComment = (log) => {
if (log.type == 'Mention') {
return true
}

View File

@@ -65,8 +65,8 @@
<Tooltip
v-if="profile.data.open_to"
:text="
profile.data.open_to === 'Opportunities'
? __('Open to Opportunities')
profile.data.open_to === 'Work'
? __('Open to Work')
: __('Hiring')
"
placement="right"
@@ -77,7 +77,7 @@
<div
class="rounded-full w-fit"
:class="
profile.data.open_to === 'Opportunities'
profile.data.open_to === 'Work'
? 'bg-surface-green-3'
: 'bg-purple-500'
"

View File

@@ -226,7 +226,6 @@ import {
onMounted,
inject,
onBeforeUnmount,
watch,
} from 'vue'
import { sessionStore } from '../stores/session'
import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next'
@@ -252,7 +251,9 @@ const props = defineProps({
},
})
const questions = ref([])
const questions = computed(() => {
return quizDetails.doc?.questions || []
})
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
@@ -273,24 +274,10 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
watch(
() => props.quizID !== 'new',
(newVal) => {
if (newVal) {
quizDetails.reload()
}
}
)
const quizDetails = createDocumentResource({
doctype: 'LMS Quiz',
name: props.quizID,
auto: false,
onSuccess(doc) {
if (doc.questions && doc.questions.length > 0) {
questions.value = doc.questions.map((question) => question)
}
},
})
const validateTitle = () => {

View File

@@ -116,20 +116,30 @@ const debouncedSaveProgress = (scormDetails) => {
}
const saveDataToLMS = (key, value) => {
if (key === 'cmi.core.lesson_status') {
if (value === 'passed') {
isSuccessfullyCompleted.value = true
saveProgress({
is_complete: isSuccessfullyCompleted.value,
scorm_content: '',
})
} else if (value === 'failed' && courseRestartOnFailure) {
saveProgress({
is_complete: isSuccessfullyCompleted.value,
scorm_content: '',
})
}
} else if (key === 'cmi.suspend_data' && !isSuccessfullyCompleted.value) {
const isLessonStatus = key === 'cmi.core.lesson_status' && value === 'passed'
const isCompletionStatus =
key === 'cmi.completion_status' && value === 'completed'
const shouldRestart =
(key === 'cmi.core.lesson_status' && value === 'failed') ||
(key === 'cmi.completion_status' && value === 'incomplete')
if (isLessonStatus || isCompletionStatus) {
isSuccessfullyCompleted.value = true
}
if (
isLessonStatus ||
isCompletionStatus ||
(shouldRestart && courseRestartOnFailure)
) {
saveProgress({
is_complete: isSuccessfullyCompleted.value,
scorm_content: '',
})
return
}
if (key === 'cmi.suspend_data' && !isSuccessfullyCompleted.value) {
debouncedSaveProgress({
is_complete: false,
scorm_content: value,