Exam generation rework, batch user tables, fastapi endpoint switch
This commit is contained in:
@@ -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">
|
||||
|
||||
</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;
|
||||
@@ -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;
|
||||
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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user