question creation UI fixes

This commit is contained in:
Yared Yemane 2026-06-06 03:47:00 -07:00
parent 095e690a68
commit b21c679e56
11 changed files with 2119 additions and 20 deletions

View File

@ -0,0 +1,241 @@
import { Plus, Trash2 } from "lucide-react"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import {
addMatchingInputRow,
addMatchingPair,
defaultMatchingAnswerFromInputs,
MATCHING_MIN_ITEMS,
parseMatchingAnswerSlotValue,
parseMatchingInputsSlotValue,
removeMatchingInputRow,
removeMatchingPair,
serializeMatchingAnswerSlotValue,
serializeMatchingInputsSlotValue,
type MatchingAnswerSlotValue,
type MatchingInputsSlotValue,
} from "../../lib/matchingSlotValue"
export function DynamicMatchingInputsSlot({
value,
onChange,
disabled,
slotLabel,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
}) {
const parsed = parseMatchingInputsSlotValue(value)
const updateValue = (next: MatchingInputsSlotValue) => {
onChange(serializeMatchingInputsSlotValue(next))
}
const updateSide = (
side: "left" | "right",
index: number,
text: string,
) => {
const next = {
left: [...parsed.left],
right: [...parsed.right],
}
next[side][index] = { ...next[side][index], text }
updateValue(next)
}
const rowCount = Math.max(parsed.left.length, parsed.right.length)
const canRemove = rowCount > MATCHING_MIN_ITEMS
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addMatchingInputRow(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add row
</Button>
</div>
<div className="space-y-3">
{Array.from({ length: rowCount }).map((_, index) => (
<div
key={`matching-row-${index}`}
className="grid gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3 md:grid-cols-[1fr_1fr_auto]"
>
<div className="space-y-1">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Left {parsed.left[index]?.id ?? `l${index + 1}`}
</p>
<Input
value={parsed.left[index]?.text ?? ""}
onChange={(e) => updateSide("left", index, e.target.value)}
placeholder={`Left item ${index + 1}`}
className="rounded-lg border-grayScale-200 bg-white"
disabled={disabled}
/>
</div>
<div className="space-y-1">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Right {parsed.right[index]?.id ?? `r${index + 1}`}
</p>
<Input
value={parsed.right[index]?.text ?? ""}
onChange={(e) => updateSide("right", index, e.target.value)}
placeholder={`Right item ${index + 1}`}
className="rounded-lg border-grayScale-200 bg-white"
disabled={disabled}
/>
</div>
<div className="flex items-end justify-end">
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || !canRemove}
className="h-9 w-9 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove row ${index + 1}`}
onClick={() => updateValue(removeMatchingInputRow(parsed, index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
<p className="text-[11px] text-grayScale-500">
Minimum {MATCHING_MIN_ITEMS} rows on each side. Whitespace in text is preserved.
</p>
</div>
)
}
export function DynamicMatchingAnswerSlot({
value,
onChange,
disabled,
slotLabel,
matchingInputs,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
matchingInputs: MatchingInputsSlotValue | null
}) {
const parsed = parseMatchingAnswerSlotValue(value, matchingInputs)
const updateValue = (next: MatchingAnswerSlotValue) => {
onChange(serializeMatchingAnswerSlotValue(next))
}
const leftOptions = matchingInputs?.left ?? []
const rightOptions = matchingInputs?.right ?? []
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<div className="flex flex-wrap gap-2">
{matchingInputs ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 rounded-lg"
onClick={() =>
updateValue(defaultMatchingAnswerFromInputs(matchingInputs))
}
>
Reset from inputs
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addMatchingPair(parsed, matchingInputs))}
>
<Plus className="h-3.5 w-3.5" />
Add pair
</Button>
</div>
</div>
{!matchingInputs ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
Fill in matching inputs first so answer pairs can reference left and right ids.
</p>
) : null}
<div className="space-y-2">
{parsed.pairs.map((pair, index) => (
<div
key={`pair-${index}-${pair.left_id}-${pair.right_id}`}
className="flex flex-wrap items-center gap-2"
>
<select
value={pair.left_id}
disabled={disabled || leftOptions.length === 0}
onChange={(e) => {
const pairs = [...parsed.pairs]
pairs[index] = { ...pairs[index], left_id: e.target.value }
updateValue({ pairs })
}}
className="h-10 min-w-[120px] rounded-lg border border-grayScale-200 bg-white px-3 text-sm"
>
{leftOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.id}
{item.text ? `: ${item.text.slice(0, 40)}` : ""}
</option>
))}
</select>
<span className="text-sm text-grayScale-400"></span>
<select
value={pair.right_id}
disabled={disabled || rightOptions.length === 0}
onChange={(e) => {
const pairs = [...parsed.pairs]
pairs[index] = { ...pairs[index], right_id: e.target.value }
updateValue({ pairs })
}}
className="h-10 min-w-[120px] rounded-lg border border-grayScale-200 bg-white px-3 text-sm"
>
{rightOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.id}
{item.text ? `: ${item.text.slice(0, 40)}` : ""}
</option>
))}
</select>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || parsed.pairs.length <= 1}
className="h-8 w-8 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove pair ${index + 1}`}
onClick={() => updateValue(removeMatchingPair(parsed, index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)
}

View File

@ -6,7 +6,7 @@ import {
type ChangeEvent,
type DragEvent,
} from "react"
import { CloudUpload, FileText, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react"
import { CloudUpload, FileText, Image as ImageIcon, Mic, Pause, Play, Plus, Trash2, X } from "lucide-react"
import { toast } from "sonner"
import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api"
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
@ -18,6 +18,30 @@ import { cn } from "../../lib/utils"
import { ResolvedImage } from "../media/ResolvedImage"
import { DynamicTableBuilder } from "./DynamicTableBuilder"
import { slotLabel } from "../../lib/schemaSlotLabel"
import {
addMultipleChoiceOption,
parseMultipleChoiceSlotValue,
removeMultipleChoiceOption,
serializeMultipleChoiceSlotValue,
MULTIPLE_CHOICE_MIN_OPTIONS,
type MultipleChoiceSlotValue,
} from "../../lib/multipleChoiceSlotValue"
import {
DynamicMatchingAnswerSlot,
DynamicMatchingInputsSlot,
} from "./DynamicMatchingSlotField"
import {
findMatchingInputsInFieldValues,
type MatchingInputsSlotValue,
} from "../../lib/matchingSlotValue"
import {
DynamicSelectMissingWordsAnswerSlot,
DynamicSelectMissingWordsStimulusSlot,
} from "./DynamicSelectMissingWordsSlotField"
import {
findSelectMissingWordsStimulusInFieldValues,
type SelectMissingWordsStimulusValue,
} from "../../lib/selectMissingWordsSlotValue"
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
@ -31,12 +55,49 @@ export interface DynamicSchemaSlotRow {
required?: boolean
}
function isMultipleChoiceKind(kind: string): boolean {
const u = kind.trim().toUpperCase()
return u === "MULTIPLE_CHOICE" || u === "OPTION"
}
function isMatchingInputsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
}
function isMatchingAnswerKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
}
function isSelectMissingWordsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
}
function slotMediaMode(
kind: string,
): "image" | "audio" | "pdf" | "table" | "seconds" | "text" {
side: "stimulus" | "response",
):
| "image"
| "audio"
| "pdf"
| "table"
| "seconds"
| "text"
| "multiple_choice"
| "matching_inputs"
| "matching_answer"
| "select_missing_words_stimulus"
| "select_missing_words_answer" {
const u = kind.trim().toUpperCase()
if (u === "IMAGE") return "image"
if (u === "TABLE") return "table"
if (isMultipleChoiceKind(kind)) return "multiple_choice"
if (isMatchingInputsKind(kind)) return "matching_inputs"
if (isMatchingAnswerKind(kind)) return "matching_answer"
if (isSelectMissingWordsKind(kind)) {
return side === "response"
? "select_missing_words_answer"
: "select_missing_words_stimulus"
}
if (u === "PDF_ATTACHMENT" || u === "PDF_UPLOAD") return "pdf"
if (u === "PREP_TIME" || u === "ANSWER_TIMER") return "seconds"
if (u === "AUDIO_PROMPT" || u === "AUDIO_CLIP" || u === "AUDIO_RESPONSE") return "audio"
@ -567,11 +628,114 @@ function DynamicAudioSlot({
)
}
function DynamicMultipleChoiceSlot({
value,
onChange,
disabled,
slotLabel: label,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
}) {
const parsed = parseMultipleChoiceSlotValue(value)
const updateValue = (next: MultipleChoiceSlotValue) => {
onChange(serializeMultipleChoiceSlotValue(next))
}
const updateOptions = (next: MultipleChoiceSlotValue["options"]) => {
updateValue({ options: next })
}
const canRemove = parsed.options.length > MULTIPLE_CHOICE_MIN_OPTIONS
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{label}</label>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addMultipleChoiceOption(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add option
</Button>
</div>
<div className="space-y-2">
{parsed.options.map((option, index) => (
<div
key={`${option.id}-${index}`}
className="flex flex-wrap items-center gap-2 sm:flex-nowrap"
>
<span className="w-6 shrink-0 text-xs font-mono text-grayScale-400">
{option.id}
</span>
<Input
value={option.text}
onChange={(e) => {
const options = [...parsed.options]
options[index] = { ...options[index], text: e.target.value }
updateOptions(options)
}}
className="min-w-0 flex-1 rounded-lg border-grayScale-200"
placeholder={`Choice ${index + 1}`}
disabled={disabled}
/>
<label className="flex shrink-0 items-center gap-2 text-sm text-grayScale-600">
<input
type="radio"
name={`mcq-correct-${label}`}
checked={option.is_correct}
disabled={disabled}
onChange={() => {
updateOptions(
parsed.options.map((opt, i) => ({
...opt,
is_correct: i === index,
})),
)
}}
/>
Correct
</label>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || !canRemove}
className="h-8 w-8 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove choice ${option.id}`}
onClick={() =>
updateValue(removeMultipleChoiceOption(parsed, index))
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<p className="text-[11px] text-grayScale-500">
Minimum {MULTIPLE_CHOICE_MIN_OPTIONS} options. Mark one as correct.
</p>
</div>
)
}
export interface DynamicSchemaSlotFieldProps {
row: DynamicSchemaSlotRow
value: string
onChange: (next: string) => void
disabled?: boolean
side: "stimulus" | "response"
allFieldValues?: Record<string, string>
stimulusSchema?: DynamicSchemaSlotRow[]
responseSchema?: DynamicSchemaSlotRow[]
}
function DynamicPdfSlot({
@ -678,9 +842,28 @@ export function DynamicSchemaSlotField({
value,
onChange,
disabled = false,
side,
allFieldValues,
stimulusSchema = [],
responseSchema = [],
}: DynamicSchemaSlotFieldProps) {
const mode = slotMediaMode(row.kind)
const mode = slotMediaMode(row.kind, side)
const fieldLabel = `${slotLabel(row)}${row.required ? " *" : ""}`
const matchingInputs: MatchingInputsSlotValue | null =
mode === "matching_answer" && allFieldValues
? findMatchingInputsInFieldValues(
allFieldValues,
stimulusSchema,
responseSchema,
)
: null
const clozeStimulus: SelectMissingWordsStimulusValue | null =
mode === "select_missing_words_answer" && allFieldValues
? findSelectMissingWordsStimulusInFieldValues(
allFieldValues,
stimulusSchema,
)
: null
if (mode === "table") {
return (
@ -713,6 +896,63 @@ export function DynamicSchemaSlotField({
)
}
if (mode === "multiple_choice") {
return (
<DynamicMultipleChoiceSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={fieldLabel}
/>
)
}
if (mode === "matching_inputs") {
return (
<DynamicMatchingInputsSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={fieldLabel}
/>
)
}
if (mode === "matching_answer") {
return (
<DynamicMatchingAnswerSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={fieldLabel}
matchingInputs={matchingInputs}
/>
)
}
if (mode === "select_missing_words_stimulus") {
return (
<DynamicSelectMissingWordsStimulusSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={fieldLabel}
/>
)
}
if (mode === "select_missing_words_answer") {
return (
<DynamicSelectMissingWordsAnswerSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={fieldLabel}
stimulus={clozeStimulus}
/>
)
}
if (mode === "text") {
return (
<div className="space-y-2">

View File

@ -0,0 +1,275 @@
import { Plus, Trash2 } from "lucide-react"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { Textarea } from "../ui/textarea"
import {
addBlankSegment,
addTextSegment,
addWordBankItem,
defaultSelectMissingWordsResponseFromStimulus,
parseSelectMissingWordsResponseSlotValue,
parseSelectMissingWordsStimulusSlotValue,
removeSegment,
removeWordBankItem,
SELECT_MISSING_WORDS_MIN_BANK,
serializeSelectMissingWordsResponseSlotValue,
serializeSelectMissingWordsStimulusSlotValue,
setAllowReuse,
syncResponseBlanksWithStimulus,
updateTextSegment,
updateWordBankText,
type SelectMissingWordsStimulusValue,
} from "../../lib/selectMissingWordsSlotValue"
export function DynamicSelectMissingWordsStimulusSlot({
value,
onChange,
disabled,
slotLabel,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
}) {
const parsed = parseSelectMissingWordsStimulusSlotValue(value)
const updateValue = (next: SelectMissingWordsStimulusValue) => {
onChange(serializeSelectMissingWordsStimulusSlotValue(next))
}
const canRemoveWord = parsed.word_bank.length > SELECT_MISSING_WORDS_MIN_BANK
const canRemoveSegment = parsed.segments.length > 1
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addTextSegment(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add text
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addBlankSegment(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add blank
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Passage segments
</p>
{parsed.segments.map((segment, index) => (
<div
key={`segment-${index}-${segment.type === "blank" ? segment.id : "text"}`}
className="flex flex-wrap items-start gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3"
>
{segment.type === "text" ? (
<div className="min-w-0 flex-1 space-y-1">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Text
</p>
<Textarea
rows={2}
value={segment.value}
onChange={(e) =>
updateValue(updateTextSegment(parsed, index, e.target.value))
}
placeholder="Text before or after a blank"
className="min-h-[56px] resize-y rounded-lg border-grayScale-200 bg-white font-mono text-sm"
disabled={disabled}
/>
</div>
) : (
<div className="flex min-h-[56px] flex-1 items-center rounded-lg border border-dashed border-brand-200 bg-brand-50/40 px-3">
<span className="text-sm font-medium text-brand-700">
Blank {segment.id}
</span>
</div>
)}
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || !canRemoveSegment}
className="h-9 w-9 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove segment ${index + 1}`}
onClick={() => updateValue(removeSegment(parsed, index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Word bank
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addWordBankItem(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add word
</Button>
</div>
{parsed.word_bank.map((item, index) => (
<div
key={`word-${item.id}`}
className="flex flex-wrap items-center gap-2"
>
<span className="w-10 text-xs font-medium text-grayScale-500">{item.id}</span>
<Input
value={item.text}
onChange={(e) =>
updateValue(updateWordBankText(parsed, index, e.target.value))
}
placeholder={`Word ${index + 1}`}
className="h-10 flex-1 rounded-lg border-grayScale-200 bg-white"
disabled={disabled}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || !canRemoveWord}
className="h-9 w-9 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove word ${item.id}`}
onClick={() => updateValue(removeWordBankItem(parsed, index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<label className="flex items-center gap-2 text-sm text-grayScale-700">
<input
type="checkbox"
checked={parsed.allow_reuse}
disabled={disabled}
onChange={(e) => updateValue(setAllowReuse(parsed, e.target.checked))}
className="h-4 w-4 rounded border-grayScale-300"
/>
Allow word reuse across blanks
</label>
<p className="text-[11px] text-grayScale-500">
Minimum {SELECT_MISSING_WORDS_MIN_BANK} words in the bank and at least one blank.
Whitespace in text is preserved.
</p>
</div>
)
}
export function DynamicSelectMissingWordsAnswerSlot({
value,
onChange,
disabled,
slotLabel,
stimulus,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
stimulus: SelectMissingWordsStimulusValue | null
}) {
const parsed = parseSelectMissingWordsResponseSlotValue(value, stimulus)
const synced = stimulus
? syncResponseBlanksWithStimulus(parsed, stimulus)
: parsed
const updateValue = (next: ReturnType<typeof parseSelectMissingWordsResponseSlotValue>) => {
onChange(serializeSelectMissingWordsResponseSlotValue(next))
}
const wordOptions =
stimulus?.word_bank.filter((item) => item.text.length > 0) ?? []
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
{stimulus ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 rounded-lg"
onClick={() =>
updateValue(defaultSelectMissingWordsResponseFromStimulus(stimulus))
}
>
Reset from blanks
</Button>
) : null}
</div>
{!stimulus ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
Fill in the cloze passage and word bank first so answers can reference blank and word ids.
</p>
) : null}
<div className="space-y-2">
{synced.blanks.map((blank, index) => (
<div
key={`blank-answer-${blank.blank_id}`}
className="flex flex-wrap items-center gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3"
>
<span className="w-10 text-xs font-medium text-grayScale-500">
{blank.blank_id}
</span>
<select
value={blank.word_id}
disabled={disabled || wordOptions.length === 0}
onChange={(e) => {
const selected = wordOptions.find((item) => item.id === e.target.value)
const blanks = [...synced.blanks]
blanks[index] = {
blank_id: blank.blank_id,
word_id: e.target.value,
text: selected?.text ?? "",
}
updateValue({ blanks })
}}
className="h-10 min-w-[160px] flex-1 rounded-lg border border-grayScale-200 bg-white px-3 text-sm"
>
<option value="">Select word</option>
{wordOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.id}
{item.text ? `: ${item.text}` : ""}
</option>
))}
</select>
</div>
))}
</div>
</div>
)
}

View File

@ -812,9 +812,13 @@ export function PracticeQuestionEditorFields({
>
<DynamicSchemaSlotField
row={row}
side="stimulus"
value={value.dynamicFieldValues[`stimulus:${row.id}`] ?? ""}
onChange={(next) => setDynamicField(`stimulus:${row.id}`, next)}
disabled={controlsDisabled}
allFieldValues={value.dynamicFieldValues}
stimulusSchema={value.dynamicStimulusRows}
responseSchema={value.dynamicResponseRows}
/>
</div>
))}
@ -830,9 +834,13 @@ export function PracticeQuestionEditorFields({
>
<DynamicSchemaSlotField
row={row}
side="response"
value={value.dynamicFieldValues[`response:${row.id}`] ?? ""}
onChange={(next) => setDynamicField(`response:${row.id}`, next)}
disabled={controlsDisabled}
allFieldValues={value.dynamicFieldValues}
stimulusSchema={value.dynamicStimulusRows}
responseSchema={value.dynamicResponseRows}
/>
</div>
))}

View File

@ -2,12 +2,89 @@ import type { CreateQuestionRequest, QuestionOption } from "../types/course.type
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
import {
parseMultipleChoiceSlotValue,
serializeMultipleChoiceSlotValue,
defaultMultipleChoiceSlotValue,
validateMultipleChoiceSlotValue,
multipleChoiceSlotHasContent,
} from "./multipleChoiceSlotValue"
import {
defaultMatchingInputsSlotValue,
findMatchingInputsInFieldValues,
matchingAnswerSlotHasContent,
matchingInputsSlotHasContent,
parseMatchingAnswerSlotValue,
parseMatchingInputsSlotValue,
serializeMatchingAnswerSlotValue,
serializeMatchingInputsSlotValue,
validateMatchingAnswerSlotValue,
validateMatchingInputsSlotValue,
} from "./matchingSlotValue"
import {
defaultSelectMissingWordsStimulusSlotValue,
findSelectMissingWordsStimulusInFieldValues,
parseSelectMissingWordsResponseSlotValue,
parseSelectMissingWordsStimulusSlotValue,
selectMissingWordsResponseHasContent,
selectMissingWordsStimulusHasContent,
serializeSelectMissingWordsResponseSlotValue,
serializeSelectMissingWordsStimulusSlotValue,
validateSelectMissingWordsResponseSlotValue,
validateSelectMissingWordsStimulusSlotValue,
} from "./selectMissingWordsSlotValue"
function defaultValueForSchemaSlot(kind: string): string {
function isMultipleChoiceKind(kind: string): boolean {
const u = kind.trim().toUpperCase()
return u === "MULTIPLE_CHOICE" || u === "OPTION"
}
function isMatchingInputsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
}
function isMatchingAnswerKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
}
function isSelectMissingWordsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
}
function isStructuredDynamicSlotKind(kind: string): boolean {
return (
isMultipleChoiceKind(kind) ||
isMatchingInputsKind(kind) ||
isMatchingAnswerKind(kind) ||
isSelectMissingWordsKind(kind)
)
}
function defaultValueForSchemaSlot(
kind: string,
side: "stimulus" | "response",
): string {
const u = kind.trim().toUpperCase()
if (u === "TABLE") {
return serializeTableSlotValue(createEmptyTable(2, 1))
}
if (isMultipleChoiceKind(kind)) {
return serializeMultipleChoiceSlotValue(defaultMultipleChoiceSlotValue())
}
if (isMatchingInputsKind(kind)) {
return serializeMatchingInputsSlotValue(defaultMatchingInputsSlotValue())
}
if (isMatchingAnswerKind(kind)) {
return serializeMatchingAnswerSlotValue({ pairs: [] })
}
if (isSelectMissingWordsKind(kind)) {
if (side === "stimulus") {
return serializeSelectMissingWordsStimulusSlotValue(
defaultSelectMissingWordsStimulusSlotValue(),
)
}
return serializeSelectMissingWordsResponseSlotValue({ blanks: [] })
}
return ""
}
@ -20,10 +97,10 @@ export function emptyDynamicFieldValuesForDefinition(
): Record<string, string> {
const o: Record<string, string> = {}
for (const r of def.stimulus_schema) {
o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind)
o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind, "stimulus")
}
for (const r of def.response_schema) {
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind)
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind, "response")
}
return o
}
@ -104,9 +181,89 @@ export function questionRowHasContent(
if (q.questionText.trim()) return true
const fv = q.dynamicFieldValues ?? {}
for (const row of def.stimulus_schema) {
if (isMultipleChoiceKind(row.kind)) {
if (
multipleChoiceSlotHasContent(
parseMultipleChoiceSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingInputsKind(row.kind)) {
if (
matchingInputsSlotHasContent(
parseMatchingInputsSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingAnswerKind(row.kind)) {
if (
matchingAnswerSlotHasContent(
parseMatchingAnswerSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (isSelectMissingWordsKind(row.kind)) {
if (
selectMissingWordsStimulusHasContent(
parseSelectMissingWordsStimulusSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (fv[`stimulus:${row.id}`]?.trim()) return true
}
for (const row of def.response_schema) {
if (isMultipleChoiceKind(row.kind)) {
if (
multipleChoiceSlotHasContent(
parseMultipleChoiceSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingInputsKind(row.kind)) {
if (
matchingInputsSlotHasContent(
parseMatchingInputsSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingAnswerKind(row.kind)) {
if (
matchingAnswerSlotHasContent(
parseMatchingAnswerSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (isSelectMissingWordsKind(row.kind)) {
if (
selectMissingWordsResponseHasContent(
parseSelectMissingWordsResponseSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (fv[`response:${row.id}`]?.trim()) return true
}
return false
@ -131,6 +288,7 @@ export function buildCreateQuestionFromDefinition(
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
fieldValues,
mcqOptions: q.mcqOptions,
})
return {
question_type: "DYNAMIC",
@ -223,17 +381,147 @@ export function validateDefinitionQuestion(
return `Question ${n}: enter prompt text (${promptRow.label || promptRow.id}).`
}
for (const row of def.stimulus_schema) {
if (isStructuredDynamicSlotKind(row.kind)) continue
if (!row.required) continue
const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
}
for (const row of def.response_schema) {
if (isStructuredDynamicSlotKind(row.kind)) continue
if (!row.required) continue
const v = fieldValues[`response:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
}
const matchingInputs = findMatchingInputsInFieldValues(
fieldValues,
def.stimulus_schema,
def.response_schema,
)
const clozeStimulus = findSelectMissingWordsStimulusInFieldValues(
fieldValues,
def.stimulus_schema,
)
for (const row of def.stimulus_schema) {
if (isMultipleChoiceKind(row.kind)) {
const val = parseMultipleChoiceSlotValue(fieldValues[`stimulus:${row.id}`])
if (!multipleChoiceSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add choices for stimulus "${row.label || row.id}".`
}
continue
}
const mcqErr = validateMultipleChoiceSlotValue(val)
if (mcqErr) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${mcqErr}`
}
}
if (isMatchingInputsKind(row.kind)) {
const val = parseMatchingInputsSlotValue(fieldValues[`stimulus:${row.id}`])
if (!matchingInputsSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching inputs for stimulus "${row.label || row.id}".`
}
continue
}
const err = validateMatchingInputsSlotValue(val)
if (err) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
}
}
if (isMatchingAnswerKind(row.kind)) {
const val = parseMatchingAnswerSlotValue(
fieldValues[`stimulus:${row.id}`],
matchingInputs,
)
if (!matchingAnswerSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching pairs for stimulus "${row.label || row.id}".`
}
continue
}
const err = validateMatchingAnswerSlotValue(val, matchingInputs)
if (err) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
}
}
if (isSelectMissingWordsKind(row.kind)) {
const val = parseSelectMissingWordsStimulusSlotValue(
fieldValues[`stimulus:${row.id}`],
)
if (!selectMissingWordsStimulusHasContent(val)) {
if (row.required) {
return `Question ${n}: add cloze passage for stimulus "${row.label || row.id}".`
}
continue
}
const err = validateSelectMissingWordsStimulusSlotValue(val)
if (err) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
}
}
}
for (const row of def.response_schema) {
if (isMultipleChoiceKind(row.kind)) {
const val = parseMultipleChoiceSlotValue(fieldValues[`response:${row.id}`])
if (!multipleChoiceSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add choices for response "${row.label || row.id}".`
}
continue
}
const mcqErr = validateMultipleChoiceSlotValue(val)
if (mcqErr) {
return `Question ${n} (response "${row.label || row.id}"): ${mcqErr}`
}
}
if (isMatchingInputsKind(row.kind)) {
const val = parseMatchingInputsSlotValue(fieldValues[`response:${row.id}`])
if (!matchingInputsSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching inputs for response "${row.label || row.id}".`
}
continue
}
const err = validateMatchingInputsSlotValue(val)
if (err) {
return `Question ${n} (response "${row.label || row.id}"): ${err}`
}
}
if (isMatchingAnswerKind(row.kind)) {
const val = parseMatchingAnswerSlotValue(
fieldValues[`response:${row.id}`],
matchingInputs,
)
if (!matchingAnswerSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching pairs for response "${row.label || row.id}".`
}
continue
}
const err = validateMatchingAnswerSlotValue(val, matchingInputs)
if (err) {
return `Question ${n} (response "${row.label || row.id}"): ${err}`
}
}
if (isSelectMissingWordsKind(row.kind)) {
const val = parseSelectMissingWordsResponseSlotValue(
fieldValues[`response:${row.id}`],
clozeStimulus,
)
if (!selectMissingWordsResponseHasContent(val)) {
if (row.required) {
return `Question ${n}: select words for each blank in response "${row.label || row.id}".`
}
continue
}
const err = validateSelectMissingWordsResponseSlotValue(val, clozeStimulus)
if (err) {
return `Question ${n} (response "${row.label || row.id}"): ${err}`
}
}
}
return null
}

View File

@ -0,0 +1,354 @@
export interface MatchingSideItem {
id: string
text: string
}
export interface MatchingInputsSlotValue {
left: MatchingSideItem[]
right: MatchingSideItem[]
}
export interface MatchingPair {
left_id: string
right_id: string
}
export interface MatchingAnswerSlotValue {
pairs: MatchingPair[]
}
export const MATCHING_MIN_ITEMS = 2
const DEFAULT_INPUT_COUNT = 4
function reindexSide(
items: MatchingSideItem[],
prefix: "l" | "r",
): MatchingSideItem[] {
return items.map((item, index) => ({
id: `${prefix}${index + 1}`,
text: item.text,
}))
}
export function defaultMatchingInputsSlotValue(
count = DEFAULT_INPUT_COUNT,
): MatchingInputsSlotValue {
return {
left: Array.from({ length: count }, (_, index) => ({
id: `l${index + 1}`,
text: "",
})),
right: Array.from({ length: count }, (_, index) => ({
id: `r${index + 1}`,
text: "",
})),
}
}
export function defaultMatchingAnswerFromInputs(
inputs: MatchingInputsSlotValue,
): MatchingAnswerSlotValue {
const count = Math.min(inputs.left.length, inputs.right.length)
return {
pairs: Array.from({ length: count }, (_, index) => ({
left_id: inputs.left[index]?.id ?? `l${index + 1}`,
right_id: inputs.right[index]?.id ?? `r${index + 1}`,
})),
}
}
export function serializeMatchingInputsSlotValue(
value: MatchingInputsSlotValue,
): string {
return JSON.stringify(value)
}
export function serializeMatchingAnswerSlotValue(
value: MatchingAnswerSlotValue,
): string {
return JSON.stringify(value)
}
export function matchingItemHasValue(text: string): boolean {
return text.length > 0
}
function normalizeSideItem(raw: unknown, index: number, prefix: "l" | "r"): MatchingSideItem {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
id: String(record.id ?? `${prefix}${index + 1}`),
text: String(record.text ?? ""),
}
}
return {
id: `${prefix}${index + 1}`,
text: String(raw ?? ""),
}
}
export function normalizeMatchingInputsValue(raw: unknown): MatchingInputsSlotValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
const left = Array.isArray(record.left)
? record.left.map((item, index) => normalizeSideItem(item, index, "l"))
: []
const right = Array.isArray(record.right)
? record.right.map((item, index) => normalizeSideItem(item, index, "r"))
: []
if (left.length > 0 || right.length > 0) {
return ensureMinMatchingInputs({ left, right })
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeMatchingInputsValue(JSON.parse(raw) as unknown)
} catch {
return defaultMatchingInputsSlotValue()
}
}
return defaultMatchingInputsSlotValue()
}
function normalizePair(raw: unknown, index: number): MatchingPair {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
left_id: String(record.left_id ?? record.leftId ?? `l${index + 1}`),
right_id: String(record.right_id ?? record.rightId ?? `r${index + 1}`),
}
}
return {
left_id: `l${index + 1}`,
right_id: `r${index + 1}`,
}
}
export function normalizeMatchingAnswerValue(raw: unknown): MatchingAnswerSlotValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (Array.isArray(record.pairs)) {
return {
pairs: record.pairs.map((pair, index) => normalizePair(pair, index)),
}
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeMatchingAnswerValue(JSON.parse(raw) as unknown)
} catch {
return { pairs: [] }
}
}
return { pairs: [] }
}
export function ensureMinMatchingInputs(
value: MatchingInputsSlotValue,
): MatchingInputsSlotValue {
const left = [...value.left]
const right = [...value.right]
while (left.length < MATCHING_MIN_ITEMS) {
left.push({ id: `l${left.length + 1}`, text: "" })
}
while (right.length < MATCHING_MIN_ITEMS) {
right.push({ id: `r${right.length + 1}`, text: "" })
}
return {
left: reindexSide(left, "l"),
right: reindexSide(right, "r"),
}
}
export function parseMatchingInputsSlotValue(
raw: string | undefined,
): MatchingInputsSlotValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) return defaultMatchingInputsSlotValue()
try {
return ensureMinMatchingInputs(
normalizeMatchingInputsValue(JSON.parse(trimmed) as unknown),
)
} catch {
return defaultMatchingInputsSlotValue()
}
}
export function parseMatchingAnswerSlotValue(
raw: string | undefined,
inputs?: MatchingInputsSlotValue | null,
): MatchingAnswerSlotValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) {
return inputs ? defaultMatchingAnswerFromInputs(inputs) : { pairs: [] }
}
try {
const parsed = normalizeMatchingAnswerValue(JSON.parse(trimmed) as unknown)
if (parsed.pairs.length > 0) return parsed
return inputs ? defaultMatchingAnswerFromInputs(inputs) : parsed
} catch {
return inputs ? defaultMatchingAnswerFromInputs(inputs) : { pairs: [] }
}
}
export function matchingInputsSlotHasContent(
value: MatchingInputsSlotValue,
): boolean {
return (
value.left.some((item) => matchingItemHasValue(item.text)) ||
value.right.some((item) => matchingItemHasValue(item.text))
)
}
export function matchingAnswerSlotHasContent(
value: MatchingAnswerSlotValue,
): boolean {
return value.pairs.some(
(pair) => pair.left_id.trim().length > 0 && pair.right_id.trim().length > 0,
)
}
export function finalizeMatchingInputsPayload(
value: MatchingInputsSlotValue,
): MatchingInputsSlotValue {
return {
left: value.left
.filter((item) => matchingItemHasValue(item.text))
.map((item) => ({ id: item.id, text: item.text })),
right: value.right
.filter((item) => matchingItemHasValue(item.text))
.map((item) => ({ id: item.id, text: item.text })),
}
}
export function finalizeMatchingAnswerPayload(
value: MatchingAnswerSlotValue,
): MatchingAnswerSlotValue {
return {
pairs: value.pairs
.filter(
(pair) => pair.left_id.trim().length > 0 && pair.right_id.trim().length > 0,
)
.map((pair) => ({
left_id: pair.left_id,
right_id: pair.right_id,
})),
}
}
export function addMatchingInputRow(
value: MatchingInputsSlotValue,
): MatchingInputsSlotValue {
return ensureMinMatchingInputs({
left: [...value.left, { id: `l${value.left.length + 1}`, text: "" }],
right: [...value.right, { id: `r${value.right.length + 1}`, text: "" }],
})
}
export function removeMatchingInputRow(
value: MatchingInputsSlotValue,
index: number,
): MatchingInputsSlotValue {
if (value.left.length <= MATCHING_MIN_ITEMS) return value
return ensureMinMatchingInputs({
left: value.left.filter((_, i) => i !== index),
right: value.right.filter((_, i) => i !== index),
})
}
export function addMatchingPair(
value: MatchingAnswerSlotValue,
inputs?: MatchingInputsSlotValue | null,
): MatchingAnswerSlotValue {
const left = inputs?.left ?? []
const right = inputs?.right ?? []
const nextIndex = value.pairs.length
return {
pairs: [
...value.pairs,
{
left_id: left[nextIndex]?.id ?? left[0]?.id ?? "l1",
right_id: right[nextIndex]?.id ?? right[0]?.id ?? "r1",
},
],
}
}
export function removeMatchingPair(
value: MatchingAnswerSlotValue,
index: number,
): MatchingAnswerSlotValue {
if (value.pairs.length <= 1) return value
return {
pairs: value.pairs.filter((_, i) => i !== index),
}
}
export function validateMatchingInputsSlotValue(
value: MatchingInputsSlotValue,
): string | null {
if (
value.left.length < MATCHING_MIN_ITEMS ||
value.right.length < MATCHING_MIN_ITEMS
) {
return `Add at least ${MATCHING_MIN_ITEMS} items on each side.`
}
const filledLeft = value.left.filter((item) => matchingItemHasValue(item.text))
const filledRight = value.right.filter((item) => matchingItemHasValue(item.text))
if (filledLeft.length < MATCHING_MIN_ITEMS) {
return `Add at least ${MATCHING_MIN_ITEMS} left-side items with text.`
}
if (filledRight.length < MATCHING_MIN_ITEMS) {
return `Add at least ${MATCHING_MIN_ITEMS} right-side items with text.`
}
return null
}
export function validateMatchingAnswerSlotValue(
value: MatchingAnswerSlotValue,
inputs?: MatchingInputsSlotValue | null,
): string | null {
const pairs = value.pairs.filter(
(pair) => pair.left_id.trim() && pair.right_id.trim(),
)
if (pairs.length < 1) return "Add at least one matching pair."
const leftIds = new Set(inputs?.left.map((item) => item.id) ?? [])
const rightIds = new Set(inputs?.right.map((item) => item.id) ?? [])
const usedLeft = new Set<string>()
for (const pair of pairs) {
if (inputs && leftIds.size > 0 && !leftIds.has(pair.left_id)) {
return `Unknown left id "${pair.left_id}" in matching answer.`
}
if (inputs && rightIds.size > 0 && !rightIds.has(pair.right_id)) {
return `Unknown right id "${pair.right_id}" in matching answer.`
}
if (usedLeft.has(pair.left_id)) {
return `Duplicate left id "${pair.left_id}" in matching answer.`
}
usedLeft.add(pair.left_id)
}
return null
}
export function findMatchingInputsInFieldValues(
fieldValues: Record<string, string>,
stimulusSchema: { id: string; kind: string }[],
responseSchema: { id: string; kind: string }[],
): MatchingInputsSlotValue | null {
for (const row of stimulusSchema) {
if (row.kind.trim().toUpperCase() !== "MATCHING_INPUTS") continue
const parsed = parseMatchingInputsSlotValue(fieldValues[`stimulus:${row.id}`])
if (matchingInputsSlotHasContent(parsed)) return parsed
}
for (const row of responseSchema) {
if (row.kind.trim().toUpperCase() !== "MATCHING_INPUTS") continue
const parsed = parseMatchingInputsSlotValue(fieldValues[`response:${row.id}`])
if (matchingInputsSlotHasContent(parsed)) return parsed
}
return null
}

View File

@ -10,8 +10,10 @@ export interface MultipleChoiceSlotValue {
const DEFAULT_OPTION_IDS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const
export const MULTIPLE_CHOICE_MIN_OPTIONS = 2
export function defaultMultipleChoiceSlotValue(
count = 4,
count = MULTIPLE_CHOICE_MIN_OPTIONS,
): MultipleChoiceSlotValue {
return {
options: Array.from({ length: count }, (_, index) => ({
@ -28,6 +30,70 @@ export function serializeMultipleChoiceSlotValue(
return JSON.stringify(value)
}
export function nextMultipleChoiceOptionId(
existing: MultipleChoiceOptionValue[],
): string {
const used = new Set(existing.map((option) => option.id))
for (const id of DEFAULT_OPTION_IDS) {
if (!used.has(id)) return id
}
return String(existing.length + 1)
}
export function addMultipleChoiceOption(
value: MultipleChoiceSlotValue,
): MultipleChoiceSlotValue {
return {
options: [
...value.options,
{
id: nextMultipleChoiceOptionId(value.options),
text: "",
is_correct: false,
},
],
}
}
export function removeMultipleChoiceOption(
value: MultipleChoiceSlotValue,
index: number,
): MultipleChoiceSlotValue {
if (value.options.length <= MULTIPLE_CHOICE_MIN_OPTIONS) return value
const removed = value.options[index]
let options = value.options.filter((_, i) => i !== index)
if (
removed?.is_correct &&
options.length > 0 &&
!options.some((option) => option.is_correct)
) {
options = options.map((option, i) => ({
...option,
is_correct: i === 0,
}))
}
return { options }
}
export function ensureMinMultipleChoiceOptions(
value: MultipleChoiceSlotValue,
): MultipleChoiceSlotValue {
if (value.options.length >= MULTIPLE_CHOICE_MIN_OPTIONS) return value
const options = [...value.options]
while (options.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
options.push({
id: nextMultipleChoiceOptionId(options),
text: "",
is_correct: options.length === 0,
})
}
return { options }
}
export function multipleChoiceOptionHasValue(text: string): boolean {
return text.length > 0
}
export function parseMultipleChoiceSlotValue(
raw: string | undefined,
): MultipleChoiceSlotValue {
@ -35,7 +101,7 @@ export function parseMultipleChoiceSlotValue(
if (!trimmed) return defaultMultipleChoiceSlotValue()
try {
const parsed = JSON.parse(trimmed) as unknown
return normalizeMultipleChoiceValue(parsed)
return ensureMinMultipleChoiceOptions(normalizeMultipleChoiceValue(parsed))
} catch {
return defaultMultipleChoiceSlotValue()
}
@ -45,15 +111,15 @@ export function normalizeMultipleChoiceValue(
raw: unknown,
mcqOptions?: { option_text?: string; text?: string; is_correct?: boolean; isCorrect?: boolean }[],
): MultipleChoiceSlotValue {
if (mcqOptions?.some((o) => (o.option_text ?? o.text ?? "").trim())) {
if (mcqOptions?.some((o) => multipleChoiceOptionHasValue(o.option_text ?? o.text ?? ""))) {
return {
options: mcqOptions
.map((option, index) => ({
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
text: (option.option_text ?? option.text ?? "").trim(),
text: option.option_text ?? option.text ?? "",
is_correct: Boolean(option.is_correct ?? option.isCorrect),
}))
.filter((option) => option.text),
.filter((option) => multipleChoiceOptionHasValue(option.text)),
}
}
@ -95,13 +161,13 @@ function normalizeMultipleChoiceOption(
const record = raw as Record<string, unknown>
return {
id: String(record.id ?? DEFAULT_OPTION_IDS[index] ?? index + 1),
text: String(record.text ?? record.option_text ?? "").trim(),
text: String(record.text ?? record.option_text ?? ""),
is_correct: Boolean(record.is_correct ?? record.isCorrect),
}
}
return {
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
text: String(raw ?? "").trim(),
text: String(raw ?? ""),
is_correct: false,
}
}
@ -109,14 +175,21 @@ function normalizeMultipleChoiceOption(
export function multipleChoiceSlotHasContent(
value: MultipleChoiceSlotValue,
): boolean {
return value.options.some((option) => option.text.trim())
return value.options.some((option) => multipleChoiceOptionHasValue(option.text))
}
export function validateMultipleChoiceSlotValue(
value: MultipleChoiceSlotValue,
): string | null {
const filled = value.options.filter((option) => option.text.trim())
if (filled.length < 2) return "Add at least two choices with text."
if (value.options.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
return `Add at least ${MULTIPLE_CHOICE_MIN_OPTIONS} choices.`
}
const filled = value.options.filter((option) =>
multipleChoiceOptionHasValue(option.text),
)
if (filled.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
return `Add at least ${MULTIPLE_CHOICE_MIN_OPTIONS} choices with text.`
}
if (!filled.some((option) => option.is_correct)) {
return "Mark one choice as correct."
}

View File

@ -1,5 +1,24 @@
import type { DynamicQuestionPayload } from "../types/questionTypeDefinition.types"
import {
finalizeMatchingAnswerPayload,
finalizeMatchingInputsPayload,
findMatchingInputsInFieldValues,
matchingAnswerSlotHasContent,
matchingInputsSlotHasContent,
parseMatchingAnswerSlotValue,
parseMatchingInputsSlotValue,
} from "./matchingSlotValue"
import {
finalizeSelectMissingWordsResponsePayload,
finalizeSelectMissingWordsStimulusPayload,
findSelectMissingWordsStimulusInFieldValues,
parseSelectMissingWordsResponseSlotValue,
parseSelectMissingWordsStimulusSlotValue,
selectMissingWordsResponseHasContent,
selectMissingWordsStimulusHasContent,
} from "./selectMissingWordsSlotValue"
import {
multipleChoiceOptionHasValue,
multipleChoiceSlotHasContent,
normalizeMultipleChoiceValue,
parseMultipleChoiceSlotValue,
@ -32,12 +51,26 @@ function isMultipleChoiceKind(kind: string): boolean {
return upper === "MULTIPLE_CHOICE" || upper === "OPTION"
}
function isMatchingInputsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
}
function isMatchingAnswerKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
}
function isSelectMissingWordsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
}
function slotValueForRow(
row: { id: string; kind: string },
side: "stimulus" | "response",
fieldValues: Record<string, string>,
mcqOptions: { option_text: string; is_correct: boolean }[] | undefined,
mcqOptionsConsumed: { current: boolean },
stimulusRows: { id: string; kind: string }[],
responseRows: { id: string; kind: string }[],
): unknown {
const fieldKey = `${side}:${row.id}`
const rawField = fieldValues[fieldKey]
@ -45,13 +78,64 @@ function slotValueForRow(
if (isMultipleChoiceKind(row.kind)) {
const fromField = parseMultipleChoiceSlotValue(rawField)
if (multipleChoiceSlotHasContent(fromField)) {
return normalizeMultipleChoiceValue(fromField)
return {
options: fromField.options
.filter((option) => multipleChoiceOptionHasValue(option.text))
.map((option) => ({
id: option.id,
text: option.text,
is_correct: option.is_correct,
})),
}
}
if (mcqOptions && !mcqOptionsConsumed.current) {
mcqOptionsConsumed.current = true
return normalizeMultipleChoiceValue(undefined, mcqOptions)
}
return normalizeMultipleChoiceValue(fromField)
return { options: [] }
}
if (isMatchingInputsKind(row.kind)) {
const fromField = parseMatchingInputsSlotValue(rawField)
if (matchingInputsSlotHasContent(fromField)) {
return finalizeMatchingInputsPayload(fromField)
}
return { left: [], right: [] }
}
if (isMatchingAnswerKind(row.kind)) {
const matchingInputs = findMatchingInputsInFieldValues(
fieldValues,
stimulusRows,
responseRows,
)
const fromField = parseMatchingAnswerSlotValue(rawField, matchingInputs)
if (matchingAnswerSlotHasContent(fromField)) {
return finalizeMatchingAnswerPayload(fromField)
}
return { pairs: [] }
}
if (isSelectMissingWordsKind(row.kind)) {
if (side === "stimulus") {
const fromField = parseSelectMissingWordsStimulusSlotValue(rawField)
if (selectMissingWordsStimulusHasContent(fromField)) {
return finalizeSelectMissingWordsStimulusPayload(fromField)
}
return { segments: [], word_bank: [], allow_reuse: false }
}
const clozeStimulus = findSelectMissingWordsStimulusInFieldValues(
fieldValues,
stimulusRows,
)
const fromField = parseSelectMissingWordsResponseSlotValue(
rawField,
clozeStimulus,
)
if (selectMissingWordsResponseHasContent(fromField)) {
return finalizeSelectMissingWordsResponsePayload(fromField)
}
return { blanks: [] }
}
if (side === "stimulus" && PLAIN_TEXT_STIMULUS_KINDS.has(row.kind.trim().toUpperCase())) {
@ -79,6 +163,8 @@ export function buildDynamicQuestionPayload(input: {
input.fieldValues,
input.mcqOptions,
mcqOptionsConsumed,
input.stimulusRows,
input.responseRows,
),
})),
response: input.responseRows.map((row) => ({
@ -90,6 +176,8 @@ export function buildDynamicQuestionPayload(input: {
input.fieldValues,
input.mcqOptions,
mcqOptionsConsumed,
input.stimulusRows,
input.responseRows,
),
})),
}

View File

@ -0,0 +1,524 @@
export interface ClozeTextSegment {
type: "text"
value: string
}
export interface ClozeBlankSegment {
type: "blank"
id: string
}
export type ClozeSegment = ClozeTextSegment | ClozeBlankSegment
export interface WordBankItem {
id: string
text: string
}
export interface SelectMissingWordsStimulusValue {
segments: ClozeSegment[]
word_bank: WordBankItem[]
allow_reuse: boolean
}
export interface ClozeBlankAnswer {
blank_id: string
text: string
word_id: string
}
export interface SelectMissingWordsResponseValue {
blanks: ClozeBlankAnswer[]
}
export const SELECT_MISSING_WORDS_MIN_BANK = 2
export const SELECT_MISSING_WORDS_MIN_BLANKS = 1
const DEFAULT_BLANK_COUNT = 2
const DEFAULT_WORD_BANK_COUNT = 4
function reindexBlankSegments(segments: ClozeSegment[]): ClozeSegment[] {
let blankIndex = 0
return segments.map((segment) => {
if (segment.type !== "blank") return segment
blankIndex += 1
return { type: "blank", id: `b${blankIndex}` }
})
}
function reindexWordBank(items: WordBankItem[]): WordBankItem[] {
return items.map((item, index) => ({
id: `w${index + 1}`,
text: item.text,
}))
}
function normalizeTextSegment(raw: unknown, index: number): ClozeTextSegment {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (record.type === "text") {
return {
type: "text",
value: String(record.value ?? ""),
}
}
}
return { type: "text", value: index === 0 ? "" : "" }
}
function normalizeBlankSegment(raw: unknown, index: number): ClozeBlankSegment {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (record.type === "blank") {
return {
type: "blank",
id: String(record.id ?? `b${index + 1}`),
}
}
}
return { type: "blank", id: `b${index + 1}` }
}
function normalizeSegment(raw: unknown, index: number): ClozeSegment {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (record.type === "blank") return normalizeBlankSegment(raw, index)
if (record.type === "text") return normalizeTextSegment(raw, index)
}
return { type: "text", value: "" }
}
function normalizeWordBankItem(raw: unknown, index: number): WordBankItem {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
id: String(record.id ?? `w${index + 1}`),
text: String(record.text ?? ""),
}
}
return { id: `w${index + 1}`, text: "" }
}
export function defaultSelectMissingWordsStimulusSlotValue(
blankCount = DEFAULT_BLANK_COUNT,
wordBankCount = DEFAULT_WORD_BANK_COUNT,
): SelectMissingWordsStimulusValue {
const segments: ClozeSegment[] = [{ type: "text", value: "" }]
for (let index = 0; index < blankCount; index += 1) {
segments.push({ type: "blank", id: `b${index + 1}` })
segments.push({ type: "text", value: "" })
}
return {
segments,
word_bank: Array.from({ length: wordBankCount }, (_, index) => ({
id: `w${index + 1}`,
text: "",
})),
allow_reuse: false,
}
}
export function blankIdsFromStimulus(
stimulus: SelectMissingWordsStimulusValue,
): string[] {
return stimulus.segments
.filter((segment): segment is ClozeBlankSegment => segment.type === "blank")
.map((segment) => segment.id)
}
export function defaultSelectMissingWordsResponseFromStimulus(
stimulus: SelectMissingWordsStimulusValue,
): SelectMissingWordsResponseValue {
return {
blanks: blankIdsFromStimulus(stimulus).map((blankId) => ({
blank_id: blankId,
text: "",
word_id: "",
})),
}
}
export function serializeSelectMissingWordsStimulusSlotValue(
value: SelectMissingWordsStimulusValue,
): string {
return JSON.stringify(value)
}
export function serializeSelectMissingWordsResponseSlotValue(
value: SelectMissingWordsResponseValue,
): string {
return JSON.stringify(value)
}
export function wordBankItemHasValue(text: string): boolean {
return text.length > 0
}
function ensureMinWordBank(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
const wordBank = [...value.word_bank]
while (wordBank.length < SELECT_MISSING_WORDS_MIN_BANK) {
wordBank.push({ id: `w${wordBank.length + 1}`, text: "" })
}
return {
...value,
segments:
value.segments.length > 0
? reindexBlankSegments(value.segments)
: defaultSelectMissingWordsStimulusSlotValue().segments,
word_bank: reindexWordBank(wordBank),
}
}
export function normalizeSelectMissingWordsStimulusValue(
raw: unknown,
): SelectMissingWordsStimulusValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
const segments = Array.isArray(record.segments)
? record.segments.map((segment, index) => normalizeSegment(segment, index))
: []
const wordBank = Array.isArray(record.word_bank)
? record.word_bank.map((item, index) => normalizeWordBankItem(item, index))
: []
if (segments.length > 0 || wordBank.length > 0) {
return ensureMinWordBank({
segments,
word_bank: wordBank,
allow_reuse: Boolean(record.allow_reuse),
})
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeSelectMissingWordsStimulusValue(JSON.parse(raw) as unknown)
} catch {
return defaultSelectMissingWordsStimulusSlotValue()
}
}
return defaultSelectMissingWordsStimulusSlotValue()
}
function normalizeBlankAnswer(raw: unknown, index: number): ClozeBlankAnswer {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
blank_id: String(record.blank_id ?? record.blankId ?? `b${index + 1}`),
text: String(record.text ?? ""),
word_id: String(record.word_id ?? record.wordId ?? ""),
}
}
return { blank_id: `b${index + 1}`, text: "", word_id: "" }
}
export function normalizeSelectMissingWordsResponseValue(
raw: unknown,
): SelectMissingWordsResponseValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (Array.isArray(record.blanks)) {
return {
blanks: record.blanks.map((blank, index) =>
normalizeBlankAnswer(blank, index),
),
}
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeSelectMissingWordsResponseValue(JSON.parse(raw) as unknown)
} catch {
return { blanks: [] }
}
}
return { blanks: [] }
}
export function parseSelectMissingWordsStimulusSlotValue(
raw: string | undefined,
): SelectMissingWordsStimulusValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) return defaultSelectMissingWordsStimulusSlotValue()
try {
return ensureMinWordBank(
normalizeSelectMissingWordsStimulusValue(JSON.parse(trimmed) as unknown),
)
} catch {
return defaultSelectMissingWordsStimulusSlotValue()
}
}
export function parseSelectMissingWordsResponseSlotValue(
raw: string | undefined,
stimulus?: SelectMissingWordsStimulusValue | null,
): SelectMissingWordsResponseValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) {
return stimulus
? defaultSelectMissingWordsResponseFromStimulus(stimulus)
: { blanks: [] }
}
try {
const parsed = normalizeSelectMissingWordsResponseValue(
JSON.parse(trimmed) as unknown,
)
if (parsed.blanks.length > 0) return parsed
return stimulus
? defaultSelectMissingWordsResponseFromStimulus(stimulus)
: parsed
} catch {
return stimulus
? defaultSelectMissingWordsResponseFromStimulus(stimulus)
: { blanks: [] }
}
}
export function selectMissingWordsStimulusHasContent(
value: SelectMissingWordsStimulusValue,
): boolean {
return (
value.segments.some(
(segment) =>
segment.type === "blank" ||
(segment.type === "text" && segment.value.length > 0),
) || value.word_bank.some((item) => wordBankItemHasValue(item.text))
)
}
export function selectMissingWordsResponseHasContent(
value: SelectMissingWordsResponseValue,
): boolean {
return value.blanks.some(
(blank) =>
blank.blank_id.trim().length > 0 &&
blank.word_id.trim().length > 0 &&
blank.text.length > 0,
)
}
export function finalizeSelectMissingWordsStimulusPayload(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
return {
segments: value.segments.map((segment) =>
segment.type === "text"
? { type: "text", value: segment.value }
: { type: "blank", id: segment.id },
),
word_bank: value.word_bank
.filter((item) => wordBankItemHasValue(item.text))
.map((item) => ({ id: item.id, text: item.text })),
allow_reuse: value.allow_reuse,
}
}
export function finalizeSelectMissingWordsResponsePayload(
value: SelectMissingWordsResponseValue,
): SelectMissingWordsResponseValue {
return {
blanks: value.blanks
.filter(
(blank) =>
blank.blank_id.trim().length > 0 &&
blank.word_id.trim().length > 0 &&
blank.text.length > 0,
)
.map((blank) => ({
blank_id: blank.blank_id,
text: blank.text,
word_id: blank.word_id,
})),
}
}
export function addWordBankItem(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
const next = ensureMinWordBank(value)
return {
...next,
word_bank: reindexWordBank([
...next.word_bank,
{ id: `w${next.word_bank.length + 1}`, text: "" },
]),
}
}
export function removeWordBankItem(
value: SelectMissingWordsStimulusValue,
index: number,
): SelectMissingWordsStimulusValue {
if (value.word_bank.length <= SELECT_MISSING_WORDS_MIN_BANK) return value
return ensureMinWordBank({
...value,
word_bank: value.word_bank.filter((_, itemIndex) => itemIndex !== index),
})
}
export function addTextSegment(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
return {
...value,
segments: [...value.segments, { type: "text", value: "" }],
}
}
export function addBlankSegment(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
const blankCount = blankIdsFromStimulus(value).length
return ensureMinWordBank({
...value,
segments: [
...value.segments,
{ type: "blank", id: `b${blankCount + 1}` },
],
})
}
export function removeSegment(
value: SelectMissingWordsStimulusValue,
index: number,
): SelectMissingWordsStimulusValue {
if (value.segments.length <= 1) return value
return ensureMinWordBank({
...value,
segments: value.segments.filter((_, segmentIndex) => segmentIndex !== index),
})
}
export function updateTextSegment(
value: SelectMissingWordsStimulusValue,
index: number,
text: string,
): SelectMissingWordsStimulusValue {
const segments = [...value.segments]
const segment = segments[index]
if (!segment || segment.type !== "text") return value
segments[index] = { type: "text", value: text }
return { ...value, segments }
}
export function updateWordBankText(
value: SelectMissingWordsStimulusValue,
index: number,
text: string,
): SelectMissingWordsStimulusValue {
const wordBank = [...value.word_bank]
if (!wordBank[index]) return value
wordBank[index] = { ...wordBank[index], text }
return { ...value, word_bank: wordBank }
}
export function setAllowReuse(
value: SelectMissingWordsStimulusValue,
allowReuse: boolean,
): SelectMissingWordsStimulusValue {
return { ...value, allow_reuse: allowReuse }
}
export function syncResponseBlanksWithStimulus(
response: SelectMissingWordsResponseValue,
stimulus: SelectMissingWordsStimulusValue,
): SelectMissingWordsResponseValue {
const blankIds = blankIdsFromStimulus(stimulus)
const existing = new Map(
response.blanks.map((blank) => [blank.blank_id, blank]),
)
return {
blanks: blankIds.map((blankId) => {
const current = existing.get(blankId)
return (
current ?? {
blank_id: blankId,
text: "",
word_id: "",
}
)
}),
}
}
export function validateSelectMissingWordsStimulusSlotValue(
value: SelectMissingWordsStimulusValue,
): string | null {
const blankCount = blankIdsFromStimulus(value).length
if (blankCount < SELECT_MISSING_WORDS_MIN_BLANKS) {
return `Add at least ${SELECT_MISSING_WORDS_MIN_BLANKS} blank in the passage.`
}
const filledWords = value.word_bank.filter((item) =>
wordBankItemHasValue(item.text),
)
if (filledWords.length < SELECT_MISSING_WORDS_MIN_BANK) {
return `Add at least ${SELECT_MISSING_WORDS_MIN_BANK} words in the word bank.`
}
return null
}
export function validateSelectMissingWordsResponseSlotValue(
value: SelectMissingWordsResponseValue,
stimulus?: SelectMissingWordsStimulusValue | null,
): string | null {
const filled = value.blanks.filter(
(blank) =>
blank.blank_id.trim() &&
blank.word_id.trim() &&
blank.text.length > 0,
)
if (filled.length < SELECT_MISSING_WORDS_MIN_BLANKS) {
return "Select a word for each blank."
}
const blankIds = new Set(stimulus ? blankIdsFromStimulus(stimulus) : [])
const wordIds = new Set(
stimulus?.word_bank
.filter((item) => wordBankItemHasValue(item.text))
.map((item) => item.id) ?? [],
)
const wordTextById = new Map(
stimulus?.word_bank.map((item) => [item.id, item.text]) ?? [],
)
for (const blank of filled) {
if (blankIds.size > 0 && !blankIds.has(blank.blank_id)) {
return `Unknown blank id "${blank.blank_id}".`
}
if (wordIds.size > 0 && !wordIds.has(blank.word_id)) {
return `Unknown word id "${blank.word_id}" for blank "${blank.blank_id}".`
}
const expectedText = wordTextById.get(blank.word_id)
if (expectedText !== undefined && blank.text !== expectedText) {
return `Answer text for blank "${blank.blank_id}" must match the selected word.`
}
}
if (stimulus && !stimulus.allow_reuse) {
const usedWordIds = filled.map((blank) => blank.word_id)
const unique = new Set(usedWordIds)
if (unique.size !== usedWordIds.length) {
return "Each word can only be used once when reuse is disabled."
}
}
return null
}
export function findSelectMissingWordsStimulusInFieldValues(
fieldValues: Record<string, string>,
stimulusSchema: { id: string; kind: string }[],
): SelectMissingWordsStimulusValue | null {
for (const row of stimulusSchema) {
if (row.kind.trim().toUpperCase() !== "SELECT_MISSING_WORDS") continue
const parsed = parseSelectMissingWordsStimulusSlotValue(
fieldValues[`stimulus:${row.id}`],
)
if (selectMissingWordsStimulusHasContent(parsed)) return parsed
}
return null
}

View File

@ -295,7 +295,7 @@ export function AddPracticeFlow() {
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
mcqOptions: (q.mcqOptions ?? []).map(
(o: { text?: string; isCorrect?: boolean }) => ({
option_text: String(o.text ?? "").trim(),
option_text: String(o.text ?? ""),
is_correct: Boolean(o.isCorrect),
}),
),

View File

@ -114,10 +114,14 @@ export function QuestionsStep({
>
<DynamicSchemaSlotField
row={row}
side="stimulus"
value={q.dynamicFieldValues?.[`stimulus:${row.id}`] ?? ""}
onChange={(next) =>
setDynamicValue(i, `stimulus:${row.id}`, next)
}
allFieldValues={q.dynamicFieldValues}
stimulusSchema={def.stimulus_schema}
responseSchema={def.response_schema}
/>
</div>
))}
@ -133,10 +137,14 @@ export function QuestionsStep({
>
<DynamicSchemaSlotField
row={row}
side="response"
value={q.dynamicFieldValues?.[`response:${row.id}`] ?? ""}
onChange={(next) =>
setDynamicValue(i, `response:${row.id}`, next)
}
allFieldValues={q.dynamicFieldValues}
stimulusSchema={def.stimulus_schema}
responseSchema={def.response_schema}
/>
</div>
))}
@ -464,7 +472,7 @@ export function QuestionsStep({
dynamicFieldValues: { ...(row.dynamicFieldValues ?? {}) },
mcqOptions: (row.mcqOptions ?? []).map(
(o: { text?: string; isCorrect?: boolean }) => ({
option_text: String(o.text ?? "").trim(),
option_text: String(o.text ?? ""),
is_correct: Boolean(o.isCorrect),
}),
),