From 15c9c4d4bd85793ff2fd0b400271b9eafc9c4a45 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Mon, 4 Nov 2024 23:29:14 +0000 Subject: [PATCH] Exam generation rework, batch user tables, fastapi endpoint switch --- package-lock.json | 38 + package.json | 2 + public/microsoft-word-icon.png | Bin 0 -> 16597 bytes src/components/Dropdown.tsx | 58 +- .../ExamEditor/Exercises/Blanks/DragNDrop.tsx | 129 +++ .../Exercises/Blanks/FillBlanksReducer.tsx | 247 ++++++ .../Blanks/Letters/FillBlanksWord.tsx | 62 ++ .../Exercises/Blanks/Letters/index.tsx | 301 +++++++ .../Blanks/MultipleChoice/MCOption.tsx | 79 ++ .../Exercises/Blanks/MultipleChoice/index.tsx | 284 +++++++ .../WriteBlankFill/AlternativeSolutions.tsx | 47 ++ .../Exercises/Blanks/WriteBlankFill/index.tsx | 189 +++++ .../Blanks/WriteBlankFill/validation.ts | 59 ++ .../ExamEditor/Exercises/Blanks/index.tsx | 246 ++++++ .../Exercises/Blanks/validateBlanks.ts | 38 + .../MatchSentences/ParagraphViewer.tsx | 45 ++ .../Exercises/MatchSentences/index.tsx | 230 ++++++ .../Exercises/MatchSentences/validation.ts | 42 + .../Underline/UnderlineQuestion.tsx | 190 +++++ .../MultipleChoice/Underline/index.tsx | 151 ++++ .../MultipleChoice/Vanilla/index.tsx | 302 +++++++ .../Exercises/MultipleChoice/index.tsx | 17 + .../ExamEditor/Exercises/Shared/Alert.tsx | 61 ++ .../ExamEditor/Exercises/Shared/GenLoader.tsx | 14 + .../Exercises/Shared/QuestionsList.tsx | 34 + .../ExamEditor/Exercises/Shared/Script.tsx | 134 +++ .../Exercises/Shared/SortableQuestion.tsx | 155 ++++ .../Exercises/Shared/setEditingAlert.ts | 21 + .../ExamEditor/Exercises/Speaking/index.tsx | 177 ++++ .../ExamEditor/Exercises/TrueFalse/index.tsx | 235 ++++++ .../Exercises/TrueFalse/validation.ts | 46 ++ .../Exercises/WriteBlanks/index.tsx | 341 ++++++++ .../Exercises/WriteBlanks/parsing.ts | 27 + .../Exercises/WriteBlanks/validation.ts | 84 ++ .../WriteBlanksForm/BlanksFormEditor.tsx | 160 ++++ .../WriteBlanksForm/SortableBlank.tsx | 41 + .../Exercises/WriteBlanksForm/index.tsx | 318 ++++++++ .../Exercises/WriteBlanksForm/parsing.ts | 79 ++ .../Exercises/WriteBlanksForm/validation.ts | 117 +++ .../ExamEditor/Exercises/Writing/index.tsx | 119 +++ .../ExamEditor/Hooks/useSectionEdit.tsx | 67 ++ .../ExamEditor/Hooks/useSettingsState.tsx | 81 ++ .../SectionRenderer/SectionContext/index.tsx | 69 ++ .../SectionContext/listening.tsx | 79 ++ .../SectionContext/reading.tsx | 106 +++ .../SectionExercises/index.tsx | 137 ++++ .../SectionExercises/level.tsx | 61 ++ .../SectionExercises/listening.tsx | 106 +++ .../SectionExercises/reading.tsx | 98 +++ .../SectionRenderer/SectionExercises/types.ts | 10 + .../ExamEditor/SectionRenderer/index.tsx | 99 +++ .../ExamEditor/SectionRenderer/types.ts | 19 + .../SettingsEditor/Shared/Generate.ts | 61 ++ .../SettingsEditor/Shared/GenerateBtn.tsx | 41 + .../Shared/SettingsDropdown.tsx | 33 + .../ExamEditor/SettingsEditor/index.tsx | 140 ++++ .../ExamEditor/SettingsEditor/level.tsx | 88 ++ .../ExamEditor/SettingsEditor/listening.tsx | 135 ++++ .../ExamEditor/SettingsEditor/reading.tsx | 131 +++ .../ExamEditor/SettingsEditor/speaking.tsx | 157 ++++ .../ExamEditor/SettingsEditor/writing.tsx | 139 ++++ .../ExamEditor/Shared/AudioEdit.tsx | 44 + .../ExamEditor/Shared/ConfirmDeleteBtn.tsx | 118 +++ .../ExamEditor/Shared/ExerciseLabel.tsx | 15 + .../Shared/ExercisePicker/ExerciseWizard.tsx | 245 ++++++ .../Shared/ExercisePicker/exercises.ts | 356 ++++++++ .../ExercisePicker/generatedExercises.ts | 22 + .../Shared/ExercisePicker/index.tsx | 173 ++++ .../Shared/ExercisePicker/templates.ts | 0 src/components/ExamEditor/Shared/Header.tsx | 71 ++ .../Shared/ImportExam/ImportOrFromScratch.tsx | 56 ++ .../Shared/ImportExam/WordUploader.tsx | 272 +++++++ src/components/ExamEditor/Shared/Passage.tsx | 40 + .../ExamEditor/Shared/SectionDropdown.tsx | 84 ++ .../ExamEditor/Shared/SortableSection.tsx | 36 + src/components/ExamEditor/index.tsx | 189 +++++ src/components/Exercises/FillBlanks/index.tsx | 41 +- src/components/Exercises/Writing.tsx | 5 +- src/components/Exercises/index.tsx | 19 +- .../Generation/fill.blanks.edit.tsx | 130 --- .../Generation/interactive.speaking.edit.tsx | 7 - .../Generation/match.sentences.edit.tsx | 130 --- .../Generation/multiple.choice.edit.tsx | 137 ---- src/components/Generation/speaking.edit.tsx | 7 - src/components/Generation/true.false.edit.tsx | 71 -- .../Generation/write.blanks.edit.tsx | 94 --- src/components/Generation/writing.edit.tsx | 7 - src/components/Low/AutoExpandingTextInput.tsx | 62 ++ src/components/Low/AutoExpandingTextarea.tsx | 53 ++ src/components/Low/Select.tsx | 7 +- src/components/Medium/ModuleTitle.tsx | 191 ----- .../Medium/ModuleTitle/MCQuestionGrid.tsx | 107 +++ src/components/Medium/ModuleTitle/index.tsx | 95 +++ src/components/Modal.tsx | 24 +- src/components/Popouts/Exam.tsx | 41 + src/components/Solutions/FillBlanks.tsx | 5 +- src/components/Waveform.tsx | 221 +++-- src/components/ui/card.tsx | 76 ++ src/exams/Level/PartDivider.tsx | 38 - src/exams/Level/Shuffle.ts | 6 - src/exams/Level/index.tsx | 138 ++-- src/exams/Navigation/SectionDivider.tsx | 50 ++ src/exams/Navigation/SectionNavbar.tsx | 42 + src/exams/Reading.tsx | 29 +- src/exams/Writing.tsx | 99 ++- src/hooks/usePersistentStorage.ts | 27 + src/interfaces/exam.ts | 63 +- src/interfaces/option.ts | 8 + .../(admin)/BatchCreateUser/IUserImport.ts | 18 + .../(admin)/BatchCreateUser/UserTable.tsx | 200 +++++ .../index.tsx} | 123 +-- src/pages/(generation)/LevelGeneration.tsx | 760 ------------------ .../(generation)/ListeningGeneration.tsx | 467 ----------- src/pages/(generation)/ReadingGeneration.tsx | 462 ----------- src/pages/(generation)/SpeakingGeneration.tsx | 433 ---------- src/pages/(generation)/WritingGeneration.tsx | 293 ------- src/pages/api/batch_users.ts | 2 +- src/pages/api/evaluate/interactiveSpeaking.ts | 2 +- src/pages/api/evaluate/speaking.ts | 2 +- src/pages/api/evaluate/writing.ts | 2 +- .../exam/[module]/generate/[...endpoint].ts | 57 -- src/pages/api/exam/[module]/import.ts | 79 ++ src/pages/api/exam/generate/[...module].ts | 47 ++ .../{[module]/generate/level.ts => upload.ts} | 19 +- src/pages/api/stats/[id]/[export]/pdf.tsx | 2 +- src/pages/api/training/index.ts | 2 +- src/pages/api/users/controller.ts | 47 ++ src/pages/generation.tsx | 101 ++- src/pages/popout.tsx | 73 ++ src/pages/settings.tsx | 4 +- src/stores/examEditor/defaults.ts | 119 +++ src/stores/examEditor/index.ts | 24 + src/stores/examEditor/reducers/index.ts | 49 ++ .../examEditor/reducers/moduleReducer.ts | 107 +++ .../examEditor/reducers/sectionReducer.ts | 118 +++ src/stores/examEditor/reorder/global.ts | 188 +++++ src/stores/examEditor/reorder/local.ts | 130 +++ src/stores/examEditor/reorder/minIds.ts | 34 + src/stores/examEditor/reorder/types.ts | 4 + src/stores/examEditor/sections.ts | 62 ++ src/stores/examEditor/types.ts | 70 ++ src/stores/examStore.ts | 78 +- src/utils/popout.ts | 20 + src/utils/query.to.url.params.ts | 15 + src/utils/type.check.ts | 5 + tailwind.config.js | 10 +- tsconfig.json | 3 +- yarn.lock | 491 ++++++----- 148 files changed, 11348 insertions(+), 3901 deletions(-) create mode 100644 public/microsoft-word-icon.png create mode 100644 src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx create mode 100644 src/components/ExamEditor/Exercises/Blanks/FillBlanksReducer.tsx create mode 100644 src/components/ExamEditor/Exercises/Blanks/Letters/FillBlanksWord.tsx create mode 100644 src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx create mode 100644 src/components/ExamEditor/Exercises/Blanks/MultipleChoice/MCOption.tsx create mode 100644 src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx create mode 100644 src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/AlternativeSolutions.tsx create mode 100644 src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/index.tsx create mode 100644 src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/validation.ts create mode 100644 src/components/ExamEditor/Exercises/Blanks/index.tsx create mode 100644 src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts create mode 100644 src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx create mode 100644 src/components/ExamEditor/Exercises/MatchSentences/index.tsx create mode 100644 src/components/ExamEditor/Exercises/MatchSentences/validation.ts create mode 100644 src/components/ExamEditor/Exercises/MultipleChoice/Underline/UnderlineQuestion.tsx create mode 100644 src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx create mode 100644 src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx create mode 100644 src/components/ExamEditor/Exercises/MultipleChoice/index.tsx create mode 100644 src/components/ExamEditor/Exercises/Shared/Alert.tsx create mode 100644 src/components/ExamEditor/Exercises/Shared/GenLoader.tsx create mode 100644 src/components/ExamEditor/Exercises/Shared/QuestionsList.tsx create mode 100644 src/components/ExamEditor/Exercises/Shared/Script.tsx create mode 100644 src/components/ExamEditor/Exercises/Shared/SortableQuestion.tsx create mode 100644 src/components/ExamEditor/Exercises/Shared/setEditingAlert.ts create mode 100644 src/components/ExamEditor/Exercises/Speaking/index.tsx create mode 100644 src/components/ExamEditor/Exercises/TrueFalse/index.tsx create mode 100644 src/components/ExamEditor/Exercises/TrueFalse/validation.ts create mode 100644 src/components/ExamEditor/Exercises/WriteBlanks/index.tsx create mode 100644 src/components/ExamEditor/Exercises/WriteBlanks/parsing.ts create mode 100644 src/components/ExamEditor/Exercises/WriteBlanks/validation.ts create mode 100644 src/components/ExamEditor/Exercises/WriteBlanksForm/BlanksFormEditor.tsx create mode 100644 src/components/ExamEditor/Exercises/WriteBlanksForm/SortableBlank.tsx create mode 100644 src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx create mode 100644 src/components/ExamEditor/Exercises/WriteBlanksForm/parsing.ts create mode 100644 src/components/ExamEditor/Exercises/WriteBlanksForm/validation.ts create mode 100644 src/components/ExamEditor/Exercises/Writing/index.tsx create mode 100644 src/components/ExamEditor/Hooks/useSectionEdit.tsx create mode 100644 src/components/ExamEditor/Hooks/useSettingsState.tsx create mode 100644 src/components/ExamEditor/SectionRenderer/SectionContext/index.tsx create mode 100644 src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx create mode 100644 src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx create mode 100644 src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx create mode 100644 src/components/ExamEditor/SectionRenderer/SectionExercises/level.tsx create mode 100644 src/components/ExamEditor/SectionRenderer/SectionExercises/listening.tsx create mode 100644 src/components/ExamEditor/SectionRenderer/SectionExercises/reading.tsx create mode 100644 src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts create mode 100755 src/components/ExamEditor/SectionRenderer/index.tsx create mode 100644 src/components/ExamEditor/SectionRenderer/types.ts create mode 100644 src/components/ExamEditor/SettingsEditor/Shared/Generate.ts create mode 100644 src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx create mode 100644 src/components/ExamEditor/SettingsEditor/Shared/SettingsDropdown.tsx create mode 100644 src/components/ExamEditor/SettingsEditor/index.tsx create mode 100644 src/components/ExamEditor/SettingsEditor/level.tsx create mode 100644 src/components/ExamEditor/SettingsEditor/listening.tsx create mode 100644 src/components/ExamEditor/SettingsEditor/reading.tsx create mode 100644 src/components/ExamEditor/SettingsEditor/speaking.tsx create mode 100644 src/components/ExamEditor/SettingsEditor/writing.tsx create mode 100644 src/components/ExamEditor/Shared/AudioEdit.tsx create mode 100644 src/components/ExamEditor/Shared/ConfirmDeleteBtn.tsx create mode 100644 src/components/ExamEditor/Shared/ExerciseLabel.tsx create mode 100644 src/components/ExamEditor/Shared/ExercisePicker/ExerciseWizard.tsx create mode 100644 src/components/ExamEditor/Shared/ExercisePicker/exercises.ts create mode 100644 src/components/ExamEditor/Shared/ExercisePicker/generatedExercises.ts create mode 100644 src/components/ExamEditor/Shared/ExercisePicker/index.tsx create mode 100644 src/components/ExamEditor/Shared/ExercisePicker/templates.ts create mode 100644 src/components/ExamEditor/Shared/Header.tsx create mode 100644 src/components/ExamEditor/Shared/ImportExam/ImportOrFromScratch.tsx create mode 100644 src/components/ExamEditor/Shared/ImportExam/WordUploader.tsx create mode 100644 src/components/ExamEditor/Shared/Passage.tsx create mode 100644 src/components/ExamEditor/Shared/SectionDropdown.tsx create mode 100644 src/components/ExamEditor/Shared/SortableSection.tsx create mode 100644 src/components/ExamEditor/index.tsx delete mode 100644 src/components/Generation/fill.blanks.edit.tsx delete mode 100644 src/components/Generation/interactive.speaking.edit.tsx delete mode 100644 src/components/Generation/match.sentences.edit.tsx delete mode 100644 src/components/Generation/multiple.choice.edit.tsx delete mode 100644 src/components/Generation/speaking.edit.tsx delete mode 100644 src/components/Generation/true.false.edit.tsx delete mode 100644 src/components/Generation/write.blanks.edit.tsx delete mode 100644 src/components/Generation/writing.edit.tsx create mode 100644 src/components/Low/AutoExpandingTextInput.tsx create mode 100755 src/components/Low/AutoExpandingTextarea.tsx delete mode 100644 src/components/Medium/ModuleTitle.tsx create mode 100644 src/components/Medium/ModuleTitle/MCQuestionGrid.tsx create mode 100644 src/components/Medium/ModuleTitle/index.tsx create mode 100644 src/components/Popouts/Exam.tsx create mode 100644 src/components/ui/card.tsx delete mode 100644 src/exams/Level/PartDivider.tsx create mode 100644 src/exams/Navigation/SectionDivider.tsx create mode 100644 src/exams/Navigation/SectionNavbar.tsx create mode 100644 src/hooks/usePersistentStorage.ts create mode 100644 src/interfaces/option.ts create mode 100644 src/pages/(admin)/BatchCreateUser/IUserImport.ts create mode 100644 src/pages/(admin)/BatchCreateUser/UserTable.tsx rename src/pages/(admin)/{BatchCreateUser.tsx => BatchCreateUser/index.tsx} (70%) delete mode 100644 src/pages/(generation)/LevelGeneration.tsx delete mode 100644 src/pages/(generation)/ListeningGeneration.tsx delete mode 100644 src/pages/(generation)/ReadingGeneration.tsx delete mode 100644 src/pages/(generation)/SpeakingGeneration.tsx delete mode 100644 src/pages/(generation)/WritingGeneration.tsx delete mode 100644 src/pages/api/exam/[module]/generate/[...endpoint].ts create mode 100644 src/pages/api/exam/[module]/import.ts create mode 100644 src/pages/api/exam/generate/[...module].ts rename src/pages/api/exam/{[module]/generate/level.ts => upload.ts} (61%) create mode 100644 src/pages/api/users/controller.ts create mode 100644 src/pages/popout.tsx create mode 100644 src/stores/examEditor/defaults.ts create mode 100644 src/stores/examEditor/index.ts create mode 100644 src/stores/examEditor/reducers/index.ts create mode 100644 src/stores/examEditor/reducers/moduleReducer.ts create mode 100644 src/stores/examEditor/reducers/sectionReducer.ts create mode 100644 src/stores/examEditor/reorder/global.ts create mode 100644 src/stores/examEditor/reorder/local.ts create mode 100644 src/stores/examEditor/reorder/minIds.ts create mode 100644 src/stores/examEditor/reorder/types.ts create mode 100644 src/stores/examEditor/sections.ts create mode 100644 src/stores/examEditor/types.ts create mode 100644 src/utils/popout.ts create mode 100644 src/utils/query.to.url.params.ts create mode 100644 src/utils/type.check.ts diff --git a/package-lock.json b/package-lock.json index c9aee8ca..059e1355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@beam-australia/react-env": "^3.1.1", "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", "@firebase/util": "^1.9.7", "@headlessui/react": "^2.1.2", @@ -47,6 +48,7 @@ "formidable-serverless": "^1.1.1", "framer-motion": "^9.0.2", "howler": "^2.2.4", + "immer": "^10.1.1", "iron-session": "^6.3.1", "lodash": "^4.17.21", "moment": "^2.29.4", @@ -463,6 +465,19 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@dnd-kit/modifiers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz", + "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, "node_modules/@dnd-kit/sortable": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", @@ -7302,6 +7317,15 @@ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -12008,6 +12032,15 @@ "tslib": "^2.0.0" } }, + "@dnd-kit/modifiers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz", + "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==", + "requires": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + } + }, "@dnd-kit/sortable": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", @@ -17337,6 +17370,11 @@ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, + "immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==" + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", diff --git a/package.json b/package.json index 8903a0c9..6e705f2a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@beam-australia/react-env": "^3.1.1", "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", "@firebase/util": "^1.9.7", "@headlessui/react": "^2.1.2", @@ -49,6 +50,7 @@ "formidable-serverless": "^1.1.1", "framer-motion": "^9.0.2", "howler": "^2.2.4", + "immer": "^10.1.1", "iron-session": "^6.3.1", "lodash": "^4.17.21", "moment": "^2.29.4", diff --git a/public/microsoft-word-icon.png b/public/microsoft-word-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..879ae1e95da9b90b20913babc1edab87d4fd0051 GIT binary patch literal 16597 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 | ((isOpen: boolean) => void); className?: string; contentWrapperClassName?: string; + titleClassName?: string; bottomPadding?: number; + disabled?: boolean, + wrapperClassName?: string; + customTitle?: ReactNode; children: ReactNode; } const Dropdown: React.FC = ({ title, open = false, + titleClassName = "", + setIsOpen: externalSetIsOpen, className = "w-full text-left font-semibold flex justify-between items-center p-4", contentWrapperClassName = "px-6", bottomPadding = 12, + disabled = false, + customTitle = undefined, + wrapperClassName, children }) => { - const [isOpen, setIsOpen] = useState(open); + const [internalIsOpen, setInternalIsOpen] = useState(open); + const isOpen = externalSetIsOpen !== undefined ? open : internalIsOpen; + const toggleOpen = externalSetIsOpen !== undefined ? externalSetIsOpen : setInternalIsOpen; + const contentRef = useRef(null); const [contentHeight, setContentHeight] = useState(0); useEffect(() => { let resizeObserver: ResizeObserver | null = null; - + if (contentRef.current) { resizeObserver = new ResizeObserver(entries => { for (let entry of entries) { @@ -38,10 +51,10 @@ const Dropdown: React.FC = ({ } } }); - + resizeObserver.observe(contentRef.current); } - + return () => { if (resizeObserver) { resizeObserver.disconnect(); @@ -56,28 +69,35 @@ const Dropdown: React.FC = ({ }); return ( - <> +

