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>
591 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|