Merge branch 'develop' of https://github.com/frappe/lms into demo-data

This commit is contained in:
Jannat Patel
2026-03-04 20:53:22 +05:30
43 changed files with 1523 additions and 222 deletions
+2
View File
@@ -60,6 +60,8 @@ declare module 'vue' {
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
GoogleMeetAccountModal: typeof import('./src/components/Settings/GoogleMeetAccountModal.vue')['default']
GoogleMeetSettings: typeof import('./src/components/Settings/GoogleMeetSettings.vue')['default']
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
InlineLessonMenu: typeof import('./src/components/Notes/InlineLessonMenu.vue')['default']
+9 -5
View File
@@ -419,11 +419,15 @@ const canGradeSubmission = computed(() => {
})
const canModifyAssignment = computed(() => {
return (
!submissionResource.doc ||
(submissionResource.doc?.owner == user.data?.name &&
submissionResource.doc?.status == 'Not Graded')
)
if (canGradeSubmission.value) {
return true
} else if (
submissionResource.doc?.owner == user.data?.name &&
submissionResource.doc?.status == 'Not Graded'
) {
return true
}
return false
})
const submissionStatusOptions = computed(() => {
+2 -2
View File
@@ -3,13 +3,13 @@
<div class="space-y-2">
<div class="flex items-center text-sm font-medium space-x-2">
<span>
{{ __('What does include in preview mean?') }}
{{ __('What are Instructor Notes?') }}
</span>
</div>
<div class="text-xs text-ink-gray-5 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.'
'Instructor Notes are private notes that only instructors can see. They can be used to provide additional context or guidance for the lesson.'
)
}}
</div>
@@ -16,7 +16,12 @@
>
<template #body-content>
<div class="space-y-4 text-base">
<FormControl label="Title" v-model="chapter.title" :required="true" />
<FormControl
label="Title"
v-model="chapter.title"
:required="true"
autocomplete="off"
/>
<Switch
size="sm"
:label="__('SCORM Package')"
@@ -67,6 +67,7 @@
/>
</div>
<FormControl
v-if="props.conferencingProvider === 'Zoom'"
v-model="liveClass.auto_recording"
type="select"
:options="getRecordingOptions()"
@@ -99,10 +100,9 @@ const props = defineProps({
type: String,
required: true,
},
zoomAccount: {
type: String,
required: true,
},
zoomAccount: String,
googleMeetAccount: String,
conferencingProvider: String,
})
let liveClass = reactive({
@@ -159,8 +159,23 @@ const createLiveClass = createResource({
},
})
const createGoogleMeetLiveClass = createResource({
url: 'lms.lms.doctype.lms_batch.lms_batch.create_google_meet_live_class',
makeParams(values) {
return {
batch_name: values.batch,
google_meet_account: props.googleMeetAccount,
...values,
}
},
})
const submitLiveClass = (close) => {
return createLiveClass.submit(liveClass, {
const resource =
props.conferencingProvider === 'Google Meet'
? createGoogleMeetLiveClass
: createLiveClass
return resource.submit(liveClass, {
validate() {
validateFormFields()
},
@@ -1,9 +1,14 @@
<template>
<div class="flex flex-col h-full">
<div class="flex flex-col h-full text-p-base">
<div>
<div class="flex items-center justify-between">
<div class="font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
<div class="space-y-2">
<div class="font-semibold text-xl text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="space-x-2">
<Badge
@@ -21,9 +26,6 @@
</Button>
</div>
</div>
<div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div>
</div>
<div class="overflow-y-auto">
<SettingFields :sections="sections" :data="branding.data" />
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
@@ -10,7 +10,7 @@
</div>
</div>
<div class="flex item-center space-x-2">
<Button variant="solid" @click="() => (showForm = !showForm)">
<Button @click="() => (showForm = !showForm)">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
@@ -0,0 +1,197 @@
<template>
<Dialog
v-model="show"
:options="{
title:
accountID === 'new'
? __('New Google Meet Account')
: __('Edit Google Meet Account'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveAccount(close)
},
},
],
}"
>
<template #body-content>
<div class="mb-4">
<FormControl
v-model="account.enabled"
:label="__('Enabled')"
type="checkbox"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="account.name"
:label="__('Account Name')"
type="text"
:required="true"
/>
<Link
v-model="account.member"
:label="__('Member')"
doctype="Course Evaluator"
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
:required="true"
/>
<Link
v-model="account.google_calendar"
:label="__('Google Calendar')"
doctype="Google Calendar"
:required="true"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, toast } from 'frappe-ui'
import { inject, reactive, watch } from 'vue'
import { User } from '@/components/Settings/types'
import { openSettings, cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
import { useTelemetry } from 'frappe-ui/frappe'
interface GoogleMeetAccount {
name: string
account_name: string
enabled: boolean
member: string
google_calendar: string
}
interface GoogleMeetAccounts {
data: GoogleMeetAccount[]
reload: () => void
insert: {
submit: (
data: GoogleMeetAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
setValue: {
submit: (
data: GoogleMeetAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
}
const show = defineModel('show')
const user = inject<User | null>('$user')
const googleMeetAccounts = defineModel<GoogleMeetAccounts>('googleMeetAccounts')
const { capture } = useTelemetry()
const account = reactive({
name: '',
enabled: false,
member: user?.data?.name || '',
google_calendar: '',
})
const props = defineProps({
accountID: {
type: String,
default: 'new',
},
})
watch(
() => props.accountID,
(val) => {
if (val === 'new') {
account.name = ''
account.enabled = false
account.member = user?.data?.name || ''
account.google_calendar = ''
} else if (val && val !== 'new') {
const acc = googleMeetAccounts.value?.data.find((acc) => acc.name === val)
if (acc) {
account.name = acc.name
account.enabled = acc.enabled || false
account.member = acc.member
account.google_calendar = acc.google_calendar
}
}
}
)
const saveAccount = (close: () => void) => {
if (props.accountID == 'new') {
createAccount(close)
} else {
updateAccount(close)
}
}
const createAccount = (close: () => void) => {
googleMeetAccounts.value?.insert.submit(
{
account_name: account.name,
...account,
},
{
onSuccess() {
capture('google_meet_account_linked')
googleMeetAccounts.value?.reload()
close()
toast.success(__('Google Meet Account created successfully'))
},
onError(err) {
console.error(err)
close()
toast.error(
cleanError(err.messages[0]) ||
__('Error creating Google Meet Account')
)
},
}
)
}
const updateAccount = async (close: () => void) => {
if (props.accountID != account.name) {
await renameDoc()
}
setValue(close)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'LMS Google Meet Settings',
old_name: props.accountID,
new_name: account.name,
})
}
const setValue = (close: () => void) => {
googleMeetAccounts.value?.setValue.submit(
{
...account,
name: account.name,
account_name: props.accountID,
},
{
onSuccess() {
googleMeetAccounts.value?.reload()
close()
toast.success(__('Google Meet Account updated successfully'))
},
onError(err: any) {
console.error(err)
close()
toast.error(
cleanError(err.messages[0]) ||
__('Error updating Google Meet Account')
)
},
}
)
}
</script>
@@ -0,0 +1,202 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="flex items-center space-x-5">
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div v-if="googleMeetAccounts.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="googleMeetAccounts.data"
row-key="name"
:options="{
showTooltip: false,
onRowClick: (row) => {
openForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in googleMeetAccounts.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="green">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAccount(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
<GoogleMeetAccountModal
v-model="showForm"
v-model:googleMeetAccounts="googleMeetAccounts"
:accountID="currentAccount"
/>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
Badge,
call,
createListResource,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import { User } from '@/components/Settings/types'
import GoogleMeetAccountModal from '@/components/Settings/GoogleMeetAccountModal.vue'
const user = inject<User | null>('$user')
const showForm = ref(false)
const currentAccount = ref<string | null>(null)
const props = defineProps({
label: String,
description: String,
})
const googleMeetAccounts = createListResource({
doctype: 'LMS Google Meet Settings',
fields: [
'name',
'enabled',
'member',
'member_name',
'member_image',
'google_calendar',
],
cache: ['googleMeetAccounts'],
})
onMounted(() => {
fetchGoogleMeetAccounts()
})
const fetchGoogleMeetAccounts = () => {
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
if (!user?.data?.is_moderator) {
googleMeetAccounts.update({
filters: {
member: user.data.name,
},
})
}
googleMeetAccounts.reload()
}
const openForm = (accountID: string) => {
currentAccount.value = accountID
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'LMS Google Meet Settings',
documents: Array.from(selections),
})
.then(() => {
googleMeetAccounts.reload()
toast.success(__('Google Meet Account deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting Google Meet Account')
)
})
}
const columns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
icon: 'user',
},
{
label: __('Account Name'),
key: 'name',
icon: 'video',
},
{
label: __('Status'),
key: 'enabled',
align: 'center',
icon: 'check-square',
},
]
})
</script>
+2 -2
View File
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
@@ -10,7 +10,7 @@
</div>
</div>
<div class="flex item-center space-x-2">
<Button variant="solid" @click="() => (showForm = !showForm)">
<Button @click="() => (showForm = !showForm)">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
@@ -131,7 +131,7 @@ watch(newGateway, () => {
let fields = gatewayFields.data || []
arrangeFields(fields)
newGatewayFields.value = makeSections(fields)
prepareGatewayData()
prepareGatewayData(fields)
})
})
@@ -209,13 +209,11 @@ const allGatewayOptions = computed(() => {
return options.map((gateway: string) => ({ label: gateway, value: gateway }))
})
const prepareGatewayData = () => {
const prepareGatewayData = (fields: any[]) => {
newGatewayData.value = {}
if (newGatewayFields.value.length) {
newGatewayFields.value.forEach((field: any) => {
newGatewayData.value[field.fieldname] = field.default || ''
})
}
fields.forEach((field: any) => {
newGatewayData.value[field.name] = field.default || ''
})
}
const makeSections = (fields: any[]) => {
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
@@ -88,6 +88,7 @@
import {
Badge,
Button,
call,
createListResource,
FeatherIcon,
ListView,
@@ -97,10 +98,12 @@ import {
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import PaymentGatewayDetails from '@/components/Settings/PaymentGatewayDetails.vue'
import { cleanError } from '@/utils'
const showForm = ref(false)
const currentGateway = ref(null)
@@ -128,6 +131,23 @@ const openForm = (gatewayID) => {
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'Payment Gateway',
documents: Array.from(selections),
})
.then(() => {
paymentGateways.reload()
toast.success(__('Payment gateways deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting payment gateways')
)
})
}
const columns = computed(() => {
return [
{
@@ -2,10 +2,13 @@
<div class="flex flex-col h-full text-base overflow-y-hidden">
<div class="">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold leading-none text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="space-x-2">
<Badge
@@ -19,9 +22,6 @@
</Button>
</div>
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<SettingFields :sections="sections" :data="data.doc" />
@@ -20,6 +20,7 @@
:doctype="field.doctype"
:label="__(field.label)"
:description="__(field.description)"
:required="field.reqd"
/>
<div v-else-if="field.type == 'Code'">
@@ -115,6 +116,7 @@
:rows="field.rows"
:options="field.options"
:description="field.description"
:required="field.reqd"
placeholder=""
/>
</div>
+48 -28
View File
@@ -76,6 +76,7 @@ import PaymentGateways from '@/components/Settings/PaymentGateways.vue'
import Coupons from '@/components/Settings/Coupons/Coupons.vue'
import Transactions from '@/components/Settings/Transactions/Transactions.vue'
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
import GoogleMeetSettings from '@/components/Settings/GoogleMeetSettings.vue'
import Badges from '@/components/Settings/Badges.vue'
const show = defineModel()
@@ -268,34 +269,6 @@ const tabsStructure = computed(() => {
},
],
},
],
},
{
label: 'Lists',
hideLabel: false,
items: [
{
label: 'Members',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'UserRoundPlus',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: '',
icon: 'UserCheck',
description:
'Add new evaluators or check the slots existing evaluators',
template: markRaw(Evaluators),
},
{
label: 'Zoom Accounts',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Badges',
description:
@@ -317,6 +290,27 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Users',
hideLabel: false,
items: [
{
label: 'Members',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'User',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: '',
icon: 'UserCircle2',
description:
'Add new evaluators or check the slots of existing evaluators',
template: markRaw(Evaluators),
},
],
},
{
label: 'Payment',
hideLabel: false,
@@ -387,6 +381,26 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Conferencing',
hideLabel: false,
items: [
{
label: 'Zoom',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Google Meet',
description:
'Manage Google Meet accounts to conduct live classes from batches',
icon: 'Presentation',
template: markRaw(GoogleMeetSettings),
},
],
},
{
label: 'Customize',
hideLabel: false,
@@ -394,6 +408,8 @@ const tabsStructure = computed(() => {
{
label: 'Branding',
icon: 'Blocks',
description:
'Customize the brand name and logo to make the application your own',
template: markRaw(BrandSettings),
sections: [
{
@@ -482,6 +498,8 @@ const tabsStructure = computed(() => {
{
label: 'Signup',
icon: 'LogIn',
description:
'Manage the settings related to user signup and registration',
sections: [
{
columns: [
@@ -517,6 +535,8 @@ const tabsStructure = computed(() => {
{
label: 'SEO',
icon: 'Search',
description:
'Manage the SEO settings to improve your website ranking on search engines',
sections: [
{
columns: [
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
+61 -20
View File
@@ -165,24 +165,48 @@
"
/>
</div>
<div class="space-y-5">
<Link
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batchDetail.doc.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
<Uploader
v-model="batchDetail.doc.video_link"
:label="__('Preview Video')"
type="video"
:required="false"
/>
</div>
<Uploader
v-model="batchDetail.doc.video_link"
:label="__('Preview Video')"
type="video"
:required="false"
/>
</div>
</div>
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Conferencing') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormControl
v-model="batchDetail.doc.conferencing_provider"
type="select"
:options="conferencingOptions"
:label="__('Conferencing Provider')"
/>
<Link
v-if="batchDetail.doc.conferencing_provider === 'Zoom'"
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batchDetail.doc.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
<Link
v-if="batchDetail.doc.conferencing_provider === 'Google Meet'"
doctype="LMS Google Meet Settings"
:label="__('Google Meet Account')"
v-model="batchDetail.doc.google_meet_account"
:onCreate="
(value, close) => {
openSettings('Google Meet Accounts', close)
}
"
/>
</div>
</div>
@@ -463,14 +487,31 @@ const trashBatch = (close) => {
})
}
const conferencingOptions = computed(() => {
return [
{
label: '',
value: '',
},
{
label: __('Zoom'),
value: 'Zoom',
},
{
label: __('Google Meet'),
value: 'Google Meet',
},
]
})
const mediumOptions = computed(() => {
return [
{
label: 'Online',
label: __('Online'),
value: 'Online',
},
{
label: 'Offline',
label: __('Offline'),
value: 'Offline',
},
]
@@ -1,14 +1,14 @@
<template>
<div class="p-5">
<div
v-if="isAdmin() && !batch.data?.zoom_account"
v-if="isAdmin() && !hasProviderAccount()"
class="flex lg:items-center space-x-2 mb-5 bg-surface-amber-1 px-3 py-2 rounded-lg text-ink-amber-3"
>
<AlertCircle class="size-7 md:size-4 stroke-1.5" />
<span class="leading-5">
{{
__(
'Link a Zoom account to this batch from the Settings tab to create live classes'
'Please select a conferencing provider and add an account to the batch to create live classes.'
)
}}
</span>
@@ -64,12 +64,12 @@
</span>
</div>
<div
v-if="canAccessClass(cls)"
v-if="canAccessClass(cls) && cls.join_url"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
:href="cls.start_url || cls.join_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
@@ -111,6 +111,8 @@
v-model="showLiveClassModal"
:batch="batch.data?.name"
:zoomAccount="batch.data?.zoom_account"
:googleMeetAccount="batch.data?.google_meet_account"
:conferencingProvider="batch.data?.conferencing_provider"
v-model:reloadLiveClasses="liveClasses"
/>
@@ -165,6 +167,8 @@ const liveClasses = createListResource({
'start_url',
'join_url',
'owner',
'conferencing_provider',
'batch_name',
],
orderBy: 'date',
auto: true,
@@ -174,9 +178,20 @@ const openLiveClassModal = () => {
showLiveClassModal.value = true
}
const hasProviderAccount = () => {
const data = props.batch.data
if (data?.conferencing_provider === 'Zoom' && data?.zoom_account) return true
if (
data?.conferencing_provider === 'Google Meet' &&
data?.google_meet_account
)
return true
return false
}
const canCreateClass = () => {
if (readOnlyMode) return false
if (!props.batch.data?.zoom_account) return false
if (!hasProviderAccount()) return false
return isAdmin()
}
@@ -209,8 +224,8 @@ const hasClassEnded = (cls) => {
const openAttendanceModal = (cls) => {
if (!isAdmin()) return
if (cls.attendees <= 0) return
showAttendance.value = true
attendanceFor.value = cls
showAttendance.value = true
}
</script>
<style>
@@ -8,81 +8,86 @@
>
<template #body-content>
<div class="text-base">
<div class="grid grid-cols-2 gap-10">
<div class="space-y-5">
<FormControl
v-model="batch.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="batch.start_date"
:label="__('Start Date')"
type="date"
:required="true"
/>
<FormControl
v-model="batch.end_date"
:label="__('End Date')"
type="date"
:required="true"
/>
<Link
doctype="LMS Category"
v-model="batch.category"
:label="__('Category')"
:allowCreate="true"
:onCreate="
() => {
openSettings('Categories')
show = false
}
"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
type="time"
:required="true"
/>
<FormControl
v-model="batch.end_time"
:label="__('End Time')"
type="time"
:required="true"
/>
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
:required="true"
/>
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
:required="false"
/>
</div>
<div class="grid grid-cols-3 gap-5">
<FormControl
v-model="batch.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="batch.start_date"
:label="__('Start Date')"
type="date"
:required="true"
/>
<FormControl
v-model="batch.end_date"
:label="__('End Date')"
type="date"
:required="true"
/>
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
type="time"
:required="true"
/>
<FormControl
v-model="batch.end_time"
:label="__('End Time')"
type="time"
:required="true"
/>
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
:required="true"
/>
<Link
doctype="LMS Category"
v-model="batch.category"
:label="__('Category')"
:allowCreate="true"
:onCreate="
() => {
openSettings('Categories')
show = false
}
"
/>
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
:required="false"
/>
<FormControl
v-model="batch.medium"
type="select"
:options="mediumOptions"
:label="__('Medium')"
class="mb-4"
/>
</div>
<div class="space-y-5 border-t mt-5 pt-5">
<MultiSelect
v-model="batch.instructors"
doctype="Course Evaluator"
:label="__('Instructors')"
:required="true"
:onCreate="(close: () => void) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }"
/>
<FormControl
v-model="batch.description"
:label="__('Description')"
type="textarea"
:required="true"
:rows="4"
/>
<div class="grid grid-cols-2 gap-5">
<MultiSelect
v-model="batch.instructors"
doctype="Course Evaluator"
:label="__('Instructors')"
:required="true"
:onCreate="(close: () => void) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }"
/>
<FormControl
v-model="batch.description"
:label="__('Description')"
type="textarea"
:required="true"
:rows="4"
/>
</div>
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Batch Details') }}
@@ -93,7 +98,7 @@
@change="(val: string) => (batch.batch_details = 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]"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem] max-h-[14rem] overflow-auto"
/>
</div>
</div>
@@ -111,7 +116,7 @@
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { ref, inject, onMounted, onBeforeUnmount } from 'vue'
import { computed, inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { cleanError, openSettings, sanitizeHTML, escapeHTML } from '@/utils'
import Link from '@/components/Controls/Link.vue'
@@ -139,6 +144,7 @@ type Batch = {
instructors: string[]
category: string | null
seat_count: number
medium: string | null
}
const batch = ref<Batch>({
@@ -153,6 +159,7 @@ const batch = ref<Batch>({
instructors: [],
category: null,
seat_count: 0,
medium: null,
})
const validateFields = () => {
@@ -227,4 +234,17 @@ onBeforeUnmount(() => {
data: batch.value,
})
})
const mediumOptions = computed(() => {
return [
{
label: __('Online'),
value: 'Online',
},
{
label: __('Offline'),
value: 'Offline',
},
]
})
</script>
+20
View File
@@ -191,6 +191,26 @@
<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="courseResource.doc.paid_course"
:label="__('Paid Course')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="courseResource.doc.enable_certification"
:label="__('Completion Certificate')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="courseResource.doc.paid_certificate"
:label="__('Paid Certificate')"
@change="makeFormDirty()"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div
v-if="
@@ -13,6 +13,7 @@
v-model="course.title"
:label="__('Title')"
:required="true"
autocomplete="off"
/>
<Link
doctype="LMS Category"
@@ -57,7 +58,7 @@
@change="(val: string) => (course.description = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem] max-h-[17rem] overflow-auto"
/>
</div>
</div>
@@ -87,6 +88,7 @@ const router = useRouter()
const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
const user = inject<any>('$user')
const courseCreated = ref(false)
const props = defineProps<{
courses: any
@@ -139,6 +141,7 @@ const saveCourse = (close: () => void = () => {}) => {
toast.success(__('Course created successfully'))
close()
capture('course_created')
courseCreated.value = true
router.push({
name: 'CourseDetail',
params: { courseName: data.name },
@@ -178,8 +181,10 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
capture('course_form_closed', {
data: course.value,
})
if (!courseCreated.value) {
capture('course_form_closed', {
data: course.value,
})
}
})
</script>
+13 -7
View File
@@ -15,17 +15,22 @@
</Button>
</header>
<div class="py-5">
<div class="w-5/6 mx-auto">
<div class="grid grid-cols-2 gap-5 w-5/6 mx-auto">
<FormControl
v-model="lesson.title"
label="Title"
:label="__('Title')"
class="mb-4"
:required="true"
autocomplete="off"
/>
<FormControl
<Switch
v-model="lesson.include_in_preview"
type="checkbox"
label="Include in Preview"
:label="__('Include in Preview')"
:description="
__(
'If enabled, the lesson will also be accessible to users who are not enrolled in the course.'
)
"
/>
</div>
<div class="border-t mt-4">
@@ -83,6 +88,7 @@ import {
Button,
createResource,
FormControl,
Switch,
usePageMeta,
toast,
} from 'frappe-ui'
@@ -708,8 +714,8 @@ iframe {
height: 15px;
}
.ce-popover--opened > .ce-popover__container {
max-height: unset;
.ce-popover--opened {
max-height: unset !important;
}
.cdx-search-field__icon svg {
+1 -1
View File
@@ -126,6 +126,7 @@ export function getEditorTools() {
defaultStyle: 'ordered',
},
},
upload: Upload,
table: {
class: Table,
inlineToolbar: true,
@@ -133,7 +134,6 @@ export function getEditorTools() {
quiz: Quiz,
assignment: Assignment,
program: Program,
upload: Upload,
markdown: {
class: Markdown,
inlineToolbar: true,