Merged in feature/ExamGenRework (pull request #120)

ENCOA-253, ENCOA-248, ENCOA-246

Approved-by: Tiago Ribeiro
This commit is contained in:
carlos.mesquita
2024-12-03 08:36:10 +00:00
committed by Tiago Ribeiro
8 changed files with 90 additions and 51 deletions

View File

@@ -112,7 +112,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
onChange={(o) => onIntroOptionChange({ value: o!.value, label: o!.label })} onChange={(o) => onIntroOptionChange({ value: o!.value, label: o!.label })}
value={localSettings.introOption} value={localSettings.introOption}
/> />
{localSettings.introOption.value !== "None" && ( {localSettings.introOption && localSettings.introOption.value !== "None" && (
<AutoExpandingTextArea <AutoExpandingTextArea
key={`section-${sectionId}`} key={`section-${sectionId}`}
value={localSettings.currentIntro || ''} value={localSettings.currentIntro || ''}

View File

@@ -33,21 +33,24 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
importModule importModule
} = useExamEditorStore(state => state.modules[currentModule]); } = useExamEditorStore(state => state.modules[currentModule]);
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== undefined ? levelParts : 1); const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1);
useEffect(() => { useEffect(() => {
setNumberOfLevelParts(levelParts); if (levelParts !== 0) {
dispatch({ setNumberOfLevelParts(levelParts);
type: 'UPDATE_MODULE', dispatch({
payload: { type: 'UPDATE_MODULE',
updates: { payload: {
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({ updates: {
id: i + 1, sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
label: `Part ${i + 1}` id: i + 1,
})) label: `Part ${i + 1}`
}))
},
module: "level"
} }
} })
}) }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelParts]) }, [levelParts])

View File

