qustion type builder integartion fix
This commit is contained in:
parent
457d09f02b
commit
9b35a8bf30
|
|
@ -285,10 +285,20 @@ export async function getQuestionTypeDefinitions(params?: {
|
||||||
return parseDefinitionsList(raw)
|
return parseDefinitionsList(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getQuestionTypeDefinitionById(id: number) {
|
/**
|
||||||
|
* GET /questions/type-definitions/:id
|
||||||
|
*
|
||||||
|
* Typical success body (axios `res.data`): envelope with nested `data` or `Data` holding the definition.
|
||||||
|
* Definition fields are often PascalCase (`ID`, `Key`, `DisplayName`, `StimulusComponentKinds`, `StimulusSchema`,
|
||||||
|
* `ResponseSchema`, `IsSystem`, `Status`, `CreatedAt`, `UpdatedAt`). Envelope `success` may be false; parsing
|
||||||
|
* does not rely on it.
|
||||||
|
*/
|
||||||
|
export async function getQuestionTypeDefinitionById(id: number): Promise<QuestionTypeDefinition | undefined> {
|
||||||
const res = await http.get<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`)
|
const res = await http.get<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`)
|
||||||
const def = unwrapApiPayload(res)
|
const fromEnvelope = unwrapApiPayload(res)
|
||||||
return normalizeTypeDefinitionFromApi(def) ?? undefined
|
return (
|
||||||
|
normalizeTypeDefinitionFromApi(fromEnvelope) ?? normalizeTypeDefinitionFromApi(res.data) ?? undefined
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateQuestionTypeDefinition(
|
export async function updateQuestionTypeDefinition(
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,15 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
|
Plus,
|
||||||
} from "lucide-react"
|
} 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 { Input } from "../../../../components/ui/input"
|
||||||
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
|
import type {
|
||||||
|
DynamicElementDefinition,
|
||||||
|
QuestionTypeDefinitionCreatePayload,
|
||||||
|
} from "../../../../types/questionTypeDefinition.types"
|
||||||
import type { FieldErrorMap } from "../../lib/questionTypeDefinitionValidation"
|
import type { FieldErrorMap } from "../../lib/questionTypeDefinitionValidation"
|
||||||
import { SchemaBuilderSection } from "./SchemaBuilderSection"
|
import { SchemaBuilderSection } from "./SchemaBuilderSection"
|
||||||
import { ComponentKindCard } from "./ComponentKindCard"
|
import { ComponentKindCard } from "./ComponentKindCard"
|
||||||
|
|
@ -33,6 +37,27 @@ function toggleKind(list: string[], kind: string): string[] {
|
||||||
return list.includes(kind) ? list.filter((k) => k !== kind) : [...list, kind]
|
return list.includes(kind) ? list.filter((k) => k !== kind) : [...list, kind]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slugFragmentFromKind(kind: string): string {
|
||||||
|
const s = (kind || "field").toLowerCase().replace(/[^a-z0-9]+/g, "_")
|
||||||
|
return s.replace(/^_|_$/g, "") || "field"
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSchemaLabel(kind: string): string {
|
||||||
|
return kind.replace(/_/g, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: string): string {
|
||||||
|
const base = slugFragmentFromKind(kind)
|
||||||
|
const existing = new Set(rows.map((r) => r.id.trim()).filter(Boolean))
|
||||||
|
let n = 1
|
||||||
|
let id = `${base}_${n}`
|
||||||
|
while (existing.has(id)) {
|
||||||
|
n++
|
||||||
|
id = `${base}_${n}`
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Record<number, string> {
|
function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Record<number, string> {
|
||||||
const prefix = `${side}_`
|
const prefix = `${side}_`
|
||||||
const out: Record<number, string> = {}
|
const out: Record<number, string> = {}
|
||||||
|
|
@ -63,6 +88,82 @@ export function QuestionTypeConfigStep({
|
||||||
|
|
||||||
const title = draft.display_name?.trim() || "Untitled definition"
|
const title = draft.display_name?.trim() || "Untitled definition"
|
||||||
|
|
||||||
|
const handleStimulusKindClick = (kind: string) => {
|
||||||
|
setDraft((d) => {
|
||||||
|
const wasSelected = d.stimulus_component_kinds.includes(kind)
|
||||||
|
const stimulus_component_kinds = toggleKind(d.stimulus_component_kinds, kind)
|
||||||
|
if (!wasSelected) {
|
||||||
|
const stimulus_schema = [...d.stimulus_schema]
|
||||||
|
if (!stimulus_schema.some((r) => r.kind === kind)) {
|
||||||
|
stimulus_schema.push({
|
||||||
|
id: nextUniqueSchemaElementId(stimulus_schema, kind),
|
||||||
|
kind,
|
||||||
|
label: defaultSchemaLabel(kind),
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { ...d, stimulus_component_kinds, stimulus_schema }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
stimulus_component_kinds,
|
||||||
|
stimulus_schema: d.stimulus_schema.filter((r) => r.kind !== kind),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResponseKindClick = (kind: string) => {
|
||||||
|
setDraft((d) => {
|
||||||
|
const wasSelected = d.response_component_kinds.includes(kind)
|
||||||
|
const response_component_kinds = toggleKind(d.response_component_kinds, kind)
|
||||||
|
if (!wasSelected) {
|
||||||
|
const response_schema = [...d.response_schema]
|
||||||
|
if (!response_schema.some((r) => r.kind === kind)) {
|
||||||
|
response_schema.push({
|
||||||
|
id: nextUniqueSchemaElementId(response_schema, kind),
|
||||||
|
kind,
|
||||||
|
label: defaultSchemaLabel(kind),
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { ...d, response_component_kinds, response_schema }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
response_component_kinds,
|
||||||
|
response_schema: d.response_schema.filter((r) => r.kind !== kind),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addStimulusSlot = (kind: string) => {
|
||||||
|
setDraft((d) => {
|
||||||
|
if (!d.stimulus_component_kinds.includes(kind)) return d
|
||||||
|
const stimulus_schema = [...d.stimulus_schema]
|
||||||
|
stimulus_schema.push({
|
||||||
|
id: nextUniqueSchemaElementId(stimulus_schema, kind),
|
||||||
|
kind,
|
||||||
|
label: defaultSchemaLabel(kind),
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
return { ...d, stimulus_schema }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addResponseSlot = (kind: string) => {
|
||||||
|
setDraft((d) => {
|
||||||
|
if (!d.response_component_kinds.includes(kind)) return d
|
||||||
|
const response_schema = [...d.response_schema]
|
||||||
|
response_schema.push({
|
||||||
|
id: nextUniqueSchemaElementId(response_schema, kind),
|
||||||
|
kind,
|
||||||
|
label: defaultSchemaLabel(kind),
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
return { ...d, response_schema }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 pb-32">
|
<div className="space-y-8 pb-32">
|
||||||
<Card className="max-w-6xl mx-auto overflow-hidden border border-grayScale-200 shadow-sm rounded-2xl bg-white">
|
<Card className="max-w-6xl mx-auto overflow-hidden border border-grayScale-200 shadow-sm rounded-2xl bg-white">
|
||||||
|
|
@ -116,26 +217,43 @@ export function QuestionTypeConfigStep({
|
||||||
Section A: Question input types
|
Section A: Question input types
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
||||||
Choose how the question is presented to the learner.
|
Choose how the question is presented to the learner. The API lists each kind once in{" "}
|
||||||
|
<code className="text-[11px] bg-grayScale-100 px-1 rounded">stimulus_component_kinds</code>{" "}
|
||||||
|
while <code className="text-[11px] bg-grayScale-100 px-1 rounded">stimulus_schema</code> can
|
||||||
|
include the same kind multiple times (different ids).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||||
{stimulusCatalogKinds.map((kind) => {
|
{stimulusCatalogKinds.map((kind) => {
|
||||||
const { label, Icon } = getStimulusKindPresentation(kind)
|
const { label, Icon } = getStimulusKindPresentation(kind)
|
||||||
const selected = draft.stimulus_component_kinds.includes(kind)
|
const selected = draft.stimulus_component_kinds.includes(kind)
|
||||||
|
const slotCount = draft.stimulus_schema.filter((r) => r.kind === kind).length
|
||||||
return (
|
return (
|
||||||
<ComponentKindCard
|
<div key={kind} className="space-y-2">
|
||||||
key={kind}
|
<ComponentKindCard
|
||||||
label={label}
|
label={label}
|
||||||
Icon={Icon}
|
Icon={Icon}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onClick={() =>
|
onClick={() => handleStimulusKindClick(kind)}
|
||||||
setDraft((d) => ({
|
/>
|
||||||
...d,
|
{selected ? (
|
||||||
stimulus_component_kinds: toggleKind(d.stimulus_component_kinds, kind),
|
<div className="flex items-center justify-between gap-2 px-0.5 min-h-[32px]">
|
||||||
}))
|
<span className="text-[12px] text-grayScale-500 font-medium">
|
||||||
}
|
{slotCount} slot{slotCount === 1 ? "" : "s"}
|
||||||
/>
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
|
||||||
|
onClick={() => addStimulusSlot(kind)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Add slot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -150,26 +268,43 @@ export function QuestionTypeConfigStep({
|
||||||
Section B: Answer types
|
Section B: Answer types
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
||||||
How should the student answer this question?
|
How should the student answer?{" "}
|
||||||
|
<code className="text-[11px] bg-grayScale-100 px-1 rounded">response_component_kinds</code> is
|
||||||
|
deduplicated; use <span className="font-medium text-grayScale-600">Add slot</span> for multiple
|
||||||
|
fields of the same kind.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||||
{responseCatalogKinds.map((kind) => {
|
{responseCatalogKinds.map((kind) => {
|
||||||
const { label, Icon } = getResponseKindPresentation(kind)
|
const { label, Icon } = getResponseKindPresentation(kind)
|
||||||
const selected = draft.response_component_kinds.includes(kind)
|
const selected = draft.response_component_kinds.includes(kind)
|
||||||
|
const slotCount = draft.response_schema.filter((r) => r.kind === kind).length
|
||||||
return (
|
return (
|
||||||
<ComponentKindCard
|
<div key={kind} className="space-y-2">
|
||||||
key={kind}
|
<ComponentKindCard
|
||||||
label={label}
|
label={label}
|
||||||
Icon={Icon}
|
Icon={Icon}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onClick={() =>
|
onClick={() => handleResponseKindClick(kind)}
|
||||||
setDraft((d) => ({
|
/>
|
||||||
...d,
|
{selected ? (
|
||||||
response_component_kinds: toggleKind(d.response_component_kinds, kind),
|
<div className="flex items-center justify-between gap-2 px-0.5 min-h-[32px]">
|
||||||
}))
|
<span className="text-[12px] text-grayScale-500 font-medium">
|
||||||
}
|
{slotCount} slot{slotCount === 1 ? "" : "s"}
|
||||||
/>
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
|
||||||
|
onClick={() => addResponseSlot(kind)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Add slot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -116,29 +116,47 @@ export function validateDefinitionBasic(payload: {
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Unique kinds used in schema rows (API expects deduplicated lists; schema may repeat the same kind). */
|
||||||
|
function uniqueKindsFromSchemaRows(rows: DynamicElementDefinition[]): string[] {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const r of rows) {
|
||||||
|
const k = r.kind?.trim()
|
||||||
|
if (k) set.add(k)
|
||||||
|
}
|
||||||
|
return [...set].sort((a, b) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
|
||||||
export function buildCreatePayload(
|
export function buildCreatePayload(
|
||||||
draft: QuestionTypeDefinitionCreatePayload,
|
draft: QuestionTypeDefinitionCreatePayload,
|
||||||
): QuestionTypeDefinitionCreatePayload {
|
): QuestionTypeDefinitionCreatePayload {
|
||||||
|
const 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,
|
||||||
|
}))
|
||||||
|
const 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,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const stimulusKindsFromSchema = uniqueKindsFromSchemaRows(stimulus_schema)
|
||||||
|
const responseKindsFromSchema = uniqueKindsFromSchemaRows(response_schema)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...draft,
|
...draft,
|
||||||
key: draft.key.trim(),
|
key: draft.key.trim(),
|
||||||
display_name: draft.display_name.trim(),
|
display_name: draft.display_name.trim(),
|
||||||
description: draft.description?.trim() || null,
|
description: draft.description?.trim() || null,
|
||||||
stimulus_component_kinds: [...draft.stimulus_component_kinds],
|
stimulus_component_kinds:
|
||||||
response_component_kinds: [...draft.response_component_kinds],
|
stimulusKindsFromSchema.length > 0 ? stimulusKindsFromSchema : [...draft.stimulus_component_kinds],
|
||||||
stimulus_schema: draft.stimulus_schema.map((r) => ({
|
response_component_kinds:
|
||||||
...r,
|
responseKindsFromSchema.length > 0 ? responseKindsFromSchema : [...draft.response_component_kinds],
|
||||||
id: r.id.trim(),
|
stimulus_schema,
|
||||||
kind: r.kind.trim(),
|
response_schema,
|
||||||
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,
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user