"use client" import { useState, useEffect } from "react" import { useForm, type SubmitHandler } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { Plus, Trash2, GripVertical } from "lucide-react" import { useRouter } from "next/navigation" import { Button } from "@/shared/components/ui/button" import { Checkbox } from "@/shared/components/ui/checkbox" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/shared/components/ui/dialog" import { Form, FormLabel, } from "@/shared/components/ui/form" import { Input } from "@/shared/components/ui/input" import { ScrollArea } from "@/shared/components/ui/scroll-area" import { SelectField } from "@/shared/components/form-fields/select-field" import { TextareaField } from "@/shared/components/form-fields/textarea-field" import { BaseQuestionSchema } from "../schema" import { createQuestionAction, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions" import { toast } from "sonner" import { Question } from "../types" import { useActionQuery } from "@/shared/hooks/use-action-query" const QuestionFormSchema = BaseQuestionSchema.extend({ difficulty: z.number().min(1).max(5), content: z.string().min(1, "Question content is required"), options: z .array( z.object({ label: z.string(), value: z.string(), isCorrect: z.boolean().default(false), }) ) .optional(), }) type QuestionFormValues = z.input interface CreateQuestionDialogProps { open: boolean onOpenChange: (open: boolean) => void initialData?: Question | null defaultKnowledgePointIds?: string[] defaultContent?: string defaultType?: "single_choice" | "multiple_choice" | "text" | "judgment" | "composite" } function getInitialTextFromContent(content: unknown) { if (typeof content === "string") return content if (content && typeof content === "object") { const text = (content as { text?: unknown }).text if (typeof text === "string") return text } if (content == null) return "" return JSON.stringify(content) } function getInitialOptionsFromContent(content: unknown) { if (!content || typeof content !== "object") return undefined const rawOptions = (content as { options?: unknown }).options if (!Array.isArray(rawOptions)) return undefined const mapped = rawOptions .map((opt) => { if (!opt || typeof opt !== "object") return null const id = (opt as { id?: unknown; value?: unknown }).id ?? (opt as { value?: unknown }).value const text = (opt as { text?: unknown; label?: unknown }).text ?? (opt as { label?: unknown }).label const isCorrect = (opt as { isCorrect?: unknown }).isCorrect return { value: typeof id === "string" ? id : "", label: typeof text === "string" ? text : "", isCorrect: typeof isCorrect === "boolean" ? isCorrect : false, } }) .filter((v): v is NonNullable => Boolean(v && v.value && v.label)) return mapped.length > 0 ? mapped : undefined } export function CreateQuestionDialog({ open, onOpenChange, initialData, defaultKnowledgePointIds = [], defaultContent = "", defaultType = "single_choice" }: CreateQuestionDialogProps) { const router = useRouter() const [isPending, setIsPending] = useState(false) const isEdit = !!initialData const [knowledgePointQuery, setKnowledgePointQuery] = useState("") const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState([]) const { data: knowledgePointOptionsData, loading: isLoadingKnowledgePoints } = useActionQuery( () => getKnowledgePointOptionsAction(), { deps: [open], enabled: open, errorMessage: "Failed to load knowledge points" } ) const knowledgePointOptions = knowledgePointOptionsData ?? [] const form = useForm({ resolver: zodResolver(QuestionFormSchema), defaultValues: { type: initialData?.type || defaultType, difficulty: initialData?.difficulty || 1, content: getInitialTextFromContent(initialData?.content) || defaultContent, options: getInitialOptionsFromContent(initialData?.content) ?? [ { label: "Option A", value: "A", isCorrect: true }, { label: "Option B", value: "B", isCorrect: false }, ], }, }) useEffect(() => { if (initialData) { form.reset({ type: initialData.type, difficulty: initialData.difficulty, content: getInitialTextFromContent(initialData.content), options: getInitialOptionsFromContent(initialData.content) ?? [ { label: "Option A", value: "A", isCorrect: true }, { label: "Option B", value: "B", isCorrect: false }, ], }) } else { form.reset({ type: defaultType, difficulty: 1, content: defaultContent, options: [ { label: "Option A", value: "A", isCorrect: true }, { label: "Option B", value: "B", isCorrect: false }, ], }) } }, [initialData, form, open, defaultContent, defaultType]) useEffect(() => { if (!open) return if (initialData) { const nextIds = initialData.knowledgePoints.map((kp) => kp.id) setSelectedKnowledgePointIds((prev) => { if (prev.length === nextIds.length && prev.every((id, idx) => id === nextIds[idx])) { return prev } return nextIds }) return } setSelectedKnowledgePointIds((prev) => { if ( prev.length === defaultKnowledgePointIds.length && prev.every((id, idx) => id === defaultKnowledgePointIds[idx]) ) { return prev } return defaultKnowledgePointIds }) }, [open, initialData, defaultKnowledgePointIds]) const questionType = form.watch("type") const filteredKnowledgePoints = knowledgePointOptions.filter((kp) => { const query = knowledgePointQuery.trim().toLowerCase() if (!query) return true const fullLabel = [ kp.textbookTitle, kp.chapterTitle, kp.name, kp.subject, kp.grade, ] .filter(Boolean) .join(" ") .toLowerCase() return fullLabel.includes(query) }) const buildContent = (data: QuestionFormValues) => { const text = data.content.trim() if (data.type === "single_choice" || data.type === "multiple_choice") { const rawOptions = (data.options ?? []).filter((o) => o.label.trim().length > 0) const base = rawOptions.map((o) => ({ id: o.value, text: o.label.trim(), isCorrect: o.isCorrect, })) if (base.length === 0) return { text } if (data.type === "single_choice") { let selectedIndex = base.findIndex((o) => o.isCorrect) if (selectedIndex === -1) selectedIndex = 0 return { text, options: base.map((o, idx) => ({ ...o, isCorrect: idx === selectedIndex })), } } const hasCorrect = base.some((o) => o.isCorrect) const options = hasCorrect ? base : [{ ...base[0], isCorrect: true }, ...base.slice(1)] return { text, options } } return { text } } const onSubmit: SubmitHandler = async (data) => { setIsPending(true) try { if (isEdit && !initialData?.id) { toast.error("Missing question id") return } const payload = { ...(isEdit && initialData ? { id: initialData.id } : {}), type: data.type, difficulty: data.difficulty, content: buildContent(data), knowledgePointIds: selectedKnowledgePointIds, } const fd = new FormData() fd.set("json", JSON.stringify(payload)) const res = isEdit ? await updateQuestionAction(undefined, fd) : await createQuestionAction(undefined, fd) if (res.success) { toast.success(isEdit ? "Updated question" : "Created question") onOpenChange(false) router.refresh() if (!isEdit) { form.reset() } } else { toast.error(res.message || "Operation failed") } } catch (e) { console.error("Failed to submit question", e) toast.error("Unexpected error") } finally { setIsPending(false) } } return ( {isEdit ? "Edit Question" : "Create New Question"} {isEdit ? "Update question details." : "Add a new question to the bank. Fill in the details below."}
String(v)} fromSelectValue={(val) => { const n = parseInt(val, 10) return Number.isFinite(n) ? n : 1 }} options={[1, 2, 3, 4, 5].map((level) => ({ value: String(level), label: `${level} - ${level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}`, }))} />
Knowledge Points {selectedKnowledgePointIds.length > 0 ? `${selectedKnowledgePointIds.length} selected` : "Optional"}
setKnowledgePointQuery(e.target.value)} />
{isLoadingKnowledgePoints ? (
Loading...
) : filteredKnowledgePoints.length === 0 ? (
No knowledge points found.
) : (
{filteredKnowledgePoints.map((kp) => { const labelParts = [ kp.textbookTitle, kp.chapterTitle, kp.name, ].filter(Boolean) const label = labelParts.join(" ยท ") return ( ) })}
)}
{(questionType === "single_choice" || questionType === "multiple_choice") && (
Options
{form.watch("options")?.map((option, index) => (
{ const next = [...(form.getValues("options") || [])] if (!next[index]) return const isChecked = checked === true if (questionType === "single_choice" && isChecked) { for (let i = 0; i < next.length; i++) next[i].isCorrect = i === index } else { next[index].isCorrect = isChecked } form.setValue("options", next) }} aria-label="Mark correct" /> { const next = [...(form.getValues("options") || [])] if (!next[index]) return next[index].label = e.target.value form.setValue("options", next) }} placeholder={`Option ${index + 1}`} />
))}
)}
) }