question creation UI fixes
This commit is contained in:
parent
095e690a68
commit
b21c679e56
241
src/components/content-management/DynamicMatchingSlotField.tsx
Normal file
241
src/components/content-management/DynamicMatchingSlotField.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
} from "react"
|
} 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 { toast } from "sonner"
|
||||||
import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api"
|
import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api"
|
||||||
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
|
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
|
||||||
|
|
@ -18,6 +18,30 @@ import { cn } from "../../lib/utils"
|
||||||
import { ResolvedImage } from "../media/ResolvedImage"
|
import { ResolvedImage } from "../media/ResolvedImage"
|
||||||
import { DynamicTableBuilder } from "./DynamicTableBuilder"
|
import { DynamicTableBuilder } from "./DynamicTableBuilder"
|
||||||
import { slotLabel } from "../../lib/schemaSlotLabel"
|
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_IMAGE_BYTES = 10 * 1024 * 1024
|
||||||
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
|
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
|
||||||
|
|
@ -31,12 +55,49 @@ export interface DynamicSchemaSlotRow {
|
||||||
required?: boolean
|
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(
|
function slotMediaMode(
|
||||||
kind: string,
|
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()
|
const u = kind.trim().toUpperCase()
|
||||||
if (u === "IMAGE") return "image"
|
if (u === "IMAGE") return "image"
|
||||||
if (u === "TABLE") return "table"
|
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 === "PDF_ATTACHMENT" || u === "PDF_UPLOAD") return "pdf"
|
||||||
if (u === "PREP_TIME" || u === "ANSWER_TIMER") return "seconds"
|
if (u === "PREP_TIME" || u === "ANSWER_TIMER") return "seconds"
|
||||||
if (u === "AUDIO_PROMPT" || u === "AUDIO_CLIP" || u === "AUDIO_RESPONSE") return "audio"
|
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 {
|
export interface DynamicSchemaSlotFieldProps {
|
||||||
row: DynamicSchemaSlotRow
|
row: DynamicSchemaSlotRow
|
||||||
value: string
|
value: string
|
||||||
onChange: (next: string) => void
|
onChange: (next: string) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
side: "stimulus" | "response"
|
||||||
|
allFieldValues?: Record<string, string>
|
||||||
|
stimulusSchema?: DynamicSchemaSlotRow[]
|
||||||
|
responseSchema?: DynamicSchemaSlotRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function DynamicPdfSlot({
|
function DynamicPdfSlot({
|
||||||
|
|
@ -678,9 +842,28 @@ export function DynamicSchemaSlotField({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
side,
|
||||||
|
allFieldValues,
|
||||||
|
stimulusSchema = [],
|
||||||
|
responseSchema = [],
|
||||||
}: DynamicSchemaSlotFieldProps) {
|
}: DynamicSchemaSlotFieldProps) {
|
||||||
const mode = slotMediaMode(row.kind)
|
const mode = slotMediaMode(row.kind, side)
|
||||||
const fieldLabel = `${slotLabel(row)}${row.required ? " *" : ""}`
|
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") {
|
if (mode === "table") {
|
||||||
return (
|
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") {
|
if (mode === "text") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -812,9 +812,13 @@ export function PracticeQuestionEditorFields({
|
||||||
>
|
>
|
||||||
<DynamicSchemaSlotField
|
<DynamicSchemaSlotField
|
||||||
row={row}
|
row={row}
|
||||||
|
side="stimulus"
|
||||||
value={value.dynamicFieldValues[`stimulus:${row.id}`] ?? ""}
|
value={value.dynamicFieldValues[`stimulus:${row.id}`] ?? ""}
|
||||||
onChange={(next) => setDynamicField(`stimulus:${row.id}`, next)}
|
onChange={(next) => setDynamicField(`stimulus:${row.id}`, next)}
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
|
allFieldValues={value.dynamicFieldValues}
|
||||||
|
stimulusSchema={value.dynamicStimulusRows}
|
||||||
|
responseSchema={value.dynamicResponseRows}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -830,9 +834,13 @@ export function PracticeQuestionEditorFields({
|
||||||
>
|
>
|
||||||
<DynamicSchemaSlotField
|
<DynamicSchemaSlotField
|
||||||
row={row}
|
row={row}
|
||||||
|
side="response"
|
||||||
value={value.dynamicFieldValues[`response:${row.id}`] ?? ""}
|
value={value.dynamicFieldValues[`response:${row.id}`] ?? ""}
|
||||||
onChange={(next) => setDynamicField(`response:${row.id}`, next)}
|
onChange={(next) => setDynamicField(`response:${row.id}`, next)}
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
|
allFieldValues={value.dynamicFieldValues}
|
||||||
|
stimulusSchema={value.dynamicStimulusRows}
|
||||||
|
responseSchema={value.dynamicResponseRows}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,89 @@ import type { CreateQuestionRequest, QuestionOption } from "../types/course.type
|
||||||
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
||||||
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
|
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
|
||||||
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
|
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()
|
const u = kind.trim().toUpperCase()
|
||||||
if (u === "TABLE") {
|
if (u === "TABLE") {
|
||||||
return serializeTableSlotValue(createEmptyTable(2, 1))
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,10 +97,10 @@ export function emptyDynamicFieldValuesForDefinition(
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const o: Record<string, string> = {}
|
const o: Record<string, string> = {}
|
||||||
for (const r of def.stimulus_schema) {
|
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) {
|
for (const r of def.response_schema) {
|
||||||
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind)
|
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind, "response")
|
||||||
}
|
}
|
||||||
return o
|
return o
|
||||||
}
|
}
|
||||||
|
|
@ -104,9 +181,89 @@ export function questionRowHasContent(
|
||||||
if (q.questionText.trim()) return true
|
if (q.questionText.trim()) return true
|
||||||
const fv = q.dynamicFieldValues ?? {}
|
const fv = q.dynamicFieldValues ?? {}
|
||||||
for (const row of def.stimulus_schema) {
|
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
|
if (fv[`stimulus:${row.id}`]?.trim()) return true
|
||||||
}
|
}
|
||||||
for (const row of def.response_schema) {
|
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
|
if (fv[`response:${row.id}`]?.trim()) return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
@ -131,6 +288,7 @@ export function buildCreateQuestionFromDefinition(
|
||||||
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
|
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
|
||||||
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
|
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
|
||||||
fieldValues,
|
fieldValues,
|
||||||
|
mcqOptions: q.mcqOptions,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
question_type: "DYNAMIC",
|
question_type: "DYNAMIC",
|
||||||
|
|
@ -223,17 +381,147 @@ export function validateDefinitionQuestion(
|
||||||
return `Question ${n}: enter prompt text (${promptRow.label || promptRow.id}).`
|
return `Question ${n}: enter prompt text (${promptRow.label || promptRow.id}).`
|
||||||
}
|
}
|
||||||
for (const row of def.stimulus_schema) {
|
for (const row of def.stimulus_schema) {
|
||||||
|
if (isStructuredDynamicSlotKind(row.kind)) continue
|
||||||
if (!row.required) continue
|
if (!row.required) continue
|
||||||
const v = fieldValues[`stimulus:${row.id}`]?.trim()
|
const v = fieldValues[`stimulus:${row.id}`]?.trim()
|
||||||
if (!v)
|
if (!v)
|
||||||
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
|
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
|
||||||
}
|
}
|
||||||
for (const row of def.response_schema) {
|
for (const row of def.response_schema) {
|
||||||
|
if (isStructuredDynamicSlotKind(row.kind)) continue
|
||||||
if (!row.required) continue
|
if (!row.required) continue
|
||||||
const v = fieldValues[`response:${row.id}`]?.trim()
|
const v = fieldValues[`response:${row.id}`]?.trim()
|
||||||
if (!v)
|
if (!v)
|
||||||
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
354
src/lib/matchingSlotValue.ts
Normal file
354
src/lib/matchingSlotValue.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -10,8 +10,10 @@ export interface MultipleChoiceSlotValue {
|
||||||
|
|
||||||
const DEFAULT_OPTION_IDS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const
|
const DEFAULT_OPTION_IDS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const
|
||||||
|
|
||||||
|
export const MULTIPLE_CHOICE_MIN_OPTIONS = 2
|
||||||
|
|
||||||
export function defaultMultipleChoiceSlotValue(
|
export function defaultMultipleChoiceSlotValue(
|
||||||
count = 4,
|
count = MULTIPLE_CHOICE_MIN_OPTIONS,
|
||||||
): MultipleChoiceSlotValue {
|
): MultipleChoiceSlotValue {
|
||||||
return {
|
return {
|
||||||
options: Array.from({ length: count }, (_, index) => ({
|
options: Array.from({ length: count }, (_, index) => ({
|
||||||
|
|
@ -28,6 +30,70 @@ export function serializeMultipleChoiceSlotValue(
|
||||||
return JSON.stringify(value)
|
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(
|
export function parseMultipleChoiceSlotValue(
|
||||||
raw: string | undefined,
|
raw: string | undefined,
|
||||||
): MultipleChoiceSlotValue {
|
): MultipleChoiceSlotValue {
|
||||||
|
|
@ -35,7 +101,7 @@ export function parseMultipleChoiceSlotValue(
|
||||||
if (!trimmed) return defaultMultipleChoiceSlotValue()
|
if (!trimmed) return defaultMultipleChoiceSlotValue()
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(trimmed) as unknown
|
const parsed = JSON.parse(trimmed) as unknown
|
||||||
return normalizeMultipleChoiceValue(parsed)
|
return ensureMinMultipleChoiceOptions(normalizeMultipleChoiceValue(parsed))
|
||||||
} catch {
|
} catch {
|
||||||
return defaultMultipleChoiceSlotValue()
|
return defaultMultipleChoiceSlotValue()
|
||||||
}
|
}
|
||||||
|
|
@ -45,15 +111,15 @@ export function normalizeMultipleChoiceValue(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
mcqOptions?: { option_text?: string; text?: string; is_correct?: boolean; isCorrect?: boolean }[],
|
mcqOptions?: { option_text?: string; text?: string; is_correct?: boolean; isCorrect?: boolean }[],
|
||||||
): MultipleChoiceSlotValue {
|
): MultipleChoiceSlotValue {
|
||||||
if (mcqOptions?.some((o) => (o.option_text ?? o.text ?? "").trim())) {
|
if (mcqOptions?.some((o) => multipleChoiceOptionHasValue(o.option_text ?? o.text ?? ""))) {
|
||||||
return {
|
return {
|
||||||
options: mcqOptions
|
options: mcqOptions
|
||||||
.map((option, index) => ({
|
.map((option, index) => ({
|
||||||
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
|
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),
|
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>
|
const record = raw as Record<string, unknown>
|
||||||
return {
|
return {
|
||||||
id: String(record.id ?? DEFAULT_OPTION_IDS[index] ?? index + 1),
|
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),
|
is_correct: Boolean(record.is_correct ?? record.isCorrect),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
|
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
|
||||||
text: String(raw ?? "").trim(),
|
text: String(raw ?? ""),
|
||||||
is_correct: false,
|
is_correct: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -109,14 +175,21 @@ function normalizeMultipleChoiceOption(
|
||||||
export function multipleChoiceSlotHasContent(
|
export function multipleChoiceSlotHasContent(
|
||||||
value: MultipleChoiceSlotValue,
|
value: MultipleChoiceSlotValue,
|
||||||
): boolean {
|
): boolean {
|
||||||
return value.options.some((option) => option.text.trim())
|
return value.options.some((option) => multipleChoiceOptionHasValue(option.text))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateMultipleChoiceSlotValue(
|
export function validateMultipleChoiceSlotValue(
|
||||||
value: MultipleChoiceSlotValue,
|
value: MultipleChoiceSlotValue,
|
||||||
): string | null {
|
): string | null {
|
||||||
const filled = value.options.filter((option) => option.text.trim())
|
if (value.options.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
|
||||||
if (filled.length < 2) return "Add at least two choices with text."
|
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)) {
|
if (!filled.some((option) => option.is_correct)) {
|
||||||
return "Mark one choice as correct."
|
return "Mark one choice as correct."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,24 @@
|
||||||
import type { DynamicQuestionPayload } from "../types/questionTypeDefinition.types"
|
import type { DynamicQuestionPayload } from "../types/questionTypeDefinition.types"
|
||||||
import {
|
import {
|
||||||
|
finalizeMatchingAnswerPayload,
|
||||||
|
finalizeMatchingInputsPayload,
|
||||||
|
findMatchingInputsInFieldValues,
|
||||||
|
matchingAnswerSlotHasContent,
|
||||||
|
matchingInputsSlotHasContent,
|
||||||
|
parseMatchingAnswerSlotValue,
|
||||||
|
parseMatchingInputsSlotValue,
|
||||||
|
} from "./matchingSlotValue"
|
||||||
|
import {
|
||||||
|
finalizeSelectMissingWordsResponsePayload,
|
||||||
|
finalizeSelectMissingWordsStimulusPayload,
|
||||||
|
findSelectMissingWordsStimulusInFieldValues,
|
||||||
|
parseSelectMissingWordsResponseSlotValue,
|
||||||
|
parseSelectMissingWordsStimulusSlotValue,
|
||||||
|
selectMissingWordsResponseHasContent,
|
||||||
|
selectMissingWordsStimulusHasContent,
|
||||||
|
} from "./selectMissingWordsSlotValue"
|
||||||
|
import {
|
||||||
|
multipleChoiceOptionHasValue,
|
||||||
multipleChoiceSlotHasContent,
|
multipleChoiceSlotHasContent,
|
||||||
normalizeMultipleChoiceValue,
|
normalizeMultipleChoiceValue,
|
||||||
parseMultipleChoiceSlotValue,
|
parseMultipleChoiceSlotValue,
|
||||||
|
|
@ -32,12 +51,26 @@ function isMultipleChoiceKind(kind: string): boolean {
|
||||||
return upper === "MULTIPLE_CHOICE" || upper === "OPTION"
|
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(
|
function slotValueForRow(
|
||||||
row: { id: string; kind: string },
|
row: { id: string; kind: string },
|
||||||
side: "stimulus" | "response",
|
side: "stimulus" | "response",
|
||||||
fieldValues: Record<string, string>,
|
fieldValues: Record<string, string>,
|
||||||
mcqOptions: { option_text: string; is_correct: boolean }[] | undefined,
|
mcqOptions: { option_text: string; is_correct: boolean }[] | undefined,
|
||||||
mcqOptionsConsumed: { current: boolean },
|
mcqOptionsConsumed: { current: boolean },
|
||||||
|
stimulusRows: { id: string; kind: string }[],
|
||||||
|
responseRows: { id: string; kind: string }[],
|
||||||
): unknown {
|
): unknown {
|
||||||
const fieldKey = `${side}:${row.id}`
|
const fieldKey = `${side}:${row.id}`
|
||||||
const rawField = fieldValues[fieldKey]
|
const rawField = fieldValues[fieldKey]
|
||||||
|
|
@ -45,13 +78,64 @@ function slotValueForRow(
|
||||||
if (isMultipleChoiceKind(row.kind)) {
|
if (isMultipleChoiceKind(row.kind)) {
|
||||||
const fromField = parseMultipleChoiceSlotValue(rawField)
|
const fromField = parseMultipleChoiceSlotValue(rawField)
|
||||||
if (multipleChoiceSlotHasContent(fromField)) {
|
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) {
|
if (mcqOptions && !mcqOptionsConsumed.current) {
|
||||||
mcqOptionsConsumed.current = true
|
mcqOptionsConsumed.current = true
|
||||||
return normalizeMultipleChoiceValue(undefined, mcqOptions)
|
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())) {
|
if (side === "stimulus" && PLAIN_TEXT_STIMULUS_KINDS.has(row.kind.trim().toUpperCase())) {
|
||||||
|
|
@ -79,6 +163,8 @@ export function buildDynamicQuestionPayload(input: {
|
||||||
input.fieldValues,
|
input.fieldValues,
|
||||||
input.mcqOptions,
|
input.mcqOptions,
|
||||||
mcqOptionsConsumed,
|
mcqOptionsConsumed,
|
||||||
|
input.stimulusRows,
|
||||||
|
input.responseRows,
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
response: input.responseRows.map((row) => ({
|
response: input.responseRows.map((row) => ({
|
||||||
|
|
@ -90,6 +176,8 @@ export function buildDynamicQuestionPayload(input: {
|
||||||
input.fieldValues,
|
input.fieldValues,
|
||||||
input.mcqOptions,
|
input.mcqOptions,
|
||||||
mcqOptionsConsumed,
|
mcqOptionsConsumed,
|
||||||
|
input.stimulusRows,
|
||||||
|
input.responseRows,
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
524
src/lib/selectMissingWordsSlotValue.ts
Normal file
524
src/lib/selectMissingWordsSlotValue.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -295,7 +295,7 @@ export function AddPracticeFlow() {
|
||||||
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
|
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
|
||||||
mcqOptions: (q.mcqOptions ?? []).map(
|
mcqOptions: (q.mcqOptions ?? []).map(
|
||||||
(o: { text?: string; isCorrect?: boolean }) => ({
|
(o: { text?: string; isCorrect?: boolean }) => ({
|
||||||
option_text: String(o.text ?? "").trim(),
|
option_text: String(o.text ?? ""),
|
||||||
is_correct: Boolean(o.isCorrect),
|
is_correct: Boolean(o.isCorrect),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -114,10 +114,14 @@ export function QuestionsStep({
|
||||||
>
|
>
|
||||||
<DynamicSchemaSlotField
|
<DynamicSchemaSlotField
|
||||||
row={row}
|
row={row}
|
||||||
|
side="stimulus"
|
||||||
value={q.dynamicFieldValues?.[`stimulus:${row.id}`] ?? ""}
|
value={q.dynamicFieldValues?.[`stimulus:${row.id}`] ?? ""}
|
||||||
onChange={(next) =>
|
onChange={(next) =>
|
||||||
setDynamicValue(i, `stimulus:${row.id}`, next)
|
setDynamicValue(i, `stimulus:${row.id}`, next)
|
||||||
}
|
}
|
||||||
|
allFieldValues={q.dynamicFieldValues}
|
||||||
|
stimulusSchema={def.stimulus_schema}
|
||||||
|
responseSchema={def.response_schema}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -133,10 +137,14 @@ export function QuestionsStep({
|
||||||
>
|
>
|
||||||
<DynamicSchemaSlotField
|
<DynamicSchemaSlotField
|
||||||
row={row}
|
row={row}
|
||||||
|
side="response"
|
||||||
value={q.dynamicFieldValues?.[`response:${row.id}`] ?? ""}
|
value={q.dynamicFieldValues?.[`response:${row.id}`] ?? ""}
|
||||||
onChange={(next) =>
|
onChange={(next) =>
|
||||||
setDynamicValue(i, `response:${row.id}`, next)
|
setDynamicValue(i, `response:${row.id}`, next)
|
||||||
}
|
}
|
||||||
|
allFieldValues={q.dynamicFieldValues}
|
||||||
|
stimulusSchema={def.stimulus_schema}
|
||||||
|
responseSchema={def.response_schema}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -464,7 +472,7 @@ export function QuestionsStep({
|
||||||
dynamicFieldValues: { ...(row.dynamicFieldValues ?? {}) },
|
dynamicFieldValues: { ...(row.dynamicFieldValues ?? {}) },
|
||||||
mcqOptions: (row.mcqOptions ?? []).map(
|
mcqOptions: (row.mcqOptions ?? []).map(
|
||||||
(o: { text?: string; isCorrect?: boolean }) => ({
|
(o: { text?: string; isCorrect?: boolean }) => ({
|
||||||
option_text: String(o.text ?? "").trim(),
|
option_text: String(o.text ?? ""),
|
||||||
is_correct: Boolean(o.isCorrect),
|
is_correct: Boolean(o.isCorrect),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user