Files
encoach_frontend/src/components/ExamEditor/Exercises/WriteBlanks/index.tsx
Carlos-Mesquita 153d7f5448 isPractice hotfix
2024-11-12 17:53:16 +00:00

355 lines
16 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, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
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, module: currentModule } });
},
onDiscard: () => {
setLocal(exercise);
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !local.isPractice
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
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: Questions"}
description="Edit questions and their solutions"
editing={editing}
handleSave={handleSave}
handleDiscard={handleDiscard}
handleDelete={handleDelete}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
/>
<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;