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:
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 && (currentModule === "reading" || currentModule === "listening" || currentModule === "level");
|
||||
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule);
|
||||
|
||||
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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user