Files
NextEdu/src/modules/questions/components/create-question-dialog.tsx
SpecialX 4f0ef217a0 refactor(modules): update existing module implementations across attendance, audit, auth, classes, course-plans, exams, files, homework, layout, proctoring, questions, scheduling, textbooks, users
- Update attendance components and data-access for record management

- Update audit log views, filters, and data-access

- Update auth login and register forms

- Update classes actions, components, and data-access (admin, schedule, stats)

- Update course-plans actions, form, list, progress, and schema

- Update exams actions, AI pipeline, preview components, and hooks

- Update files components (icon, list, preview, upload) and data-access

- Update homework assignment form, review view, auto-save hook, and stats-service

- Update layout sidebar, header, and navigation config

- Update proctoring actions, anti-cheat monitor, and data-access

- Update questions actions, components (dialog, actions, columns, filters), and data-access

- Update scheduling actions, auto-scheduler, components, and schema

- Update textbooks constants and text-selection hook

- Update users class-registration, import-dialog, data-access, and user-service
2026-06-23 17:38:56 +08:00

454 lines
17 KiB
TypeScript

"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<typeof QuestionFormSchema>
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<typeof v> => 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<string[]>([])
const { data: knowledgePointOptionsData, loading: isLoadingKnowledgePoints } = useActionQuery(
() => getKnowledgePointOptionsAction(),
{ deps: [open], enabled: open, errorMessage: "Failed to load knowledge points" }
)
const knowledgePointOptions = knowledgePointOptionsData ?? []
const form = useForm<QuestionFormValues>({
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<QuestionFormValues> = 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit Question" : "Create New Question"}</DialogTitle>
<DialogDescription>
{isEdit ? "Update question details." : "Add a new question to the bank. Fill in the details below."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<SelectField
control={form.control}
name="type"
label="Question Type"
placeholder="Select type"
options={[
{ value: "single_choice", label: "Single Choice" },
{ value: "multiple_choice", label: "Multiple Choice" },
{ value: "judgment", label: "True/False" },
{ value: "text", label: "Short Answer" },
{ value: "composite", label: "Composite" },
]}
/>
<SelectField
control={form.control}
name="difficulty"
label="Difficulty (1-5)"
placeholder="Select difficulty"
toSelectValue={(v) => 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"}`,
}))}
/>
</div>
<TextareaField
control={form.control}
name="content"
label="Question Content"
placeholder="Enter the question text here..."
description="Supports basic text. Rich text editor coming soon."
textareaClassName="min-h-[100px]"
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<FormLabel>Knowledge Points</FormLabel>
<span className="text-xs text-muted-foreground">
{selectedKnowledgePointIds.length > 0 ? `${selectedKnowledgePointIds.length} selected` : "Optional"}
</span>
</div>
<Input
placeholder="Search knowledge points..."
value={knowledgePointQuery}
onChange={(e) => setKnowledgePointQuery(e.target.value)}
/>
<div className="rounded-md border">
<ScrollArea className="h-48">
{isLoadingKnowledgePoints ? (
<div className="p-3 text-sm text-muted-foreground">Loading...</div>
) : filteredKnowledgePoints.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">No knowledge points found.</div>
) : (
<div className="space-y-1 p-2">
{filteredKnowledgePoints.map((kp) => {
const labelParts = [
kp.textbookTitle,
kp.chapterTitle,
kp.name,
].filter(Boolean)
const label = labelParts.join(" · ")
return (
<label key={kp.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
<Checkbox
checked={selectedKnowledgePointIds.includes(kp.id)}
onCheckedChange={(checked) => {
const isChecked = checked === true
setSelectedKnowledgePointIds((prev) => {
if (isChecked) {
if (prev.includes(kp.id)) return prev
return [...prev, kp.id]
}
return prev.filter((id) => id !== kp.id)
})
}}
/>
<span className="text-sm">{label}</span>
</label>
)
})}
</div>
)}
</ScrollArea>
</div>
</div>
{(questionType === "single_choice" || questionType === "multiple_choice") && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<FormLabel>Options</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const currentOptions = form.getValues("options") || []
const nextIndex = currentOptions.length
const nextChar = nextIndex < 26 ? String.fromCharCode(65 + nextIndex) : String(nextIndex + 1)
form.setValue("options", [
...currentOptions,
{
label: `Option ${nextChar}`,
value: nextChar,
isCorrect: false,
},
])
}}
>
<Plus className="mr-2 h-3 w-3" /> Add Option
</Button>
</div>
<div className="space-y-2">
{form.watch("options")?.map((option, index) => (
<div key={option.value || `option-${index}`} className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
<GripVertical className="h-4 w-4" />
</div>
<Checkbox
checked={option.isCorrect}
onCheckedChange={(checked) => {
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"
/>
<Input
value={option.label}
onChange={(e) => {
const next = [...(form.getValues("options") || [])]
if (!next[index]) return
next[index].label = e.target.value
form.setValue("options", next)
}}
placeholder={`Option ${index + 1}`}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive/90"
onClick={() => {
const next = [...(form.getValues("options") || [])]
next.splice(index, 1)
form.setValue("options", next)
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (isEdit ? "Updating..." : "Creating...") : (isEdit ? "Update Question" : "Create Question")}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}