342 lines
15 KiB
TypeScript
342 lines
15 KiB
TypeScript
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;
|