diff --git a/src/components/Exercises/FillBlanks/index.tsx b/src/components/Exercises/FillBlanks/index.tsx index f59096a2..bd6a1c4c 100644 --- a/src/components/Exercises/FillBlanks/index.tsx +++ b/src/components/Exercises/FillBlanks/index.tsx @@ -20,6 +20,7 @@ const FillBlanks: React.FC = ({ onNext, onBack, }) => { + const { shuffleMaps } = useExamStore((state) => state); const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions); const hasExamEnded = useExamStore((state) => state.hasExamEnded); @@ -62,6 +63,15 @@ const FillBlanks: React.FC = ({ } else if ('letter' in option) { return solution.toLowerCase() === option.word.toLowerCase(); } else if ('options' in option) { + if (shuffleMaps.length !== 0) { + const shuffleMap = shuffleMaps.find((map) => map.id == x.id) + if (!shuffleMap) { + return false; + } + const original = shuffleMap[x.solution as keyof typeof shuffleMap]; + return solution.toLowerCase() === (option.options[original as keyof typeof option.options] || '').toLowerCase(); + } + return solution.toLowerCase() === (option.options[x.solution as keyof typeof option.options] || '').toLowerCase(); } return false; @@ -119,6 +129,18 @@ const FillBlanks: React.FC = ({ setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]); } + const getShuffles = () => { + let shuffle = {}; + if (shuffleMaps.length !== 0) { + shuffle = { + shuffleMaps: shuffleMaps.filter((map) => + answers.some(answer => answer.id === map.id) + ) + } + } + return shuffle; + } + return ( <>
@@ -190,14 +212,14 @@ const FillBlanks: React.FC = ({ diff --git a/src/components/Exercises/MultipleChoice.tsx b/src/components/Exercises/MultipleChoice.tsx index b6e1e3e0..1c07e9af 100644 --- a/src/components/Exercises/MultipleChoice.tsx +++ b/src/components/Exercises/MultipleChoice.tsx @@ -1,10 +1,10 @@ /* eslint-disable @next/next/no-img-element */ -import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"; +import { MultipleChoiceExercise, MultipleChoiceQuestion } from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import clsx from "clsx"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; import reactStringReplace from "react-string-replace"; -import {CommonProps} from "."; +import { CommonProps } from "."; import Button from "../Low/Button"; function Question({ @@ -14,12 +14,10 @@ function Question({ options, userSolution, onSelectOption, - setContextHighlight }: MultipleChoiceQuestion & { - userSolution: string | undefined; - onSelectOption?: (option: string) => void; + userSolution: string | undefined; + onSelectOption?: (option: string) => void; showSolution?: boolean, - setContextHighlight?: React.Dispatch> }) { /* @@ -35,11 +33,11 @@ function Question({ // {renderPrompt(prompt).filter((x) => x?.toString() !== "")}
{isNaN(Number(id)) ? ( - + ) : ( <> - {id} - + {id} - )} @@ -75,53 +73,79 @@ function Question({ ); } -export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) { - const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); +export default function MultipleChoice({ id, prompt, type, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) { + const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions); - const {questionIndex, setQuestionIndex} = useExamStore((state) => state); - const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state); + const { shuffleMaps } = useExamStore((state) => state); + const { questionIndex, setQuestionIndex } = useExamStore((state) => state); + const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state); const hasExamEnded = useExamStore((state) => state.hasExamEnded); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); useEffect(() => { - setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]); + setUserSolutions( + [...storeUserSolutions.filter((x) => x.exercise !== id), { + exercise: id, solutions: answers, score: calculateScore(), type + }]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [answers]); useEffect(() => { - if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); + if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasExamEnded]); const onSelectOption = (option: string) => { const question = questions[questionIndex]; - setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]); + setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]); }; const calculateScore = () => { const total = questions.length; - const correct = answers.filter( - (x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false, - ).length; - const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length; + const correct = answers.filter((x) => { + const matchingQuestion = questions.find((y) => { + return y.id.toString() === x.question.toString(); + }); - return {total, correct, missing}; + let isSolutionCorrect; + if (shuffleMaps.length == 0) { + isSolutionCorrect = matchingQuestion?.solution === x.option; + } else { + const shuffleMap = shuffleMaps.find((map) => map.id == x.question) + isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution; + } + return isSolutionCorrect || false; + }).length; + const missing = total - correct; + + return { total, correct, missing }; }; + const getShuffles = () => { + let shuffle = {}; + if (shuffleMaps.length !== 0) { + shuffle = { + shuffleMaps: shuffleMaps.filter((map) => + answers.some(answer => answer.question === map.id) + ) + } + } + return shuffle; + } + const next = () => { if (questionIndex === questions.length - 1) { - onNext({exercise: id, solutions: answers, score: calculateScore(), type}); + onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() }); } else { setQuestionIndex(questionIndex + 1); } - scrollToTop(); }; const back = () => { if (questionIndex === 0) { - onBack({exercise: id, solutions: answers, score: calculateScore(), type}); + onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() }); } else { setQuestionIndex(questionIndex - 1); } diff --git a/src/components/Solutions/FillBlanks.tsx b/src/components/Solutions/FillBlanks.tsx index 00f5ba47..807536f9 100644 --- a/src/components/Solutions/FillBlanks.tsx +++ b/src/components/Solutions/FillBlanks.tsx @@ -105,7 +105,7 @@ export default function FillBlanksSolutions({ solutionText = options.options[correctKey as keyof typeof options.options] || solution.solution; } else { correct = false; - solutionText = solution.solution; + solutionText = solution?.solution; } } else { diff --git a/src/components/Solutions/MultipleChoice.tsx b/src/components/Solutions/MultipleChoice.tsx index 7f1ba0fd..2c3f35e2 100644 --- a/src/components/Solutions/MultipleChoice.tsx +++ b/src/components/Solutions/MultipleChoice.tsx @@ -1,10 +1,10 @@ /* eslint-disable @next/next/no-img-element */ -import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"; +import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import clsx from "clsx"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; import reactStringReplace from "react-string-replace"; -import {CommonProps} from "."; +import { CommonProps } from "."; import Button from "../Low/Button"; function Question({ @@ -14,7 +14,30 @@ function Question({ solution, options, userSolution, -}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { +}: MultipleChoiceQuestion & { userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean }) { + const { userSolutions } = useExamStore((state) => state); + + const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => { + if (foundMap) return foundMap; + return userSolution.shuffleMaps?.find(map => map.id === id) || null; + }, null as ShuffleMap | null); + + const shuffledOptions = new Array(options.length); + options.forEach(option => { + const newId = questionShuffleMap?.map[option.id]; + const newIndex = options.findIndex(opt => opt.id === newId); + shuffledOptions[newIndex] = option; + }); + + const lettersMap = ['A', 'B', 'C', 'D']; + const optionsWithLetters = shuffledOptions.map((option, index) => ({ + ...option, + id: lettersMap[index] + })); + + const questionOptions = questionShuffleMap ? optionsWithLetters : options; + const newQuestionSolution = questionShuffleMap ? questionShuffleMap.map[solution] : solution; + const renderPrompt = (prompt: string) => { return reactStringReplace(prompt, /(()[\w\s']+(<\/u>))/g, (match) => { const word = match.replaceAll("", "").replaceAll("", ""); @@ -23,11 +46,11 @@ function Question({ }; const optionColor = (option: string) => { - if (option === solution && !userSolution) { + if (option === newQuestionSolution && !userSolution) { return "!border-mti-gray-davy !text-mti-gray-davy"; } - if (option === solution) { + if (option === newQuestionSolution) { return "!border-mti-purple-light !text-mti-purple-light"; } @@ -47,24 +70,24 @@ function Question({ )}
{variant === "image" && - options.map((option) => ( + questionOptions.map((option) => (
- {option.id} - {`Option + {option?.id} + {`Option
))} {variant === "text" && - options.map((option) => ( + questionOptions.map((option) => (
- {option.id}. - {option.text} + key={option?.id} + className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option!.id))}> + {option?.id}. + {option?.text}
))}
@@ -72,8 +95,8 @@ function Question({ ); } -export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) { - const {questionIndex, setQuestionIndex} = useExamStore((state) => state); +export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) { + const { questionIndex, setQuestionIndex } = useExamStore((state) => state); const calculateScore = () => { const total = questions.length; @@ -82,12 +105,12 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio ).length; const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length; - return {total, correct, missing}; + return { total, correct, missing }; }; const next = () => { if (questionIndex === questions.length - 1) { - onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type}); + onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type }); } else { setQuestionIndex(questionIndex + 1); } @@ -95,7 +118,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio const back = () => { if (questionIndex === 0) { - onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type}); + onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type }); } else { setQuestionIndex(questionIndex - 1); } diff --git a/src/exams/Level.tsx b/src/exams/Level.tsx index 76b30b8d..2cd591f4 100644 --- a/src/exams/Level.tsx +++ b/src/exams/Level.tsx @@ -5,14 +5,15 @@ import Button from "@/components/Low/Button"; import ModuleTitle from "@/components/Medium/ModuleTitle"; import { renderSolution } from "@/components/Solutions"; import { infoButtonStyle } from "@/constants/buttonStyles"; -import { LevelExam, LevelPart, UserSolution, WritingExam } from "@/interfaces/exam"; +import { Module } from "@/interfaces"; +import { Exercise, FillBlanksExercise, FillBlanksMCOption, LevelExam, LevelPart, MultipleChoiceExercise, ShuffleMap, UserSolution, WritingExam } from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import { defaultUserSolutions } from "@/utils/exams"; import { countExercises } from "@/utils/moduleUtils"; import { mdiArrowRight } from "@mdi/js"; import Icon from "@mdi/react"; import clsx from "clsx"; -import { Fragment, use, useEffect, useRef, useState } from "react"; +import { Dispatch, Fragment, SetStateAction, use, useEffect, useMemo, useRef, useState } from "react"; import { BsChevronDown, BsChevronUp } from "react-icons/bs"; import { toast } from "react-toastify"; @@ -39,7 +40,7 @@ function TextComponent({ const lineHeightValue = parseFloat(computedStyle.lineHeight); const containerWidth = textRef.current.clientWidth; setLineHeight(lineHeightValue); - + const offscreenElement = document.createElement('div'); offscreenElement.style.position = 'absolute'; offscreenElement.style.top = '-9999px'; @@ -50,51 +51,51 @@ function TextComponent({ offscreenElement.style.lineHeight = computedStyle.lineHeight; offscreenElement.style.wordWrap = 'break-word'; offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign; - + const textContent = textRef.current.textContent || ''; textContent.split(/(\s+)/).forEach((word: string) => { const span = document.createElement('span'); span.textContent = word; offscreenElement.appendChild(span); }); - + document.body.appendChild(offscreenElement); - + const lines: string[][] = [[]]; let currentLine = 1; let currentLineTop: number | undefined; let contextWordLine: number | null = null; - + const firstChild = offscreenElement.firstChild as HTMLElement; if (firstChild) { currentLineTop = firstChild.getBoundingClientRect().top; } - + const spans = offscreenElement.querySelectorAll('span'); - + spans.forEach(span => { const rect = span.getBoundingClientRect(); const top = rect.top; - + if (currentLineTop !== undefined && top > currentLineTop) { currentLine++; currentLineTop = top; lines.push([]); } - + lines[lines.length - 1].push(span.textContent?.trim() || ''); - + if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) { contextWordLine = currentLine; } }); - + setLineNumbers(lines.map((_, index) => index + 1)); if (contextWordLine) { setContextWordLine(contextWordLine); } - + document.body.removeChild(offscreenElement); } }; @@ -154,6 +155,12 @@ function TextComponent({ } +const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => { + return Array.isArray(words) && words.every( + word => word && typeof word === 'object' && 'id' in word && 'options' in word + ); +} + export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) { const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]); @@ -164,6 +171,8 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = const { partIndex, setPartIndex } = useExamStore((state) => state); const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state); const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]); + const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps]) + const [currentExercise, setCurrentExercise] = useState(); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); @@ -171,6 +180,12 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = const [contextWord, setContextWord] = useState(undefined); const [contextWordLine, setContextWordLine] = useState(undefined); + useEffect(() => { + if (showSolutions && userSolutions[exerciseIndex].shuffleMaps) { + setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[]) + } + }, [showSolutions]) + useEffect(() => { if (hasExamEnded && exerciseIndex === -1) { setExerciseIndex(exerciseIndex + 1); @@ -186,6 +201,128 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = onFinish(userSolutions); }; + + const getExercise = () => { + if (exerciseIndex === -1) { + return undefined; + } + let exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; + if (!exercise) return undefined; + + exercise = { + ...exercise, + userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], + }; + + if (exam.shuffle && exercise.type === "multipleChoice") { + if (shuffleMaps.length == 0 && !showSolutions) { + const newShuffleMaps: ShuffleMap[] = []; + + exercise.questions = exercise.questions.map(question => { + const options = [...question.options]; + let shuffledOptions = [...options].sort(() => Math.random() - 0.5); + + const newOptions = options.map((option, index) => ({ + id: option.id, + text: shuffledOptions[index].text + })); + + const optionMapping = options.reduce<{ [key: string]: string }>((acc, originalOption) => { + const shuffledPosition = newOptions.find(newOpt => newOpt.text === originalOption.text)?.id; + if (shuffledPosition) { + acc[shuffledPosition] = originalOption.id; + } + return acc; + }, {}); + + newShuffleMaps.push({ id: question.id, map: optionMapping }); + + return { ...question, options: newOptions }; + }); + + setShuffleMaps(newShuffleMaps); + } else { + exercise.questions = exercise.questions.map(question => { + const questionShuffleMap = shuffleMaps.find(map => map.id === question.id); + if (questionShuffleMap) { + const newOptions = question.options.map(option => ({ + id: option.id, + text: question.options.find(o => questionShuffleMap.map[o.id] === option.id)?.text || option.text + })); + return { ...question, options: newOptions }; + } + return question; + }); + } + } else if (exam.shuffle && exercise.type === "fillBlanks" && typeCheckWordsMC(exercise.words)) { + if (shuffleMaps.length === 0 && !showSolutions) { + const newShuffleMaps: ShuffleMap[] = []; + + exercise.words = exercise.words.map(word => { + if ('options' in word) { + const options = { ...word.options }; + const shuffledKeys = Object.keys(options).sort(() => Math.random() - 0.5); + + const newOptions = shuffledKeys.reduce((acc, key, index) => { + acc[key as keyof typeof options] = options[shuffledKeys[index] as keyof typeof options]; + return acc; + }, {} as { [key in keyof typeof options]: string }); + + const optionMapping = shuffledKeys.reduce((acc, key, index) => { + acc[key as keyof typeof options] = Object.keys(options)[index] as keyof typeof options; + return acc; + }, {} as { [key in keyof typeof options]: string }); + + newShuffleMaps.push({ id: word.id, map: optionMapping }); + + return { ...word, options: newOptions }; + } + return word; + }); + + setShuffleMaps(newShuffleMaps); + } + } + + return exercise; + }; + + useEffect(() => { + const newExercise = getExercise(); + setCurrentExercise(newExercise); + }, [partIndex, exerciseIndex]); + + + //useShuffledMultipleChoiceOptions(currentExercise, exam.shuffle, storeQuestionIndex, shuffleMaps, setShuffleMaps, setCurrentExercise); + //useShuffledFillBlanksOptions(currentExercise, exam.shuffle, storeQuestionIndex, shuffleMaps, setShuffleMaps, setCurrentExercise); + + + useEffect(() => { + const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; + if (currentExercise && currentExercise.type === "multipleChoice") { + const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex); + if (match) { + const word = match[1]; + const originalLineNumber = match[2]; + setContextHighlight([word]); + + if (word !== contextWord) { + setContextWord(word); + } + + const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace( + `in line ${originalLineNumber}`, + `in line ${contextWordLine || originalLineNumber}` + ); + + currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt; + } else { + setContextHighlight([]); + setContextWord(undefined); + } + } + }, [storeQuestionIndex, contextWordLine, exerciseIndex, partIndex, shuffleMaps]); + const nextExercise = (solution?: UserSolution) => { scrollToTop(); if (solution) { @@ -193,8 +330,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = } if (storeQuestionIndex > 0) { - const exercise = getExercise(); - setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise!.id), { id: exercise!.id, amount: storeQuestionIndex }]); + setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: storeQuestionIndex }]); } setStoreQuestionIndex(0); @@ -225,7 +361,11 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = setHasExamEnded(false); if (solution) { - onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); + let stat = { ...solution, module: "level" as Module, exam: exam.id } + if (exam.shuffle) { + stat.shuffleMaps = shuffleMaps + } + onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...stat }]); } else { onFinish(userSolutions); } @@ -238,25 +378,13 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = } if (storeQuestionIndex > 0) { - const exercise = getExercise(); - setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise!.id), { id: exercise!.id, amount: storeQuestionIndex }]); + setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: storeQuestionIndex }]); } setStoreQuestionIndex(0); setExerciseIndex(exerciseIndex - 1); }; - const getExercise = () => { - if (exerciseIndex === -1) { - return undefined; - } - const exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; - return { - ...exercise, - userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], - }; - }; - const calculateExerciseIndex = () => { if (partIndex === 0) return ( @@ -292,35 +420,6 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
); - const exercise = getExercise(); - - - useEffect(() => { - const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; - if (exercise && exercise.type === "multipleChoice") { - const match = exercise.questions[storeQuestionIndex].prompt.match(regex); - if (match) { - const word = match[1]; - const originalLineNumber = match[2]; - setContextHighlight([word]); - - if (word !== contextWord) { - setContextWord(word); - } - - const updatedPrompt = exercise.questions[storeQuestionIndex].prompt.replace( - `in line ${originalLineNumber}`, - `in line ${contextWordLine || originalLineNumber}` - ); - - exercise.questions[storeQuestionIndex].prompt = updatedPrompt; - } else { - setContextHighlight([]); - setContextWord(undefined); - } - } - }, [storeQuestionIndex, contextWordLine]); - return ( <>
@@ -344,7 +443,8 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = exerciseIndex < exam.parts[partIndex].exercises.length && !showSolutions && !editing && - renderExercise(exercise!, exam.id, nextExercise, previousExercise)} + currentExercise && + renderExercise(currentExercise, exam.id, nextExercise, previousExercise)} {exerciseIndex > -1 && partIndex > -1 && diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index 514ac6fa..85a1bc2c 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -12,6 +12,7 @@ interface ExamBase { isDiagnostic: boolean; variant?: Variant; difficulty?: Difficulty; + shuffle?: boolean; createdBy?: string; // option as it has been added later createdAt?: string; // option as it has been added later } @@ -66,6 +67,7 @@ export interface UserSolution { }; exercise: string; isDisabled?: boolean; + shuffleMaps?: ShuffleMap[] } export interface WritingExam extends ExamBase { @@ -78,7 +80,7 @@ interface WordCounter { limit: number; } -export interface SpeakingExam extends ExamBase { +export interface SpeakingExam extends ExamBase { module: "speaking"; exercises: (SpeakingExercise | InteractiveSpeakingExercise)[]; instructorGender: InstructorGender; @@ -97,8 +99,8 @@ export type Exercise = export interface Evaluation { comment: string; overall: number; - task_response: {[key: string]: number | {grade: number; comment: string}}; - misspelled_pairs?: {correction: string | null; misspelled: string}[]; + task_response: { [key: string]: number | { grade: number; comment: string } }; + misspelled_pairs?: { correction: string | null; misspelled: string }[]; } @@ -111,10 +113,9 @@ type InteractiveTranscriptType = { [key in InteractiveTranscriptKey]?: string }; type InteractiveFixedTextType = { [key in InteractiveFixedTextKey]?: string }; interface InteractiveSpeakingEvaluation extends Evaluation, -InteractivePerfectAnswerType, -InteractiveTranscriptType, -InteractiveFixedTextType -{} + InteractivePerfectAnswerType, + InteractiveTranscriptType, + InteractiveFixedTextType { } interface SpeakingEvaluation extends CommonEvaluation { @@ -233,7 +234,7 @@ export interface TrueFalseExercise { id: string; prompt: string; // *EXAMPLE: "Select the appropriate option." questions: TrueFalseQuestion[]; - userSolutions: {id: string; solution: "true" | "false" | "not_given"}[]; + userSolutions: { id: string; solution: "true" | "false" | "not_given" }[]; } export interface TrueFalseQuestion { @@ -262,7 +263,7 @@ export interface MatchSentencesExercise { type: "matchSentences"; id: string; prompt: string; - userSolutions: {question: string; option: string}[]; + userSolutions: { question: string; option: string }[]; sentences: MatchSentenceExerciseSentence[]; allowRepetition: boolean; options: MatchSentenceExerciseOption[]; @@ -285,8 +286,7 @@ export interface MultipleChoiceExercise { id: string; prompt: string; // *EXAMPLE: "Select the appropriate option." questions: MultipleChoiceQuestion[]; - userSolutions: {question: string; option: string}[]; - setContextHighlight?: React.Dispatch> + userSolutions: { question: string; option: string }[]; } export interface MultipleChoiceQuestion { @@ -299,4 +299,12 @@ export interface MultipleChoiceQuestion { src?: string; // *EXAMPLE: "https://i.imgur.com/rEbrSqA.png" (only used if the variant is "image") text?: string; // *EXAMPLE: "wallet, pens and novel" (only used if the variant is "text") }[]; + shuffleMap?: Record; +} + +export interface ShuffleMap { + id: string; + map: { + [key: string]: string; + } } diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index ddda1ccb..96603ac1 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -1,5 +1,5 @@ import { Module } from "."; -import { InstructorGender } from "./exam"; +import { InstructorGender, ShuffleMap } from "./exam"; import { PermissionType } from "./permissions"; export type User = @@ -148,6 +148,7 @@ export interface Stat { missing: number; }; isDisabled?: boolean; + shuffleMaps?: ShuffleMap[]; } export interface Group { diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index 671712af..57b38e4b 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -12,7 +12,7 @@ import Selection from "@/exams/Selection"; import Speaking from "@/exams/Speaking"; import Writing from "@/exams/Writing"; import useUser from "@/hooks/useUser"; -import {Exam, UserSolution, Variant} from "@/interfaces/exam"; +import {Exam, LevelExam, UserSolution, Variant} from "@/interfaces/exam"; import {Stat} from "@/interfaces/user"; import useExamStore from "@/stores/examStore"; import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation"; @@ -257,6 +257,7 @@ export default function ExamPage({page}: Props) { user: user?.id || "", date: new Date().getTime(), isDisabled: solution.isDisabled, + shuffleMaps: solution.shuffleMaps, ...(assignment ? {assignment: assignment.id} : {}), })); @@ -459,6 +460,19 @@ export default function ExamPage({page}: Props) { inactivity: totalInactivity, }} onViewResults={(index?: number) => { + if (exams[0].module === "level") { + const levelExam = exams[0] as LevelExam; + const allExercises = levelExam.parts.flatMap(part => part.exercises); + const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index])); + const orderedSolutions = userSolutions.slice().sort((a, b) => { + const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity; + const indexB = exerciseOrderMap.get(b.exercise) ?? Infinity; + return indexA - indexB; + }); + setUserSolutions(orderedSolutions); + } else { + setUserSolutions(userSolutions); + } setShowSolutions(true); setModuleIndex(index || 0); setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0); diff --git a/src/stores/examStore.ts b/src/stores/examStore.ts index 4c0184e5..60bc8fcb 100644 --- a/src/stores/examStore.ts +++ b/src/stores/examStore.ts @@ -1,5 +1,5 @@ import {Module} from "@/interfaces"; -import {Exam, UserSolution} from "@/interfaces/exam"; +import {Exam, ShuffleMap, UserSolution} from "@/interfaces/exam"; import {Assignment} from "@/interfaces/results"; import {create} from "zustand"; @@ -18,6 +18,7 @@ export interface ExamState { exerciseIndex: number; questionIndex: number; inactivity: number; + shuffleMaps: ShuffleMap[]; } export interface ExamFunctions { @@ -35,6 +36,7 @@ export interface ExamFunctions { setExerciseIndex: (exerciseIndex: number) => void; setQuestionIndex: (questionIndex: number) => void; setInactivity: (inactivity: number) => void; + setShuffleMaps: (shuffleMaps: ShuffleMap[]) => void; reset: () => void; } @@ -53,6 +55,7 @@ export const initialState: ExamState = { exerciseIndex: -1, questionIndex: 0, inactivity: 0, + shuffleMaps: [] }; const useExamStore = create((set) => ({ @@ -72,6 +75,7 @@ const useExamStore = create((set) => ({ setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})), setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})), setInactivity: (inactivity: number) => set(() => ({inactivity})), + setShuffleMaps: (shuffleMaps) => set(() => ({shuffleMaps})), reset: () => set(() => initialState), })); diff --git a/src/utils/stats.ts b/src/utils/stats.ts index ebd2f074..e60fcec7 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -137,5 +137,6 @@ export const convertToUserSolutions = (stats: Stat[]): UserSolution[] => { solutions: stat.solutions, type: stat.type, module: stat.module, + shuffleMaps: stat.shuffleMaps })); };