import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { useT } from '../i18n'; import type { DirectionCard, FormOption, QuestionForm } from '../artifacts/question-form'; import { formatFormAnswers, formOptionValueForLabel } from '../artifacts/question-form'; interface Props { form: QuestionForm; // Whether the user can still submit answers. The owning AssistantMessage // disables the form when the assistant turn is no longer the most recent // one (i.e. the user has already moved past it). interactive: boolean; // Pre-existing answers — when we detect a follow-up user message that // begins with "[form answers — ]", we parse it back out and pass it // here so the rendered form reflects what was sent. submittedAnswers?: Record; // When the form lives in the Questions tab the Continue button owns the // submit, so hide the form's own footer button and report ready-state out. hideInternalSubmit?: boolean; onReadyChange?: (ready: boolean) => void; onSubmit?: (text: string, answers: Record) => void; } // Lets a parent (the Questions tab Continue button) trigger submission. export interface QuestionFormHandle { submit: () => void; // Submit with no answers — backs the "skip all" affordance. Every question // is optional, so this just records each as "(skipped)" and moves on. skipAll: () => void; } export const QuestionFormView = forwardRef(function QuestionFormView( { form, interactive, submittedAnswers, hideInternalSubmit = false, onReadyChange, onSubmit }, ref, ) { const t = useT(); const initial = useMemo(() => buildInitialState(form, submittedAnswers), [form, submittedAnswers]); const [answers, setAnswers] = useState>(initial); const locked = !interactive || !onSubmit || submittedAnswers !== undefined; const currentAnswers = submittedAnswers ?? answers; // When the form streams in question-by-question, backfill state for newly // revealed questions without disturbing answers the user already touched. useEffect(() => { setAnswers((prev) => { let changed = false; const next = { ...prev }; for (const q of form.questions) { if (next[q.id] !== undefined) continue; changed = true; if (submittedAnswers && submittedAnswers[q.id] !== undefined) { next[q.id] = canonicalizeQuestionValue(q, submittedAnswers[q.id]!); } else if (q.defaultValue !== undefined) { next[q.id] = canonicalizeQuestionValue(q, q.defaultValue); } else { next[q.id] = q.type === 'checkbox' ? [] : ''; } } return changed ? next : prev; }); }, [form, submittedAnswers]); function update(id: string, value: string | string[]) { if (locked) return; setAnswers((prev) => ({ ...prev, [id]: value })); } function toggleCheckbox(id: string, option: string, maxSelections?: number) { if (locked) return; setAnswers((prev) => { const current = Array.isArray(prev[id]) ? (prev[id] as string[]) : []; const has = current.includes(option); if (!has && maxSelections !== undefined && current.length >= maxSelections) { return prev; } const next = has ? current.filter((v) => v !== option) : [...current, option]; return { ...prev, [id]: next }; }); } function handleSubmit() { if (locked || !onSubmit) return; if (!withinSelectionLimits) return; onSubmit(formatFormAnswers(form, answers), answers); } function handleSkipAll() { if (locked || !onSubmit) return; const empty: Record = {}; onSubmit(formatFormAnswers(form, empty), empty); } // Every question is optional; the only hard constraint is per-question // checkbox selection caps. "Ready" therefore just means we're within those. const withinSelectionLimits = form.questions.every((q) => { if (q.type !== 'checkbox' || q.maxSelections === undefined) return true; const v = currentAnswers[q.id]; return !Array.isArray(v) || v.length <= q.maxSelections; }); const ready = withinSelectionLimits; useImperativeHandle(ref, () => ({ submit: handleSubmit, skipAll: handleSkipAll })); useEffect(() => { onReadyChange?.(!locked && ready); }, [onReadyChange, locked, ready]); return (
?
{form.title}
{form.description ? (
{form.description}
) : null}
{locked ? {t('qf.answered')} : null}
{form.questions.map((q) => { const value = currentAnswers[q.id]; return (
{q.help ?
{q.help}
: null} {q.type === 'radio' && q.options ? (
{q.options.map((opt) => ( ))}
) : null} {q.type === 'checkbox' && q.options ? (
{q.options.map((opt) => { const arr = Array.isArray(value) ? value : []; const on = arr.includes(opt.value); const maxed = q.maxSelections !== undefined && !on && arr.length >= q.maxSelections; return ( ); })}
) : null} {q.type === 'select' && q.options ? ( ) : null} {q.type === 'text' ? ( update(q.id, e.target.value)} /> ) : null} {q.type === 'textarea' ? (