Merge branch 'develop' of https://github.com/frappe/lms into tests-1

This commit is contained in:
Jannat Patel
2025-12-15 12:13:19 +05:30
69 changed files with 6747 additions and 3348 deletions

View File

@@ -71,7 +71,7 @@ const addAssessment = () => {
}
const redirectToForm = () => {
if (props.type == 'quiz') window.open('/lms/quizzes/new', '_blank')
else window.open('/lms/assignments/new', '_blank')
if (props.type == 'quiz') window.open('/lms/quizzes?new=true', '_blank')
else window.open('/lms/assignments?new=true', '_blank')
}
</script>

View File

@@ -0,0 +1,272 @@
<template>
<Dialog v-model="show" :options="{ size: '2xl' }">
<template #body>
<div class="text-base">
<div class="flex items-center space-x-2 pl-4.5 border-b">
<Search class="size-4 text-ink-gray-4" />
<input
ref="inputRef"
type="text"
placeholder="Search"
class="w-full border-none bg-transparent py-3 !pl-2 pr-4.5 text-base text-ink-gray-7 placeholder-ink-gray-4 focus:ring-0"
@input="onInput"
v-model="query"
autocomplete="off"
/>
</div>
<div class="max-h-96 overflow-auto mb-2">
<div v-if="query.length" class="mt-5 space-y-5">
<CommandPaletteGroup
:list="searchResults"
@navigateTo="navigateTo"
/>
</div>
<div v-else class="mt-5 space-y-5">
<CommandPaletteGroup
:list="jumpToOptions"
@navigateTo="navigateTo"
/>
</div>
</div>
<div
class="flex items-center space-x-5 w-full border-t py-2 text-sm text-ink-gray-7 px-4.5"
>
<div class="flex items-center space-x-2">
<MoveUp
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
/>
<MoveDown
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
/>
<span>
{{ __('to navigate') }}
</span>
</div>
<div class="flex items-center space-x-2">
<CornerDownLeft
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
/>
<span>
{{ __('to select') }}
</span>
</div>
<div class="flex items-center space-x-2">
<span class="bg-surface-gray-2 p-1 rounded-sm"> esc </span>
<span>
{{ __('to close') }}
</span>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { createResource, debounce, Dialog } from 'frappe-ui'
import { nextTick, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
BookOpen,
Briefcase,
CornerDownLeft,
FileSearch,
MoveUp,
MoveDown,
Search,
Users,
} from 'lucide-vue-next'
import CommandPaletteGroup from './CommandPaletteGroup.vue'
const show = defineModel<boolean>({ required: true, default: false })
const router = useRouter()
const query = ref<string>('')
const searchResults = ref<Array<any>>([])
const search = createResource({
url: 'lms.command_palette.search_sqlite',
makeParams: () => ({
query: query.value,
}),
onSuccess() {
generateSearchResults()
},
})
const debouncedSearch = debounce(() => {
if (query.value.length > 2) {
search.reload()
}
}, 500)
const onInput = () => {
debouncedSearch()
}
const generateSearchResults = () => {
search.data?.forEach((type: any) => {
let result: { title: string; items: any[] } = { title: '', items: [] }
result.title = type.title
type.items.forEach((item: any) => {
let paramName = item.doctype === 'LMS Course' ? 'courseName' : 'batchName'
item.route = {
name: item.doctype === 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
params: {
[paramName]: item.name,
},
}
item.isActive = false
})
result.items = type.items
searchResults.value.push(result)
})
}
const appendSearchPage = () => {
let searchPage: { title: string; items: Array<any> } = {
title: '',
items: [],
}
searchPage.title = __('Jump to')
searchPage.items = [
{
title: __('Search for ') + `"${query.value}"`,
route: {
name: 'Search',
query: {
q: query.value,
},
},
icon: FileSearch,
isActive: true,
},
]
searchResults.value = [searchPage]
}
watch(
query,
() => {
appendSearchPage()
},
{ immediate: true }
)
watch(show, () => {
if (!show.value) {
query.value = ''
searchResults.value = []
}
})
onMounted(() => {
addKeyboardShortcuts()
})
const addKeyboardShortcuts = () => {
window.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'ArrowUp' && show.value) {
e.preventDefault()
shortcutForArrowKey(-1)
} else if (e.key === 'ArrowDown' && show.value) {
shortcutForArrowKey(1)
} else if (e.key === 'Enter' && show.value) {
shortcutForEnter()
} else if (e.key === 'Escape' && show.value) {
show.value = false
}
})
}
const shortcutForArrowKey = (direction: number) => {
let currentList = query.value.length
? searchResults.value
: jumpToOptions.value
let allItems = currentList.flatMap((result: any) => result.items)
let indexOfActive = allItems.findIndex((option: any) => option.isActive)
let newIndex = indexOfActive + direction
if (newIndex < 0) newIndex = allItems.length - 1
if (newIndex >= allItems.length) newIndex = 0
allItems[indexOfActive].isActive = false
allItems[newIndex].isActive = true
nextTick(scrollActiveItemIntoView)
}
const scrollActiveItemIntoView = () => {
const activeItem = document.querySelector(
'.hover\\:bg-surface-gray-2.bg-surface-gray-2'
) as HTMLElement
if (activeItem) {
activeItem.scrollIntoView({ block: 'nearest' })
}
}
const shortcutForEnter = () => {
let currentList = query.value.length
? searchResults.value
: jumpToOptions.value
let allItems = currentList.flatMap((result: any) => result.items)
let activeOption = allItems.find((option) => option.isActive)
if (activeOption) {
navigateTo(activeOption.route)
}
}
const navigateTo = (route: {
name: string
params?: Record<string, any>
query?: Record<string, any>
}) => {
show.value = false
query.value = ''
router.replace({ name: route.name, params: route.params, query: route.query })
}
const jumpToOptions = ref([
{
title: __('Jump to'),
items: [
{
title: 'Advanced Search',
icon: Search,
route: {
name: 'Search',
},
isActive: true,
},
{
title: 'Courses',
icon: BookOpen,
route: {
name: 'Courses',
},
isActive: false,
},
{
title: 'Batches',
icon: Users,
route: {
name: 'Batches',
},
isActive: false,
},
{
title: 'Jobs',
icon: Briefcase,
route: {
name: 'Jobs',
},
isActive: false,
},
],
},
])
</script>
<style>
mark {
background-color: theme('colors.amber.100');
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div v-for="result in list" class="px-2.5 space-y-2">
<div class="text-ink-gray-5 px-2">
{{ result.title }}
</div>
<div class="">
<div
v-for="item in result.items"
class="flex items-center justify-between p-2 rounded hover:bg-surface-gray-2 cursor-pointer"
:class="{ 'bg-surface-gray-2': item.isActive }"
@click="emit('navigateTo', item.route)"
>
<div class="flex items-center space-x-3">
<component
v-if="item.icon"
:is="item.icon"
class="size-4 stroke-1.5 text-ink-gray-6"
/>
<div v-html="item.title"></div>
</div>
<div v-if="item.modified" class="text-ink-gray-5">
{{ dayjs.unix(item.modified).fromNow(true) }}
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { inject } from 'vue'
const dayjs = inject<any>('$dayjs')
const emit = defineEmits(['navigateTo'])
const props = defineProps<{
list: Array<{
title: string
items: Array<{
title: string
icon?: any
isActive?: boolean
modified?: string
}>
}>
}>()
</script>

View File

@@ -48,7 +48,7 @@ const settingsStore = useSettings()
const sendMail = (close: Function) => {
call('frappe.core.doctype.communication.email.make', {
recipients: settingsStore.contactUsEmail?.data,
recipients: settingsStore.settings?.data?.contact_us_email,
subject: subject.value,
content: message.value,
send_email: true,

View File

@@ -156,7 +156,7 @@ const getGradientColor = () => {
localStorage.getItem('theme') == 'light' ? 'lightMode' : 'darkMode'
let color = props.course.card_gradient?.toLowerCase() || 'blue'
let colorMap = colors[theme][color]
return `linear-gradient(to top right, black, ${colorMap[400]})`
return `linear-gradient(to top right, black, ${colorMap[200]})`
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */

View File

@@ -66,7 +66,7 @@
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { computed, reactive, watch } from 'vue'
import { escapeHTML } from '@/utils'
import { escapeHTML, sanitizeHTML } from '@/utils'
const show = defineModel()
const assignments = defineModel<Assignments>('assignments')
@@ -122,12 +122,13 @@ watch(show, (newVal) => {
}
})
const validateTitle = () => {
const validateFields = () => {
assignment.title = escapeHTML(assignment.title.trim())
assignment.question = sanitizeHTML(assignment.question)
}
const saveAssignment = () => {
validateTitle()
validateFields()
if (props.assignmentID == 'new') {
createAssignment()
} else {

View File

@@ -104,9 +104,8 @@ import {
} from 'frappe-ui'
import { ref, reactive, watch } from 'vue'
import { X } from 'lucide-vue-next'
import { getFileSize, decodeEntities } from '@/utils'
import { getFileSize, sanitizeHTML } from '@/utils'
import Link from '@/components/Controls/Link.vue'
import DOMPurify from 'dompurify'
const reloadProfile = defineModel('reloadProfile')
const hasLanguageChanged = ref(false)
@@ -157,22 +156,7 @@ const updateProfile = createResource({
})
const saveProfile = (close) => {
profile.bio = DOMPurify.sanitize(decodeEntities(profile.bio), {
ALLOWED_TAGS: [
'b',
'i',
'em',
'strong',
'a',
'p',
'br',
'ul',
'ol',
'li',
'img',
],
ALLOWED_ATTR: ['href', 'target', 'src'],
})
profile.bio = sanitizeHTML(profile.bio)
updateProfile.submit(
{},
{

View File

@@ -120,6 +120,13 @@ const tabsStructure = computed(() => {
description:
'If enabled, users will no able to move forward in a video',
},
{
label: 'Disable PWA',
name: 'disable_pwa',
type: 'checkbox',
description:
'If checked, users will not be able to install the application as a Progressive Web App.',
},
{
label: 'Send calendar invite for evaluations',
name: 'send_calendar_invite_for_evaluations',

View File

@@ -9,11 +9,21 @@
>
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
<div class="flex flex-col" v-if="sidebarSettings.data">
<div v-for="link in sidebarLinks" class="mx-2 my-0.5">
<SidebarLink
:link="link"
:isCollapsed="sidebarStore.isSidebarCollapsed"
/>
<div v-for="link in sidebarLinks" class="mx-2 my-2.5">
<div
v-if="!link.hideLabel"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-ink-gray-5 transition-all duration-300 ease-in-out"
>
<span>{{ __(link.label) }}</span>
</div>
<nav class="space-y-1">
<div v-for="item in link.items">
<SidebarLink
:link="item"
:isCollapsed="sidebarStore.isSidebarCollapsed"
/>
</div>
</nav>
</div>
</div>
<div
@@ -173,6 +183,7 @@
:currentStep="currentStep"
/>
</div>
<CommandPalette v-model="settingsStore.isCommandPaletteOpen" />
<PageModal
v-model="showPageModal"
v-model:reloadSidebar="sidebarSettings"
@@ -181,9 +192,6 @@
</template>
<script setup>
import UserDropdown from '@/components/Sidebar/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/Sidebar/SidebarLink.vue'
import { getSidebarLinks } from '@/utils'
import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session'
@@ -194,7 +202,6 @@ import PageModal from '@/components/Modals/PageModal.vue'
import { capture } from '@/telemetry'
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { useRouter } from 'vue-router'
import InviteIcon from '@/components/Icons/InviteIcon.vue'
import {
ref,
onMounted,
@@ -217,7 +224,6 @@ import {
Users,
BookText,
Zap,
Check,
} from 'lucide-vue-next'
import {
TrialBanner,
@@ -228,19 +234,24 @@ import {
minimize,
IntermediateStepModal,
} from 'frappe-ui/frappe'
import InviteIcon from '@/components/Icons/InviteIcon.vue'
import UserDropdown from '@/components/Sidebar/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/Sidebar/SidebarLink.vue'
import CommandPalette from '@/components/CommandPalette/CommandPalette.vue'
const { user } = sessionStore()
const { userResource } = usersStore()
let sidebarStore = useSidebar()
const socket = inject('$socket')
const unreadCount = ref(0)
const sidebarLinks = ref(getSidebarLinks())
const sidebarLinks = ref(null)
const showPageModal = ref(false)
const isModerator = ref(false)
const isInstructor = ref(false)
const pageToEdit = ref(null)
const { sidebarSettings, activeTab, isSettingsOpen, programs } = useSettings()
const settingsStore = useSettings()
const { sidebarSettings } = settingsStore
const showOnboarding = ref(false)
const showIntermediateModal = ref(false)
const currentStep = ref({})
@@ -255,9 +266,8 @@ const iconProps = {
}
onMounted(() => {
addNotifications()
setSidebarLinks()
setUpOnboarding()
addKeyboardShortcut()
socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload()
})
@@ -270,9 +280,11 @@ const setSidebarLinks = () => {
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label.toLowerCase().split(' ').join('_') !== key
)
sidebarLinks.value.forEach((link) => {
link.items = link.items.filter(
(item) => item.label.toLowerCase().split(' ').join('_') !== key
)
})
}
})
},
@@ -280,6 +292,23 @@ const setSidebarLinks = () => {
)
}
const addKeyboardShortcut = () => {
window.addEventListener('keydown', (e) => {
if (
e.key === 'k' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
toggleCommandPalette()
e.preventDefault()
}
})
}
const toggleCommandPalette = () => {
settingsStore.isCommandPaletteOpen = !settingsStore.isCommandPaletteOpen
}
const unreadNotifications = createResource({
cache: 'Unread Notifications Count',
url: 'frappe.client.get_count',
@@ -294,140 +323,18 @@ const unreadNotifications = createResource({
},
onSuccess(data) {
unreadCount.value = data
sidebarLinks.value = sidebarLinks.value.map((link) => {
if (link.label === 'Notifications') {
link.count = data
}
return link
})
updateUnreadCount()
},
auto: user ? true : false,
})
const addNotifications = () => {
if (user) {
sidebarLinks.value.push({
label: 'Notifications',
icon: 'Bell',
to: 'Notifications',
activeFor: ['Notifications'],
count: unreadCount.value,
const updateUnreadCount = () => {
sidebarLinks.value?.forEach((link) => {
link.items.forEach((item) => {
if (item.label === 'Notifications') {
item.count = unreadCount.value || 0
}
})
}
}
const addQuizzes = () => {
if (!isInstructor.value && !isModerator.value) return
const quizzesLinkExists = sidebarLinks.value.some(
(link) => link.label === 'Quizzes'
)
if (quizzesLinkExists) return
sidebarLinks.value.splice(4, 0, {
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
activeFor: ['Quizzes', 'QuizForm', 'QuizSubmissionList', 'QuizSubmission'],
})
}
const addAssignments = () => {
if (!isInstructor.value && !isModerator.value) return
const assignmentsLinkExists = sidebarLinks.value.some(
(link) => link.label === 'Assignments'
)
if (assignmentsLinkExists) return
sidebarLinks.value.splice(5, 0, {
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
activeFor: [
'Assignments',
'AssignmentForm',
'AssignmentSubmissionList',
'AssignmentSubmission',
],
})
}
const addProgrammingExercises = () => {
if (!isInstructor.value && !isModerator.value) return
const programmingExercisesLinkExists = sidebarLinks.value.some(
(link) => link.label === 'Programming Exercises'
)
if (programmingExercisesLinkExists) return
sidebarLinks.value.splice(3, 0, {
label: 'Programming Exercises',
icon: 'Code',
to: 'ProgrammingExercises',
activeFor: [
'ProgrammingExercises',
'ProgrammingExerciseForm',
'ProgrammingExerciseSubmissions',
'ProgrammingExerciseSubmission',
],
})
}
const addPrograms = async () => {
const programsLinkExists = sidebarLinks.value.some(
(link) => link.label === 'Programs'
)
if (programsLinkExists) return
let canAddProgram = await checkIfCanAddProgram()
if (!canAddProgram) return
let activeFor = ['Programs', 'ProgramDetail']
let index = 2
sidebarLinks.value.splice(index, 0, {
label: 'Programs',
icon: 'Route',
to: 'Programs',
activeFor: activeFor,
})
}
const addContactUsDetails = () => {
if (!settingsStore.contactUsEmail?.data && !settingsStore.contactUsURL?.data)
return
const contactUsLinkExists = sidebarLinks.value.some(
(link) => link.label === 'Contact Us'
)
if (contactUsLinkExists) return
sidebarLinks.value.push({
label: 'Contact Us',
icon: settingsStore.contactUsURL?.data ? 'Headset' : 'Mail',
to: settingsStore.contactUsURL?.data
? settingsStore.contactUsURL.data
: settingsStore.contactUsEmail?.data,
})
}
const checkIfCanAddProgram = async () => {
if (isModerator.value || isInstructor.value) {
return true
}
const programs = await call('lms.lms.utils.get_programs')
return programs.enrolled.length > 0 || programs.published.length > 0
}
const addHome = () => {
const homeLinkExists = sidebarLinks.value.some(
(link) => link.label === 'Home'
)
if (homeLinkExists) return
sidebarLinks.value.unshift({
label: 'Home',
icon: 'Home',
to: 'Home',
activeFor: ['Home'],
})
}
@@ -540,8 +447,8 @@ const steps = reactive([
completed: false,
onClick: () => {
minimize.value = true
settingsStore.activeTab = 'Members'
settingsStore.isSettingsOpen = true
activeTab.value = 'Members'
isSettingsOpen.value = true
},
},
{
@@ -675,18 +582,16 @@ const setUpOnboarding = () => {
}
}
watch(userResource, () => {
addContactUsDetails()
watch(userResource, async () => {
await userResource.promise
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addHome()
addPrograms()
addProgrammingExercises()
addQuizzes()
addAssignments()
await programs.reload()
setUpOnboarding()
}
sidebarLinks.value = getSidebarLinks()
setSidebarLinks()
})
const redirectToWebsite = () => {

View File

@@ -48,7 +48,7 @@ const apps = createResource({
name: 'frappe',
logo: '/assets/lms/images/desk.png',
title: __('Desk'),
route: '/app',
route: '/desk/lms',
},
]
data.map((app) => {

View File

@@ -171,7 +171,7 @@ const showQuizLoader = ref(false)
const quizLoadTimer = ref(0)
const currentQuiz = ref(null)
const nextQuiz = ref({})
const { preventSkippingVideos } = useSettings()
const { settings } = useSettings()
const props = defineProps({
file: {
@@ -299,7 +299,7 @@ const toggleMute = () => {
const changeCurrentTime = () => {
if (
preventSkippingVideos.data &&
settings.data?.prevent_skipping_videos &&
currentTime.value > videoRef.value.currentTime
)
return