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,160 @@
import { useSensors, useSensor, PointerSensor, KeyboardSensor, DragEndEvent, DndContext, closestCenter } from "@dnd-kit/core";
import { sortableKeyboardCoordinates, arrayMove, SortableContext, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { useState } from "react";
import { BsCursorText } from "react-icons/bs";
import { MdSpaceBar } from "react-icons/md";
import { toast } from "react-toastify";
import { formatDisplayContent, formatStorageContent, PromptPart, reconstructLine } from "./parsing";
import SortableBlank from "./SortableBlank";
import { validatePlaceholders } from "./validation";
interface Props {
parts: PromptPart[];
onUpdate: (newText: string) => void;
}
interface EditingState {
text: string;
isPlaceholderMode: boolean;
}
const BlanksFormEditor: React.FC<Props> = ({ parts, onUpdate }) => {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [editingState, setEditingState] = useState<EditingState>({
text: formatDisplayContent(reconstructLine(parts)),
isPlaceholderMode: true
});
const handleTextChange = (newText: string) => {
const placeholder = parts.find(p => p.isPlaceholder);
if (!placeholder) return;
const displayPlaceholder = formatDisplayContent(placeholder.content);
if (!newText.includes(displayPlaceholder)) {
const placeholderIndex = editingState.text.indexOf(displayPlaceholder);
if (placeholderIndex >= 0) {
const beforePlaceholder = newText.slice(0, Math.min(placeholderIndex, newText.length));
const afterPlaceholder = newText.slice(Math.min(placeholderIndex, newText.length));
newText = beforePlaceholder + displayPlaceholder + afterPlaceholder;
} else {
newText = newText + ' ' + displayPlaceholder;
}
}
setEditingState(prev => ({
...prev,
text: newText
}));
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = parts.findIndex(part => part.id === active.id);
const newIndex = parts.findIndex(part => part.id === over.id);
const newParts = [...parts];
const [movedPart] = newParts.splice(oldIndex, 1);
newParts.splice(newIndex, 0, movedPart);
onUpdate(reconstructLine(newParts));
setEditingState(prev => ({
...prev,
text: formatDisplayContent(reconstructLine(newParts))
}));
};
const toggleEditMode = () => {
setEditingState(prev => ({
...prev,
isPlaceholderMode: !prev.isPlaceholderMode
}));
};
const saveTextChanges = () => {
const placeholderId = parts.find(p => p.isPlaceholder)?.id;
if (!placeholderId) return;
const validation = validatePlaceholders(editingState.text, placeholderId);
if (!validation.isValid) {
toast.error(validation.message);
setEditingState(prev => ({
...prev,
text: formatDisplayContent(reconstructLine(parts))
}));
return;
}
onUpdate(formatStorageContent(editingState.text));
};
return (
<div className="flex flex-row items-center gap-2">
<div className="flex-grow">
{editingState.isPlaceholderMode ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={parts.map(part => part.id)}
strategy={horizontalListSortingStrategy}
>
<div className="flex flex-wrap items-center gap-1 min-h-[40px] p-2 border rounded-lg bg-white">
{parts.map((part) => (
<SortableBlank
key={part.id}
id={part.id}
isPlaceholder={part.isPlaceholder}
>
{part.isPlaceholder ? (
<div className="bg-blue-200 px-2 py-1 rounded cursor-move">
{formatDisplayContent(part.content)}
</div>
) : /^\s+$/.test(part.content) ? (
<div className="px-1 border-l-2 border-r-2 border-transparent">
&nbsp;
</div>
) : (
<div className="px-1">
{part.content}
</div>
)}
</SortableBlank>
))}
</div>
</SortableContext>
</DndContext>
) : (
<input
type="text"
value={editingState.text}
onChange={(e) => handleTextChange(e.target.value)}
onPaste={(e) => e.preventDefault()}
onBlur={saveTextChanges}
className="w-full p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
)}
</div>
<button
className={`p-2 rounded ${editingState.isPlaceholderMode ? 'bg-blue-500 text-white' : 'bg-gray-200'}`}
onClick={toggleEditMode}
title={editingState.isPlaceholderMode ? "Switch to text editing" : "Switch to placeholder editing"}
>
{editingState.isPlaceholderMode ? <BsCursorText size={20} /> : <MdSpaceBar size={20} />}
</button>
</div>
);
};
export default BlanksFormEditor;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface SortableBlankProps {
id: string;
isPlaceholder?: boolean;
children: React.ReactNode;
}
const SortableBlank: React.FC<SortableBlankProps> = ({ id, isPlaceholder, children }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
cursor: isPlaceholder ? 'move' : 'default',
};
const draggableProps = isPlaceholder ? { ...attributes, ...listeners } : {};
return (
<div
ref={setNodeRef}
style={style}
{...draggableProps}
>
{children}
</div>
);
};
export default SortableBlank;

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;