-
+
{children}
- +
); }; diff --git a/src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx b/src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx new file mode 100644 index 00000000..19f4e36c --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx @@ -0,0 +1,129 @@ +import { useDraggable, useDroppable } from "@dnd-kit/core"; +import clsx from "clsx"; +import { MdClose, MdDelete, MdDragIndicator } from "react-icons/md"; +import { CSS } from "@dnd-kit/utilities"; +import { useEffect, useState } from "react"; +import ConfirmDeleteBtn from "../../Shared/ConfirmDeleteBtn"; + +interface BlankProps { + id: number; + module: string; + variant: "text" | "bank"; + isSelected?: boolean; + isDragging?: boolean; + onSelect?: (id: number) => void; + onRemove?: (id: number) => void; + disabled?: boolean; +} + +export const Blank: React.FC = ({ + id, + module, + variant, + isSelected, + isDragging, + onSelect, + onRemove, + disabled, +}) => { + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: `${variant}-blank-${id}`, + disabled: disabled || variant !== "text", + }); + + const style = transform ? { + transform: CSS.Translate.toString(transform), + transition: 'none', + zIndex: 999, + position: 'relative' as const, + touchAction: 'none', + } : { + transition: 'transform 0.2s cubic-bezier(0.25, 1, 0.5, 1)', + touchAction: 'none', + position: 'relative' as const, + }; + + const handleClick = (e: React.MouseEvent) => { + if (variant === "bank" && !disabled && onSelect) { + onSelect(id); + } + }; + + const dragProps = variant === "text" ? { + ...attributes, + ...listeners, + } : {}; + + return ( +
+ {variant === "text" && ( + + {isSelected ? + : + + } + + )} + + {id} + + + {onRemove && !isDragging && ( + onRemove(id)} + size="md" + position="top-right" + className="-translate-y-2 translate-x-1.5" + /> + )} +
+ ); +}; + +export const DropZone: React.FC<{ index: number, module: string; }> = ({ index, module }) => { + const { setNodeRef, isOver } = useDroppable({ + id: `drop-${index}`, + }); + + return ( + + ); +}; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Blanks/FillBlanksReducer.tsx b/src/components/ExamEditor/Exercises/Blanks/FillBlanksReducer.tsx new file mode 100644 index 00000000..5f3862a8 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/FillBlanksReducer.tsx @@ -0,0 +1,247 @@ +import { toast } from "react-toastify"; + +type TextToken = { + type: 'text'; + content: string; + isWhitespace: boolean; + isLineBreak?: boolean; +}; + +type BlankToken = { + type: 'blank'; + id: number; +}; + +type Token = TextToken | BlankToken; + +export type BlankState = { + id: number; + position: number; +}; + + +export const getTextSegments = (text: string): Token[] => { + const tokens: Token[] = []; + let lastIndex = 0; + const regex = /{{(\d+)}}/g; + let match; + + const addTextTokens = (text: string) => { + // Split by newlines first + const lines = text.replaceAll("\\n",'\n').split(/(\n)/); + + lines.forEach((line, i) => { + if (line === '\n') { + tokens.push({ + type: 'text', + content: '
', + isWhitespace: false, + isLineBreak: true + }); + return; + } + + const normalizedText = line.replace(/\s+/g, ' '); + if (normalizedText) { + const parts = normalizedText.split(/(\s)/); + parts.forEach(part => { + if (part) { + tokens.push({ + type: 'text', + content: part, + isWhitespace: /^\s+$/.test(part) + }); + } + }); + } + }); + }; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + addTextTokens(text.slice(lastIndex, match.index)); + } + tokens.push({ + type: 'blank', + id: parseInt(match[1]) + }); + lastIndex = regex.lastIndex; + } + + if (lastIndex < text.length) { + addTextTokens(text.slice(lastIndex)); + } + + return tokens; +} + +export const reconstructTextFromTokens = (tokens: Token[]): string => { + return tokens.map(token => { + if (token.type === 'blank') { + return `{{${token.id}}}`; + } + if (token.type === 'text' && token.isLineBreak) { + return '\n'; + } + return token.content; + }).join(''); +} + + +export type BlanksState = { + text: string; + blanks: BlankState[]; + selectedBlankId: number | null; + draggedItemId: string | null; + textMode: boolean; + setEditing: React.Dispatch>; +}; + +export type BlanksAction = + | { type: "SET_TEXT"; payload: string } + | { type: "SET_BLANKS"; payload: BlankState[] } + | { type: "ADD_BLANK" } + | { type: "REMOVE_BLANK"; payload: number } + | { type: "SELECT_BLANK"; payload: number | null } + | { type: "SET_DRAGGED_ITEM"; payload: string | null } + | { type: "MOVE_BLANK"; payload: { blankId: number; newPosition: number } } + | { type: "TOGGLE_EDIT_MODE" } + | { type: "RESET", payload: { text: string } }; + + +export const blanksReducer = (state: BlanksState, action: BlanksAction): BlanksState => { + switch (action.type) { + case "SET_TEXT": { + return { + ...state, + text: action.payload, + }; + } + case "SET_BLANKS": { + return { + ...state, + blanks: action.payload, + }; + } + case "ADD_BLANK": + state.setEditing(true); + const newBlankId = Math.max(...state.blanks.map(b => b.id), 0) + 1; + const newBlanks = [ + ...state.blanks, + { id: newBlankId, position: state.blanks.length } + ]; + const newText = state.text + ` {{${newBlankId}}}`; + + return { + ...state, + blanks: newBlanks, + text: newText + }; + + case "REMOVE_BLANK": { + if (state.blanks.length === 1) { + toast.error("There needs to be at least 1 blank!"); + break; + } + state.setEditing(true); + const blanksToKeep = state.blanks.filter(b => b.id !== action.payload); + const updatedBlanks = blanksToKeep.map((blank, index) => ({ + ...blank, + position: index + })); + + const tokens = getTextSegments(state.text).filter( + token => !(token.type === 'blank' && token.id === action.payload) + ); + + const newText = reconstructTextFromTokens(tokens); + + return { + ...state, + blanks: updatedBlanks, + text: newText, + selectedBlankId: state.selectedBlankId === action.payload ? null : state.selectedBlankId + }; + } + + case "MOVE_BLANK": { + state.setEditing(true); + const { blankId, newPosition } = action.payload; + const tokens = getTextSegments(state.text); + + // Find the current position of the blank + const currentPosition = tokens.findIndex( + token => token.type === 'blank' && token.id === blankId + ); + + if (currentPosition === -1) return state; + + // Remove the blank and its surrounding whitespace + const blankToken = tokens[currentPosition]; + tokens.splice(currentPosition, 1); + + // When inserting at new position, ensure there's whitespace around the blank + let insertPosition = newPosition; + const prevToken = tokens[insertPosition - 1]; + const nextToken = tokens[insertPosition]; + + // Insert space before if needed + if (!prevToken || (prevToken.type === 'text' && !prevToken.isWhitespace)) { + tokens.splice(insertPosition, 0, { + type: 'text', + content: ' ', + isWhitespace: true + }); + insertPosition++; + } + + // Insert the blank + tokens.splice(insertPosition, 0, blankToken); + insertPosition++; + + // Insert space after if needed + if (!nextToken || (nextToken.type === 'text' && !nextToken.isWhitespace)) { + tokens.splice(insertPosition, 0, { + type: 'text', + content: ' ', + isWhitespace: true + }); + } + + // Reconstruct the text + const newText = reconstructTextFromTokens(tokens); + + // Update blank positions + const updatedBlanks = tokens.reduce((acc, token, idx) => { + if (token.type === 'blank') { + acc.push({ id: token.id, position: idx }); + } + return acc; + }, [] as BlankState[]); + + return { + ...state, + text: newText, + blanks: updatedBlanks + }; + } + case "SELECT_BLANK": + return { ...state, selectedBlankId: action.payload }; + case "SET_DRAGGED_ITEM": + state.setEditing(true); + return { ...state, draggedItemId: action.payload }; + case "TOGGLE_EDIT_MODE": + return { ...state, textMode: !state.textMode }; + + case "RESET": + return { + text: action.payload.text || "", + blanks: [], + selectedBlankId: null, + draggedItemId: null, + textMode: false, + setEditing: state.setEditing + }; + } + return state; +}; diff --git a/src/components/ExamEditor/Exercises/Blanks/Letters/FillBlanksWord.tsx b/src/components/ExamEditor/Exercises/Blanks/Letters/FillBlanksWord.tsx new file mode 100644 index 00000000..0919cf50 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/Letters/FillBlanksWord.tsx @@ -0,0 +1,62 @@ +import { MdDelete } from "react-icons/md"; + +interface Props { + letter: string; + word: string; + isSelected: boolean; + isUsed: boolean; + onClick: () => void; + onRemove?: () => void; + onEdit?: (newWord: string) => void; + isEditMode?: boolean; +} + +const FillBlanksWord: React.FC = ({ + letter, + word, + isSelected, + isUsed, + onClick, + onRemove, + onEdit, + isEditMode + }) => { + return ( +
+ {isEditMode ? ( +
+ {letter} + onEdit?.(e.target.value)} + className="w-full min-w-0 focus:outline-none" + /> +
+ ) : ( + + )} + {isEditMode && onRemove && ( + + )} +
+ ); + }; +export default FillBlanksWord; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx b/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx new file mode 100644 index 00000000..2086380b --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx @@ -0,0 +1,301 @@ +import { FillBlanksExercise, ReadingPart } from "@/interfaces/exam"; +import { useEffect, useReducer, useState } from "react"; +import BlanksEditor from ".."; +import { Card, CardContent } from "@/components/ui/card"; +import { MdEdit, MdEditOff } from "react-icons/md"; +import FillBlanksWord from "./FillBlanksWord"; +import { FaPlus } from "react-icons/fa"; +import useExamEditorStore from "@/stores/examEditor"; +import { blanksReducer, BlankState, getTextSegments } from "../FillBlanksReducer"; +import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit"; +import { AlertItem } from "../../Shared/Alert"; +import validateBlanks from "../validateBlanks"; +import { toast } from "react-toastify"; +import setEditingAlert from "../../Shared/setEditingAlert"; + +interface Word { + letter: string; + word: string; +} + +const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const { state } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const section = state as ReadingPart; + + const [alerts, setAlerts] = useState([]); + + const [local, setLocal] = useState(exercise); + const [selectedBlankId, setSelectedBlankId] = useState(null); + const [answers, setAnswers] = useState>( + new Map(exercise.solutions.map(({ id, solution }) => [id, solution])) + ); + const [isEditMode, setIsEditMode] = useState(false); + const [newWord, setNewWord] = useState(''); + + const [editing, setEditing] = useState(false); + + const [blanksState, blanksDispatcher] = useReducer(blanksReducer, { + text: exercise.text || "", + blanks: [], + selectedBlankId: null, + draggedItemId: null, + textMode: false, + setEditing, + }); + + const { handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + editing, + setEditing, + onSave: () => { + if (!validateBlanks(blanksState.blanks, answers, alerts, setAlerts)) { + toast.error("Please fix the errors before saving!"); + return; + } + + setEditing(false); + setAlerts([]); + + const updatedExercise = { + ...local, + text: blanksState.text, + solutions: Array.from(answers.entries()).map(([id, solution]) => ({ + id, + solution + })) + }; + + const newState = { ...section }; + newState.exercises = newState.exercises.map((ex) => + ex.id === exercise.id ? updatedExercise : ex + ); + + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + onDiscard: () => { + setSelectedBlankId(null); + setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))); + setIsEditMode(false); + setNewWord(''); + setLocal(exercise); + + blanksDispatcher({ type: "RESET", payload: { text: exercise.text } }); + blanksDispatcher({ type: "SET_TEXT", payload: exercise.text || "" }); + + const tokens = getTextSegments(exercise.text || ""); + const initialBlanks = tokens.reduce((acc, token, idx) => { + if (token.type === 'blank') { + acc.push({ id: token.id, position: idx }); + } + return acc; + }, [] as BlankState[]); + blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks }); + + }, + onMode: () => { + const newSection = { + ...section, + exercises: section.exercises.filter((ex) => ex.id !== local.id) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + } + }); + + useEffect(() => { + if (!editing) { + setLocal(exercise); + setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))); + } + }, [exercise, editing]); + + const handleWordSelect = (word: string) => { + if (!selectedBlankId) return; + + if (!editing) setEditing(true); + + const newAnswers = new Map(answers); + newAnswers.set(selectedBlankId, word); + + setAnswers(newAnswers); + + setLocal(prev => ({ + ...prev, + solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + id, + solution + })) + })); + }; + + const handleAddWord = () => { + const word = newWord.trim(); + if (!word) return; + + setLocal(prev => { + const nextLetter = String.fromCharCode(65 + prev.words.length); + return { + ...prev, + words: [...prev.words, { letter: nextLetter, word }] + }; + }); + setNewWord(''); + }; + + const handleRemoveWord = (index: number) => { + if (!editing) setEditing(true); + + if (answers.size === 1) { + toast.error("There needs to be at least 1 word!"); + return; + } + + setLocal(prev => { + const newWords = prev.words.filter((_, i) => i !== index) as Word[]; + const removedWord = prev.words[index] as Word; + + const newAnswers = new Map(answers); + for (const [blankId, answer] of newAnswers.entries()) { + if (answer === removedWord.word) { + newAnswers.delete(blankId); + } + } + setAnswers(newAnswers); + + return { + ...prev, + words: newWords, + solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + id, + solution + })) + }; + }); + }; + + 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); + + + setLocal(prev => { + const newWords = [...prev.words] as Word[]; + const oldWord = newWords[index].word; + newWords[index] = { ...newWords[index], word: newWord }; + + const newAnswers = new Map(answers); + for (const [blankId, answer] of newAnswers.entries()) { + if (answer === oldWord) { + newAnswers.set(blankId, newWord); + } + } + setAnswers(newAnswers); + + return { + ...prev, + words: newWords, + solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + id, + solution + })) + }; + }); + }; + + useEffect(() => { + validateBlanks(blanksState.blanks, answers, alerts, setAlerts); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [answers, blanksState.blanks, blanksState.textMode]) + + useEffect(()=> { + setEditingAlert(editing, setAlerts); + }, [editing]) + + return ( +
+ setSelectedBlankId(blankId?.toString() || null)} + onSave={handleSave} + onDiscard={handleDiscard} + onDelete={modeHandle} + setEditing={setEditing} + > + <> + {!blanksState.textMode && + +
+
Word Bank
+ +
+ +
+ {(local.words as Word[]).map((wordItem, index) => ( + handleWordSelect(wordItem.word)} + onRemove={isEditMode ? () => handleRemoveWord(index) : undefined} + onEdit={isEditMode ? (newWord) => handleEditWord(index, newWord) : undefined} + isEditMode={isEditMode} + /> + ))} +
+ + {isEditMode && ( +
+ setNewWord(e.target.value)} + placeholder="Enter new word" + className="flex-1 px-3 py-2 border border-r-0 rounded-l-md focus:outline-none" + name="" + /> + +
+ + )} +
+
+ } + +
+
+ ); +}; + +export default FillBlanksLetters; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/MCOption.tsx b/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/MCOption.tsx new file mode 100644 index 00000000..6b5595c0 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/MCOption.tsx @@ -0,0 +1,79 @@ + +import { MdDelete, } from "react-icons/md"; +import clsx from "clsx"; + +interface MCOptionProps { + id: string; + options: { + A: string; + B: string; + C: string; + D: string; + }; + selectedOption?: string; + onSelect: (option: string) => void; + isEditMode?: boolean; + onEdit?: (key: 'A' | 'B' | 'C' | 'D', value: string) => void; + onRemove?: () => void; +} + +const MCOption: React.FC = ({ + id, + options, + selectedOption, + onSelect, + isEditMode, + onEdit, + onRemove +}) => { + const optionKeys = ['A', 'B', 'C', 'D'] as const; + + return ( +
+
+ Question {id} + {isEditMode && onRemove && ( + + )} +
+
+ {optionKeys.map((key) => ( +
+ {isEditMode ? ( +
+ {key} + onEdit?.(key, e.target.value)} + className="w-full focus:outline-none" + /> +
+ ) : ( + + )} +
+ ))} +
+
+ ); +}; + +export default MCOption; diff --git a/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx b/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx new file mode 100644 index 00000000..f0908fea --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx @@ -0,0 +1,284 @@ +import { FillBlanksExercise, FillBlanksMCOption, ReadingPart } from "@/interfaces/exam"; +import { useEffect, useReducer, useState } from "react"; +import BlanksEditor from ".."; +import { Card, CardContent } from "@/components/ui/card"; +import useExamEditorStore from "@/stores/examEditor"; +import { blanksReducer, BlankState, getTextSegments } from "../FillBlanksReducer"; +import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit"; +import { AlertItem } from "../../Shared/Alert"; +import validateBlanks from "../validateBlanks"; +import { toast } from "react-toastify"; +import setEditingAlert from "../../Shared/setEditingAlert"; +import { MdEdit, MdEditOff } from "react-icons/md"; +import MCOption from "./MCOption"; + + +const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const { state } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const section = state as ReadingPart; + const [alerts, setAlerts] = useState([]); + const [local, setLocal] = useState(exercise); + const [selectedBlankId, setSelectedBlankId] = useState(null); + + const [answers, setAnswers] = useState>(() => { + return new Map( + exercise.solutions.map(({ id, solution }) => [ + id.toString(), + solution + ]) + ); + }); + + const [isEditMode, setIsEditMode] = useState(false); + const [editing, setEditing] = useState(false); + + const [blanksState, blanksDispatcher] = useReducer(blanksReducer, { + text: exercise.text || "", + blanks: [], + selectedBlankId: null, + draggedItemId: null, + textMode: false, + setEditing, + }); + + const { handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + editing, + setEditing, + onSave: () => { + if (!validateBlanks(blanksState.blanks, answers, alerts, setAlerts)) { + toast.error("Please fix the errors before saving!"); + return; + } + + setEditing(false); + setAlerts([]); + + const updatedExercise = { + ...local, + text: blanksState.text, + solutions: Array.from(answers.entries()).map(([id, solution]) => ({ + id, + solution + })) + }; + + const newState = { ...section }; + newState.exercises = newState.exercises.map((ex) => + ex.id === exercise.id ? updatedExercise : ex + ); + + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + onDiscard: () => { + setSelectedBlankId(null); + setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))); + setIsEditMode(false); + setLocal(exercise); + + blanksDispatcher({ type: "RESET", payload: { text: exercise.text } }); + blanksDispatcher({ type: "SET_TEXT", payload: exercise.text || "" }); + + const tokens = getTextSegments(exercise.text || ""); + const initialBlanks = tokens.reduce((acc, token, idx) => { + if (token.type === 'blank') { + acc.push({ id: token.id, position: idx }); + } + return acc; + }, [] as BlankState[]); + blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks }); + }, + onMode: () => { + const newSection = { + ...section, + exercises: section.exercises.filter((ex) => ex.id !== local.id) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + } + }); + + useEffect(() => { + if (!editing) { + setLocal(exercise); + setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))); + } + }, [exercise, editing]); + + const handleOptionSelect = (option: string) => { + if (!selectedBlankId) return; + if (!editing) setEditing(true); + + const newAnswers = new Map(answers); + newAnswers.set(selectedBlankId, option); + + setAnswers(newAnswers); + + setLocal(prev => ({ + ...prev, + solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + id, + solution + })) + })); + }; + + const handleEditOption = (mcOptionIndex: number, key: keyof FillBlanksMCOption['options'], value: string) => { + if (!editing) setEditing(true); + + setLocal(prev => { + const newWords = [...prev.words] as FillBlanksMCOption[]; + const mcOption = newWords[mcOptionIndex] as FillBlanksMCOption; + + const newOptions = { ...mcOption.options, [key]: value }; + newWords[mcOptionIndex] = { ...mcOption, options: newOptions }; + + const oldValue = (mcOption.options as any)[key]; + const newAnswers = new Map(answers); + for (const [blankId, answer] of newAnswers.entries()) { + if (answer === oldValue) { + newAnswers.set(blankId, value); + } + } + setAnswers(newAnswers); + + return { + ...prev, + words: newWords, + solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + id, + solution + })) + }; + }); + }; + + const handleRemoveOption = (index: number) => { + if (!editing) setEditing(true); + + if (answers.size === 1) { + toast.error("There needs to be at least 1 question!"); + return; + } + + setLocal(prev => { + const newWords = prev.words.filter((_, i) => i !== index) as FillBlanksMCOption[]; + const removedOption = prev.words[index] as FillBlanksMCOption; + const removedValues = Object.values(removedOption.options); + const newAnswers = new Map(answers); + for (const [blankId, answer] of newAnswers.entries()) { + if (removedValues.includes(answer)) { + newAnswers.delete(blankId); + } + } + setAnswers(newAnswers); + + return { + ...prev, + words: newWords, + solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + id, + solution + })) + }; + }); + }; + + useEffect(() => { + validateBlanks(blanksState.blanks, answers, alerts, setAlerts); + }, [answers, blanksState.blanks, blanksState.textMode]); + + useEffect(() => { + setEditingAlert(editing, setAlerts); + }, [editing]); + + useEffect(() => { + if (!editing) { + setLocal(exercise); + setAnswers(new Map( + exercise.solutions.map(({ id, solution }) => [ + id.toString(), + solution + ]) + )); + } + }, [exercise, editing]); + + useEffect(() => { + setAnswers(new Map( + exercise.solutions.map(({ id, solution }) => [ + id.toString(), + solution + ]) + )); + }, []); + + return ( +
+ setSelectedBlankId(blankId?.toString() || null)} + onSave={handleSave} + onDiscard={handleDiscard} + onDelete={modeHandle} + setEditing={setEditing} + > + {!blanksState.textMode && selectedBlankId && ( + + +
+
Multiple Choice Options
+ +
+ + {(local.words as FillBlanksMCOption[]).map((mcOption) => { + if (mcOption.id.toString() !== selectedBlankId) return null; + + return ( + handleOptionSelect(option)} + isEditMode={isEditMode} + onEdit={(key, value) => handleEditOption( + (local.words as FillBlanksMCOption[]).findIndex(w => w.id === mcOption.id), + key as "A" | "B" | "C" | "D", + value + )} + onRemove={() => handleRemoveOption( + (local.words as FillBlanksMCOption[]).findIndex(w => w.id === mcOption.id) + )} + /> + ); + })} +
+
+ )} +
+
+ ); +}; + +export default FillBlanksMC; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/AlternativeSolutions.tsx b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/AlternativeSolutions.tsx new file mode 100644 index 00000000..f387a3c3 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/AlternativeSolutions.tsx @@ -0,0 +1,47 @@ +import { MdDelete, MdAdd } from "react-icons/md"; + +interface AlternativeSolutionProps { + solutions: string[]; + onAdd: () => void; + onRemove: (index: number) => void; + onEdit: (index: number, value: string) => void; +} + +const AlternativeSolutions: React.FC = ({ + solutions, + onAdd, + onRemove, + onEdit, +}) => { + return ( +
+ {solutions.map((solution, index) => ( +
+ onEdit(index, e.target.value)} + className="flex-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none" + placeholder={`Solution ${index + 1}`} + /> + +
+ ))} + +
+ ); +}; + +export default AlternativeSolutions; diff --git a/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/index.tsx b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/index.tsx new file mode 100644 index 00000000..58507d72 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/index.tsx @@ -0,0 +1,189 @@ +import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit"; +import { Card, CardContent } from "@/components/ui/card"; +import { WriteBlanksExercise, ReadingPart } from "@/interfaces/exam"; +import useExamEditorStore from "@/stores/examEditor"; +import { useState, useReducer, useEffect } from "react"; +import { toast } from "react-toastify"; +import BlanksEditor from ".."; +import { AlertItem } from "../../Shared/Alert"; +import setEditingAlert from "../../Shared/setEditingAlert"; +import { blanksReducer } from "../FillBlanksReducer"; +import { validateWriteBlanks } from "./validation"; +import AlternativeSolutions from "./AlternativeSolutions"; +import clsx from "clsx"; + +const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const { state } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const section = state as ReadingPart; + + const [alerts, setAlerts] = useState([]); + const [local, setLocal] = useState(exercise); + const [selectedBlankId, setSelectedBlankId] = useState(null); + const [editing, setEditing] = useState(false); + + const [blanksState, blanksDispatcher] = useReducer(blanksReducer, { + text: exercise.text || "", + blanks: [], + selectedBlankId: null, + draggedItemId: null, + textMode: false, + setEditing, + }); + + const { handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + editing, + setEditing, + onSave: () => { + if (!validateWriteBlanks(local.solutions, local.maxWords, setAlerts)) { + toast.error("Please fix the errors before saving!"); + return; + } + + setEditing(false); + setAlerts([]); + + const updatedExercise = { + ...local, + text: blanksState.text, + }; + + const newState = { ...section }; + newState.exercises = newState.exercises.map((ex) => + ex.id === exercise.id ? updatedExercise : ex + ); + + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }); + }, + onDiscard: () => { + setSelectedBlankId(null); + setLocal(exercise); + blanksDispatcher({ type: "RESET", payload: { text: exercise.text } }); + }, + onMode: () => { + const newSection = { + ...section, + exercises: section.exercises.filter((ex) => ex.id !== local.id) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + } + }); + + useEffect(() => { + if (!editing) { + setLocal(exercise); + } + }, [exercise, editing]); + + const handleAddSolution = (blankId: string) => { + if (!editing) setEditing(true); + setLocal(prev => ({ + ...prev, + solutions: prev.solutions.map(s => + s.id === blankId + ? { ...s, solution: [...s.solution, ""] } + : s + ) + })); + }; + + const handleRemoveSolution = (blankId: string, index: number) => { + if (!editing) setEditing(true); + + const solutions = local.solutions.find(s => s.id === blankId); + if (solutions && solutions.solution.length <= 1) { + toast.error("Each blank must have at least one solution!"); + return; + } + + setLocal(prev => ({ + ...prev, + solutions: prev.solutions.map(s => + s.id === blankId + ? { ...s, solution: s.solution.filter((_, i) => i !== index) } + : s + ) + })); + }; + + const handleEditSolution = (blankId: string, index: number, value: string) => { + if (!editing) setEditing(true); + + setLocal(prev => ({ + ...prev, + solutions: prev.solutions.map(s => + s.id === blankId + ? { + ...s, + solution: s.solution.map((sol, i) => i === index ? value : sol) + } + : s + ) + })); + }; + + useEffect(() => { + validateWriteBlanks(local.solutions, local.maxWords, setAlerts); + }, [local.solutions, local.maxWords]); + + useEffect(() => { + setEditingAlert(editing, setAlerts); + }, [editing]); + + return ( +
+ setSelectedBlankId(blankId?.toString() || null)} + onSave={handleSave} + onDiscard={handleDiscard} + onDelete={modeHandle} + setEditing={setEditing} + > + {!blanksState.textMode && ( + + +
+ + {selectedBlankId + ? `Solutions for Blank ${selectedBlankId}` + : "Click a blank to edit its solutions"} + + {selectedBlankId && ( + + Max words per solution: {local.maxWords} + + )} +
+ +
+ {selectedBlankId && ( + s.id === selectedBlankId)?.solution || []} + onAdd={() => handleAddSolution(selectedBlankId)} + onRemove={(index: number) => handleRemoveSolution(selectedBlankId, index)} + onEdit={(index: number, value: string) => handleEditSolution(selectedBlankId, index, value)} + /> + )} +
+
+
+ )} +
+
+ ); +}; + +export default WriteBlanksFill; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/validation.ts b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/validation.ts new file mode 100644 index 00000000..b23f0b4c --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/validation.ts @@ -0,0 +1,59 @@ +import { AlertItem } from "../../Shared/Alert"; +import { BlankState } from "../FillBlanksReducer"; + + +export const validateWriteBlanks = ( + solutions: { id: string; solution: string[] }[], + maxWords: number, + setAlerts: React.Dispatch> +): boolean => { + let isValid = true; + + const emptySolutions = solutions.flatMap(s => + s.solution.map((sol, index) => ({ + blankId: s.id, + solutionIndex: index, + isEmpty: !sol.trim() + })) + ).filter(({ isEmpty }) => isEmpty); + + if (emptySolutions.length > 0) { + isValid = false; + setAlerts(prev => { + const filtered = prev.filter(a => !a.tag?.startsWith('empty-solution')); + return [...filtered, ...emptySolutions.map(({ blankId, solutionIndex }) => ({ + variant: "error" as const, + tag: `empty-solution-${blankId}-${solutionIndex}`, + description: `Solution ${solutionIndex + 1} for blank ${blankId} cannot be empty` + }))]; + }); + } else { + setAlerts(prev => prev.filter(a => !a.tag?.startsWith('empty-solution'))); + } + + if (maxWords > 0) { + const invalidWordCount = solutions.flatMap(s => + s.solution.map((sol, index) => ({ + blankId: s.id, + solutionIndex: index, + wordCount: sol.trim().split(/\s+/).length + })) + ).filter(({ wordCount }) => wordCount > maxWords); + + if (invalidWordCount.length > 0) { + isValid = false; + setAlerts(prev => { + const filtered = prev.filter(a => !a.tag?.startsWith('word-count')); + return [...filtered, ...invalidWordCount.map(({ blankId, solutionIndex, wordCount }) => ({ + variant: "error" as const, + tag: `word-count-${blankId}-${solutionIndex}`, + description: `Solution ${solutionIndex + 1} for blank ${blankId} exceeds maximum of ${maxWords} words (current: ${wordCount} words)` + }))]; + }); + } else { + setAlerts(prev => prev.filter(a => !a.tag?.startsWith('word-count'))); + } + } + + return isValid; +}; diff --git a/src/components/ExamEditor/Exercises/Blanks/index.tsx b/src/components/ExamEditor/Exercises/Blanks/index.tsx new file mode 100644 index 00000000..553180ac --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/index.tsx @@ -0,0 +1,246 @@ +import React, { useCallback, useMemo, useReducer, useEffect, ReactNode } from "react"; +import { + DndContext, + DragEndEvent, + DragStartEvent, + MeasuringStrategy, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + restrictToWindowEdges, + snapCenterToCursor, +} from "@dnd-kit/modifiers"; +import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; +import Header from "../../Shared/Header"; +import Alert, { AlertItem } from "../Shared/Alert"; +import clsx from "clsx"; +import { Card, CardContent } from "@/components/ui/card"; +import { Blank, DropZone } from "./DragNDrop"; +import { getTextSegments, BlankState, BlanksState, BlanksAction } from "./FillBlanksReducer"; + + +interface Props { + title?: string; + initialText: string; + description: string; + state: BlanksState; + module: string; + editing: boolean; + showBlankBank: boolean; + alerts: AlertItem[]; + setEditing: React.Dispatch>; + blanksDispatcher: React.Dispatch + onBlankSelect?: (blankId: number | null) => void; + onSave: () => void; + onDiscard: () => void; + onDelete: () => void; + children: ReactNode; +} + +const BlanksEditor: React.FC = ({ + title = "Fill Blanks", + initialText, + description, + state, + editing, + module, + children, + showBlankBank = true, + alerts, + blanksDispatcher, + onBlankSelect, + onSave, + onDiscard, + onDelete, + setEditing +}) => { + + useEffect(() => { + const tokens = getTextSegments(initialText); + const initialBlanks = tokens.reduce((acc, token, idx) => { + if (token.type === 'blank') { + acc.push({ id: token.id, position: idx }); + } + return acc; + }, [] as BlankState[]); + + blanksDispatcher({ type: "SET_TEXT", payload: initialText }); + blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks }); + }, [initialText, blanksDispatcher]); + + const tokens = useMemo(() => { + return getTextSegments(state.text || ""); + }, [state.text]); + + const handleDragStart = useCallback((event: DragStartEvent) => { + blanksDispatcher({ type: "SET_DRAGGED_ITEM", payload: event.active.id.toString() }); + }, [blanksDispatcher]); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over) return; + + const blankId = parseInt(active.id.toString().split("-").pop() || ""); + const dropIndex = parseInt(over.id.toString().split("-")[1]); + + blanksDispatcher({ + type: "MOVE_BLANK", + payload: { blankId, newPosition: dropIndex }, + }); + + blanksDispatcher({ type: "SET_DRAGGED_ITEM", payload: null }); + }, + [blanksDispatcher] + ); + + const handleTextChange = useCallback( + (newText: string) => { + const processedText = newText.replace(/\[(\d+)\]/g, "{{$1}}"); + blanksDispatcher({ type: "SET_TEXT", payload: processedText }); + }, + [blanksDispatcher] + ); + + useEffect(() => { + if (onBlankSelect !== undefined) onBlankSelect(state.selectedBlankId); + }, [state.selectedBlankId, onBlankSelect]); + + const handleBlankSelect = (blankId: number) => { + blanksDispatcher({ + type: "SELECT_BLANK", + payload: blankId === state.selectedBlankId ? null : blankId, + }); + }; + + const handleBlankRemove = useCallback((blankId: number) => { + blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId }); + }, [blanksDispatcher]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 4, + tolerance: 5, + }, + }) + ); + + const modifiers = [snapCenterToCursor, restrictToWindowEdges]; + + const measuring = { + droppable: { + strategy: MeasuringStrategy.Always, + }, + }; + + return ( +
+
+ {alerts.length > 0 && } + + + + + + + + + + + {state.textMode ? ( + { handleTextChange(text); if (!editing) setEditing(true) } } + className="w-full h-full min-h-[200px] p-2 bg-white border rounded-md" + placeholder="Enter text here. Use [1], [2], etc. for blanks..." + /> + ) : ( +
+ {tokens.map((token, index) => { + const isWordToken = token.type === 'text' && !token.isWhitespace; + const showDropZone = isWordToken || token.type === 'blank'; + + return ( + + {showDropZone && } + {token.type === 'blank' ? ( + + ) : token.isLineBreak ? ( +
+ ) : ( + {token.content} + )} +
+ ); + })} + {tokens.length > 0 && + tokens[tokens.length - 1].type === 'text' && ( + + )} +
+ )} +
+
+ + + {(!state.textMode && showBlankBank) && ( + + + {state.blanks.map(blank => ( + + ))} + + + )} + {children} +
+
+ ); +} + +export default BlanksEditor; diff --git a/src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts b/src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts new file mode 100644 index 00000000..9c0ff825 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts @@ -0,0 +1,38 @@ +import { AlertItem } from "../Shared/Alert"; +import { BlankState } from "./FillBlanksReducer"; + + +const validateBlanks = ( + blanks: BlankState[], + answers: Map, + alerts: AlertItem[], + setAlerts: React.Dispatch>, + save: boolean = false, +): boolean => { + const unfilledBlanks = blanks.filter(blank => !answers.has(blank.id.toString())); + const filteredAlerts = alerts.filter(alert => alert.tag !== "unfilled-blanks"); + + if (unfilledBlanks.length > 0) { + if (!save && !filteredAlerts.some(alert => alert.tag === "editing")) { + filteredAlerts.push({ + variant: "info", + description: "You have unsaved changes. Don't forget to save your work!", + tag: "editing" + }); + } + setAlerts([ + ...filteredAlerts, + { + variant: "error", + tag: "unfilled-blanks", + description: `${unfilledBlanks.length} blank${unfilledBlanks.length > 1 ? 's' : ''} ${unfilledBlanks.length > 1 ? 'are' : 'is'} missing a word (blanks: ${unfilledBlanks.map(blank => blank.id).join(", ")})` + } + ]); + return false; + } else if (filteredAlerts.length !== alerts.length) { + setAlerts(filteredAlerts); + } + return true; +}; + +export default validateBlanks; diff --git a/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx b/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx new file mode 100644 index 00000000..cdee19cc --- /dev/null +++ b/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx @@ -0,0 +1,45 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { MatchSentenceExerciseOption } from "@/interfaces/exam"; +import { MdVisibilityOff } from "react-icons/md"; + +interface Props { + showReference: boolean; + selectedReference: string | null; + options: MatchSentenceExerciseOption[]; + headings: boolean; + setShowReference: React.Dispatch>; +} + +const ReferenceViewer: React.FC = ({ showReference, selectedReference, options, setShowReference, headings = true}) => ( +
+
+
+

{headings ? "Reference Paragraphs" : "Authors"}

+ +
+
+
+ {options.map((option) => ( + + + {headings ? "Paragraph" : "Author" } {option.id} + + +

{option.sentence}

+
+
+ ))} +
+
+
+
+); + +export default ReferenceViewer; diff --git a/src/components/ExamEditor/Exercises/MatchSentences/index.tsx b/src/components/ExamEditor/Exercises/MatchSentences/index.tsx new file mode 100644 index 00000000..a81aa89b --- /dev/null +++ b/src/components/ExamEditor/Exercises/MatchSentences/index.tsx @@ -0,0 +1,230 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { + MdAdd, + MdVisibility, + MdVisibilityOff +} from 'react-icons/md'; +import { MatchSentencesExercise, ReadingPart } from '@/interfaces/exam'; +import Alert, { AlertItem } from '../Shared/Alert'; +import ReferenceViewer from './ParagraphViewer'; +import Header from '../../Shared/Header'; +import SortableQuestion from '../Shared/SortableQuestion'; +import QuestionsList from '../Shared/QuestionsList'; +import useExamEditorStore from '@/stores/examEditor'; +import useSectionEdit from '../../Hooks/useSectionEdit'; +import validateMatchSentences from './validation'; +import setEditingAlert from '../Shared/setEditingAlert'; +import { toast } from 'react-toastify'; +import { DragEndEvent } from '@dnd-kit/core'; +import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local'; + +const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const { state } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const section = state as ReadingPart; + + const [local, setLocal] = useState(exercise); + const [selectedParagraph, setSelectedParagraph] = useState(null); + const [showReference, setShowReference] = useState(false); + const [alerts, setAlerts] = useState([]); + + const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + onSave: () => { + + const isValid = validateMatchSentences(local.sentences, setAlerts); + + if (!isValid) { + toast.error("Please fix the errors before saving!"); + return; + } + + setEditing(false); + setAlerts([]); + + const newState = { ...section }; + newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? local : ex); + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + onDiscard: () => { + setLocal(exercise); + setSelectedParagraph(null); + setShowReference(false); + }, + onMode: () => { + const newSection = { + ...section, + exercises: section.exercises.filter((ex) => ex.id !== local.id) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + } + }); + + const usedOptions = useMemo(() => { + return local.sentences.reduce((acc, sentence) => { + if (sentence.solution) { + acc.add(sentence.solution); + } + return acc; + }, new Set()); + }, [local.sentences]); + + const addHeading = () => { + setEditing(true); + const newId = (parseInt(local.sentences[local.sentences.length - 1].id) + 1).toString(); + setLocal({ + ...local, + sentences: [ + ...local.sentences, + { + id: newId, + sentence: "", + solution: "" + } + ] + }); + }; + + const updateHeading = (index: number, field: string, value: string) => { + setEditing(true); + const newSentences = [...local.sentences]; + + if (field === 'solution') { + const oldSolution = newSentences[index].solution; + if (oldSolution) { + usedOptions.delete(oldSolution); + } + } + + newSentences[index] = { ...newSentences[index], [field]: value }; + setLocal({ ...local, sentences: newSentences }); + }; + + const deleteHeading = (index: number) => { + setEditing(true); + if (local.sentences.length <= 1) { + toast.error(`There needs to be at least one ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"}!`); + return; + } + + const deletedSolution = local.sentences[index].solution; + if (deletedSolution) { + usedOptions.delete(deletedSolution); + } + + const newSentences = local.sentences.filter((_, i) => i !== index); + setLocal({ ...local, sentences: newSentences }); + }; + + useEffect(() => { + validateMatchSentences(local.sentences, setAlerts); + }, [local.sentences]); + + useEffect(() => { + setEditingAlert(editing, setAlerts); + }, [editing]); + + + const handleDragEnd = (event: DragEndEvent) => { + setEditing(true); + setLocal(handleMatchSentencesReorder(event, local)); + } + + return ( +
+
+ +
+ +
+ {alerts.length > 0 && } + s.id)} + handleDragEnd={handleDragEnd} + > + {local.sentences.map((sentence, index) => ( + deleteHeading(index)} + onFocus={() => setSelectedParagraph(sentence.solution)} + > + <> + updateHeading(index, 'sentence', e.target.value)} + className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none text-mti-gray-dim" + placeholder={`Enter ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"} ...`} + /> +
+ +
+ +
+ ))} +
+ + +
+ + +
+ ); +}; + +export default MatchSentences; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/MatchSentences/validation.ts b/src/components/ExamEditor/Exercises/MatchSentences/validation.ts new file mode 100644 index 00000000..a96fa6f4 --- /dev/null +++ b/src/components/ExamEditor/Exercises/MatchSentences/validation.ts @@ -0,0 +1,42 @@ +import { AlertItem } from "../Shared/Alert"; + +const validateMatchSentences = ( + sentences: {id: string; sentence: string; solution: string;}[], + setAlerts: React.Dispatch> +): boolean => { + let hasErrors = false; + + const emptySentences = sentences.filter(s => !s.sentence.trim()); + if (emptySentences.length > 0) { + hasErrors = true; + setAlerts(prev => { + const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-sentence')); + return [...filteredAlerts, ...emptySentences.map(s => ({ + variant: "error" as const, + tag: `empty-sentence-${s.id}`, + description: `Heading ${s.id} is empty` + }))]; + }); + } else { + setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-sentence'))); + } + + const unmatchedSentences = sentences.filter(s => !s.solution); + if (unmatchedSentences.length > 0) { + hasErrors = true; + setAlerts(prev => { + const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('unmatched-sentence')); + return [...filteredAlerts, ...unmatchedSentences.map(s => ({ + variant: "error" as const, + tag: `unmatched-sentence-${s.id}`, + description: `Heading ${s.id} has no paragraph selected` + }))]; + }); + } else { + setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('unmatched-sentence'))); + } + + return !hasErrors; +}; + +export default validateMatchSentences; diff --git a/src/components/ExamEditor/Exercises/MultipleChoice/Underline/UnderlineQuestion.tsx b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/UnderlineQuestion.tsx new file mode 100644 index 00000000..5ed4ce33 --- /dev/null +++ b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/UnderlineQuestion.tsx @@ -0,0 +1,190 @@ +import { MultipleChoiceQuestion } from "@/interfaces/exam"; +import clsx from "clsx"; +import { useEffect, useState } from "react"; +import { MdEdit, MdEditOff } from "react-icons/md"; + +interface UnderlineQuestionProps { + question: MultipleChoiceQuestion; + onQuestionChange: (updatedQuestion: MultipleChoiceQuestion) => void; + onValidationChange?: (isValid: boolean) => void; +} + +interface Option { + id: string; + text?: string; + src?: string; +} + +export const UnderlineQuestion: React.FC = ({ + question, + onQuestionChange, + onValidationChange, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + + const stripUnderlineTags = (text: string = '') => text.replace(/<\/?u>/g, ''); + + const addUnderlineTags = (text: string, options: Option[]) => { + let result = text; + + // Sort options by length (longest first) to handle overlapping matches + const sortedOptions = [...options] + .filter(opt => opt.text?.trim() && opt.text.trim().length > 1) + .sort((a, b) => ((b.text?.length || 0) - (a.text?.length || 0))); + + for (const option of sortedOptions) { + if (!option.text?.trim()) continue; + + const optionText = stripUnderlineTags(option.text).trim(); + const textLower = result.toLowerCase(); + const optionLower = optionText.toLowerCase(); + + let startIndex = textLower.indexOf(optionLower); + while (startIndex !== -1) { + // Check if this portion is already underlined + const beforeTag = result.slice(Math.max(0, startIndex - 3), startIndex); + const afterTag = result.slice(startIndex + optionText.length, startIndex + optionText.length + 4); + + if (!beforeTag.includes('') && !afterTag.includes('')) { + const before = result.substring(0, startIndex); + const match = result.substring(startIndex, startIndex + optionText.length); + const after = result.substring(startIndex + optionText.length); + result = `${before}${match}${after}`; + } + + // Find next occurrence + startIndex = textLower.indexOf(optionLower, startIndex + 1); + } + } + + return result; + }; + + const validateQuestion = (q: MultipleChoiceQuestion) => { + const errors: string[] = []; + const rawPrompt = stripUnderlineTags(q.prompt).toLowerCase(); + + q.options.forEach((option) => { + if (option.text?.trim() && !rawPrompt.includes(stripUnderlineTags(option.text).trim().toLowerCase())) { + errors.push(`Option ${option.id} text not found in prompt`); + } + }); + + setValidationErrors(errors); + onValidationChange?.(errors.length === 0); + return errors.length === 0; + }; + + useEffect(() => { + validateQuestion(question); + }, [question]); + + const handlePromptChange = (value: string) => { + const newPrompt = addUnderlineTags(value, question.options); + onQuestionChange({ + ...question, + prompt: newPrompt + }); + }; + + const handleOptionChange = (optionIndex: number, value: string) => { + const updatedOptions = question.options.map((opt, idx) => + idx === optionIndex ? { ...opt, text: value } : opt + ); + + const strippedPrompt = stripUnderlineTags(question.prompt); + const newPrompt = addUnderlineTags(strippedPrompt, updatedOptions); + + onQuestionChange({ + ...question, + prompt: newPrompt, + options: updatedOptions + }); + }; + + return ( +
+
+ {isEditing ? ( + handlePromptChange(e.target.value)} + className="flex-1 p-3 border rounded-lg focus:outline-none" + placeholder="Enter text for underlining..." + /> + ) : ( +
+ )} + +
+ + {validationErrors.length > 0 && ( +
+ {validationErrors.map((error, index) => ( +
{error}
+ ))} +
+ )} + +
+ {question.options.map((option, optionIndex) => { + const isInvalidOption = option.text?.trim() && + !stripUnderlineTags(question.prompt || '').toLowerCase() + .includes(stripUnderlineTags(option.text).trim().toLowerCase()); + + return ( +
+ + handleOptionChange(optionIndex, e.target.value)} + className={clsx( + "flex-1 p-3 border rounded-lg focus:ring-2 focus:outline-none", + isInvalidOption + ? "border-red-500 focus:ring-red-500 bg-red-50" + : "focus:ring-blue-500" + )} + placeholder={`Option ${option.id}...`} + /> +
+ ); + })} +
+
+ ); +}; + +export default UnderlineQuestion; diff --git a/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx new file mode 100644 index 00000000..b046dab8 --- /dev/null +++ b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx @@ -0,0 +1,151 @@ +import Header from "@/components/ExamEditor/Shared/Header"; +import QuestionsList from "../../Shared/QuestionsList"; +import SortableQuestion from "../../Shared/SortableQuestion"; +import UnderlineQuestion from "./UnderlineQuestion"; +import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit"; +import { toast } from "react-toastify"; +import setEditingAlert from "../../Shared/setEditingAlert"; +import { LevelPart, ListeningPart, MultipleChoiceExercise, MultipleChoiceQuestion, ReadingPart } from "@/interfaces/exam"; +import useExamEditorStore from "@/stores/examEditor"; +import { useEffect, useState } from "react"; +import { MdAdd } from "react-icons/md"; +import Alert, { AlertItem } from "../../Shared/Alert"; + + +const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({ + exercise, + sectionId, +}) => { + const { currentModule, dispatch } = useExamEditorStore(); + const { state } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + const section = state as ReadingPart | ListeningPart | LevelPart; + + const [local, setLocal] = useState(exercise); + const [alerts, setAlerts] = useState([]); + + useEffect(() => { + setLocal(exercise); + }, [exercise]); + + const updateLocal = (exercise: MultipleChoiceExercise) => { + setLocal(exercise); + setEditingAlert(true, setAlerts); + setEditing(true); + }; + + const handleQuestionChange = (questionIndex: number, updatedQuestion: MultipleChoiceQuestion) => { + const newQuestions = [...local.questions]; + newQuestions[questionIndex] = updatedQuestion; + updateLocal({ ...local, questions: newQuestions }); + }; + + const addQuestion = () => { + const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString(); + const options = Array.from({ length: 4 }, (_, i) => ({ + id: String.fromCharCode(65 + i), + text: '' + })); + + updateLocal({ + ...local, + questions: [ + ...local.questions, + { + prompt: "", + solution: "", + id: newId, + options, + variant: "text" + }, + ] + }); + }; + + const deleteQuestion = (index: number) => { + if (local.questions.length === 1) { + toast.error("There needs to be at least one question!"); + return; + } + + const newQuestions = local.questions.filter((_, i) => i !== index); + updateLocal({ ...local, questions: newQuestions }); + }; + + const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({ + sectionId, + mode: "edit", + onSave: () => { + setEditing(false); + setAlerts([]); + const newSection = { + ...section, + exercises: section.exercises.map((ex) => + ex.id === local.id ? local : ex + ) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + onDiscard: () => { + setLocal(exercise); + setEditing(false); + }, + onMode: () => { + const newSection = { + ...section, + exercises: section.exercises.filter((ex) => ex.id !== local.id) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + }); + + return ( +
+
+ {alerts.length > 0 && } + +
+ q.id)} + handleDragEnd={()=> {}} + > + {local.questions.map((question, questionIndex) => ( + deleteQuestion(questionIndex)} + > + + handleQuestionChange(questionIndex, updatedQuestion) + } + /> + + ))} + + + +
+
+ ); +}; + +export default UnderlineMultipleChoice; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx b/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx new file mode 100644 index 00000000..b3686cfa --- /dev/null +++ b/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx @@ -0,0 +1,302 @@ +import React, { useEffect, useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { + MdAdd, + MdEdit, + MdEditOff, +} from 'react-icons/md'; +import { ReadingPart, MultipleChoiceExercise, MultipleChoiceQuestion, LevelPart, ListeningPart } from '@/interfaces/exam'; +import clsx from 'clsx'; +import useExamEditorStore from '@/stores/examEditor'; +import { toast } from 'react-toastify'; +import { DragEndEvent } from '@dnd-kit/core'; +import useSectionEdit from '@/components/ExamEditor/Hooks/useSectionEdit'; +import Header from '@/components/ExamEditor/Shared/Header'; +import Alert, { AlertItem } from '../../Shared/Alert'; +import QuestionsList from '../../Shared/QuestionsList'; +import SortableQuestion from '../../Shared/SortableQuestion'; +import setEditingAlert from '../../Shared/setEditingAlert'; +import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local'; + +interface MultipleChoiceProps { + exercise: MultipleChoiceExercise; + sectionId: number; + optionsQuantity: number; +} + +const validateMultipleChoiceQuestions = ( + questions: MultipleChoiceQuestion[], + optionsQuantity: number, + setAlerts: React.Dispatch> +) => { + const validationAlerts: AlertItem[] = []; + + questions.forEach((question, index) => { + if (!question.prompt.trim()) { + validationAlerts.push({ + variant: 'error', + tag: `missing-prompt-${index}`, + description: `Question ${index + 1} is missing a prompt` + }); + } + if (!question.solution) { + validationAlerts.push({ + variant: 'error', + tag: `missing-solution-${index}`, + description: `Question ${index + 1} is missing a solution` + }); + } + if (question.options.length !== optionsQuantity) { + validationAlerts.push({ + variant: 'error', + tag: `invalid-options-${index}`, + description: `Question ${index + 1} must have exactly ${optionsQuantity} options` + }); + } + question.options.forEach((option, optionIndex) => { + if (option.text && option.text.trim() === "") { + validationAlerts.push({ + variant: 'error', + tag: `empty-option-${index}-${optionIndex}`, + description: `Question ${index + 1} has an empty option` + }); + } + }); + }); + + setAlerts(prev => { + const editingAlert = prev.find(alert => alert.tag === 'editing'); + return [...validationAlerts, ...(editingAlert ? [editingAlert] : [])]; + }); + + return validationAlerts.length === 0; +}; + +const MultipleChoice: React.FC = ({ exercise, sectionId, optionsQuantity }) => { + const { currentModule, dispatch} = useExamEditorStore(); + const { state } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const section = state as ReadingPart | ListeningPart| LevelPart; + + const [local, setLocal] = useState(exercise); + const [editingPrompt, setEditingPrompt] = useState(false); + const [alerts, setAlerts] = useState([]); + + const updateLocal = (exercise: MultipleChoiceExercise) => { + setLocal(exercise); + setEditingAlert(true, setAlerts); + setEditing(true); + }; + + const updateQuestion = (index: number, field: string, value: string) => { + const newQuestions = [...local.questions]; + newQuestions[index] = { ...newQuestions[index], [field]: value }; + updateLocal({ ...local, questions: newQuestions }); + }; + + const updateOption = (questionIndex: number, optionIndex: number, value: string) => { + const newQuestions = [...local.questions]; + const newOptions = [...newQuestions[questionIndex].options]; + newOptions[optionIndex] = { ...newOptions[optionIndex], text: value }; + newQuestions[questionIndex] = { ...newQuestions[questionIndex], options: newOptions }; + updateLocal({ ...local, questions: newQuestions }); + }; + + const addQuestion = () => { + const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString(); + const options = Array.from({ length: optionsQuantity }, (_, i) => ({ + id: String.fromCharCode(65 + i), + text: '' + })); + + updateLocal({ + ...local, + questions: [ + ...local.questions, + { + prompt: "", + solution: "", + id: newId, + options, + variant: "text" + }, + ] + }); + }; + + const deleteQuestion = (index: number) => { + if (local.questions.length === 1) { + toast.error("There needs to be at least one question!"); + return; + } + + const newQuestions = local.questions.filter((_, i) => i !== index); + const minId = Math.min(...newQuestions.map(q => parseInt(q.id))); + + const updatedQuestions = newQuestions.map((question, i) => ({ + ...question, + id: String(minId + i) + })); + + updateLocal({ ...local, questions: updatedQuestions }); + }; + + const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({ + sectionId, + mode: "edit", + onSave: () => { + const isValid = validateMultipleChoiceQuestions( + local.questions, + optionsQuantity, + setAlerts + ); + + if (!isValid) { + toast.error("Please fix the errors before saving!"); + return; + } + + setEditing(false); + setAlerts([]); + const newSection = { + ...section, + exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + onDiscard: () => { + setLocal(exercise); + }, + onMode: () => { + const newSection = { + ...section, + exercises: section.exercises.filter((ex) => ex.id !== local.id) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + }); + + useEffect(() => { + validateMultipleChoiceQuestions(local.questions, optionsQuantity, setAlerts); + }, [local.questions, optionsQuantity]); + + const handleDragEnd = (event: DragEndEvent) => { + setEditingAlert(true, setAlerts); + setEditing(true); + setLocal(handleMultipleChoiceReorder(event, local)); + }; + + return ( +
+
+ {alerts.length > 0 && } + + +
+ {editingPrompt ? ( +