344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
import clsx from "clsx";
|
|
import SectionRenderer from "./SectionRenderer";
|
|
import Input from "../Low/Input";
|
|
import Select from "../Low/Select";
|
|
import { capitalize } from "lodash";
|
|
import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { toast } from "react-toastify";
|
|
import { ModuleState, SectionState } from "@/stores/examEditor/types";
|
|
import { Module } from "@/interfaces";
|
|
import useExamEditorStore from "@/stores/examEditor";
|
|
import WritingSettings from "./SettingsEditor/writing";
|
|
import ReadingSettings from "./SettingsEditor/reading";
|
|
import LevelSettings from "./SettingsEditor/level";
|
|
import ListeningSettings from "./SettingsEditor/listening";
|
|
import SpeakingSettings from "./SettingsEditor/speaking";
|
|
import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch";
|
|
import { defaultSectionSettings } from "@/stores/examEditor/defaults";
|
|
import Button from "../Low/Button";
|
|
import ResetModule from "./Standalone/ResetModule";
|
|
import ListeningInstructions from "./Standalone/ListeningInstructions";
|
|
import { EntityWithRoles } from "@/interfaces/entity";
|
|
import Option from "../../interfaces/option";
|
|
|
|
const DIFFICULTIES: Option[] = [
|
|
{ value: "A1", label: "A1" },
|
|
{ value: "A2", label: "A2" },
|
|
{ value: "B1", label: "B1" },
|
|
{ value: "B2", label: "B2" },
|
|
{ value: "C1", label: "C1" },
|
|
{ value: "C2", label: "C2" },
|
|
];
|
|
|
|
const ModuleSettings: Record<Module, React.ComponentType> = {
|
|
reading: ReadingSettings,
|
|
writing: WritingSettings,
|
|
speaking: SpeakingSettings,
|
|
listening: ListeningSettings,
|
|
level: LevelSettings,
|
|
};
|
|
|
|
const ExamEditor: React.FC<{
|
|
levelParts?: number;
|
|
entitiesAllowEditPrivacy: EntityWithRoles[];
|
|
entitiesAllowConfExams: EntityWithRoles[];
|
|
entitiesAllowPublicExams: EntityWithRoles[];
|
|
}> = ({
|
|
levelParts = 0,
|
|
entitiesAllowEditPrivacy = [],
|
|
entitiesAllowConfExams = [],
|
|
entitiesAllowPublicExams = [],
|
|
}) => {
|
|
const { currentModule, dispatch } = useExamEditorStore();
|
|
const {
|
|
sections,
|
|
minTimer,
|
|
expandedSections,
|
|
examLabel,
|
|
access,
|
|
difficulty,
|
|
sectionLabels,
|
|
importModule,
|
|
} = useExamEditorStore((state) => state.modules[currentModule]);
|
|
|
|
const [numberOfLevelParts, setNumberOfLevelParts] = useState(
|
|
levelParts !== 0 ? levelParts : 1
|
|
);
|
|
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
|
|
|
// For exam edits
|
|
useEffect(() => {
|
|
if (levelParts !== 0) {
|
|
setNumberOfLevelParts(levelParts);
|
|
dispatch({
|
|
type: "UPDATE_MODULE",
|
|
payload: {
|
|
updates: {
|
|
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
|
|
id: i + 1,
|
|
label: `Part ${i + 1}`,
|
|
})),
|
|
},
|
|
module: "level",
|
|
},
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [levelParts]);
|
|
|
|
useEffect(() => {
|
|
const currentSections = sections;
|
|
const currentLabels = sectionLabels;
|
|
let updatedSections: SectionState[];
|
|
let updatedLabels: any;
|
|
if (
|
|
(currentModule === "level" &&
|
|
currentSections.length !== currentLabels.length) ||
|
|
numberOfLevelParts !== currentSections.length
|
|
) {
|
|
const newSections = [...currentSections];
|
|
const newLabels = [...currentLabels];
|
|
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
|
|
if (currentSections.length !== numberOfLevelParts)
|
|
newSections.push(defaultSectionSettings(currentModule, i + 1));
|
|
newLabels.push({
|
|
id: i + 1,
|
|
label: `Part ${i + 1}`,
|
|
});
|
|
}
|
|
updatedSections = newSections;
|
|
updatedLabels = newLabels;
|
|
} else if (numberOfLevelParts < currentSections.length) {
|
|
updatedSections = currentSections.slice(0, numberOfLevelParts);
|
|
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
const updatedExpandedSections = expandedSections.filter((sectionId) =>
|
|
updatedSections.some((section) => section.sectionId === sectionId)
|
|
);
|
|
|
|
dispatch({
|
|
type: "UPDATE_MODULE",
|
|
payload: {
|
|
updates: {
|
|
sections: updatedSections,
|
|
sectionLabels: updatedLabels,
|
|
expandedSections: updatedExpandedSections,
|
|
},
|
|
},
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [numberOfLevelParts]);
|
|
|
|
const sectionIds = useMemo(
|
|
() => sections.map((section) => section.sectionId),
|
|
[sections]
|
|
);
|
|
|
|
const updateModule = useCallback(
|
|
(updates: Partial<ModuleState>) => {
|
|
dispatch({ type: "UPDATE_MODULE", payload: { updates } });
|
|
},
|
|
[dispatch]
|
|
);
|
|
|
|
const toggleSection = useCallback(
|
|
(sectionId: number) => {
|
|
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
|
toast.error("Include at least one section!");
|
|
return;
|
|
}
|
|
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
|
|
},
|
|
[dispatch, expandedSections, sectionIds]
|
|
);
|
|
|
|
const Settings = useMemo(
|
|
() => ModuleSettings[currentModule],
|
|
[currentModule]
|
|
);
|
|
|
|
const showImport = useMemo(
|
|
() =>
|
|
importModule && ["reading", "listening", "level"].includes(currentModule),
|
|
[importModule, currentModule]
|
|
);
|
|
|
|
const accessTypeOptions = useMemo(() => {
|
|
let options: Option[] = [{ value: "private", label: "Private" }];
|
|
if (entitiesAllowConfExams.length > 0) {
|
|
options.push({ value: "confidential", label: "Confidential" });
|
|
}
|
|
if (entitiesAllowPublicExams.length > 0) {
|
|
options.push({ value: "public", label: "Public" });
|
|
}
|
|
return options;
|
|
}, [entitiesAllowConfExams.length, entitiesAllowPublicExams.length]);
|
|
|
|
const updateLevelParts = useCallback((parts: number) => {
|
|
setNumberOfLevelParts(parts);
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
{showImport ? (
|
|
<ImportOrStartFromScratch
|
|
module={currentModule}
|
|
setNumberOfLevelParts={updateLevelParts}
|
|
/>
|
|
) : (
|
|
<>
|
|
{isResetModuleOpen && (
|
|
<ResetModule
|
|
module={currentModule}
|
|
isOpen={isResetModuleOpen}
|
|
setIsOpen={setIsResetModuleOpen}
|
|
setNumberOfLevelParts={setNumberOfLevelParts}
|
|
/>
|
|
)}
|
|
<div
|
|
className={clsx(
|
|
"flex gap-4 w-full",
|
|
sectionLabels.length > 3 ? "-2xl:flex-col" : "-xl:flex-col"
|
|
)}
|
|
>
|
|
<div className="flex flex-row gap-3">
|
|
<div className="flex flex-col gap-3 ">
|
|
<label className="font-normal text-base text-mti-gray-dim">
|
|
Timer
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
name="minTimer"
|
|
onChange={(e) =>
|
|
updateModule({
|
|
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
|
|
})
|
|
}
|
|
value={minTimer}
|
|
className="max-w-[125px] min-w-[100px] w-min"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-3 ">
|
|
<label className="font-normal text-base text-mti-gray-dim">
|
|
Difficulty
|
|
</label>
|
|
<Select
|
|
isMulti={true}
|
|
options={DIFFICULTIES}
|
|
onChange={(values) => {
|
|
const selectedDifficulties = values
|
|
? values.map((v) => v.value as Difficulty)
|
|
: [];
|
|
updateModule({ difficulty: selectedDifficulties });
|
|
}}
|
|
value={
|
|
difficulty
|
|
? (Array.isArray(difficulty)
|
|
? difficulty
|
|
: [difficulty]
|
|
).map((d) => ({
|
|
value: d,
|
|
label: capitalize(d),
|
|
}))
|
|
: null
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{sectionLabels.length != 0 && currentModule !== "level" ? (
|
|
<div className="flex flex-col gap-3 -xl:w-full">
|
|
<label className="font-normal text-base text-mti-gray-dim">
|
|
{sectionLabels[0].label.split(" ")[0]}
|
|
</label>
|
|
<div className="flex flex-row gap-3">
|
|
{sectionLabels.map(({ id, label }) => (
|
|
<span
|
|
key={id}
|
|
className={clsx(
|
|
"px-6 py-4 w-40 2xl:w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
"transition duration-300 ease-in-out",
|
|
sectionIds.includes(id)
|
|
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
|
: "bg-white border-mti-gray-platinum"
|
|
)}
|
|
onClick={() => toggleSection(id)}
|
|
>
|
|
{label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<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) => setNumberOfLevelParts(parseInt(v))}
|
|
value={numberOfLevelParts}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="max-w-[200px] w-full">
|
|
<Select
|
|
label="Access Type"
|
|
disabled={
|
|
accessTypeOptions.length === 0 ||
|
|
entitiesAllowEditPrivacy.length === 0
|
|
}
|
|
options={accessTypeOptions}
|
|
onChange={(value) => {
|
|
if (value?.value) {
|
|
updateModule({ access: value.value! as AccessType });
|
|
}
|
|
}}
|
|
value={{ value: access, label: capitalize(access) }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-row gap-3 w-full">
|
|
<div className="flex flex-col gap-3 flex-grow">
|
|
<label className="font-normal text-base text-mti-gray-dim">
|
|
Exam Label *
|
|
</label>
|
|
<Input
|
|
type="text"
|
|
placeholder="Exam Label"
|
|
name="label"
|
|
onChange={(text) => updateModule({ examLabel: text })}
|
|
roundness="xl"
|
|
value={examLabel}
|
|
required
|
|
/>
|
|
</div>
|
|
{currentModule === "listening" && <ListeningInstructions />}
|
|
<Button
|
|
onClick={() => setIsResetModuleOpen(true)}
|
|
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
|
|
className={`text-white self-end`}
|
|
>
|
|
Reset Module
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-row gap-8 -xl:flex-col">
|
|
<Settings />
|
|
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
|
|
<SectionRenderer />
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ExamEditor;
|