dynamic question type builder integration
This commit is contained in:
parent
d1fcfc19b0
commit
457d09f02b
303
src/api/questionTypeDefinitions.api.ts
Normal file
303
src/api/questionTypeDefinitions.api.ts
Normal 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}`)
|
||||||
|
}
|
||||||
|
|
@ -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 />}
|
||||||
|
|
|
||||||
|
|
@ -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&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'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" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{currentStep === 2 && (
|
draft={draft}
|
||||||
<QuestionTypeConfigStep onNext={handleNext} onBack={handleBack} />
|
setDraft={setDraft}
|
||||||
|
errors={stepErrors}
|
||||||
|
keyReadOnly={isEdit}
|
||||||
|
onNext={handleNextFromStep1}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep > 2 && (
|
{currentStep === 2 && (
|
||||||
<div className="bg-white rounded-2xl p-12 text-center border border-grayScale-100 shadow-sm">
|
<QuestionTypeConfigStep
|
||||||
<p className="text-grayScale-400 font-medium">
|
draft={draft}
|
||||||
Step {currentStep} implementation in progress...
|
setDraft={setDraft}
|
||||||
</p>
|
versionName={versionName}
|
||||||
<Button onClick={handleBack} variant="outline" className="mt-4">
|
setVersionName={setVersionName}
|
||||||
Go Back
|
stimulusCatalogKinds={componentCatalog.stimulus_component_kinds}
|
||||||
</Button>
|
responseCatalogKinds={componentCatalog.response_component_kinds}
|
||||||
</div>
|
catalogLoading={catalogLoading}
|
||||||
|
catalogError={catalogError}
|
||||||
|
errors={stepErrors}
|
||||||
|
onNext={handleNextFromStep2}
|
||||||
|
onBack={handleBack}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 3 && (
|
||||||
|
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
|
||||||
|
)}
|
||||||
|
{currentStep === 4 && (
|
||||||
|
<QuestionTypeReviewPublishStep draft={draft} onBack={handleBack} editDefinitionId={editDefinitionId} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</div>
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
) : 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>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
</Badge>
|
||||||
"px-3 py-1 rounded-[4px] text-[11px] font-bold tracking-wider shadow-none border-none",
|
) : null}
|
||||||
examColors[exam],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{exam}
|
|
||||||
</Badge>
|
|
||||||
<div className="flex items-center gap-1 text-grayScale-900 font-bold text-[13px]">
|
|
||||||
<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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Question Type */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1">
|
|
||||||
Question Type <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
|
|
||||||
<option>Single Format</option>
|
|
||||||
<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..."
|
|
||||||
/>
|
/>
|
||||||
|
{errors.display_name ? <p className="text-sm text-red-600">{errors.display_name}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="space-y-2">
|
||||||
<span className="text-[13px] font-medium text-grayScale-500">
|
<label className="text-[14px] font-medium text-grayScale-700">Description</label>
|
||||||
Suggestions:
|
<Textarea
|
||||||
</span>
|
className="min-h-[100px] rounded-[12px] border-grayScale-300 bg-[#F8FAFC]"
|
||||||
<div className="flex items-center gap-2">
|
placeholder="Optional description for admins"
|
||||||
{suggestions.map((s) => (
|
value={draft.description ?? ""}
|
||||||
<button
|
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
|
||||||
key={s}
|
/>
|
||||||
onClick={() => addChip(s)}
|
</div>
|
||||||
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"
|
|
||||||
>
|
<div className="space-y-2 max-w-xs">
|
||||||
{s}
|
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
|
||||||
</button>
|
Status <span className="text-red-500">*</span>
|
||||||
))}
|
</label>
|
||||||
</div>
|
<Select
|
||||||
</div>
|
className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC]"
|
||||||
|
value={draft.status}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
status: e.target.value === "INACTIVE" ? "INACTIVE" : "ACTIVE",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="ACTIVE">Active</option>
|
||||||
|
<option value="INACTIVE">Inactive (draft)</option>
|
||||||
|
</Select>
|
||||||
</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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
58
src/types/questionTypeDefinition.types.ts
Normal file
58
src/types/questionTypeDefinition.types.ts
Normal 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[]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user