Exam generation rework, batch user tables, fastapi endpoint switch

This commit is contained in:
Carlos-Mesquita
2024-11-04 23:29:14 +00:00
parent a2bc997e8f
commit 15c9c4d4bd
148 changed files with 11348 additions and 3901 deletions

View File

@@ -1,38 +0,0 @@
import Button from "@/components/Low/Button";
import { Module } from "@/interfaces";
import { LevelPart, UserSolution } from "@/interfaces/exam";
import clsx from "clsx";
import { ReactNode } from "react";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
interface Props {
partIndex: number;
part: LevelPart // for now
onNext: () => void;
}
const PartDivider: React.FC<Props> = ({ partIndex, part, onNext }) => {
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" />,
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
level: <BsClipboard className="text-white w-6 h-6" />,
};
return (
<div className={clsx("flex flex-col h-fit border bg-white rounded-3xl p-12 gap-8", part.intro ? "w-3/6" : "items-center my-auto")}>
{/** only level for now */}
<div className="flex flex-row gap-4 items-center"><div className="w-12 h-12 bg-ielts-level flex items-center justify-center rounded-lg">{moduleIcon["level"]}</div><p className="text-3xl">{part.intro ? `Part ${partIndex + 1}` : "Placement Test"}</p></div>
{part.intro && part.intro.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip" dangerouslySetInnerHTML={{__html: x.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>')}}></p>)}
<div className="flex items-center justify-center mt-4">
<Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl">
{partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`}
</Button>
</div>
</div>
)
}
export default PartDivider;

View File

@@ -147,9 +147,3 @@ function fisherYatesShuffle<T>(array: T[]): T[] {
}
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
);
}

View File

@@ -6,34 +6,33 @@ import { renderSolution } from "@/components/Solutions";
import { Module } from "@/interfaces";
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import { usePersistentExamStore } from "@/stores/examStore";
import { countExercises } from "@/utils/moduleUtils";
import clsx from "clsx";
import { use, useEffect, useMemo, useState } from "react";
import TextComponent from "./TextComponent";
import PartDivider from "./PartDivider";
import PartDivider from "../Navigation/SectionDivider";
import Timer from "@/components/Medium/Timer";
import shuffleExamExercise from "./Shuffle";
import { Tab } from "@headlessui/react";
import Modal from "@/components/Modal";
import { typeCheckWordsMC } from "@/utils/type.check";
import SectionNavbar from "../Navigation/SectionNavbar";
interface Props {
exam: LevelExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
editing?: boolean;
preview?: 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) {
export default function Level({ exam, showSolutions = false, onFinish, preview = false }: Props) {
const levelBgColor = "bg-ielts-level-light";
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {
userSolutions,
hasExamEnded,
@@ -50,7 +49,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
setQuestionIndex,
setShuffles,
setCurrentSolution
} = useExamStore((state) => state);
} = !preview ? examState : persistentExamState;
// In case client want to switch back
const textRenderDisabled = true;
@@ -74,7 +73,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
const [currentExercise, setCurrentExercise] = useState<Exercise | undefined>(undefined);
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
const [startNow, setStartNow] = useState<boolean>(true && !showSolutions);
const [startNow, setStartNow] = useState<boolean>(!showSolutions);
useEffect(() => {
if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) {
@@ -175,7 +174,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
return;
}
if (partIndex + 1 === exam.parts.length && exerciseIndex === exam.parts[partIndex].exercises.length - 1 && !continueAnyways) {
if (partIndex + 1 === exam.parts.length && exerciseIndex === exam.parts[partIndex].exercises.length - 1 && !continueAnyways && !showSolutions) {
modalKwargs();
setShowQuestionsModal(true);
}
@@ -414,9 +413,9 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
exam: exam,
partIndex: partIndex,
showSolutions: showSolutions,
"setExerciseIndex": setExerciseIndex,
"setPartIndex": setPartIndex,
"runOnClick": setQuestionIndex
setExerciseIndex: setExerciseIndex,
setPartIndex: setPartIndex,
runOnClick: setQuestionIndex
}
@@ -427,8 +426,8 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
{textRender && !textRenderDisabled ?
renderText() :
<>
{exam.parts[partIndex].context && renderText()}
{(showSolutions || editing) ?
{exam.parts[partIndex]?.context && renderText()}
{(showSolutions) ?
currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) :
currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise)
}
@@ -465,64 +464,55 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
!(partIndex === 0 && questionIndex === 0 && (showPartDivider || startNow)) &&
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} />
}
{(showPartDivider || startNow) ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setStartNow(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 client wants to revert uncomment and remove the added if statement
if (!seenParts.has(index)) {
e.preventDefault();
} else {
*/
setExerciseIndex(0);
setQuestionIndex(0);
if (!seenParts.has(index)) {
setShowPartDivider(true);
setBgColor(levelBgColor);
setSeenParts(prev => new Set(prev).add(index));
}
}}
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 hover:bg-white/70",
selected && "bg-white shadow",
// seenParts.includes(index) ? "hover:bg-white/70" : "cursor-not-allowed"
)
}
>{`Part ${index + 1}`}</Tab>
)
{(showPartDivider || startNow) ?
<PartDivider
module="level"
sectionLabel="Part"
defaultTitle="Placement Test"
section={exam.parts[partIndex]}
sectionIndex={partIndex}
onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); }}
/> : (
<>
{exam.parts[0].intro && (
<SectionNavbar
module="level"
sections={exam.parts}
sectionLabel="Part"
sectionIndex={partIndex}
setSectionIndex={setPartIndex}
onClick={
(index: number) => {
setExerciseIndex(0);
setQuestionIndex(0);
if (!seenParts.has(index)) {
setShowPartDivider(true);
setBgColor(levelBgColor);
setSeenParts(prev => new Set(prev).add(index));
}
}
</Tab.List>
</Tab.Group>
} />
)}
<ModuleTitle
examLabel={exam.label}
partLabel={partLabel()}
minTimer={exam.minTimer}
exerciseIndex={calculateExerciseIndex()}
module="level"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions}
showTimer={false}
{...mcNavKwargs}
/>
<div
className={clsx(
"mb-20 w-full",
!!exam.parts[partIndex].context && !textRender && "grid grid-cols-2 gap-4",
)}>
{memoizedRender}
</div>
)}
<ModuleTitle
examLabel={exam.label}
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",
)}>
{memoizedRender}
</div>
</>
)}
</>
)}
</div>
</>
);

View File

@@ -0,0 +1,50 @@
import Button from "@/components/Low/Button";
import { Module } from "@/interfaces";
import { LevelPart, ListeningPart, ReadingPart, SpeakingExercise, UserSolution, WritingExercise } from "@/interfaces/exam";
import clsx from "clsx";
import { ReactNode } from "react";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
interface Props {
sectionIndex: number;
sectionLabel: string;
defaultTitle: string;
module: Module;
section: LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise;
onNext: () => void;
}
const PartDivider: React.FC<Props> = ({ sectionIndex, sectionLabel, section, module, defaultTitle, onNext }) => {
const iconStyle = "text-white w-6 h-6";
const moduleIcon: { [key in Module]: ReactNode } = {
reading: <BsBook className={iconStyle} />,
listening: <BsHeadphones className={iconStyle} />,
writing: <BsPen className={iconStyle} />,
speaking: <BsMegaphone className={iconStyle} />,
level: <BsClipboard className={iconStyle} />,
};
return (
<div className={clsx("flex flex-col h-fit border bg-white rounded-3xl p-12 gap-8", section.intro ? "w-3/6" : "items-center my-auto")}>
<div className="flex flex-row gap-4 items-center">
<div className={`w-12 h-12 bg-ielts-${module} flex items-center justify-center rounded-lg`}>{moduleIcon[module]}</div>
<p className="text-3xl">{section.intro ? `${sectionLabel} ${sectionIndex + 1}` : defaultTitle}</p>
</div>
{section.intro && section.intro.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip" dangerouslySetInnerHTML={{ __html: x.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>') }}></p>)}
<div className="flex items-center justify-center mt-4">
<button
onClick={() => onNext()}
className={clsx(
"max-w-[200px] self-end w-full text-2xl text-white",
"rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer select-none",
"py-4 px-6",
`bg-ielts-${module} hover:bg-ielts-${module}/70`
)}>
{sectionIndex === 0 ? `Start now` : `Start ${sectionLabel} ${sectionIndex + 1}`}
</button>
</div>
</div>
)
}
export default PartDivider;

View File

@@ -0,0 +1,42 @@
import { Module } from "@/interfaces";
import { LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
import { Tab, TabGroup, TabList } from "@headlessui/react";
import clsx from "clsx";
import React from "react";
interface Props {
module: Module;
sections: LevelPart[] | ReadingPart[] | ListeningPart[] | WritingExercise[] | SpeakingExercise[];
sectionIndex: number;
sectionLabel: string;
setSectionIndex: (index: number) => void;
onClick: (index: number) => void;
}
const SectionNavbar: React.FC<Props> = ({module, sections, sectionIndex, sectionLabel, setSectionIndex, onClick}) => {
return (
<div className="w-full">
<TabGroup className="w-[90%]" selectedIndex={sectionIndex} onChange={setSectionIndex}>
<TabList className={`flex space-x-1 rounded-xl bg-ielts-${module}/20 p-1`}>
{sections.map((_, index) =>
<Tab key={index} onClick={() => onClick(index)}
className={({ selected }) =>
clsx(
`w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-${module}/80`,
"ring-white ring-opacity-60 focus:outline-none",
"transition duration-300 ease-in-out hover:bg-white/70",
selected && "bg-white shadow",
)
}
>{`${sectionLabel} ${index + 1}`}</Tab>
)
}
</TabList>
</TabGroup>
</div>
);
}
export default SectionNavbar;

View File

@@ -16,13 +16,14 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import {Divider} from "primereact/divider";
import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/QuestionsModal";
import useExamStore from "@/stores/examStore";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
interface Props {
exam: ReadingExam;
showSolutions?: boolean;
preview?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
}
@@ -105,18 +106,30 @@ function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: s
);
}
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
export default function Reading({exam, showSolutions = false, preview = false, onFinish}: Props) {
const [showTextModal, setShowTextModal] = useState(false);
const [showBlankModal, setShowBlankModal] = useState(false);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
const [isTextMinimized, setIsTextMinimzed] = useState(false);
const [exerciseType, setExerciseType] = useState("");
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 examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {
hasExamEnded,
userSolutions,
exerciseIndex,
partIndex,
questionIndex: storeQuestionIndex,
setBgColor,
setUserSolutions,
setHasExamEnded,
setExerciseIndex,
setPartIndex,
setQuestionIndex: setStoreQuestionIndex
} = !preview ? examState : persistentExamState;
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
@@ -308,7 +321,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)}
{exerciseIndex > -1 &&
partIndex > -1 &&

View File

@@ -1,34 +1,52 @@
import {renderExercise} from "@/components/Exercises";
import { renderExercise } from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, WritingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
import { renderSolution } from "@/components/Solutions";
import { UserSolution, WritingExam } from "@/interfaces/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import { countExercises } from "@/utils/moduleUtils";
import PartDivider from "./Navigation/SectionDivider";
import { useEffect, useState } from "react";
interface Props {
exam: WritingExam;
showSolutions?: boolean;
preview?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
}
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
export default function Writing({ exam, showSolutions = false, preview = false, onFinish }: Props) {
const writingBgColor = "bg-ielts-writing-light";
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {
userSolutions,
exerciseIndex,
setBgColor,
setUserSolutions,
setHasExamEnded,
setExerciseIndex,
} = !preview ? examState : persistentExamState;
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : []));
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
setShowPartDivider(true);
setBgColor(writingBgColor);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]);
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
}
if (exerciseIndex + 1 < exam.exercises.length) {
@@ -41,7 +59,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
setHasExamEnded(false);
if (solution) {
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
} else {
onFinish(userSolutions);
}
@@ -50,7 +68,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
}
if (exerciseIndex > 0) {
@@ -68,23 +86,34 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
return (
<>
<div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1}
{(showPartDivider) ?
<PartDivider
module="writing"
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions}
/>
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div>
sectionLabel="Task"
defaultTitle="Writing exam"
section={exam.exercises[exerciseIndex]}
sectionIndex={exerciseIndex}
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex))}}
/> : (
<div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1}
module="writing"
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions || preview}
/>
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, preview)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div>
)
}
</>
);
}