Exam generation rework, batch user tables, fastapi endpoint switch

This commit is contained in:
Carlos-Mesquita
2024-11-04 23:29:14 +00:00
parent a2bc997e8f
commit 15c9c4d4bd
148 changed files with 11348 additions and 3901 deletions

View 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;