Part intro's, modals between parts and some fixes

This commit is contained in:
Carlos Mesquita
2024-08-20 18:52:38 +01:00
parent 3299acee36
commit 505df31d6b
19 changed files with 907 additions and 708 deletions

416
src/exams/Level/index.tsx Normal file
View File

@@ -0,0 +1,416 @@
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";
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 [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
const [showQuestionsModal, setShowQuestionsModal] = 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 [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);
useEffect(() => {
if (showSolutions && exerciseIndex && userSolutions[exerciseIndex].shuffleMaps) {
setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[])
}
}, [showSolutions])
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const getExercise = () => {
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
if (!exercise) return undefined;
exercise = {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
if (exam.shuffle && exercise.type === "multipleChoice") {
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 {
exercise.questions = exercise.questions.map(question => {
const questionShuffleMap = shuffleMaps.find(map => map.id === question.id);
if (questionShuffleMap) {
const newOptions = question.options.map(option => ({
id: option.id,
text: question.options.find(o => questionShuffleMap.map[o.id] === option.id)?.text || option.text
}));
return { ...question, options: newOptions };
}
return question;
});
}
} else if (exam.shuffle && exercise.type === "fillBlanks" && typeCheckWordsMC(exercise.words)) {
if (shuffleMaps.length === 0 && !showSolutions) {
const newShuffleMaps: ShuffleMap[] = [];
exercise.words = exercise.words.map(word => {
if ('options' in word) {
const options = { ...word.options };
const 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);
}
}
return exercise;
};
useEffect(() => {
if (exerciseIndex !== -1) {
setCurrentExercise(getExercise());
}
}, [partIndex, exerciseIndex, shuffleMaps, exam.parts[partIndex].context]);
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 (match) {
const word = match[1];
const originalLineNumber = match[2];
if (word !== contextWord) {
setContextWord(word);
}
const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace(
`in line ${originalLineNumber}`,
`in line ${contextWordLine || originalLineNumber}`
);
currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt;
} else {
setContextWord(undefined);
}
}
}, [currentExercise, storeQuestionIndex]);
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) {
setShowPartDivider(true);
setBgColor(levelBgColor);
}
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);
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 }]);
} else {
onFinish(userSolutions);
}
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
}
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") {
setStoreQuestionIndex(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);
}
};
useEffect(() => {
if (exerciseIndex === -1) {
nextExercise()
}
}, [exerciseIndex])
const calculateExerciseIndex = () => {
if (partIndex === 0) {
return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex //+ 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) +
storeQuestionIndex
+ 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">
<h4 className="text-xl font-semibold">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
</h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div>
<TextComponent
part={exam.parts[partIndex]}
contextWord={contextWord}
setContextWordLine={setContextWordLine}
/>
</>
</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 modalKwargs = () => {
const allSolutionsCorrectLength = 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;
});
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) } }
}
}
return (
<>
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
<QuestionsModal isOpen={showQuestionsModal} {...modalKwargs()} />
{
!(partIndex === 0 && storeQuestionIndex === 0 && showPartDivider) &&
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} />
}
{showPartDivider ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setBgColor("bg-white") }} /> : (
<>
<ModuleTitle
partLabel={partLabel()}
minTimer={exam.minTimer}
exerciseIndex={calculateExerciseIndex()}
module="level"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions || editing}
showTimer={typeof exam.parts[0].intro === "undefined"}
/>
<div
className={clsx(
"mb-20 w-full",
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "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)}
</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" && storeQuestionIndex === 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>
</>
);
}