lms/resources/js/pages/dashboard/exams/partials/question-dialog.tsx
2025-12-15 12:26:23 +01:00

272 lines
9.4 KiB
TypeScript

import InputError from '@/components/input-error';
import LoadingButton from '@/components/loading-button';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { onHandleChange } from '@/lib/inertia';
import { useForm } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { Editor } from 'richtor';
import 'richtor/styles';
import FillBlankForm from './question-types/fill-blank-form';
import ListeningForm from './question-types/listening-form';
import MatchingForm from './question-types/matching-form';
import MultipleChoiceForm from './question-types/multiple-choice-form';
import OrderingForm from './question-types/ordering-form';
import ShortAnswerForm from './question-types/short-answer-form';
interface Props {
exam: Exam;
question?: ExamQuestion;
handler: React.ReactNode;
}
type QuestionFormData = {
exam_id: number | string;
title: string;
description: string;
marks: number;
options: {
answers?: string[];
matches?: Array<{ id: number; question: string; answer: string }>;
items?: string[];
correct_order?: number[];
sample_answer?: string;
audio_url?: string;
audio_file?: File;
audio_source?: 'url' | 'upload';
instructions?: string;
[key: string]: any;
};
question_options: Array<{
id?: number;
option_text: string;
is_correct: boolean;
sort: number;
}>;
question_type: ExamQuestionType;
exam_question_id: number | null;
};
const questionTypes: { value: ExamQuestionType; label: string }[] = [
{ value: 'multiple_choice', label: 'Multiple Choice' },
{ value: 'multiple_select', label: 'Multiple Select' },
{ value: 'matching', label: 'Matching' },
{ value: 'fill_blank', label: 'Fill in the Blank' },
{ value: 'ordering', label: 'Ordering' },
{ value: 'short_answer', label: 'Short Answer' },
{ value: 'listening', label: 'Listening' },
];
const QuestionDialog = ({ exam, question, handler }: Props) => {
const [open, setOpen] = useState(false);
const [isSubmit, setIsSubmit] = useState(false);
const [isFileSelected, setIsFileSelected] = useState(false);
const [isFileUploaded, setIsFileUploaded] = useState(false);
const initialFormData: QuestionFormData = {
exam_id: exam.id,
title: question?.title || '',
description: question?.description || '',
marks: question?.marks || 1,
options: question?.options || {},
question_options: (question?.question_options || []).map((opt) => ({
id: opt.id ? Number(opt.id) : undefined,
option_text: opt.option_text,
is_correct: opt.is_correct,
sort: opt.sort,
})),
question_type: question?.question_type || 'multiple_choice',
exam_question_id: question?.id ? Number(question.id) : null,
};
const { data, setData, post, put, errors, processing, reset, clearErrors } = useForm(initialFormData);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// For listening questions with file upload
if (data.question_type === 'listening' && data.options?.audio_source === 'upload') {
// Only wait for upload if it's a NEW question or if a new file was selected
if (!question || isFileSelected) {
if (!isFileUploaded) {
setIsSubmit(true);
return;
}
}
}
// Otherwise submit the form immediately
submitForm();
};
const submitForm = () => {
clearErrors();
if (question) {
put(route('exam-questions.update', question.id), {
preserveScroll: true,
onSuccess: () => {
setOpen(false);
reset();
setIsSubmit(false);
setIsFileUploaded(false);
},
});
} else {
post(route('exam-questions.store'), {
preserveScroll: true,
onSuccess: () => {
setOpen(false);
reset();
setIsSubmit(false);
setIsFileUploaded(false);
},
});
}
};
// Auto-submit after file upload completes
useEffect(() => {
if (isFileUploaded && isSubmit) {
submitForm();
}
}, [isFileUploaded, isSubmit]);
// Reset upload states when dialog opens/closes
useEffect(() => {
if (!open) {
setIsSubmit(false);
setIsFileSelected(false);
setIsFileUploaded(false);
}
}, [open]);
const renderQuestionTypeForm = () => {
const props = {
data,
setData,
errors,
};
switch (data.question_type) {
case 'multiple_choice':
case 'multiple_select':
return <MultipleChoiceForm {...props} isMultipleSelect={data.question_type === 'multiple_select'} />;
case 'matching':
return <MatchingForm {...props} />;
case 'fill_blank':
return <FillBlankForm {...props} />;
case 'ordering':
return <OrderingForm {...props} />;
case 'short_answer':
return <ShortAnswerForm {...props} />;
case 'listening':
return (
<ListeningForm
{...props}
isSubmit={isSubmit}
setIsSubmit={setIsSubmit}
setIsFileSelected={setIsFileSelected}
setIsFileUploaded={setIsFileUploaded}
/>
);
default:
return null;
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>{handler}</DialogTrigger>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{question ? 'Edit Question' : 'Create Question'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<div>
<Label>Question Type *</Label>
<Select
value={data.question_type}
onValueChange={(value: ExamQuestionType) => setData('question_type', value)}
disabled={question ? true : false}
>
<SelectTrigger>
<SelectValue placeholder="Select question type" />
</SelectTrigger>
<SelectContent>
{questionTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.question_type} />
</div>
<div>
<Label>Marks *</Label>
<Input
type="number"
step="0.5"
min="0.5"
name="marks"
value={data.marks.toString()}
onChange={(e) => setData('marks', parseFloat(e.target.value) || 0)}
placeholder="Enter marks"
/>
<InputError message={errors.marks} />
</div>
</div>
<div>
<Label>Question Title *</Label>
<Input name="title" value={data.title} onChange={(e) => onHandleChange(e, setData)} placeholder="Enter question title" />
<InputError message={errors.title} />
</div>
<div>
<Label>Description (Optional)</Label>
<Editor
ssr={true}
output="html"
placeholder={{
paragraph: 'Add additional context or instructions...',
imageCaption: 'Add additional context or instructions...',
}}
contentMinHeight={150}
contentMaxHeight={300}
initialContent={data.description}
onContentChange={(value) =>
setData((prev) => ({
...prev,
description: value as string,
}))
}
/>
<InputError message={errors.description} />
</div>
{renderQuestionTypeForm()}
<div className="flex justify-end gap-3 border-t pt-4">
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={processing || isSubmit}>
Cancel
</Button>
<LoadingButton loading={processing || isSubmit} disabled={processing || isSubmit}>
{question ? 'Update Question' : 'Create Question'}
</LoadingButton>
</div>
</form>
</DialogContent>
</Dialog>
);
};
export default QuestionDialog;