478 lines
21 KiB
TypeScript
478 lines
21 KiB
TypeScript
import QuestionsModal from "@/components/QuestionsModal";
|
|
import { renderExercise } from "@/components/Exercises";
|
|
import Button from "@/components/Low/Button";
|
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
|
import { renderSolution } from "@/components/Solutions";
|
|
import { Module } from "@/interfaces";
|
|
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap, UserSolution } from "@/interfaces/exam";
|
|
import useExamStore from "@/stores/examStore";
|
|
import { countExercises } from "@/utils/moduleUtils";
|
|
import clsx from "clsx";
|
|
import { use, useEffect, useState } from "react";
|
|
import TextComponent from "./TextComponent";
|
|
import PartDivider from "./PartDivider";
|
|
import Timer from "@/components/Medium/Timer";
|
|
import shuffleExamExercise from "./Shuffle";
|
|
import { Tab } from "@headlessui/react";
|
|
|
|
interface Props {
|
|
exam: LevelExam;
|
|
showSolutions?: boolean;
|
|
onFinish: (userSolutions: UserSolution[]) => void;
|
|
editing?: boolean;
|
|
partDividers?: boolean;
|
|
}
|
|
|
|
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
|
return Array.isArray(words) && words.every(
|
|
word => word && typeof word === 'object' && 'id' in word && 'options' in word
|
|
);
|
|
}
|
|
|
|
|
|
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) {
|
|
const 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 [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));
|
|
|
|
const [contextWord, setContextWord] = useState<string | undefined>(undefined);
|
|
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
|
|
|
|
const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined)
|
|
|
|
useEffect(() => {
|
|
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!] : [] }]);
|
|
}
|
|
// 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];
|
|
exercise = {
|
|
...exercise,
|
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
|
};
|
|
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
|
|
|
|
return exercise;
|
|
};
|
|
|
|
useEffect(() => {
|
|
setCurrentExercise(getExercise());
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [partIndex, exerciseIndex, questionIndex]);
|
|
|
|
|
|
useEffect(() => {
|
|
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
|
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];
|
|
|
|
if (word !== contextWord) {
|
|
setContextWord(word);
|
|
}
|
|
|
|
const updatedPrompt = currentExercise.questions[questionIndex].prompt.replace(
|
|
`in line ${originalLineNumber}`,
|
|
`in line ${contextWordLine || originalLineNumber}`
|
|
);
|
|
|
|
currentExercise.questions[questionIndex].prompt = updatedPrompt;
|
|
} else {
|
|
setContextWord(undefined);
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [currentExercise, questionIndex]);
|
|
|
|
const nextExercise = (solution?: UserSolution) => {
|
|
scrollToTop();
|
|
|
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
|
setExerciseIndex(exerciseIndex + 1);
|
|
return;
|
|
}
|
|
|
|
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(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 (typeof showSolutionsSave !== "undefined") {
|
|
onFinish(showSolutionsSave);
|
|
} else {
|
|
onFinish(userSolutions);
|
|
}
|
|
};
|
|
|
|
const previousExercise = (solution?: UserSolution) => {
|
|
scrollToTop();
|
|
|
|
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);
|
|
if (exerciseIndex - 1 === -1) {
|
|
setPartIndex(partIndex - 1);
|
|
const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
|
const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex];
|
|
if (previousExercise.type === "multipleChoice") {
|
|
setQuestionIndex(previousExercise.questions.length - 1)
|
|
}
|
|
const multipleChoiceQuestionsDone = [];
|
|
for (let i = 0; i < exam.parts.length; i++) {
|
|
if (i == (partIndex - 1)) break;
|
|
for (let j = 0; j < exam.parts[i].exercises.length; j++) {
|
|
const exercise = exam.parts[i].exercises[j];
|
|
if (exercise.type === "multipleChoice") {
|
|
multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.questions.length - 1 })
|
|
}
|
|
if (exercise.type === "fillBlanks") {
|
|
multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.words.length - 1 })
|
|
}
|
|
}
|
|
}
|
|
setMultipleChoicesDone(multipleChoiceQuestionsDone);
|
|
}
|
|
|
|
};
|
|
|
|
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) + questionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
|
);
|
|
}
|
|
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
|
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
|
return (
|
|
exercisesDone +
|
|
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
|
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>
|
|
</>
|
|
) : (
|
|
<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 = () => {
|
|
if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words))
|
|
return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})\n\n${currentExercise.prompt}`
|
|
|
|
if (currentExercise?.type === "multipleChoice") {
|
|
return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})\n\n${currentExercise.prompt}`
|
|
}
|
|
|
|
if (typeof exam.parts[partIndex].context === "string") {
|
|
const nextExercise = exam.parts[partIndex].exercises[0] as MultipleChoiceExercise;
|
|
return `Part ${partIndex + 1} (Questions ${nextExercise.questions[0].id} - ${nextExercise.questions[nextExercise.questions.length - 1].id})\n\n${nextExercise.prompt}`
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (exercise.type === "fillBlanks") {
|
|
return userSolution?.solutions.length === exercise.words.length;
|
|
}
|
|
return 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} {...questionModalKwargs} />
|
|
{
|
|
!(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}
|
|
exerciseIndex={calculateExerciseIndex()}
|
|
module="level"
|
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
|
disableTimer={showSolutions || editing}
|
|
showTimer={false}
|
|
{...mcNavKwargs}
|
|
/>
|
|
<div
|
|
className={clsx(
|
|
"mb-20 w-full",
|
|
!!exam.parts[partIndex].context && !textRender && "grid grid-cols-2 gap-4",
|
|
)}>
|
|
|
|
{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">
|
|
<Button
|
|
color="purple"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
|
setPartIndex(partIndex - 1);
|
|
}}
|
|
className="max-w-[200px] w-full"
|
|
disabled={
|
|
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
|
|
typeof exam.parts[0].intro === "string" && questionIndex === 0}
|
|
>
|
|
Back
|
|
</Button>
|
|
|
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
Next
|
|
</Button>
|
|
</div>
|
|
)*/}
|
|
{exerciseIndex === -1 && partIndex === 0 && (
|
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
Start now
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|