@@ -2,8 +2,9 @@ import Button from "@/components/Low/Button";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap } from "@/interfaces/exam"; import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap } from "@/interfaces/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/exam"; import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import { defaultExamUserSolutions } from "@/utils/exams";
import clsx from "clsx"; import clsx from "clsx";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { BsFillGrid3X3GapFill } from "react-icons/bs"; import { BsFillGrid3X3GapFill } from "react-icons/bs";
interface Props { interface Props {
@@ -18,7 +19,7 @@ const MCQuestionGrid: React.FC<Props> = ({ exam, showSolutions, runOnClick, prev
const examState = useExamStore((state) => state); const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state); const persistentExamState = usePersistentExamStore((state) => state);
const { const {
userSolutions, userSolutions,
partIndex: sectionIndex, partIndex: sectionIndex,
@@ -27,7 +28,10 @@ const MCQuestionGrid: React.FC<Props> = ({ exam, showSolutions, runOnClick, prev
const currentExercise = useMemo(() => (exam as LevelExam).parts[sectionIndex!].exercises[exerciseIndex] as MultipleChoiceExercise, [exam, exerciseIndex, sectionIndex]) const currentExercise = useMemo(() => (exam as LevelExam).parts[sectionIndex!].exercises[exerciseIndex] as MultipleChoiceExercise, [exam, exerciseIndex, sectionIndex])
const userSolution = useMemo(() => userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!, [currentExercise.id, userSolutions]) const userSolution = useMemo(() => userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!, [currentExercise.id, userSolutions])
const answeredQuestions = useMemo(() => new Set(userSolution.solutions.map(sol => sol.question.toString())), [userSolution.solutions]) const answeredQuestions = useMemo(() =>
userSolution ? new Set(userSolution.solutions.map(sol => sol.question.toString())) : new Set(),
[userSolution]
);
const exerciseOffset = useMemo(() => Number(currentExercise.questions[0].id), [currentExercise.questions]) const exerciseOffset = useMemo(() => Number(currentExercise.questions[0].id), [currentExercise.questions])
const lastExercise = useMemo(() => exerciseOffset + (currentExercise.questions.length - 1), const lastExercise = useMemo(() => exerciseOffset + (currentExercise.questions.length - 1),
[currentExercise.questions.length, exerciseOffset]); [currentExercise.questions.length, exerciseOffset]);

View File

@@ -8,29 +8,38 @@ import { LevelExam, ListeningExam, ReadingExam, SpeakingExam, WritingExam } from
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { usePersistentExamStore } from "@/stores/exam"; import { usePersistentExamStore } from "@/stores/exam";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect } from "react";
// TODO: perms // TODO: perms
const Popout: React.FC<{ user: User }> = ({ user }) => { const Popout: React.FC<{ user: User }> = ({ user }) => {
const state = usePersistentExamStore((state) => state); const { bgColor, exam, partIndex, setUserSolutions } = usePersistentExamStore();
usePersistentStorage(usePersistentExamStore); usePersistentStorage(usePersistentExamStore);
useEffect(() => {
return () => {
setUserSolutions([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return ( return (
<div className={`relative flex w-full min-h-screen p-4 shadow-md items-center rounded-2xl ${state.bgColor}`}> <div className={`relative flex w-full min-h-screen p-4 shadow-md items-center rounded-2xl ${bgColor}`}>
<div className={clsx("relative flex p-20 justify-center flex-1")}> <div className={clsx("relative flex p-20 justify-center flex-1")}>
{state.exam?.module == "level" && state.exam.parts && state.partIndex >= 0 && {exam?.module == "level" && exam.parts && partIndex >= 0 &&
<Level exam={state.exam as LevelExam} preview={true} /> <Level exam={exam as LevelExam} preview={true} />
} }
{state.exam?.module == "writing" && state.exam.exercises && state.partIndex >= 0 && {exam?.module == "writing" && exam.exercises && partIndex >= 0 &&
<Writing exam={state.exam as WritingExam} preview={true} /> <Writing exam={exam as WritingExam} preview={true} />
} }
{state.exam?.module == "reading" && state.exam.parts.length > 0 && {exam?.module == "reading" && exam.parts.length > 0 &&
<Reading exam={state.exam as ReadingExam} preview={true} /> <Reading exam={exam as ReadingExam} preview={true} />
} }
{state.exam?.module == "listening" && state.exam.parts.length > 0 && {exam?.module == "listening" && exam.parts.length > 0 &&
<Listening exam={state.exam as ListeningExam} preview={true} /> <Listening exam={exam as ListeningExam} preview={true} />
} }
{state.exam?.module == "speaking" && state.exam.exercises.length > 0 && {exam?.module == "speaking" && exam.exercises.length > 0 &&
<Speaking exam={state.exam as SpeakingExam} preview={true} /> <Speaking exam={exam as SpeakingExam} preview={true} />
} }
</div> </div>
</div> </div>

View File

@@ -92,7 +92,7 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
const { const {
nextExercise, previousExercise, nextExercise, previousExercise,
showPartDivider, setShowPartDivider, showPartDivider, setShowPartDivider,
seenParts, setSeenParts, startNow seenParts, setSeenParts, startNow, setStartNow
} = useExamNavigation( } = useExamNavigation(
{ {
exam, module: "level", showBlankModal: showQuestionsModal, exam, module: "level", showBlankModal: showQuestionsModal,
@@ -361,14 +361,14 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
(!showPartDivider && !startNow) && (!showPartDivider && !startNow) &&
<Timer minTimer={exam.minTimer} disableTimer={showSolutions || preview} standalone={true} /> <Timer minTimer={exam.minTimer} disableTimer={showSolutions || preview} standalone={true} />
} }
{(showPartDivider || startNow) ? {(showPartDivider || (startNow && partIndex === 0)) ?
<PartDivider <PartDivider
module="level" module="level"
sectionLabel="Part" sectionLabel="Part"
defaultTitle="Placement Test" defaultTitle="Placement Test"
section={exam.parts[partIndex]} section={exam.parts[partIndex]}
sectionIndex={partIndex} sectionIndex={partIndex}
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts(prev => new Set(prev).add(partIndex)); }} onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); setSeenParts(prev => new Set(prev).add(partIndex)); }}
/> : ( /> : (
<> <>
{exam.parts.length > 1 && <SectionNavbar {exam.parts.length > 1 && <SectionNavbar

View File

@@ -29,6 +29,7 @@ type UseExamNavigation = (props: {
setShowPartDivider: React.Dispatch<React.SetStateAction<boolean>>; setShowPartDivider: React.Dispatch<React.SetStateAction<boolean>>;
setSeenParts: React.Dispatch<React.SetStateAction<Set<number>>>; setSeenParts: React.Dispatch<React.SetStateAction<Set<number>>>;
setIsBetweenParts: React.Dispatch<React.SetStateAction<boolean>>; setIsBetweenParts: React.Dispatch<React.SetStateAction<boolean>>;
setStartNow: React.Dispatch<React.SetStateAction<boolean>>;
}; };
const useExamNavigation: UseExamNavigation = ({ const useExamNavigation: UseExamNavigation = ({
@@ -50,8 +51,7 @@ const useExamNavigation: UseExamNavigation = ({
exerciseIndex, setExerciseIndex, exerciseIndex, setExerciseIndex,
partIndex, setPartIndex, partIndex, setPartIndex,
questionIndex, setQuestionIndex, questionIndex, setQuestionIndex,
userSolutions, setModuleIndex, userSolutions, setBgColor,
setBgColor,
dispatch, dispatch,
} = !preview ? examState : persistentExamState; } = !preview ? examState : persistentExamState;
@@ -74,19 +74,34 @@ const useExamNavigation: UseExamNavigation = ({
const [startNow, setStartNow] = useState(!showPartDivider && !showSolutions); const [startNow, setStartNow] = useState(!showPartDivider && !showSolutions);
// when navbar is used // when navbar is used
useEffect(()=> { useEffect(() => {
if(startNow && !showPartDivider && partIndex !== 0) { if (startNow && !showPartDivider && partIndex !== 0) {
setStartNow(false); setStartNow(false);
} }
} , [partIndex, startNow, showPartDivider]) }, [partIndex, startNow, showPartDivider])
useEffect(() => { useEffect(() => {
if (!showSolutions && hasDivider(exam, isPartExam ? partIndex : exerciseIndex) && !seenParts.has(partIndex)) { if (
setShowPartDivider(true); (
module === "level" ?
(startNow || hasDivider(exam, isPartExam ? partIndex : exerciseIndex))
:
(hasDivider(exam, isPartExam ? partIndex : exerciseIndex))
)
&& !showSolutions && !seenParts.has(partIndex)
) {
if (
// partIndex === 0 -> startNow
(module === "level" && (partIndex === 0 || hasDivider(exam, isPartExam ? partIndex : exerciseIndex))) ||
module !== "level"
) {
setShowPartDivider(true);
}
setBgColor(`bg-ielts-${module}-light`); setBgColor(`bg-ielts-${module}-light`);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex]); }, [partIndex, startNow]);
const nextExercise = (keepGoing: boolean = false) => { const nextExercise = (keepGoing: boolean = false) => {
scrollToTop(); scrollToTop();
@@ -160,7 +175,7 @@ const useExamNavigation: UseExamNavigation = ({
setQuestionIndex(0); setQuestionIndex(0);
return; return;
} }
if (modalBetweenParts && !seenParts.has(partIndex + 1) && !answeredEveryQuestionInPart(exam as PartExam, partIndex, userSolutions) && !keepGoing && setShowBlankModal && !showSolutions && !preview) { if (modalBetweenParts && !seenParts.has(partIndex + 1) && !answeredEveryQuestionInPart(exam as PartExam, partIndex, userSolutions) && !keepGoing && setShowBlankModal && !showSolutions && !preview) {
if (modalKwargs) modalKwargs(); if (modalKwargs) modalKwargs();
setShowBlankModal(true); setShowBlankModal(true);
@@ -197,7 +212,7 @@ const useExamNavigation: UseExamNavigation = ({
const previousPartExam = () => { const previousPartExam = () => {
if (!showPartDivider && partIndex === 0 && exerciseIndex === 0 && questionIndex === 0 && !startNow) { if (!showPartDivider && partIndex === 0 && exerciseIndex === 0 && questionIndex === 0 && !startNow) {
setStartNow(true); if (module !== "level") setStartNow(true);
return; return;
} }
@@ -226,7 +241,7 @@ const useExamNavigation: UseExamNavigation = ({
setQuestionIndex(Math.max(0, questionIndex - MC_PER_PAGE)); setQuestionIndex(Math.max(0, questionIndex - MC_PER_PAGE));
return; return;
} }
if (exerciseIndex !== 0) { if (exerciseIndex !== 0) {
setExerciseIndex(exerciseIndex - 1); setExerciseIndex(exerciseIndex - 1);
setQuestionIndex(0); setQuestionIndex(0);
@@ -294,7 +309,8 @@ const useExamNavigation: UseExamNavigation = ({
setSeenParts, setSeenParts,
isBetweenParts, isBetweenParts,
setIsBetweenParts, setIsBetweenParts,
startNow startNow,
setStartNow
}; };
} }

View File

@@ -5,7 +5,7 @@ export type Variant = "full" | "partial";
export type InstructorGender = "male" | "female" | "varied"; export type InstructorGender = "male" | "female" | "varied";
export type Difficulty = "easy" | "medium" | "hard"; export type Difficulty = "easy" | "medium" | "hard";
interface ExamBase { export interface ExamBase {
id: string; id: string;
module: Module; module: Module;
minTimer: number; minTimer: number;

View File

@@ -3,7 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Exam, InstructorGender, Variant } from "@/interfaces/exam"; import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
import { getExams } from "@/utils/exams.be"; import { getExams } from "@/utils/exams.be";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { getUserCorporate } from "@/utils/groups.be"; import { getUserCorporate } from "@/utils/groups.be";
@@ -60,18 +60,25 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
}; };
await session.withTransaction(async () => { await session.withTransaction(async () => {
const docSnap = await db.collection(module).findOne({ id: req.body.id }, { session }); const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
if (docSnap) { // Check whether the id of the exam matches another exam with different
// owners, throw exception if there is, else allow editing
const ownersSet = new Set(docSnap?.owners || []);
if (docSnap?.owners?.length === exam.owners.lenght && exam.owners.every((e: string) => ownersSet.has(e))) {
throw new Error("Name already exists"); throw new Error("Name already exists");
} }
await db.collection(module).insertOne( await db.collection(module).updateOne(
{ id: req.body.id, ...exam }, { id: req.body.id },
{ session } { $set: { id: req.body.id, ...exam } },
{
upsert: true,
session
}
); );
}); });
res.status(200).json(exam); res.status(200).json(exam);
} catch (error) { } catch (error) {