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

407 lines
12 KiB
TypeScript

"use client";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { FormMappingPicker } from "./form-mapping-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;
}[];
};
const OPERATORS = ["eq", "neq", "contains", "isEmpty"] as const;
const ACTIONS = ["show", "hide"] as const;
const CHOICE_TYPES = new Set([
"select",
"multi_select",
"radio",
]);
export function FormFieldConfig({
field,
allFields,
workspaceHandle,
onChange,
}: {
field: FormField;
allFields: FormField[];
workspaceHandle: string;
onChange: (patch: Partial<FormField>) => void;
}) {
const otherFields = allFields.filter((f) => f.id !== field.id);
const v = field.validation ?? {};
const conditionals = field.conditionals ?? [];
const updateOption = (
index: number,
patch: Partial<{ label: string; value: string }>,
) => {
const options = [...(field.options ?? [])];
const current = options[index] ?? { label: "", value: "" };
options[index] = { ...current, ...patch };
onChange({ options });
};
const addOption = () => {
const n = (field.options?.length ?? 0) + 1;
onChange({
options: [
...(field.options ?? []),
{ label: `Option ${n}`, value: `option_${n}` },
],
});
};
const removeOption = (index: number) => {
const options = [...(field.options ?? [])];
options.splice(index, 1);
onChange({ options: options.length ? options : undefined });
};
return (
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 shadow-sm">
<div>
<h3 className="text-sm font-semibold text-foreground">Field settings</h3>
<p className="text-xs text-muted-foreground">
{field.label} · {field.type.replaceAll("_", " ")}
</p>
</div>
<Separator />
<div className="space-y-2">
<label className="text-xs font-medium text-foreground" htmlFor="ff-label">
Label
</label>
<Input
id="ff-label"
value={field.label}
onChange={(e) => onChange({ label: e.target.value })}
/>
</div>
<div className="space-y-2">
<label
className="text-xs font-medium text-foreground"
htmlFor="ff-placeholder"
>
Placeholder
</label>
<Input
id="ff-placeholder"
value={field.placeholder ?? ""}
placeholder="Optional"
onChange={(e) =>
onChange({
placeholder: e.target.value || undefined,
})
}
/>
</div>
<div className="space-y-2">
<label
className="text-xs font-medium text-foreground"
htmlFor="ff-help"
>
Help text
</label>
<textarea
id="ff-help"
value={field.helpText ?? ""}
placeholder="Shown below the field"
rows={3}
onChange={(e) =>
onChange({
helpText: e.target.value || undefined,
})
}
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-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) => onChange({ required: e.target.checked })}
className="size-4 rounded border-input accent-primary"
/>
Required
</label>
<Separator />
<div className="space-y-2">
<span className="text-xs font-medium text-foreground">Validation</span>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
placeholder="Min"
value={v.min ?? ""}
onChange={(e) =>
onChange({
validation: {
...v,
min:
e.target.value === ""
? undefined
: Number(e.target.value),
},
})
}
/>
<Input
type="number"
placeholder="Max"
value={v.max ?? ""}
onChange={(e) =>
onChange({
validation: {
...v,
max:
e.target.value === ""
? undefined
: Number(e.target.value),
},
})
}
/>
</div>
<Input
placeholder="Pattern (regex)"
value={v.pattern ?? ""}
onChange={(e) =>
onChange({
validation: {
...v,
pattern: e.target.value || undefined,
},
})
}
/>
<Input
type="number"
placeholder="Max length"
value={v.maxLength ?? ""}
onChange={(e) =>
onChange({
validation: {
...v,
maxLength:
e.target.value === ""
? undefined
: Number(e.target.value),
},
})
}
/>
</div>
{CHOICE_TYPES.has(field.type) ? (
<>
<Separator />
<div className="space-y-2">
<span className="text-xs font-medium text-foreground">Options</span>
<div className="flex flex-col gap-2">
{(field.options ?? []).map((opt, i) => (
<div key={i} className="flex gap-2">
<Input
placeholder="Label"
value={opt.label}
onChange={(e) => updateOption(i, { label: e.target.value })}
/>
<Input
placeholder="Value"
value={opt.value}
onChange={(e) => updateOption(i, { value: e.target.value })}
/>
<Button
type="button"
size="icon"
variant="ghost"
className="shrink-0"
onClick={() => removeOption(i)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-full gap-1"
onClick={addOption}
>
<Plus className="size-4" />
Add option
</Button>
</div>
</>
) : null}
<Separator />
<div className="space-y-2">
<span className="text-xs font-medium text-foreground">
Map to task property
</span>
<FormMappingPicker
workspaceHandle={workspaceHandle}
value={field.mappedProperty}
onChange={(next) => onChange({ mappedProperty: next })}
/>
</div>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-foreground">
Conditional rules
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() =>
onChange({
conditionals: [
...conditionals,
{
fieldId: otherFields[0]?.id ?? "",
operator: "eq",
value: "",
action: "show",
},
],
})
}
>
<Plus className="size-3.5" />
Add rule
</Button>
</div>
{conditionals.length === 0 ? (
<p className="text-xs text-muted-foreground">
No rules. Show or hide this field based on another field&apos;s value.
</p>
) : (
<ul className="flex flex-col gap-3">
{conditionals.map((rule, index) => (
<li
key={index}
className="space-y-2 rounded-md border border-border p-2"
>
<div className="flex justify-end">
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
onClick={() => {
const next = conditionals.filter((_, i) => i !== index);
onChange({
conditionals: next.length ? next : undefined,
});
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
value={rule.fieldId}
onChange={(e) => {
const next = [...conditionals];
next[index] = { ...rule, fieldId: e.target.value };
onChange({ conditionals: next });
}}
>
<option value="">Select field</option>
{otherFields.map((f) => (
<option key={f.id} value={f.id}>
{f.label}
</option>
))}
</select>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
value={rule.operator}
onChange={(e) => {
const next = [...conditionals];
next[index] = { ...rule, operator: e.target.value };
onChange({ conditionals: next });
}}
>
{OPERATORS.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
<Input
placeholder="Value"
value={
rule.value === undefined || rule.value === null
? ""
: String(rule.value)
}
disabled={rule.operator === "isEmpty"}
onChange={(e) => {
const next = [...conditionals];
next[index] = { ...rule, value: e.target.value };
onChange({ conditionals: next });
}}
/>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
value={rule.action}
onChange={(e) => {
const next = [...conditionals];
next[index] = { ...rule, action: e.target.value };
onChange({ conditionals: next });
}}
>
{ACTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</li>
))}
</ul>
)}
</div>
</div>
);
}