Merged in feature/ExamGenRework (pull request #115)

More modal patches and a bug in level that I'm still trying to solve

Approved-by: Tiago Ribeiro
This commit is contained in:
carlos.mesquita
2024-11-26 17:25:22 +00:00
committed by Tiago Ribeiro
8 changed files with 183 additions and 44 deletions

View File

@@ -1,5 +1,5 @@
import {Dialog, Transition} from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import {Fragment} from "react"; import { Fragment, useCallback, useEffect, useState } from "react";
import Button from "./Low/Button"; import Button from "./Low/Button";
interface Props { interface Props {
@@ -11,10 +11,54 @@ interface Props {
onCancel: () => void; onCancel: () => void;
} }
export default function AbandonPopup({isOpen, abandonPopupTitle, abandonPopupDescription, abandonConfirmButtonText, onAbandon, onCancel}: Props) { export default function AbandonPopup({ isOpen, abandonPopupTitle, abandonPopupDescription, abandonConfirmButtonText, onAbandon, onCancel }: Props) {
const [isClosing, setIsClosing] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
if (isOpen) {
setMounted(true);
}
}, [isOpen]);
useEffect(() => {
if (!isOpen && mounted) {
const timer = setTimeout(() => {
setMounted(false);
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}
}, [isOpen, mounted]);
const blockMultipleClicksClose = useCallback((cancel: boolean) => {
if (isClosing) return;
setIsClosing(true);
const func = cancel ? onCancel : onAbandon;
func();
const timer = setTimeout(() => {
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}, [isClosing, onCancel, onAbandon]);
if (!mounted && !isOpen) return null;
return ( return (
<Transition show={isOpen} as={Fragment}> <Transition
<Dialog onClose={onCancel} className="relative z-50"> show={isOpen}
as={Fragment}
beforeEnter={() => setIsClosing(false)}
beforeLeave={() => setIsClosing(true)}
afterLeave={() => {
setIsClosing(false);
setMounted(false);
}}
>
<Dialog onClose={() => blockMultipleClicksClose(true)} className="relative z-50">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
@@ -39,10 +83,10 @@ export default function AbandonPopup({isOpen, abandonPopupTitle, abandonPopupDes
<Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title> <Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title>
<span>{abandonPopupDescription}</span> <span>{abandonPopupDescription}</span>
<div className="w-full flex justify-between mt-8"> <div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={onCancel} variant="outline" className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={() => blockMultipleClicksClose(true)} variant="outline" className="max-w-[200px] self-end w-full">
Cancel Cancel
</Button> </Button>
<Button color="purple" onClick={onAbandon} className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={() => blockMultipleClicksClose(false)} className="max-w-[200px] self-end w-full">
{abandonConfirmButtonText} {abandonConfirmButtonText}
</Button> </Button>
</div> </div>

View File

@@ -4,7 +4,6 @@ interface Props {
letter: string; letter: string;
word: string; word: string;
isSelected: boolean; isSelected: boolean;
isUsed: boolean;
onClick: () => void; onClick: () => void;
onRemove?: () => void; onRemove?: () => void;
onEdit?: (newWord: string) => void; onEdit?: (newWord: string) => void;
@@ -15,7 +14,6 @@ const FillBlanksWord: React.FC<Props> = ({
letter, letter,
word, word,
isSelected, isSelected,
isUsed,
onClick, onClick,
onRemove, onRemove,
onEdit, onEdit,
@@ -36,10 +34,8 @@ const FillBlanksWord: React.FC<Props> = ({
) : ( ) : (
<button <button
onClick={onClick} onClick={onClick}
disabled={isUsed}
className={` className={`
min-w-0 flex-1 flex items-center gap-2 p-2 rounded-md border text-left transition-colors min-w-0 flex-1 flex items-center gap-2 p-2 rounded-md border text-left transition-colors
${isUsed ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-blue-50'}
${isSelected ? 'border-blue-500 bg-blue-100' : 'border-gray-200'} ${isSelected ? 'border-blue-500 bg-blue-100' : 'border-gray-200'}
`} `}
> >

View File

@@ -195,11 +195,6 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
}); });
}; };
const isWordUsed = (word: string): boolean => {
if (local.allowRepetition) return false;
return Array.from(answers.values()).includes(word);
};
const handleEditWord = (index: number, newWord: string) => { const handleEditWord = (index: number, newWord: string) => {
if (!editing) setEditing(true); if (!editing) setEditing(true);
@@ -299,7 +294,6 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
letter={wordItem.letter} letter={wordItem.letter}
word={wordItem.word} word={wordItem.word}
isSelected={answers.get(selectedBlankId || '') === wordItem.word} isSelected={answers.get(selectedBlankId || '') === wordItem.word}
isUsed={isWordUsed(wordItem.word)}
onClick={() => handleWordSelect(wordItem.word)} onClick={() => handleWordSelect(wordItem.word)}
onRemove={isEditMode ? () => handleRemoveWord(index) : undefined} onRemove={isEditMode ? () => handleRemoveWord(index) : undefined}
onEdit={isEditMode ? (newWord) => handleEditWord(index, newWord) : undefined} onEdit={isEditMode ? (newWord) => handleEditWord(index, newWord) : undefined}

View File

@@ -6,7 +6,10 @@ import WordUploader from './WordUploader';
import GenLoader from '../Exercises/Shared/GenLoader'; import GenLoader from '../Exercises/Shared/GenLoader';
import useExamEditorStore from '@/stores/examEditor'; import useExamEditorStore from '@/stores/examEditor';
const ImportOrFromScratch: React.FC<{ module: Module; }> = ({ module }) => { const ImportOrFromScratch: React.FC<{
module: Module;
setNumberOfLevelParts: React.Dispatch<React.SetStateAction<number>>
}> = ({ module, setNumberOfLevelParts }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
const { importing } = useExamEditorStore((store) => store.modules[currentModule]) const { importing } = useExamEditorStore((store) => store.modules[currentModule])
@@ -45,7 +48,7 @@ const ImportOrFromScratch: React.FC<{ module: Module; }> = ({ module }) => {
</span> </span>
</button> </button>
<div className='h-full'> <div className='h-full'>
<WordUploader module={module} /> <WordUploader module={module} setNumberOfLevelParts={setNumberOfLevelParts} />
</div> </div>
</div> </div>
)} )}

View File

@@ -9,8 +9,9 @@ import useExamEditorStore from '@/stores/examEditor';
import { LevelPart, ListeningPart, ReadingPart } from '@/interfaces/exam'; import { LevelPart, ListeningPart, ReadingPart } from '@/interfaces/exam';
import { defaultSectionSettings } from '@/stores/examEditor/defaults'; import { defaultSectionSettings } from '@/stores/examEditor/defaults';
const WordUploader: React.FC<{ module: Module }> = ({ module }) => { const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: React.Dispatch<React.SetStateAction<number>> }> = ({ module, setNumberOfLevelParts }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
const {sectionLabels} = useExamEditorStore(state => state.modules[currentModule]);
const examInputRef = useRef<HTMLInputElement>(null); const examInputRef = useRef<HTMLInputElement>(null);
const solutionsInputRef = useRef<HTMLInputElement>(null); const solutionsInputRef = useRef<HTMLInputElement>(null);
@@ -74,6 +75,20 @@ const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
const newSectionsStates = data.parts.map( const newSectionsStates = data.parts.map(
(part: ReadingPart | ListeningPart | LevelPart, index: number) => defaultSectionSettings(module, index + 1, part) (part: ReadingPart | ListeningPart | LevelPart, index: number) => defaultSectionSettings(module, index + 1, part)
); );
if (module === "level") {
// default is 1
const newLabelCount = data.parts.length - 2;
setNumberOfLevelParts(newLabelCount);
const newLabels = Array.from({ length: newLabelCount }, (_, index) => ({
id: index + 2,
label: `Part ${index + 2}`
}));
dispatch({type: "UPDATE_MODULE", payload: { updates: { sectionLabels: [...sectionLabels, ...newLabels] }}})
}
dispatch({ dispatch({
type: "UPDATE_MODULE", payload: { type: "UPDATE_MODULE", payload: {
updates: { updates: {

View File

@@ -5,7 +5,7 @@ import Input from "../Low/Input";
import Select from "../Low/Select"; import Select from "../Low/Select";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import { Difficulty } from "@/interfaces/exam"; import { Difficulty } from "@/interfaces/exam";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { ModuleState, SectionState } from "@/stores/examEditor/types"; import { ModuleState, SectionState } from "@/stores/examEditor/types";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
@@ -33,7 +33,7 @@ const ExamEditor: React.FC = () => {
importModule importModule
} = useExamEditorStore(state => state.modules[currentModule]); } = useExamEditorStore(state => state.modules[currentModule]);
const [numberOfParts, setNumberOfParts] = useState(1); const [numberOfLevelParts, setNumberOfLevelParts] = useState(1);
useEffect(() => { useEffect(() => {
const currentSections = sections; const currentSections = sections;
@@ -41,23 +41,22 @@ const ExamEditor: React.FC = () => {
let updatedSections: SectionState[]; let updatedSections: SectionState[];
let updatedLabels: any; let updatedLabels: any;
if (numberOfParts > currentSections.length) { if (numberOfLevelParts > currentSections.length) {
const newSections = [...currentSections]; const newSections = [...currentSections];
const newLabels = [...currentLabels]; const newLabels = [...currentLabels];
for (let i = currentSections.length; i < numberOfParts; i++) { for (let i = currentSections.length; i < numberOfLevelParts; i++) {
newSections.push(defaultSectionSettings(currentModule, i + 1)); newSections.push(defaultSectionSettings(currentModule, i + 1));
newLabels.push({ newLabels.push({
id: i + 1, id: i + 1,
label: `Part ${i + 1}` label: `Part ${i + 1}`
}); });
} }
updatedSections = newSections; updatedSections = newSections;
updatedLabels = newLabels; updatedLabels = newLabels;
} else if (numberOfParts < currentSections.length) { } else if (numberOfLevelParts < currentSections.length) {
updatedSections = currentSections.slice(0, numberOfParts); updatedSections = currentSections.slice(0, numberOfLevelParts);
updatedLabels = currentLabels.slice(0, numberOfParts); updatedLabels = currentLabels.slice(0, numberOfLevelParts);
} else { } else {
return; return;
} }
@@ -77,7 +76,7 @@ const ExamEditor: React.FC = () => {
} }
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [numberOfParts]); }, [numberOfLevelParts]);
const sectionIds = sections.map((section) => section.sectionId) const sectionIds = sections.map((section) => section.sectionId)
@@ -103,11 +102,11 @@ const ExamEditor: React.FC = () => {
}; };
const Settings = ModuleSettings[currentModule]; const Settings = ModuleSettings[currentModule];
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule);
const showImport = importModule && (currentModule === "reading" || currentModule === "listening" || currentModule === "level");
return ( return (
<> <>
{showImport ? <ImportOrStartFromScratch module={currentModule} /> : ( {showImport ? <ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={setNumberOfLevelParts}/> : (
<> <>
<div className="flex gap-4 w-full items-center"> <div className="flex gap-4 w-full items-center">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -153,7 +152,7 @@ const ExamEditor: React.FC = () => {
) : ( ) : (
<div className="flex flex-col gap-3 w-1/3"> <div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label> <label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
<Input type="number" name="Number of Parts" min={1} onChange={(v) => setNumberOfParts(parseInt(v))} value={numberOfParts} /> <Input type="number" name="Number of Parts" min={1} onChange={(v) => setNumberOfLevelParts(parseInt(v))} value={numberOfLevelParts} />
</div> </div>
)} )}
<div className="flex flex-col gap-3 w-fit h-fit"> <div className="flex flex-col gap-3 w-fit h-fit">

View File

@@ -1,6 +1,6 @@
import {Dialog, DialogPanel, DialogTitle, Transition, TransitionChild} from "@headlessui/react"; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import {Fragment, ReactElement} from "react"; import { Fragment, ReactElement, useCallback, useEffect, useState } from "react";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@@ -12,10 +12,55 @@ interface Props {
children?: ReactElement; children?: ReactElement;
} }
export default function Modal({isOpen, maxWidth, title, className, titleClassName, onClose, children}: Props) { export default function Modal({ isOpen, maxWidth, title, className, titleClassName, onClose, children }: Props) {
const [isClosing, setIsClosing] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
if (isOpen) {
setMounted(true);
}
}, [isOpen]);
useEffect(() => {
if (!isOpen && mounted) {
const timer = setTimeout(() => {
setMounted(false);
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}
}, [isOpen, mounted]);
const blockMultipleClicksClose = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
onClose();
const timer = setTimeout(() => {
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}, [isClosing, onClose]);
if (!mounted && !isOpen) return null;
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition
<Dialog as="div" className="relative z-[200]" onClose={onClose}> appear
show={isOpen}
as={Fragment}
beforeEnter={() => setIsClosing(false)}
beforeLeave={() => setIsClosing(true)}
afterLeave={() => {
setIsClosing(false);
setMounted(false);
}}
>
<Dialog as="div" className="relative z-[200]" onClose={() => blockMultipleClicksClose()}>
<TransitionChild <TransitionChild
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"

View File

@@ -1,5 +1,5 @@
import {Dialog, Transition} from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import {Fragment} from "react"; import { Fragment, useCallback, useEffect, useState } from "react";
import Button from "./Low/Button"; import Button from "./Low/Button";
interface Props { interface Props {
@@ -7,10 +7,53 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
export default function TimerEndedModal({isOpen, onClose}: Props) { export default function TimerEndedModal({ isOpen, onClose }: Props) {
const [isClosing, setIsClosing] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
if (isOpen) {
setMounted(true);
}
}, [isOpen]);
useEffect(() => {
if (!isOpen && mounted) {
const timer = setTimeout(() => {
setMounted(false);
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}
}, [isOpen, mounted]);
const blockMultipleClicksClose = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
onClose();
const timer = setTimeout(() => {
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}, [isClosing, onClose]);
if (!mounted && !isOpen) return null;
return ( return (
<Transition show={isOpen} as={Fragment}> <Transition
<Dialog onClose={onClose} className="relative z-50"> show={isOpen}
as={Fragment}
beforeEnter={() => setIsClosing(false)}
beforeLeave={() => setIsClosing(true)}
afterLeave={() => {
setIsClosing(false);
setMounted(false);
}}
>
<Dialog onClose={()=> blockMultipleClicksClose()} className="relative z-50">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
@@ -37,7 +80,7 @@ export default function TimerEndedModal({isOpen, onClose}: Props) {
The timer has ended! Your answers have been registered and saved, you will now move on to the next module (or to the The timer has ended! Your answers have been registered and saved, you will now move on to the next module (or to the
finish screen, if this was the last one). finish screen, if this was the last one).
</span> </span>
<Button color="purple" onClick={onClose} className="max-w-[200px] self-end w-full mt-8"> <Button color="purple" onClick={()=> blockMultipleClicksClose()} className="max-w-[200px] self-end w-full mt-8">
Continue Continue
</Button> </Button>
</Dialog.Panel> </Dialog.Panel>