Exam generation rework, batch user tables, fastapi endpoint switch
This commit is contained in:
119
src/stores/examEditor/defaults.ts
Normal file
119
src/stores/examEditor/defaults.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Module } from "@/interfaces"
|
||||
import { Difficulty} from "@/interfaces/exam"
|
||||
import { sample } from "lodash"
|
||||
import { ExamPart, ModuleState, SectionState } from "./types"
|
||||
import { levelPart, listeningSection, readingPart, speakingTask, writingTask } from "@/stores/examEditor/sections"
|
||||
|
||||
|
||||
const defaultSettings = (module: Module) => {
|
||||
const baseSettings = {
|
||||
category: '',
|
||||
introOption: { label: 'None', value: 'None' },
|
||||
customIntro: '',
|
||||
currentIntro: '',
|
||||
topic: '',
|
||||
isCategoryDropdownOpen: false,
|
||||
isIntroDropdownOpen: false,
|
||||
isExerciseDropdownOpen: false,
|
||||
}
|
||||
|
||||
switch (module) {
|
||||
case 'reading':
|
||||
return {
|
||||
...baseSettings,
|
||||
isPassageOpen: false,
|
||||
}
|
||||
case 'listening':
|
||||
return {
|
||||
...baseSettings,
|
||||
isAudioContextOpen: false
|
||||
}
|
||||
default:
|
||||
return baseSettings;
|
||||
}
|
||||
}
|
||||
|
||||
const sectionLabels = (module: Module) => {
|
||||
switch (module) {
|
||||
case 'reading':
|
||||
return Array.from({ length: 3 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
label: `Passage ${index + 1}`
|
||||
}));
|
||||
case 'writing':
|
||||
return [{id: 1, label: "Task 1"}, {id: 2, label: "Task 2"}];
|
||||
case 'speaking':
|
||||
return [{id: 1, label: "Speaking 1"}, {id: 2, label: "Speaking 2"}, {id: 3, label: "Interactive Speaking"}];
|
||||
case 'listening':
|
||||
return Array.from({ length: 4 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
label: `Section ${index + 1}`
|
||||
}));
|
||||
case 'level':
|
||||
return [{id: 1, label: "Part 1"}];
|
||||
}
|
||||
}
|
||||
|
||||
const defaultExamLabel = (module: Module) => {
|
||||
switch (module) {
|
||||
case 'reading':
|
||||
return "Reading Exam";
|
||||
case 'writing':
|
||||
return "Writing Exam";
|
||||
case 'speaking':
|
||||
return "Speaking Exam";
|
||||
case 'listening':
|
||||
return "Listening Exam";
|
||||
case 'level':
|
||||
return "Placement Test";
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSection = (module: Module, sectionId: number) => {
|
||||
switch (module) {
|
||||
case 'reading':
|
||||
return readingPart(sectionId);
|
||||
case 'writing':
|
||||
return writingTask(sectionId);
|
||||
case 'listening':
|
||||
return listeningSection(sectionId)
|
||||
case 'speaking':
|
||||
return speakingTask(sectionId)
|
||||
case 'level':
|
||||
return levelPart(sectionId)
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultSectionSettings = (module: Module, sectionId: number, part?: ExamPart) => {
|
||||
return {
|
||||
sectionId: sectionId,
|
||||
sectionLabel: "",
|
||||
settings: defaultSettings(module),
|
||||
state: part !== undefined ? part : defaultSection(module, sectionId),
|
||||
generating: undefined,
|
||||
genResult: undefined,
|
||||
expandedSubSections: [],
|
||||
exercisePickerState: [],
|
||||
selectedExercises: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const defaultModuleSettings = (module: Module, minTimer: number, states?: SectionState[]): ModuleState => {
|
||||
const state: ModuleState = {
|
||||
examLabel: defaultExamLabel(module),
|
||||
minTimer,
|
||||
difficulty: sample(["easy", "medium", "hard"] as Difficulty[])!,
|
||||
isPrivate: false,
|
||||
sectionLabels: sectionLabels(module),
|
||||
expandedSections: [1],
|
||||
focusedSection: 1,
|
||||
sections: [defaultSectionSettings(module, 1)],
|
||||
importModule: true,
|
||||
importing: false,
|
||||
edit: [],
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
export default defaultModuleSettings;
|
||||
24
src/stores/examEditor/index.ts
Normal file
24
src/stores/examEditor/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import defaultModuleSettings from "./defaults";
|
||||
import { Action, rootReducer } from "./reducers";
|
||||
import ExamEditorStore from "./types";
|
||||
import { create } from "zustand";
|
||||
|
||||
|
||||
const useExamEditorStore = create<
|
||||
ExamEditorStore & {
|
||||
dispatch: (action: Action) => void;
|
||||
}>((set) => ({
|
||||
title: "",
|
||||
globalEdit: [],
|
||||
currentModule: "reading",
|
||||
modules: {
|
||||
reading: defaultModuleSettings("reading", 60),
|
||||
writing: defaultModuleSettings("writing", 60),
|
||||
speaking: defaultModuleSettings("speaking", 14),
|
||||
listening: defaultModuleSettings("listening", 30),
|
||||
level: defaultModuleSettings("level", 60)
|
||||
},
|
||||
dispatch: (action) => set((state) => rootReducer(state, action))
|
||||
}));
|
||||
|
||||
export default useExamEditorStore;
|
||||
49
src/stores/examEditor/reducers/index.ts
Normal file
49
src/stores/examEditor/reducers/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import ExamEditorStore from "../types";
|
||||
import { MODULE_ACTIONS, ModuleActions, moduleReducer } from "./moduleReducer";
|
||||
import { SECTION_ACTIONS, SectionActions, sectionReducer } from "./sectionReducer";
|
||||
|
||||
|
||||
export type Action = ModuleActions | SectionActions | { type: 'UPDATE_ROOT'; payload: { updates: Partial<ExamEditorStore> } };
|
||||
|
||||
export const rootReducer = (
|
||||
state: ExamEditorStore,
|
||||
action: Action
|
||||
): Partial<ExamEditorStore> => {
|
||||
if (MODULE_ACTIONS.includes(action.type as any)) {
|
||||
if (action.type === "REORDER_EXERCISES" && "payload" in action && "event" in action.payload) {
|
||||
const updatedState = sectionReducer(state, action as SectionActions);
|
||||
if (!updatedState.modules) return state;
|
||||
|
||||
return moduleReducer({
|
||||
...state,
|
||||
modules: updatedState.modules
|
||||
}, { type: "REORDER_EXERCISES" });
|
||||
}
|
||||
|
||||
return moduleReducer(state, action as ModuleActions);
|
||||
}
|
||||
|
||||
if (SECTION_ACTIONS.includes(action.type as any)) {
|
||||
if (action.type === "UPDATE_SECTION_STATE") {
|
||||
const updatedState = sectionReducer(state, action as SectionActions);
|
||||
if (!updatedState.modules) return state;
|
||||
|
||||
return moduleReducer({
|
||||
...state,
|
||||
modules: updatedState.modules
|
||||
}, { type: "REORDER_EXERCISES" });
|
||||
}
|
||||
return sectionReducer(state, action as SectionActions);
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'UPDATE_ROOT':
|
||||
const { updates } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
...updates
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
107
src/stores/examEditor/reducers/moduleReducer.ts
Normal file
107
src/stores/examEditor/reducers/moduleReducer.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Module } from "@/interfaces";
|
||||
import { defaultSectionSettings } from "../defaults";
|
||||
import { reorderExercises } from "../reorder/global";
|
||||
import ExamEditorStore, { ModuleState } from "../types";
|
||||
|
||||
export type ModuleActions =
|
||||
| { type: 'UPDATE_MODULE'; payload: { updates: Partial<ModuleState>, module?: Module } }
|
||||
| { type: 'TOGGLE_SECTION'; payload: { sectionId: number; } }
|
||||
| { type: 'RESET_FOCUS'; }
|
||||
| { type: 'REORDER_EXERCISES' };
|
||||
|
||||
export const MODULE_ACTIONS = [
|
||||
'UPDATE_MODULE',
|
||||
'TOGGLE_SECTION',
|
||||
'RESET_FOCUS',
|
||||
'REORDER_EXERCISES'
|
||||
];
|
||||
|
||||
export const moduleReducer = (
|
||||
state: ExamEditorStore,
|
||||
action: ModuleActions
|
||||
): Partial<ExamEditorStore> => {
|
||||
const currentModule = state.currentModule;
|
||||
const currentModuleState = state.modules[currentModule];
|
||||
const expandedSections = currentModuleState.expandedSections;
|
||||
|
||||
switch (action.type) {
|
||||
case 'UPDATE_MODULE':
|
||||
const { updates, module } = action.payload;
|
||||
if (module === undefined) {
|
||||
return {
|
||||
modules: {
|
||||
...state.modules,
|
||||
[currentModule]: {
|
||||
...currentModuleState,
|
||||
...updates
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
modules: {
|
||||
...state.modules,
|
||||
[module]: {
|
||||
...state.modules[module],
|
||||
...updates
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'TOGGLE_SECTION':
|
||||
const { sectionId } = action.payload;
|
||||
|
||||
const prev = currentModuleState.sections;
|
||||
const updatedSections = prev.some(section => section.sectionId === sectionId)
|
||||
? prev.filter(section => section.sectionId !== sectionId)
|
||||
: [
|
||||
...prev,
|
||||
defaultSectionSettings(currentModule, sectionId)
|
||||
];
|
||||
|
||||
const updatedCheckedSections = expandedSections.includes(sectionId)
|
||||
? expandedSections.filter(i => i !== sectionId)
|
||||
: [...expandedSections, sectionId];
|
||||
|
||||
return {
|
||||
modules: {
|
||||
...state.modules,
|
||||
[currentModule]: {
|
||||
...currentModuleState,
|
||||
sections: updatedSections,
|
||||
expandedSections: updatedCheckedSections
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
case 'RESET_FOCUS': {
|
||||
const currentFocus = currentModuleState.focusedSection;
|
||||
const updatedSections = expandedSections?.filter(id => id != currentFocus);
|
||||
|
||||
return {
|
||||
modules: {
|
||||
...state.modules,
|
||||
[currentModule]: {
|
||||
...currentModuleState,
|
||||
expandedSections: updatedSections,
|
||||
focusedSection: updatedSections[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'REORDER_EXERCISES': {
|
||||
if (currentModule === "writing" || currentModule === "speaking") return state;
|
||||
return {
|
||||
...state,
|
||||
modules: {
|
||||
...state.modules,
|
||||
[currentModule]: reorderExercises(currentModuleState)
|
||||
}
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
118
src/stores/examEditor/reducers/sectionReducer.ts
Normal file
118
src/stores/examEditor/reducers/sectionReducer.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Module } from "@/interfaces";
|
||||
import ExamEditorStore, { Generating, ReadingSectionSettings, Section, SectionSettings, SectionState } from "../types";
|
||||
import { DragEndEvent } from "@dnd-kit/core";
|
||||
import { LevelPart, ListeningPart, ReadingPart } from "@/interfaces/exam";
|
||||
|
||||
export type SectionActions =
|
||||
| { type: 'UPDATE_SECTION_SINGLE_FIELD'; payload: { module: Module; sectionId: number; field: string; value: any } }
|
||||
| { type: 'UPDATE_SECTION_SETTINGS'; payload: { sectionId: number; update: Partial<SectionSettings | ReadingSectionSettings>; } }
|
||||
| { type: 'UPDATE_SECTION_STATE'; payload: { sectionId: number; update: Partial<Section>; } }
|
||||
| { type: 'REORDER_EXERCISES'; payload: { event: DragEndEvent, sectionId: number; } };
|
||||
|
||||
export const SECTION_ACTIONS = [
|
||||
'UPDATE_SECTION_SETTINGS',
|
||||
'UPDATE_SECTION_STATE',
|
||||
'UPDATE_SECTION_SINGLE_FIELD',
|
||||
];
|
||||
|
||||
export const sectionReducer = (
|
||||
state: ExamEditorStore,
|
||||
action: SectionActions
|
||||
): Partial<ExamEditorStore> => {
|
||||
const currentModule = state.currentModule;
|
||||
const modules = state.modules;
|
||||
const sections = state.modules[currentModule].sections;
|
||||
let sectionId: number;
|
||||
|
||||
if (action.payload && 'sectionId' in action.payload) {
|
||||
sectionId = action.payload.sectionId;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'UPDATE_SECTION_SINGLE_FIELD':
|
||||
const { module, field, value } = action.payload;
|
||||
return {
|
||||
modules: {
|
||||
...modules,
|
||||
[module]: {
|
||||
...modules[module],
|
||||
sections: sections.map((section: SectionState) =>
|
||||
section.sectionId === sectionId
|
||||
? {
|
||||
...section,
|
||||
[field]: value
|
||||
}
|
||||
: section
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
case 'UPDATE_SECTION_SETTINGS':
|
||||
let updatedSettings = action.payload.update;
|
||||
return {
|
||||
modules: {
|
||||
...modules,
|
||||
[currentModule]: {
|
||||
...modules[currentModule],
|
||||
sections: sections.map((section: SectionState) =>
|
||||
section.sectionId === sectionId
|
||||
? {
|
||||
...section,
|
||||
settings: {
|
||||
...section.settings,
|
||||
...updatedSettings
|
||||
}
|
||||
}
|
||||
: section
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
case 'UPDATE_SECTION_STATE':
|
||||
const updatedState = action.payload.update;
|
||||
return {
|
||||
modules: {
|
||||
...modules,
|
||||
[currentModule]: {
|
||||
...modules[currentModule],
|
||||
sections: sections.map(section =>
|
||||
section.sectionId === sectionId
|
||||
? { ...section, state: {...section.state, ...updatedState} }
|
||||
: section
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
case 'REORDER_EXERCISES': {
|
||||
const { active, over } = action.payload.event;
|
||||
if (!over) return state;
|
||||
|
||||
const oldIndex = active.id as number;
|
||||
const newIndex = over.id as number;
|
||||
|
||||
const currentSectionState = sections.find((s) => s.sectionId = sectionId)!.state as ReadingPart | ListeningPart | LevelPart;
|
||||
|
||||
const [removed] = currentSectionState.exercises.splice(oldIndex, 1);
|
||||
currentSectionState.exercises.splice(newIndex, 0, removed);
|
||||
return {
|
||||
...state,
|
||||
modules: {
|
||||
...state.modules,
|
||||
[currentModule]: {
|
||||
...modules[currentModule],
|
||||
sections: sections.map(section =>
|
||||
section.sectionId === sectionId
|
||||
? { ...section, state: currentSectionState }
|
||||
: section
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
188
src/stores/examEditor/reorder/global.ts
Normal file
188
src/stores/examEditor/reorder/global.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { FillBlanksExercise, LevelPart, ListeningPart, MatchSentencesExercise, MultipleChoiceExercise, ReadingPart, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam";
|
||||
import { ModuleState } from "../types";
|
||||
import ReorderResult from "./types";
|
||||
|
||||
const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): ReorderResult<FillBlanksExercise> => {
|
||||
const newSolutions = exercise.solutions
|
||||
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
|
||||
.map((solution, index) => ({
|
||||
...solution,
|
||||
id: (startId + index).toString()
|
||||
}));
|
||||
|
||||
const idMapping = exercise.solutions
|
||||
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
|
||||
.reduce((acc, solution, index) => {
|
||||
acc[solution.id] = (startId + index).toString();
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
let newText = exercise.text;
|
||||
Object.entries(idMapping).forEach(([oldId, newId]) => {
|
||||
const regex = new RegExp(`\\{\\{${oldId}\\}\\}`, 'g');
|
||||
newText = newText.replace(regex, `{{${newId}}}`);
|
||||
});
|
||||
|
||||
|
||||
const newWords = exercise.words.map(word => {
|
||||
if (typeof word === 'string') {
|
||||
return word;
|
||||
} else if ('letter' in word && 'word' in word) {
|
||||
return word;
|
||||
} else if ('options' in word) {
|
||||
return word;
|
||||
}
|
||||
return word;
|
||||
});
|
||||
|
||||
const newUserSolutions = exercise.userSolutions?.map(solution => ({
|
||||
...solution,
|
||||
id: idMapping[solution.id] || solution.id
|
||||
}));
|
||||
|
||||
return {
|
||||
exercise: {
|
||||
...exercise,
|
||||
solutions: newSolutions,
|
||||
text: newText,
|
||||
words: newWords,
|
||||
userSolutions: newUserSolutions
|
||||
},
|
||||
lastId: startId + newSolutions.length - 1
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): ReorderResult<WriteBlanksExercise> => {
|
||||
const newSolutions = exercise.solutions
|
||||
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
|
||||
.map((solution, index) => ({
|
||||
...solution,
|
||||
id: (startId + index).toString()
|
||||
}));
|
||||
|
||||
return {
|
||||
exercise: {
|
||||
...exercise,
|
||||
solutions: newSolutions,
|
||||
text: newSolutions.reduce((text, solution, index) => {
|
||||
return text.replace(
|
||||
new RegExp(`\\{\\{${solution.id}\\}\\}`),
|
||||
`{{${startId + index}}}`
|
||||
);
|
||||
}, exercise.text)
|
||||
},
|
||||
lastId: startId + newSolutions.length
|
||||
};
|
||||
};
|
||||
|
||||
const reorderTrueFalse = (exercise: TrueFalseExercise, startId: number): ReorderResult<TrueFalseExercise> => {
|
||||
const newQuestions = exercise.questions
|
||||
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
|
||||
.map((question, index) => ({
|
||||
...question,
|
||||
id: (startId + index).toString()
|
||||
}));
|
||||
|
||||
return {
|
||||
exercise: {
|
||||
...exercise,
|
||||
questions: newQuestions
|
||||
},
|
||||
lastId: startId + newQuestions.length
|
||||
};
|
||||
};
|
||||
|
||||
const reorderMatchSentences = (exercise: MatchSentencesExercise, startId: number): ReorderResult<MatchSentencesExercise> => {
|
||||
const newSentences = exercise.sentences
|
||||
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
|
||||
.map((sentence, index) => ({
|
||||
...sentence,
|
||||
id: (startId + index).toString()
|
||||
}));
|
||||
|
||||
return {
|
||||
exercise: {
|
||||
...exercise,
|
||||
sentences: newSentences
|
||||
},
|
||||
lastId: startId + newSentences.length
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const reorderMultipleChoice = (exercise: MultipleChoiceExercise, startId: number): ReorderResult<MultipleChoiceExercise> => {
|
||||
const newQuestions = exercise.questions
|
||||
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
|
||||
.map((question, index) => ({
|
||||
...question,
|
||||
id: (startId + index).toString()
|
||||
}));
|
||||
|
||||
return {
|
||||
exercise: {
|
||||
...exercise,
|
||||
questions: newQuestions
|
||||
},
|
||||
lastId: startId + newQuestions.length
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
const reorderExercises = (moduleState: ModuleState) => {
|
||||
let currentId = 1;
|
||||
|
||||
const reorderedSections = moduleState.sections.map(section => {
|
||||
const currentSection = section.state as ReadingPart | ListeningPart |LevelPart;
|
||||
const reorderedExercises = currentSection.exercises.map(exercise => {
|
||||
let result;
|
||||
switch (exercise.type) {
|
||||
case 'fillBlanks':
|
||||
result = reorderFillBlanks(exercise, currentId);
|
||||
currentId = result.lastId;
|
||||
return result.exercise;
|
||||
case 'writeBlanks':
|
||||
result = reorderWriteBlanks(exercise, currentId);
|
||||
currentId = result.lastId;
|
||||
return result.exercise;
|
||||
case 'trueFalse':
|
||||
result = reorderTrueFalse(exercise, currentId);
|
||||
currentId = result.lastId;
|
||||
return result.exercise;
|
||||
case 'matchSentences':
|
||||
result = reorderMatchSentences(exercise, currentId);
|
||||
currentId = result.lastId;
|
||||
return result.exercise;
|
||||
case 'multipleChoice':
|
||||
result = reorderMultipleChoice(exercise, currentId);
|
||||
currentId = result.lastId
|
||||
return result.exercise;
|
||||
default:
|
||||
return exercise;
|
||||
}
|
||||
});
|
||||
return {
|
||||
...section,
|
||||
state: {
|
||||
...currentSection,
|
||||
exercises: reorderedExercises
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...moduleState,
|
||||
sections: reorderedSections
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
reorderFillBlanks,
|
||||
reorderWriteBlanks,
|
||||
reorderTrueFalse,
|
||||
reorderMatchSentences,
|
||||
reorderExercises,
|
||||
reorderMultipleChoice,
|
||||
};
|
||||
130
src/stores/examEditor/reorder/local.ts
Normal file
130
src/stores/examEditor/reorder/local.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { DragEndEvent } from "@dnd-kit/core";
|
||||
import { WriteBlanksExercise, TrueFalseExercise, MatchSentencesExercise, MultipleChoiceExercise } from "@/interfaces/exam";
|
||||
import getMinExerciseId from "./minIds";
|
||||
import { parseText, reconstructText } from "@/components/ExamEditor/Exercises/WriteBlanks/parsing";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
|
||||
|
||||
const handleWriteBlanksReorder = (event: DragEndEvent, exercise: WriteBlanksExercise) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return exercise;
|
||||
|
||||
const oldIndex = exercise.solutions.findIndex(s => s.id === active.id);
|
||||
const newIndex = exercise.solutions.findIndex(s => s.id === over.id);
|
||||
|
||||
const newSolutions = [...exercise.solutions];
|
||||
const [movedItem] = newSolutions.splice(oldIndex, 1);
|
||||
newSolutions.splice(newIndex, 0, movedItem);
|
||||
|
||||
const questions = parseText(exercise.text);
|
||||
|
||||
const reorderedQuestions = questions.map((q, i) => {
|
||||
const solutionIndex = questions.findIndex(origQ => origQ.id === newSolutions[i].id);
|
||||
return questions[solutionIndex];
|
||||
});
|
||||
|
||||
const minId = getMinExerciseId(exercise);
|
||||
const finalSolutions = newSolutions.map((solution, index) => ({
|
||||
...solution,
|
||||
id: (minId + index).toString()
|
||||
}));
|
||||
|
||||
const finalQuestions = reorderedQuestions.map((q, index) => ({
|
||||
...q,
|
||||
id: (minId + index).toString()
|
||||
}));
|
||||
|
||||
const newText = reconstructText(finalQuestions);
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
solutions: finalSolutions,
|
||||
text: newText
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const handleTrueFalseReorder = (event: DragEndEvent, exercise: TrueFalseExercise) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return exercise;
|
||||
|
||||
const oldIndex = exercise.questions.findIndex(q => q.id === active.id);
|
||||
const newIndex = exercise.questions.findIndex(q => q.id === over.id);
|
||||
|
||||
const newQuestions = [...exercise.questions];
|
||||
const [movedItem] = newQuestions.splice(oldIndex, 1);
|
||||
newQuestions.splice(newIndex, 0, movedItem);
|
||||
|
||||
const minId = getMinExerciseId(exercise);
|
||||
const reorderedQuestions = newQuestions.map((question, index) => ({
|
||||
...question,
|
||||
id: (minId + index).toString()
|
||||
}));
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
questions: reorderedQuestions
|
||||
};
|
||||
};
|
||||
|
||||
const handleMatchSentencesReorder = (event: DragEndEvent, exercise: MatchSentencesExercise) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return exercise;
|
||||
|
||||
const oldIndex = exercise.sentences.findIndex(s => s.id === active.id);
|
||||
const newIndex = exercise.sentences.findIndex(s => s.id === over.id);
|
||||
|
||||
const newSentences = [...exercise.sentences];
|
||||
const [movedItem] = newSentences.splice(oldIndex, 1);
|
||||
newSentences.splice(newIndex, 0, movedItem);
|
||||
|
||||
const minId = getMinExerciseId(exercise);
|
||||
const reorderedSentences = newSentences.map((sentence, index) => ({
|
||||
...sentence,
|
||||
id: (minId + index).toString()
|
||||
}));
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
sentences: reorderedSentences
|
||||
};
|
||||
};
|
||||
|
||||
const handleMultipleChoiceReorder = (event: DragEndEvent, exercise: MultipleChoiceExercise): MultipleChoiceExercise => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return exercise;
|
||||
}
|
||||
|
||||
const oldIndex = exercise.questions.findIndex(
|
||||
question => question.id === active.id
|
||||
);
|
||||
const overIndex = exercise.questions.findIndex(
|
||||
question => question.id === over.id
|
||||
);
|
||||
|
||||
if (oldIndex === -1 || overIndex === -1) {
|
||||
return exercise;
|
||||
}
|
||||
|
||||
const reorderedQuestions = arrayMove(exercise.questions, oldIndex, overIndex);
|
||||
const minId = Math.min(...exercise.questions.map(q => parseInt(q.id)));
|
||||
const updatedQuestions = reorderedQuestions.map((question, index) => ({
|
||||
...question,
|
||||
id: (minId + index).toString()
|
||||
}));
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
questions: updatedQuestions
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
handleWriteBlanksReorder,
|
||||
handleTrueFalseReorder,
|
||||
handleMatchSentencesReorder,
|
||||
handleMultipleChoiceReorder,
|
||||
};
|
||||
34
src/stores/examEditor/reorder/minIds.ts
Normal file
34
src/stores/examEditor/reorder/minIds.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { FillBlanksExercise, MatchSentencesExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam";
|
||||
|
||||
const getMinFillBlanksId = (exercise: FillBlanksExercise): number => {
|
||||
return Math.min(...exercise.solutions.map(s => parseInt(s.id)));
|
||||
};
|
||||
|
||||
const getMinWriteBlanksId = (exercise: WriteBlanksExercise): number => {
|
||||
return Math.min(...exercise.solutions.map(s => parseInt(s.id)));
|
||||
};
|
||||
|
||||
const getMinTrueFalseId = (exercise: TrueFalseExercise): number => {
|
||||
return Math.min(...exercise.questions.map(q => parseInt(q.id)));
|
||||
};
|
||||
|
||||
const getMinMatchSentencesId = (exercise: MatchSentencesExercise): number => {
|
||||
return Math.min(...exercise.sentences.map(s => parseInt(s.id)));
|
||||
};
|
||||
|
||||
const getMinExerciseId = (exercise: FillBlanksExercise | WriteBlanksExercise | TrueFalseExercise | MatchSentencesExercise): number => {
|
||||
switch (exercise.type) {
|
||||
case 'fillBlanks':
|
||||
return getMinFillBlanksId(exercise);
|
||||
case 'writeBlanks':
|
||||
return getMinWriteBlanksId(exercise);
|
||||
case 'trueFalse':
|
||||
return getMinTrueFalseId(exercise);
|
||||
case 'matchSentences':
|
||||
return getMinMatchSentencesId(exercise);
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
export default getMinExerciseId;
|
||||
4
src/stores/examEditor/reorder/types.ts
Normal file
4
src/stores/examEditor/reorder/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default interface ReorderResult<T> {
|
||||
exercise: T;
|
||||
lastId: number;
|
||||
}
|
||||
62
src/stores/examEditor/sections.ts
Normal file
62
src/stores/examEditor/sections.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
export const writingTask = (task: number) => ({
|
||||
id: v4(),
|
||||
type: "writing",
|
||||
prefix: `You should spend about ${task == 1 ? "20" : "40"} minutes on this task.`,
|
||||
prompt: "",
|
||||
userSolutions: [],
|
||||
suffix: `You should write at least ${task == 1 ? "150" : "250"} words.`,
|
||||
wordCounter: {
|
||||
limit: task == 1 ? 150 : 250,
|
||||
type: "min",
|
||||
}
|
||||
} as WritingExercise);
|
||||
|
||||
export const readingPart = (task: number) => {
|
||||
return {
|
||||
text: {
|
||||
title: "",
|
||||
content: ""
|
||||
},
|
||||
exercises: []
|
||||
} as ReadingPart;
|
||||
};
|
||||
|
||||
|
||||
export const listeningSection = (task: number) => {
|
||||
return {
|
||||
audio: undefined,
|
||||
script: undefined,
|
||||
exercises: [],
|
||||
} as ListeningPart;
|
||||
};
|
||||
|
||||
export const speakingTask = (task: number) => {
|
||||
if (task === 3) {
|
||||
return {
|
||||
id: v4(),
|
||||
type: "interactiveSpeaking",
|
||||
title: "",
|
||||
text: "",
|
||||
prompts: [],
|
||||
userSolutions: []
|
||||
} as InteractiveSpeakingExercise;
|
||||
}
|
||||
return {
|
||||
id: v4(),
|
||||
type: "speaking",
|
||||
title: "",
|
||||
text: "",
|
||||
prompts: [],
|
||||
video_url: "",
|
||||
userSolutions: [],
|
||||
} as SpeakingExercise;
|
||||
}
|
||||
|
||||
export const levelPart = (task : number) => {
|
||||
return {
|
||||
exercises: []
|
||||
} as LevelPart;
|
||||
}
|
||||
70
src/stores/examEditor/types.ts
Normal file
70
src/stores/examEditor/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
||||
import { Module } from "@/interfaces";
|
||||
import Option from "@/interfaces/option";
|
||||
import { ExerciseConfig } from "@/components/ExamEditor/Shared/ExercisePicker/ExerciseWizard";
|
||||
|
||||
export interface GeneratedExercises {
|
||||
exercises: Record<string, string>[];
|
||||
sectionId: number;
|
||||
module: string;
|
||||
}
|
||||
|
||||
export interface SectionSettings {
|
||||
category: string;
|
||||
introOption: Option;
|
||||
customIntro: string;
|
||||
currentIntro: string | undefined;
|
||||
isCategoryDropdownOpen: boolean;
|
||||
isIntroDropdownOpen: boolean;
|
||||
isExerciseDropdownOpen: boolean;
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export interface SpeakingSectionSettings extends SectionSettings {
|
||||
secondTopic?: string;
|
||||
}
|
||||
|
||||
export interface ReadingSectionSettings extends SectionSettings {
|
||||
isPassageOpen: boolean;
|
||||
}
|
||||
|
||||
export interface ListeningSectionSettings extends SectionSettings {
|
||||
isAudioContextOpen: boolean;
|
||||
}
|
||||
|
||||
export type Generating = "context" | "exercises" | "media" | undefined;
|
||||
export type Section = LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise;
|
||||
export type ExamPart = ListeningPart | ReadingPart | LevelPart;
|
||||
|
||||
export interface SectionState {
|
||||
sectionId: number;
|
||||
settings: SectionSettings | ReadingSectionSettings;
|
||||
state: Section;
|
||||
expandedSubSections: number[];
|
||||
generating: Generating;
|
||||
genResult: Record<string, any>[] | undefined;
|
||||
exercisePickerState: ExerciseConfig[];
|
||||
selectedExercises: string[];
|
||||
}
|
||||
|
||||
export interface ModuleState {
|
||||
examLabel: string;
|
||||
sections: SectionState[];
|
||||
minTimer: number;
|
||||
difficulty: Difficulty;
|
||||
isPrivate: boolean;
|
||||
sectionLabels: {id: number; label: string;}[];
|
||||
expandedSections: number[];
|
||||
focusedSection: number;
|
||||
importModule: boolean;
|
||||
importing: boolean;
|
||||
edit: number[];
|
||||
}
|
||||
|
||||
export default interface ExamEditorStore {
|
||||
title: string;
|
||||
currentModule: Module;
|
||||
modules: {
|
||||
[K in Module]: ModuleState
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import {Exam, ShuffleMap, Shuffles, UserSolution} from "@/interfaces/exam";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {create} from "zustand";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Exam, Shuffles, UserSolution } from "@/interfaces/exam";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
|
||||
export interface ExamState {
|
||||
exams: Exam[];
|
||||
@@ -67,25 +69,59 @@ export const initialState: ExamState = {
|
||||
const useExamStore = create<ExamState & ExamFunctions>((set) => ({
|
||||
...initialState,
|
||||
|
||||
setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({userSolutions})),
|
||||
setExams: (exams: Exam[]) => set(() => ({exams})),
|
||||
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
|
||||
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
|
||||
setHasExamEnded: (hasExamEnded: boolean) => set(() => ({hasExamEnded})),
|
||||
setAssignment: (assignment?: Assignment) => set(() => ({assignment})),
|
||||
setTimeSpent: (timeSpent) => set(() => ({timeSpent})),
|
||||
setSessionId: (sessionId: string) => set(() => ({sessionId})),
|
||||
setExam: (exam?: Exam) => set(() => ({exam})),
|
||||
setModuleIndex: (moduleIndex: number) => set(() => ({moduleIndex})),
|
||||
setPartIndex: (partIndex: number) => set(() => ({partIndex})),
|
||||
setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})),
|
||||
setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})),
|
||||
setInactivity: (inactivity: number) => set(() => ({inactivity})),
|
||||
setShuffles: (shuffles: Shuffles[]) => set(() => ({shuffles})),
|
||||
setBgColor: (bgColor) => set(() => ({bgColor})),
|
||||
setCurrentSolution: (currentSolution: UserSolution | undefined) => set(() => ({currentSolution})),
|
||||
setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({ userSolutions })),
|
||||
setExams: (exams: Exam[]) => set(() => ({ exams })),
|
||||
setShowSolutions: (showSolutions: boolean) => set(() => ({ showSolutions })),
|
||||
setSelectedModules: (modules: Module[]) => set(() => ({ selectedModules: modules })),
|
||||
setHasExamEnded: (hasExamEnded: boolean) => set(() => ({ hasExamEnded })),
|
||||
setAssignment: (assignment?: Assignment) => set(() => ({ assignment })),
|
||||
setTimeSpent: (timeSpent) => set(() => ({ timeSpent })),
|
||||
setSessionId: (sessionId: string) => set(() => ({ sessionId })),
|
||||
setExam: (exam?: Exam) => set(() => ({ exam })),
|
||||
setModuleIndex: (moduleIndex: number) => set(() => ({ moduleIndex })),
|
||||
setPartIndex: (partIndex: number) => set(() => ({ partIndex })),
|
||||
setExerciseIndex: (exerciseIndex: number) => set(() => ({ exerciseIndex })),
|
||||
setQuestionIndex: (questionIndex: number) => set(() => ({ questionIndex })),
|
||||
setInactivity: (inactivity: number) => set(() => ({ inactivity })),
|
||||
setShuffles: (shuffles: Shuffles[]) => set(() => ({ shuffles })),
|
||||
setBgColor: (bgColor) => set(() => ({ bgColor })),
|
||||
setCurrentSolution: (currentSolution: UserSolution | undefined) => set(() => ({ currentSolution })),
|
||||
|
||||
reset: () => set(() => initialState),
|
||||
}));
|
||||
|
||||
export default useExamStore;
|
||||
|
||||
|
||||
export const usePersistentExamStore = create<ExamState & ExamFunctions>()(
|
||||
persist(
|
||||
immer((set) => ({
|
||||
...initialState,
|
||||
setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({ userSolutions })),
|
||||
setExams: (exams: Exam[]) => set(() => ({ exams })),
|
||||
setShowSolutions: (showSolutions: boolean) => set(() => ({ showSolutions })),
|
||||
setSelectedModules: (modules: Module[]) => set(() => ({ selectedModules: modules })),
|
||||
setHasExamEnded: (hasExamEnded: boolean) => set(() => ({ hasExamEnded })),
|
||||
setAssignment: (assignment?: Assignment) => set(() => ({ assignment })),
|
||||
setTimeSpent: (timeSpent) => set(() => ({ timeSpent })),
|
||||
setSessionId: (sessionId: string) => set(() => ({ sessionId })),
|
||||
setExam: (exam?: Exam) => set(() => ({ exam })),
|
||||
setModuleIndex: (moduleIndex: number) => set(() => ({ moduleIndex })),
|
||||
setPartIndex: (partIndex: number) => set(() => ({ partIndex })),
|
||||
setExerciseIndex: (exerciseIndex: number) => set(() => ({ exerciseIndex })),
|
||||
setQuestionIndex: (questionIndex: number) => set(() => ({ questionIndex })),
|
||||
setInactivity: (inactivity: number) => set(() => ({ inactivity })),
|
||||
setShuffles: (shuffles: Shuffles[]) => set(() => ({ shuffles })),
|
||||
setBgColor: (bgColor) => set(() => ({ bgColor })),
|
||||
setCurrentSolution: (currentSolution: UserSolution | undefined) => set(() => ({ currentSolution })),
|
||||
|
||||
reset: () => set(() => initialState),
|
||||
})),
|
||||
{
|
||||
name: 'persistent-exam-store',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({ ...state }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user