Exam generation rework, batch user tables, fastapi endpoint switch
This commit is contained in:
341
src/components/ExamEditor/Exercises/WriteBlanks/index.tsx
Normal file
341
src/components/ExamEditor/Exercises/WriteBlanks/index.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
MdAdd,
|
||||
MdEdit,
|
||||
MdEditOff,
|
||||
MdDelete,
|
||||
} from 'react-icons/md';
|
||||
import QuestionsList from '../Shared/QuestionsList';
|
||||
import SortableQuestion from '../Shared/SortableQuestion';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import Header from '../../Shared/Header';
|
||||
import clsx from 'clsx';
|
||||
import Alert, { AlertItem } from '../Shared/Alert';
|
||||
import AutoExpandingTextArea from '@/components/Low/AutoExpandingTextarea';
|
||||
import { ReadingPart, WriteBlanksExercise } from '@/interfaces/exam';
|
||||
import useExamEditorStore from '@/stores/examEditor';
|
||||
import useSectionEdit from '../../Hooks/useSectionEdit';
|
||||
import setEditingAlert from '../Shared/setEditingAlert';
|
||||
import { toast } from 'react-toastify';
|
||||
import { validateEmptySolutions, validateQuestionText, validateWordCount } from './validation';
|
||||
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
|
||||
import { ParsedQuestion, parseText, reconstructText } from './parsing';
|
||||
|
||||
|
||||
const WriteBlanks: 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 [errors, setErrors] = useState<{ [key: string]: string[] }>({});
|
||||
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
|
||||
|
||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||
sectionId,
|
||||
mode: "edit",
|
||||
onSave: () => {
|
||||
const isQuestionTextValid = validateQuestionText(
|
||||
parsedQuestions,
|
||||
setAlerts
|
||||
);
|
||||
|
||||
const isSolutionsValid = validateEmptySolutions(
|
||||
local.solutions,
|
||||
setAlerts
|
||||
);
|
||||
|
||||
if (!isQuestionTextValid || !isSolutionsValid) {
|
||||
toast.error("Please fix the errors before saving!");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
setAlerts([]);
|
||||
//dispatch({ type: 'UPDATE_ROOT', payload: { updates: {globalEdit: globalEdit.filter(id => id !== sectionId)} } });
|
||||
|
||||
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);
|
||||
},
|
||||
onMode: () => {
|
||||
const newSection = {
|
||||
...section,
|
||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||
};
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setParsedQuestions(parseText(local.text));
|
||||
}, [local.text]);
|
||||
|
||||
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 newQuestion = {
|
||||
id: newId,
|
||||
questionText: "New question"
|
||||
};
|
||||
|
||||
const updatedQuestions = [...parsedQuestions, newQuestion];
|
||||
const updatedText = reconstructText(updatedQuestions);
|
||||
|
||||
const updatedSolutions = [...local.solutions, {
|
||||
id: newId,
|
||||
solution: [""]
|
||||
}];
|
||||
|
||||
updateLocal({
|
||||
...local,
|
||||
text: updatedText,
|
||||
solutions: updatedSolutions
|
||||
});
|
||||
};
|
||||
|
||||
const updateQuestionText = (id: string, newText: string) => {
|
||||
const updatedQuestions = parsedQuestions.map(q =>
|
||||
q.id === id ? { ...q, questionText: newText } : q
|
||||
);
|
||||
const updatedText = reconstructText(updatedQuestions);
|
||||
updateLocal({ ...local, text: updatedText });
|
||||
};
|
||||
|
||||
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 updatedText = reconstructText(updatedQuestions);
|
||||
const updatedSolutions = local.solutions.filter(s => s.id !== id);
|
||||
updateLocal({
|
||||
...local,
|
||||
text: updatedText,
|
||||
solutions: updatedSolutions
|
||||
});
|
||||
};
|
||||
|
||||
const addSolutionToQuestion = (questionId: string) => {
|
||||
const newSolutions = [...local.solutions];
|
||||
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
|
||||
|
||||
if (questionIndex !== -1) {
|
||||
newSolutions[questionIndex] = {
|
||||
...newSolutions[questionIndex],
|
||||
solution: [...newSolutions[questionIndex].solution, ""]
|
||||
};
|
||||
updateLocal({ ...local, solutions: newSolutions });
|
||||
}
|
||||
};
|
||||
|
||||
const updateSolution = (questionId: string, solutionIndex: number, value: string) => {
|
||||
const wordCount = value.trim().split(/\s+/).length;
|
||||
|
||||
const newSolutions = [...local.solutions];
|
||||
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
|
||||
|
||||
if (questionIndex !== -1) {
|
||||
const newSolutionArray = [...newSolutions[questionIndex].solution];
|
||||
newSolutionArray[solutionIndex] = value;
|
||||
newSolutions[questionIndex] = {
|
||||
...newSolutions[questionIndex],
|
||||
solution: newSolutionArray
|
||||
};
|
||||
updateLocal({ ...local, solutions: newSolutions });
|
||||
}
|
||||
|
||||
if (wordCount > local.maxWords) {
|
||||
setAlerts(prev => {
|
||||
const filteredAlerts = prev.filter(alert => alert.tag !== `solution-error-${questionId}-${solutionIndex}`);
|
||||
return [...filteredAlerts, {
|
||||
variant: "error",
|
||||
tag: `solution-error-${questionId}-${solutionIndex}`,
|
||||
description: `Alternative solution ${solutionIndex + 1} for question ${questionId} exceeds maximum of ${local.maxWords} words (current: ${wordCount} words)`
|
||||
}];
|
||||
});
|
||||
} else {
|
||||
setAlerts(prev => prev.filter(alert => alert.tag !== `solution-error-${questionId}-${solutionIndex}`));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSolution = (questionId: string, solutionIndex: number) => {
|
||||
const newSolutions = [...local.solutions];
|
||||
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
|
||||
|
||||
if (questionIndex !== -1) {
|
||||
if (newSolutions[questionIndex].solution.length == 1) {
|
||||
toast.error("There needs to be at least one solution!");
|
||||
return;
|
||||
}
|
||||
const newSolutionArray = newSolutions[questionIndex].solution.filter((_, i) => i !== solutionIndex);
|
||||
newSolutions[questionIndex] = {
|
||||
...newSolutions[questionIndex],
|
||||
solution: newSolutionArray
|
||||
};
|
||||
updateLocal({ ...local, solutions: newSolutions });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setEditing(true);
|
||||
setLocal(handleWriteBlanksReorder(event, local));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setEditingAlert(editing, setAlerts);
|
||||
}, [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
validateWordCount(local.solutions, local.maxWords, setAlerts);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [local.maxWords, local.solutions]);
|
||||
|
||||
useEffect(() => {
|
||||
validateQuestionText(parsedQuestions, setAlerts);
|
||||
}, [parsedQuestions]);
|
||||
|
||||
useEffect(() => {
|
||||
validateEmptySolutions(local.solutions, setAlerts);
|
||||
}, [local.solutions]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Header
|
||||
title="Write Blanks 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 displayed to the student:</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={handleDragEnd}
|
||||
>
|
||||
{parsedQuestions.map((question) => {
|
||||
const questionSolutions = local.solutions.find(s => s.id === question.id)?.solution || [];
|
||||
return (
|
||||
<SortableQuestion
|
||||
key={question.id}
|
||||
id={question.id}
|
||||
index={parseInt(question.id)}
|
||||
deleteQuestion={() => deleteQuestion(question.id)}
|
||||
variant="writeBlanks"
|
||||
questionText={question.questionText}
|
||||
onQuestionChange={(value) => updateQuestionText(question.id, value)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{questionSolutions.map((solution, solutionIndex) => (
|
||||
<div key={solutionIndex} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={solution}
|
||||
onChange={(e) => updateSolution(question.id, solutionIndex, e.target.value)}
|
||||
className={clsx(
|
||||
"flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none",
|
||||
errors[question.id]?.[solutionIndex] && "border-red-500"
|
||||
)}
|
||||
placeholder="Enter solution..."
|
||||
/>
|
||||
<button
|
||||
onClick={() => deleteSolution(question.id, solutionIndex)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<MdDelete size={20} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => addSolutionToQuestion(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>
|
||||
</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 WriteBlanks;
|
||||
Reference in New Issue
Block a user