"use client"; import { useEffect, useRef, useState } from "react"; import { DndContext, type DragEndEvent, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { api } from "@/lib/trpc"; import { FormFieldCard } from "./form-field-card"; import { FormFieldConfig } from "./form-field-config"; import { FormFieldTypePicker } from "./form-field-type-picker"; type FormField = { id: string; label: string; type: string; required: boolean; placeholder?: string; helpText?: string; options?: { label: string; value: string }[]; defaultValue?: unknown; mappedProperty: string | null; validation?: { min?: number; max?: number; pattern?: string; maxLength?: number; }; conditionals?: { fieldId: string; operator: string; value: unknown; action: string; }[]; }; type Draft = { title: string; description: string | null; isPublished: boolean; fields: FormField[]; }; function normalizeFields(raw: unknown): FormField[] { if (!Array.isArray(raw)) return []; return raw.map((item, i) => { const o = item as Record; return { id: typeof o.id === "string" ? o.id : `field_${i}`, label: typeof o.label === "string" ? o.label : "Untitled", type: typeof o.type === "string" ? o.type : "short_text", required: Boolean(o.required), placeholder: typeof o.placeholder === "string" ? o.placeholder : undefined, helpText: typeof o.helpText === "string" ? o.helpText : undefined, options: Array.isArray(o.options) ? (o.options as FormField["options"]) : undefined, defaultValue: o.defaultValue, mappedProperty: o.mappedProperty === null || typeof o.mappedProperty === "string" ? (o.mappedProperty as string | null) : null, validation: o.validation && typeof o.validation === "object" ? (o.validation as FormField["validation"]) : undefined, conditionals: Array.isArray(o.conditionals) ? (o.conditionals as FormField["conditionals"]) : undefined, }; }); } function createField(type: string): FormField { const id = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `fld_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; const field: FormField = { id, label: "Untitled", type, required: false, mappedProperty: null, }; if (type === "select" || type === "multi_select" || type === "radio") { field.options = [ { label: "Option 1", value: "option_1" }, { label: "Option 2", value: "option_2" }, ]; } return field; } export function FormBuilder({ formId, workspaceHandle, }: { formId: string; workspaceHandle: string; }) { const utils = api.useUtils(); const [draft, setDraft] = useState(null); const [selectedFieldId, setSelectedFieldId] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const hydratedRef = useRef(false); const skipSaveRef = useRef(false); const formQuery = api.forms.getById.useQuery( { workspace: workspaceHandle, id: formId }, { enabled: Boolean(formId) && Boolean(workspaceHandle) }, ); const updateMutation = api.forms.update.useMutation({ onSuccess: () => { void utils.forms.getById.invalidate({ workspace: workspaceHandle, id: formId }); }, }); useEffect(() => { hydratedRef.current = false; setDraft(null); setSelectedFieldId(null); }, [formId, workspaceHandle]); useEffect(() => { if ( !formQuery.isSuccess || !formQuery.data || formQuery.data.id !== formId ) { return; } if (hydratedRef.current) return; const row = formQuery.data; setDraft({ title: row.title, description: row.description ?? null, isPublished: row.isPublished, fields: normalizeFields(row.fields), }); hydratedRef.current = true; skipSaveRef.current = true; }, [formQuery.isSuccess, formQuery.data, formId]); useEffect(() => { if (!draft || !hydratedRef.current) return; if (skipSaveRef.current) { skipSaveRef.current = false; return; } const t = setTimeout(() => { updateMutation.mutate({ workspace: workspaceHandle, id: formId, title: draft.title, description: draft.description, fields: draft.fields, isPublished: draft.isPublished, }); }, 550); return () => clearTimeout(t); }, [draft, formId, workspaceHandle, updateMutation]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const onDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id || !draft) return; const oldIndex = draft.fields.findIndex((f) => f.id === active.id); const newIndex = draft.fields.findIndex((f) => f.id === over.id); if (oldIndex < 0 || newIndex < 0) return; setDraft({ ...draft, fields: arrayMove(draft.fields, oldIndex, newIndex), }); }; const updateField = (id: string, patch: Partial) => { setDraft((d) => { if (!d) return d; return { ...d, fields: d.fields.map((f) => (f.id === id ? { ...f, ...patch } : f)), }; }); }; const deleteField = (id: string) => { setDraft((d) => { if (!d) return d; return { ...d, fields: d.fields.filter((f) => f.id !== id), }; }); setSelectedFieldId((cur) => (cur === id ? null : cur)); }; const selectedField = draft?.fields.find((f) => f.id === selectedFieldId); if (formQuery.isLoading || draft === null) { return (
Loading form…
); } if (formQuery.isError) { return (
Could not load this form.
); } return (
setDraft((d) => (d ? { ...d, title: e.target.value } : d)) } className="text-2xl font-semibold tracking-tight" placeholder="Form title" />