feat: enhance question formats by adding support for matching pairs, error recognition, and text insertion. Update related components and schemas to accommodate new formats, including UI adjustments for displaying options and handling user answers. Implement partial credit scoring for matching pairs and improve answer validation logic in the backend.
02ffd8a | import { useState, useEffect, useRef, useCallback, useMemo } from "react"; | |
| import { Input } from "@labas/ui/components/input"; | |
| const MCQ_FORMATS = [ | |
| "multiple_choice", | |
| "synonym", | |
| "grammar_in_context", | |
| "sentence_completion", | |
| "reference", | |
| "kanji_reading", | |
| "particle_choice", | |
| "article_case", | |
| "matching_headings", | |
| "matching_information", | |
| "summary_completion", | |
| "cloze", | |
| "error_recognition", | |
| "text_insertion", | |
| "matching_pairs", | |
| ]; | |
| const TRUE_FALSE_CHOICES = [ | |
| { key: "TRUE", label: "True" }, | |
| { key: "FALSE", label: "False" }, | |
| { key: "NOT_GIVEN", label: "Not Given" }, | |
| ]; | |
| const AUTHOR_VIEW_CHOICES = [ | |
| { key: "YES", label: "Yes" }, | |
| { key: "NO", label: "No" }, | |
| { key: "NOT_GIVEN", label: "Not Given" }, | |
| ]; | |
| const radioClass = | |
| "flex items-center gap-3 p-3 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] cursor-pointer hover:border-[var(--matcha-300)] transition-all"; | |
| const radioSelected = | |
| "border-[var(--matcha-600)] bg-[#e8f5ed] ring-2 ring-[var(--matcha-600)]/30 shadow-sm"; | |
| const radioDisabled = "opacity-60 cursor-not-allowed"; | |
| /** Normalize TF / NG style answers from API or loose model output */ | |
| function normalizeTriStateKey( | |
| value: string, | |
| choices: readonly { key: string }[], | |
| ): string { | |
| const raw = (value ?? "").trim(); | |
| if (!raw) return ""; | |
| const u = raw.toUpperCase().replace(/\s+/g, "_"); | |
| if (choices.some((c) => c.key === u)) return u; | |
| const compact = u.replace(/_/g, ""); | |
| const alias: Record<string, string> = { | |
| TRUE: "TRUE", | |
| FALSE: "FALSE", | |
| NOTGIVEN: "NOT_GIVEN", | |
| YES: "YES", | |
| NO: "NO", | |
| }; | |
| return alias[compact] ?? u; | |
| } | |
| /** Debounced text input to avoid parent re-render on every keystroke */ | |
| function DebouncedTextInput({ | |
| value, | |
| onChange, | |
| disabled, | |
| placeholder, | |
| className, | |
| }: { | |
| value: string; | |
| onChange: (val: string) => void; | |
| disabled?: boolean; | |
| placeholder?: string; | |
| className?: string; | |
| }) { | |
| const [localValue, setLocalValue] = useState(value); | |
| const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); | |
| // Sync when prop value changes from outside (e.g. restore from server) | |
| useEffect(() => { | |
| setLocalValue(value); | |
| }, [value]); | |
| const handleChange = useCallback( | |
| (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const val = e.target.value; | |
| setLocalValue(val); | |
| clearTimeout(debounceRef.current); | |
| debounceRef.current = setTimeout(() => { | |
| onChange(val); | |
| }, 400); | |
| }, | |
| [onChange], | |
| ); | |
| const handleBlur = useCallback(() => { | |
| clearTimeout(debounceRef.current); | |
| onChange(localValue); | |
| }, [localValue, onChange]); | |
| return ( | |
| <Input | |
| value={localValue} | |
| onChange={handleChange} | |
| onBlur={handleBlur} | |
| disabled={disabled} | |
| placeholder={placeholder} | |
| className={className} | |
| /> | |
| ); | |
| } | |
| export function QuestionInput({ | |
| question, | |
| value, | |
| onChange, | |
| disabled, | |
| }: { | |
| question: any; | |
| value: string; | |
| onChange: (val: string) => void; | |
| disabled?: boolean; | |
| }) { | |
| const format = question.format; | |
| const options = question.options as Array<{ key: string; text: string }> | undefined; | |
| if (format === "matching_pairs") { | |
| if (!options || options.length === 0) { | |
| return ( | |
| <div className="text-sm text-[var(--warm-silver)] italic"> | |
| Tidak ada opsi tersedia untuk soal ini. | |
| </div> | |
| ); | |
| } | |
| const parseCurrentMapping = (val: string): Map<string, string> => { | |
| const map = new Map<string, string>(); | |
| val.split(",").forEach((pair) => { | |
| const [k, v] = pair.split(":").map((s) => s.trim()); | |
| if (k && v) map.set(k, v); | |
| }); | |
| return map; | |
| }; | |
| const currentMap = value ? parseCurrentMapping(value) : new Map(); | |
| const updateMapping = (key: string, val: string) => { | |
| currentMap.set(key, val); | |
| const serialized = Array.from(currentMap.entries()) | |
| .map(([k, v]) => `${k}:${v}`) | |
| .join(","); | |
| onChange(serialized); | |
| }; | |
| return ( | |
| <div className="space-y-3"> | |
| {options.map((opt) => { | |
| const matched = currentMap.get(opt.key) || ""; | |
| return ( | |
| <div key={opt.key} className="flex items-center gap-3"> | |
| <span className="w-8 h-8 rounded-full text-sm font-bold flex items-center justify-center shrink-0 bg-[var(--oat-light)] text-[var(--clay-black)]"> | |
| {opt.key} | |
| </span> | |
| <span className="flex-1 text-sm text-[var(--clay-black)]">{opt.text}</span> | |
| <span className="text-[var(--warm-silver)]">→</span> | |
| <input | |
| type="text" | |
| value={matched} | |
| onChange={(e) => updateMapping(opt.key, e.target.value)} | |
| disabled={disabled} | |
| placeholder="Padanan..." | |
| className="w-24 px-3 py-2 text-sm rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] focus:outline-none focus:border-[var(--matcha-600)]" | |
| /> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| if (MCQ_FORMATS.includes(format)) { | |
| if (!options || options.length === 0) { | |
| return ( | |
| <div className="text-sm text-[var(--warm-silver)] italic"> | |
| Tidak ada opsi tersedia untuk soal ini. | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="space-y-2"> | |
| {options.map((opt) => ( | |
| <label | |
| key={opt.key} | |
| className={`${radioClass} ${value === opt.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`} | |
| > | |
| <input | |
| type="radio" | |
| name={question.id} | |
| value={opt.key} | |
| checked={value === opt.key} | |
| onChange={() => onChange(opt.key)} | |
| disabled={disabled} | |
| className="hidden" | |
| /> | |
| <span className={`w-8 h-8 rounded-full text-sm font-bold flex items-center justify-center shrink-0 transition-colors ${value === opt.key ? "bg-[var(--matcha-600)] text-[var(--pure-white)]" : "bg-[var(--oat-light)] text-[var(--clay-black)]"}`}> | |
| {opt.key} | |
| </span> | |
| <span className="text-sm text-[var(--clay-black)]">{opt.text}</span> | |
| </label> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| if (format === "true_false_not_given") { | |
| const normalizedValue = normalizeTriStateKey(value, TRUE_FALSE_CHOICES); | |
| return ( | |
| <div className="space-y-2"> | |
| {TRUE_FALSE_CHOICES.map((c) => { | |
| const selected = normalizedValue === c.key; | |
| return ( | |
| <label | |
| key={c.key} | |
| className={`${radioClass} ${selected ? radioSelected : ""} ${disabled ? radioDisabled : ""}`} | |
| > | |
| <input | |
| type="radio" | |
| name={question.id} | |
| value={c.key} | |
| checked={selected} | |
| onChange={() => onChange(c.key)} | |
| disabled={disabled} | |
| className="sr-only" | |
| /> | |
| <span | |
| aria-hidden | |
| className={`w-5 h-5 rounded-full border-2 shrink-0 flex items-center justify-center transition-colors ${ | |
| selected | |
| ? "border-[var(--matcha-600)] bg-[var(--matcha-600)]" | |
| : "border-[var(--oat-border)] bg-[var(--pure-white)]" | |
| }`} | |
| > | |
| {selected ? <span className="w-2 h-2 rounded-full bg-[var(--pure-white)]" /> : null} | |
| </span> | |
| <span | |
| className={`text-sm font-semibold ${ | |
| selected ? "text-[var(--matcha-800)]" : "text-[var(--clay-black)]" | |
| }`} | |
| > | |
| {c.label} | |
| </span> | |
| </label> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| if (format === "author_view") { | |
| const normalizedValue = normalizeTriStateKey(value, AUTHOR_VIEW_CHOICES); | |
| return ( | |
| <div className="space-y-2"> | |
| {AUTHOR_VIEW_CHOICES.map((c) => { | |
| const selected = normalizedValue === c.key; | |
| return ( | |
| <label | |
| key={c.key} | |
| className={`${radioClass} ${selected ? radioSelected : ""} ${disabled ? radioDisabled : ""}`} | |
| > | |
| <input | |
| type="radio" | |
| name={question.id} | |
| value={c.key} | |
| checked={selected} | |
| onChange={() => onChange(c.key)} | |
| disabled={disabled} | |
| className="sr-only" | |
| /> | |
| <span | |
| aria-hidden | |
| className={`w-5 h-5 rounded-full border-2 shrink-0 flex items-center justify-center transition-colors ${ | |
| selected | |
| ? "border-[var(--matcha-600)] bg-[var(--matcha-600)]" | |
| : "border-[var(--oat-border)] bg-[var(--pure-white)]" | |
| }`} | |
| > | |
| {selected ? <span className="w-2 h-2 rounded-full bg-[var(--pure-white)]" /> : null} | |
| </span> | |
| <span | |
| className={`text-sm font-semibold ${selected ? "text-[var(--matcha-800)]" : "text-[var(--clay-black)]"}`} | |
| > | |
| {c.label} | |
| </span> | |
| </label> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| // Fallback: debounced text input for fill_blank and other free-text formats | |
| return ( | |
| <DebouncedTextInput | |
| value={value} | |
| onChange={onChange} | |
| disabled={disabled} | |
| placeholder="Ketik jawaban Anda..." | |
| className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-lg)]" | |
| /> | |
| ); | |
| } | |