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:
parent
9b35a8bf30
commit
77b71abfd8
|
|
@ -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<string, string>
|
||||
}
|
||||
|
||||
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<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 (
|
||||
<>
|
||||
<div className="mt-5 space-y-5">
|
||||
|
|
@ -615,6 +739,7 @@ export function PracticeQuestionEditorFields({
|
|||
<option value="TRUE_FALSE">True/False</option>
|
||||
<option value="SHORT">Short Answer</option>
|
||||
<option value="AUDIO">Audio</option>
|
||||
<option value="DYNAMIC">Dynamic (schema-driven)</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -644,6 +769,89 @@ export function PracticeQuestionEditorFields({
|
|||
</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" && (
|
||||
<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>
|
||||
|
|
@ -777,6 +985,8 @@ export function PracticeQuestionEditorFields({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{value.questionType !== "DYNAMIC" ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Voice Prompt (Optional)</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
|
@ -908,6 +1118,8 @@ export function PracticeQuestionEditorFields({
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{recordingModal ? (
|
||||
|
|
|
|||
34
src/lib/practiceDynamicQuestionPayload.ts
Normal file
34
src/lib/practiceDynamicQuestionPayload.ts
Normal 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}`]),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import { toast } from "sonner"
|
|||
import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api"
|
||||
import { uploadVideoFile } from "../../api/files.api"
|
||||
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 { Card } from "../../components/ui/card"
|
||||
import { Input } from "../../components/ui/input"
|
||||
|
|
@ -12,7 +14,7 @@ import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
|||
import type { QuestionOption } from "../../types/course.types"
|
||||
|
||||
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 ResultStatus = "success" | "error"
|
||||
|
||||
|
|
@ -35,6 +37,10 @@ interface Question {
|
|||
audioCorrectAnswerText: string
|
||||
shortAnswers: string[]
|
||||
imageUrl: string
|
||||
questionTypeDefinitionId: number | null
|
||||
dynamicStimulusRows: PracticeQuestionDynamicRow[]
|
||||
dynamicResponseRows: PracticeQuestionDynamicRow[]
|
||||
dynamicFieldValues: Record<string, string>
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
|
|
@ -63,6 +69,10 @@ function createEmptyQuestion(id: string): Question {
|
|||
audioCorrectAnswerText: "",
|
||||
shortAnswers: [],
|
||||
imageUrl: "",
|
||||
questionTypeDefinitionId: null,
|
||||
dynamicStimulusRows: [],
|
||||
dynamicResponseRows: [],
|
||||
dynamicFieldValues: {},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +114,7 @@ function questionTypeLabel(type: QuestionType): string {
|
|||
if (type === "TRUE_FALSE") return "True/False"
|
||||
if (type === "SHORT") return "Short Answer"
|
||||
if (type === "AUDIO") return "Audio"
|
||||
if (type === "DYNAMIC") return "Dynamic"
|
||||
return "Multiple Choice"
|
||||
}
|
||||
|
||||
|
|
@ -224,6 +235,39 @@ export function AddNewLessonPage() {
|
|||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i]
|
||||
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[] =
|
||||
q.questionType === "MCQ"
|
||||
? 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({
|
||||
question_text: q.questionText,
|
||||
question_type: q.questionType,
|
||||
|
|
@ -240,13 +293,22 @@ export function AddNewLessonPage() {
|
|||
points: q.points,
|
||||
tips: q.tips || undefined,
|
||||
explanation: q.explanation || undefined,
|
||||
status: "PUBLISHED",
|
||||
status,
|
||||
options: options.length > 0 ? options : undefined,
|
||||
voice_prompt: q.voicePrompt || undefined,
|
||||
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
|
||||
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
|
||||
image_url: q.imageUrl.trim() || undefined,
|
||||
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
|
||||
voice_prompt: q.questionType === "DYNAMIC" ? undefined : q.voicePrompt || undefined,
|
||||
sample_answer_voice_prompt:
|
||||
q.questionType === "DYNAMIC" ? undefined : q.sampleAnswerVoicePrompt || undefined,
|
||||
audio_correct_answer_text:
|
||||
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
|
||||
if (questionId) {
|
||||
|
|
@ -457,6 +519,10 @@ export function AddNewLessonPage() {
|
|||
audioCorrectAnswerText: question.audioCorrectAnswerText,
|
||||
shortAnswer: question.shortAnswers[0] ?? "",
|
||||
imageUrl: question.imageUrl,
|
||||
questionTypeDefinitionId: question.questionTypeDefinitionId,
|
||||
dynamicStimulusRows: question.dynamicStimulusRows,
|
||||
dynamicResponseRows: question.dynamicResponseRows,
|
||||
dynamicFieldValues: question.dynamicFieldValues,
|
||||
}}
|
||||
onChange={(next) =>
|
||||
updateQuestion(question.id, {
|
||||
|
|
@ -472,6 +538,10 @@ export function AddNewLessonPage() {
|
|||
audioCorrectAnswerText: next.audioCorrectAnswerText,
|
||||
shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
|
||||
imageUrl: next.imageUrl,
|
||||
questionTypeDefinitionId: next.questionTypeDefinitionId,
|
||||
dynamicStimulusRows: next.dynamicStimulusRows,
|
||||
dynamicResponseRows: next.dynamicResponseRows,
|
||||
dynamicFieldValues: next.dynamicFieldValues,
|
||||
})
|
||||
}
|
||||
mediaBusy={saving}
|
||||
|
|
|
|||
|
|
@ -25,12 +25,13 @@ import {
|
|||
addQuestionToSet,
|
||||
} from "../../api/courses.api";
|
||||
import { uploadVideoFile } from "../../api/files.api";
|
||||
import { Select } from "../../components/ui/select";
|
||||
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 ResultStatus = "success" | "error";
|
||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO";
|
||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC";
|
||||
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
|
||||
|
||||
interface Persona {
|
||||
|
|
@ -58,6 +59,10 @@ interface Question {
|
|||
audioCorrectAnswerText: string;
|
||||
shortAnswers: string[];
|
||||
imageUrl: string;
|
||||
questionTypeDefinitionId: number | null;
|
||||
dynamicStimulusRows: PracticeQuestionDynamicRow[];
|
||||
dynamicResponseRows: PracticeQuestionDynamicRow[];
|
||||
dynamicFieldValues: Record<string, string>;
|
||||
}
|
||||
|
||||
const PERSONAS: Persona[] = [
|
||||
|
|
@ -231,6 +236,10 @@ function createEmptyQuestion(id: string): Question {
|
|||
audioCorrectAnswerText: "",
|
||||
shortAnswers: [],
|
||||
imageUrl: "",
|
||||
questionTypeDefinitionId: null,
|
||||
dynamicStimulusRows: [],
|
||||
dynamicResponseRows: [],
|
||||
dynamicFieldValues: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -414,6 +423,38 @@ export function AddNewPracticePage() {
|
|||
const q = questions[i];
|
||||
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[] =
|
||||
q.questionType === "MCQ"
|
||||
? 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({
|
||||
question_text: q.questionText,
|
||||
question_type: q.questionType,
|
||||
|
|
@ -430,14 +480,22 @@ export function AddNewPracticePage() {
|
|||
points: q.points,
|
||||
tips: q.tips || undefined,
|
||||
explanation: q.explanation || undefined,
|
||||
status: "PUBLISHED",
|
||||
status,
|
||||
options: options.length > 0 ? options : undefined,
|
||||
voice_prompt: q.voicePrompt || undefined,
|
||||
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
|
||||
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
|
||||
image_url: q.imageUrl.trim() || undefined,
|
||||
voice_prompt: q.questionType === "DYNAMIC" ? undefined : q.voicePrompt || undefined,
|
||||
sample_answer_voice_prompt:
|
||||
q.questionType === "DYNAMIC" ? undefined : q.sampleAnswerVoicePrompt || undefined,
|
||||
audio_correct_answer_text:
|
||||
q.questionType === "DYNAMIC" ? undefined : q.audioCorrectAnswerText || undefined,
|
||||
image_url: q.questionType === "DYNAMIC" ? undefined : q.imageUrl.trim() || undefined,
|
||||
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;
|
||||
|
|
@ -912,7 +970,7 @@ export function AddNewPracticePage() {
|
|||
Step 3: Questions
|
||||
</h2>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -952,6 +1010,10 @@ export function AddNewPracticePage() {
|
|||
audioCorrectAnswerText: question.audioCorrectAnswerText,
|
||||
shortAnswer: question.shortAnswers[0] ?? "",
|
||||
imageUrl: question.imageUrl,
|
||||
questionTypeDefinitionId: question.questionTypeDefinitionId,
|
||||
dynamicStimulusRows: question.dynamicStimulusRows,
|
||||
dynamicResponseRows: question.dynamicResponseRows,
|
||||
dynamicFieldValues: question.dynamicFieldValues,
|
||||
}}
|
||||
onChange={(next) => {
|
||||
updateQuestion(question.id, {
|
||||
|
|
@ -970,6 +1032,10 @@ export function AddNewPracticePage() {
|
|||
? [next.shortAnswer.trim()]
|
||||
: [],
|
||||
imageUrl: next.imageUrl,
|
||||
questionTypeDefinitionId: next.questionTypeDefinitionId,
|
||||
dynamicStimulusRows: next.dynamicStimulusRows,
|
||||
dynamicResponseRows: next.dynamicResponseRows,
|
||||
dynamicFieldValues: next.dynamicFieldValues,
|
||||
});
|
||||
}}
|
||||
mediaBusy={saving}
|
||||
|
|
@ -1180,6 +1246,8 @@ export function AddNewPracticePage() {
|
|||
? "True/False"
|
||||
: question.questionType === "AUDIO"
|
||||
? "Audio"
|
||||
: question.questionType === "DYNAMIC"
|
||||
? "Dynamic"
|
||||
: "Short Answer"}
|
||||
</span>
|
||||
<span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user