"use client"; /** * FormBuilderView — in-app editor for a form that belongs to a database. * * The API has NO per-question routes: a form's questions are managed as one * ordered array. So this builder keeps a local **draft** (settings + questions, * including order) and persists with a single PATCH /forms/:formId — the * server rebuilds the questions in the given order (regenerating fractional * positions). Add/edit/remove/reorder are all local; "Save form" flushes. * * Each question maps (optionally) onto a database property so its answer lands * on the created record. Question types are UPPERCASE (FormQuestionType). * Select options + number/rating bounds live in the question's `validation`. */ import { useState } from "react"; import { ArrowDown, ArrowUp, GripVertical, Link2, Plus, Save, Trash2, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "@/stores/ui-store"; import { DatabaseProperty } from "@/hooks/use-database"; import { FormConfig, FormDto, FormOption, FormQuestionInput, FormQuestionType, FormStatus, FormValidation, FORM_QUESTION_TYPES, FORM_STATUSES, questionOptions, useUpdateForm } from "@/hooks/use-forms"; import { FormField } from "@/components/form/form-field"; /** Toast adapter: call sites use variant:"destructive"; core store uses kind. */ const notify = (t: { title: string; description?: string; variant?: string }) => toast(t.variant === "destructive" ? "error" : "success", t.title, t.description); const QUESTION_TYPES: { type: FormQuestionType; label: string }[] = FORM_QUESTION_TYPES.map((t) => ({ type: t, label: t.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()) })); const inputCls = "w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"; export interface FormBuilderViewProps { form: FormDto; properties: DatabaseProperty[]; databaseId: string; /** Workspace id (PATCH /workspaces/:wid/forms/:id). */ workspaceId?: string; } /** Draft question = input + a client-side key (for React list keys during draft). */ type DraftQuestion = FormQuestionInput & { key: string }; function keyFor(q: { id?: string }) { return q.id ?? `dq_${Math.random().toString(36).slice(2, 10)}`; } function toInput(q: DraftQuestion): FormQuestionInput { return { type: q.type, label: q.label, description: q.description, required: q.required, propertyId: q.propertyId ?? null, validation: q.validation ?? null, placeholder: q.placeholder }; } /** Property type (lowercase wire) → compatible question type. */ function defaultTypeFor(propertyType?: string): FormQuestionType { switch (propertyType) { case "title": case "text": case "rich_text": return "SHORT_TEXT"; case "number": return "NUMBER"; case "email": return "EMAIL"; case "date": case "date_range": return "DATE"; case "checkbox": return "CHECKBOX"; case "select": case "status": return "SINGLE_SELECT"; case "multi_select": return "MULTI_SELECT"; case "files": return "FILE"; default: return "SHORT_TEXT"; } } function optionsForProperty(prop?: DatabaseProperty): FormOption[] { const opts = prop?.config?.options; if (!opts || !Array.isArray(opts)) return []; return opts.map((o) => ({ id: o.id, name: o.name, color: o.color })); } function setSelectOptions(input: FormQuestionInput, options: FormOption[]): FormQuestionInput { const validation: FormValidation = { ...(input.validation ?? {}) }; // The wire contract is a STRING array (the server's validation.options is // `string[]` — see use-forms.ts FormValidation). Emit names, not objects. const names = options.map((o) => o.name.trim()).filter((n) => n.length > 0); if (names.length) validation.options = names; else delete validation.options; return { ...input, validation: Object.keys(validation).length ? validation : null }; } export function FormBuilderView({ form, properties, databaseId, workspaceId }: FormBuilderViewProps) { // PATCH /forms/:formId is database-scoped (the workspaceId prop is kept for // the component's props contract but no longer drives the mutation hook). void workspaceId; const updateForm = useUpdateForm(databaseId); // Local draft (server state lives in TanStack Query; this is the edit buffer). const [slug, setSlug] = useState(form.slug); const [status, setStatus] = useState(form.status); const [config, setConfig] = useState({ ...(form.config ?? {}) }); const [draft, setDraft] = useState( (form.questions ?? []).map((q) => ({ key: keyFor(q), type: q.type, label: q.label, description: q.description, required: q.required, propertyId: q.propertyId ?? null, validation: q.validation ?? null, placeholder: q.placeholder })) ); const [editKey, setEditKey] = useState(null); const [previewAnswers, setPreviewAnswers] = useState>({}); const editing = draft.find((q) => q.key === editKey) ?? null; const busy = updateForm.isPending; const unmappedProps = properties.filter( (p) => !draft.some((q) => q.propertyId === p.id) && p.type !== "button" ); const addQuestion = (propertyId?: string) => { const prop = properties.find((p) => p.id === propertyId); let input: FormQuestionInput = { type: defaultTypeFor(prop?.type), label: prop?.name ?? "New question", required: false, propertyId: propertyId ?? null, placeholder: "", validation: null }; if (input.type === "SINGLE_SELECT" || input.type === "MULTI_SELECT") { input = setSelectOptions(input, optionsForProperty(prop)); } const q: DraftQuestion = { ...input, key: `dq_${Math.random().toString(36).slice(2, 10)}` }; setDraft((d) => [...d, q]); setEditKey(q.key); }; const removeQuestion = (key: string) => { setDraft((d) => d.filter((q) => q.key !== key)); if (editKey === key) setEditKey(null); }; const move = (key: string, dir: -1 | 1) => { setDraft((d) => { const idx = d.findIndex((q) => q.key === key); const j = idx + dir; if (idx < 0 || j < 0 || j >= d.length) return d; const next = [...d]; [next[idx], next[j]] = [next[j], next[idx]]; return next; }); }; const saveEdit = (input: FormQuestionInput) => { setDraft((d) => d.map((q) => (q.key === editKey ? { ...q, ...input } : q))); setEditKey(null); }; const saveForm = () => { const cfg: FormConfig = { ...config, intro: config.intro || undefined, successMessage: config.successMessage || undefined, closedMessage: config.closedMessage || undefined }; updateForm.mutate( { formId: form.id, slug, status, config: cfg, questions: draft.map(toInput) }, { onSuccess: () => notify({ title: "Form saved" }), onError: (e) => notify({ title: "Save failed", description: (e as Error).message, variant: "destructive" }) } ); }; return (
{/* left: settings + questions */}
{/* settings */}

Form settings

/f/ setSlug(e.target.value)} />