diff --git a/src/api/questionTypeDefinitions.api.ts b/src/api/questionTypeDefinitions.api.ts new file mode 100644 index 0000000..f1970a6 --- /dev/null +++ b/src/api/questionTypeDefinitions.api.ts @@ -0,0 +1,303 @@ +import http from "./http" +import type { + DynamicElementDefinition, + QuestionComponentCatalog, + QuestionTypeDefinition, + QuestionTypeDefinitionCreatePayload, + QuestionTypeDefinitionUpdatePayload, + QuestionTypeDefinitionValidatePayload, + ValidateQuestionTypeDefinitionResult, +} from "../types/questionTypeDefinition.types" + +interface ApiEnvelope { + message?: string + data?: T + /** Some routes use PascalCase in JSON */ + Data?: T + success?: boolean + status_code?: number + error?: string +} + +/** + * Reads the inner payload from a typical API envelope, or the raw body. + * Supports `data` / `Data` and list bodies where `res.data` is already an array + * (e.g. GET /questions/type-definitions → `data: [ { ID, Key, … }, … ]`). + */ +export function unwrapApiPayload(res: { data?: unknown }): unknown { + const body = res.data + if (body === null || body === undefined) return undefined + if (Array.isArray(body)) return body + if (typeof body !== "object") return body + const o = body as Record + if ("data" in o || "Data" in o) { + const inner = o.data ?? o.Data + return inner + } + return body +} + +function fromStringArray(arr: unknown): string[] { + return Array.isArray(arr) + ? arr.filter((k): k is string => typeof k === "string" && k.length > 0) + : [] +} + +function sortUniqueStrings(values: string[]): string[] { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)) +} + +const emptyCatalog = (): QuestionComponentCatalog => ({ + stimulus_component_kinds: [], + response_component_kinds: [], +}) + +/** + * Parse GET /questions/component-catalog body. + * Canonical shape: `data.stimulus_component_kinds` + `data.response_component_kinds`. + */ +export function parseComponentCatalog(payload: unknown): QuestionComponentCatalog { + if (!payload || typeof payload !== "object") return emptyCatalog() + const d = payload as Record + + if ( + Array.isArray(d.stimulus_component_kinds) || + Array.isArray(d.response_component_kinds) + ) { + return { + stimulus_component_kinds: sortUniqueStrings(fromStringArray(d.stimulus_component_kinds)), + response_component_kinds: sortUniqueStrings(fromStringArray(d.response_component_kinds)), + } + } + + if (d.data !== undefined && d.data !== null && typeof d.data === "object") { + const inner = parseComponentCatalog(d.data) + if ( + inner.stimulus_component_kinds.length > 0 || + inner.response_component_kinds.length > 0 + ) { + return inner + } + } + + const sk = fromStringArray(d.stimulus_kinds) + const rk = fromStringArray(d.response_kinds) + if (sk.length || rk.length) { + return { + stimulus_component_kinds: sortUniqueStrings(sk), + response_component_kinds: sortUniqueStrings(rk), + } + } + + const mergedFlat = sortUniqueStrings([ + ...fromStringArray(d.kinds), + ...fromStringArray(d.codes), + ...fromStringArray(d.component_kinds), + ]) + if (mergedFlat.length) { + return { + stimulus_component_kinds: mergedFlat, + response_component_kinds: [...mergedFlat], + } + } + + if (Array.isArray(payload)) { + const merged = sortUniqueStrings(fromStringArray(payload)) + return { + stimulus_component_kinds: merged, + response_component_kinds: [...merged], + } + } + + return emptyCatalog() +} + +export async function getQuestionComponentCatalog(): Promise { + const res = await http.get>("/questions/component-catalog") + const raw = unwrapApiPayload(res) ?? res.data + return parseComponentCatalog(raw) +} + +function parseInnerValidFlag(inner: unknown): boolean | undefined { + if (inner == null || typeof inner !== "object") return undefined + const o = inner as Record + const v = o.valid ?? o.Valid + if (typeof v === "boolean") return v + if (v === "true" || v === 1) return true + if (v === "false" || v === 0) return false + return undefined +} + +/** + * POST /questions/validate-question-type-definition + * Success: 200 with `data.valid` (envelope `success` may still be false). + * Invalid: 400 with `message` / `error` on the JSON body (axios throws). + */ +export async function validateQuestionTypeDefinition( + body: QuestionTypeDefinitionValidatePayload, +): Promise { + try { + const res = await http.post>("/questions/validate-question-type-definition", body) + const envelope = res.data as ApiEnvelope + const inner = unwrapApiPayload(res) + const validFlag = parseInnerValidFlag(inner) + if (validFlag === true) { + return { valid: true, message: envelope?.message } + } + const envErr = + typeof envelope?.error === "string" + ? envelope.error + : envelope && typeof envelope === "object" && "Error" in envelope + ? String((envelope as { Error?: unknown }).Error) + : undefined + return { + valid: false, + message: envelope?.message, + error: + envErr || + (validFlag === false ? "Definition is not valid." : "Validation response did not include a valid flag."), + } + } catch (e: unknown) { + const err = e as { response?: { data?: { message?: string; error?: string } } } + const d = err.response?.data + return { + valid: false, + message: d?.message, + error: d?.error || d?.message || "Validation request failed", + } + } +} + +export async function createQuestionTypeDefinition(body: QuestionTypeDefinitionCreatePayload) { + return http.post>("/questions/type-definitions", body) +} + +function asStr(v: unknown): string { + if (v == null || v === undefined) return "" + return String(v) +} + +function asStringArray(v: unknown): string[] { + if (!Array.isArray(v)) return [] + return v.filter((x): x is string => typeof x === "string" && x.length > 0) +} + +function normalizeSchemaRows(v: unknown): DynamicElementDefinition[] { + if (!Array.isArray(v)) return [] + return v.map((row) => { + if (!row || typeof row !== "object") { + return { id: "", kind: "", required: false, label: undefined, config: undefined } + } + const r = row as Record + const id = asStr(r.id ?? r.Id) + const kind = asStr(r.kind ?? r.Kind) + const labelRaw = r.label ?? r.Label + const config = (r.config ?? r.Config) as Record | undefined + return { + id, + kind, + label: labelRaw != null && labelRaw !== "" ? asStr(labelRaw) : undefined, + required: Boolean(r.required ?? r.Required), + config: config && typeof config === "object" && !Array.isArray(config) ? config : undefined, + } + }) +} + +/** + * Maps GET/POST definition objects from PascalCase (ID, Key, StimulusSchema, …) + * or snake_case into {@link QuestionTypeDefinition}. + */ +export function normalizeTypeDefinitionFromApi(raw: unknown): QuestionTypeDefinition | null { + if (!raw || typeof raw !== "object") return null + const o = raw as Record + const id = Number(o.ID ?? o.id) + if (!Number.isFinite(id)) return null + const statusRaw = asStr(o.Status ?? o.status).toUpperCase() + const status = statusRaw === "INACTIVE" ? "INACTIVE" : "ACTIVE" + return { + id, + key: asStr(o.Key ?? o.key), + display_name: asStr(o.DisplayName ?? o.display_name), + description: (() => { + const d = o.Description ?? o.description + if (d == null) return null + const s = asStr(d) + return s === "" ? null : s + })(), + stimulus_component_kinds: asStringArray(o.StimulusComponentKinds ?? o.stimulus_component_kinds), + response_component_kinds: asStringArray(o.ResponseComponentKinds ?? o.response_component_kinds), + stimulus_schema: normalizeSchemaRows(o.StimulusSchema ?? o.stimulus_schema), + response_schema: normalizeSchemaRows(o.ResponseSchema ?? o.response_schema), + status, + is_system: Boolean(o.IsSystem ?? o.is_system), + created_at: o.CreatedAt != null ? asStr(o.CreatedAt) : o.created_at != null ? asStr(o.created_at) : undefined, + updated_at: o.UpdatedAt != null ? asStr(o.UpdatedAt) : o.updated_at != null ? asStr(o.updated_at) : undefined, + } +} + +/** + * Definition id from POST create or PUT update (`data.ID`, `data.id`, or PascalCase `Id`). + * Example update: `{ "data": { "id": 6 } }`. + */ +export function extractDefinitionMutationId(res: { data?: unknown }): number | undefined { + const data = unwrapApiPayload(res) + if (!data || typeof data !== "object" || Array.isArray(data)) return undefined + const o = data as Record + const id = Number(o.ID ?? o.id ?? o.Id) + return Number.isFinite(id) && id > 0 ? id : undefined +} + +/** @deprecated use extractDefinitionMutationId */ +export const extractCreatedDefinitionId = extractDefinitionMutationId + +export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] { + if (!payload) return [] + if (Array.isArray(payload)) { + return payload + .map((item) => normalizeTypeDefinitionFromApi(item)) + .filter((x): x is QuestionTypeDefinition => x != null) + } + if (typeof payload === "object" && payload !== null) { + const o = payload as Record + const inner = o.definitions ?? o.items ?? o.rows ?? o.Definitions + if (Array.isArray(inner)) return parseDefinitionsList(inner) + if (inner && typeof inner === "object") return parseDefinitionsList(inner) + const data = o.data ?? o.Data + if (Array.isArray(data)) return parseDefinitionsList(data) + if (data && typeof data === "object") { + const single = normalizeTypeDefinitionFromApi(data) + return single ? [single] : [] + } + const single = normalizeTypeDefinitionFromApi(payload) + return single ? [single] : [] + } + return [] +} + +export async function getQuestionTypeDefinitions(params?: { + include_system?: boolean + status?: string + limit?: number + offset?: number +}) { + const res = await http.get>("/questions/type-definitions", { params }) + const raw = unwrapApiPayload(res) ?? res.data + return parseDefinitionsList(raw) +} + +export async function getQuestionTypeDefinitionById(id: number) { + const res = await http.get>(`/questions/type-definitions/${id}`) + const def = unwrapApiPayload(res) + return normalizeTypeDefinitionFromApi(def) ?? undefined +} + +export async function updateQuestionTypeDefinition( + id: number, + body: QuestionTypeDefinitionUpdatePayload, +) { + return http.put>(`/questions/type-definitions/${id}`, body) +} + +export async function deleteQuestionTypeDefinition(id: number) { + return http.delete>(`/questions/type-definitions/${id}`) +} diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index eb3f023..af2a147 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -170,6 +170,10 @@ export function AppRoutes() { path="/new-content/question-types" element={} /> + } + /> } diff --git a/src/pages/content-management/AddQuestionPage.tsx b/src/pages/content-management/AddQuestionPage.tsx index 9f65077..83eec8a 100644 --- a/src/pages/content-management/AddQuestionPage.tsx +++ b/src/pages/content-management/AddQuestionPage.tsx @@ -8,11 +8,18 @@ import { Input } from "../../components/ui/input" import { Textarea } from "../../components/ui/textarea" import { Select } from "../../components/ui/select" import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api" +import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api" +import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types" -type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" +type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | "DYNAMIC" type Difficulty = "EASY" | "MEDIUM" | "HARD" type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE" +const defaultDynamicPayloadJson = `{ + "stimulus": [], + "response": [] +}` + interface Question { id?: number question: string @@ -27,6 +34,9 @@ interface Question { voicePrompt: string sampleAnswerVoicePrompt: string audioCorrectAnswerText: string + /** Definition id as string for select value */ + questionTypeDefinitionId: string + dynamicPayloadJson: string } const initialForm: Question = { @@ -42,6 +52,8 @@ const initialForm: Question = { voicePrompt: "", sampleAnswerVoicePrompt: "", audioCorrectAnswerText: "", + questionTypeDefinitionId: "", + dynamicPayloadJson: defaultDynamicPayloadJson, } export function AddQuestionPage() { @@ -52,6 +64,7 @@ export function AddQuestionPage() { const [formData, setFormData] = useState(initialForm) const [loading, setLoading] = useState(false) const [submitting, setSubmitting] = useState(false) + const [typeDefinitions, setTypeDefinitions] = useState([]) useEffect(() => { const loadQuestion = async () => { @@ -64,7 +77,8 @@ export function AddQuestionPage() { q.question_type === "MCQ" || q.question_type === "TRUE_FALSE" || q.question_type === "SHORT_ANSWER" || - q.question_type === "AUDIO" + q.question_type === "AUDIO" || + q.question_type === "DYNAMIC" ? q.question_type : "MCQ" const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0 @@ -100,6 +114,14 @@ export function AddQuestionPage() { voicePrompt: q.voice_prompt || "", sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "", audioCorrectAnswerText: q.audio_correct_answer_text || "", + questionTypeDefinitionId: + mappedType === "DYNAMIC" && q.question_type_definition_id != null + ? String(q.question_type_definition_id) + : "", + dynamicPayloadJson: + mappedType === "DYNAMIC" && q.dynamic_payload + ? JSON.stringify(q.dynamic_payload, null, 2) + : defaultDynamicPayloadJson, }) } catch (error) { console.error("Failed to load question:", error) @@ -111,6 +133,22 @@ export function AddQuestionPage() { loadQuestion() }, [isEditing, id]) + useEffect(() => { + if (formData.type !== "DYNAMIC") return + let cancelled = false + ;(async () => { + try { + const rows = await getQuestionTypeDefinitions({ include_system: true }) + if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : []) + } catch { + if (!cancelled) setTypeDefinitions([]) + } + })() + return () => { + cancelled = true + } + }, [formData.type]) + const handleTypeChange = (type: QuestionType) => { setFormData((prev) => { if (type === "TRUE_FALSE") { @@ -120,6 +158,15 @@ export function AddQuestionPage() { options: ["True", "False"], correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "", } + } else if (type === "DYNAMIC") { + return { + ...prev, + type, + options: [], + correctAnswer: "", + questionTypeDefinitionId: "", + dynamicPayloadJson: defaultDynamicPayloadJson, + } } else if (type === "SHORT_ANSWER" || type === "AUDIO") { return { ...prev, @@ -200,6 +247,27 @@ export function AddQuestionPage() { }) return } + } else if (formData.type === "DYNAMIC") { + const defId = Number(formData.questionTypeDefinitionId) + if (!Number.isFinite(defId) || defId < 1) { + toast.error("Definition required", { description: "Select a question type definition." }) + return + } + try { + const parsed = JSON.parse(formData.dynamicPayloadJson || "{}") as { + stimulus?: unknown + response?: unknown + } + if (!Array.isArray(parsed.stimulus) || !Array.isArray(parsed.response)) { + toast.error("Invalid dynamic payload", { + description: 'JSON must include "stimulus" and "response" arrays.', + }) + return + } + } catch { + toast.error("Invalid JSON", { description: "Fix dynamic_payload JSON before saving." }) + return + } } setSubmitting(true) @@ -221,6 +289,18 @@ export function AddQuestionPage() { { acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const }, ] : undefined + let dynamicPayload: { stimulus: unknown[]; response: unknown[] } | undefined + if (formData.type === "DYNAMIC") { + try { + dynamicPayload = JSON.parse(formData.dynamicPayloadJson) as { + stimulus: unknown[] + response: unknown[] + } + } catch { + dynamicPayload = { stimulus: [], response: [] } + } + } + const payload = { question_text: formData.question, question_type: formData.type, @@ -236,6 +316,12 @@ export function AddQuestionPage() { formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined, audio_correct_answer_text: formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined, + ...(formData.type === "DYNAMIC" && dynamicPayload + ? { + question_type_definition_id: Number(formData.questionTypeDefinitionId), + dynamic_payload: dynamicPayload, + } + : {}), } if (isEditing && id) { await updateQuestion(Number(id), payload) @@ -303,15 +389,58 @@ export function AddQuestionPage() { + + {formData.type === "DYNAMIC" && ( + <> +
+ + +

+ Loaded from GET /questions/type-definitions?include_system=true&status=ACTIVE +

+
+
+ +