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 { 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 ? (
|
||||||
|
|
|
||||||
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 { 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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user