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

@@ -85,7 +85,7 @@ import {
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { Plus } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { sessionStore } from '../stores/session'
import AssignmentForm from '@/components/Modals/AssignmentForm.vue'
import EmptyState from '@/components/EmptyState.vue'
@@ -99,12 +99,17 @@ const assignmentID = ref('new')
const assignmentCount = ref(0)
const { brand } = sessionStore()
const router = useRouter()
const route = useRoute()
const readOnlyMode = window.read_only_mode
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' })
}
if (route.query.new === 'true') {
assignmentID.value = 'new'
showAssignmentForm.value = true
}
getAssignmentCount()
titleFilter.value = router.currentRoute.value.query.title
typeFilter.value = router.currentRoute.value.query.type

View File

@@ -340,11 +340,12 @@ import { sessionStore } from '../stores/session'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue'
import {
openSettings,
escapeHTML,
getMetaInfo,
openSettings,
sanitizeHTML,
updateMetaInfo,
validateFile,
escapeHTML,
} from '@/utils'
const router = useRouter()
@@ -502,6 +503,9 @@ const imageResource = createResource({
})
const validateFields = () => {
batch.description = sanitizeHTML(batch.description)
batch.batch_details = sanitizeHTML(batch.batch_details)
Object.keys(batch).forEach((key) => {
if (
!['description', 'batch_details'].includes(key) &&

View File

@@ -353,11 +353,12 @@ import { capture, startRecording, stopRecording } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import {
openSettings,
escapeHTML,
getMetaInfo,
openSettings,
sanitizeHTML,
updateMetaInfo,
validateFile,
escapeHTML,
} from '@/utils'
import Link from '@/components/Controls/Link.vue'
import CourseOutline from '@/components/CourseOutline.vue'
@@ -539,6 +540,8 @@ const imageResource = createResource({
})
const validateFields = () => {
course.description = sanitizeHTML(course.description)
Object.keys(course).forEach((key) => {
if (key != 'description' && typeof course[key] === 'string') {
course[key] = escapeHTML(course[key])

View File

@@ -158,7 +158,7 @@ import { computed, onMounted, reactive, inject } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import { escapeHTML, getFileSize, validateFile } from '@/utils'
import { escapeHTML, getFileSize, sanitizeHTML, validateFile } from '@/utils'
const user = inject('$user')
const router = useRouter()
@@ -254,8 +254,18 @@ onMounted(() => {
}
if (props.jobName != 'new') jobDetail.reload()
addKeyboardShortcuts()
})
const addKeyboardShortcuts = () => {
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
saveJob()
}
})
}
const saveJob = () => {
validateJobFields()
if (jobDetail.data) {
@@ -304,6 +314,7 @@ const editJobDetails = () => {
}
const validateJobFields = () => {
job.description = sanitizeHTML(job.description)
Object.keys(job).forEach((key) => {
if (key != 'description' && typeof job[key] === 'string') {
job[key] = escapeHTML(job[key])
@@ -359,7 +370,7 @@ const breadcrumbs = computed(() => {
usePageMeta(() => {
return {
title: props.jobName == 'new' ? 'New Job' : jobDetail.data?.title,
title: props.jobName == 'new' ? 'New Job' : jobDetail.data?.job_title,
icon: brand.favicon,
}
})

View File

@@ -168,7 +168,7 @@ const testCaseSection = ref<HTMLElement | null>(null)
const testCases = ref<TestCase[]>([])
const boilerplate = ref<string>('')
const { brand } = sessionStore()
const { livecodeURL } = useSettings()
const { settings } = useSettings()
const router = useRouter()
const fromLesson = ref(false)
const falconURL = ref<string>('https://falcon.frappe.io/')
@@ -291,8 +291,8 @@ watch(
)
const loadFalcon = () => {
if (livecodeURL.data) {
falconURL.value = livecodeURL.data
if (settings.data) {
falconURL.value = settings.data.livecode_url
}
return new Promise((resolve, reject) => {
const script = document.createElement('script')

View File

@@ -134,7 +134,7 @@ import {
toast,
usePageMeta,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
@@ -145,6 +145,7 @@ const { brand } = sessionStore()
const user = inject('$user')
const dayjs = inject('$dayjs')
const router = useRouter()
const route = useRoute()
const search = ref('')
const readOnlyMode = window.read_only_mode
const quizFilters = ref({})
@@ -157,6 +158,9 @@ onMounted(() => {
} else if (!user.data?.is_moderator) {
quizFilters.value['owner'] = user.data?.name
}
if (route.query.new === 'true') {
showForm.value = true
}
})
watch(search, () => {

View File

@@ -0,0 +1,232 @@
<template>
<header
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="[{ label: __('Search') }]" />
</header>
<div class="w-4/6 mx-auto py-5">
<div class="px-2.5">
<TextInput
ref="searchInput"
class="flex-1"
placeholder="Search for a keyword or phrase and press enter"
autocomplete="off"
:model-value="query"
@update:model-value="updateQuery"
@keydown.enter="() => submit()"
>
<template #prefix>
<Search class="w-4 text-ink-gray-5" />
</template>
<template #suffix>
<div class="flex items-center">
<button
v-if="query"
@click="clearSearch"
class="p-1 size-6 grid place-content-center focus:outline-none focus:ring focus:ring-outline-gray-3 rounded"
>
<X class="w-4 text-ink-gray-7" />
</button>
</div>
</template>
</TextInput>
<div
v-if="query && searchResults.length"
class="text-sm text-ink-gray-5 mt-2"
>
{{ searchResults.length }}
{{ searchResults.length === 1 ? __('match') : __('matches') }}
</div>
<div v-else-if="queryChanged" class="text-sm text-ink-gray-5 mt-2">
{{ __('Press enter to search') }}
</div>
<div
v-else-if="query && !searchResults.length"
class="text-sm text-ink-gray-5 mt-2"
>
{{ __('No results found') }}
</div>
</div>
<div class="mt-5">
<div v-if="searchResults.length" class="">
<div
v-for="(result, index) in searchResults"
@click="navigate(result)"
class="rounded-md cursor-pointer hover:bg-surface-gray-2 px-2"
>
<div
class="flex space-x-2 py-3"
:class="{
'border-b': index !== searchResults.length - 1,
}"
>
<Tooltip :text="result.author_info.full_name">
<Avatar
:label="result.author_info.full_name"
:image="result.author_info.user_image"
size="md"
/>
</Tooltip>
<div class="space-y-1 w-full">
<div class="flex items-center">
<div class="font-medium" v-html="result.title"></div>
<div class="text-sm text-ink-gray-5 ml-2">
{{ getDocTypeTitle(result.doctype) }}
</div>
<div
v-if="
result.published_on || result.start_date || result.creation
"
class="ml-auto text-sm text-ink-gray-5"
>
{{
dayjs(
result.published_on ||
result.start_date ||
result.creation
).format('DD MMM YYYY')
}}
</div>
</div>
<div class="leading-5" v-html="result.content"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
Avatar,
Breadcrumbs,
createResource,
debounce,
TextInput,
Tooltip,
usePageMeta,
} from 'frappe-ui'
import { inject, onMounted, ref, watch } from 'vue'
import { Search, X } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { useRouter, useRoute } from 'vue-router'
const query = ref('')
const searchInput = ref<HTMLInputElement | null>(null)
const searchResults = ref<Array<any>>([])
const { brand } = sessionStore()
const router = useRouter()
const route = useRoute()
const queryChanged = ref(false)
const dayjs = inject<any>('$dayjs')
onMounted(() => {
if (router.currentRoute.value.query.q) {
query.value = router.currentRoute.value.query.q as string
submit()
}
})
const updateQuery = (value: string) => {
query.value = value
router.replace({ query: value ? { q: value } : {} })
}
const submit = debounce(() => {
if (query.value.length > 2) {
search.reload()
}
}, 500)
const search = createResource({
url: 'lms.command_palette.search_sqlite',
makeParams: () => ({
query: query.value,
}),
onSuccess() {
generateSearchResults()
},
})
const generateSearchResults = () => {
searchResults.value = []
if (search.data) {
queryChanged.value = false
search.data.forEach((group: any) => {
group.items.forEach((item: any) => {
searchResults.value.push(item)
})
})
searchResults.value.sort((a, b) => b.score - a.score)
}
}
const navigate = (result: any) => {
if (result.doctype == 'LMS Course') {
router.push({
name: 'CourseDetail',
params: {
courseName: result.name,
},
})
} else if (result.doctype == 'LMS Batch') {
router.push({
name: 'BatchDetail',
params: {
batchName: result.name,
},
})
} else if (result.doctype == 'Job Opportunity') {
router.push({
name: 'JobDetail',
params: {
job: result.name,
},
})
}
}
watch(query, () => {
if (query.value && query.value != search.params?.query) {
queryChanged.value = true
} else if (!query.value) {
queryChanged.value = false
searchResults.value = []
}
})
watch(
() => route.query.q,
(newQ) => {
if (newQ && newQ !== query.value) {
query.value = newQ as string
submit()
}
}
)
const getDocTypeTitle = (doctype: string) => {
if (doctype === 'LMS Course') {
return __('Course')
} else if (doctype === 'LMS Batch') {
return __('Batch')
} else if (doctype === 'Job Opportunity') {
return __('Job')
} else {
return doctype
}
}
const clearSearch = () => {
query.value = ''
updateQuery('')
}
usePageMeta(() => {
return {
title: __('Search'),
icon: brand.favicon,
}
})
</script>