dynamic question type builder integration

This commit is contained in:
Yared Yemane 2026-05-11 07:42:57 -07:00
parent d1fcfc19b0
commit 457d09f02b
16 changed files with 2167 additions and 1451 deletions

View File

@ -0,0 +1,303 @@
import http from "./http"
import type {
DynamicElementDefinition,
QuestionComponentCatalog,
QuestionTypeDefinition,
QuestionTypeDefinitionCreatePayload,
QuestionTypeDefinitionUpdatePayload,
QuestionTypeDefinitionValidatePayload,
ValidateQuestionTypeDefinitionResult,
} from "../types/questionTypeDefinition.types"
interface ApiEnvelope<T> {
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<string, unknown>
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<string, unknown>
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<QuestionComponentCatalog> {
const res = await http.get<ApiEnvelope<unknown>>("/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<string, unknown>
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<ValidateQuestionTypeDefinitionResult> {
try {
const res = await http.post<ApiEnvelope<unknown>>("/questions/validate-question-type-definition", body)
const envelope = res.data as ApiEnvelope<unknown>
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<ApiEnvelope<unknown>>("/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<string, unknown>
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<string, unknown> | 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<string, unknown>
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<string, unknown>
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<string, unknown>
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<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
const raw = unwrapApiPayload(res) ?? res.data
return parseDefinitionsList(raw)
}
export async function getQuestionTypeDefinitionById(id: number) {
const res = await http.get<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`)
const def = unwrapApiPayload(res)
return normalizeTypeDefinitionFromApi(def) ?? undefined
}
export async function updateQuestionTypeDefinition(
id: number,
body: QuestionTypeDefinitionUpdatePayload,
) {
return http.put<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`, body)
}
export async function deleteQuestionTypeDefinition(id: number) {
return http.delete<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`)
}

View File

@ -170,6 +170,10 @@ export function AppRoutes() {
path="/new-content/question-types" path="/new-content/question-types"
element={<QuestionTypeLibraryPage />} element={<QuestionTypeLibraryPage />}
/> />
<Route
path="/new-content/question-types/:definitionId/edit"
element={<CreateQuestionTypeFlow />}
/>
<Route <Route
path="/new-content/question-types/create" path="/new-content/question-types/create"
element={<CreateQuestionTypeFlow />} element={<CreateQuestionTypeFlow />}

View File

@ -8,11 +8,18 @@ import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select" import { Select } from "../../components/ui/select"
import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api" 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 Difficulty = "EASY" | "MEDIUM" | "HARD"
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE" type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
const defaultDynamicPayloadJson = `{
"stimulus": [],
"response": []
}`
interface Question { interface Question {
id?: number id?: number
question: string question: string
@ -27,6 +34,9 @@ interface Question {
voicePrompt: string voicePrompt: string
sampleAnswerVoicePrompt: string sampleAnswerVoicePrompt: string
audioCorrectAnswerText: string audioCorrectAnswerText: string
/** Definition id as string for select value */
questionTypeDefinitionId: string
dynamicPayloadJson: string
} }
const initialForm: Question = { const initialForm: Question = {
@ -42,6 +52,8 @@ const initialForm: Question = {
voicePrompt: "", voicePrompt: "",
sampleAnswerVoicePrompt: "", sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "", audioCorrectAnswerText: "",
questionTypeDefinitionId: "",
dynamicPayloadJson: defaultDynamicPayloadJson,
} }
export function AddQuestionPage() { export function AddQuestionPage() {
@ -52,6 +64,7 @@ export function AddQuestionPage() {
const [formData, setFormData] = useState<Question>(initialForm) const [formData, setFormData] = useState<Question>(initialForm)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>([])
useEffect(() => { useEffect(() => {
const loadQuestion = async () => { const loadQuestion = async () => {
@ -64,7 +77,8 @@ export function AddQuestionPage() {
q.question_type === "MCQ" || q.question_type === "MCQ" ||
q.question_type === "TRUE_FALSE" || q.question_type === "TRUE_FALSE" ||
q.question_type === "SHORT_ANSWER" || q.question_type === "SHORT_ANSWER" ||
q.question_type === "AUDIO" q.question_type === "AUDIO" ||
q.question_type === "DYNAMIC"
? q.question_type ? q.question_type
: "MCQ" : "MCQ"
const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0 const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0
@ -100,6 +114,14 @@ export function AddQuestionPage() {
voicePrompt: q.voice_prompt || "", voicePrompt: q.voice_prompt || "",
sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "", sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "",
audioCorrectAnswerText: q.audio_correct_answer_text || "", 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) { } catch (error) {
console.error("Failed to load question:", error) console.error("Failed to load question:", error)
@ -111,6 +133,22 @@ export function AddQuestionPage() {
loadQuestion() loadQuestion()
}, [isEditing, id]) }, [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) => { const handleTypeChange = (type: QuestionType) => {
setFormData((prev) => { setFormData((prev) => {
if (type === "TRUE_FALSE") { if (type === "TRUE_FALSE") {
@ -120,6 +158,15 @@ export function AddQuestionPage() {
options: ["True", "False"], options: ["True", "False"],
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "", 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") { } else if (type === "SHORT_ANSWER" || type === "AUDIO") {
return { return {
...prev, ...prev,
@ -200,6 +247,27 @@ export function AddQuestionPage() {
}) })
return 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) setSubmitting(true)
@ -221,6 +289,18 @@ export function AddQuestionPage() {
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const }, { acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const },
] ]
: undefined : 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 = { const payload = {
question_text: formData.question, question_text: formData.question,
question_type: formData.type, question_type: formData.type,
@ -236,6 +316,12 @@ export function AddQuestionPage() {
formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined, formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: audio_correct_answer_text:
formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined, formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined,
...(formData.type === "DYNAMIC" && dynamicPayload
? {
question_type_definition_id: Number(formData.questionTypeDefinitionId),
dynamic_payload: dynamicPayload,
}
: {}),
} }
if (isEditing && id) { if (isEditing && id) {
await updateQuestion(Number(id), payload) await updateQuestion(Number(id), payload)
@ -303,15 +389,58 @@ export function AddQuestionPage() {
<option value="TRUE_FALSE">True/False</option> <option value="TRUE_FALSE">True/False</option>
<option value="SHORT_ANSWER">Short Answer</option> <option value="SHORT_ANSWER">Short Answer</option>
<option value="AUDIO">Audio</option> <option value="AUDIO">Audio</option>
<option value="DYNAMIC">Dynamic (schema-driven)</option>
</Select> </Select>
</div> </div>
{formData.type === "DYNAMIC" && (
<>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Question type definition <span className="text-red-500">*</span>
</label>
<Select
value={formData.questionTypeDefinitionId}
onChange={(e) =>
setFormData((prev) => ({ ...prev, questionTypeDefinitionId: e.target.value }))
}
required
>
<option value="">Select definition</option>
{typeDefinitions.map((d) => (
<option key={d.id} value={String(d.id)}>
{d.display_name} ({d.key})
</option>
))}
</Select>
<p className="mt-1 text-xs text-grayScale-400">
Loaded from GET /questions/type-definitions?include_system=true&amp;status=ACTIVE
</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
dynamic_payload (JSON) <span className="text-red-500">*</span>
</label>
<Textarea
value={formData.dynamicPayloadJson}
onChange={(e) => setFormData((prev) => ({ ...prev, dynamicPayloadJson: e.target.value }))}
rows={12}
className="font-mono text-xs"
spellCheck={false}
/>
<p className="mt-1 text-xs text-grayScale-400">
Must match the selected definition&apos;s stimulus/response schema (see integration guide).
</p>
</div>
</>
)}
<hr className="border-grayScale-100" /> <hr className="border-grayScale-100" />
{/* Question Text */} {/* Question Text */}
<div> <div>
<label htmlFor="question" className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label htmlFor="question" className="mb-1.5 block text-sm font-medium text-grayScale-500">
Question {formData.type === "DYNAMIC" ? "Question title / stem" : "Question"}
</label> </label>
<Textarea <Textarea
id="question" id="question"
@ -368,6 +497,7 @@ export function AddQuestionPage() {
<hr className="border-grayScale-100" /> <hr className="border-grayScale-100" />
{/* Correct Answer */} {/* Correct Answer */}
{formData.type !== "DYNAMIC" && (
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"} {formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"}
@ -403,6 +533,7 @@ export function AddQuestionPage() {
/> />
)} )}
</div> </div>
)}
<hr className="border-grayScale-100" /> <hr className="border-grayScale-100" />

View File

@ -1,31 +1,279 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react"
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom"
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react"
import { Button } from "../../components/ui/button"; import { toast } from "sonner"
import { Stepper } from "../../components/ui/stepper"; import { Button } from "../../components/ui/button"
import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/QuestionTypeBasicInfoStep"; import { Card } from "../../components/ui/card"
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"; import { Stepper } from "../../components/ui/stepper"
import {
createQuestionTypeDefinition,
validateQuestionTypeDefinition,
extractDefinitionMutationId,
getQuestionComponentCatalog,
getQuestionTypeDefinitionById,
updateQuestionTypeDefinition,
} from "../../api/questionTypeDefinitions.api"
import type {
QuestionComponentCatalog,
QuestionTypeDefinition,
QuestionTypeDefinitionCreatePayload,
} from "../../types/questionTypeDefinition.types"
import {
buildCreatePayload,
validateDefinitionBasic,
validateDefinitionKinds,
validateDefinitionSchemas,
type FieldErrorMap,
} from "./lib/questionTypeDefinitionValidation"
import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/QuestionTypeBasicInfoStep"
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"
import { QuestionTypeValidatePreviewStep } from "./components/question-type-steps/QuestionTypeValidatePreviewStep"
import { QuestionTypeReviewPublishStep } from "./components/question-type-steps/QuestionTypeReviewPublishStep"
const initialDraft = (): QuestionTypeDefinitionCreatePayload => ({
key: "",
display_name: "",
description: "",
status: "ACTIVE",
stimulus_component_kinds: [],
response_component_kinds: [],
stimulus_schema: [],
response_schema: [],
})
function seedSchemaFromKinds(kinds: string[]) {
return kinds.map((k, i) => ({
id: `${(k || "field").toLowerCase().replace(/[^a-z0-9]+/g, "_") || "field"}_${i + 1}`,
kind: k,
label: k.replace(/_/g, " "),
required: true as boolean,
}))
}
function definitionToDraft(def: QuestionTypeDefinition): QuestionTypeDefinitionCreatePayload {
return {
key: def.key,
display_name: def.display_name,
description: def.description ?? "",
status: def.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
stimulus_component_kinds: [...(def.stimulus_component_kinds ?? [])],
response_component_kinds: [...(def.response_component_kinds ?? [])],
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({ ...r })),
response_schema: (def.response_schema ?? []).map((r) => ({ ...r })),
}
}
export function CreateQuestionTypeFlow() { export function CreateQuestionTypeFlow() {
const navigate = useNavigate(); const navigate = useNavigate()
const [currentStep, setCurrentStep] = useState(1); const { definitionId: definitionIdParam } = useParams<{ definitionId?: string }>()
const editDefinitionId = useMemo(() => {
if (!definitionIdParam || !/^\d+$/.test(definitionIdParam)) return null
const n = Number(definitionIdParam)
return Number.isFinite(n) && n > 0 ? n : null
}, [definitionIdParam])
const isEdit = editDefinitionId != null
const steps = [ const [currentStep, setCurrentStep] = useState(1)
"Basic Info", const [draft, setDraft] = useState<QuestionTypeDefinitionCreatePayload>(initialDraft)
"Input & Answer Configuration", const [versionName, setVersionName] = useState("Test 1")
"Versions", const [stepErrors, setStepErrors] = useState<FieldErrorMap>({})
"Review & Publish", const [definitionReady, setDefinitionReady] = useState(!isEdit)
];
const handleNext = () => const [componentCatalog, setComponentCatalog] = useState<QuestionComponentCatalog>({
setCurrentStep((prev) => Math.min(prev + 1, steps.length)); stimulus_component_kinds: [],
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1)); response_component_kinds: [],
})
const [catalogLoading, setCatalogLoading] = useState(true)
const [catalogError, setCatalogError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setCatalogLoading(true)
setCatalogError(null)
try {
const cat = await getQuestionComponentCatalog()
if (!cancelled) {
setComponentCatalog(cat)
if (
!cat.stimulus_component_kinds.length &&
!cat.response_component_kinds.length
) {
setCatalogError("Catalog returned no kinds — check API response shape.")
}
}
} catch (e) {
if (!cancelled) {
console.error(e)
setCatalogError("Failed to load component catalog.")
toast.error("Failed to load component catalog")
}
} finally {
if (!cancelled) setCatalogLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!isEdit || editDefinitionId == null) {
setDefinitionReady(true)
return
}
let cancelled = false
setDefinitionReady(false)
;(async () => {
try {
const def = await getQuestionTypeDefinitionById(editDefinitionId)
if (cancelled) return
if (!def) {
toast.error("Definition not found")
navigate("/new-content/question-types")
return
}
setDraft(definitionToDraft(def))
setVersionName("Test 1")
setCurrentStep(1)
setStepErrors({})
} catch (e) {
if (!cancelled) {
console.error(e)
toast.error("Failed to load definition")
navigate("/new-content/question-types")
}
} finally {
if (!cancelled) setDefinitionReady(true)
}
})()
return () => {
cancelled = true
}
}, [isEdit, editDefinitionId, navigate])
useEffect(() => {
if (!isEdit) {
setDraft(initialDraft())
setVersionName("Test 1")
setCurrentStep(1)
setStepErrors({})
setDefinitionReady(true)
}
}, [isEdit])
const catalogForSchemaValidation = {
stimulus: new Set(componentCatalog.stimulus_component_kinds),
response: new Set(componentCatalog.response_component_kinds),
}
const handleNextFromStep1 = () => {
const e1 = validateDefinitionBasic(draft)
setStepErrors(e1)
if (Object.keys(e1).length) {
toast.error("Fix the highlighted fields before continuing.")
return
}
setStepErrors({})
setCurrentStep(2)
}
const handleNextFromStep2 = () => {
const versionErr: FieldErrorMap = {}
if (!versionName.trim()) {
versionErr.version_name = "Version name is required."
}
const eKinds = validateDefinitionKinds(draft, componentCatalog)
const mergedKinds = { ...versionErr, ...eKinds }
setStepErrors(mergedKinds)
if (Object.keys(mergedKinds).length) {
toast.error("Complete version name and component selections.")
return
}
const nextDraft: QuestionTypeDefinitionCreatePayload = { ...draft }
if (!nextDraft.stimulus_schema.length && nextDraft.stimulus_component_kinds.length) {
nextDraft.stimulus_schema = seedSchemaFromKinds(nextDraft.stimulus_component_kinds)
}
if (!nextDraft.response_schema.length && nextDraft.response_component_kinds.length) {
nextDraft.response_schema = seedSchemaFromKinds(nextDraft.response_component_kinds)
}
setDraft(nextDraft)
const mergedSchema = validateDefinitionSchemas(nextDraft, catalogForSchemaValidation)
setStepErrors(mergedSchema)
if (Object.keys(mergedSchema).length) {
toast.error("Fix schema issues (expand Advanced) or adjust selected kinds.", {
description: "Open “Advanced: edit schema rows” to fix row ids and kinds.",
})
return
}
setStepErrors({})
setCurrentStep(3)
}
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1))
const handleHeaderSaveDraft = async () => {
setDraft((d) => ({ ...d, status: "INACTIVE" }))
if (currentStep < 4) {
toast.message("Status set to Inactive", {
description: "Complete the wizard; on the last step you can save the definition to the server.",
})
return
}
const body = { ...buildCreatePayload({ ...draft, status: "INACTIVE" }), status: "INACTIVE" as const }
try {
if (isEdit && editDefinitionId != null) {
const res = await updateQuestionTypeDefinition(editDefinitionId, body)
const id = extractDefinitionMutationId(res) ?? editDefinitionId
toast.success(res.data?.message || "Definition saved as draft", {
description: `Definition id: ${id}`,
})
navigate(`/new-content/question-types?updated=${id}`)
return
}
const validation = await validateQuestionTypeDefinition(body)
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
})
return
}
const res = await createQuestionTypeDefinition(body)
const id = extractDefinitionMutationId(res)
if (id == null) {
toast.error(res.data?.message ?? "Save failed: missing definition id in response.")
return
}
toast.success(res.data?.message || "Definition saved as draft", {
description: `Definition id: ${id}`,
})
navigate(`/new-content/question-types?created=${id}`)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string; error?: string } } }
toast.error(String(err.response?.data?.message || "Save failed"), {
description: err.response?.data?.error ? String(err.response.data.error) : undefined,
})
}
}
const steps = ["Basic Info", "Input & answer types", "Validate", "Review & publish"]
if (isEdit && !definitionReady) {
return (
<div className="min-h-screen pb-20 flex items-center justify-center px-6">
<Card className="p-10 max-w-md w-full text-center border-grayScale-200">
<p className="text-grayScale-600 font-medium">Loading definition</p>
</Card>
</div>
)
}
return ( return (
<div className="min-h-screen pb-20 overflow-x-hidden"> <div className="min-h-screen pb-20 overflow-x-hidden">
{/* Header */} <div className=" border-b border-grayScale-100 sticky top-0 z-50 bg-white/95 backdrop-blur">
<div className=" border-b border-grayScale-100 sticky top-0 z-50"> <div className="max-w-[1440px] mx-auto py-6 px-4 sm:px-6">
<div className="max-w-[1440px] mx-auto py-6">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<Link <Link
to="/new-content/question-types" to="/new-content/question-types"
@ -36,16 +284,29 @@ export function CreateQuestionTypeFlow() {
</Link> </Link>
</div> </div>
<div className="flex items-start justify-between"> <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight"> <h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight">
Create Question Type {isEdit ? "Edit question type definition" : "Create question type definition"}
</h1> </h1>
<p className="text-grayScale-500 text-[14px] font-medium"> <p className="text-grayScale-500 text-[14px] font-medium max-w-2xl">
Create a new immersive practice session for students. {isEdit ? (
<>
Update definition{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">#{editDefinitionId}</code> via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">PUT /questions/type-definitions/:id</code>
.
</>
) : (
<>
Build a reusable dynamic question type (schema + kinds) for{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions. Data is sent to{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/type-definitions</code>.
</>
)}
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4 shrink-0">
<Button <Button
variant="outline" variant="outline"
className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50" className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50"
@ -53,7 +314,10 @@ export function CreateQuestionTypeFlow() {
> >
Cancel Cancel
</Button> </Button>
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all"> <Button
className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all"
onClick={handleHeaderSaveDraft}
>
Save as Draft Save as Draft
</Button> </Button>
</div> </div>
@ -65,23 +329,38 @@ export function CreateQuestionTypeFlow() {
</div> </div>
</div> </div>
{/* Main Content */} <div className="max-w-[1440px] mx-auto px-4 sm:px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="max-w-[1440px] mx-auto px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500"> {currentStep === 1 && (
{currentStep === 1 && <QuestionTypeBasicInfoStep onNext={handleNext} />} <QuestionTypeBasicInfoStep
draft={draft}
setDraft={setDraft}
errors={stepErrors}
keyReadOnly={isEdit}
onNext={handleNextFromStep1}
/>
)}
{currentStep === 2 && ( {currentStep === 2 && (
<QuestionTypeConfigStep onNext={handleNext} onBack={handleBack} /> <QuestionTypeConfigStep
draft={draft}
setDraft={setDraft}
versionName={versionName}
setVersionName={setVersionName}
stimulusCatalogKinds={componentCatalog.stimulus_component_kinds}
responseCatalogKinds={componentCatalog.response_component_kinds}
catalogLoading={catalogLoading}
catalogError={catalogError}
errors={stepErrors}
onNext={handleNextFromStep2}
onBack={handleBack}
/>
)} )}
{currentStep > 2 && ( {currentStep === 3 && (
<div className="bg-white rounded-2xl p-12 text-center border border-grayScale-100 shadow-sm"> <QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
<p className="text-grayScale-400 font-medium"> )}
Step {currentStep} implementation in progress... {currentStep === 4 && (
</p> <QuestionTypeReviewPublishStep draft={draft} onBack={handleBack} editDefinitionId={editDefinitionId} />
<Button onClick={handleBack} variant="outline" className="mt-4">
Go Back
</Button>
</div>
)} )}
</div> </div>
</div> </div>
); )
} }

View File

@ -1,50 +1,116 @@
import { useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router-dom"; import { Link, useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, Plus, Search } from "lucide-react"; import { ArrowLeft, Plus, Search, Trash2 } from "lucide-react"
import { Button } from "../../components/ui/button"; import { toast } from "sonner"
import { Input } from "../../components/ui/input"; import { Button } from "../../components/ui/button"
import { Select } from "../../components/ui/select"; import { Input } from "../../components/ui/input"
import { Card } from "../../components/ui/card"; import { Card } from "../../components/ui/card"
import { cn } from "../../lib/utils"; import {
import { QuestionTypeCard } from "./components/QuestionTypeCard"; Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import { QuestionTypeCard } from "./components/QuestionTypeCard"
import {
deleteQuestionTypeDefinition,
getQuestionTypeDefinitions,
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
export function QuestionTypeLibraryPage() { export function QuestionTypeLibraryPage() {
const [activeTab, setActiveTab] = useState("All"); const navigate = useNavigate()
const [searchParams] = useSearchParams()
const createdId = searchParams.get("created")
const updatedId = searchParams.get("updated")
const questionTypes = [ const [loading, setLoading] = useState(true)
{ const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
title: "Describe a Photo", const [query, setQuery] = useState("")
exam: "DUOLINGO" as const, const [activeTab, setActiveTab] = useState<"All" | "ACTIVE" | "INACTIVE">("All")
skill: "Speaking" as const, const [definitionPendingDelete, setDefinitionPendingDelete] = useState<QuestionTypeDefinition | null>(null)
variations: 12, const [deleteSubmitting, setDeleteSubmitting] = useState(false)
status: "Published" as const,
}, const load = useCallback(async () => {
{ setLoading(true)
title: "Write About the Topic", try {
exam: "DUOLINGO" as const, const rows = await getQuestionTypeDefinitions({ include_system: true })
skill: "Writing" as const, setDefinitions(Array.isArray(rows) ? rows : [])
variations: 12, } catch (e) {
status: "Published" as const, console.error(e)
}, toast.error("Failed to load question type definitions")
{ setDefinitions([])
title: "Fill in the Blanks", } finally {
exam: "IELTS" as const, setLoading(false)
skill: "Writing" as const, }
variations: 12, }, [])
status: "Published" as const,
}, useEffect(() => {
{ void load()
title: "Describe a Photo", }, [load])
exam: "DUOLINGO" as const,
skill: "Speaking" as const, useEffect(() => {
variations: 12, if (createdId) {
status: "Published" as const, toast.success("Definition created", { description: `Id ${createdId}` })
}, }
]; }, [createdId])
useEffect(() => {
if (updatedId) {
toast.success("Definition updated", { description: `Id ${updatedId}` })
}
}, [updatedId])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return definitions.filter((d) => {
if (activeTab !== "All") {
const st = (d.status || "").toString().toUpperCase()
if (activeTab === "ACTIVE" && st !== "ACTIVE") return false
if (activeTab === "INACTIVE" && st !== "INACTIVE") return false
}
if (!q) return true
const name = (d.display_name || "").toLowerCase()
const key = (d.key || "").toLowerCase()
return name.includes(q) || key.includes(q) || String(d.id).includes(q)
})
}, [definitions, query, activeTab])
const openDeleteConfirm = (row: QuestionTypeDefinition) => {
if (row.is_system) {
toast.error("System definitions cannot be deleted.")
return
}
setDefinitionPendingDelete(row)
}
const handleDeleteDialogOpenChange = (open: boolean) => {
if (!open && !deleteSubmitting) setDefinitionPendingDelete(null)
}
const handleConfirmDeleteDefinition = async () => {
const row = definitionPendingDelete
if (!row) return
setDeleteSubmitting(true)
try {
await deleteQuestionTypeDefinition(row.id)
toast.success("Definition deleted")
setDefinitionPendingDelete(null)
void load()
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } }
toast.error(String(err.response?.data?.message || "Delete failed"))
} finally {
setDeleteSubmitting(false)
}
}
return ( return (
<div className="space-y-8 animate-in fade-in duration-500 pb-20"> <div className="space-y-8 animate-in fade-in duration-500 pb-20">
{/* Navigation & Header */}
<div className="space-y-6"> <div className="space-y-6">
<Link <Link
to="/new-content/courses" to="/new-content/courses"
@ -54,54 +120,43 @@ export function QuestionTypeLibraryPage() {
Back to Courses Back to Courses
</Link> </Link>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight"> <h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1>
Question Type Library <p className="text-grayScale-500 text-[16px] font-medium max-w-2xl">
</h1> Reusable dynamic question type templates from{" "}
<p className="text-grayScale-500 text-[16px] font-medium"> <code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/type-definitions</code>. Use them
Create and manage reusable question structures for practices and when authoring <code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions.
assessments.
</p> </p>
</div> </div>
<Link to="/new-content/question-types/create"> <Link to="/new-content/question-types/create">
<Button className="h-12 px-8 rounded-[10px] bg-[#9E2891] font-bold text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3"> <Button className="h-12 px-8 rounded-[10px] bg-[#9E2891] font-bold text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3">
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
Create Question Type Create definition
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
{/* Control Bar */}
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6"> <Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
<div className="flex items-center gap-4"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
<Input <Input
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm" className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm"
placeholder="Search by practice name, ID, or keywords..." placeholder="Search by display name, key, or id…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/> />
</div> </div>
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
<option>All Exams</option>
<option>IELTS</option>
<option>Duolingo</option>
</Select>
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
<option>All Skills</option>
<option>Speaking</option>
<option>Writing</option>
</Select>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 flex-wrap">
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2"> <span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">Status</span>
STATUS: {(["All", "ACTIVE", "INACTIVE"] as const).map((tab) => (
</span>
{["All", "Published", "Drafts", "Archived"].map((tab) => (
<button <button
key={tab} key={tab}
type="button"
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
className={cn( className={cn(
"h-10 px-4 rounded-full text-[13px] font-medium transition-all", "h-10 px-4 rounded-full text-[13px] font-medium transition-all",
@ -110,18 +165,82 @@ export function QuestionTypeLibraryPage() {
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100", : "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
)} )}
> >
{tab} {tab === "All" ? "All" : tab === "ACTIVE" ? "Active" : "Inactive"}
</button> </button>
))} ))}
</div> </div>
</Card> </Card>
{/* Grid of Cards */} {loading ? (
<div className="grid grid-cols-4 gap-6"> <p className="text-sm text-grayScale-500 px-2">Loading definitions</p>
{questionTypes.map((qt, index) => ( ) : filtered.length === 0 ? (
<QuestionTypeCard key={index} {...qt} /> <Card className="p-12 text-center border-dashed border-grayScale-200 rounded-2xl">
<p className="text-grayScale-600 font-medium">No definitions match your filters.</p>
<p className="text-sm text-grayScale-400 mt-2">Create one to get started.</p>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filtered.map((d) => (
<QuestionTypeCard
key={d.id}
id={d.id}
definitionKey={d.key}
display_name={d.display_name}
status={d.status}
is_system={d.is_system}
stimulusKindsCount={d.stimulus_component_kinds?.length ?? 0}
responseKindsCount={d.response_component_kinds?.length ?? 0}
deleteDisabled={!!d.is_system}
onEdit={() => navigate(`/new-content/question-types/${d.id}/edit`)}
onDelete={() => openDeleteConfirm(d)}
/>
))} ))}
</div> </div>
)}
<Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-grayScale-900">
<Trash2 className="h-5 w-5 text-red-600 shrink-0" aria-hidden />
Delete question type definition?
</DialogTitle>
<DialogDescription className="text-left text-grayScale-600">
This removes the template from the library. Existing questions that reference it may be affected. This
action cannot be undone.
</DialogDescription>
</DialogHeader>
{definitionPendingDelete ? (
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50 px-4 py-3 space-y-1">
<p className="text-sm font-semibold text-grayScale-900">{definitionPendingDelete.display_name}</p>
<p className="text-xs font-mono text-grayScale-500 break-all">
#{definitionPendingDelete.id} · {definitionPendingDelete.key}
</p>
</div> </div>
); ) : null}
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
className="border-grayScale-200"
disabled={deleteSubmitting}
onClick={() => setDefinitionPendingDelete(null)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
disabled={deleteSubmitting}
className="gap-2"
onClick={() => void handleConfirmDeleteDefinition()}
>
{deleteSubmitting ? <SpinnerIcon className="h-4 w-4" /> : <Trash2 className="h-4 w-4" aria-hidden />}
{deleteSubmitting ? "Deleting…" : "Delete definition"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
} }

View File

@ -1,83 +1,90 @@
import { Edit2, Trash2, Mic2, Keyboard, Layers, MicIcon } from "lucide-react"; import { Edit2, Trash2, Layers, Shield } from "lucide-react"
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge"
import { Card } from "../../../components/ui/card"; import { Card } from "../../../components/ui/card"
import { cn } from "../../../lib/utils"; import { Button } from "../../../components/ui/button"
import { cn } from "../../../lib/utils"
interface QuestionTypeCardProps { export interface QuestionTypeDefinitionCardModel {
title: string; id: number
exam: "DUOLINGO" | "IELTS" | "TOEFL"; definitionKey: string
skill: "Speaking" | "Writing" | "Listening" | "Reading"; display_name: string
variations: number; status?: string
status: "Published" | "Draft" | "Archived"; is_system?: boolean
stimulusKindsCount: number
responseKindsCount: number
onEdit?: () => void
onDelete?: () => void
deleteDisabled?: boolean
} }
export function QuestionTypeCard({ export function QuestionTypeCard({
title, id,
exam, definitionKey,
skill, display_name,
variations,
status, status,
}: QuestionTypeCardProps) { is_system,
const SkillIcon = skill === "Speaking" ? MicIcon : Keyboard; stimulusKindsCount,
responseKindsCount,
const examColors = { onEdit,
DUOLINGO: "bg-[#22C55EE5] text-[#fff] border-transparent", onDelete,
IELTS: "bg-[#EF4444E5] text-[#fff] border-transparent", deleteDisabled,
TOEFL: "bg-[#DBEAFE] text-[#fff] border-transparent", }: QuestionTypeDefinitionCardModel) {
}; const statusLabel = (status || "—").toString()
const isActive = statusLabel.toUpperCase() === "ACTIVE"
const statusColors = {
Published: "bg-[#F0FDF4] text-[#16A34A]",
Draft: "bg-grayScale-50 text-grayScale-500",
Archived: "bg-red-50 text-red-500",
};
return ( return (
<Card className="group overflow-hidden border-grayScale-200 rounded-[12px] bg-white transition-all duration-300"> <Card className="group overflow-hidden border-grayScale-200 rounded-[12px] bg-white transition-all duration-300">
<div className="px-4 py-6 space-y-8"> <div className="px-4 py-6 space-y-4">
<h3 className="text-[20px] font-bold text-grayScale-900 leading-[1.2]"> <div className="flex items-start justify-between gap-2">
{title} <h3 className="text-[18px] font-bold text-grayScale-900 leading-[1.2]">{display_name}</h3>
</h3> {is_system ? (
<Badge className="shrink-0 border-none bg-violet-100 text-violet-800 flex items-center gap-1">
<div className="flex items-center justify-between"> <Shield className="h-3 w-3" />
<Badge System
className={cn(
"px-3 py-1 rounded-[4px] text-[11px] font-bold tracking-wider shadow-none border-none",
examColors[exam],
)}
>
{exam}
</Badge> </Badge>
<div className="flex items-center gap-1 text-grayScale-900 font-bold text-[13px]"> ) : null}
<SkillIcon className="h-4 w-4" />
{skill}
</div>
</div> </div>
<div className="flex items-center gap-2.5 text-[#9E2891] font-medium text-[15px]"> <p className="text-[12px] font-mono text-grayScale-500 break-all">#{id} · {definitionKey}</p>
<Layers className="h-[16px] w-[16px]" />
{variations} Variations <div className="flex flex-wrap items-center gap-2 text-grayScale-700 font-medium text-[14px]">
<Layers className="h-4 w-4 text-[#9E2891]" />
<span>
{stimulusKindsCount} stimulus kinds · {responseKindsCount} response kinds
</span>
</div> </div>
<div className="pt-4 flex items-center justify-between border-t border-grayScale-200"> <div className="pt-4 flex items-center justify-between border-t border-grayScale-200 gap-2">
<Badge <Badge
className={cn( className={cn(
"px-3 py-1 rounded-[4px] text-[12px] font-bold shadow-none border-none", "px-3 py-1 rounded-[4px] text-[12px] font-bold shadow-none border-none",
statusColors[status], isActive ? "bg-[#F0FDF4] text-[#16A34A]" : "bg-grayScale-50 text-grayScale-600",
)} )}
> >
{status} {statusLabel}
</Badge> </Badge>
<div className="flex items-center gap-5 transition-opacity"> <div className="flex items-center gap-1">
<button className="text-grayScale-500/70 transition-all"> {onEdit ? (
<Edit2 className="h-5 w-5" /> <Button type="button" variant="ghost" size="sm" className="h-9 w-9 p-0" onClick={onEdit} aria-label="Edit">
</button> <Edit2 className="h-4 w-4 text-grayScale-500" />
<button className="text-grayScale-500/70 transition-all"> </Button>
<Trash2 className="h-5 w-5" /> ) : null}
</button> {onDelete ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 p-0"
disabled={deleteDisabled}
onClick={onDelete}
aria-label="Delete"
>
<Trash2 className="h-4 w-4 text-grayScale-500 disabled:opacity-30" />
</Button>
) : null}
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
); )
} }

View File

@ -0,0 +1,47 @@
import { Check } from "lucide-react"
import { cn } from "../../../../lib/utils"
import type { LucideIcon } from "lucide-react"
interface ComponentKindCardProps {
label: string
Icon: LucideIcon
selected: boolean
onClick: () => void
}
export function ComponentKindCard({ label, Icon, selected, onClick }: ComponentKindCardProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex flex-col items-start justify-between p-4 min-h-[132px] rounded-[16px] border text-left transition-all group relative",
selected
? "border-[#9E2891] bg-white shadow-[0_4px_12px_rgba(158,40,145,0.08)] ring-1 ring-[#9E2891]"
: "border-grayScale-200 bg-white hover:border-grayScale-300 hover:bg-grayScale-50/80",
)}
>
<div className="w-full flex items-start justify-between gap-2">
<div
className={cn(
"h-12 w-12 rounded-xl flex items-center justify-center shrink-0 transition-colors",
selected ? "bg-[#9E2891] text-white" : "bg-[#F1F5F9] text-grayScale-600 group-hover:bg-grayScale-100",
)}
>
<Icon className="h-6 w-6" />
</div>
<div
className={cn(
"h-6 w-6 rounded-full border-2 flex items-center justify-center shrink-0 transition-all",
selected ? "border-[#9E2891] bg-white" : "border-grayScale-300 bg-white",
)}
>
{selected ? <Check className="h-3.5 w-3.5 text-[#9E2891] stroke-[3]" /> : null}
</div>
</div>
<span className={cn("text-[15px] font-bold leading-tight mt-2", selected ? "text-grayScale-900" : "text-grayScale-800")}>
{label}
</span>
</button>
)
}

View File

@ -1,150 +1,118 @@
import { useState } from "react"; import { ArrowRight } from "lucide-react"
import { X, ArrowRight } from "lucide-react"; 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 { Select } from "../../../../components/ui/select"; import { Textarea } from "../../../../components/ui/textarea"
import { Badge } from "../../../../components/ui/badge"; import { Select } from "../../../../components/ui/select"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import type { FieldErrorMap } from "../../lib/questionTypeDefinitionValidation"
interface QuestionTypeBasicInfoStepProps { interface QuestionTypeBasicInfoStepProps {
onNext: () => void; draft: QuestionTypeDefinitionCreatePayload
setDraft: React.Dispatch<React.SetStateAction<QuestionTypeDefinitionCreatePayload>>
errors: FieldErrorMap
onNext: () => void
/** When editing an existing definition, the key is immutable on the server */
keyReadOnly?: boolean
} }
export function QuestionTypeBasicInfoStep({ export function QuestionTypeBasicInfoStep({
draft,
setDraft,
errors,
onNext, onNext,
keyReadOnly,
}: QuestionTypeBasicInfoStepProps) { }: QuestionTypeBasicInfoStepProps) {
const [selectedChips, setSelectedChips] = useState([
"Multiple Choice",
"Sentence Completion",
]);
const suggestions = ["Matching Headings", "True/False/NG"];
const removeChip = (chip: string) => {
setSelectedChips(selectedChips.filter((c) => c !== chip));
};
const addChip = (chip: string) => {
if (!selectedChips.includes(chip)) {
setSelectedChips([...selectedChips, chip]);
}
};
return ( return (
<div className="space-y-8 pb-32"> <div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white"> <Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200"> <div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900"> <h2 className="text-[20px] font-medium text-grayScale-900">STEP 1: Definition basics</h2>
STEP 1: Basic Info
</h2>
<p className="text-grayScale-500 font-medium mt-1"> <p className="text-grayScale-500 font-medium mt-1">
Define what this question type is and where it applies. Set the reusable key, display name, and status. On the next step you will pick stimulus and response
component types from the live catalog (
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/component-catalog</code>
).
</p> </p>
</div> </div>
<div className="p-10 space-y-10"> <div className="p-10 space-y-8">
{/* Top Row: Course Type & Skill Category */} <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-2 gap-10"> <div className="space-y-2">
<div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1"> <label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
Course Type <span className="text-red-500">*</span> Key <span className="text-red-500">*</span>
</label> </label>
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all "> <Input
<option>Select an exam type</option> className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] disabled:opacity-70"
<option>IELTS</option> placeholder="e.g. dynamic_visual_mcq_001"
<option>Duolingo</option> value={draft.key}
<option>TOEFL</option> onChange={(e) => setDraft((d) => ({ ...d, key: e.target.value }))}
</Select> readOnly={keyReadOnly}
<p className="text-grayScale-400 text-[13px] font-medium leading-relaxed"> disabled={keyReadOnly}
The core framework for the practice test. />
{errors.key ? <p className="text-sm text-red-600">{errors.key}</p> : null}
<p className="text-grayScale-400 text-[13px] font-medium">
{keyReadOnly
? "Key cannot be changed when editing an existing definition."
: "Unique slug-like identifier stored on the definition."}
</p> </p>
</div> </div>
<div className="space-y-3"> <div className="space-y-2">
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1"> <label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
Skill Category <span className="text-red-500">*</span> Display name <span className="text-red-500">*</span>
</label> </label>
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all "> <Input
<option>Select a skill</option> className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC]"
<option>Speaking</option> placeholder="e.g. Speak About the Photo"
<option>Writing</option> value={draft.display_name}
<option>Listening</option> onChange={(e) => setDraft((d) => ({ ...d, display_name: e.target.value }))}
<option>Reading</option> />
</Select> {errors.display_name ? <p className="text-sm text-red-600">{errors.display_name}</p> : null}
</div> </div>
</div> </div>
{/* Question Type */} <div className="space-y-2">
<div className="space-y-3"> <label className="text-[14px] font-medium text-grayScale-700">Description</label>
<label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1"> <Textarea
Question Type <span className="text-red-500">*</span> className="min-h-[100px] rounded-[12px] border-grayScale-300 bg-[#F8FAFC]"
</label> placeholder="Optional description for admins"
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all "> value={draft.description ?? ""}
<option>Single Format</option> onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
<option>Mixed Format</option>
</Select>
</div>
{/* Question Types Chip Input */}
<div className="space-y-3">
<label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1">
Question Types <span className="text-red-500">*</span>
</label>
<div className="min-h-[56px] p-3 flex flex-wrap gap-2.5 rounded-[12px] border border-grayScale-300 bg-[#F8FAFC]">
{selectedChips.map((chip) => (
<Badge
key={chip}
className="bg-[#9E28911A] text-[#9E2891] border-[#9E289133] px-2 py-0 rounded-full text-[13px] font-medium flex items-center gap-2"
>
{chip}
<button
onClick={() => removeChip(chip)}
className="hover:text-red-500 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</Badge>
))}
<input
className="flex-1 min-w-[150px] bg-transparent border-none focus:ring-0 text-[14px] font-medium text-grayScale-900 px-3 placeholder:text-grayScale-400"
placeholder="Add question types..."
/> />
</div> </div>
<div className="flex items-center gap-3 pt-1"> <div className="space-y-2 max-w-xs">
<span className="text-[13px] font-medium text-grayScale-500"> <label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
Suggestions: Status <span className="text-red-500">*</span>
</span> </label>
<div className="flex items-center gap-2"> <Select
{suggestions.map((s) => ( className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC]"
<button value={draft.status}
key={s} onChange={(e) =>
onClick={() => addChip(s)} setDraft((d) => ({
className="px-3 py-1.5 rounded-[6px] border border-grayScale-300 text-[13px] font-medium text-grayScale-600 hover:bg-grayScale-50 hover:text-[#9E2891] hover:border-[#9E2891]/20 transition-all" ...d,
status: e.target.value === "INACTIVE" ? "INACTIVE" : "ACTIVE",
}))
}
> >
{s} <option value="ACTIVE">Active</option>
</button> <option value="INACTIVE">Inactive (draft)</option>
))} </Select>
</div>
</div>
</div> </div>
</div> </div>
{/* Footer */} <div className="px-4 py-4 border border-grayScale-200 flex items-center justify-end bg-[#F8FAFC]">
<div className="px-4 py-4 border border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-none shadow-none text-grayScale-600 font-bold hover:bg-grayScale-100"
>
Cancel
</Button>
<Button <Button
type="button"
onClick={onNext} onClick={onNext}
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3" className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3"
> >
Next: Structure Next: Input and answer types
<ArrowRight className="h-5 w-5" /> <ArrowRight className="h-5 w-5" />
</Button> </Button>
</div> </div>
</Card> </Card>
</div> </div>
); )
} }

View File

@ -0,0 +1,175 @@
import { useState } from "react"
import { ArrowLeft, Loader2 } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card"
import {
createQuestionTypeDefinition,
extractDefinitionMutationId,
updateQuestionTypeDefinition,
validateQuestionTypeDefinition,
} from "../../../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation"
interface QuestionTypeReviewPublishStepProps {
draft: QuestionTypeDefinitionCreatePayload
onBack: () => void
/** When set, saves via PUT /questions/type-definitions/:id */
editDefinitionId?: number | null
}
export function QuestionTypeReviewPublishStep({
draft,
onBack,
editDefinitionId,
}: QuestionTypeReviewPublishStepProps) {
const navigate = useNavigate()
const [submitting, setSubmitting] = useState(false)
const isEdit = editDefinitionId != null && editDefinitionId > 0
const payload = buildCreatePayload(draft)
const submit = async (status: "ACTIVE" | "INACTIVE") => {
const body = { ...payload, status }
setSubmitting(true)
try {
if (isEdit) {
const res = await updateQuestionTypeDefinition(editDefinitionId, body)
const id = extractDefinitionMutationId(res) ?? editDefinitionId
toast.success(res.data?.message || "Question type definition updated", {
description: `Definition id: ${id}`,
})
navigate(`/new-content/question-types?updated=${id}`)
return
}
const validation = await validateQuestionTypeDefinition(body)
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
})
return
}
const res = await createQuestionTypeDefinition(body)
const id = extractDefinitionMutationId(res)
if (id == null) {
toast.error(
res.data?.message ?? "Definition may not have been created: response did not include an id.",
)
return
}
toast.success(res.data?.message || "Question type definition created", {
description: `Definition id: ${id}`,
})
navigate(`/new-content/question-types?created=${id}`)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string; error?: string } } }
const msg =
err.response?.data?.message ||
(e instanceof Error ? e.message : isEdit ? "Update failed" : "Create failed")
const detail = err.response?.data?.error
toast.error(String(msg), { description: detail ? String(detail) : undefined })
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 4: Review & publish</h2>
<p className="text-grayScale-500 font-medium mt-1">
{isEdit ? (
<>
Confirm changes, then update via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">
PUT /questions/type-definitions/{editDefinitionId}
</code>
.
</>
) : (
<>
Confirm details, then create via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/type-definitions</code>.
</>
)}
</p>
</div>
<div className="p-10 space-y-6">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Key</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.key}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Display name</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.display_name}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Description</dt>
<dd className="font-medium text-grayScale-800 mt-1">{payload.description || "—"}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Status</dt>
<dd className="font-medium text-grayScale-900 mt-1">{draft.status}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus kinds</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.stimulus_component_kinds.join(", ") || "—"}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Response kinds</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.response_component_kinds.join(", ") || "—"}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus schema rows</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.stimulus_schema.length}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Response schema rows</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.response_schema.length}</dd>
</div>
</dl>
<div className="flex flex-wrap gap-3 pt-2">
<Button
type="button"
variant="outline"
className="h-11"
disabled={submitting}
onClick={() => void submit("INACTIVE")}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : isEdit ? "Save as inactive" : "Save as inactive (draft)"}
</Button>
<Button
type="button"
className="h-11 bg-[#9E2891] hover:bg-[#8A237E] text-white"
disabled={submitting}
onClick={() => void submit("ACTIVE")}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : isEdit ? "Save as active" : "Create as active"}
</Button>
</div>
</div>
<div className="px-4 py-4 border border-grayScale-200 flex items-center justify-start bg-[#F8FAFC]">
<Button
type="button"
variant="outline"
className="h-10 px-6 rounded-[6px] border-none shadow-none text-grayScale-600 font-bold hover:bg-grayScale-100"
onClick={onBack}
disabled={submitting}
>
<ArrowLeft className="h-4 w-4 mr-2 inline" />
Back
</Button>
</div>
</Card>
</div>
)
}

View File

@ -0,0 +1,119 @@
import { useState } from "react"
import { ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card"
import { validateQuestionTypeDefinition } from "../../../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation"
interface QuestionTypeValidatePreviewStepProps {
draft: QuestionTypeDefinitionCreatePayload
onNext: () => void
onBack: () => void
}
export function QuestionTypeValidatePreviewStep({
draft,
onNext,
onBack,
}: QuestionTypeValidatePreviewStepProps) {
const [validating, setValidating] = useState(false)
const [serverOk, setServerOk] = useState<boolean | null>(null)
const [serverDetail, setServerDetail] = useState<string | null>(null)
const payload = buildCreatePayload(draft)
const json = JSON.stringify(payload, null, 2)
const runValidate = async () => {
setValidating(true)
setServerOk(null)
setServerDetail(null)
try {
const res = await validateQuestionTypeDefinition(payload)
if (!res.valid) {
setServerOk(false)
const detail = res.error || res.message || "Validation failed"
setServerDetail(detail)
toast.error(res.message || "Invalid question type definition", { description: res.error })
return
}
setServerOk(true)
setServerDetail(res.message || "Question type definition is valid.")
toast.success(res.message || "Question type definition is valid")
} finally {
setValidating(false)
}
}
return (
<div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 3: Validate & preview</h2>
<p className="text-grayScale-500 font-medium mt-1">
Optional server check via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/validate-question-type-definition</code>
. Uses the same JSON as create; validity comes from <code className="text-xs bg-grayScale-100 px-1 rounded">data.valid</code>{" "}
(not the envelope <code className="text-xs bg-grayScale-100 px-1 rounded">success</code> flag).
</p>
</div>
<div className="p-10 space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={runValidate}
disabled={validating}
className="h-10"
>
{validating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Validating
</>
) : (
"Validate with server"
)}
</Button>
{serverOk === true ? (
<span className="flex items-center gap-1.5 text-sm font-medium text-green-700">
<CheckCircle2 className="h-4 w-4" />
{serverDetail}
</span>
) : null}
{serverOk === false ? <span className="text-sm font-medium text-red-600">{serverDetail}</span> : null}
</div>
<div className="space-y-2">
<p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">Create payload (JSON)</p>
<pre className="text-[12px] leading-relaxed font-mono bg-grayScale-900 text-grayScale-50 rounded-xl p-4 overflow-x-auto max-h-[420px] overflow-y-auto">
{json}
</pre>
</div>
</div>
<div className="px-4 py-4 border border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
<Button
type="button"
variant="outline"
className="h-10 px-6 rounded-[6px] border-none shadow-none text-grayScale-600 font-bold hover:bg-grayScale-100"
onClick={onBack}
>
<ArrowLeft className="h-4 w-4 mr-2 inline" />
Back
</Button>
<Button
type="button"
onClick={onNext}
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3"
>
Next: Review
<ArrowRight className="h-5 w-5" />
</Button>
</div>
</Card>
</div>
)
}

View File

@ -0,0 +1,194 @@
import { Plus, Trash2 } from "lucide-react"
import { Button } from "../../../../components/ui/button"
import { Input } from "../../../../components/ui/input"
import { Textarea } from "../../../../components/ui/textarea"
import { Select } from "../../../../components/ui/select"
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
type Side = "stimulus" | "response"
interface SchemaBuilderSectionProps {
title: string
side: Side
allowedKinds: string[]
catalogKinds: string[]
rows: DynamicElementDefinition[]
onChange: (rows: DynamicElementDefinition[]) => void
error?: string
rowErrors?: Record<number, string>
}
function emptyRow(allowedKinds: string[]): DynamicElementDefinition {
const first = allowedKinds[0] ?? ""
return {
id: "",
kind: first,
label: "",
required: true,
config: undefined,
}
}
export function SchemaBuilderSection({
title,
side,
allowedKinds,
catalogKinds,
rows,
onChange,
error,
rowErrors,
}: SchemaBuilderSectionProps) {
const kindOptions =
allowedKinds.length > 0 ? allowedKinds : catalogKinds.length > 0 ? catalogKinds : []
const updateRow = (index: number, patch: Partial<DynamicElementDefinition>) => {
const next = rows.map((r, i) => (i === index ? { ...r, ...patch } : r))
onChange(next)
}
const commitConfigString = (index: number, raw: string) => {
const trimmed = raw.trim()
if (!trimmed) {
updateRow(index, { config: undefined })
return
}
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
updateRow(index, { config: parsed })
}
} catch {
/* keep previous config; user can fix JSON */
}
}
const removeRow = (index: number) => {
onChange(rows.filter((_, i) => i !== index))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-[16px] font-bold text-grayScale-900">{title}</h3>
<p className="text-[13px] text-grayScale-500 mt-0.5">
Each row defines one element in the{" "}
{side === "stimulus" ? "stimulus" : "response"} schema (id, kind, label, required, optional JSON
config).
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => onChange([...rows, emptyRow(kindOptions)])}
disabled={!kindOptions.length}
>
<Plus className="h-4 w-4 mr-1.5" />
Add row
</Button>
</div>
{error ? <p className="text-sm font-medium text-red-600">{error}</p> : null}
{!kindOptions.length ? (
<p className="text-sm text-grayScale-500 rounded-lg border border-dashed border-grayScale-200 p-4">
Select component kinds in the previous step (or wait for the catalog to load) before building the{" "}
{side} schema.
</p>
) : null}
<div className="space-y-3">
{rows.map((row, index) => (
<div
key={`${side}-${index}`}
className="rounded-xl border border-grayScale-200 bg-[#F8FAFC] p-4 space-y-3"
>
<div className="flex items-start justify-between gap-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">
Row {index + 1}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-grayScale-500 hover:text-red-600"
onClick={() => removeRow(index)}
aria-label="Remove row"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-grayScale-600">
Element id <span className="text-red-500">*</span>
</label>
<Input
value={row.id}
onChange={(e) => updateRow(index, { id: e.target.value })}
placeholder="e.g. prompt"
className="h-10 bg-white"
/>
</div>
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-grayScale-600">
Kind <span className="text-red-500">*</span>
</label>
<Select
className="h-10 bg-white"
value={row.kind}
onChange={(e) => updateRow(index, { kind: e.target.value })}
>
{kindOptions.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-grayScale-600">Label</label>
<Input
value={row.label ?? ""}
onChange={(e) => updateRow(index, { label: e.target.value })}
placeholder="Author-facing label"
className="h-10 bg-white"
/>
</div>
<label className="flex items-center gap-2 pt-6 md:pt-8">
<input
type="checkbox"
checked={row.required}
onChange={(e) => updateRow(index, { required: e.target.checked })}
className="rounded border-grayScale-300"
/>
<span className="text-[13px] font-medium text-grayScale-700">Required</span>
</label>
</div>
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-grayScale-600">Config (JSON object)</label>
<Textarea
className="min-h-[72px] font-mono text-[13px] bg-white"
placeholder='{"max_length": 1000}'
defaultValue={row.config ? JSON.stringify(row.config, null, 2) : ""}
key={`${side}-cfg-${index}-${rows.length}`}
onBlur={(e) => commitConfigString(index, e.target.value)}
/>
<p className="text-[11px] text-grayScale-400">Tab out of the field to apply JSON config.</p>
</div>
{rowErrors?.[index] ? <p className="text-sm text-red-600">{rowErrors[index]}</p> : null}
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,104 @@
import type { LucideIcon } from "lucide-react"
import {
BarChart3,
CheckSquare,
CircleDot,
Clock,
FileText,
FileUp,
GitBranch,
GitCompare,
Image as ImageIcon,
Info,
Link2,
ListOrdered,
ListTodo,
Mic2,
MousePointer2,
Table as TableIcon,
Type,
Volume2,
} from "lucide-react"
/** Human label for API kind codes; unknown kinds fall back to title-cased code. */
const STIMULUS_LABELS: Record<string, string> = {
QUESTION_TEXT: "Question Text",
PREP_TIME: "Prep Time",
INSTRUCTION: "Instruction",
AUDIO_PROMPT: "Audio Prompt",
AUDIO_CLIP: "Audio Clip",
TEXT_PASSAGE: "Text Passage",
IMAGE: "Image",
CHART: "Chart",
MATCHING_INPUTS: "Matching Inputs",
SELECT_MISSING_WORDS: "Select Missing Words",
TABLE: "Table",
FLOW_CHART: "Flow Chart",
}
const RESPONSE_LABELS: Record<string, string> = {
AUDIO_RESPONSE: "Audio Response",
TEXT_INPUT: "Text Input",
SHORT_ANSWER: "Short Answer",
MULTIPLE_CHOICE: "Multiple Choice",
OPTION: "Options",
ANSWER_TIMER: "Answer Timer",
SELECT_MISSING_WORDS: "Select Missing Words",
PDF_UPLOAD: "PDF Upload",
MATCHING_ANSWER: "Matching Answer",
LABEL_SELECTION: "Label Selection",
SEQUENCE_ORDER: "Sequence Order",
}
/** Legacy screenshot labels → map to closest API kind for display only (same code path). */
const STIMULUS_ICONS: Record<string, LucideIcon> = {
QUESTION_TEXT: FileText,
PREP_TIME: Clock,
INSTRUCTION: Info,
AUDIO_PROMPT: Volume2,
AUDIO_CLIP: Volume2,
TEXT_PASSAGE: FileText,
IMAGE: ImageIcon,
CHART: BarChart3,
MATCHING_INPUTS: Link2,
SELECT_MISSING_WORDS: ListTodo,
TABLE: TableIcon,
FLOW_CHART: GitBranch,
}
const RESPONSE_ICONS: Record<string, LucideIcon> = {
AUDIO_RESPONSE: Mic2,
TEXT_INPUT: Type,
SHORT_ANSWER: ListTodo,
MULTIPLE_CHOICE: CheckSquare,
OPTION: CircleDot,
ANSWER_TIMER: Clock,
SELECT_MISSING_WORDS: ListTodo,
PDF_UPLOAD: FileUp,
MATCHING_ANSWER: GitCompare,
LABEL_SELECTION: MousePointer2,
SEQUENCE_ORDER: ListOrdered,
}
const DEFAULT_ICON = FileText
function humanizeKind(kind: string): string {
return kind
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function getStimulusKindPresentation(kind: string): { label: string; Icon: LucideIcon } {
return {
label: STIMULUS_LABELS[kind] ?? humanizeKind(kind),
Icon: STIMULUS_ICONS[kind] ?? DEFAULT_ICON,
}
}
export function getResponseKindPresentation(kind: string): { label: string; Icon: LucideIcon } {
return {
label: RESPONSE_LABELS[kind] ?? humanizeKind(kind),
Icon: RESPONSE_ICONS[kind] ?? DEFAULT_ICON,
}
}

View File

@ -0,0 +1,144 @@
import type {
DynamicElementDefinition,
QuestionComponentCatalog,
QuestionTypeDefinitionCreatePayload,
} from "../../../types/questionTypeDefinition.types"
export type FieldErrorMap = Record<string, string>
function uniqueStrings(values: string[]): boolean {
return new Set(values).size === values.length
}
function idsUniqueAndNonEmpty(rows: DynamicElementDefinition[], prefix: string, errors: FieldErrorMap) {
const ids = rows.map((r) => r.id?.trim()).filter(Boolean) as string[]
if (ids.length !== rows.length) {
errors[`${prefix}_ids`] = "Each schema row needs a non-empty id."
return
}
if (!uniqueStrings(ids)) {
errors[`${prefix}_ids`] = "Schema ids must be unique within this section."
}
}
export function validateDefinitionKinds(
payload: {
stimulus_component_kinds: string[]
response_component_kinds: string[]
},
catalog?: QuestionComponentCatalog | null,
): FieldErrorMap {
const errors: FieldErrorMap = {}
const { stimulus_component_kinds: sk, response_component_kinds: rk } = payload
if (!sk.length) errors.stimulus_kinds = "Select at least one stimulus component kind."
if (!rk.length) errors.response_kinds = "Select at least one response component kind."
if (sk.length && !uniqueStrings(sk)) errors.stimulus_kinds = "Stimulus kinds must be unique (no duplicates)."
if (rk.length && !uniqueStrings(rk)) errors.response_kinds = "Response kinds must be unique (no duplicates)."
if (rk.length === 1 && rk[0] === "ANSWER_TIMER") {
errors.response_kinds = "ANSWER_TIMER cannot be the only response kind."
}
if (catalog) {
const sCat = new Set(catalog.stimulus_component_kinds)
const rCat = new Set(catalog.response_component_kinds)
if (sCat.size > 0 && sk.length) {
const invalid = sk.filter((k) => !sCat.has(k))
if (invalid.length) {
const msg = `Not in stimulus catalog: ${invalid.join(", ")}.`
errors.stimulus_kinds = errors.stimulus_kinds ? `${errors.stimulus_kinds} ${msg}` : msg
}
}
if (rCat.size > 0 && rk.length) {
const invalid = rk.filter((k) => !rCat.has(k))
if (invalid.length) {
const msg = `Not in response catalog: ${invalid.join(", ")}.`
errors.response_kinds = errors.response_kinds ? `${errors.response_kinds} ${msg}` : msg
}
}
}
return errors
}
export function validateDefinitionSchemas(
payload: Pick<
QuestionTypeDefinitionCreatePayload,
"stimulus_schema" | "response_schema" | "stimulus_component_kinds" | "response_component_kinds"
>,
catalog: { stimulus: Set<string>; response: Set<string> },
): FieldErrorMap {
const errors: FieldErrorMap = {}
const validateSide = (
rows: DynamicElementDefinition[],
allowed: string[],
side: "stimulus" | "response",
) => {
const prefix = side
if (!rows.length) {
errors[`${prefix}_schema`] = `Add at least one ${side} schema row.`
return
}
idsUniqueAndNonEmpty(rows, prefix, errors)
const allowedSet = new Set(allowed)
const catalogSet = side === "stimulus" ? catalog.stimulus : catalog.response
rows.forEach((row, i) => {
if (!row.kind) errors[`${prefix}_${i}`] = "Kind is required."
else if (allowedSet.size && !allowedSet.has(row.kind)) {
errors[`${prefix}_${i}`] = `Kind "${row.kind}" is not in selected ${side} kinds.`
}
if (catalogSet.size && row.kind && !catalogSet.has(row.kind)) {
errors[`${prefix}_${i}`] = `Kind "${row.kind}" is not in the ${side} component catalog.`
}
})
}
validateSide(payload.stimulus_schema, payload.stimulus_component_kinds, "stimulus")
validateSide(payload.response_schema, payload.response_component_kinds, "response")
return errors
}
export function validateDefinitionBasic(payload: {
key: string
display_name: string
}): FieldErrorMap {
const errors: FieldErrorMap = {}
if (!payload.key?.trim()) errors.key = "Key is required (slug-like, unique)."
if (!payload.display_name?.trim()) errors.display_name = "Display name is required."
if (payload.key && !/^[a-z0-9][a-z0-9_-]*$/i.test(payload.key.trim())) {
errors.key = "Use letters, numbers, underscores, or hyphens; start with a letter or number."
}
return errors
}
export function buildCreatePayload(
draft: QuestionTypeDefinitionCreatePayload,
): QuestionTypeDefinitionCreatePayload {
return {
...draft,
key: draft.key.trim(),
display_name: draft.display_name.trim(),
description: draft.description?.trim() || null,
stimulus_component_kinds: [...draft.stimulus_component_kinds],
response_component_kinds: [...draft.response_component_kinds],
stimulus_schema: draft.stimulus_schema.map((r) => ({
...r,
id: r.id.trim(),
kind: r.kind.trim(),
label: r.label?.trim() || undefined,
config: r.config && Object.keys(r.config).length ? r.config : undefined,
})),
response_schema: draft.response_schema.map((r) => ({
...r,
id: r.id.trim(),
kind: r.kind.trim(),
label: r.label?.trim() || undefined,
config: r.config && Object.keys(r.config).length ? r.config : undefined,
})),
}
}

View File

@ -1,3 +1,5 @@
import type { DynamicQuestionPayload } from "./questionTypeDefinition.types"
export interface CourseCategory { export interface CourseCategory {
id: number id: number
name: string name: string
@ -1043,6 +1045,10 @@ export interface CreateQuestionRequest {
sample_answer_voice_prompt?: string sample_answer_voice_prompt?: string
audio_correct_answer_text?: string audio_correct_answer_text?: string
short_answers?: string[] | { acceptable_answer: string; match_type: "EXACT" | "CASE_INSENSITIVE" }[] short_answers?: string[] | { acceptable_answer: string; match_type: "EXACT" | "CASE_INSENSITIVE" }[]
/** Required for `DYNAMIC` questions — links to `/questions/type-definitions/:id` */
question_type_definition_id?: number
/** Required for `DYNAMIC` — stimulus/response element instances per definition schema */
dynamic_payload?: DynamicQuestionPayload
} }
export interface CreateQuestionResponse { export interface CreateQuestionResponse {
@ -1076,6 +1082,8 @@ export interface QuestionDetail {
voice_prompt?: string | null voice_prompt?: string | null
sample_answer_voice_prompt?: string | null sample_answer_voice_prompt?: string | null
audio_correct_answer_text?: string | null audio_correct_answer_text?: string | null
question_type_definition_id?: number | null
dynamic_payload?: DynamicQuestionPayload | null
} }
export interface GetQuestionDetailResponse { export interface GetQuestionDetailResponse {

View File

@ -0,0 +1,58 @@
/** Dynamic question type definition builder (admin) — aligns with POST /questions/type-definitions */
/** GET /questions/component-catalog — `data` object shape */
export interface QuestionComponentCatalog {
stimulus_component_kinds: string[]
response_component_kinds: string[]
}
export type DefinitionStatus = "ACTIVE" | "INACTIVE"
export interface DynamicElementDefinition {
id: string
kind: string
label?: string
required: boolean
config?: Record<string, unknown>
}
export interface QuestionTypeDefinitionCreatePayload {
key: string
display_name: string
description?: string | null
stimulus_component_kinds: string[]
response_component_kinds: string[]
stimulus_schema: DynamicElementDefinition[]
response_schema: DynamicElementDefinition[]
status: DefinitionStatus
}
/** PUT /questions/type-definitions/:id — any subset of create fields */
export type QuestionTypeDefinitionUpdatePayload = Partial<QuestionTypeDefinitionCreatePayload>
/** POST /questions/validate-question-type-definition — body is often kinds-only; full create shape is also accepted */
export type QuestionTypeDefinitionValidatePayload = Partial<QuestionTypeDefinitionCreatePayload>
/** Parsed outcome (do not rely on envelope `success`; it may be false when `data.valid` is true) */
export type ValidateQuestionTypeDefinitionResult =
| { valid: true; message?: string }
| { valid: false; message?: string; error?: string }
export interface QuestionTypeDefinition extends QuestionTypeDefinitionCreatePayload {
id: number
is_system?: boolean
created_at?: string
updated_at?: string
}
export interface DynamicElementInstance {
id: string
kind: string
value?: unknown
meta?: Record<string, unknown>
}
export interface DynamicQuestionPayload {
stimulus: DynamicElementInstance[]
response: DynamicElementInstance[]
}