ubiquitous-invention/apps/web/components/forms/form-renderer.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

591 lines
18 KiB
TypeScript

"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<string, unknown>,
): 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<Record<string, unknown>>({});
const [errors, setErrors] = React.useState<Record<string, string>>({});
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<string, string> = {};
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<string, unknown> = {};
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 (
<div className={cn("flex items-center justify-center py-16", className)}>
<Loader2 className="size-8 animate-spin text-muted-foreground" aria-hidden />
<span className="sr-only">Loading form</span>
</div>
);
}
if (formQuery.isError || !formQuery.data) {
return (
<p className={cn("text-sm text-muted-foreground", className)}>
Could not load this form.
</p>
);
}
if (submitted) {
return (
<div
className={cn(
"rounded-lg border border-border bg-muted/30 px-6 py-8 text-center",
className,
)}
role="status"
>
<p className="text-sm font-medium text-foreground">{confirmationMessage}</p>
<Badge variant="secondary" className="mt-4">
Submitted
</Badge>
</div>
);
}
const form = formQuery.data;
return (
<form onSubmit={handleSubmit} className={cn("space-y-6", className)}>
{form.description ? (
<p className="text-sm text-muted-foreground">{form.description}</p>
) : null}
{fields.map((field) => {
if (!isFieldVisible(field, values)) return null;
if (field.type === "section_header") {
return (
<h3
key={field.id}
className="border-b border-border pb-1 text-sm font-semibold tracking-tight"
>
{field.label}
</h3>
);
}
if (field.type === "divider") {
return <hr key={field.id} className="border-border" />;
}
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 (
<div key={field.id} className="space-y-1.5">
<label
htmlFor={field.type === "rating" ? undefined : field.id}
className="text-sm font-medium leading-none"
>
{field.label}
{field.required ? (
<span className="text-destructive" aria-hidden>
{" "}
*
</span>
) : null}
</label>
{field.helpText ? (
<p className="text-xs text-muted-foreground">{field.helpText}</p>
) : null}
{field.type === "short_text" || field.type === "text" ? (
<Input
id={field.id}
value={typeof v === "string" ? v : ""}
onChange={(e) => 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" ? (
<textarea
id={field.id}
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
placeholder={field.placeholder}
rows={4}
aria-invalid={!!err}
className={cn(
"flex min-h-[80px] 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",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
) : null}
{field.type === "number" ? (
<Input
id={field.id}
type="number"
value={
typeof v === "number"
? String(v)
: v === "" || v === undefined
? ""
: String(v ?? "")
}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") setField(field.id, "");
else setField(field.id, Number(raw));
}}
placeholder={field.placeholder}
min={field.validation?.min}
max={field.validation?.max}
aria-invalid={!!err}
/>
) : null}
{field.type === "email" ? (
<Input
id={field.id}
type="email"
autoComplete="email"
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
placeholder={field.placeholder ?? "you@example.com"}
aria-invalid={!!err}
/>
) : null}
{field.type === "url" ? (
<Input
id={field.id}
type="url"
inputMode="url"
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
placeholder={field.placeholder ?? "https://"}
aria-invalid={!!err}
/>
) : null}
{field.type === "date" ? (
<Input
id={field.id}
type="date"
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
aria-invalid={!!err}
/>
) : null}
{field.type === "datetime" || field.type === "datetime-local" ? (
<Input
id={field.id}
type="datetime-local"
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
aria-invalid={!!err}
/>
) : null}
{field.type === "select" ? (
<select
id={field.id}
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
aria-invalid={!!err}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<option value="">{field.placeholder ?? "Choose…"}</option>
{(field.options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : null}
{field.type === "multi_select" ? (
<div className="flex flex-col gap-2 rounded-md border border-input p-3">
{(field.options ?? []).map((opt) => {
const selected = Array.isArray(v) && v.includes(opt.value);
return (
<label key={opt.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={!!selected}
onChange={() => {
const cur = Array.isArray(v) ? [...v] : [];
if (selected) {
setField(
field.id,
cur.filter((x) => x !== opt.value),
);
} else {
setField(field.id, [...cur, opt.value]);
}
}}
/>
{opt.label}
</label>
);
})}
</div>
) : null}
{field.type === "checkbox" ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={v === true}
onChange={(e) => setField(field.id, e.target.checked)}
aria-invalid={!!err}
/>
{field.placeholder ?? "Yes"}
</label>
) : null}
{field.type === "radio" ? (
<div className="flex flex-col gap-2">
{(field.options ?? []).map((opt) => (
<label key={opt.value} className="flex items-center gap-2 text-sm">
<input
type="radio"
name={field.id}
value={opt.value}
checked={v === opt.value}
onChange={() => setField(field.id, opt.value)}
/>
{opt.label}
</label>
))}
</div>
) : null}
{field.type === "rating" ? (
<RatingStars
id={field.id}
min={field.validation?.min ?? 1}
max={field.validation?.max ?? 5}
value={typeof v === "number" ? v : field.validation?.min ?? 1}
onChange={(n) => setField(field.id, n)}
/>
) : null}
{field.type === "file_upload" ? (
<Input
id={field.id}
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
setField(field.id, file ? file.name : "");
}}
aria-invalid={!!err}
/>
) : null}
{!inputTypes.has(field.type) ? (
<p className="text-xs text-muted-foreground">
Unsupported field type: {field.type}
</p>
) : null}
{err ? (
<p id={`${field.id}-err`} className="text-xs text-destructive" role="alert">
{err}
</p>
) : null}
</div>
);
})}
{submitMutation.isError ? (
<p className="text-sm text-destructive" role="alert">
Something went wrong while submitting. Please try again.
</p>
) : null}
<Button type="submit" disabled={submitMutation.isPending}>
{submitMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin" aria-hidden />
Submitting
</>
) : (
"Submit"
)}
</Button>
</form>
);
}
function RatingStars({
id,
min,
max,
value,
onChange,
}: {
id: string;
min: number;
max: number;
value: number;
onChange: (n: number) => void;
}) {
const stars = React.useMemo(
() => Array.from({ length: max - min + 1 }, (_, i) => min + i),
[min, max],
);
return (
<div id={id} className="flex flex-wrap items-center gap-1" role="group">
{stars.map((n) => (
<button
key={n}
type="button"
onClick={() => onChange(n)}
className={cn(
"rounded p-0.5 text-2xl leading-none transition-colors",
n <= value ? "text-amber-500" : "text-muted-foreground/30 hover:text-muted-foreground/60",
)}
aria-label={`${n} stars`}
aria-pressed={n === value}
>
</button>
))}
</div>
);
}