Merged in feature/level-file-upload (pull request #78)
Shuffles fixed Approved-by: Tiago Ribeiro
This commit is contained in:
@@ -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 }>();
|
||||
|
||||
@@ -77,7 +79,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
};
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
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,6 +162,22 @@ 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>
|
||||
</>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 "Back" 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 />
|
||||
{unanswered ? (
|
||||
<>
|
||||
By clicking "Submit," 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 continue?
|
||||
Are you sure you want to submit and complete the exam with unanswered questions?
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
By clicking "Submit," 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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
155
src/exams/Level/Shuffle.ts
Normal file
155
src/exams/Level/Shuffle.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ interface Props {
|
||||
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 calculateLineNumbers = () => {
|
||||
@@ -130,8 +130,6 @@ const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine})
|
||||
}*/
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||
<div className="flex mt-2">
|
||||
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4">
|
||||
{part.context!.split('\n\n').map((line, index) => {
|
||||
@@ -139,7 +137,6 @@ const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine})
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import { use, useEffect, useState } from "react";
|
||||
import TextComponent from "./TextComponent";
|
||||
import PartDivider from "./PartDivider";
|
||||
import Timer from "@/components/Medium/Timer";
|
||||
import { Stat } from "@/interfaces/user";
|
||||
import shuffleExamExercise from "./Shuffle";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
interface Props {
|
||||
exam: LevelExam;
|
||||
@@ -31,17 +32,43 @@ const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||
|
||||
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) {
|
||||
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 [showQuestionsModal, setShowQuestionsModal] = useState(false);
|
||||
const [continueAnyways, setContinueAnyways] = useState(false);
|
||||
const [textRender, setTextRender] = useState(false);
|
||||
|
||||
const { setBgColor } = useExamStore((state) => state);
|
||||
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
|
||||
const { hasExamEnded, setHasExamEnded } = useExamStore((state) => state);
|
||||
const { partIndex, setPartIndex } = useExamStore((state) => state);
|
||||
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
|
||||
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
||||
const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps])
|
||||
const [currentExercise, setCurrentExercise] = useState<Exercise>();
|
||||
const [seenParts, setSeenParts] = useState<number[]>(showSolutions ? exam.parts.map((_, index) => index) : [0]);
|
||||
|
||||
const [questionModalKwargs, setQuestionModalKwargs] = useState<{
|
||||
type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined;
|
||||
}>({
|
||||
type: "blankQuestions",
|
||||
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
||||
});
|
||||
|
||||
|
||||
|
||||
const [currentExercise, setCurrentExercise] = useState<Exercise>(exam.parts[0].exercises[0]);
|
||||
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));
|
||||
@@ -49,135 +76,60 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
const [contextWord, setContextWord] = useState<string | undefined>(undefined);
|
||||
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions && exerciseIndex && exam.shuffle && userSolutions[exerciseIndex].shuffleMaps) {
|
||||
setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[])
|
||||
}
|
||||
}, [showSolutions, exerciseIndex, setShuffleMaps, userSolutions, exam.shuffle])
|
||||
const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
if (typeof currentSolution !== "undefined") {
|
||||
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 = () => {
|
||||
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
|
||||
if (!exercise) return undefined;
|
||||
|
||||
exercise = {
|
||||
...exercise,
|
||||
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;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (exerciseIndex !== -1) {
|
||||
setCurrentExercise(getExercise());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex, exerciseIndex, shuffleMaps, exam.parts[partIndex].context]);
|
||||
}, [partIndex, exerciseIndex, questionIndex]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||
if (exerciseIndex !== -1 && currentExercise && currentExercise.type === "multipleChoice" && currentExercise.questions[storeQuestionIndex].prompt) {
|
||||
const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex);
|
||||
if (
|
||||
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) {
|
||||
const word = match[1];
|
||||
const originalLineNumber = match[2];
|
||||
@@ -186,72 +138,58 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
setContextWord(word);
|
||||
}
|
||||
|
||||
const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace(
|
||||
const updatedPrompt = currentExercise.questions[questionIndex].prompt.replace(
|
||||
`in line ${originalLineNumber}`,
|
||||
`in line ${contextWordLine || originalLineNumber}`
|
||||
);
|
||||
|
||||
currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt;
|
||||
currentExercise.questions[questionIndex].prompt = updatedPrompt;
|
||||
} else {
|
||||
setContextWord(undefined);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentExercise, storeQuestionIndex]);
|
||||
}, [currentExercise, questionIndex]);
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
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) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded && (showQuestionsModal || showSolutions)) {
|
||||
if (!showSolutions && exam.parts[0].intro) {
|
||||
if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) {
|
||||
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);
|
||||
setBgColor(levelBgColor);
|
||||
}
|
||||
setSeenParts((prev) => [...prev, partIndex + 1])
|
||||
if (partIndex < exam.parts.length - 1 && exam.parts[partIndex + 1].context) {
|
||||
setTextRender(true);
|
||||
}
|
||||
setPartIndex(partIndex + 1);
|
||||
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
|
||||
setStoreQuestionIndex(0);
|
||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
|
||||
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);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : questionIndex }]);
|
||||
return;
|
||||
}
|
||||
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
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 }]);
|
||||
if (typeof showSolutionsSave !== "undefined") {
|
||||
onFinish(showSolutionsSave);
|
||||
} else {
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
@@ -259,8 +197,24 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
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);
|
||||
@@ -269,7 +223,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
||||
const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex];
|
||||
if (previousExercise.type === "multipleChoice") {
|
||||
setStoreQuestionIndex(previousExercise.questions.length - 1)
|
||||
setQuestionIndex(previousExercise.questions.length - 1)
|
||||
}
|
||||
const multipleChoiceQuestionsDone = [];
|
||||
for (let i = 0; i < exam.parts.length; i++) {
|
||||
@@ -289,17 +243,18 @@ 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 = () => {
|
||||
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) + storeQuestionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + questionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||
);
|
||||
}
|
||||
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
||||
@@ -307,27 +262,56 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
return (
|
||||
exercisesDone +
|
||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
||||
storeQuestionIndex
|
||||
questionIndex
|
||||
+ multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount }, 0)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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="flex flex-col w-full gap-2">
|
||||
{textRender ? (
|
||||
<>
|
||||
<h4 className="text-xl font-semibold">
|
||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||
</h4>
|
||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<h4 className="text-xl font-semibold">
|
||||
Answer the questions on the right based on what you'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const partLabel = () => {
|
||||
@@ -344,8 +328,8 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
}
|
||||
}
|
||||
|
||||
const modalKwargs = () => {
|
||||
const allSolutionsCorrectLength = exam.parts[partIndex].exercises.every((exercise) => {
|
||||
const answeredEveryQuestion = (partIndex: number) => {
|
||||
return exam.parts[partIndex].exercises.every((exercise) => {
|
||||
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
|
||||
if (exercise.type === "multipleChoice") {
|
||||
return userSolution?.solutions.length === exercise.questions.length;
|
||||
@@ -355,27 +339,81 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<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} />
|
||||
}
|
||||
{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
|
||||
partLabel={partLabel()}
|
||||
minTimer={exam.minTimer}
|
||||
@@ -383,29 +421,26 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
module="level"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions || editing}
|
||||
showTimer={typeof exam.parts[0].intro === "undefined"}
|
||||
showTimer={false}
|
||||
{...mcNavKwargs}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
"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 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
!editing &&
|
||||
currentExercise &&
|
||||
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)}
|
||||
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
(showSolutions || editing) &&
|
||||
currentExercise &&
|
||||
renderSolution(currentExercise, nextExercise, previousExercise)}
|
||||
{textRender ?
|
||||
renderText() :
|
||||
<>
|
||||
{exam.parts[partIndex].context && renderText()}
|
||||
{(showSolutions || editing) ?
|
||||
renderSolution(currentExercise, nextExercise, previousExercise)
|
||||
:
|
||||
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
{/*exerciseIndex === -1 && partIndex > 0 && (
|
||||
<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"
|
||||
disabled={
|
||||
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
|
||||
typeof exam.parts[0].intro === "string" && storeQuestionIndex === 0}
|
||||
typeof exam.parts[0].intro === "string" && questionIndex === 0}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
@@ -303,8 +303,13 @@ export interface MultipleChoiceQuestion {
|
||||
}
|
||||
|
||||
export interface ShuffleMap {
|
||||
id: string;
|
||||
questionID: string;
|
||||
map: {
|
||||
[key: string]: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Shuffles {
|
||||
exerciseID: string;
|
||||
shuffles: ShuffleMap[]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {Module} from "@/interfaces";
|
||||
import {useEffect, useState} from "react";
|
||||
import { Module } from "@/interfaces";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import AbandonPopup from "@/components/AbandonPopup";
|
||||
import Layout from "@/components/High/Layout";
|
||||
@@ -12,15 +12,15 @@ import Selection from "@/exams/Selection";
|
||||
import Speaking from "@/exams/Speaking";
|
||||
import Writing from "@/exams/Writing";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import {Exam, LevelExam, UserSolution, Variant} from "@/interfaces/exam";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
import { Exam, LevelExam, UserSolution, Variant } from "@/interfaces/exam";
|
||||
import { Stat } from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
|
||||
import {defaultExamUserSolutions, getExam} from "@/utils/exams";
|
||||
import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation";
|
||||
import { defaultExamUserSolutions, getExam } from "@/utils/exams";
|
||||
import axios from "axios";
|
||||
import {useRouter} from "next/router";
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import { useRouter } from "next/router";
|
||||
import { toast, ToastContainer } from "react-toastify";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import useSessions from "@/hooks/useSessions";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import clsx from "clsx";
|
||||
@@ -29,7 +29,7 @@ interface Props {
|
||||
page: "exams" | "exercises";
|
||||
}
|
||||
|
||||
export default function ExamPage({page}: Props) {
|
||||
export default function ExamPage({ page }: Props) {
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
||||
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
||||
@@ -44,20 +44,21 @@ export default function ExamPage({page}: Props) {
|
||||
const assignment = useExamStore((state) => state.assignment);
|
||||
const initialTimeSpent = useExamStore((state) => state.timeSpent);
|
||||
|
||||
const {exam, setExam} = useExamStore((state) => state);
|
||||
const {exams, setExams} = useExamStore((state) => state);
|
||||
const {sessionId, setSessionId} = useExamStore((state) => state);
|
||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||
const {moduleIndex, setModuleIndex} = useExamStore((state) => state);
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
|
||||
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
||||
const {inactivity, setInactivity} = useExamStore((state) => state);
|
||||
const {bgColor, setBgColor} = useExamStore((state) => state);
|
||||
const { exam, setExam } = useExamStore((state) => state);
|
||||
const { exams, setExams } = useExamStore((state) => state);
|
||||
const { sessionId, setSessionId } = useExamStore((state) => state);
|
||||
const { partIndex, setPartIndex } = useExamStore((state) => state);
|
||||
const { moduleIndex, setModuleIndex } = useExamStore((state) => state);
|
||||
const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
|
||||
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
|
||||
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
|
||||
const { showSolutions, setShowSolutions } = useExamStore((state) => state);
|
||||
const { selectedModules, setSelectedModules } = useExamStore((state) => state);
|
||||
const { inactivity, setInactivity } = 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();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -260,11 +261,11 @@ export default function ExamPage({page}: Props) {
|
||||
date: new Date().getTime(),
|
||||
isDisabled: solution.isDisabled,
|
||||
shuffleMaps: solution.shuffleMaps,
|
||||
...(assignment ? {assignment: assignment.id} : {}),
|
||||
...(assignment ? { assignment: assignment.id } : {}),
|
||||
}));
|
||||
|
||||
axios
|
||||
.post<{ok: boolean}>("/api/stats", newStats)
|
||||
.post<{ ok: boolean }>("/api/stats", newStats)
|
||||
.then((response) => setHasBeenUploaded(response.data.ok))
|
||||
.catch(() => setHasBeenUploaded(false));
|
||||
}
|
||||
@@ -282,9 +283,9 @@ export default function ExamPage({page}: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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");
|
||||
}
|
||||
}, [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) =>
|
||||
@@ -340,7 +341,7 @@ export default function ExamPage({page}: Props) {
|
||||
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
||||
}),
|
||||
);
|
||||
return Object.assign(exam, {exercises});
|
||||
return Object.assign(exam, { exercises });
|
||||
};
|
||||
|
||||
const onFinish = async (solutions: UserSolution[]) => {
|
||||
@@ -395,7 +396,7 @@ export default function ExamPage({page}: Props) {
|
||||
correct: number;
|
||||
}[] => {
|
||||
const scores: {
|
||||
[key in Module]: {total: number; missing: number; correct: number};
|
||||
[key in Module]: { total: number; missing: number; correct: number };
|
||||
} = {
|
||||
reading: {
|
||||
total: 0,
|
||||
@@ -437,7 +438,7 @@ export default function ExamPage({page}: Props) {
|
||||
|
||||
return Object.keys(scores)
|
||||
.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 = () => {
|
||||
@@ -479,9 +480,11 @@ export default function ExamPage({page}: Props) {
|
||||
return indexA - indexB;
|
||||
});
|
||||
setUserSolutions(orderedSolutions);
|
||||
|
||||
} else {
|
||||
setUserSolutions(userSolutions);
|
||||
}
|
||||
setShuffleMaps([]);
|
||||
setShowSolutions(true);
|
||||
setModuleIndex(index || 0);
|
||||
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {create} from "zustand";
|
||||
|
||||
@@ -18,8 +18,9 @@ export interface ExamState {
|
||||
exerciseIndex: number;
|
||||
questionIndex: number;
|
||||
inactivity: number;
|
||||
shuffleMaps: ShuffleMap[];
|
||||
shuffles: Shuffles[];
|
||||
bgColor: string;
|
||||
currentSolution?: UserSolution | undefined;
|
||||
}
|
||||
|
||||
export interface ExamFunctions {
|
||||
@@ -37,8 +38,9 @@ export interface ExamFunctions {
|
||||
setExerciseIndex: (exerciseIndex: number) => void;
|
||||
setQuestionIndex: (questionIndex: number) => void;
|
||||
setInactivity: (inactivity: number) => void;
|
||||
setShuffleMaps: (shuffleMaps: ShuffleMap[]) => void;
|
||||
setShuffles: (shuffles: Shuffles[]) => void;
|
||||
setBgColor: (bgColor: string) => void;
|
||||
setCurrentSolution: (currentSolution: UserSolution | undefined) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -57,8 +59,9 @@ export const initialState: ExamState = {
|
||||
exerciseIndex: -1,
|
||||
questionIndex: 0,
|
||||
inactivity: 0,
|
||||
shuffleMaps: [],
|
||||
bgColor: "bg-white"
|
||||
shuffles: [],
|
||||
bgColor: "bg-white",
|
||||
currentSolution: undefined
|
||||
};
|
||||
|
||||
const useExamStore = create<ExamState & ExamFunctions>((set) => ({
|
||||
@@ -78,8 +81,9 @@ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
|
||||
setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})),
|
||||
setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})),
|
||||
setInactivity: (inactivity: number) => set(() => ({inactivity})),
|
||||
setShuffleMaps: (shuffleMaps) => set(() => ({shuffleMaps})),
|
||||
setBgColor: (bgColor) => set(()=> ({bgColor})),
|
||||
setShuffles: (shuffles: Shuffles[]) => set(() => ({shuffles})),
|
||||
setBgColor: (bgColor) => set(() => ({bgColor})),
|
||||
setCurrentSolution: (currentSolution: UserSolution | undefined) => set(() => ({currentSolution})),
|
||||
|
||||
reset: () => set(() => initialState),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user