feat: negative marking in quiz
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '3xl',
|
||||
size: '5xl',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
@@ -21,7 +21,7 @@
|
||||
class="!p-0"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!chooseFromExisting || editMode" class="space-y-2">
|
||||
<div v-if="!chooseFromExisting || editMode">
|
||||
<div>
|
||||
<label class="block text-xs text-ink-gray-5 mb-1">
|
||||
{{ __('Question') }}
|
||||
@@ -34,7 +34,7 @@
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-2 gap-8 mt-4">
|
||||
<FormControl
|
||||
v-model="question.marks"
|
||||
:label="__('Marks')"
|
||||
@@ -51,7 +51,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="question.type == 'Choices'"
|
||||
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
|
||||
class="text-base font-semibold text-ink-gray-9 mb-5 mt-10"
|
||||
>
|
||||
{{ __('Options') }}
|
||||
</div>
|
||||
@@ -61,7 +61,10 @@
|
||||
>
|
||||
{{ __('Possibilities') }}
|
||||
</div>
|
||||
<div v-if="question.type == 'Choices'" class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
v-if="question.type == 'Choices'"
|
||||
class="grid grid-cols-2 gap-x-8 gap-y-4"
|
||||
>
|
||||
<div v-for="n in 4" class="space-y-4 py-2">
|
||||
<FormControl
|
||||
:label="__('Option') + ' ' + n"
|
||||
@@ -81,7 +84,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="question.type == 'User Input'"
|
||||
class="grid grid-cols-2 gap-4 py-2"
|
||||
class="grid grid-cols-2 gap-x-8 gap-y-4 py-2"
|
||||
>
|
||||
<div v-for="n in 4">
|
||||
<FormControl
|
||||
@@ -106,7 +109,7 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-end space-x-2 mt-5">
|
||||
<Button variant="solid" @click="submitQuestion()">
|
||||
{{ __('Submit') }}
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,7 +220,7 @@ const questionRow = createResource({
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Quiz Question',
|
||||
parent: quiz.value.data.name,
|
||||
parent: quiz.value.doc.name,
|
||||
parentfield: 'questions',
|
||||
parenttype: 'LMS Quiz',
|
||||
...values,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="quiz.data">
|
||||
<div
|
||||
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3 leading-5"
|
||||
class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 leading-5"
|
||||
>
|
||||
<div v-if="inVideo">
|
||||
{{ __('You will have to complete the quiz to continue the video') }}
|
||||
@@ -41,6 +41,16 @@
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.enable_negative_marking" class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
|
||||
).format(
|
||||
quiz.data.marks_to_cut,
|
||||
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
||||
|
||||
@@ -57,22 +57,24 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-4 py-5 border-b">
|
||||
<div class="flex flex-col space-y-4 pt-5 border-b">
|
||||
<Code
|
||||
v-model="code"
|
||||
:language="exercise.doc?.language.toLowerCase()"
|
||||
height="400px"
|
||||
maxHeight="1000px"
|
||||
/>
|
||||
<span v-if="error" class="text-xs text-ink-gray-5 px-2">
|
||||
{{ __('Compiler Message') }}:
|
||||
</span>
|
||||
<textarea
|
||||
v-if="error"
|
||||
v-model="errorMessage"
|
||||
class="bg-surface-gray-1 border-none text-sm h-32 leading-6"
|
||||
readonly
|
||||
/>
|
||||
<div class="flex flex-col space-y-1">
|
||||
<span v-if="error" class="text-xs text-ink-gray-5 px-1">
|
||||
{{ __('Compiler Message') }}:
|
||||
</span>
|
||||
<textarea
|
||||
v-if="error"
|
||||
v-model="errorMessage"
|
||||
class="font-mono text-ink-red-3 bg-surface-gray-1 border-none text-sm h-32 leading-6"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<!-- <textarea v-else v-model="output" class="bg-surface-gray-1 border-none text-sm h-28 leading-6" readonly /> -->
|
||||
</div>
|
||||
|
||||
|
||||
@@ -93,6 +93,12 @@
|
||||
{{ row[column.key] }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'modified'"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
@@ -261,7 +267,7 @@ const submissionColumns = computed(() => {
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
width: '20%',
|
||||
width: '30%',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
@@ -279,7 +285,7 @@ const submissionColumns = computed(() => {
|
||||
{
|
||||
label: __('Modified'),
|
||||
key: 'modified',
|
||||
width: '20%',
|
||||
width: '15%',
|
||||
icon: 'clock',
|
||||
align: 'right',
|
||||
},
|
||||
|
||||
@@ -4,30 +4,39 @@
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div v-if="!readOnlyMode" class="space-x-2">
|
||||
<Badge v-if="quizDetails.isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<router-link
|
||||
v-if="quizDetails.data?.name"
|
||||
v-if="quizDetails.doc?.name"
|
||||
:to="{
|
||||
name: 'QuizPage',
|
||||
params: {
|
||||
quizID: quizDetails.data.name,
|
||||
quizID: quizDetails.doc.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Open') }}
|
||||
<template #prefix>
|
||||
<ListChecks class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Test Quiz') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="quizDetails.data?.name"
|
||||
v-if="quizDetails.doc?.name"
|
||||
:to="{
|
||||
name: 'QuizSubmissionList',
|
||||
params: {
|
||||
quizID: quizDetails.data.name,
|
||||
quizID: quizDetails.doc.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Submission List') }}
|
||||
<template #prefix>
|
||||
<ClipboardList class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Check Submissions') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button variant="solid" @click="submitQuiz()">
|
||||
@@ -35,144 +44,152 @@
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="w-3/4 mx-auto py-5">
|
||||
<!-- Details -->
|
||||
<div class="mb-8">
|
||||
<div class="font-semibold text-ink-gray-9 mb-4">
|
||||
<div v-if="quizDetails.doc" class="py-5">
|
||||
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="quiz.title"
|
||||
:label="
|
||||
quizDetails.data?.name
|
||||
? __('Title')
|
||||
: __('Enter a title and save the quiz to proceed')
|
||||
"
|
||||
:required="true"
|
||||
/>
|
||||
<div v-if="quizDetails.data?.name">
|
||||
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="quiz.max_attempts"
|
||||
v-model="quizDetails.doc.max_attempts"
|
||||
:label="__('Maximum Attempts')"
|
||||
/>
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="quiz.duration"
|
||||
v-model="quizDetails.doc.duration"
|
||||
:label="__('Duration (in minutes)')"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="quiz.total_marks"
|
||||
v-model="quizDetails.doc.total_marks"
|
||||
:label="__('Total Marks')"
|
||||
disabled
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quiz.passing_percentage"
|
||||
v-model="quizDetails.doc.passing_percentage"
|
||||
:label="__('Passing Percentage')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="mb-8">
|
||||
<div class="font-semibold text-ink-gray-9 mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 my-4">
|
||||
<FormControl
|
||||
v-model="quiz.show_answers"
|
||||
type="checkbox"
|
||||
:label="__('Show Answers')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quiz.show_submission_history"
|
||||
type="checkbox"
|
||||
:label="__('Show Submission History')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<div class="font-semibold text-ink-gray-9 mb-4">
|
||||
{{ __('Shuffle Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3">
|
||||
<FormControl
|
||||
v-model="quiz.shuffle_questions"
|
||||
type="checkbox"
|
||||
:label="__('Shuffle Questions')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="quiz.shuffle_questions"
|
||||
v-model="quiz.limit_questions_to"
|
||||
:label="__('Limit Questions To')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Questions -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="font-semibold text-ink-gray-9">
|
||||
{{ __('Questions') }}
|
||||
</div>
|
||||
<Button v-if="!readOnlyMode" @click="openQuestionModal()">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New Question') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ListView
|
||||
:columns="questionColumns"
|
||||
:rows="quiz.questions"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in questionColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-slot="{ idx, column, item }"
|
||||
v-for="row in quiz.questions"
|
||||
@click="openQuestionModal(row)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<ListRowItem :item="item">
|
||||
<div
|
||||
v-if="column.key == 'question_detail'"
|
||||
class="text-xs truncate h-4"
|
||||
v-html="item"
|
||||
></div>
|
||||
<div v-else class="text-xs">
|
||||
{{ item }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteQuestions(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
<div class="flex flex-col space-y-10">
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.show_answers"
|
||||
type="checkbox"
|
||||
:label="__('Show Answers')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.show_submission_history"
|
||||
type="checkbox"
|
||||
:label="__('Show Submission History')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-5">
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.shuffle_questions"
|
||||
type="checkbox"
|
||||
:label="__('Shuffle Questions')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="quizDetails.doc.shuffle_questions"
|
||||
v-model="quizDetails.doc.limit_questions_to"
|
||||
:label="__('Limit Questions To')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-5">
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.enable_negative_marking"
|
||||
type="checkbox"
|
||||
:label="__('Enable Negative Marking')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="quizDetails.doc.enable_negative_marking"
|
||||
v-model="quizDetails.doc.marks_to_cut"
|
||||
:label="__('Marks to Cut')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-20 pb-5 space-y-5 mb-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Questions') }}
|
||||
</div>
|
||||
<Button v-if="!readOnlyMode" @click="openQuestionModal()">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New Question') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="questions.length"
|
||||
:columns="questionColumns"
|
||||
:rows="questions"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in questionColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-slot="{ idx, column, item }"
|
||||
v-for="row in questions"
|
||||
@click="openQuestionModal(row)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<ListRowItem :item="item">
|
||||
<div
|
||||
v-if="column.key == 'question_detail'"
|
||||
class="text-xs truncate h-4"
|
||||
v-html="item"
|
||||
></div>
|
||||
<div v-else class="text-xs">
|
||||
{{ item }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteQuestions(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<div v-else class="text-ink-gray-6 text-sm">
|
||||
{{ __('No questions added yet') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Question
|
||||
v-model="showQuestionModal"
|
||||
:questionDetail="currentQuestion"
|
||||
@@ -199,6 +216,8 @@ import {
|
||||
Button,
|
||||
usePageMeta,
|
||||
toast,
|
||||
createDocumentResource,
|
||||
Badge,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
computed,
|
||||
@@ -210,8 +229,7 @@ import {
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Question from '@/components/Modals/Question.vue'
|
||||
|
||||
@@ -233,18 +251,7 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const quiz = reactive({
|
||||
title: '',
|
||||
total_marks: 0,
|
||||
passing_percentage: 0,
|
||||
max_attempts: 0,
|
||||
duration: 0,
|
||||
limit_questions_to: 0,
|
||||
show_answers: true,
|
||||
show_submission_history: false,
|
||||
shuffle_questions: false,
|
||||
questions: [],
|
||||
})
|
||||
const questions = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
@@ -280,86 +287,26 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const quizDetails = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return { doctype: 'LMS Quiz', name: props.quizID }
|
||||
},
|
||||
const quizDetails = createDocumentResource({
|
||||
doctype: 'LMS Quiz',
|
||||
name: props.quizID,
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (Object.hasOwn(quiz, key)) quiz[key] = data[key]
|
||||
})
|
||||
|
||||
let checkboxes = [
|
||||
'show_answers',
|
||||
'show_submission_history',
|
||||
'shuffle_questions',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
quiz[key] = quiz[key] ? true : false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const quizCreate = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
auto: false,
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Quiz',
|
||||
...quiz,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const quizUpdate = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
auto: false,
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Quiz',
|
||||
name: values.quizID,
|
||||
fieldname: {
|
||||
total_marks: calculateTotalMarks(),
|
||||
...quiz,
|
||||
},
|
||||
onSuccess(doc) {
|
||||
if (doc.questions && doc.questions.length > 0) {
|
||||
questions.value = doc.questions.map((question) => question)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submitQuiz = () => {
|
||||
if (quizDetails.data?.name) updateQuiz()
|
||||
else createQuiz()
|
||||
}
|
||||
|
||||
const createQuiz = () => {
|
||||
quizCreate.submit(
|
||||
{},
|
||||
quizDetails.setValue.submit(
|
||||
{
|
||||
...quizDetails.doc,
|
||||
total_marks: calculateTotalMarks(),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success(__('Quiz created successfully'))
|
||||
router.push({
|
||||
name: 'QuizForm',
|
||||
params: { quizID: data.name },
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateQuiz = () => {
|
||||
quizUpdate.submit(
|
||||
{ quizID: quizDetails.data?.name },
|
||||
{
|
||||
onSuccess(data) {
|
||||
quiz.total_marks = data.total_marks
|
||||
quizDetails.doc.total_marks = data.total_marks
|
||||
toast.success(__('Quiz updated successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
@@ -371,9 +318,15 @@ const updateQuiz = () => {
|
||||
|
||||
const calculateTotalMarks = () => {
|
||||
let totalMarks = 0
|
||||
if (quiz.limit_questions_to && quiz.questions.length > 0)
|
||||
return quiz.questions[0].marks * quiz.limit_questions_to
|
||||
quiz.questions.forEach((question) => {
|
||||
if (
|
||||
quizDetails.doc?.limit_questions_to &&
|
||||
quizDetails.doc?.questions.length > 0
|
||||
)
|
||||
return (
|
||||
quizDetails.doc.questions[0].marks * quizDetails.doc.limit_questions_to
|
||||
)
|
||||
|
||||
quizDetails.doc?.questions.forEach((question) => {
|
||||
totalMarks += question.marks
|
||||
})
|
||||
return totalMarks
|
||||
@@ -448,7 +401,7 @@ const breadcrumbs = computed(() => {
|
||||
]
|
||||
|
||||
crumbs.push({
|
||||
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
|
||||
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.doc?.title,
|
||||
route: { name: 'QuizForm', params: { quizID: props.quizID } },
|
||||
})
|
||||
return crumbs
|
||||
@@ -456,7 +409,7 @@ const breadcrumbs = computed(() => {
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
|
||||
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.doc?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,37 +3,42 @@
|
||||
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 :items="breadcrumbs" />
|
||||
<router-link
|
||||
v-if="!readOnlyMode"
|
||||
:to="{
|
||||
name: 'QuizForm',
|
||||
params: {
|
||||
quizID: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New Quiz') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button v-if="!readOnlyMode" variant="solid" @click="showForm = true">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<div v-if="quizCount" class="text-xl font-semibold text-ink-gray-7 mb-4">
|
||||
{{ __('{0} Quizzes').format(quizCount) }}
|
||||
<div class="py-5 mx-5">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="text-xl font-semibold text-ink-gray-7">
|
||||
{{
|
||||
quizzes.data?.length
|
||||
? __('{0} Quizzes').format(quizzes.data.length)
|
||||
: __('No Quizzes')
|
||||
}}
|
||||
</div>
|
||||
<FormControl v-model="search" type="text" placeholder="Search">
|
||||
<template #prefix>
|
||||
<FeatherIcon name="search" class="size-4 text-ink-gray-5" />
|
||||
</template>
|
||||
</FormControl>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="quizzes.data?.length"
|
||||
:columns="quizColumns"
|
||||
:rows="quizzes.data"
|
||||
row-key="name"
|
||||
:options="{ showTooltip: false, selectable: false }"
|
||||
:options="{ showTooltip: false, selectable: true }"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in quizColumns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
@@ -46,72 +51,176 @@
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row" />
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'show_answers'">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="row[column.key]"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'modified'"
|
||||
class="text-xs text-ink-gray-5"
|
||||
>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteQuiz(selections, unselectAll)"
|
||||
>
|
||||
<FeatherIcon name="trash-2" class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<div class="flex justify-center my-5">
|
||||
<Button v-if="quizzes.hasNextPage" @click="quizzes.next()">
|
||||
<EmptyState v-else type="Quizzes" />
|
||||
<div v-if="quizzes.hasNextPage" class="flex justify-center my-5">
|
||||
<Button @click="quizzes.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else type="Quizzes" />
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
title: __('Create a Quiz'),
|
||||
size: 'sm',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick({ close }) {
|
||||
insertQuiz(close)
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<FormControl v-model="title" :label="__('Title')" type="text" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
Dialog,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const router = useRouter()
|
||||
const quizCount = ref(0)
|
||||
const search = ref('')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const quizFilters = ref({})
|
||||
const showForm = ref(false)
|
||||
const title = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
} else if (!user.data?.is_moderator) {
|
||||
quizFilters.value['owner'] = user.data?.name
|
||||
}
|
||||
getQuizCount()
|
||||
})
|
||||
|
||||
const quizFilter = computed(() => {
|
||||
if (user.data?.is_moderator) return {}
|
||||
return {
|
||||
owner: user.data?.name,
|
||||
}
|
||||
watch(search, () => {
|
||||
quizFilters.value['title'] = ['like', `%${search.value}%`]
|
||||
quizzes.update({
|
||||
filters: quizFilters.value,
|
||||
})
|
||||
quizzes.reload()
|
||||
})
|
||||
|
||||
const quizzes = createListResource({
|
||||
doctype: 'LMS Quiz',
|
||||
filters: quizFilter,
|
||||
fields: ['name', 'title', 'passing_percentage', 'total_marks'],
|
||||
filters: quizFilters,
|
||||
fields: [
|
||||
'name',
|
||||
'title',
|
||||
'passing_percentage',
|
||||
'total_marks',
|
||||
'show_answers',
|
||||
'max_attempts',
|
||||
'modified',
|
||||
],
|
||||
auto: true,
|
||||
cache: ['quizzes', user.data?.name],
|
||||
orderBy: 'modified desc',
|
||||
transform(data) {
|
||||
return data.map((quiz) => {
|
||||
return {
|
||||
...quiz,
|
||||
modified: dayjs(quiz.modified).fromNow(),
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const getQuizCount = () => {
|
||||
call('frappe.client.get_count', {
|
||||
doctype: 'LMS Quiz',
|
||||
}).then((data) => {
|
||||
quizCount.value = data
|
||||
const insertQuiz = (close) => {
|
||||
quizzes.insert.submit(
|
||||
{
|
||||
title: title.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success(__('Quiz created successfully'))
|
||||
close()
|
||||
title.value = ''
|
||||
router.push({
|
||||
name: 'QuizForm',
|
||||
params: {
|
||||
quizID: data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(__('Error creating quiz: {0}', error.message))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteQuiz = (selections, unselectAll) => {
|
||||
Array.from(selections).forEach(async (quizName) => {
|
||||
await quizzes.delete.submit(quizName)
|
||||
})
|
||||
unselectAll()
|
||||
toast.success(__('Quizzes deleted successfully'))
|
||||
}
|
||||
|
||||
const quizColumns = computed(() => {
|
||||
@@ -120,18 +229,42 @@ const quizColumns = computed(() => {
|
||||
label: __('Title'),
|
||||
key: 'title',
|
||||
width: 2,
|
||||
icon: 'file-text',
|
||||
},
|
||||
{
|
||||
label: __('Total Marks'),
|
||||
key: 'total_marks',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'hash',
|
||||
},
|
||||
{
|
||||
label: __('Passing Percentage'),
|
||||
key: 'passing_percentage',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'percent',
|
||||
},
|
||||
{
|
||||
label: __('Max Attempts'),
|
||||
key: 'max_attempts',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'repeat',
|
||||
},
|
||||
{
|
||||
label: __('Show Answers'),
|
||||
key: 'show_answers',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'eye',
|
||||
},
|
||||
{
|
||||
label: __('Modified'),
|
||||
key: 'modified',
|
||||
width: 1,
|
||||
align: 'right',
|
||||
icon: 'clock',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -104,24 +104,6 @@ export function getImgDimensions(imgSrc) {
|
||||
})
|
||||
}
|
||||
|
||||
export function updateDocumentTitle(meta) {
|
||||
watch(
|
||||
() => meta,
|
||||
(meta) => {
|
||||
if (!meta.value.title) return
|
||||
if (meta.value.title && meta.value.subtitle) {
|
||||
document.title = `${meta.value.title} | ${meta.value.subtitle}`
|
||||
return
|
||||
}
|
||||
if (meta.value.title) {
|
||||
document.title = `${meta.value.title}`
|
||||
return
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function htmlToText(html) {
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
"duration",
|
||||
"section_break_tzbu",
|
||||
"shuffle_questions",
|
||||
"column_break_clsh",
|
||||
"limit_questions_to",
|
||||
"column_break_clsh",
|
||||
"enable_negative_marking",
|
||||
"marks_to_cut",
|
||||
"section_break_sbjx",
|
||||
"questions",
|
||||
"section_break_3",
|
||||
@@ -134,6 +136,18 @@
|
||||
"fieldname": "duration",
|
||||
"fieldtype": "Data",
|
||||
"label": "Duration (in minutes)"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enable_negative_marking",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Negative Marking"
|
||||
},
|
||||
{
|
||||
"depends_on": "enable_negative_marking",
|
||||
"fieldname": "marks_to_cut",
|
||||
"fieldtype": "Int",
|
||||
"label": "Marks To Cut"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -144,8 +158,8 @@
|
||||
"link_fieldname": "quiz"
|
||||
}
|
||||
],
|
||||
"modified": "2025-04-07 15:03:48.525458",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-06-27 18:33:13.714465",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Quiz",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -44,6 +44,11 @@ class LMSQuiz(Document):
|
||||
frappe.throw(_("All questions should have the same marks if the limit is set."))
|
||||
|
||||
def calculate_total_marks(self):
|
||||
if len(self.questions) == 0:
|
||||
self.total_marks = 0
|
||||
self.passing_percentage = 100
|
||||
return
|
||||
|
||||
if self.limit_questions_to:
|
||||
self.total_marks = sum(
|
||||
question.marks for question in self.questions[: cint(self.limit_questions_to)]
|
||||
@@ -102,11 +107,19 @@ def quiz_summary(quiz, results):
|
||||
quiz_details = frappe.db.get_value(
|
||||
"LMS Quiz",
|
||||
quiz,
|
||||
["total_marks", "passing_percentage", "lesson", "course"],
|
||||
[
|
||||
"name",
|
||||
"total_marks",
|
||||
"passing_percentage",
|
||||
"lesson",
|
||||
"course",
|
||||
"enable_negative_marking",
|
||||
"marks_to_cut",
|
||||
],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
data = process_results(results, quiz)
|
||||
data = process_results(results, quiz_details)
|
||||
results = data["results"]
|
||||
score = data["score"]
|
||||
is_open_ended = data["is_open_ended"]
|
||||
@@ -129,14 +142,14 @@ def quiz_summary(quiz, results):
|
||||
}
|
||||
|
||||
|
||||
def process_results(results, quiz):
|
||||
def process_results(results, quiz_details):
|
||||
score = 0
|
||||
is_open_ended = False
|
||||
|
||||
for result in results:
|
||||
question_details = frappe.db.get_value(
|
||||
"LMS Quiz Question",
|
||||
{"parent": quiz, "question": result["question_name"]},
|
||||
{"parent": quiz_details.name, "question": result["question_name"]},
|
||||
["question", "marks", "question_detail", "type"],
|
||||
as_dict=1,
|
||||
)
|
||||
@@ -154,7 +167,11 @@ def process_results(results, quiz):
|
||||
else:
|
||||
result["is_correct"] = 0
|
||||
|
||||
marks = question_details.marks if correct else 0
|
||||
if correct:
|
||||
marks = question_details.marks
|
||||
else:
|
||||
marks = -quiz_details.marks_to_cut if quiz_details.enable_negative_marking else 0
|
||||
|
||||
result["marks"] = marks
|
||||
score += marks
|
||||
|
||||
|
||||
Reference in New Issue
Block a user