View File

@@ -0,0 +1,79 @@
export interface PromptPart {
id: string;
content: string;
isPlaceholder?: boolean;
}
export interface ParsedQuestion {
id: string;
parts: PromptPart[];
editingPlaceholders: boolean;
}
const parseLine = (line: string): PromptPart[] => {
const parts: PromptPart[] = [];
let lastIndex = 0;
const regex = /{{(\d+)}}/g;
let match;
while ((match = regex.exec(line)) !== null) {
if (match.index > lastIndex) {
const textBefore = line.slice(lastIndex, match.index);
const words = textBefore.split(/(\s+)/).filter(Boolean);
words.forEach(word => {
parts.push({
id: `text-${Date.now()}-${parts.length}`,
content: word
});
});
}
const placeholderId = match[1];
parts.push({
id: placeholderId,
content: match[0],
isPlaceholder: true
});
lastIndex = match.index + match[0].length;
}
if (lastIndex < line.length) {
const textAfter = line.slice(lastIndex);
const words = textAfter.split(/(\s+)/).filter(Boolean);
words.forEach(word => {
parts.push({
id: `text-${Date.now()}-${parts.length}`,
content: word
});
});
}
return parts;
};
const reconstructLine = (parts: PromptPart[]): string => {
const text = parts
.map(part => part.content)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
return text;
};
const formatDisplayContent = (content: string): string => {
return content.replace(/{{(\d+)}}/g, '[$1]');
};
const formatStorageContent = (content: string): string => {
return content.replace(/\[(\d+)\]/g, '{{$1}}');
};
export {
parseLine,
reconstructLine,
formatDisplayContent,
formatStorageContent
}

View File

@@ -0,0 +1,117 @@
import { AlertItem } from "../Shared/Alert";
import { ParsedQuestion, reconstructLine } from "./parsing";
const validatePlaceholders = (text: string, originalId: string): { isValid: boolean; message?: string } => {
const matches = text.match(/\[(\d+)\]/g) || [];
if (matches.length === 0) {
return {
isValid: false,
message: "Each question must have exactly one blank"
};
}
if (matches.length > 1) {
return {
isValid: false,
message: "Only one blank is allowed per question"
};
}
const idMatch = matches[0]?.match(/\[(\d+)\]/);
if (!idMatch || idMatch[1] !== originalId) {
return {
isValid: false,
message: "The blank ID cannot be changed"
};
}
return { isValid: true };
};
const validateQuestions = (
parsedQuestions: ParsedQuestion[],
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
const emptyQuestions = parsedQuestions.filter(q => reconstructLine(q.parts).trim() === '');
if (emptyQuestions.length > 0) {
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-question'));
return [...filteredAlerts, ...emptyQuestions.map(q => ({
variant: "error" as const,
tag: `empty-question-${q.id}`,
description: `Question ${q.id} is empty`
}))];
});
return false;
}
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-question')));
return true;
};
const validateEmptySolutions = (
solutions: Array<{ id: string; solution: string[] }>,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
const questionsWithEmptySolutions = solutions.flatMap(solution =>
solution.solution.map((sol, index) => ({
questionId: solution.id,
solutionIndex: index,
isEmpty: !sol.trim()
})).filter(({ isEmpty }) => isEmpty)
);
if (questionsWithEmptySolutions.length > 0) {
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-solution'));
return [...filteredAlerts, ...questionsWithEmptySolutions.map(({ questionId, solutionIndex }) => ({
variant: "error" as const,
tag: `empty-solution-${questionId}-${solutionIndex}`,
description: `Solution ${solutionIndex + 1} for question ${questionId} cannot be empty`
}))];
});
return false;
}
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-solution')));
return true;
};
const validateWordCount = (
solutions: Array<{ id: string; solution: string[] }>,
maxWords: number,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
let isValid = true;
solutions.forEach((solution) => {
solution.solution.forEach((value, solutionIndex) => {
const wordCount = value.trim().split(/\s+/).length;
if (wordCount > maxWords) {
isValid = false;
setAlerts(prev => {
const filteredAlerts = prev.filter(alert =>
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
);
return [...filteredAlerts, {
variant: "error",
tag: `solution-error-${solution.id}-${solutionIndex}`,
description: `Solution ${solutionIndex + 1} for question ${solution.id} exceeds maximum of ${maxWords} words (current: ${wordCount} words)`
}];
});
} else {
setAlerts(prev =>
prev.filter(alert =>
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
)
);
}
});
});
return isValid;
};
export {
validateQuestions,
validateEmptySolutions,
validateWordCount,
validatePlaceholders
}