233 lines
10 KiB
TypeScript
233 lines
10 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
MdAdd,
|
|
} from 'react-icons/md';
|
|
import Alert, { AlertItem } from '../Shared/Alert';
|
|
import { Difficulty, ReadingPart, TrueFalseExercise } from '@/interfaces/exam';
|
|
import QuestionsList from '../Shared/QuestionsList';
|
|
import Header from '../../Shared/Header';
|
|
import SortableQuestion from '../Shared/SortableQuestion';
|
|
import clsx from 'clsx';
|
|
import useExamEditorStore from '@/stores/examEditor';
|
|
import useSectionEdit from '../../Hooks/useSectionEdit';
|
|
import { toast } from 'react-toastify';
|
|
import validateTrueFalseQuestions from './validation';
|
|
import setEditingAlert from '../Shared/setEditingAlert';
|
|
import { DragEndEvent } from '@dnd-kit/core';
|
|
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
|
|
import PromptEdit from '../Shared/PromptEdit';
|
|
import { uuidv4 } from '@firebase/util';
|
|
|
|
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
|
const { currentModule, dispatch } = useExamEditorStore();
|
|
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
|
|
const { state } = useExamEditorStore(
|
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
|
);
|
|
|
|
const section = state as ReadingPart;
|
|
|
|
const [local, setLocal] = useState(exercise);
|
|
|
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
|
|
|
const updateLocal = (exercise: TrueFalseExercise) => {
|
|
setLocal(exercise);
|
|
setEditing(true);
|
|
};
|
|
|
|
const updateQuestion = (index: number, field: string, value: string) => {
|
|
const newQuestions = [...local.questions];
|
|
newQuestions[index] = { ...newQuestions[index], [field]: value };
|
|
updateLocal({ ...local, questions: newQuestions });
|
|
};
|
|
|
|
const addQuestion = () => {
|
|
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
|
|
updateLocal({
|
|
...local,
|
|
questions: [
|
|
...local.questions,
|
|
{
|
|
prompt: "",
|
|
solution: undefined,
|
|
uuid: uuidv4(),
|
|
id: newId
|
|
}
|
|
]
|
|
});
|
|
};
|
|
|
|
const deleteQuestion = (index: number) => {
|
|
if (local.questions.length == 1) {
|
|
toast.error("There needs to be at least one question!");
|
|
return;
|
|
}
|
|
|
|
const newQuestions = local.questions.filter((_, i) => i !== index);
|
|
const minId = Math.min(...newQuestions.map(q => parseInt(q.id)));
|
|
|
|
const updatedQuestions = newQuestions.map((question, i) => ({
|
|
...question,
|
|
id: String(minId + i)
|
|
}));
|
|
|
|
updateLocal({ ...local, questions: updatedQuestions });
|
|
};
|
|
|
|
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
|
|
sectionId,
|
|
onSave: () => {
|
|
const isValid = validateTrueFalseQuestions(
|
|
local.questions,
|
|
setAlerts
|
|
);
|
|
|
|
if (!isValid) {
|
|
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: false
|
|
};
|
|
const newState = { ...section };
|
|
newState.exercises = newState.exercises.map((ex) =>
|
|
ex.id === exercise.id ? updatedExercise : ex
|
|
);
|
|
updateLocal({...local, isPractice: !local.isPractice})
|
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
validateTrueFalseQuestions(local.questions, setAlerts);
|
|
}, [local.questions]);
|
|
|
|
useEffect(() => {
|
|
setEditingAlert(editing, setAlerts);
|
|
}, [editing]);
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
setEditing(true);
|
|
setLocal(handleTrueFalseReorder(event, local));
|
|
}
|
|
|
|
const saveDifficulty = useCallback((diff: Difficulty) => {
|
|
if (!difficulty.includes(diff)) {
|
|
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
|
|
}
|
|
const updatedExercise = { ...exercise, difficulty: diff };
|
|
const newState = { ...section };
|
|
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
|
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
|
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
|
|
|
|
return (
|
|
<div className="p-4">
|
|
<Header
|
|
title='True/False/Not Given Exercise'
|
|
description='Edit questions and their solutions'
|
|
editing={editing}
|
|
difficulty={exercise.difficulty}
|
|
saveDifficulty={saveDifficulty}
|
|
handleSave={handleSave}
|
|
handleDelete={handleDelete}
|
|
handleDiscard={handleDiscard}
|
|
handlePractice={handlePractice}
|
|
isEvaluationEnabled={!local.isPractice}
|
|
/>
|
|
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
|
<PromptEdit
|
|
value={local.prompt}
|
|
onChange={(text) => updateLocal({ ...local, prompt: text })}
|
|
/>
|
|
<div className="space-y-4">
|
|
<QuestionsList
|
|
ids={local.questions.map(q => q.id)}
|
|
handleDragEnd={handleDragEnd}
|
|
>
|
|
{local.questions.map((question, index) => (
|
|
<SortableQuestion
|
|
key={question.id}
|
|
id={question.id}
|
|
index={index}
|
|
deleteQuestion={deleteQuestion}
|
|
>
|
|
<>
|
|
<input
|
|
type="text"
|
|
value={question.prompt}
|
|
onChange={(e) => updateQuestion(index, 'prompt', e.target.value)}
|
|
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
placeholder="Enter question..."
|
|
/>
|
|
<div className="flex gap-3">
|
|
{['true', 'false', 'not_given'].map((value) => (
|
|
<label
|
|
key={value}
|
|
className="flex-1 cursor-pointer"
|
|
>
|
|
<div
|
|
className={clsx(
|
|
"p-3 text-center rounded-lg border-2 transition-all flex items-center justify-center gap-2",
|
|
question.solution === value
|
|
? 'border-blue-500 bg-blue-50 text-blue-700'
|
|
: 'border-gray-200 hover:border-gray-300'
|
|
)}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name={`solution-${question.id}`}
|
|
value={value}
|
|
checked={question.solution === value}
|
|
onChange={(e) => updateQuestion(index, 'solution', e.target.value)}
|
|
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 sr-only"
|
|
/>
|
|
<span>
|
|
{value.replace('_', ' ').charAt(0).toUpperCase() + value.slice(1).replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</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>
|
|
);
|
|
};
|
|
|
|
export default TrueFalse;
|