Download apps/web/src/components/test/QuestionInput.tsx from Atmint/cpns: direct link, hf CLI and curl.
- Browser
- Download file 9.6 kB
-
https://huggingface.co/spaces/Atmint/cpns/resolve/main/apps/web/src/components/test/QuestionInput.tsx
- Command line
-
hf download hf://spaces/Atmint/cpns/apps/web/src/components/test/QuestionInput.tsx
-
curl -L -o QuestionInput.tsx https://huggingface.co/spaces/Atmint/cpns/resolve/main/apps/web/src/components/test/QuestionInput.tsx
9.6 kB
| import { useState, useEffect, useRef, useCallback } from "react"; | |
| import { Input } from "@pram/ui/components/input"; | |
| import { MCQ_FORMATS } from "@/lib/question-formats"; | |
| import type { Question } from "@/lib/types"; | |
| 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 | |
| aria-label="Jawaban esai" | |
| value={localValue} | |
| onChange={handleChange} | |
| onBlur={handleBlur} | |
| disabled={disabled} | |
| placeholder={placeholder} | |
| className={className} | |
| /> | |
| ); | |
| } | |
| export function QuestionInput({ | |
| question, | |
| value, | |
| onChange, | |
| disabled, | |
| }: { | |
| question: Question; | |
| 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..." | |
| aria-label="Padanan jawaban" | |
| 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 as readonly string[]).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)]" | |
| /> | |
| ); | |
| } | |