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 {Fragment} from "react";
import { Dialog, Transition } from "@headlessui/react";
import { Fragment, useCallback, useEffect, useState } from "react";
import Button from "./Low/Button";
interface Props {
@@ -11,10 +11,54 @@ interface Props {
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 (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={onCancel} className="relative z-50">
<Transition
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
as={Fragment}
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>
<span>{abandonPopupDescription}</span>
<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
</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}
</Button>
</div>

View File

@@ -4,7 +4,6 @@ interface Props {
letter: string;
word: string;
isSelected: boolean;
isUsed: boolean;
onClick: () => void;
onRemove?: () => void;
onEdit?: (newWord: string) => void;
@@ -15,7 +14,6 @@ const FillBlanksWord: React.FC<Props> = ({
letter,
word,
isSelected,
isUsed,
onClick,
onRemove,
onEdit,
@@ -36,10 +34,8 @@ const FillBlanksWord: React.FC<Props> = ({
) : (
<button
onClick={onClick}
disabled={isUsed}
className={`
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'}
`}
>

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) => {
if (!editing) setEditing(true);
@@ -299,7 +294,6 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
letter={wordItem.letter}
word={wordItem.word}
isSelected={answers.get(selectedBlankId || '') === wordItem.word}
isUsed={isWordUsed(wordItem.word)}
onClick={() => handleWordSelect(wordItem.word)}
onRemove={isEditMode ? () => handleRemoveWord(index) : 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 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 { importing } = useExamEditorStore((store) => store.modules[currentModule])
@@ -45,7 +48,7 @@ const ImportOrFromScratch: React.FC<{ module: Module; }> = ({ module }) => {
</span>
</button>
<div className='h-full'>
<WordUploader module={module} />
<WordUploader module={module} setNumberOfLevelParts={setNumberOfLevelParts} />
</div>
</div>
)}

View File

@@ -9,8 +9,9 @@ import useExamEditorStore from '@/stores/examEditor';
import { LevelPart, ListeningPart, ReadingPart } from '@/interfaces/exam';
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 {sectionLabels} = useExamEditorStore(state => state.modules[currentModule]);
const examInputRef = useRef<HTMLInputElement>(null);
const solutionsInputRef = useRef<HTMLInputElement>(null);
@@ -74,6 +75,20 @@ const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
const newSectionsStates = data.parts.map(
(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({
type: "UPDATE_MODULE", payload: {
updates: {

View File

@@ -5,7 +5,7 @@ import Input from "../Low/Input";
import Select from "../Low/Select";
import { capitalize } from "lodash";
import { Difficulty } from "@/interfaces/exam";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "react-toastify";
import { ModuleState, SectionState } from "@/stores/examEditor/types";
import { Module } from "@/interfaces";
@@ -33,7 +33,7 @@ const ExamEditor: React.FC = () => {
importModule
} = useExamEditorStore(state => state.modules[currentModule]);
const [numberOfParts, setNumberOfParts] = useState(1);
const [numberOfLevelParts, setNumberOfLevelParts] = useState(1);
useEffect(() => {
const currentSections = sections;
@@ -41,23 +41,22 @@ const ExamEditor: React.FC = () => {
let updatedSections: SectionState[];
let updatedLabels: any;
if (numberOfParts > currentSections.length) {
if (numberOfLevelParts > currentSections.length) {
const newSections = [...currentSections];
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));
newLabels.push({
id: i + 1,
label: `Part ${i + 1}`
});
}
updatedSections = newSections;
updatedLabels = newLabels;
} else if (numberOfParts < currentSections.length) {
updatedSections = currentSections.slice(0, numberOfParts);
updatedLabels = currentLabels.slice(0, numberOfParts);
} else if (numberOfLevelParts < currentSections.length) {
updatedSections = currentSections.slice(0, numberOfLevelParts);
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
} else {
return;
}
@@ -77,7 +76,7 @@ const ExamEditor: React.FC = () => {
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [numberOfParts]);
}, [numberOfLevelParts]);
const sectionIds = sections.map((section) => section.sectionId)
@@ -103,11 +102,11 @@ const ExamEditor: React.FC = () => {
};
const Settings = ModuleSettings[currentModule];
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule);
const showImport = importModule && (currentModule === "reading" || currentModule === "listening" || currentModule === "level");
return (
<>
{showImport ? <ImportOrStartFromScratch module={currentModule} /> : (
{showImport ? <ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={setNumberOfLevelParts}/> : (
<>
<div className="flex gap-4 w-full items-center">
<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">
<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 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 {Fragment, ReactElement} from "react";
import { Fragment, ReactElement, useCallback, useEffect, useState } from "react";
interface Props {
isOpen: boolean;
@@ -12,10 +12,55 @@ interface Props {
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 (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
<Transition
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
as={Fragment}
enter="ease-out duration-300"

View File

@@ -1,5 +1,5 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment} from "react";
import { Dialog, Transition } from "@headlessui/react";
import { Fragment, useCallback, useEffect, useState } from "react";
import Button from "./Low/Button";
interface Props {
@@ -7,10 +7,53 @@ interface Props {
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 (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={onClose} className="relative z-50">
<Transition
show={isOpen}
as={Fragment}
beforeEnter={() => setIsClosing(false)}
beforeLeave={() => setIsClosing(true)}
afterLeave={() => {
setIsClosing(false);
setMounted(false);
}}
>
<Dialog onClose={()=> blockMultipleClicksClose()} className="relative z-50">
<Transition.Child
as={Fragment}
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
finish screen, if this was the last one).
</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
</Button>
</Dialog.Panel>