306 lines
13 KiB
TypeScript
306 lines
13 KiB
TypeScript
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { WriteBlanksExercise, ReadingPart, Difficulty } from "@/interfaces/exam";
|
|
import useExamEditorStore from "@/stores/examEditor";
|
|
import { DragEndEvent } from "@dnd-kit/core";
|
|
import { arrayMove } from "@dnd-kit/sortable";
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { MdEditOff, MdEdit, MdDelete, MdAdd } from "react-icons/md";
|
|
import { toast } from "react-toastify";
|
|
import useSectionEdit from "../../Hooks/useSectionEdit";
|
|
import Alert, { AlertItem } from "../Shared/Alert";
|
|
import QuestionsList from "../Shared/QuestionsList";
|
|
import setEditingAlert from "../Shared/setEditingAlert";
|
|
import SortableQuestion from "../Shared/SortableQuestion";
|
|
import { ParsedQuestion, parseLine, reconstructLine } from "./parsing";
|
|
import { validateQuestions, validateEmptySolutions, validateWordCount } from "./validation";
|
|
import Header from "../../Shared/Header";
|
|
import BlanksFormEditor from "./BlanksFormEditor";
|
|
import PromptEdit from "../Shared/PromptEdit";
|
|
import { uuidv4 } from "@firebase/util";
|
|
|
|
|
|
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
|
const { currentModule, dispatch } = useExamEditorStore();
|
|
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
|
|
const { state } = useExamEditorStore(
|
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
|
);
|
|
|
|
const section = state as ReadingPart;
|
|
|
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
|
const [local, setLocal] = useState(exercise);
|
|
const [editingPrompt, setEditingPrompt] = useState(false);
|
|
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
|
|
|
|
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
|
|
sectionId,
|
|
onSave: () => {
|
|
const isQuestionsValid = validateQuestions(parsedQuestions, setAlerts);
|
|
const isSolutionsValid = validateEmptySolutions(local.solutions, setAlerts);
|
|
|
|
if (!isQuestionsValid || !isSolutionsValid) {
|
|
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, module: currentModule } });
|
|
},
|
|
onDiscard: () => {
|
|
setLocal(exercise);
|
|
setParsedQuestions([]);
|
|
},
|
|
onDelete: () => {
|
|
const newSection = {
|
|
...section,
|
|
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
|
};
|
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
|
|
},
|
|
onPractice: () => {
|
|
const updatedExercise = {
|
|
...local,
|
|
isPractice: !local.isPractice
|
|
};
|
|
const newState = { ...section };
|
|
newState.exercises = newState.exercises.map((ex) =>
|
|
ex.id === exercise.id ? updatedExercise : ex
|
|
);
|
|
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
|
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
const questions = local.text.split('\\n')
|
|
.filter(line => line.trim())
|
|
.map(line => {
|
|
const match = line.match(/{{(\d+)}}/);
|
|
return {
|
|
id: match ? match[1] : `unknown-${Date.now()}`,
|
|
parts: parseLine(line),
|
|
editingPlaceholders: true
|
|
};
|
|
});
|
|
setParsedQuestions(questions);
|
|
}, [local.text]);
|
|
|
|
useEffect(() => {
|
|
setEditingAlert(editing, setAlerts);
|
|
}, [editing]);
|
|
|
|
useEffect(() => {
|
|
validateWordCount(local.solutions, local.maxWords, setAlerts);
|
|
}, [local.maxWords, local.solutions]);
|
|
|
|
const updateLocal = (exercise: WriteBlanksExercise) => {
|
|
setLocal(exercise);
|
|
setEditing(true);
|
|
};
|
|
|
|
const addQuestion = () => {
|
|
const existingIds = parsedQuestions.map(q => parseInt(q.id));
|
|
const newId = (Math.max(...existingIds, 0) + 1).toString();
|
|
|
|
const newLine = `New question with blank {{${newId}}}`;
|
|
const updatedQuestions = [...parsedQuestions, {
|
|
uuid: uuidv4(),
|
|
id: newId,
|
|
parts: parseLine(newLine),
|
|
editingPlaceholders: true
|
|
}];
|
|
|
|
const newText = updatedQuestions
|
|
.map(q => reconstructLine(q.parts))
|
|
.join('\\n') + '\\n';
|
|
|
|
const updatedSolutions = [...local.solutions, {
|
|
uuid: uuidv4(),
|
|
id: newId,
|
|
solution: [""]
|
|
}];
|
|
|
|
updateLocal({
|
|
...local,
|
|
text: newText,
|
|
solutions: updatedSolutions
|
|
});
|
|
};
|
|
|
|
const deleteQuestion = (id: string) => {
|
|
if (parsedQuestions.length === 1) {
|
|
toast.error("There needs to be at least one question!");
|
|
return;
|
|
}
|
|
const updatedQuestions = parsedQuestions.filter(q => q.id !== id);
|
|
const newText = updatedQuestions
|
|
.map(q => reconstructLine(q.parts))
|
|
.join('\\n') + '\\n';
|
|
|
|
const updatedSolutions = local.solutions.filter(s => s.id !== id);
|
|
updateLocal({
|
|
...local,
|
|
text: newText,
|
|
solutions: updatedSolutions
|
|
});
|
|
};
|
|
|
|
const handleQuestionUpdate = (questionId: string, newText: string) => {
|
|
const updatedQuestions = parsedQuestions.map(q =>
|
|
q.id === questionId ? { ...q, parts: parseLine(newText) } : q
|
|
);
|
|
|
|
const updatedText = updatedQuestions
|
|
.map(q => reconstructLine(q.parts))
|
|
.join('\\n') + '\\n';
|
|
|
|
updateLocal({ ...local, text: updatedText });
|
|
};
|
|
|
|
const addSolution = (questionId: string) => {
|
|
const newSolutions = local.solutions.map(s =>
|
|
s.id === questionId
|
|
? { ...s, solution: [...s.solution, ""] }
|
|
: s
|
|
);
|
|
updateLocal({ ...local, solutions: newSolutions });
|
|
};
|
|
|
|
const updateSolution = (questionId: string, index: number, value: string) => {
|
|
const newSolutions = local.solutions.map(s =>
|
|
s.id === questionId
|
|
? { ...s, solution: s.solution.map((sol, i) => i === index ? value : sol) }
|
|
: s
|
|
);
|
|
updateLocal({ ...local, solutions: newSolutions });
|
|
};
|
|
|
|
const deleteSolution = (questionId: string, index: number) => {
|
|
const solutions = local.solutions.find(s => s.id === questionId);
|
|
if (solutions && solutions.solution.length <= 1) {
|
|
toast.error("Each question must have at least one solution!");
|
|
return;
|
|
}
|
|
const newSolutions = local.solutions.map(s =>
|
|
s.id === questionId
|
|
? { ...s, solution: s.solution.filter((_, i) => i !== index) }
|
|
: s
|
|
);
|
|
updateLocal({ ...local, solutions: newSolutions });
|
|
};
|
|
|
|
const handleQuestionsReorder = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
|
|
const oldIndex = parsedQuestions.findIndex(q => q.id === active.id);
|
|
const newIndex = parsedQuestions.findIndex(q => q.id === over.id);
|
|
|
|
const reorderedQuestions = arrayMove(parsedQuestions, oldIndex, newIndex);
|
|
const newText = reorderedQuestions
|
|
.map(q => reconstructLine(q.parts))
|
|
.join('\\n') + '\\n';
|
|
|
|
updateLocal({ ...local, text: newText });
|
|
};
|
|
|
|
const saveDifficulty = useCallback((diff: Difficulty) => {
|
|
if (!difficulty.includes(diff)) {
|
|
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
|
|
}
|
|
const updatedExercise = { ...exercise, difficulty: diff };
|
|
const newState = { ...section };
|
|
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
|
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
|
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
|
|
|
|
return (
|
|
<div className="p-4">
|
|
<Header
|
|
title="Write Blanks: Form Exercise"
|
|
description="Edit questions and their solutions"
|
|
editing={editing}
|
|
difficulty={exercise.difficulty}
|
|
saveDifficulty={saveDifficulty}
|
|
handleSave={handleSave}
|
|
handleDiscard={handleDiscard}
|
|
handleDelete={handleDelete}
|
|
handlePractice={handlePractice}
|
|
/>
|
|
<div className="space-y-4">
|
|
{alerts.length > 0 && <Alert alerts={alerts} />}
|
|
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({ ...local, prompt })}/>
|
|
<div className="space-y-4">
|
|
<QuestionsList
|
|
ids={parsedQuestions.map(q => q.id)}
|
|
handleDragEnd={handleQuestionsReorder}
|
|
>
|
|
{parsedQuestions.map((question, index) => (
|
|
<SortableQuestion
|
|
key={question.id}
|
|
id={question.id}
|
|
index={index}
|
|
deleteQuestion={() => deleteQuestion(question.id)}
|
|
variant="del-up"
|
|
>
|
|
<div className="space-y-4">
|
|
<BlanksFormEditor
|
|
parts={question.parts}
|
|
onUpdate={(newText) => handleQuestionUpdate(question.id, newText)}
|
|
/>
|
|
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-medium text-gray-700">Solutions:</h4>
|
|
{local.solutions.find(s => s.id === question.id)?.solution.map((solution, index) => (
|
|
<div key={index} className="flex gap-2 items-center">
|
|
<input
|
|
type="text"
|
|
value={solution}
|
|
onChange={(e) => updateSolution(question.id, 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}`}
|
|
/>
|
|
<button
|
|
onClick={() => deleteSolution(question.id, index)}
|
|
className="p-2 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
|
title="Delete solution"
|
|
>
|
|
<MdDelete size={20} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
onClick={() => addSolution(question.id)}
|
|
className="w-full p-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
|
>
|
|
<MdAdd size={18} />
|
|
Add Alternative Solution
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</SortableQuestion>
|
|
))}
|
|
</QuestionsList>
|
|
<button
|
|
onClick={addQuestion}
|
|
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
|
>
|
|
<MdAdd size={18} />
|
|
Add New Question
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default WriteBlanksForm;
|