Part and MC question grid jump to, has a bug on next going to refactor the whole thing

This commit is contained in:
Carlos Mesquita
2024-08-23 21:17:32 +01:00
parent b4b078c8c9
commit f0f38b335f
9 changed files with 339 additions and 114 deletions

View File

@@ -23,6 +23,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state); const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions); const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles; const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>(); const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>();
@@ -122,20 +123,9 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
); );
}; };
const onSelection = (id: string, value: string) => { const onSelection = (questionID: string, value: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]); setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
} setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
const getShuffles = () => {
let shuffle = {};
if (shuffleMaps) {
shuffle = {
shuffleMaps: shuffleMaps.filter((map) =>
answers.some(answer => answer.id === map.questionID)
)
}
}
return shuffle;
} }
return ( return (
@@ -221,7 +211,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })} onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
className="max-w-[200px] w-full" className="max-w-[200px] w-full"
disabled={ disabled={
exam && typeof partIndex !== "undefined" && exam.module === "level" && exam && typeof partIndex !== "undefined" && exam.module === "level" &&
@@ -232,7 +222,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
<Button <Button
color="purple" color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })} onClick={() => {onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>

View File

@@ -81,7 +81,8 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
hasExamEnded, hasExamEnded,
userSolutions: storeUserSolutions, userSolutions: storeUserSolutions,
setQuestionIndex, setQuestionIndex,
setUserSolutions setUserSolutions,
setCurrentSolution
} = useExamStore((state) => state); } = useExamStore((state) => state);
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles; const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
@@ -106,6 +107,11 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]); setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]);
}; };
useEffect(()=> {
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers])
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => { const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) { for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
if (originalPosition === originalSolution) { if (originalPosition === originalSolution) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import TextComponent from "./TextComponent";
import PartDivider from "./PartDivider"; import PartDivider from "./PartDivider";
import Timer from "@/components/Medium/Timer"; import Timer from "@/components/Medium/Timer";
import shuffleExamExercise from "./Shuffle"; import shuffleExamExercise from "./Shuffle";
import { Tab } from "@headlessui/react";
interface Props { interface Props {
exam: LevelExam; exam: LevelExam;
@@ -33,6 +34,17 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
const levelBgColor = "bg-ielts-level-light"; const levelBgColor = "bg-ielts-level-light";
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]); const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
const [showQuestionsModal, setShowQuestionsModal] = useState(false); const [showQuestionsModal, setShowQuestionsModal] = useState(false);
const [continueAnyways, setContinueAnyways] = useState(false);
const [seenParts, setSeenParts] = useState<number[]>(showSolutions ? exam.parts.map((_, index) => index) : [0]);
const [lastSolution, setLastSolution] = useState<UserSolution | undefined>(undefined);
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 { setBgColor } = useExamStore((state) => state); const { setBgColor } = useExamStore((state) => state);
const { userSolutions, setUserSolutions } = useExamStore((state) => state); const { userSolutions, setUserSolutions } = useExamStore((state) => state);
@@ -48,7 +60,28 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
const [contextWord, setContextWord] = useState<string | undefined>(undefined); const [contextWord, setContextWord] = useState<string | undefined>(undefined);
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined); const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
const [currentSolution, setCurrentSolution] = useExamStore((state) => [state.currentSolution, state.setCurrentSolution])
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!] : [] }]);
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
},[]);
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (hasExamEnded && exerciseIndex === -1) {
@@ -56,27 +89,21 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
} }
}, [hasExamEnded, exerciseIndex, setExerciseIndex]); }, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const getExercise = () => { const getExercise = () => {;
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
if (!exercise) return undefined; if (!exercise) return undefined;
exercise = { exercise = {
...exercise, ...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}; };
if (showSolutions) {
setShuffles([...shuffles.filter((x) => x.exerciseID !== exercise.id), { exerciseID: exercise.id, shuffles: userSolutions.find((x) => x.exercise === exercise.id)!.shuffleMaps!}]);
} else {
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles); exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
}
return exercise; return exercise;
}; };
useEffect(() => { useEffect(() => {
if (exerciseIndex !== -1) {
setCurrentExercise(getExercise()); setCurrentExercise(getExercise());
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex, exam.parts[partIndex].context]); }, [partIndex, exerciseIndex, exam.parts[partIndex].context]);
@@ -108,24 +135,31 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
}
/*if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") {
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
}*/
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex(exerciseIndex + 1); setExerciseIndex(exerciseIndex + 1);
return; return;
} }
if (partIndex + 1 < exam.parts.length && !hasExamEnded && (showQuestionsModal || showSolutions)) { if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) {
setLastSolution(solution);
modalKwargs();
setShowQuestionsModal(true);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions) {
modalKwargs();
setShowQuestionsModal(true);
return;
}
if (!showSolutions && exam.parts[0].intro) { if (!showSolutions && exam.parts[0].intro) {
setShowPartDivider(true); setShowPartDivider(true);
setBgColor(levelBgColor); setBgColor(levelBgColor);
} }
setSeenParts((prev) => [...prev, partIndex + 1])
setPartIndex(partIndex + 1); setPartIndex(partIndex + 1);
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0); setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
setStoreQuestionIndex(0); setStoreQuestionIndex(0);
@@ -133,28 +167,9 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
return; 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); setHasExamEnded(false);
if (typeof showSolutionsSave !== "undefined") {
if (solution) { onFinish(showSolutionsSave);
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
} else { } else {
onFinish(userSolutions); onFinish(userSolutions);
} }
@@ -200,6 +215,14 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
}, [exerciseIndex]) }, [exerciseIndex])
const calculateExerciseIndex = () => { 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) + (storeQuestionIndex + 1);
} else {
if (partIndex === 0) { if (partIndex === 0) {
return ( return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0) (exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
@@ -213,6 +236,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
storeQuestionIndex storeQuestionIndex
+ multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount }, 0) + multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount }, 0)
); );
}
}; };
const renderText = () => ( const renderText = () => (
@@ -247,8 +271,8 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
} }
} }
const modalKwargs = () => { const answeredEveryQuestion = (partIndex: number) => {
const allSolutionsCorrectLength = exam.parts[partIndex].exercises.every((exercise) => { return exam.parts[partIndex].exercises.every((exercise) => {
const userSolution = userSolutions.find(x => x.exercise === exercise.id); const userSolution = userSolutions.find(x => x.exercise === exercise.id);
if (exercise.type === "multipleChoice") { if (exercise.type === "multipleChoice") {
return userSolution?.solutions.length === exercise.questions.length; return userSolution?.solutions.length === exercise.questions.length;
@@ -258,27 +282,81 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
} }
return false; return false;
}); });
return {
blankQuestions: !allSolutionsCorrectLength,
finishingWhat: "part",
onClose: partIndex !== exam.parts.length - 1 ? (
function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
) : function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); onFinish(userSolutions); } else { setShowQuestionsModal(false) } }
} }
useEffect(() => {
if (continueAnyways) {
nextExercise(lastSolution);
setContinueAnyways(false);
} }
// 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": setStoreQuestionIndex
}
return ( return (
<> <>
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}> <div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
<QuestionsModal isOpen={showQuestionsModal} {...modalKwargs()} /> <QuestionsModal isOpen={showQuestionsModal} {...questionModalKwargs} />
{ {
!(partIndex === 0 && storeQuestionIndex === 0 && showPartDivider) && !(partIndex === 0 && storeQuestionIndex === 0 && showPartDivider) &&
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} /> <Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} />
} }
{exam.parts[0].intro && showPartDivider ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setBgColor("bg-white") }} /> : ( {exam.parts[0].intro && showPartDivider ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setBgColor("bg-white") }} /> : (
<> <>
{exam.parts[0].intro && (
<div className="w-full">
<Tab.Group className="w-[90%]" selectedIndex={partIndex} onChange={setPartIndex}>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
{exam.parts.map((_, index) =>
<Tab key={index} onClick={(e) => {
if (!seenParts.includes(index)) {
e.preventDefault();
} else {
setExerciseIndex(0);
setStoreQuestionIndex(0);
}
}}
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/80",
"ring-white ring-opacity-60 focus:outline-none",
"transition duration-300 ease-in-out",
selected && "bg-white shadow",
seenParts.includes(index) ? "hover:bg-white/70" : "cursor-not-allowed"
)
}
>{`Part ${index + 1}`}</Tab>
)
}
</Tab.List>
</Tab.Group>
</div>
)}
<ModuleTitle <ModuleTitle
partLabel={partLabel()} partLabel={partLabel()}
minTimer={exam.minTimer} minTimer={exam.minTimer}
@@ -287,6 +365,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions || editing} disableTimer={showSolutions || editing}
showTimer={false} showTimer={false}
{...mcNavKwargs}
/> />
<div <div
className={clsx( className={clsx(

View File

@@ -20,6 +20,7 @@ export interface ExamState {
inactivity: number; inactivity: number;
shuffles: Shuffles[]; shuffles: Shuffles[];
bgColor: string; bgColor: string;
currentSolution?: UserSolution | undefined;
} }
export interface ExamFunctions { export interface ExamFunctions {
@@ -39,6 +40,7 @@ export interface ExamFunctions {
setInactivity: (inactivity: number) => void; setInactivity: (inactivity: number) => void;
setShuffles: (shuffles: Shuffles[]) => void; setShuffles: (shuffles: Shuffles[]) => void;
setBgColor: (bgColor: string) => void; setBgColor: (bgColor: string) => void;
setCurrentSolution: (currentSolution: UserSolution | undefined) => void;
reset: () => void; reset: () => void;
} }
@@ -58,7 +60,8 @@ export const initialState: ExamState = {
questionIndex: 0, questionIndex: 0,
inactivity: 0, inactivity: 0,
shuffles: [], shuffles: [],
bgColor: "bg-white" bgColor: "bg-white",
currentSolution: undefined
}; };
const useExamStore = create<ExamState & ExamFunctions>((set) => ({ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
@@ -80,6 +83,7 @@ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
setInactivity: (inactivity: number) => set(() => ({inactivity})), setInactivity: (inactivity: number) => set(() => ({inactivity})),
setShuffles: (shuffles: Shuffles[]) => set(() => ({shuffles})), setShuffles: (shuffles: Shuffles[]) => set(() => ({shuffles})),
setBgColor: (bgColor) => set(() => ({bgColor})), setBgColor: (bgColor) => set(() => ({bgColor})),
setCurrentSolution: (currentSolution: UserSolution | undefined) => set(() => ({currentSolution})),
reset: () => set(() => initialState), reset: () => set(() => initialState),
})); }));