mirror of
https://github.com/frappe/lms.git
synced 2026-04-19 22:52:29 +03:00
feat: navigate between questions in quiz
This commit is contained in:
1
.github/workflows/ui-tests.yml
vendored
1
.github/workflows/ui-tests.yml
vendored
@@ -17,7 +17,6 @@ jobs:
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'frappe' }}
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,56 +1,65 @@
|
||||
<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>
|
||||
{{
|
||||
__('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 +144,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 +153,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 +219,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 +228,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 +283,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 +365,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 +420,31 @@ 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, 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
|
||||
@@ -486,10 +597,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 +666,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 +707,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 +733,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 +749,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 +765,6 @@ const resetQuestion = () => {
|
||||
const submitQuiz = () => {
|
||||
if (!quiz.data.show_answers) {
|
||||
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
|
||||
else checkAnswer()
|
||||
setTimeout(() => {
|
||||
createSubmission()
|
||||
}, 500)
|
||||
@@ -639,8 +798,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 +830,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 [
|
||||
{
|
||||
|
||||
@@ -145,6 +145,7 @@ def process_results(results: list, quiz_details: dict):
|
||||
is_open_ended = False
|
||||
|
||||
for result in results:
|
||||
print(result)
|
||||
question_details = frappe.db.get_value(
|
||||
"LMS Quiz Question",
|
||||
{"parent": quiz_details.name, "question": result["question_name"]},
|
||||
@@ -170,7 +171,7 @@ def process_results(results: list, quiz_details: dict):
|
||||
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 +264,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()
|
||||
|
||||
Reference in New Issue
Block a user