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 DragEvent,
|
||||
} from "react"
|
||||
import { CloudUpload, FileText, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react"
|
||||
import { CloudUpload, FileText, Image as ImageIcon, Mic, Pause, Play, Plus, Trash2, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api"
|
||||
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
|
||||
|
|
@ -18,6 +18,30 @@ import { cn } from "../../lib/utils"
|
|||
import { ResolvedImage } from "../media/ResolvedImage"
|
||||
import { DynamicTableBuilder } from "./DynamicTableBuilder"
|
||||
import { slotLabel } from "../../lib/schemaSlotLabel"
|
||||
import {
|
||||
addMultipleChoiceOption,
|
||||
parseMultipleChoiceSlotValue,
|
||||
removeMultipleChoiceOption,
|
||||
serializeMultipleChoiceSlotValue,
|
||||
MULTIPLE_CHOICE_MIN_OPTIONS,
|
||||
type MultipleChoiceSlotValue,
|
||||
} from "../../lib/multipleChoiceSlotValue"
|
||||
import {
|
||||
DynamicMatchingAnswerSlot,
|
||||
DynamicMatchingInputsSlot,
|
||||
} from "./DynamicMatchingSlotField"
|
||||
import {
|
||||
findMatchingInputsInFieldValues,
|
||||
type MatchingInputsSlotValue,
|
||||
} from "../../lib/matchingSlotValue"
|
||||
import {
|
||||
DynamicSelectMissingWordsAnswerSlot,
|
||||
DynamicSelectMissingWordsStimulusSlot,
|
||||
} from "./DynamicSelectMissingWordsSlotField"
|
||||
import {
|
||||
findSelectMissingWordsStimulusInFieldValues,
|
||||
type SelectMissingWordsStimulusValue,
|
||||
} from "../../lib/selectMissingWordsSlotValue"
|
||||
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
|
||||
|
|
@ -31,12 +55,49 @@ export interface DynamicSchemaSlotRow {
|
|||
required?: boolean
|
||||
}
|
||||
|
||||
function isMultipleChoiceKind(kind: string): boolean {
|
||||
const u = kind.trim().toUpperCase()
|
||||
return u === "MULTIPLE_CHOICE" || u === "OPTION"
|
||||
}
|
||||
|
||||
function isMatchingInputsKind(kind: string): boolean {
|
||||
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
|
||||
}
|
||||
|
||||
function isMatchingAnswerKind(kind: string): boolean {
|
||||
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
|
||||
}
|
||||
|
||||
function isSelectMissingWordsKind(kind: string): boolean {
|
||||
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
|
||||
}
|
||||
|
||||
function slotMediaMode(
|
||||
kind: string,
|
||||
): "image" | "audio" | "pdf" | "table" | "seconds" | "text" {
|
||||
side: "stimulus" | "response",
|
||||
):
|
||||
| "image"
|
||||
| "audio"
|
||||
| "pdf"
|
||||
| "table"
|
||||
| "seconds"
|
||||
| "text"
|
||||
| "multiple_choice"
|
||||
| "matching_inputs"
|
||||
| "matching_answer"
|
||||
| "select_missing_words_stimulus"
|
||||
| "select_missing_words_answer" {
|
||||
const u = kind.trim().toUpperCase()
|
||||
if (u === "IMAGE") return "image"
|
||||
if (u === "TABLE") return "table"
|
||||
if (isMultipleChoiceKind(kind)) return "multiple_choice"
|
||||
if (isMatchingInputsKind(kind)) return "matching_inputs"
|
||||
if (isMatchingAnswerKind(kind)) return "matching_answer"
|
||||
if (isSelectMissingWordsKind(kind)) {
|
||||
return side === "response"
|
||||
? "select_missing_words_answer"
|
||||
: "select_missing_words_stimulus"
|
||||
}
|
||||
if (u === "PDF_ATTACHMENT" || u === "PDF_UPLOAD") return "pdf"
|
||||
if (u === "PREP_TIME" || u === "ANSWER_TIMER") return "seconds"
|
||||
if (u === "AUDIO_PROMPT" || u === "AUDIO_CLIP" || u === "AUDIO_RESPONSE") return "audio"
|
||||
|
|
@ -567,11 +628,114 @@ function DynamicAudioSlot({
|
|||
)
|
||||
}
|
||||
|
||||
function DynamicMultipleChoiceSlot({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
slotLabel: label,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (next: string) => void
|
||||
disabled: boolean
|
||||
slotLabel: string
|
||||
}) {
|
||||
const parsed = parseMultipleChoiceSlotValue(value)
|
||||
|
||||
const updateValue = (next: MultipleChoiceSlotValue) => {
|
||||
onChange(serializeMultipleChoiceSlotValue(next))
|
||||
}
|
||||
|
||||
const updateOptions = (next: MultipleChoiceSlotValue["options"]) => {
|
||||
updateValue({ options: next })
|
||||
}
|
||||
|
||||
const canRemove = parsed.options.length > MULTIPLE_CHOICE_MIN_OPTIONS
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">{label}</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
|
||||
onClick={() => updateValue(addMultipleChoiceOption(parsed))}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add option
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{parsed.options.map((option, index) => (
|
||||
<div
|
||||
key={`${option.id}-${index}`}
|
||||
className="flex flex-wrap items-center gap-2 sm:flex-nowrap"
|
||||
>
|
||||
<span className="w-6 shrink-0 text-xs font-mono text-grayScale-400">
|
||||
{option.id}
|
||||
</span>
|
||||
<Input
|
||||
value={option.text}
|
||||
onChange={(e) => {
|
||||
const options = [...parsed.options]
|
||||
options[index] = { ...options[index], text: e.target.value }
|
||||
updateOptions(options)
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded-lg border-grayScale-200"
|
||||
placeholder={`Choice ${index + 1}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<label className="flex shrink-0 items-center gap-2 text-sm text-grayScale-600">
|
||||
<input
|
||||
type="radio"
|
||||
name={`mcq-correct-${label}`}
|
||||
checked={option.is_correct}
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
updateOptions(
|
||||
parsed.options.map((opt, i) => ({
|
||||
...opt,
|
||||
is_correct: i === index,
|
||||
})),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
Correct
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled || !canRemove}
|
||||
className="h-8 w-8 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
|
||||
aria-label={`Remove choice ${option.id}`}
|
||||
onClick={() =>
|
||||
updateValue(removeMultipleChoiceOption(parsed, index))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-grayScale-500">
|
||||
Minimum {MULTIPLE_CHOICE_MIN_OPTIONS} options. Mark one as correct.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface DynamicSchemaSlotFieldProps {
|
||||
row: DynamicSchemaSlotRow
|
||||
value: string
|
||||
onChange: (next: string) => void
|
||||
disabled?: boolean
|
||||
side: "stimulus" | "response"
|
||||
allFieldValues?: Record<string, string>
|
||||
stimulusSchema?: DynamicSchemaSlotRow[]
|
||||
responseSchema?: DynamicSchemaSlotRow[]
|
||||
}
|
||||
|
||||
function DynamicPdfSlot({
|
||||
|
|
@ -678,9 +842,28 @@ export function DynamicSchemaSlotField({
|
|||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
side,
|
||||
allFieldValues,
|
||||
stimulusSchema = [],
|
||||
responseSchema = [],
|
||||
}: DynamicSchemaSlotFieldProps) {
|
||||
const mode = slotMediaMode(row.kind)
|
||||
const mode = slotMediaMode(row.kind, side)
|
||||
const fieldLabel = `${slotLabel(row)}${row.required ? " *" : ""}`
|
||||
const matchingInputs: MatchingInputsSlotValue | null =
|
||||
mode === "matching_answer" && allFieldValues
|
||||
? findMatchingInputsInFieldValues(
|
||||
allFieldValues,
|
||||
stimulusSchema,
|
||||
responseSchema,
|
||||
)
|
||||
: null
|
||||
const clozeStimulus: SelectMissingWordsStimulusValue | null =
|
||||
mode === "select_missing_words_answer" && allFieldValues
|
||||
? findSelectMissingWordsStimulusInFieldValues(
|
||||
allFieldValues,
|
||||
stimulusSchema,
|
||||
)
|
||||
: null
|
||||
|
||||
if (mode === "table") {
|
||||
return (
|
||||
|
|
@ -713,6 +896,63 @@ export function DynamicSchemaSlotField({
|
|||
)
|
||||
}
|
||||
|
||||
if (mode === "multiple_choice") {
|
||||
return (
|
||||
<DynamicMultipleChoiceSlot
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slotLabel={fieldLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === "matching_inputs") {
|
||||
return (
|
||||
<DynamicMatchingInputsSlot
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slotLabel={fieldLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === "matching_answer") {
|
||||
return (
|
||||
<DynamicMatchingAnswerSlot
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slotLabel={fieldLabel}
|
||||
matchingInputs={matchingInputs}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === "select_missing_words_stimulus") {
|
||||
return (
|
||||
<DynamicSelectMissingWordsStimulusSlot
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slotLabel={fieldLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === "select_missing_words_answer") {
|
||||
return (
|
||||
<DynamicSelectMissingWordsAnswerSlot
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slotLabel={fieldLabel}
|
||||
stimulus={clozeStimulus}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === "text") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
row={row}
|
||||
side="stimulus"
|
||||
value={value.dynamicFieldValues[`stimulus:${row.id}`] ?? ""}
|
||||
onChange={(next) => setDynamicField(`stimulus:${row.id}`, next)}
|
||||
disabled={controlsDisabled}
|
||||
allFieldValues={value.dynamicFieldValues}
|
||||
stimulusSchema={value.dynamicStimulusRows}
|
||||
responseSchema={value.dynamicResponseRows}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -830,9 +834,13 @@ export function PracticeQuestionEditorFields({
|
|||
>
|
||||
<DynamicSchemaSlotField
|
||||
row={row}
|
||||
side="response"
|
||||
value={value.dynamicFieldValues[`response:${row.id}`] ?? ""}
|
||||
onChange={(next) => setDynamicField(`response:${row.id}`, next)}
|
||||
disabled={controlsDisabled}
|
||||
allFieldValues={value.dynamicFieldValues}
|
||||
stimulusSchema={value.dynamicStimulusRows}
|
||||
responseSchema={value.dynamicResponseRows}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,89 @@ import type { CreateQuestionRequest, QuestionOption } from "../types/course.type
|
|||
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
||||
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
|
||||
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
|
||||
import {
|
||||
parseMultipleChoiceSlotValue,
|
||||
serializeMultipleChoiceSlotValue,
|
||||
defaultMultipleChoiceSlotValue,
|
||||
validateMultipleChoiceSlotValue,
|
||||
multipleChoiceSlotHasContent,
|
||||
} from "./multipleChoiceSlotValue"
|
||||
import {
|
||||
defaultMatchingInputsSlotValue,
|
||||
findMatchingInputsInFieldValues,
|
||||
matchingAnswerSlotHasContent,
|
||||
matchingInputsSlotHasContent,
|
||||
parseMatchingAnswerSlotValue,
|
||||
parseMatchingInputsSlotValue,
|
||||
serializeMatchingAnswerSlotValue,
|
||||
serializeMatchingInputsSlotValue,
|
||||
validateMatchingAnswerSlotValue,
|
||||
validateMatchingInputsSlotValue,
|
||||
} from "./matchingSlotValue"
|
||||
import {
|
||||
defaultSelectMissingWordsStimulusSlotValue,
|
||||
findSelectMissingWordsStimulusInFieldValues,
|
||||
parseSelectMissingWordsResponseSlotValue,
|
||||
parseSelectMissingWordsStimulusSlotValue,
|
||||
selectMissingWordsResponseHasContent,
|
||||
selectMissingWordsStimulusHasContent,
|
||||
serializeSelectMissingWordsResponseSlotValue,
|
||||
serializeSelectMissingWordsStimulusSlotValue,
|
||||
validateSelectMissingWordsResponseSlotValue,
|
||||
validateSelectMissingWordsStimulusSlotValue,
|
||||
} from "./selectMissingWordsSlotValue"
|
||||
|
||||
function defaultValueForSchemaSlot(kind: string): string {
|
||||
function isMultipleChoiceKind(kind: string): boolean {
|
||||
const u = kind.trim().toUpperCase()
|
||||
return u === "MULTIPLE_CHOICE" || u === "OPTION"
|
||||
}
|
||||
|
||||
function isMatchingInputsKind(kind: string): boolean {
|
||||
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
|
||||
}
|
||||
|
||||
function isMatchingAnswerKind(kind: string): boolean {
|
||||
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
|
||||
}
|
||||
|
||||
function isSelectMissingWordsKind(kind: string): boolean {
|
||||
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
|
||||
}
|
||||
|
||||
function isStructuredDynamicSlotKind(kind: string): boolean {
|
||||
return (
|
||||
isMultipleChoiceKind(kind) ||
|
||||
isMatchingInputsKind(kind) ||
|
||||
isMatchingAnswerKind(kind) ||
|
||||
isSelectMissingWordsKind(kind)
|
||||
)
|
||||
}
|
||||
|
||||
function defaultValueForSchemaSlot(
|
||||
kind: string,
|
||||
side: "stimulus" | "response",
|
||||
): string {
|
||||
const u = kind.trim().toUpperCase()
|
||||
if (u === "TABLE") {
|
||||
return serializeTableSlotValue(createEmptyTable(2, 1))
|
||||
}
|
||||
if (isMultipleChoiceKind(kind)) {
|
||||
return serializeMultipleChoiceSlotValue(defaultMultipleChoiceSlotValue())
|
||||
}
|
||||
if (isMatchingInputsKind(kind)) {
|
||||
return serializeMatchingInputsSlotValue(defaultMatchingInputsSlotValue())
|
||||
}
|
||||
if (isMatchingAnswerKind(kind)) {
|
||||
return serializeMatchingAnswerSlotValue({ pairs: [] })
|
||||
}
|
||||
if (isSelectMissingWordsKind(kind)) {
|
||||
if (side === "stimulus") {
|
||||
return serializeSelectMissingWordsStimulusSlotValue(
|
||||
defaultSelectMissingWordsStimulusSlotValue(),
|
||||
)
|
||||
}
|
||||
return serializeSelectMissingWordsResponseSlotValue({ blanks: [] })
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
@ -20,10 +97,10 @@ export function emptyDynamicFieldValuesForDefinition(
|
|||
): Record<string, string> {
|
||||
const o: Record<string, string> = {}
|
||||
for (const r of def.stimulus_schema) {
|
||||
o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind)
|
||||
o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind, "stimulus")
|
||||
}
|
||||
for (const r of def.response_schema) {
|
||||
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind)
|
||||
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind, "response")
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
|
@ -104,9 +181,89 @@ export function questionRowHasContent(
|
|||
if (q.questionText.trim()) return true
|
||||
const fv = q.dynamicFieldValues ?? {}
|
||||
for (const row of def.stimulus_schema) {
|
||||
if (isMultipleChoiceKind(row.kind)) {
|
||||
if (
|
||||
multipleChoiceSlotHasContent(
|
||||
parseMultipleChoiceSlotValue(fv[`stimulus:${row.id}`]),
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isMatchingInputsKind(row.kind)) {
|
||||
if (
|
||||
matchingInputsSlotHasContent(
|
||||
parseMatchingInputsSlotValue(fv[`stimulus:${row.id}`]),
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isMatchingAnswerKind(row.kind)) {
|
||||
if (
|
||||
matchingAnswerSlotHasContent(
|
||||
parseMatchingAnswerSlotValue(fv[`stimulus:${row.id}`]),
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isSelectMissingWordsKind(row.kind)) {
|
||||
if (
|
||||
selectMissingWordsStimulusHasContent(
|
||||
parseSelectMissingWordsStimulusSlotValue(fv[`stimulus:${row.id}`]),
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (fv[`stimulus:${row.id}`]?.trim()) return true
|
||||
}
|
||||
for (const row of def.response_schema) {
|
||||
if (isMultipleChoiceKind(row.kind)) {
|
||||
if (
|
||||
multipleChoiceSlotHasContent(
|
||||
parseMultipleChoiceSlotValue(fv[`response:${row.id}`]),
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isMatchingInputsKind(row.kind)) {
|
||||
if (
|
||||
matchingInputsSlotHasContent(
|
||||
parseMatchingInputsSlotValue(fv[`response:${row.id}`]),
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isMatchingAnswerKind(row.kind)) {
|
||||
if (
|
||||
matchingAnswerSlotHasContent(
|
||||
parseMatchingAnswerSlotValue(fv[`response:${row.id}`]),
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isSelectMissingWordsKind(row.kind)) {
|
||||
if (
|
||||
selectMissingWordsResponseHasContent(
|
||||
parseSelectMissingWordsResponseSlotValue(fv[`response:${row.id}`]),
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (fv[`response:${row.id}`]?.trim()) return true
|
||||
}
|
||||
return false
|
||||
|
|
@ -131,6 +288,7 @@ export function buildCreateQuestionFromDefinition(
|
|||
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
|
||||
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
|
||||
fieldValues,
|
||||
mcqOptions: q.mcqOptions,
|
||||
})
|
||||
return {
|
||||
question_type: "DYNAMIC",
|
||||
|
|
@ -223,17 +381,147 @@ export function validateDefinitionQuestion(
|
|||
return `Question ${n}: enter prompt text (${promptRow.label || promptRow.id}).`
|
||||
}
|
||||
for (const row of def.stimulus_schema) {
|
||||
if (isStructuredDynamicSlotKind(row.kind)) continue
|
||||
if (!row.required) continue
|
||||
const v = fieldValues[`stimulus:${row.id}`]?.trim()
|
||||
if (!v)
|
||||
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
|
||||
}
|
||||
for (const row of def.response_schema) {
|
||||
if (isStructuredDynamicSlotKind(row.kind)) continue
|
||||
if (!row.required) continue
|
||||
const v = fieldValues[`response:${row.id}`]?.trim()
|
||||
if (!v)
|
||||
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
|
||||
}
|
||||
const matchingInputs = findMatchingInputsInFieldValues(
|
||||
fieldValues,
|
||||
def.stimulus_schema,
|
||||
def.response_schema,
|
||||
)
|
||||
const clozeStimulus = findSelectMissingWordsStimulusInFieldValues(
|
||||
fieldValues,
|
||||
def.stimulus_schema,
|
||||
)
|
||||
for (const row of def.stimulus_schema) {
|
||||
if (isMultipleChoiceKind(row.kind)) {
|
||||
const val = parseMultipleChoiceSlotValue(fieldValues[`stimulus:${row.id}`])
|
||||
if (!multipleChoiceSlotHasContent(val)) {
|
||||
if (row.required) {
|
||||
return `Question ${n}: add choices for stimulus "${row.label || row.id}".`
|
||||
}
|
||||
continue
|
||||
}
|
||||
const mcqErr = validateMultipleChoiceSlotValue(val)
|
||||
if (mcqErr) {
|
||||
return `Question ${n} (stimulus "${row.label || row.id}"): ${mcqErr}`
|
||||
}
|
||||
}
|
||||
if (isMatchingInputsKind(row.kind)) {
|
||||
const val = parseMatchingInputsSlotValue(fieldValues[`stimulus:${row.id}`])
|
||||
if (!matchingInputsSlotHasContent(val)) {
|
||||
if (row.required) {
|
||||
return `Question ${n}: add matching inputs for stimulus "${row.label || row.id}".`
|
||||
}
|
||||
continue
|
||||
}
|
||||
const err = validateMatchingInputsSlotValue(val)
|
||||
if (err) {
|
||||
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
|
||||
}
|
||||
}
|
||||
if (isMatchingAnswerKind(row.kind)) {
|
||||
const val = parseMatchingAnswerSlotValue(
|
||||
fieldValues[`stimulus:${row.id}`],
|
||||
matchingInputs,
|
||||
)
|
||||
if (!matchingAnswerSlotHasContent(val)) {
|
||||
if (row.required) {
|
||||
return `Question ${n}: add matching pairs for stimulus "${row.label || row.id}".`
|
||||
}
|
||||
continue
|
||||
}
|
||||
const err = validateMatchingAnswerSlotValue(val, matchingInputs)
|
||||
if (err) {
|
||||
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
|
||||
}
|
||||
}
|
||||
if (isSelectMissingWordsKind(row.kind)) {
|
||||
const val = parseSelectMissingWordsStimulusSlotValue(
|
||||
fieldValues[`stimulus:${row.id}`],
|
||||
)
|
||||
if (!selectMissingWordsStimulusHasContent(val)) {
|
||||
if (row.required) {
|
||||
return `Question ${n}: add cloze passage for stimulus "${row.label || row.id}".`
|
||||
}
|
||||
continue
|
||||
}
|
||||
const err = validateSelectMissingWordsStimulusSlotValue(val)
|
||||
if (err) {
|
||||
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const row of def.response_schema) {
|
||||
if (isMultipleChoiceKind(row.kind)) {
|
||||
const val = parseMultipleChoiceSlotValue(fieldValues[`response:${row.id}`])
|
||||
if (!multipleChoiceSlotHasContent(val)) {
|
||||
if (row.required) {
|
||||
return `Question ${n}: add choices for response "${row.label || row.id}".`
|
||||
}
|
||||
continue
|
||||
}
|
||||
const mcqErr = validateMultipleChoiceSlotValue(val)
|
||||
if (mcqErr) {
|
||||
return `Question ${n} (response "${row.label || row.id}"): ${mcqErr}`
|
||||
}
|
||||
}
|
||||
if (isMatchingInputsKind(row.kind)) {
|
||||
const val = parseMatchingInputsSlotValue(fieldValues[`response:${row.id}`])
|
||||
if (!matchingInputsSlotHasContent(val)) {
|
||||
if (row.required) {
|
||||
return `Question ${n}: add matching inputs for response "${row.label || row.id}".`
|
||||
}
|
||||
continue
|
||||
}
|
||||
const err = validateMatchingInputsSlotValue(val)
|
||||
if (err) {
|
||||
return `Question ${n} (response "${row.label || row.id}"): ${err}`
|
||||
}
|
||||
}
|
||||
if (isMatchingAnswerKind(row.kind)) {
|
||||
const val = parseMatchingAnswerSlotValue(
|
||||
fieldValues[`response:${row.id}`],
|
||||
matchingInputs,
|
||||
)
|
||||
if (!matchingAnswerSlotHasContent(val)) {
|
||||
if (row.required) {
|
||||
return `Question ${n}: add matching pairs for response "${row.label || row.id}".`
|
||||
}
|
||||
continue
|
||||
}
|
||||
const err = validateMatchingAnswerSlotValue(val, matchingInputs)
|
||||
if (err) {
|
||||
return `Question ${n} (response "${row.label || row.id}"): ${err}`
|
||||
}
|
||||
}
|
||||
if (isSelectMissingWordsKind(row.kind)) {
|
||||
const val = parseSelectMissingWordsResponseSlotValue(
|
||||
fieldValues[`response:${row.id}`],
|
||||
clozeStimulus,
|
||||
)
|
||||
if (!selectMissingWordsResponseHasContent(val)) {
|
||||
if (row.required) {
|
||||
return `Question ${n}: select words for each blank in response "${row.label || row.id}".`
|
||||
}
|
||||
continue
|
||||
}
|
||||
const err = validateSelectMissingWordsResponseSlotValue(val, clozeStimulus)
|
||||
if (err) {
|
||||
return `Question ${n} (response "${row.label || row.id}"): ${err}`
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
|||
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
|
||||
|
||||
export const MULTIPLE_CHOICE_MIN_OPTIONS = 2
|
||||
|
||||
export function defaultMultipleChoiceSlotValue(
|
||||
count = 4,
|
||||
count = MULTIPLE_CHOICE_MIN_OPTIONS,
|
||||
): MultipleChoiceSlotValue {
|
||||
return {
|
||||
options: Array.from({ length: count }, (_, index) => ({
|
||||
|
|
@ -28,6 +30,70 @@ export function serializeMultipleChoiceSlotValue(
|
|||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
export function nextMultipleChoiceOptionId(
|
||||
existing: MultipleChoiceOptionValue[],
|
||||
): string {
|
||||
const used = new Set(existing.map((option) => option.id))
|
||||
for (const id of DEFAULT_OPTION_IDS) {
|
||||
if (!used.has(id)) return id
|
||||
}
|
||||
return String(existing.length + 1)
|
||||
}
|
||||
|
||||
export function addMultipleChoiceOption(
|
||||
value: MultipleChoiceSlotValue,
|
||||
): MultipleChoiceSlotValue {
|
||||
return {
|
||||
options: [
|
||||
...value.options,
|
||||
{
|
||||
id: nextMultipleChoiceOptionId(value.options),
|
||||
text: "",
|
||||
is_correct: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function removeMultipleChoiceOption(
|
||||
value: MultipleChoiceSlotValue,
|
||||
index: number,
|
||||
): MultipleChoiceSlotValue {
|
||||
if (value.options.length <= MULTIPLE_CHOICE_MIN_OPTIONS) return value
|
||||
const removed = value.options[index]
|
||||
let options = value.options.filter((_, i) => i !== index)
|
||||
if (
|
||||
removed?.is_correct &&
|
||||
options.length > 0 &&
|
||||
!options.some((option) => option.is_correct)
|
||||
) {
|
||||
options = options.map((option, i) => ({
|
||||
...option,
|
||||
is_correct: i === 0,
|
||||
}))
|
||||
}
|
||||
return { options }
|
||||
}
|
||||
|
||||
export function ensureMinMultipleChoiceOptions(
|
||||
value: MultipleChoiceSlotValue,
|
||||
): MultipleChoiceSlotValue {
|
||||
if (value.options.length >= MULTIPLE_CHOICE_MIN_OPTIONS) return value
|
||||
const options = [...value.options]
|
||||
while (options.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
|
||||
options.push({
|
||||
id: nextMultipleChoiceOptionId(options),
|
||||
text: "",
|
||||
is_correct: options.length === 0,
|
||||
})
|
||||
}
|
||||
return { options }
|
||||
}
|
||||
|
||||
export function multipleChoiceOptionHasValue(text: string): boolean {
|
||||
return text.length > 0
|
||||
}
|
||||
|
||||
export function parseMultipleChoiceSlotValue(
|
||||
raw: string | undefined,
|
||||
): MultipleChoiceSlotValue {
|
||||
|
|
@ -35,7 +101,7 @@ export function parseMultipleChoiceSlotValue(
|
|||
if (!trimmed) return defaultMultipleChoiceSlotValue()
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown
|
||||
return normalizeMultipleChoiceValue(parsed)
|
||||
return ensureMinMultipleChoiceOptions(normalizeMultipleChoiceValue(parsed))
|
||||
} catch {
|
||||
return defaultMultipleChoiceSlotValue()
|
||||
}
|
||||
|
|
@ -45,15 +111,15 @@ export function normalizeMultipleChoiceValue(
|
|||
raw: unknown,
|
||||
mcqOptions?: { option_text?: string; text?: string; is_correct?: boolean; isCorrect?: boolean }[],
|
||||
): MultipleChoiceSlotValue {
|
||||
if (mcqOptions?.some((o) => (o.option_text ?? o.text ?? "").trim())) {
|
||||
if (mcqOptions?.some((o) => multipleChoiceOptionHasValue(o.option_text ?? o.text ?? ""))) {
|
||||
return {
|
||||
options: mcqOptions
|
||||
.map((option, index) => ({
|
||||
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
|
||||
text: (option.option_text ?? option.text ?? "").trim(),
|
||||
text: option.option_text ?? option.text ?? "",
|
||||
is_correct: Boolean(option.is_correct ?? option.isCorrect),
|
||||
}))
|
||||
.filter((option) => option.text),
|
||||
.filter((option) => multipleChoiceOptionHasValue(option.text)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,13 +161,13 @@ function normalizeMultipleChoiceOption(
|
|||
const record = raw as Record<string, unknown>
|
||||
return {
|
||||
id: String(record.id ?? DEFAULT_OPTION_IDS[index] ?? index + 1),
|
||||
text: String(record.text ?? record.option_text ?? "").trim(),
|
||||
text: String(record.text ?? record.option_text ?? ""),
|
||||
is_correct: Boolean(record.is_correct ?? record.isCorrect),
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
|
||||
text: String(raw ?? "").trim(),
|
||||
text: String(raw ?? ""),
|
||||
is_correct: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -109,14 +175,21 @@ function normalizeMultipleChoiceOption(
|
|||
export function multipleChoiceSlotHasContent(
|
||||
value: MultipleChoiceSlotValue,
|
||||
): boolean {
|
||||
return value.options.some((option) => option.text.trim())
|
||||
return value.options.some((option) => multipleChoiceOptionHasValue(option.text))
|
||||
}
|
||||
|
||||
export function validateMultipleChoiceSlotValue(
|
||||
value: MultipleChoiceSlotValue,
|
||||
): string | null {
|
||||
const filled = value.options.filter((option) => option.text.trim())
|
||||
if (filled.length < 2) return "Add at least two choices with text."
|
||||
if (value.options.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
|
||||
return `Add at least ${MULTIPLE_CHOICE_MIN_OPTIONS} choices.`
|
||||
}
|
||||
const filled = value.options.filter((option) =>
|
||||
multipleChoiceOptionHasValue(option.text),
|
||||
)
|
||||
if (filled.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
|
||||
return `Add at least ${MULTIPLE_CHOICE_MIN_OPTIONS} choices with text.`
|
||||
}
|
||||
if (!filled.some((option) => option.is_correct)) {
|
||||
return "Mark one choice as correct."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,24 @@
|
|||
import type { DynamicQuestionPayload } from "../types/questionTypeDefinition.types"
|
||||
import {
|
||||
finalizeMatchingAnswerPayload,
|
||||
finalizeMatchingInputsPayload,
|
||||
findMatchingInputsInFieldValues,
|
||||
matchingAnswerSlotHasContent,
|
||||
matchingInputsSlotHasContent,
|
||||
parseMatchingAnswerSlotValue,
|
||||
parseMatchingInputsSlotValue,
|
||||
} from "./matchingSlotValue"
|
||||
import {
|
||||
finalizeSelectMissingWordsResponsePayload,
|
||||
finalizeSelectMissingWordsStimulusPayload,
|
||||
findSelectMissingWordsStimulusInFieldValues,
|
||||
parseSelectMissingWordsResponseSlotValue,
|
||||
parseSelectMissingWordsStimulusSlotValue,
|
||||
selectMissingWordsResponseHasContent,
|
||||
selectMissingWordsStimulusHasContent,
|
||||
} from "./selectMissingWordsSlotValue"
|
||||
import {
|
||||
multipleChoiceOptionHasValue,
|
||||
multipleChoiceSlotHasContent,
|
||||
normalizeMultipleChoiceValue,
|
||||
parseMultipleChoiceSlotValue,
|
||||
|
|
@ -32,12 +51,26 @@ function isMultipleChoiceKind(kind: string): boolean {
|
|||
return upper === "MULTIPLE_CHOICE" || upper === "OPTION"
|
||||
}
|
||||
|
||||
function isMatchingInputsKind(kind: string): boolean {
|
||||
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
|
||||
}
|
||||
|
||||
function isMatchingAnswerKind(kind: string): boolean {
|
||||
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
|
||||
}
|
||||
|
||||
function isSelectMissingWordsKind(kind: string): boolean {
|
||||
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
|
||||
}
|
||||
|
||||
function slotValueForRow(
|
||||
row: { id: string; kind: string },
|
||||
side: "stimulus" | "response",
|
||||
fieldValues: Record<string, string>,
|
||||
mcqOptions: { option_text: string; is_correct: boolean }[] | undefined,
|
||||
mcqOptionsConsumed: { current: boolean },
|
||||
stimulusRows: { id: string; kind: string }[],
|
||||
responseRows: { id: string; kind: string }[],
|
||||
): unknown {
|
||||
const fieldKey = `${side}:${row.id}`
|
||||
const rawField = fieldValues[fieldKey]
|
||||
|
|
@ -45,13 +78,64 @@ function slotValueForRow(
|
|||
if (isMultipleChoiceKind(row.kind)) {
|
||||
const fromField = parseMultipleChoiceSlotValue(rawField)
|
||||
if (multipleChoiceSlotHasContent(fromField)) {
|
||||
return normalizeMultipleChoiceValue(fromField)
|
||||
return {
|
||||
options: fromField.options
|
||||
.filter((option) => multipleChoiceOptionHasValue(option.text))
|
||||
.map((option) => ({
|
||||
id: option.id,
|
||||
text: option.text,
|
||||
is_correct: option.is_correct,
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (mcqOptions && !mcqOptionsConsumed.current) {
|
||||
mcqOptionsConsumed.current = true
|
||||
return normalizeMultipleChoiceValue(undefined, mcqOptions)
|
||||
}
|
||||
return normalizeMultipleChoiceValue(fromField)
|
||||
return { options: [] }
|
||||
}
|
||||
|
||||
if (isMatchingInputsKind(row.kind)) {
|
||||
const fromField = parseMatchingInputsSlotValue(rawField)
|
||||
if (matchingInputsSlotHasContent(fromField)) {
|
||||
return finalizeMatchingInputsPayload(fromField)
|
||||
}
|
||||
return { left: [], right: [] }
|
||||
}
|
||||
|
||||
if (isMatchingAnswerKind(row.kind)) {
|
||||
const matchingInputs = findMatchingInputsInFieldValues(
|
||||
fieldValues,
|
||||
stimulusRows,
|
||||
responseRows,
|
||||
)
|
||||
const fromField = parseMatchingAnswerSlotValue(rawField, matchingInputs)
|
||||
if (matchingAnswerSlotHasContent(fromField)) {
|
||||
return finalizeMatchingAnswerPayload(fromField)
|
||||
}
|
||||
return { pairs: [] }
|
||||
}
|
||||
|
||||
if (isSelectMissingWordsKind(row.kind)) {
|
||||
if (side === "stimulus") {
|
||||
const fromField = parseSelectMissingWordsStimulusSlotValue(rawField)
|
||||
if (selectMissingWordsStimulusHasContent(fromField)) {
|
||||
return finalizeSelectMissingWordsStimulusPayload(fromField)
|
||||
}
|
||||
return { segments: [], word_bank: [], allow_reuse: false }
|
||||
}
|
||||
const clozeStimulus = findSelectMissingWordsStimulusInFieldValues(
|
||||
fieldValues,
|
||||
stimulusRows,
|
||||
)
|
||||
const fromField = parseSelectMissingWordsResponseSlotValue(
|
||||
rawField,
|
||||
clozeStimulus,
|
||||
)
|
||||
if (selectMissingWordsResponseHasContent(fromField)) {
|
||||
return finalizeSelectMissingWordsResponsePayload(fromField)
|
||||
}
|
||||
return { blanks: [] }
|
||||
}
|
||||
|
||||
if (side === "stimulus" && PLAIN_TEXT_STIMULUS_KINDS.has(row.kind.trim().toUpperCase())) {
|
||||
|
|
@ -79,6 +163,8 @@ export function buildDynamicQuestionPayload(input: {
|
|||
input.fieldValues,
|
||||
input.mcqOptions,
|
||||
mcqOptionsConsumed,
|
||||
input.stimulusRows,
|
||||
input.responseRows,
|
||||
),
|
||||
})),
|
||||
response: input.responseRows.map((row) => ({
|
||||
|
|
@ -90,6 +176,8 @@ export function buildDynamicQuestionPayload(input: {
|
|||
input.fieldValues,
|
||||
input.mcqOptions,
|
||||
mcqOptionsConsumed,
|
||||
input.stimulusRows,
|
||||
input.responseRows,
|
||||
),
|
||||
})),
|
||||
}
|
||||
|
|
|
|||
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 ?? {}) },
|
||||
mcqOptions: (q.mcqOptions ?? []).map(
|
||||
(o: { text?: string; isCorrect?: boolean }) => ({
|
||||
option_text: String(o.text ?? "").trim(),
|
||||
option_text: String(o.text ?? ""),
|
||||
is_correct: Boolean(o.isCorrect),
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -114,10 +114,14 @@ export function QuestionsStep({
|
|||
>
|
||||
<DynamicSchemaSlotField
|
||||
row={row}
|
||||
side="stimulus"
|
||||
value={q.dynamicFieldValues?.[`stimulus:${row.id}`] ?? ""}
|
||||
onChange={(next) =>
|
||||
setDynamicValue(i, `stimulus:${row.id}`, next)
|
||||
}
|
||||
allFieldValues={q.dynamicFieldValues}
|
||||
stimulusSchema={def.stimulus_schema}
|
||||
responseSchema={def.response_schema}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -133,10 +137,14 @@ export function QuestionsStep({
|
|||
>
|
||||
<DynamicSchemaSlotField
|
||||
row={row}
|
||||
side="response"
|
||||
value={q.dynamicFieldValues?.[`response:${row.id}`] ?? ""}
|
||||
onChange={(next) =>
|
||||
setDynamicValue(i, `response:${row.id}`, next)
|
||||
}
|
||||
allFieldValues={q.dynamicFieldValues}
|
||||
stimulusSchema={def.stimulus_schema}
|
||||
responseSchema={def.response_schema}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -464,7 +472,7 @@ export function QuestionsStep({
|
|||
dynamicFieldValues: { ...(row.dynamicFieldValues ?? {}) },
|
||||
mcqOptions: (row.mcqOptions ?? []).map(
|
||||
(o: { text?: string; isCorrect?: boolean }) => ({
|
||||
option_text: String(o.text ?? "").trim(),
|
||||
option_text: String(o.text ?? ""),
|
||||
is_correct: Boolean(o.isCorrect),
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user