"use client"; import * as React from "react"; import { Loader2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; export type FormField = { id: string; label: string; type: string; required: boolean; placeholder?: string; helpText?: string; options?: { label: string; value: string }[]; mappedProperty: string | null; validation?: { min?: number; max?: number }; conditionals?: { fieldId: string; operator: string; value: unknown; action: string; }[]; }; function matchesConditional( fieldValue: unknown, operator: string, expected: unknown, ): boolean { switch (operator) { case "eq": return fieldValue === expected; case "neq": return fieldValue !== expected; case "contains": { const a = String(fieldValue ?? "").toLowerCase(); const b = String(expected ?? "").toLowerCase(); return a.includes(b); } case "isEmpty": return ( fieldValue === undefined || fieldValue === null || fieldValue === "" || (Array.isArray(fieldValue) && fieldValue.length === 0) ); default: return true; } } /** Resolves whether a field should be shown given current answer values and its conditionals. */ export function isFieldVisible( field: FormField, values: Record, ): boolean { if (!field.conditionals?.length) return true; let visible = true; for (const c of field.conditionals) { const other = values[c.fieldId]; const ok = matchesConditional(other, c.operator, c.value); if (c.action === "hide" && ok) visible = false; if (c.action === "show" && !ok) visible = false; } return visible; } const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function validateField( field: FormField, value: unknown, visible: boolean, ): string | null { if (!visible) return null; const skip = new Set(["section_header", "divider"]); if (skip.has(field.type)) return null; const empty = value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0); if (field.required && empty) { return `${field.label || "This field"} is required`; } if (empty) return null; if (field.type === "email" && typeof value === "string" && !EMAIL_RE.test(value)) { return "Enter a valid email address"; } if (field.type === "number" && typeof value === "number") { const { min, max } = field.validation ?? {}; if (min !== undefined && value < min) return `Must be at least ${min}`; if (max !== undefined && value > max) return `Must be at most ${max}`; } if (field.type === "rating" && typeof value === "number") { const min = field.validation?.min ?? 1; const max = field.validation?.max ?? 5; if (value < min || value > max) return `Pick a rating between ${min} and ${max}`; } return null; } function parseFields(raw: unknown): FormField[] { if (!Array.isArray(raw)) return []; return raw as FormField[]; } function defaultValueForField(field: FormField): unknown { switch (field.type) { case "checkbox": return false; case "multi_select": return []; case "rating": { const min = field.validation?.min ?? 1; return min; } default: return ""; } } export interface FormRendererProps { formId: string; workspaceHandle: string; onSubmitted?: (objectId: string) => void; className?: string; } export function FormRenderer({ formId, workspaceHandle, onSubmitted, className }: FormRendererProps) { const formQuery = api.forms.getById.useQuery( { workspace: workspaceHandle, id: formId }, { enabled: Boolean(formId) && Boolean(workspaceHandle) }, ); const fields = React.useMemo( () => parseFields(formQuery.data?.fields), [formQuery.data?.fields], ); const [values, setValues] = React.useState>({}); const [errors, setErrors] = React.useState>({}); const [submitted, setSubmitted] = React.useState(false); React.useEffect(() => { setSubmitted(false); setErrors({}); setValues({}); }, [formId]); React.useEffect(() => { if (!fields.length) return; setValues((prev) => { const next = { ...prev }; for (const f of fields) { if (!(f.id in next)) { next[f.id] = defaultValueForField(f); } } return next; }); }, [fields]); const confirmationMessage = React.useMemo(() => { const s = formQuery.data?.settings; if (s && typeof s === "object" && s !== null && "confirmationMessage" in s) { const m = (s as { confirmationMessage?: unknown }).confirmationMessage; if (typeof m === "string" && m.trim()) return m.trim(); } return "Thank you — your response was recorded."; }, [formQuery.data?.settings]); const submitMutation = api.forms.submit.useMutation({ onSuccess: (result) => { setSubmitted(true); onSubmitted?.(result.object.id); }, }); const setField = (id: string, v: unknown) => { setValues((p) => ({ ...p, [id]: v })); setErrors((p) => { const { [id]: _, ...rest } = p; return rest; }); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!formQuery.data) return; const nextErrors: Record = {}; for (const f of fields) { const vis = isFieldVisible(f, values); const err = validateField(f, values[f.id], vis); if (err) nextErrors[f.id] = err; } setErrors(nextErrors); if (Object.keys(nextErrors).length > 0) return; const data: Record = {}; for (const f of fields) { if (!isFieldVisible(f, values)) continue; if (f.type === "section_header" || f.type === "divider") continue; data[f.id] = values[f.id]; } submitMutation.mutate({ workspace: workspaceHandle, formId, data }); }; if (formQuery.isPending) { return (
Loading form
); } if (formQuery.isError || !formQuery.data) { return (

Could not load this form.

); } if (submitted) { return (

{confirmationMessage}

Submitted
); } const form = formQuery.data; return (
{form.description ? (

{form.description}

) : null} {fields.map((field) => { if (!isFieldVisible(field, values)) return null; if (field.type === "section_header") { return (

{field.label}

); } if (field.type === "divider") { return
; } const err = errors[field.id]; const v = values[field.id]; const inputTypes = new Set([ "short_text", "text", "long_text", "textarea", "number", "email", "url", "date", "datetime", "datetime-local", "select", "multi_select", "checkbox", "radio", "rating", "file_upload", ]); return (
{field.helpText ? (

{field.helpText}

) : null} {field.type === "short_text" || field.type === "text" ? ( setField(field.id, e.target.value)} placeholder={field.placeholder} aria-invalid={!!err} aria-describedby={err ? `${field.id}-err` : undefined} /> ) : null} {field.type === "long_text" || field.type === "textarea" ? (