Merged in feature/level-file-upload (pull request #78)

Shuffles fixed

Approved-by: Tiago Ribeiro
This commit is contained in:
carlos.mesquita
2024-08-25 17:36:17 +00:00
committed by Tiago Ribeiro
14 changed files with 772 additions and 416 deletions

View File

@@ -20,9 +20,11 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
onNext, onNext,
onBack, onBack,
}) => { }) => {
const { shuffleMaps, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state); const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions); const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>(); const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>();
@@ -42,42 +44,42 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
}, [hasExamEnded]); }, [hasExamEnded]);
let correctWords: any; let correctWords: any;
if (exam && exam.module === "level" && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") { if (exam && exam.module === "level" && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words; correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
} }
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers!.filter((x) => { const correct = answers!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
if (!solution) return false; if (!solution) return false;
const option = correctWords!.find((w: any) => { const option = correctWords!.find((w: any) => {
if (typeof w === "string") { if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase(); return w.toLowerCase() === x.solution.toLowerCase();
} else if ('letter' in w) { } else if ('letter' in w) {
return w.word.toLowerCase() === x.solution.toLowerCase(); return w.word.toLowerCase() === x.solution.toLowerCase();
} else { } else {
return w.id.toString() === x.id.toString(); return w.id.toString() === x.id.toString();
} }
}); });
if (!option) return false; if (!option) return false;
if (typeof option === "string") { if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase(); return solution.toLowerCase() === option.toLowerCase();
} else if ('letter' in option) { } else if ('letter' in option) {
return solution.toLowerCase() === option.word.toLowerCase(); return solution.toLowerCase() === option.word.toLowerCase();
} else if ('options' in option) { } else if ('options' in option) {
return option.options[solution as keyof typeof option.options] == x.solution; return option.options[solution as keyof typeof option.options] == x.solution;
} }
return false; return false;
}).length; }).length;
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing }; return { total, correct, missing };
}; };
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
<div className="text-base leading-5"> <div className="text-base leading-5" key={v4()}>
{reactStringReplace(line, /({{\d+}})/g, (match) => { {reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, ""); const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id); const userSolution = answers.find((x) => x.id === id);
@@ -121,21 +123,14 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
); );
}; };
const onSelection = (id: string, value: string) => { const onSelection = (questionID: string, value: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]); setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
} }
const getShuffles = () => { useEffect(() => {
let shuffle = {}; setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
if (shuffleMaps.length !== 0) { // eslint-disable-next-line react-hooks/exhaustive-deps
shuffle = { }, [answers])
shuffleMaps: shuffleMaps.filter((map) =>
answers.some(answer => answer.id === map.id)
)
}
}
return shuffle;
}
return ( return (
<> <>
@@ -220,18 +215,21 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })} onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
className="max-w-[200px] w-full" className="max-w-[200px] w-full"
disabled={ disabled={
exam && typeof partIndex !== "undefined" && exam.module === "level" && exam && exam.module === "level" &&
typeof exam.parts[0].intro === "string" && questionIndex === 0} typeof exam.parts[0].intro === "string" &&
partIndex === 0 &&
questionIndex === 0
}
> >
Back Back
</Button> </Button>
<Button <Button
color="purple" color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })} onClick={() => { onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps }) }}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>

View File

