Exam generation rework, batch user tables, fastapi endpoint switch
This commit is contained in:
318
src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx
Normal file
318
src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { WriteBlanksExercise, ReadingPart } from "@/interfaces/exam";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { DragEndEvent } from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { useState, useEffect } 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";
|
||||
|
||||
|
||||
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
||||
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<AlertItem[]>([]);
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [editingPrompt, setEditingPrompt] = useState(false);
|
||||
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
|
||||
|
||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||
sectionId,
|
||||
mode: "edit",
|
||||
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 } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal(exercise);
|
||||
setParsedQuestions([]);
|
||||
},
|
||||
onMode: () => {
|
||||
const newSection = {
|
||||
...section,
|
||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||
};
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||
},
|
||||
});
|
||||
|
||||
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, {
|
||||
id: newId,
|
||||
parts: parseLine(newLine),
|
||||
editingPlaceholders: true
|
||||
}];
|
||||
|
||||
const newText = updatedQuestions
|
||||
.map(q => reconstructLine(q.parts))
|
||||
.join('\\n') + '\\n';
|
||||
|
||||
const updatedSolutions = [...local.solutions, {
|
||||
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 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Header
|
||||
title="Write Blanks: Form Exercise"
|
||||
description="Edit questions and their solutions"
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
handleDiscard={handleDiscard}
|
||||
modeHandle={modeHandle}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="flex justify-between items-start gap-4 mb-6">
|
||||
{editingPrompt ? (
|
||||
<AutoExpandingTextArea
|
||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
||||
value={local.prompt}
|
||||
onChange={(text) => updateLocal({ ...local, prompt: text })}
|
||||
onBlur={() => setEditingPrompt(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions:</h3>
|
||||
<p className="text-gray-600">{local.prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditingPrompt(!editingPrompt)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{editingPrompt ?
|
||||
<MdEditOff size={20} className="text-gray-500" /> :
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-800">Maximum words per solution:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={local.maxWords}
|
||||
onChange={(e) => updateLocal({ ...local, maxWords: parseInt(e.target.value) })}
|
||||
className="w-20 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
min="1"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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;
|
||||
Reference in New Issue
Block a user