feat(practices): DYNAMIC questions with schema-driven payload

- Add practiceDynamicQuestionPayload helper to build stimulus/response slots
- Extend PracticeQuestionEditorFields with DYNAMIC type, definition picker, and per-slot values (JSON-capable)
- Wire AddNewPracticePage and AddNewLessonPage createQuestion to send question_type_definition_id and dynamic_payload
- Use lesson/practice save status (DRAFT/PUBLISHED) for created questions instead of always PUBLISHED

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-13 05:11:48 -07:00
parent 9b35a8bf30
commit 77b71abfd8
4 changed files with 402 additions and 18 deletions

View File

@ -9,7 +9,13 @@ import { Check, Image as ImageIcon, Mic, Plus, Upload, X } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia" import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
import { uploadAudioFile, uploadImageFile } from "../../api/files.api" 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 { Input } from "../ui/input"
import { Textarea } from "../ui/textarea"
import { Select } from "../ui/select" import { Select } from "../ui/select"
import { Button } from "../ui/button" import { Button } from "../ui/button"
import { SpinnerIcon } from "../ui/spinner-icon" import { SpinnerIcon } from "../ui/spinner-icon"
@ -17,7 +23,7 @@ import { cn } from "../../lib/utils"
import { ResolvedAudio } from "../media/ResolvedAudio" import { ResolvedAudio } from "../media/ResolvedAudio"
import { ResolvedImage } from "../media/ResolvedImage" 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 type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
export interface PracticeQuestionOptionDraft { 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 MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024
const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "webp", "gif"]) 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 { export interface PracticeQuestionEditorValue {
questionText: string questionText: string
questionType: PracticeQuestionEditorType questionType: PracticeQuestionEditorType
@ -46,6 +59,12 @@ export interface PracticeQuestionEditorValue {
shortAnswer: string shortAnswer: string
/** Stored URL or object key; same semantics as Speaking practice editor */ /** Stored URL or object key; same semantics as Speaking practice editor */
imageUrl: string 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<string, string>
} }
export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue { export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue {
@ -67,6 +86,10 @@ export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue
audioCorrectAnswerText: "", audioCorrectAnswerText: "",
shortAnswer: "", shortAnswer: "",
imageUrl: "", imageUrl: "",
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
} }
} }
@ -84,6 +107,9 @@ function defaultOptionsForType(
previousType: PracticeQuestionEditorType, previousType: PracticeQuestionEditorType,
current: PracticeQuestionOptionDraft[], current: PracticeQuestionOptionDraft[],
): PracticeQuestionOptionDraft[] { ): PracticeQuestionOptionDraft[] {
if (type === "DYNAMIC") {
return current
}
if (type === "TRUE_FALSE") { if (type === "TRUE_FALSE") {
if (previousType === "TRUE_FALSE" && current.length >= 2) { if (previousType === "TRUE_FALSE" && current.length >= 2) {
return current.map((o, i) => ({ return current.map((o, i) => ({
@ -146,6 +172,18 @@ export function PracticeQuestionEditorFields({
const setType = (questionType: PracticeQuestionEditorType) => { const setType = (questionType: PracticeQuestionEditorType) => {
const options = defaultOptionsForType(questionType, value.questionType, value.options) 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 }) onChange({ ...value, questionType, options })
} }
@ -586,6 +624,92 @@ export function PracticeQuestionEditorFields({
const controlsDisabled = mediaBusy const controlsDisabled = mediaBusy
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>([])
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<string, string> = { ...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 ( return (
<> <>
<div className="mt-5 space-y-5"> <div className="mt-5 space-y-5">
@ -615,6 +739,7 @@ export function PracticeQuestionEditorFields({
<option value="TRUE_FALSE">True/False</option> <option value="TRUE_FALSE">True/False</option>
<option value="SHORT">Short Answer</option> <option value="SHORT">Short Answer</option>
<option value="AUDIO">Audio</option> <option value="AUDIO">Audio</option>
<option value="DYNAMIC">Dynamic (schema-driven)</option>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -644,6 +769,89 @@ export function PracticeQuestionEditorFields({
</div> </div>
</div> </div>
{value.questionType === "DYNAMIC" && (
<div className="space-y-5 rounded-xl border border-violet-200 bg-violet-50/50 p-4 sm:p-5">
<p className="text-sm leading-relaxed text-grayScale-600">
Pick a question type definition, then fill each stimulus/response slot. Element{" "}
<code className="rounded bg-white px-1 text-xs">id</code> and{" "}
<code className="rounded bg-white px-1 text-xs">kind</code> must match the definition schema. Use JSON
for object values (e.g. <code className="text-xs">{"{\"placeholder\":\"Type here\"}"}</code>).
</p>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Question type definition <span className="text-red-500">*</span>
</label>
<Select
value={value.questionTypeDefinitionId != null ? String(value.questionTypeDefinitionId) : ""}
onChange={(e) => void handleDynamicDefinitionChange(e.target.value)}
disabled={definitionsLoading || definitionDetailLoading}
>
<option value="">{definitionsLoading ? "Loading definitions…" : "Select definition…"}</option>
{typeDefinitions.map((d) => (
<option key={d.id} value={String(d.id)}>
#{d.id} {d.display_name} ({d.key})
</option>
))}
</Select>
</div>
{definitionDetailLoading ? (
<p className="text-sm font-medium text-grayScale-500">Loading schema</p>
) : null}
{value.dynamicStimulusRows.length > 0 ? (
<div className="space-y-3">
<p className="text-xs font-bold uppercase tracking-wide text-violet-800">Stimulus</p>
{value.dynamicStimulusRows.map((row) => (
<div
key={`stimulus-${row.id}`}
className="space-y-2 rounded-lg border border-grayScale-200 bg-white p-3 shadow-sm"
>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="text-sm font-semibold text-grayScale-900">{row.label || row.id}</span>
<span className="text-[11px] font-mono text-grayScale-500">
{row.id} · {row.kind}
{row.required ? <span className="text-red-500"> *</span> : null}
</span>
</div>
<Textarea
rows={3}
value={value.dynamicFieldValues[`stimulus:${row.id}`] ?? ""}
onChange={(e) => setDynamicField(`stimulus:${row.id}`, e.target.value)}
placeholder="URL, plain text, or JSON object"
className="min-h-[72px] resize-y font-mono text-[13px]"
/>
</div>
))}
</div>
) : null}
{value.dynamicResponseRows.length > 0 ? (
<div className="space-y-3">
<p className="text-xs font-bold uppercase tracking-wide text-violet-800">Response</p>
{value.dynamicResponseRows.map((row) => (
<div
key={`response-${row.id}`}
className="space-y-2 rounded-lg border border-grayScale-200 bg-white p-3 shadow-sm"
>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="text-sm font-semibold text-grayScale-900">{row.label || row.id}</span>
<span className="text-[11px] font-mono text-grayScale-500">
{row.id} · {row.kind}
{row.required ? <span className="text-red-500"> *</span> : null}
</span>
</div>
<Textarea
rows={3}
value={value.dynamicFieldValues[`response:${row.id}`] ?? ""}
onChange={(e) => setDynamicField(`response:${row.id}`, e.target.value)}
placeholder="URL, plain text, or JSON object"
className="min-h-[72px] resize-y font-mono text-[13px]"
/>
</div>
))}
</div>
) : null}
</div>
)}
{value.questionType === "MCQ" && ( {value.questionType === "MCQ" && (
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4"> <div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
@ -777,6 +985,8 @@ export function PracticeQuestionEditorFields({
</div> </div>
</div> </div>
{value.questionType !== "DYNAMIC" ? (
<>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Voice Prompt (Optional)</label> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Voice Prompt (Optional)</label>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -908,6 +1118,8 @@ export function PracticeQuestionEditorFields({
) : null} ) : null}
</div> </div>
</div> </div>
</>
) : null}
</div> </div>
{recordingModal ? ( {recordingModal ? (

View File

@ -0,0 +1,34 @@
import type { DynamicQuestionPayload } from "../types/questionTypeDefinition.types"
/** Parse a single slot value: plain string/URL, or JSON object/array when input looks like JSON. */
export function parseDynamicSlotValue(raw: string | undefined): unknown {
const t = (raw ?? "").trim()
if (!t) return ""
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
try {
return JSON.parse(t) as unknown
} catch {
return t
}
}
return t
}
export function buildDynamicQuestionPayload(input: {
stimulusRows: { id: string; kind: string }[]
responseRows: { id: string; kind: string }[]
fieldValues: Record<string, string>
}): DynamicQuestionPayload {
return {
stimulus: input.stimulusRows.map((row) => ({
id: row.id,
kind: row.kind,
value: parseDynamicSlotValue(input.fieldValues[`stimulus:${row.id}`]),
})),
response: input.responseRows.map((row) => ({
id: row.id,
kind: row.kind,
value: parseDynamicSlotValue(input.fieldValues[`response:${row.id}`]),
})),
}
}

View File

@ -5,6 +5,8 @@ import { toast } from "sonner"
import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api" import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api"
import { uploadVideoFile } from "../../api/files.api" import { uploadVideoFile } from "../../api/files.api"
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields" import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"
import type { PracticeQuestionDynamicRow } from "../../components/content-management/PracticeQuestionEditorFields"
import { buildDynamicQuestionPayload } from "../../lib/practiceDynamicQuestionPayload"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card" import { Card } from "../../components/ui/card"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
@ -12,7 +14,7 @@ import { SpinnerIcon } from "../../components/ui/spinner-icon"
import type { QuestionOption } from "../../types/course.types" import type { QuestionOption } from "../../types/course.types"
type Step = 1 | 2 | 3 | 4 type Step = 1 | 2 | 3 | 4
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD" type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
type ResultStatus = "success" | "error" type ResultStatus = "success" | "error"
@ -35,6 +37,10 @@ interface Question {
audioCorrectAnswerText: string audioCorrectAnswerText: string
shortAnswers: string[] shortAnswers: string[]
imageUrl: string imageUrl: string
questionTypeDefinitionId: number | null
dynamicStimulusRows: PracticeQuestionDynamicRow[]
dynamicResponseRows: PracticeQuestionDynamicRow[]
dynamicFieldValues: Record<string, string>
} }
const STEPS = [ const STEPS = [
@ -63,6 +69,10 @@ function createEmptyQuestion(id: string): Question {
audioCorrectAnswerText: "", audioCorrectAnswerText: "",
shortAnswers: [], shortAnswers: [],
imageUrl: "", imageUrl: "",
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
} }
} }
@ -104,6 +114,7 @@ function questionTypeLabel(type: QuestionType): string {
if (type === "TRUE_FALSE") return "True/False" if (type === "TRUE_FALSE") return "True/False"
if (type === "SHORT") return "Short Answer" if (type === "SHORT") return "Short Answer"
if (type === "AUDIO") return "Audio" if (type === "AUDIO") return "Audio"
if (type === "DYNAMIC") return "Dynamic"
return "Multiple Choice" return "Multiple Choice"
} }
@ -224,6 +235,39 @@ export function AddNewLessonPage() {
for (let i = 0; i < questions.length; i++) { for (let i = 0; i < questions.length; i++) {
const q = questions[i] const q = questions[i]
if (!q.questionText.trim()) continue if (!q.questionText.trim()) continue
if (q.questionType === "DYNAMIC") {
if (q.questionTypeDefinitionId == null || q.questionTypeDefinitionId <= 0) {
toast.error(`Question ${i + 1}: select a question type definition for dynamic questions.`)
setSaving(false)
return
}
const missingStimulus = q.dynamicStimulusRows.find(
(row) =>
row.required &&
!(q.dynamicFieldValues[`stimulus:${row.id}`]?.trim()),
)
if (missingStimulus) {
toast.error(
`Question ${i + 1}: fill required stimulus "${missingStimulus.label || missingStimulus.id}".`,
)
setSaving(false)
return
}
const missingResponse = q.dynamicResponseRows.find(
(row) =>
row.required &&
!(q.dynamicFieldValues[`response:${row.id}`]?.trim()),
)
if (missingResponse) {
toast.error(
`Question ${i + 1}: fill required response "${missingResponse.label || missingResponse.id}".`,
)
setSaving(false)
return
}
}
const options: QuestionOption[] = const options: QuestionOption[] =
q.questionType === "MCQ" q.questionType === "MCQ"
? q.options.map((opt, idx) => ({ ? q.options.map((opt, idx) => ({
@ -233,6 +277,15 @@ export function AddNewLessonPage() {
})) }))
: [] : []
const dynamicPayload =
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null
? buildDynamicQuestionPayload({
stimulusRows: q.dynamicStimulusRows,
responseRows: q.dynamicResponseRows,
fieldValues: q.dynamicFieldValues,
})
: undefined
const qRes = await createQuestion({ const qRes = await createQuestion({
question_text: q.questionText, question_text: q.questionText,
question_type: q.questionType, question_type: q.questionType,
@ -240,13 +293,22 @@ export function AddNewLessonPage() {
points: q.points, points: q.points,
tips: q.tips || undefined, tips: q.tips || undefined,
explanation: q.explanation || undefined, explanation: q.explanation || undefined,
status: "PUBLISHED", status,
options: options.length > 0 ? options : undefined, options: options.length > 0 ? options : undefined,
voice_prompt: q.voicePrompt || undefined, voice_prompt: q.questionType === "DYNAMIC" ? undefined : q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined, sample_answer_voice_prompt:
audio_correct_answer_text: q.audioCorrectAnswerText || undefined, q.questionType === "DYNAMIC" ? undefined : q.sampleAnswerVoicePrompt || undefined,
image_url: q.imageUrl.trim() || undefined, audio_correct_answer_text:
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined, q.questionType === "DYNAMIC" ? undefined : q.audioCorrectAnswerText || undefined,
image_url: q.questionType === "DYNAMIC" ? undefined : q.imageUrl.trim() || undefined,
short_answers:
q.questionType !== "DYNAMIC" && q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
...(q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
? {
question_type_definition_id: q.questionTypeDefinitionId,
dynamic_payload: dynamicPayload,
}
: {}),
}) })
const questionId = qRes.data?.data?.id const questionId = qRes.data?.data?.id
if (questionId) { if (questionId) {
@ -457,6 +519,10 @@ export function AddNewLessonPage() {
audioCorrectAnswerText: question.audioCorrectAnswerText, audioCorrectAnswerText: question.audioCorrectAnswerText,
shortAnswer: question.shortAnswers[0] ?? "", shortAnswer: question.shortAnswers[0] ?? "",
imageUrl: question.imageUrl, imageUrl: question.imageUrl,
questionTypeDefinitionId: question.questionTypeDefinitionId,
dynamicStimulusRows: question.dynamicStimulusRows,
dynamicResponseRows: question.dynamicResponseRows,
dynamicFieldValues: question.dynamicFieldValues,
}} }}
onChange={(next) => onChange={(next) =>
updateQuestion(question.id, { updateQuestion(question.id, {
@ -472,6 +538,10 @@ export function AddNewLessonPage() {
audioCorrectAnswerText: next.audioCorrectAnswerText, audioCorrectAnswerText: next.audioCorrectAnswerText,
shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [], shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
imageUrl: next.imageUrl, imageUrl: next.imageUrl,
questionTypeDefinitionId: next.questionTypeDefinitionId,
dynamicStimulusRows: next.dynamicStimulusRows,
dynamicResponseRows: next.dynamicResponseRows,
dynamicFieldValues: next.dynamicFieldValues,
}) })
} }
mediaBusy={saving} mediaBusy={saving}

View File

@ -25,12 +25,13 @@ import {
addQuestionToSet, addQuestionToSet,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { uploadVideoFile } from "../../api/files.api"; import { uploadVideoFile } from "../../api/files.api";
import { Select } from "../../components/ui/select";
import type { QuestionOption } from "../../types/course.types"; import type { QuestionOption } from "../../types/course.types";
import type { PracticeQuestionDynamicRow } from "../../components/content-management/PracticeQuestionEditorFields";
import { buildDynamicQuestionPayload } from "../../lib/practiceDynamicQuestionPayload";
type Step = 1 | 2 | 3 | 4 | 5; type Step = 1 | 2 | 3 | 4 | 5;
type ResultStatus = "success" | "error"; type ResultStatus = "success" | "error";
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"; type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC";
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"; type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
interface Persona { interface Persona {
@ -58,6 +59,10 @@ interface Question {
audioCorrectAnswerText: string; audioCorrectAnswerText: string;
shortAnswers: string[]; shortAnswers: string[];
imageUrl: string; imageUrl: string;
questionTypeDefinitionId: number | null;
dynamicStimulusRows: PracticeQuestionDynamicRow[];
dynamicResponseRows: PracticeQuestionDynamicRow[];
dynamicFieldValues: Record<string, string>;
} }
const PERSONAS: Persona[] = [ const PERSONAS: Persona[] = [
@ -231,6 +236,10 @@ function createEmptyQuestion(id: string): Question {
audioCorrectAnswerText: "", audioCorrectAnswerText: "",
shortAnswers: [], shortAnswers: [],
imageUrl: "", imageUrl: "",
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
}; };
} }
@ -414,6 +423,38 @@ export function AddNewPracticePage() {
const q = questions[i]; const q = questions[i];
if (!q.questionText.trim()) continue; if (!q.questionText.trim()) continue;
if (q.questionType === "DYNAMIC") {
if (q.questionTypeDefinitionId == null || q.questionTypeDefinitionId <= 0) {
toast.error(`Question ${i + 1}: select a question type definition for dynamic questions.`);
setSaving(false);
return;
}
const missingStimulus = q.dynamicStimulusRows.find(
(row) =>
row.required &&
!(q.dynamicFieldValues[`stimulus:${row.id}`]?.trim()),
);
if (missingStimulus) {
toast.error(
`Question ${i + 1}: fill required stimulus "${missingStimulus.label || missingStimulus.id}".`,
);
setSaving(false);
return;
}
const missingResponse = q.dynamicResponseRows.find(
(row) =>
row.required &&
!(q.dynamicFieldValues[`response:${row.id}`]?.trim()),
);
if (missingResponse) {
toast.error(
`Question ${i + 1}: fill required response "${missingResponse.label || missingResponse.id}".`,
);
setSaving(false);
return;
}
}
const options: QuestionOption[] = const options: QuestionOption[] =
q.questionType === "MCQ" q.questionType === "MCQ"
? q.options.map((opt, idx) => ({ ? q.options.map((opt, idx) => ({
@ -423,6 +464,15 @@ export function AddNewPracticePage() {
})) }))
: []; : [];
const dynamicPayload =
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null
? buildDynamicQuestionPayload({
stimulusRows: q.dynamicStimulusRows,
responseRows: q.dynamicResponseRows,
fieldValues: q.dynamicFieldValues,
})
: undefined;
const qRes = await createQuestion({ const qRes = await createQuestion({
question_text: q.questionText, question_text: q.questionText,
question_type: q.questionType, question_type: q.questionType,
@ -430,14 +480,22 @@ export function AddNewPracticePage() {
points: q.points, points: q.points,
tips: q.tips || undefined, tips: q.tips || undefined,
explanation: q.explanation || undefined, explanation: q.explanation || undefined,
status: "PUBLISHED", status,
options: options.length > 0 ? options : undefined, options: options.length > 0 ? options : undefined,
voice_prompt: q.voicePrompt || undefined, voice_prompt: q.questionType === "DYNAMIC" ? undefined : q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined, sample_answer_voice_prompt:
audio_correct_answer_text: q.audioCorrectAnswerText || undefined, q.questionType === "DYNAMIC" ? undefined : q.sampleAnswerVoicePrompt || undefined,
image_url: q.imageUrl.trim() || undefined, audio_correct_answer_text:
q.questionType === "DYNAMIC" ? undefined : q.audioCorrectAnswerText || undefined,
image_url: q.questionType === "DYNAMIC" ? undefined : q.imageUrl.trim() || undefined,
short_answers: short_answers:
q.shortAnswers.length > 0 ? q.shortAnswers : undefined, q.questionType !== "DYNAMIC" && q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
...(q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
? {
question_type_definition_id: q.questionTypeDefinitionId,
dynamic_payload: dynamicPayload,
}
: {}),
}); });
const questionId = qRes.data?.data?.id; const questionId = qRes.data?.data?.id;
@ -912,7 +970,7 @@ export function AddNewPracticePage() {
Step 3: Questions Step 3: Questions
</h2> </h2>
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500"> <p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
Add MCQ, True/False, Short Answer, or Audio items. Use the full Add MCQ, True/False, Short Answer, Audio, or Dynamic (schema-driven) items. Use the full
width for stems and options. width for stems and options.
</p> </p>
</div> </div>
@ -952,6 +1010,10 @@ export function AddNewPracticePage() {
audioCorrectAnswerText: question.audioCorrectAnswerText, audioCorrectAnswerText: question.audioCorrectAnswerText,
shortAnswer: question.shortAnswers[0] ?? "", shortAnswer: question.shortAnswers[0] ?? "",
imageUrl: question.imageUrl, imageUrl: question.imageUrl,
questionTypeDefinitionId: question.questionTypeDefinitionId,
dynamicStimulusRows: question.dynamicStimulusRows,
dynamicResponseRows: question.dynamicResponseRows,
dynamicFieldValues: question.dynamicFieldValues,
}} }}
onChange={(next) => { onChange={(next) => {
updateQuestion(question.id, { updateQuestion(question.id, {
@ -970,6 +1032,10 @@ export function AddNewPracticePage() {
? [next.shortAnswer.trim()] ? [next.shortAnswer.trim()]
: [], : [],
imageUrl: next.imageUrl, imageUrl: next.imageUrl,
questionTypeDefinitionId: next.questionTypeDefinitionId,
dynamicStimulusRows: next.dynamicStimulusRows,
dynamicResponseRows: next.dynamicResponseRows,
dynamicFieldValues: next.dynamicFieldValues,
}); });
}} }}
mediaBusy={saving} mediaBusy={saving}
@ -1180,7 +1246,9 @@ export function AddNewPracticePage() {
? "True/False" ? "True/False"
: question.questionType === "AUDIO" : question.questionType === "AUDIO"
? "Audio" ? "Audio"
: "Short Answer"} : question.questionType === "DYNAMIC"
? "Dynamic"
: "Short Answer"}
</span> </span>
<span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600"> <span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
{question.difficultyLevel} {question.difficultyLevel}