diff --git a/src/components/content-management/PracticeQuestionEditorFields.tsx b/src/components/content-management/PracticeQuestionEditorFields.tsx index 3635961..1e0bc35 100644 --- a/src/components/content-management/PracticeQuestionEditorFields.tsx +++ b/src/components/content-management/PracticeQuestionEditorFields.tsx @@ -9,7 +9,13 @@ import { Check, Image as ImageIcon, Mic, Plus, Upload, X } from "lucide-react" import { toast } from "sonner" import { resolveMediaPreviewUrl } from "../../lib/practiceMedia" import { uploadAudioFile, uploadImageFile } from "../../api/files.api" +import { + getQuestionTypeDefinitionById, + getQuestionTypeDefinitions, +} from "../../api/questionTypeDefinitions.api" +import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types" import { Input } from "../ui/input" +import { Textarea } from "../ui/textarea" import { Select } from "../ui/select" import { Button } from "../ui/button" import { SpinnerIcon } from "../ui/spinner-icon" @@ -17,7 +23,7 @@ import { cn } from "../../lib/utils" import { ResolvedAudio } from "../media/ResolvedAudio" import { ResolvedImage } from "../media/ResolvedImage" -export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" +export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC" export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD" export interface PracticeQuestionOptionDraft { @@ -32,6 +38,13 @@ const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "we const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024 const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "webp", "gif"]) +export interface PracticeQuestionDynamicRow { + id: string + kind: string + label?: string + required?: boolean +} + export interface PracticeQuestionEditorValue { questionText: string questionType: PracticeQuestionEditorType @@ -46,6 +59,12 @@ export interface PracticeQuestionEditorValue { shortAnswer: string /** Stored URL or object key; same semantics as Speaking practice editor */ imageUrl: string + /** When `questionType` is DYNAMIC — definition used to shape `dynamic_payload` */ + questionTypeDefinitionId: number | null + dynamicStimulusRows: PracticeQuestionDynamicRow[] + dynamicResponseRows: PracticeQuestionDynamicRow[] + /** Keys `stimulus:${elementId}` and `response:${elementId}` (ids from the type definition schema) */ + dynamicFieldValues: Record } export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue { @@ -67,6 +86,10 @@ export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue audioCorrectAnswerText: "", shortAnswer: "", imageUrl: "", + questionTypeDefinitionId: null, + dynamicStimulusRows: [], + dynamicResponseRows: [], + dynamicFieldValues: {}, } } @@ -84,6 +107,9 @@ function defaultOptionsForType( previousType: PracticeQuestionEditorType, current: PracticeQuestionOptionDraft[], ): PracticeQuestionOptionDraft[] { + if (type === "DYNAMIC") { + return current + } if (type === "TRUE_FALSE") { if (previousType === "TRUE_FALSE" && current.length >= 2) { return current.map((o, i) => ({ @@ -146,6 +172,18 @@ export function PracticeQuestionEditorFields({ const setType = (questionType: PracticeQuestionEditorType) => { const options = defaultOptionsForType(questionType, value.questionType, value.options) + if (questionType === "DYNAMIC" || value.questionType === "DYNAMIC") { + onChange({ + ...value, + questionType, + options, + questionTypeDefinitionId: null, + dynamicStimulusRows: [], + dynamicResponseRows: [], + dynamicFieldValues: {}, + }) + return + } onChange({ ...value, questionType, options }) } @@ -586,6 +624,92 @@ export function PracticeQuestionEditorFields({ const controlsDisabled = mediaBusy + const [typeDefinitions, setTypeDefinitions] = useState([]) + const [definitionsLoading, setDefinitionsLoading] = useState(false) + const [definitionDetailLoading, setDefinitionDetailLoading] = useState(false) + + useEffect(() => { + if (value.questionType !== "DYNAMIC") return + let cancelled = false + setDefinitionsLoading(true) + ;(async () => { + try { + const rows = await getQuestionTypeDefinitions({ include_system: true }) + if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : []) + } catch { + if (!cancelled) setTypeDefinitions([]) + } finally { + if (!cancelled) setDefinitionsLoading(false) + } + })() + return () => { + cancelled = true + } + }, [value.questionType]) + + const handleDynamicDefinitionChange = async (rawId: string) => { + if (!rawId) { + onChange({ + ...value, + questionTypeDefinitionId: null, + dynamicStimulusRows: [], + dynamicResponseRows: [], + dynamicFieldValues: {}, + }) + return + } + const id = Number(rawId) + if (!Number.isFinite(id) || id <= 0) return + setDefinitionDetailLoading(true) + try { + const def = await getQuestionTypeDefinitionById(id) + if (!def) { + toast.error("Definition not found") + return + } + const fieldValues: Record = { ...value.dynamicFieldValues } + const dynamicStimulusRows: PracticeQuestionDynamicRow[] = def.stimulus_schema.map((r) => ({ + id: r.id, + kind: r.kind, + label: r.label, + required: r.required, + })) + const dynamicResponseRows: PracticeQuestionDynamicRow[] = def.response_schema.map((r) => ({ + id: r.id, + kind: r.kind, + label: r.label, + required: r.required, + })) + for (const r of dynamicStimulusRows) { + const k = `stimulus:${r.id}` + if (fieldValues[k] === undefined) fieldValues[k] = "" + } + for (const r of dynamicResponseRows) { + const k = `response:${r.id}` + if (fieldValues[k] === undefined) fieldValues[k] = "" + } + onChange({ + ...value, + questionTypeDefinitionId: id, + dynamicStimulusRows, + dynamicResponseRows, + dynamicFieldValues: fieldValues, + }) + } catch (e) { + console.error(e) + toast.error("Failed to load definition details") + } finally { + setDefinitionDetailLoading(false) + } + } + + const setDynamicField = (key: string, next: string) => { + onChange({ + ...value, + dynamicFieldValues: { ...value.dynamicFieldValues, [key]: next }, + }) + } + return ( <>
@@ -615,6 +739,7 @@ export function PracticeQuestionEditorFields({ +
@@ -644,6 +769,89 @@ export function PracticeQuestionEditorFields({
+ {value.questionType === "DYNAMIC" && ( +
+

+ Pick a question type definition, then fill each stimulus/response slot. Element{" "} + id and{" "} + kind must match the definition schema. Use JSON + for object values (e.g. {"{\"placeholder\":\"Type here\"}"}). +

+
+ + +
+ {definitionDetailLoading ? ( +

Loading schema…

+ ) : null} + {value.dynamicStimulusRows.length > 0 ? ( +
+

Stimulus

+ {value.dynamicStimulusRows.map((row) => ( +
+
+ {row.label || row.id} + + {row.id} · {row.kind} + {row.required ? * : null} + +
+