ubiquitous-invention/apps/web/components/forms/form-builder.tsx
Randall Stillwell c582d621ce multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:

- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
  property_definitions, templates, forms, markdown_backlog_items,
  cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
  with a hand-augmented data-copy migration that preserves IDs and slug-collision-
  proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
  that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
  tenant-scoped routers (objects, types, properties, templates, forms, search,
  ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
  of the old `workspaceId`, including a workspace-sync layer that rewrites
  /<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
  workspace://{handle}/tree resource to accept either a slug or UUID so existing
  agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
  rename with redirect, owner-only archive).

Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 23:02:55 -05:00

379 lines
11 KiB
TypeScript

"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<string, unknown>;
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<Draft | null>(null);
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(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<FormField>) => {
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 (
<div className="rounded-lg border border-dashed border-border bg-muted/20 p-12 text-center text-sm text-muted-foreground">
Loading form
</div>
);
}
if (formQuery.isError) {
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-sm text-destructive">
Could not load this form.
</div>
);
}
return (
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
<div className="min-w-0 flex-1 space-y-6">
<div className="space-y-3">
<Input
value={draft.title}
onChange={(e) =>
setDraft((d) => (d ? { ...d, title: e.target.value } : d))
}
className="text-2xl font-semibold tracking-tight"
placeholder="Form title"
/>
<textarea
value={draft.description ?? ""}
onChange={(e) =>
setDraft((d) =>
d
? {
...d,
description: e.target.value || null,
}
: d,
)
}
placeholder="Description (optional)"
rows={3}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</div>
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 text-sm font-medium shadow-sm">
<input
type="checkbox"
checked={draft.isPublished}
onChange={(e) =>
setDraft((d) =>
d ? { ...d, isPublished: e.target.checked } : d,
)
}
className="size-4 rounded border-input accent-primary"
/>
Published
<span className="text-xs font-normal text-muted-foreground">
When on, the form can accept responses (when wired).
</span>
</label>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-foreground">Fields</h2>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setPickerOpen((o) => !o)}
>
{pickerOpen ? "Close picker" : "Add field"}
</Button>
</div>
{pickerOpen ? (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<p className="mb-2 text-xs text-muted-foreground">
Choose a field type
</p>
<FormFieldTypePicker
onSelect={(type) => {
const next = createField(type);
setDraft((d) =>
d ? { ...d, fields: [...d.fields, next] } : d,
);
setSelectedFieldId(next.id);
setPickerOpen(false);
}}
/>
</div>
) : null}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<SortableContext
items={draft.fields.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{draft.fields.length === 0 ? (
<p className="rounded-lg border border-dashed border-border py-10 text-center text-sm text-muted-foreground">
No fields yet. Add a field to start building.
</p>
) : (
<ul className="flex flex-col gap-2">
{draft.fields.map((field) => (
<li key={field.id}>
<FormFieldCard
field={field}
isSelected={field.id === selectedFieldId}
onSelect={() => setSelectedFieldId(field.id)}
onChange={(patch) => updateField(field.id, patch)}
onDelete={() => deleteField(field.id)}
/>
</li>
))}
</ul>
)}
</SortableContext>
</DndContext>
</div>
{updateMutation.isError ? (
<p className="text-xs text-destructive">
Failed to save changes. Try again.
</p>
) : null}
</div>
<aside className="w-full shrink-0 lg:sticky lg:top-6 lg:w-96">
{selectedField ? (
<FormFieldConfig
key={selectedField.id}
field={selectedField}
allFields={draft.fields}
workspaceHandle={workspaceHandle}
onChange={(patch) => updateField(selectedField.id, patch)}
/>
) : (
<div className="rounded-lg border border-dashed border-border bg-muted/10 p-6 text-center text-sm text-muted-foreground">
Select a field to edit its settings, validation, mapping, and
conditional rules.
</div>
)}
</aside>
</div>
);
}