Merge pull request #2188 from pateljannat/quiz-navigation

feat: navigate between questions in quiz
This commit is contained in:
Jannat Patel
2026-03-13 18:19:00 +05:30
committed by GitHub
6 changed files with 359 additions and 100 deletions

View File

@@ -17,7 +17,6 @@ jobs:
test:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'frappe' }}
timeout-minutes: 60
strategy:

View File

@@ -189,7 +189,6 @@ import {
Plus,
SquareCode,
Trash2,
Notebook,
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue'

View File

@@ -1,56 +1,72 @@
<template>
<div v-if="quiz.data">
<div
class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 leading-5"
class="bg-surface-blue-2 text-ink-blue-3 space-y-2 p-3 mb-4 rounded-lg leading-5"
>
<div v-if="inVideo">
{{ __('You will have to complete the quiz to continue the video') }}
</div>
<div class="leading-5">
{{
__('This quiz consists of {0} questions.').format(questions.length)
}}
</div>
<div v-if="quiz.data?.duration" class="leading-5">
<div class="font-medium">
{{
__(
'Please ensure that you complete all the questions in {0} minutes.'
).format(quiz.data.duration)
}}
</div>
<div v-if="quiz.data?.duration" class="leading-5">
{{
__(
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
)
}}
</div>
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
{{
__(
'You will have to get {0}% correct answers in order to pass the quiz.'
).format(quiz.data.passing_percentage)
}}
</div>
<div v-if="quiz.data.max_attempts" class="leading-5">
{{
__('You can attempt this quiz {0}.').format(
quiz.data.max_attempts == 1
? '1 time'
: `${quiz.data.max_attempts} times`
)
}}
</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'
'Please read the following instructions carefully before starting the quiz'
)
}}
</div>
<ol class="list-decimal list-inside space-y-2">
<li v-if="inVideo">
{{ __('You will have to complete the quiz to continue the video') }}
</li>
<li>
{{
__(
'Do not refresh the page or close this window. If you do, the quiz will be submitted automatically.'
)
}}
</li>
<li>
{{
__('This quiz consists of {0} questions.').format(questions.length)
}}
</li>
<li v-if="quiz.data?.duration">
{{
__(
'Please ensure that you complete all the questions in {0} minutes.'
).format(quiz.data.duration)
}}
</li>
<li v-if="quiz.data?.duration">
{{
__(
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
)
}}
</li>
<li v-if="quiz.data.passing_percentage">
{{
__(
'You will have to get {0}% correct answers in order to pass the quiz.'
).format(quiz.data.passing_percentage)
}}
</li>
<li v-if="quiz.data.max_attempts">
{{
__('You can attempt this quiz {0}.').format(
quiz.data.max_attempts == 1
? '1 time'
: `${quiz.data.max_attempts} times`
)
}}
</li>
<li v-if="quiz.data.enable_negative_marking">
{{
__(
'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'
)
}}
</li>
</ol>
</div>
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
@@ -135,6 +151,7 @@
:name="encodeURIComponent(questionDetails.data.question)"
class="w-3.5 h-3.5 text-ink-gray-9 focus:ring-outline-gray-modals"
@change="markAnswer(index)"
:checked="selectedOptions[index - 1]"
/>
<input
@@ -143,6 +160,7 @@
:name="encodeURIComponent(questionDetails.data.question)"
class="w-3.5 h-3.5 text-ink-gray-9 rounded-sm focus:ring-outline-gray-modals"
@change="markAnswer(index)"
:checked="selectedOptions[index - 1]"
/>
<div
v-else-if="quiz.data.show_answers"
@@ -208,7 +226,7 @@
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-[7rem]"
/>
</div>
<div class="flex items-center justify-between mt-4">
<div class="flex items-center justify-between mt-8">
<div class="text-sm text-ink-gray-5">
{{
__('Question {0} of {1}').format(
@@ -217,6 +235,48 @@
)
}}
</div>
<div
v-if="!quiz.data.show_answers"
class="flex items-center space-x-2"
>
<Button
@click="switchQuestion(activeQuestion - 1)"
:disabled="activeQuestion == 1"
class="rounded-full"
>
<template #icon>
<ChevronLeft class="size-4 stroke-1.5" />
</template>
</Button>
<span
v-for="item in paginationWindow"
:key="item"
class="w-6 h-6 rounded-full flex items-center justify-center text-sm"
:class="{
'cursor-pointer': item !== '...',
'bg-surface-gray-4 border border-outline-gray-5 font-medium':
activeQuestion == item,
'bg-surface-gray-3 text-ink-gray-6':
activeQuestion != item && item !== '...',
'text-ink-gray-5': item === '...',
'bg-surface-blue-3 text-ink-white':
attemptedQuestions.includes(item) && activeQuestion != item,
}"
@click="item !== '...' && switchQuestion(item)"
>
{{ item }}
</span>
<Button
@click="switchQuestion(activeQuestion + 1)"
:disabled="activeQuestion == questions.length"
class="rounded-full"
>
<template #icon>
<ChevronRight class="size-4 stroke-1.5" />
</template>
</Button>
</div>
<Button
v-if="
quiz.data.show_answers &&
@@ -230,14 +290,16 @@
</span>
</Button>
<Button
v-else-if="activeQuestion != questions.length"
v-else-if="
activeQuestion != questions.length && quiz.data.show_answers
"
@click="nextQuestion()"
>
<span>
{{ __('Next') }}
</span>
</Button>
<Button v-else @click="submitQuiz()">
<Button variant="solid" v-else @click="handleSubmitClick()">
<span>
{{ __('Submit') }}
</span>
@@ -310,6 +372,54 @@
</ListView>
</div>
</div>
<Dialog
v-model="showSubmissionConfirmation"
:options="{
title: __('Are you sure you want to submit the quiz?'),
actions: [
{
size: 'sm',
label: __('Submit'),
variant: 'solid',
onClick() {
submitQuiz()
showSubmissionConfirmation = false
},
},
],
}"
>
<template #body-content>
<div class="border border-outline-gray-modals rounded-lg text-base">
<div class="divide-y divide-outline-gray-modals">
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
<div class="p-2">
{{ __('Total Questions') }}
</div>
<div class="p-2">
{{ questions.length }}
</div>
</div>
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
<div class="p-2">
{{ __('Attempted Questions') }}
</div>
<div class="p-2">
{{ attemptedQuestions.length }}
</div>
</div>
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
<div class="p-2">
{{ __('Unattempted Questions') }}
</div>
<div class="p-2">
{{ questions.length - attemptedQuestions.length }}
</div>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import {
@@ -317,23 +427,39 @@ import {
Button,
call,
createResource,
Dialog,
ListView,
TextEditor,
FormControl,
toast,
} from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import {
computed,
inject,
onMounted,
onUnmounted,
reactive,
ref,
watch,
} from 'vue'
import {
CheckCircle,
ChevronLeft,
ChevronRight,
XCircle,
MinusCircle,
} from 'lucide-vue-next'
import { timeAgo } from '@/utils'
import { useRouter } from 'vue-router'
import ProgressBar from '@/components/ProgressBar.vue'
const user = inject('$user')
const activeQuestion = ref(0)
const currentQuestion = ref('')
const selectedOptions = reactive([0, 0, 0, 0])
const selectedOptions = ref([0, 0, 0, 0])
const showAnswers = reactive([])
let questions = reactive([])
const attemptedQuestions = ref([])
const showSubmissionConfirmation = ref(false)
const possibleAnswer = ref(null)
const timer = ref(0)
let timerInterval = null
@@ -353,6 +479,40 @@ const props = defineProps({
},
})
onMounted(() => {
window.addEventListener('pagehide', handlePageHide)
window.addEventListener('beforeunload', handleBeforeUnload)
})
onUnmounted(() => {
window.removeEventListener('pagehide', handlePageHide)
window.removeEventListener('beforeunload', handleBeforeUnload)
})
const handlePageHide = () => {
if (activeQuestion.value > 0 && !quizSubmission.data) {
const params = new URLSearchParams({
quiz: quiz.data.name,
results: localStorage.getItem(quiz.data.title),
})
navigator.sendBeacon(
'/api/method/lms.lms.doctype.lms_quiz.lms_quiz.submit_quiz?' +
params.toString()
)
}
}
const handleBeforeUnload = (event) => {
if (activeQuestion.value > 0 && !quizSubmission.data) {
if (attemptedQuestions.value.length) {
switchQuestion(activeQuestion.value)
}
event.preventDefault()
event.returnValue = ''
}
}
const quiz = createResource({
url: 'frappe.client.get',
makeParams(values) {
@@ -486,10 +646,58 @@ const questionDetails = createResource({
watch(activeQuestion, (value) => {
if (value > 0) {
currentQuestion.value = quiz.data.questions[value - 1].question
questionDetails.reload()
questionDetails.reload(
{},
{
onSuccess() {
if (!quiz.data.show_answers) {
loadSavedAnswers()
}
},
}
)
}
})
const switchQuestion = (questionNumber) => {
let answers = getAnswers()
if (answers.length) {
if (!attemptedQuestions.value.includes(activeQuestion.value)) {
attemptedQuestions.value.push(activeQuestion.value)
}
addToLocalStorage()
resetQuestion()
}
if (questionNumber < 1 || questionNumber > questions.length) return
activeQuestion.value = questionNumber
}
const loadSavedAnswers = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
if (quizData) {
let localQuestion = quizData.find(
(q) => q.question_name == currentQuestion.value
)
if (localQuestion) {
let localAnswers = localQuestion.answer
if (localAnswers.length) {
if (questionDetails.data.type == 'Choices') {
localAnswers.forEach((answer) => {
for (let i = 1; i <= 4; i++) {
if (questionDetails.data[`option_${i}`] == answer) {
selectedOptions.value[i - 1] = 1
}
}
})
} else {
possibleAnswer.value = localAnswers[0]
}
}
}
}
}
watch(
() => props.quizName,
(newName) => {
@@ -507,17 +715,20 @@ const startQuiz = () => {
const markAnswer = (index) => {
if (!questionDetails.data.multiple)
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
selectedOptions[index - 1] = selectedOptions[index - 1] ? 0 : 1
selectedOptions.value.splice(
0,
selectedOptions.value.length,
...[0, 0, 0, 0]
)
selectedOptions.value[index - 1] = selectedOptions.value[index - 1] ? 0 : 1
}
const getAnswers = () => {
let answers = []
const type = questionDetails.data.type
if (type == 'Choices') {
selectedOptions.forEach((value, index) => {
if (selectedOptions[index])
selectedOptions.value.forEach((value, index) => {
if (selectedOptions.value[index])
answers.push(questionDetails.data[`option_${index + 1}`])
})
} else {
@@ -545,7 +756,7 @@ const checkAnswer = () => {
onSuccess(data) {
let type = questionDetails.data.type
if (type == 'Choices') {
selectedOptions.forEach((option, index) => {
selectedOptions.value.forEach((option, index) => {
if (option) {
showAnswers[index] = option && data[index]
} else if (data[index] == 2) {
@@ -571,12 +782,13 @@ const addToLocalStorage = () => {
question_name: currentQuestion.value,
answer: getAnswers(),
}
if (quizData) {
let existingQuestion = quizData.find(
(q) => q.question_name == questionData.question_name
)
if (!existingQuestion) {
if (existingQuestion) {
existingQuestion.answer = questionData.answer
} else {
quizData.push(questionData)
}
} else {
@@ -586,18 +798,15 @@ const addToLocalStorage = () => {
}
const nextQuestion = () => {
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
checkAnswer()
} else {
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion()
}
if (!quiz.data.show_answers) return
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion()
}
const resetQuestion = () => {
if (activeQuestion.value == quiz.data.questions.length) return
activeQuestion.value = activeQuestion.value + 1
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
selectedOptions.value.splice(0, selectedOptions.value.length, ...[0, 0, 0, 0])
showAnswers.length = 0
possibleAnswer.value = null
}
@@ -605,7 +814,6 @@ const resetQuestion = () => {
const submitQuiz = () => {
if (!quiz.data.show_answers) {
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
else checkAnswer()
setTimeout(() => {
createSubmission()
}, 500)
@@ -639,8 +847,10 @@ const createSubmission = () => {
const resetQuiz = () => {
activeQuestion.value = 0
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
selectedOptions.value.splice(0, selectedOptions.value.length, ...[0, 0, 0, 0])
showAnswers.length = 0
possibleAnswer.value = null
attemptedQuestions.value = []
quizSubmission.reset()
populateQuestions()
setupTimer()
@@ -669,6 +879,37 @@ const markLessonProgress = () => {
}
}
const handleSubmitClick = () => {
if (attemptedQuestions.value.length) {
switchQuestion(activeQuestion.value)
}
showSubmissionConfirmation.value = true
}
const paginationWindow = computed(() => {
const total = questions.length
const current = activeQuestion.value
const pages = []
const size = 5
let start = Math.floor((current - 1) / size) * size + 1
let end = Math.min(start + size - 1, total)
if (start > 1) {
pages.push('...')
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < total) {
pages.push('...')
}
return pages
})
const getSubmissionColumns = () => {
return [
{

View File

@@ -151,7 +151,6 @@ def process_results(results: list, quiz_details: dict):
["question", "marks", "question_detail", "type"],
as_dict=1,
)
result["question_name"] = question_details.question
result["question"] = question_details.question_detail
result["marks_out_of"] = question_details.marks
@@ -165,12 +164,13 @@ def process_results(results: list, quiz_details: dict):
result["marks"] = -quiz_details.marks_to_cut if quiz_details.enable_negative_marking else 0
score += result["marks"]
result["is_correct"] = 1 if correct else 0
else:
is_open_ended = True
result["is_correct"] = 0
result["answer"] = re.sub(
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"]
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"][0]
)
return {
@@ -263,10 +263,12 @@ def create_submission(quiz: str, results: list, score_out_of: int, passing_perce
def save_progress_after_quiz(quiz_details: dict, percentage: float):
if percentage >= quiz_details.passing_percentage and quiz_details.lesson and quiz_details.course:
save_progress(quiz_details.lesson, quiz_details.course)
elif not quiz_details.passing_percentage:
save_progress(quiz_details.lesson, quiz_details.course)
if not quiz_details.lesson or not quiz_details.course:
return
if quiz_details.passing_percentage and percentage < quiz_details.passing_percentage:
return
save_progress(quiz_details.lesson, quiz_details.course)
@frappe.whitelist()

View File

@@ -47,8 +47,8 @@ class TestLMSAPI(BaseTestUtils):
for quiz in progress.quizzes:
self.assertEqual(quiz.quiz, self.quiz.name)
self.assertEqual(quiz.quiz_title, self.quiz.title)
self.assertEqual(quiz.score, 12)
self.assertEqual(quiz.percentage, 80)
self.assertEqual(quiz.score, 10)
self.assertEqual(quiz.percentage, 66)
self.assertEqual(len(progress.assignments), 1)
for assignment in progress.assignments:
@@ -61,3 +61,25 @@ class TestLMSAPI(BaseTestUtils):
self.assertEqual(exercise.exercise, self.programming_exercise.name)
self.assertEqual(exercise.exercise_title, self.programming_exercise.title)
self.assertEqual(exercise.status, "Passed")
def test_quiz_submission(self):
submission = frappe.get_all(
"LMS Quiz Submission", filters={"quiz": self.quiz.name, "member": self.student1.name}
)
self.assertEqual(len(submission), 1)
submission = submission[0]
submission = frappe.get_doc("LMS Quiz Submission", submission.name)
self.assertEqual(submission.score, 10)
self.assertEqual(submission.score_out_of, 15)
self.assertEqual(submission.percentage, 66)
self.assertEqual(submission.passing_percentage, 70)
self.assertEqual(len(submission.result), 3)
for index, result in enumerate(submission.result):
self.assertEqual(result.question_name, self.quiz.questions[index].question)
self.assertEqual(
result.answer,
self.questions[index].option_1 if index % 2 == 0 else self.questions[index].option_2,
)
self.assertEqual(result.is_correct, 1 if index % 2 == 0 else 0)
self.assertEqual(result.marks, 5 if index % 2 == 0 else 0)

View File

@@ -1,8 +1,11 @@
import json
import frappe
from frappe.tests import UnitTestCase
from frappe.utils import add_days, nowdate
from lms.lms.doctype.lms_certificate.lms_certificate import get_default_certificate_template
from lms.lms.doctype.lms_quiz.lms_quiz import submit_quiz
class BaseTestUtils(UnitTestCase):
@@ -267,7 +270,7 @@ class BaseTestUtils(UnitTestCase):
}
)
question.save()
self.cleanup_items.append(("LMS Quiz Question", question.name))
self.cleanup_items.append(("LMS Question", question.name))
questions.append(question)
return questions
@@ -450,29 +453,22 @@ class BaseTestUtils(UnitTestCase):
existing = frappe.db.exists("LMS Quiz Submission", {"quiz": self.quiz.name, "member": member})
if existing:
return frappe.get_doc("LMS Quiz Submission", existing)
submission = frappe.new_doc("LMS Quiz Submission")
submission.update(
{
"quiz": self.quiz.name,
"member": member,
"score_out_of": self.quiz.total_marks,
"passing_percentage": self.quiz.passing_percentage,
}
)
for question in self.questions:
submission.append(
"result",
frappe.session.user = member
results = []
for index, question in enumerate(self.questions):
results.append(
{
"question": question.name,
"marks": 4,
"marks_out_of": 5,
},
"question_name": question.name,
"answer": [question.option_1 if index % 2 == 0 else question.option_2],
}
)
submission.insert()
self.cleanup_items.append(("LMS Quiz Submission", submission.name))
return submission
submit_quiz(self.quiz.name, json.dumps(results))
submission = frappe.db.get_value(
"LMS Quiz Submission", {"quiz": self.quiz.name, "member": member}, "name"
)
self.cleanup_items.append(("LMS Quiz Submission", submission))
frappe.session.user = "Administrator"
def _create_assignment_submission(self, member):
existing = frappe.db.exists(