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

View File

@@ -1,5 +1,5 @@
/* 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";
@@ -24,7 +24,7 @@ function Question({
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
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",
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()}`} />
</div>
))}
@@ -77,13 +77,17 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
const {
questionIndex,
exam,
shuffleMaps,
shuffles,
hasExamEnded,
partIndex,
userSolutions: storeUserSolutions,
setQuestionIndex,
setUserSolutions
setUserSolutions,
setCurrentSolution
} = 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));
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 }]);
};
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 total = questions.length;
const correct = answers.filter((x) => {
@@ -112,34 +131,25 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
});
let isSolutionCorrect;
if (shuffleMaps.length == 0) {
if (!shuffleMaps) {
isSolutionCorrect = matchingQuestion?.solution === x.option;
} else {
const shuffleMap = shuffleMaps.find((map) => map.id == x.question)
isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution;
const shuffleMap = shuffleMaps.find((map) => map.questionID == x.question);
if (shuffleMap) {
isSolutionCorrect = getShuffledSolution(x.option, shuffleMap) == matchingQuestion?.solution;
} else {
isSolutionCorrect = matchingQuestion?.solution === x.option;
}
}
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, ...getShuffles() });
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
} else {
setQuestionIndex(questionIndex + 1);
}
@@ -148,8 +158,9 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
const back = () => {
if (questionIndex === 0) {
onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() });
onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
} else {
if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
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">
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"
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
</Button>

View File

@@ -9,6 +9,7 @@ interface Props {
className?: string;
disabled?: boolean;
isLoading?: boolean;
padding?: string;
onClick?: () => void;
type?: "button" | "reset" | "submit";
}
@@ -21,6 +22,7 @@ export default function Button({
className,
children,
type,
padding = "py-4 px-6",
onClick,
}: Props) {
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
@@ -61,7 +63,8 @@ export default function Button({
type={type}
onClick={onClick}
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],
className,
)}

View File

@@ -1,13 +1,17 @@
import {Module} from "@/interfaces";
import useExamStore from "@/stores/examStore";
import {moduleLabels} from "@/utils/moduleUtils";
import { Module } from "@/interfaces";
import { moduleLabels } from "@/utils/moduleUtils";
import clsx from "clsx";
import {motion} from "framer-motion";
import {ReactNode, useEffect, useState} from "react";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import { Fragment, ReactNode, useCallback, useState } from "react";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar";
import TimerEndedModal from "../TimerEndedModal";
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 {
minTimer: number;
@@ -18,13 +22,32 @@ interface Props {
disableTimer?: boolean;
partLabel?: string;
showTimer?: boolean;
showSolutions?: boolean;
runOnClick?: ((questionIndex: number) => void) | undefined;
}
export default function ModuleTitle({
minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true
export default function ModuleTitle({
minTimer,
module,
label,
exerciseIndex,
totalExercises,
disableTimer = false,
partLabel,
showTimer = true,
showSolutions = false,
runOnClick = undefined
}: 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" />,
listening: <BsHeadphones className="text-ielts-listening 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" />,
};
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 (
<>
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
@@ -67,8 +162,24 @@ export default function ModuleTitle({
</div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
</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>
</>
);
}
}

View File

@@ -51,7 +51,7 @@ const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) =>
<motion.div
className={clsx(
"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",
)}
initial={{ scale: warningMode && !disableTimer ? 0.8 : 1 }}

View File

@@ -1,15 +1,28 @@
import { Dialog, Transition } from "@headlessui/react";
import { Fragment } from "react";
import { Fragment, useEffect, useState } from "react";
import Button from "./Low/Button";
interface Props {
isOpen: boolean;
blankQuestions?: boolean;
finishingWhat? : string;
type?: "module" | "blankQuestions" | "submit";
unanswered?: boolean;
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 (
<Transition show={isOpen} as={Fragment}>
<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">
<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">
{blankQuestions ? (
{type === "module" && (
<>
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
<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 />
<br />
Are you sure you want to continue without completing those questions?
</span>
<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
</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
</Button>
</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>
<span>
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be
able to review the answers of the current one. <br />
<br />
Are you sure you want to continue?
{unanswered ? (
<>
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 />
<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>
<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
</Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
Continue
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full">
Submit
</Button>
</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 reactStringReplace from "react-string-replace";
import { CommonProps } from ".";
@@ -16,19 +16,18 @@ export default function FillBlanksSolutions({
onNext,
onBack,
}: FillBlanksExercise & CommonProps) {
// next and back was all messed up and still don't know why, anyways
const storeUserSolutions = useExamStore((state) => state.userSolutions);
const correctUserSolutions = storeUserSolutions.find(
(solution) => solution.exercise === id
)?.solutions;
const shuffles = useExamStore((state) => state.shuffles);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = correctUserSolutions!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
console.log(solution);
if (!solution) return false;
const option = words.find((w) => {
@@ -66,16 +65,18 @@ export default function FillBlanksSolutions({
return (
<span>
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === id.toString());
const answerSolution = solutions.find(sol => sol.id.toString() === id.toString())!.solution;
const questionId = match.replaceAll(/[\{\}]/g, "");
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === questionId.toString());
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) {
let answerText;
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 =>
key.toLowerCase() === answerSolution.toLowerCase()
key.toLowerCase() === newAnswerSolution
);
answerText = options!.options[correctKey as keyof typeof options];
} else {
@@ -98,7 +99,7 @@ export default function FillBlanksSolutions({
: 'letter' in w
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
: 'options' in w
? w.id === userSolution.id
? w.id === userSolution.questionId
: false
);
@@ -114,10 +115,10 @@ export default function FillBlanksSolutions({
let correct;
let solutionText;
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) {
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];
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 { CommonProps } from ".";
import Button from "../Low/Button";
import { v4 } from "uuid";
function Question({
id,
@@ -17,34 +18,12 @@ function Question({
}: MultipleChoiceQuestion & { userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean }) {
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) => {
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);
const questionOptions = questionShuffleMap ? getShuffledOptions(options as { id: string, text: string }[], questionShuffleMap) : options;
const newSolution = questionShuffleMap ? getShuffledSolution(solution, questionShuffleMap) : solution;
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
@@ -70,15 +49,15 @@ function Question({
{isNaN(Number(id)) ? (
<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>
)}
<div className="flex flex-wrap gap-4 justify-between">
{variant === "image" &&
questionOptions.map((option) => (
options.map((option) => (
<div
key={option?.id}
className={clsx(
@@ -90,7 +69,7 @@ function Question({
</div>
))}
{variant === "text" &&
questionOptions.map((option) => (
options.map((option) => (
<div
key={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) {
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
const stats = useExamStore((state) => state.userSolutions);
const calculateScore = () => {
const total = questions.length;
const questionShuffleMap = stats.find((x) => x.exercise == id)?.shuffleMaps;
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;
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
return { total, correct, missing };
};
@@ -159,12 +147,12 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
Wrong
</div>
</div>
</div>
</div>
<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"
disabled={
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
typeof exam.parts[0].intro === "string" && questionIndex === 0 && partIndex === 0}
>
Back