@@ -1,5 +1,5 @@
/* eslint-disable @next/next/no-img-element */ /* 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 useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -24,7 +24,7 @@ function Question({
const renderPrompt = (prompt: string) => { const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => { return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", ""); const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null; return word.length > 0 ? <u key={v4()}>{word}</u> : null;
}); });
}; };
@@ -49,7 +49,7 @@ function Question({
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none", "flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
userSolution === option.id.toString() && "border-mti-purple-light", userSolution === option.id.toString() && "border-mti-purple-light",
)}> )}>
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span> <span key={v4()} className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
<img src={option.src!} alt={`Option ${option.id.toString()}`} /> <img src={option.src!} alt={`Option ${option.id.toString()}`} />
</div> </div>
))} ))}
@@ -77,13 +77,17 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
const { const {
questionIndex, questionIndex,
exam, exam,
shuffleMaps, shuffles,
hasExamEnded, hasExamEnded,
partIndex,
userSolutions: storeUserSolutions, userSolutions: storeUserSolutions,
setQuestionIndex, setQuestionIndex,
setUserSolutions setUserSolutions,
setCurrentSolution
} = useExamStore((state) => state); } = useExamStore((state) => state);
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => { useEffect(() => {
@@ -104,6 +108,21 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
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 }]);
}; };
useEffect(() => {
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers])
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
if (originalPosition === originalSolution) {
return newPosition;
}
}
return originalSolution;
}
const calculateScore = () => { const calculateScore = () => {
const total = questions.length; const total = questions.length;
const correct = answers.filter((x) => { const correct = answers.filter((x) => {
@@ -112,34 +131,25 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
}); });
let isSolutionCorrect; let isSolutionCorrect;
if (shuffleMaps.length == 0) { if (!shuffleMaps) {
isSolutionCorrect = matchingQuestion?.solution === x.option; isSolutionCorrect = matchingQuestion?.solution === x.option;
} else { } else {
const shuffleMap = shuffleMaps.find((map) => map.id == x.question) const shuffleMap = shuffleMaps.find((map) => map.questionID == x.question);
isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution; if (shuffleMap) {
isSolutionCorrect = getShuffledSolution(x.option, shuffleMap) == matchingQuestion?.solution;
} else {
isSolutionCorrect = matchingQuestion?.solution === x.option;
}
} }
return isSolutionCorrect || false; return isSolutionCorrect || false;
}).length; }).length;
const missing = total - correct; const missing = total - correct;
return { total, correct, missing }; 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 = () => { const next = () => {
if (questionIndex === questions.length - 1) { if (questionIndex === questions.length - 1) {
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() }); onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
} else { } else {
setQuestionIndex(questionIndex + 1); setQuestionIndex(questionIndex + 1);
} }
@@ -148,8 +158,9 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
const back = () => { const back = () => {
if (questionIndex === 0) { if (questionIndex === 0) {
onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() }); onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
} else { } else {
if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
setQuestionIndex(questionIndex - 1); setQuestionIndex(questionIndex - 1);
} }
@@ -172,7 +183,11 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full" <Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"
disabled={ disabled={
exam && exam.module === "level" && typeof exam.parts[0].intro === "string" && questionIndex === 0} exam && exam.module === "level" &&
typeof exam.parts[0].intro === "string" &&
partIndex === 0 &&
questionIndex === 0
}
> >
Back Back
</Button> </Button>

View File

@@ -9,6 +9,7 @@ interface Props {
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
padding?: string;
onClick?: () => void; onClick?: () => void;
type?: "button" | "reset" | "submit"; type?: "button" | "reset" | "submit";
} }
@@ -21,6 +22,7 @@ export default function Button({
className, className,
children, children,
type, type,
padding = "py-4 px-6",
onClick, onClick,
}: Props) { }: Props) {
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = { const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
@@ -61,7 +63,8 @@ export default function Button({
type={type} type={type}
onClick={onClick} onClick={onClick}
className={clsx( className={clsx(
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer", "rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer select-none",
padding,
colorClassNames[color][variant], colorClassNames[color][variant],
className, className,
)} )}

View File

@@ -1,13 +1,17 @@
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import useExamStore from "@/stores/examStore"; import { moduleLabels } from "@/utils/moduleUtils";
import {moduleLabels} from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import {motion} from "framer-motion"; import { Fragment, ReactNode, useCallback, useState } from "react";
import {ReactNode, useEffect, useState} from "react"; import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar"; import ProgressBar from "../Low/ProgressBar";
import TimerEndedModal from "../TimerEndedModal";
import Timer from "./Timer"; import Timer from "./Timer";
import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap, UserSolution } from "@/interfaces/exam";
import { BsFillGrid3X3GapFill } from "react-icons/bs";
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import Button from "../Low/Button";
import { Dialog, Transition } from "@headlessui/react";
import useExamStore from "@/stores/examStore";
import Modal from "../Modal";
interface Props { interface Props {
minTimer: number; minTimer: number;
@@ -18,13 +22,32 @@ interface Props {
disableTimer?: boolean; disableTimer?: boolean;
partLabel?: string; partLabel?: string;
showTimer?: boolean; showTimer?: boolean;
showSolutions?: boolean;
runOnClick?: ((questionIndex: number) => void) | undefined;
} }
export default function ModuleTitle({ export default function ModuleTitle({
minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true minTimer,
module,
label,
exerciseIndex,
totalExercises,
disableTimer = false,
partLabel,
showTimer = true,
showSolutions = false,
runOnClick = undefined
}: Props) { }: Props) {
const {
userSolutions,
partIndex,
exam
} = useExamStore((state) => state);
const examExerciseIndex = useExamStore((state) => state.exerciseIndex)
const moduleIcon: {[key in Module]: ReactNode} = { const [isOpen, setIsOpen] = useState(false);
const moduleIcon: { [key in Module]: ReactNode } = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />, reading: <BsBook className="text-ielts-reading w-6 h-6" />,
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />, listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
writing: <BsPen className="text-ielts-writing w-6 h-6" />, writing: <BsPen className="text-ielts-writing w-6 h-6" />,
@@ -32,6 +55,78 @@ export default function ModuleTitle({
level: <BsClipboard className="text-ielts-level w-6 h-6" />, level: <BsClipboard className="text-ielts-level w-6 h-6" />,
}; };
const isMultipleChoiceLevelExercise = () => {
if (exam?.module === 'level' && typeof partIndex === "number" && partIndex > -1) {
const currentExercise = (exam as LevelExam).parts[partIndex].exercises[examExerciseIndex];
return currentExercise && currentExercise.type === 'multipleChoice';
}
return false;
};
const renderMCQuestionGrid = () => {
if (!isMultipleChoiceLevelExercise() && !userSolutions) return null;
const currentExercise = (exam as LevelExam).parts[partIndex!].exercises[examExerciseIndex] as MultipleChoiceExercise;
const userSolution = userSolutions!.find((x) => x.exercise == currentExercise.id)!;
const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question));
const exerciseOffset = currentExercise.questions[0].id;
const lastExercise = exerciseOffset + (currentExercise.questions.length - 1);
const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => {
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
if (foundMap) return foundMap;
return userSolution.shuffleMaps?.find(map => map.questionID.toString() === questionId.toString()) || null;
}, null as ShuffleMap | null);
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
if (!userSolutions) return "";
if (!userQuestionSolution) {
return "!bg-mti-gray-davy !border--mti-gray-davy !text-mti-gray-davy !text-white hover:!bg-gray-700";
}
return userQuestionSolution === newSolution ?
"!bg-mti-purple-light !text-mti-purple-light !text-white hover:!bg-mti-purple-dark" :
"!bg-mti-rose-light !border-mti-rose-light !text-mti-rose-light !text-white hover:!bg-mti-rose-dark";
}
return (
<>
<h3 className="text-xl font-semibold mb-4 text-center">{`Part ${partIndex + 1} (Questions ${exerciseOffset} - ${lastExercise})`}</h3>
<div className="grid grid-cols-5 gap-3 px-4 py-2">
{currentExercise.questions.map((_, index) => {
const questionNumber = exerciseOffset + index;
const isAnswered = answeredQuestions.has(questionNumber);
const solution = currentExercise.questions.find((x) => x.id == questionNumber)!.solution;
const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question == questionNumber)?.option;
return (
<Button
variant={showSolutions ? "solid" : (isAnswered ? "solid" : "outline")}
key={index}
className={clsx(
"w-12 h-12 flex items-center justify-center rounded-lg text-sm font-bold transition-all duration-200 ease-in-out",
(showSolutions ?
getQuestionColor(questionNumber.toString(), solution, userQuestionSolution) :
(isAnswered ?
"bg-mti-purple-light border-mti-purple-light text-white hover:bg-mti-purple-dark hover:border-mti-purple-dark":
"bg-white border-gray-400 hover:bg-gray-100 hover:text-gray-700"
)
)
)}
onClick={() => { if (typeof runOnClick !== "undefined") { runOnClick(index); } setIsOpen(false); }}
>
{questionNumber}
</Button>
);
})}
</div>
<p className="mt-4 text-sm text-gray-600 text-center">
Click a question number to jump to that question
</p>
</>
);
};
return ( return (
<> <>
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />} {showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
@@ -67,6 +162,22 @@ export default function ModuleTitle({
</div> </div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" /> <ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
</div> </div>
{isMultipleChoiceLevelExercise() && (
<>
<Button variant="outline" onClick={() => setIsOpen(true)} padding="p-2" className="rounded-lg">
<BsFillGrid3X3GapFill size={24} />
</Button>
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white shadow-xl transition-all"
>
<>
{renderMCQuestionGrid()}
</>
</Modal>
</>
)}
</div> </div>
</div> </div>
</> </>

View File

@@ -51,7 +51,7 @@ const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) =>
<motion.div <motion.div
className={clsx( className={clsx(
"absolute right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy", "absolute right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
standalone ? "top-6" : "top-4", standalone ? "top-10" : "top-4",
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt", warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
)} )}
initial={{ scale: warningMode && !disableTimer ? 0.8 : 1 }} initial={{ scale: warningMode && !disableTimer ? 0.8 : 1 }}

View File

@@ -1,15 +1,28 @@
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { Fragment } from "react"; import { Fragment, useEffect, useState } from "react";
import Button from "./Low/Button"; import Button from "./Low/Button";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
blankQuestions?: boolean; type?: "module" | "blankQuestions" | "submit";
finishingWhat? : string; unanswered?: boolean;
onClose: (next?: boolean) => void; onClose: (next?: boolean) => void;
} }
export default function QuestionsModal({ isOpen, onClose, blankQuestions = true, finishingWhat = "module" }: Props) { export default function QuestionsModal({ isOpen, onClose, type = "module", unanswered = false }: Props) {
const [isClosing, setIsClosing] = useState(false);
const blockMultipleClicksClose = (x: boolean) => {
if (!isClosing) {
setIsClosing(true);
onClose(x);
}
setTimeout(() => {
setIsClosing(false);
}, 400);
}
return ( return (
<Transition show={isOpen} as={Fragment}> <Transition show={isOpen} as={Fragment}>
<Dialog onClose={() => onClose(false)} className="relative z-50"> <Dialog onClose={() => onClose(false)} className="relative z-50">
@@ -34,39 +47,67 @@ export default function QuestionsModal({ isOpen, onClose, blankQuestions = true,
leaveTo="opacity-0 scale-95"> leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4"> <div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4"> <Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
{blankQuestions ? ( {type === "module" && (
<> <>
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title> <Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
<span> <span>
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
able to change the answers of the current one, including your unanswered questions. <br /> able to change the answers of the current one, including your unanswered questions. <br />
<br /> <br />
Are you sure you want to continue without completing those questions? Are you sure you want to continue without completing those questions?
</span> </span>
<div className="w-full flex justify-between mt-8"> <div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back Go Back
</Button> </Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full">
Continue Continue
</Button> </Button>
</div> </div>
</> </>
): ( )}
{type === "blankQuestions" && (
<>
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
<div className="flex flex-col text-lg gap-2">
<p>You have left some questions unanswered in the current part.</p>
<p>If you wish to continue, you can still access this part later using the navigation bar at the top or the &quot;Back&quot; button.</p>
<p>Do you want to proceed to the next part, or would you like to go back and complete the unanswered questions in the current part?</p>
</div>
<div className="w-full flex justify-between mt-6">
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back
</Button>
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full">
Continue
</Button>
</div>
</>
)}
{type === "submit" && (
<> <>
<Dialog.Title className="font-bold text-xl">Confirm Submission</Dialog.Title> <Dialog.Title className="font-bold text-xl">Confirm Submission</Dialog.Title>
<span> <span>
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be {unanswered ? (
able to review the answers of the current one. <br /> <>
<br /> By clicking &quot;Submit,&quot; you are finalizing your exam with some <b>questions left unanswered</b>. Once you submit, you will not be able to review or change any of your answers, including the unanswered ones. <br />
Are you sure you want to continue? <br />
Are you sure you want to submit and complete the exam with unanswered questions?
</>
) : (
<>
By clicking &quot;Submit,&quot; you are finalizing your exam. Once you submit, you will not be able to review or change any of your answers. <br />
<br />
Are you sure you want to submit and complete the exam?
</>
)}
</span> </span>
<div className="w-full flex justify-between mt-8"> <div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back Go Back
</Button> </Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full">
Continue Submit
</Button> </Button>
</div> </div>
</> </>

View File

@@ -1,4 +1,4 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam"; import { FillBlanksExercise, FillBlanksMCOption, ShuffleMap } from "@/interfaces/exam";
import clsx from "clsx"; import clsx from "clsx";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { CommonProps } from "."; import { CommonProps } from ".";
@@ -16,19 +16,18 @@ export default function FillBlanksSolutions({
onNext, onNext,
onBack, onBack,
}: FillBlanksExercise & CommonProps) { }: FillBlanksExercise & CommonProps) {
// next and back was all messed up and still don't know why, anyways
const storeUserSolutions = useExamStore((state) => state.userSolutions); const storeUserSolutions = useExamStore((state) => state.userSolutions);
const correctUserSolutions = storeUserSolutions.find( const correctUserSolutions = storeUserSolutions.find(
(solution) => solution.exercise === id (solution) => solution.exercise === id
)?.solutions; )?.solutions;
const shuffles = useExamStore((state) => state.shuffles);
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = correctUserSolutions!.filter((x) => { const correct = correctUserSolutions!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
console.log(solution);
if (!solution) return false; if (!solution) return false;
const option = words.find((w) => { const option = words.find((w) => {
@@ -66,16 +65,18 @@ export default function FillBlanksSolutions({
return ( return (
<span> <span>
{reactStringReplace(line, /({{\d+}})/g, (match) => { {reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, ""); const questionId = match.replaceAll(/[\{\}]/g, "");
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === id.toString()); const userSolution = correctUserSolutions!.find((x) => x.id.toString() === questionId.toString());
const answerSolution = solutions.find(sol => sol.id.toString() === id.toString())!.solution; const answerSolution = solutions.find(sol => sol.id.toString() === questionId.toString())!.solution;
const questionShuffleMap = shuffles.find((x) => x.exerciseID == id)?.shuffles.find((y) => y.questionID == questionId);
const newAnswerSolution = questionShuffleMap ? questionShuffleMap.map[answerSolution].toLowerCase() : answerSolution.toLowerCase();
if (!userSolution) { if (!userSolution) {
let answerText; let answerText;
if (typeCheckWordsMC(words)) { if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id.toString() === id.toString()); const options = words.find((x) => x.id.toString() === questionId.toString());
const correctKey = Object.keys(options!.options).find(key => const correctKey = Object.keys(options!.options).find(key =>
key.toLowerCase() === answerSolution.toLowerCase() key.toLowerCase() === newAnswerSolution
); );
answerText = options!.options[correctKey as keyof typeof options]; answerText = options!.options[correctKey as keyof typeof options];
} else { } else {
@@ -98,7 +99,7 @@ export default function FillBlanksSolutions({
: 'letter' in w : 'letter' in w
? w.letter.toLowerCase() === userSolution.solution.toLowerCase() ? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
: 'options' in w : 'options' in w
? w.id === userSolution.id ? w.id === userSolution.questionId
: false : false
); );
@@ -114,10 +115,10 @@ export default function FillBlanksSolutions({
let correct; let correct;
let solutionText; let solutionText;
if (typeCheckWordsMC(words)) { if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id.toString() === id.toString()); const options = words.find((x) => x.id.toString() === questionId.toString());
if (options) { if (options) {
const correctKey = Object.keys(options.options).find(key => const correctKey = Object.keys(options.options).find(key =>
key.toLowerCase() === answerSolution.toLowerCase() key.toLowerCase() === newAnswerSolution
); );
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options]; correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution; solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;

View File

@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { CommonProps } from "."; import { CommonProps } from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
import { v4 } from "uuid";
function Question({ function Question({
id, id,
@@ -17,34 +18,12 @@ function Question({
}: 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 { userSolutions } = useExamStore((state) => state);
const getShuffledOptions = (options: { id: string, text: string }[], questionShuffleMap: ShuffleMap) => {
const shuffledOptions = ['A', 'B', 'C', 'D'].map(newId => {
const originalId = questionShuffleMap.map[newId];
const originalOption = options.find(option => option.id === originalId);
return {
id: newId,
text: originalOption!.text
};
});
return shuffledOptions;
}
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
if (originalPosition === originalSolution) {
return newPosition;
}
}
return originalSolution;
}
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => { const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
if (foundMap) return foundMap; if (foundMap) return foundMap;
return userSolution.shuffleMaps?.find(map => map.id === id) || null; return userSolution.shuffleMaps?.find(map => map.questionID === id) || null;
}, null as ShuffleMap | null); }, null as ShuffleMap | null);
const questionOptions = questionShuffleMap ? getShuffledOptions(options as { id: string, text: string }[], questionShuffleMap) : options; const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
const newSolution = questionShuffleMap ? getShuffledSolution(solution, questionShuffleMap) : solution;
const renderPrompt = (prompt: string) => { const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => { return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
@@ -70,15 +49,15 @@ function Question({
{isNaN(Number(id)) ? ( {isNaN(Number(id)) ? (
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span> <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
) : ( ) : (
<span className="text-lg"> <span className="text-lg" key={v4()}>
<> <>
{id} - <span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span> {id} - <span className="text-lg" key={v4()}>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
</> </>
</span> </span>
)} )}
<div className="flex flex-wrap gap-4 justify-between"> <div className="flex flex-wrap gap-4 justify-between">
{variant === "image" && {variant === "image" &&
questionOptions.map((option) => ( options.map((option) => (
<div <div
key={option?.id} key={option?.id}
className={clsx( className={clsx(
@@ -90,7 +69,7 @@ function Question({
</div> </div>
))} ))}
{variant === "text" && {variant === "text" &&
questionOptions.map((option) => ( options.map((option) => (
<div <div
key={option?.id} key={option?.id}
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none", optionColor(option!.id))}> className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none", optionColor(option!.id))}>
@@ -106,14 +85,23 @@ function Question({
export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) { export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state); const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
const stats = useExamStore((state) => state.userSolutions);
const calculateScore = () => { const calculateScore = () => {
const total = questions.length; const total = questions.length;
const questionShuffleMap = stats.find((x) => x.exercise == id)?.shuffleMaps;
const correct = userSolutions.filter( const correct = userSolutions.filter(
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false, (x) => {
if (questionShuffleMap) {
const shuffleMap = questionShuffleMap.find((y) => y.questionID === x.question)
const originalSol = questions.find((y) => y.id.toString() === x.question.toString())?.solution!;
return x.option == shuffleMap?.map[originalSol]
} else {
return questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false
}
},
).length; ).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).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 };
}; };

155
src/exams/Level/Shuffle.ts Normal file
View File

@@ -0,0 +1,155 @@
import { Exercise, FillBlanksExercise, FillBlanksMCOption, MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap, Shuffles, UserSolution } from "@/interfaces/exam";
export default function shuffleExamExercise(
shuffle: boolean | undefined,
exercise: Exercise,
showSolutions: boolean,
userSolutions: UserSolution[],
shuffles: Shuffles[],
setShuffles: (maps: Shuffles[]) => void
): Exercise {
if (!shuffle) {
return exercise;
}
const userSolution = userSolutions.find((x) => x.exercise === exercise.id)!;
if (exercise.type === "multipleChoice") {
return shuffleMultipleChoice(exercise, userSolution, shuffles, setShuffles, showSolutions);
} else if (exercise.type === "fillBlanks") {
return shuffleFillBlanks(exercise, userSolution, shuffles, setShuffles, showSolutions);
}
return exercise;
}
function shuffleMultipleChoice(
exercise: MultipleChoiceExercise,
userSolution: UserSolution,
shuffles: Shuffles[],
setShuffles: (maps: Shuffles[]) => void,
showSolutions: boolean,
): MultipleChoiceExercise {
if (typeof userSolution.shuffleMaps === "undefined" || (userSolution.shuffleMaps && userSolution.shuffleMaps.length === 0) && !showSolutions) {
const newShuffleMaps: ShuffleMap[] = [];
exercise.questions = exercise.questions.map(shuffleQuestion(newShuffleMaps));
userSolution!.shuffleMaps = newShuffleMaps;
setShuffles([...shuffles.filter((x) => x.exerciseID !== exercise.id), {exerciseID: exercise.id, shuffles: newShuffleMaps}]);
} else {
exercise.questions = exercise.questions.map(retrieveShuffledQuestion(userSolution.shuffleMaps));
}
return exercise;
}
function shuffleQuestion(newShuffleMaps: ShuffleMap[]) {
return (question: MultipleChoiceQuestion): MultipleChoiceQuestion => {
const options = [...question.options];
const shuffledOptions = fisherYatesShuffle(options);
const optionMapping: Record<string, string> = {};
const newOptions = shuffledOptions.map((option, index) => {
const newId = String.fromCharCode(65 + index);
optionMapping[option.id] = newId;
return { ...option, id: newId };
});
newShuffleMaps.push({ questionID: question.id, map: optionMapping });
return { ...question, options: newOptions, shuffleMap: optionMapping };
};
}
function retrieveShuffledQuestion(shuffleMaps: ShuffleMap[]) {
return (question: MultipleChoiceQuestion): MultipleChoiceQuestion => {
const questionShuffleMap = shuffleMaps.find(map => map.questionID === question.id);
if (questionShuffleMap) {
const shuffledOptions = Object.entries(questionShuffleMap.map)
.sort(([, a], [, b]) => a.localeCompare(b))
.map(([originalId, newId]) => {
const originalOption = question.options.find(opt => opt.id === originalId);
return { ...originalOption, id: newId };
});
return { ...question, options: shuffledOptions, shuffleMap: questionShuffleMap.map };
}
return question;
};
}
function shuffleFillBlanks(
exercise: FillBlanksExercise,
userSolution: UserSolution,
shuffles: Shuffles[],
setShuffles: (maps: Shuffles[]) => void,
showSolutions: boolean
): FillBlanksExercise {
if (typeof userSolution.shuffleMaps === "undefined" || (userSolution.shuffleMaps && userSolution.shuffleMaps.length === 0) && !showSolutions) {
const newShuffleMaps: ShuffleMap[] = [];
exercise.words = exercise.words.map(shuffleWord(newShuffleMaps));
userSolution.shuffleMaps = newShuffleMaps;
setShuffles([...shuffles.filter((x) => x.exerciseID !== exercise.id), {exerciseID: exercise.id, shuffles: newShuffleMaps}]);
} else {
exercise.words = exercise.words.map(retrieveShuffledWord(userSolution.shuffleMaps!));
}
return exercise;
}
function shuffleWord(newShuffleMaps: ShuffleMap[]) {
return (word: string | { letter: string; word: string } | FillBlanksMCOption): typeof word => {
if (typeof word === 'object' && 'options' in word) {
const options = word.options;
const originalKeys = Object.keys(options);
const shuffledKeys = fisherYatesShuffle(originalKeys);
const newOptions = shuffledKeys.reduce<typeof options>((acc, key, index) => {
acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options];
return acc;
}, {} as typeof options);
const optionMapping = originalKeys.reduce<Record<string, string>>((acc, key, index) => {
acc[key] = shuffledKeys[index];
return acc;
}, {});
newShuffleMaps.push({ questionID: word.id, map: optionMapping });
return { ...word, options: newOptions };
}
return word;
};
}
function retrieveShuffledWord(shuffleMaps: ShuffleMap[]) {
return (word: string | { letter: string; word: string } | FillBlanksMCOption): typeof word => {
if (typeof word === 'object' && 'options' in word) {
const shuffleMap = shuffleMaps.find(map => map.questionID === word.id);
if (shuffleMap) {
const options = word.options;
const shuffledOptions = Object.keys(options).reduce<typeof options>((acc, key) => {
const shuffledKey = shuffleMap.map[key as keyof typeof options];
acc[shuffledKey as keyof typeof options] = options[key as keyof typeof options];
return acc;
}, {} as typeof options);
return { ...word, options: shuffledOptions };
}
}
return word;
};
}
function fisherYatesShuffle<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every(
word => word && typeof word === 'object' && 'id' in word && 'options' in word
);
}

View File

@@ -7,7 +7,7 @@ interface Props {
setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>> setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>>
} }
const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine}) => { const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine }) => {
const textRef = useRef<HTMLDivElement>(null); const textRef = useRef<HTMLDivElement>(null);
const calculateLineNumbers = () => { const calculateLineNumbers = () => {
@@ -130,14 +130,11 @@ const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine})
}*/ }*/
return ( return (
<div className="flex flex-col gap-2 w-full"> <div className="flex mt-2">
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" /> <div ref={textRef} className="h-fit ml-2 flex flex-col gap-4">
<div className="flex mt-2"> {part.context!.split('\n\n').map((line, index) => {
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4"> return <p key={`line-${index}`}><span className="mr-6">{index + 1}</span>{line}</p>
{part.context!.split('\n\n').map((line, index) => { })}
return <p key={`line-${index}`}><span className="mr-6">{index + 1}</span>{line}</p>
})}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -12,7 +12,8 @@ import { use, useEffect, useState } from "react";
import TextComponent from "./TextComponent"; import TextComponent from "./TextComponent";
import PartDivider from "./PartDivider"; import PartDivider from "./PartDivider";
import Timer from "@/components/Medium/Timer"; import Timer from "@/components/Medium/Timer";
import { Stat } from "@/interfaces/user"; import shuffleExamExercise from "./Shuffle";
import { Tab } from "@headlessui/react";
interface Props { interface Props {
exam: LevelExam; exam: LevelExam;
@@ -31,17 +32,43 @@ const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) { export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) {
const levelBgColor = "bg-ielts-level-light"; const levelBgColor = "bg-ielts-level-light";
const {
userSolutions,
hasExamEnded,
partIndex,
exerciseIndex,
questionIndex,
shuffles,
currentSolution,
setBgColor,
setUserSolutions,
setHasExamEnded,
setPartIndex,
setExerciseIndex,
setQuestionIndex,
setShuffles,
setCurrentSolution
} = useExamStore((state) => state);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]); const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
const [showQuestionsModal, setShowQuestionsModal] = useState(false); const [showQuestionsModal, setShowQuestionsModal] = useState(false);
const [continueAnyways, setContinueAnyways] = useState(false);
const [textRender, setTextRender] = useState(false);
const { setBgColor } = useExamStore((state) => state); const [seenParts, setSeenParts] = useState<number[]>(showSolutions ? exam.parts.map((_, index) => index) : [0]);
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
const { hasExamEnded, setHasExamEnded } = useExamStore((state) => state); const [questionModalKwargs, setQuestionModalKwargs] = useState<{
const { partIndex, setPartIndex } = useExamStore((state) => state); type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined;
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state); }>({
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]); type: "blankQuestions",
const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps]) onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
const [currentExercise, setCurrentExercise] = useState<Exercise>(); });
const [currentExercise, setCurrentExercise] = useState<Exercise>(exam.parts[0].exercises[0]);
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions); const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
@@ -49,135 +76,60 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
const [contextWord, setContextWord] = useState<string | undefined>(undefined); const [contextWord, setContextWord] = useState<string | undefined>(undefined);
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined); const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
useEffect(() => { const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined)
if (showSolutions && exerciseIndex && exam.shuffle && userSolutions[exerciseIndex].shuffleMaps) {
setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[])
}
}, [showSolutions, exerciseIndex, setShuffleMaps, userSolutions, exam.shuffle])
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (typeof currentSolution !== "undefined") {
setExerciseIndex(exerciseIndex + 1); setUserSolutions([...userSolutions.filter((x) => x.exercise !== currentSolution.exercise), { ...currentSolution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
} }
}, [hasExamEnded, exerciseIndex, setExerciseIndex]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSolution, exam.id, exam.shuffle, shuffles, currentExercise])
useEffect(() => {
if (typeof currentSolution !== "undefined") {
setCurrentSolution(undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSolution]);
useEffect(() => {
if (showSolutions) {
const solutionShuffles = userSolutions.map(solution => ({
exerciseID: solution.exercise,
shuffles: solution.shuffleMaps || []
}));
setShuffles(solutionShuffles);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getExercise = () => { const getExercise = () => {
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
if (!exercise) return undefined;
exercise = { exercise = {
...exercise, ...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}; };
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
if (exam.shuffle && exercise.type === "multipleChoice" && !showSolutions) {
console.log("Shuffling MC ");
const exerciseShuffles = userSolutions[exerciseIndex].shuffleMaps;
if (exerciseShuffles && exerciseShuffles.length == 0) {
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 {
console.log("retrieving MC shuffles");
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) && !showSolutions) {
if (shuffleMaps.length === 0 && !showSolutions) {
const newShuffleMaps: ShuffleMap[] = [];
console.log("Shuffling Words");
exercise.words = exercise.words.map(word => {
if ('options' in word) {
const options = { ...word.options };
const originalKeys = Object.keys(options);
const shuffledKeys = [...originalKeys].sort(() => Math.random() - 0.5);
const newOptions = shuffledKeys.reduce((acc, key, index) => {
acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options];
return acc;
}, {} as { [key in keyof typeof options]: string });
const optionMapping = originalKeys.reduce((acc, key, index) => {
acc[key as keyof typeof options] = shuffledKeys[index];
return acc;
}, {} as { [key in keyof typeof options]: string });
newShuffleMaps.push({ id: word.id, map: optionMapping });
return { ...word, options: newOptions };
}
return word;
});
setShuffleMaps(newShuffleMaps);
} else {
console.log("Retrieving Words shuffle");
exercise.words = exercise.words.map(word => {
if ('options' in word) {
const shuffleMap = shuffleMaps.find(map => map.id === word.id);
if (shuffleMap) {
const options = { ...word.options };
const shuffledOptions = Object.keys(options).reduce((acc, key) => {
const shuffledKey = shuffleMap.map[key as keyof typeof options];
acc[shuffledKey as keyof typeof options] = options[key as keyof typeof options];
return acc;
}, {} as { [key in keyof typeof options]: string });
return { ...word, options: shuffledOptions };
}
}
return word;
});
}
}
console.log(exercise);
return exercise; return exercise;
}; };
useEffect(() => { useEffect(() => {
if (exerciseIndex !== -1) { setCurrentExercise(getExercise());
setCurrentExercise(getExercise());
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex, shuffleMaps, exam.parts[partIndex].context]); }, [partIndex, exerciseIndex, questionIndex]);
useEffect(() => { useEffect(() => {
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
if (exerciseIndex !== -1 && currentExercise && currentExercise.type === "multipleChoice" && currentExercise.questions[storeQuestionIndex].prompt) { if (
const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex); exerciseIndex !== -1 && currentExercise &&
currentExercise.type === "multipleChoice" &&
currentExercise.questions[questionIndex] &&
currentExercise.questions[questionIndex].prompt &&
exam.parts[partIndex].context
) {
const match = currentExercise.questions[questionIndex].prompt.match(regex);
if (match) { if (match) {
const word = match[1]; const word = match[1];
const originalLineNumber = match[2]; const originalLineNumber = match[2];
@@ -186,72 +138,58 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
setContextWord(word); setContextWord(word);
} }
const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace( const updatedPrompt = currentExercise.questions[questionIndex].prompt.replace(
`in line ${originalLineNumber}`, `in line ${originalLineNumber}`,
`in line ${contextWordLine || originalLineNumber}` `in line ${contextWordLine || originalLineNumber}`
); );
currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt; currentExercise.questions[questionIndex].prompt = updatedPrompt;
} else { } else {
setContextWord(undefined); setContextWord(undefined);
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentExercise, storeQuestionIndex]); }, [currentExercise, questionIndex]);
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
}
/*if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") {
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
}*/
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex(exerciseIndex + 1); setExerciseIndex(exerciseIndex + 1);
return; return;
} }
if (partIndex + 1 < exam.parts.length && !hasExamEnded && (showQuestionsModal || showSolutions)) { if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) {
if (!showSolutions && exam.parts[0].intro) { modalKwargs();
setShowQuestionsModal(true);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions && !seenParts.includes(partIndex + 1)) {
modalKwargs();
setShowQuestionsModal(true);
return;
}
if (!showSolutions && exam.parts[0].intro && !seenParts.includes(partIndex + 1)) {
setShowPartDivider(true); setShowPartDivider(true);
setBgColor(levelBgColor); setBgColor(levelBgColor);
} }
setSeenParts((prev) => [...prev, partIndex + 1])
if (partIndex < exam.parts.length - 1 && exam.parts[partIndex + 1].context) {
setTextRender(true);
}
setPartIndex(partIndex + 1); setPartIndex(partIndex + 1);
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0); setExerciseIndex(0);
setStoreQuestionIndex(0); setQuestionIndex(0);
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]); setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : questionIndex }]);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions) {
setShowQuestionsModal(true);
return;
}
if (
solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
(x) => x === 0,
) &&
!showSolutions &&
!editing &&
!hasExamEnded
) {
setShowQuestionsModal(true);
return; return;
} }
setHasExamEnded(false); setHasExamEnded(false);
if (typeof showSolutionsSave !== "undefined") {
if (solution) { onFinish(showSolutionsSave);
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 { } else {
onFinish(userSolutions); onFinish(userSolutions);
} }
@@ -259,8 +197,24 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); if (exam.parts[partIndex].context && questionIndex === 0 && !textRender) {
setTextRender(true);
return;
}
if (questionIndex == 0) {
setPartIndex(partIndex - 1);
const lastExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
const lastExercise = exam.parts[partIndex - 1].exercises[lastExerciseIndex];
setExerciseIndex(lastExerciseIndex);
if (lastExercise.type === "multipleChoice") {
setQuestionIndex(lastExercise.questions.length - 1)
} else {
setQuestionIndex(0)
}
return;
} }
setExerciseIndex(exerciseIndex - 1); setExerciseIndex(exerciseIndex - 1);
@@ -269,7 +223,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1; const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex]; const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex];
if (previousExercise.type === "multipleChoice") { if (previousExercise.type === "multipleChoice") {
setStoreQuestionIndex(previousExercise.questions.length - 1) setQuestionIndex(previousExercise.questions.length - 1)
} }
const multipleChoiceQuestionsDone = []; const multipleChoiceQuestionsDone = [];
for (let i = 0; i < exam.parts.length; i++) { for (let i = 0; i < exam.parts.length; i++) {
@@ -289,45 +243,75 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
}; };
useEffect(() => {
if (exerciseIndex === -1) {
nextExercise()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex])
const calculateExerciseIndex = () => { const calculateExerciseIndex = () => {
if (partIndex === 0) { if (exam.parts[0].intro) {
return exam.parts.reduce((acc, curr, index) => {
if (index < partIndex) {
return acc + countExercises(curr.exercises)
}
return acc;
}, 0) + (questionIndex + 1);
} else {
if (partIndex === 0) {
return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + questionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
}
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
return ( return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0) exercisesDone +
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
questionIndex
+ multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount }, 0)
); );
} }
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
return (
exercisesDone +
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
storeQuestionIndex
+ multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount }, 0)
);
}; };
const renderText = () => ( const renderText = () => (
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}> <>
<> <div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
<div className="flex flex-col w-full gap-2"> <>
<h4 className="text-xl font-semibold"> <div className="flex flex-col w-full gap-2">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read. {textRender ? (
</h4> <>
<span className="text-base">You will be allowed to read the text while doing the exercises</span> <h4 className="text-xl font-semibold">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
</h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</>
) : (
<h4 className="text-xl font-semibold">
Answer the questions on the right based on what you&apos;ve read.
</h4>
)}
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{exam.parts[partIndex].context &&
<TextComponent
part={exam.parts[partIndex]}
contextWord={contextWord}
setContextWordLine={setContextWordLine}
/>}
</div>
</>
</div>
{textRender && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
className="max-w-[200px] w-full"
onClick={() => { setTextRender(false); previousExercise(); }}
>
Back
</Button>
<Button color="purple" onClick={() => setTextRender(false)} className="max-w-[200px] self-end w-full">
Next
</Button>
</div> </div>
<TextComponent )}
part={exam.parts[partIndex]} </>
contextWord={contextWord}
setContextWordLine={setContextWordLine}
/>
</>
</div>
); );
const partLabel = () => { const partLabel = () => {
@@ -344,8 +328,8 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
} }
} }
const modalKwargs = () => { const answeredEveryQuestion = (partIndex: number) => {
const allSolutionsCorrectLength = exam.parts[partIndex].exercises.every((exercise) => { return exam.parts[partIndex].exercises.every((exercise) => {
const userSolution = userSolutions.find(x => x.exercise === exercise.id); const userSolution = userSolutions.find(x => x.exercise === exercise.id);
if (exercise.type === "multipleChoice") { if (exercise.type === "multipleChoice") {
return userSolution?.solutions.length === exercise.questions.length; return userSolution?.solutions.length === exercise.questions.length;
@@ -355,27 +339,81 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
} }
return false; return false;
}); });
return {
blankQuestions: !allSolutionsCorrectLength,
finishingWhat: "part",
onClose: partIndex !== exam.parts.length - 1 ? (
function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
) : function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); onFinish(userSolutions); } else { setShowQuestionsModal(false) } }
}
} }
useEffect(() => {
if (continueAnyways) {
setContinueAnyways(false);
nextExercise();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [continueAnyways]);
const modalKwargs = () => {
const kwargs: { type: "module" | "blankQuestions" | "submit", unanswered: boolean, onClose: (next?: boolean) => void; } = {
type: "blankQuestions",
unanswered: false,
onClose: function (x: boolean | undefined) { if (x) { setContinueAnyways(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } }
};
if (partIndex === exam.parts.length - 1) {
kwargs.type = "submit"
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestion(partIndex));
kwargs.onClose = function (x: boolean | undefined) { if (x) { setContinueAnyways(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } };
}
setQuestionModalKwargs(kwargs);
}
const mcNavKwargs = {
userSolutions: userSolutions,
exam: exam,
partIndex: partIndex,
showSolutions: showSolutions,
"setExerciseIndex": setExerciseIndex,
"setPartIndex": setPartIndex,
"runOnClick": setQuestionIndex
}
return ( return (
<> <>
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}> <div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
<QuestionsModal isOpen={showQuestionsModal} {...modalKwargs()} /> <QuestionsModal isOpen={showQuestionsModal} {...questionModalKwargs} />
{ {
!(partIndex === 0 && storeQuestionIndex === 0 && showPartDivider) && !(partIndex === 0 && questionIndex === 0 && showPartDivider) &&
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} /> <Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} />
} }
{exam.parts[0].intro && showPartDivider ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setBgColor("bg-white") }} /> : ( {exam.parts[0].intro && showPartDivider ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setBgColor("bg-white") }} /> : (
<> <>
{exam.parts[0].intro && (
<div className="w-full">
<Tab.Group className="w-[90%]" selectedIndex={partIndex} onChange={setPartIndex}>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
{exam.parts.map((_, index) =>
<Tab key={index} onClick={(e) => {
if (!seenParts.includes(index)) {
e.preventDefault();
} else {
setExerciseIndex(0);
setQuestionIndex(0);
}
}}
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/80",
"ring-white ring-opacity-60 focus:outline-none",
"transition duration-300 ease-in-out",
selected && "bg-white shadow",
seenParts.includes(index) ? "hover:bg-white/70" : "cursor-not-allowed"
)
}
>{`Part ${index + 1}`}</Tab>
)
}
</Tab.List>
</Tab.Group>
</div>
)}
<ModuleTitle <ModuleTitle
partLabel={partLabel()} partLabel={partLabel()}
minTimer={exam.minTimer} minTimer={exam.minTimer}
@@ -383,29 +421,26 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
module="level" module="level"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions || editing} disableTimer={showSolutions || editing}
showTimer={typeof exam.parts[0].intro === "undefined"} showTimer={false}
{...mcNavKwargs}
/> />
<div <div
className={clsx( className={clsx(
"mb-20 w-full", "mb-20 w-full",
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4", !!exam.parts[partIndex].context && !textRender && "grid grid-cols-2 gap-4",
)}> )}>
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
{exerciseIndex > -1 && {textRender ?
partIndex > -1 && renderText() :
exerciseIndex < exam.parts[partIndex].exercises.length && <>
!showSolutions && {exam.parts[partIndex].context && renderText()}
!editing && {(showSolutions || editing) ?
currentExercise && renderSolution(currentExercise, nextExercise, previousExercise)
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)} :
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)
{exerciseIndex > -1 && }
partIndex > -1 && </>
exerciseIndex < exam.parts[partIndex].exercises.length && }
(showSolutions || editing) &&
currentExercise &&
renderSolution(currentExercise, nextExercise, previousExercise)}
</div> </div>
{/*exerciseIndex === -1 && partIndex > 0 && ( {/*exerciseIndex === -1 && partIndex > 0 && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
@@ -419,7 +454,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
className="max-w-[200px] w-full" className="max-w-[200px] w-full"
disabled={ disabled={
exam && typeof partIndex !== "undefined" && exam.module === "level" && exam && typeof partIndex !== "undefined" && exam.module === "level" &&
typeof exam.parts[0].intro === "string" && storeQuestionIndex === 0} typeof exam.parts[0].intro === "string" && questionIndex === 0}
> >
Back Back
</Button> </Button>

View File

@@ -303,8 +303,13 @@ export interface MultipleChoiceQuestion {
} }
export interface ShuffleMap { export interface ShuffleMap {
id: string; questionID: string;
map: { map: {
[key: string]: string; [key: string]: string;
} }
} }
export interface Shuffles {
exerciseID: string;
shuffles: ShuffleMap[]
}

View File

@@ -1,6 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import AbandonPopup from "@/components/AbandonPopup"; import AbandonPopup from "@/components/AbandonPopup";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
@@ -12,15 +12,15 @@ import Selection from "@/exams/Selection";
import Speaking from "@/exams/Speaking"; import Speaking from "@/exams/Speaking";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import {Exam, LevelExam, UserSolution, Variant} from "@/interfaces/exam"; import { Exam, LevelExam, UserSolution, Variant } from "@/interfaces/exam";
import {Stat} from "@/interfaces/user"; import { Stat } from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation"; import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation";
import {defaultExamUserSolutions, getExam} from "@/utils/exams"; import { defaultExamUserSolutions, getExam } from "@/utils/exams";
import axios from "axios"; import axios from "axios";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {toast, ToastContainer} from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import {v4 as uuidv4} from "uuid"; import { v4 as uuidv4 } from "uuid";
import useSessions from "@/hooks/useSessions"; import useSessions from "@/hooks/useSessions";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import clsx from "clsx"; import clsx from "clsx";
@@ -29,7 +29,7 @@ interface Props {
page: "exams" | "exercises"; page: "exams" | "exercises";
} }
export default function ExamPage({page}: Props) { export default function ExamPage({ page }: Props) {
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false);
const [hasBeenUploaded, setHasBeenUploaded] = useState(false); const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
@@ -44,20 +44,21 @@ export default function ExamPage({page}: Props) {
const assignment = useExamStore((state) => state.assignment); const assignment = useExamStore((state) => state.assignment);
const initialTimeSpent = useExamStore((state) => state.timeSpent); const initialTimeSpent = useExamStore((state) => state.timeSpent);
const {exam, setExam} = useExamStore((state) => state); const { exam, setExam } = useExamStore((state) => state);
const {exams, setExams} = useExamStore((state) => state); const { exams, setExams } = useExamStore((state) => state);
const {sessionId, setSessionId} = useExamStore((state) => state); const { sessionId, setSessionId } = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state); const { partIndex, setPartIndex } = useExamStore((state) => state);
const {moduleIndex, setModuleIndex} = useExamStore((state) => state); const { moduleIndex, setModuleIndex } = useExamStore((state) => state);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state); const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
const {userSolutions, setUserSolutions} = useExamStore((state) => state); const { userSolutions, setUserSolutions } = useExamStore((state) => state);
const {showSolutions, setShowSolutions} = useExamStore((state) => state); const { showSolutions, setShowSolutions } = useExamStore((state) => state);
const {selectedModules, setSelectedModules} = useExamStore((state) => state); const { selectedModules, setSelectedModules } = useExamStore((state) => state);
const {inactivity, setInactivity} = useExamStore((state) => state); const { inactivity, setInactivity } = useExamStore((state) => state);
const {bgColor, setBgColor} = useExamStore((state) => state); const { bgColor, setBgColor } = useExamStore((state) => state);
const setShuffleMaps = useExamStore((state) => state.setShuffles)
const {user} = useUser({redirectTo: "/login"}); const { user } = useUser({ redirectTo: "/login" });
const router = useRouter(); const router = useRouter();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -260,11 +261,11 @@ export default function ExamPage({page}: Props) {
date: new Date().getTime(), date: new Date().getTime(),
isDisabled: solution.isDisabled, isDisabled: solution.isDisabled,
shuffleMaps: solution.shuffleMaps, shuffleMaps: solution.shuffleMaps,
...(assignment ? {assignment: assignment.id} : {}), ...(assignment ? { assignment: assignment.id } : {}),
})); }));
axios axios
.post<{ok: boolean}>("/api/stats", newStats) .post<{ ok: boolean }>("/api/stats", newStats)
.then((response) => setHasBeenUploaded(response.data.ok)) .then((response) => setHasBeenUploaded(response.data.ok))
.catch(() => setHasBeenUploaded(false)); .catch(() => setHasBeenUploaded(false));
} }
@@ -282,9 +283,9 @@ export default function ExamPage({page}: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [statsAwaitingEvaluation]); }, [statsAwaitingEvaluation]);
useEffect(()=> { useEffect(() => {
if(exam && exam.module === "level" && exam.parts[0].intro && !showSolutions) { if (exam && exam.module === "level" && exam.parts[0].intro && !showSolutions) {
setBgColor("bg-ielts-level-light"); setBgColor("bg-ielts-level-light");
} }
}, [exam, showSolutions, setBgColor]) }, [exam, showSolutions, setBgColor])
@@ -332,7 +333,7 @@ export default function ExamPage({page}: Props) {
), ),
}), }),
); );
return Object.assign(exam, {parts}); return Object.assign(exam, { parts });
} }
const exercises = exam.exercises.map((x) => const exercises = exam.exercises.map((x) =>
@@ -340,7 +341,7 @@ export default function ExamPage({page}: Props) {
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions, userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
}), }),
); );
return Object.assign(exam, {exercises}); return Object.assign(exam, { exercises });
}; };
const onFinish = async (solutions: UserSolution[]) => { const onFinish = async (solutions: UserSolution[]) => {
@@ -395,7 +396,7 @@ export default function ExamPage({page}: Props) {
correct: number; correct: number;
}[] => { }[] => {
const scores: { const scores: {
[key in Module]: {total: number; missing: number; correct: number}; [key in Module]: { total: number; missing: number; correct: number };
} = { } = {
reading: { reading: {
total: 0, total: 0,
@@ -437,7 +438,7 @@ export default function ExamPage({page}: Props) {
return Object.keys(scores) return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0) .filter((x) => scores[x as Module].total > 0)
.map((x) => ({module: x as Module, ...scores[x as Module]})); .map((x) => ({ module: x as Module, ...scores[x as Module] }));
}; };
const renderScreen = () => { const renderScreen = () => {
@@ -479,9 +480,11 @@ export default function ExamPage({page}: Props) {
return indexA - indexB; return indexA - indexB;
}); });
setUserSolutions(orderedSolutions); setUserSolutions(orderedSolutions);
} else { } else {
setUserSolutions(userSolutions); setUserSolutions(userSolutions);
} }
setShuffleMaps([]);
setShowSolutions(true); setShowSolutions(true);
setModuleIndex(index || 0); setModuleIndex(index || 0);
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0); setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);

View File

@@ -1,5 +1,5 @@
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {Exam, ShuffleMap, UserSolution} from "@/interfaces/exam"; import {Exam, ShuffleMap, Shuffles, UserSolution} from "@/interfaces/exam";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import {create} from "zustand"; import {create} from "zustand";
@@ -18,8 +18,9 @@ export interface ExamState {
exerciseIndex: number; exerciseIndex: number;
questionIndex: number; questionIndex: number;
inactivity: number; inactivity: number;
shuffleMaps: ShuffleMap[]; shuffles: Shuffles[];
bgColor: string; bgColor: string;
currentSolution?: UserSolution | undefined;
} }
export interface ExamFunctions { export interface ExamFunctions {
@@ -37,8 +38,9 @@ export interface ExamFunctions {
setExerciseIndex: (exerciseIndex: number) => void; setExerciseIndex: (exerciseIndex: number) => void;
setQuestionIndex: (questionIndex: number) => void; setQuestionIndex: (questionIndex: number) => void;
setInactivity: (inactivity: number) => void; setInactivity: (inactivity: number) => void;
setShuffleMaps: (shuffleMaps: ShuffleMap[]) => void; setShuffles: (shuffles: Shuffles[]) => void;
setBgColor: (bgColor: string) => void; setBgColor: (bgColor: string) => void;
setCurrentSolution: (currentSolution: UserSolution | undefined) => void;
reset: () => void; reset: () => void;
} }
@@ -57,8 +59,9 @@ export const initialState: ExamState = {
exerciseIndex: -1, exerciseIndex: -1,
questionIndex: 0, questionIndex: 0,
inactivity: 0, inactivity: 0,
shuffleMaps: [], shuffles: [],
bgColor: "bg-white" bgColor: "bg-white",
currentSolution: undefined
}; };
const useExamStore = create<ExamState & ExamFunctions>((set) => ({ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
@@ -78,8 +81,9 @@ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})), setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})),
setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})), setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})),
setInactivity: (inactivity: number) => set(() => ({inactivity})), setInactivity: (inactivity: number) => set(() => ({inactivity})),
setShuffleMaps: (shuffleMaps) => set(() => ({shuffleMaps})), setShuffles: (shuffles: Shuffles[]) => set(() => ({shuffles})),
setBgColor: (bgColor) => set(()=> ({bgColor})), setBgColor: (bgColor) => set(() => ({bgColor})),
setCurrentSolution: (currentSolution: UserSolution | undefined) => set(() => ({currentSolution})),
reset: () => set(() => initialState), reset: () => set(() => initialState),
})); }));