ubiquitous-invention/apps/web/components/templates/template-editor.tsx

385 lines
12 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import * as React from "react";
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Trash2 } from "lucide-react";
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@/server/root";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import type { TemplateSchemaJson } from "./template-picker";
type TemplateRow = inferRouterOutputs<AppRouter>["templates"]["getById"];
const TARGET_TYPES = ["task", "document", "project"] as const;
const FIELD_TYPES = [
"text",
"textarea",
"number",
"date",
"select",
"checkbox",
"url",
"email",
] as const;
type PropertyRow = {
id: string;
name: string;
fieldType: string;
defaultValue: string;
};
function newPropertyRow(): PropertyRow {
return {
id:
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `p-${Date.now()}-${Math.random().toString(16).slice(2)}`,
name: "",
fieldType: "text",
defaultValue: "",
};
}
function schemaToRows(schema: TemplateSchemaJson | null | undefined): PropertyRow[] {
const props = schema?.properties ?? [];
return props.map((p, i) => ({
id:
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `p-${i}`,
name: p.name,
fieldType: p.fieldType,
defaultValue:
p.defaultValue === undefined || p.defaultValue === null
? ""
: typeof p.defaultValue === "string"
? p.defaultValue
: JSON.stringify(p.defaultValue),
}));
}
function rowsToSchema(
rows: PropertyRow[],
defaultContent: string,
): TemplateSchemaJson {
return {
properties: rows
.filter((r) => r.name.trim() !== "")
.map((r) => {
let defaultValue: unknown = r.defaultValue;
if (r.fieldType === "number" && r.defaultValue.trim() !== "") {
const n = Number(r.defaultValue);
defaultValue = Number.isFinite(n) ? n : r.defaultValue;
} else if (r.fieldType === "checkbox") {
defaultValue = r.defaultValue === "true" || r.defaultValue === "1";
} else if (r.defaultValue.trim() === "") {
defaultValue = undefined;
}
return {
name: r.name.trim(),
fieldType: r.fieldType,
...(defaultValue !== undefined ? { defaultValue } : {}),
};
}),
...(defaultContent.trim() !== "" ? { defaultContent } : {}),
};
}
function SortablePropertyRow({
row,
onChange,
onRemove,
}: {
row: PropertyRow;
onChange: (id: string, patch: Partial<PropertyRow>) => void;
onRemove: (id: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: row.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex flex-col gap-2 rounded-md border bg-card p-3 sm:flex-row sm:items-end",
isDragging && "z-10 opacity-90 shadow-md",
)}
>
<button
type="button"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-dashed text-muted-foreground hover:bg-muted"
{...attributes}
{...listeners}
aria-label="Reorder property"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="grid min-w-0 flex-1 gap-2 sm:grid-cols-3">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Name</label>
<Input
value={row.name}
onChange={(e) => onChange(row.id, { name: e.target.value })}
placeholder="Property name"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Field type</label>
<select
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",
)}
value={row.fieldType}
onChange={(e) => onChange(row.id, { fieldType: e.target.value })}
>
{FIELD_TYPES.map((ft) => (
<option key={ft} value={ft}>
{ft}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Default value</label>
<Input
value={row.defaultValue}
onChange={(e) => onChange(row.id, { defaultValue: e.target.value })}
placeholder="Optional"
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-destructive hover:text-destructive"
onClick={() => onRemove(row.id)}
aria-label="Remove property"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}
export type TemplateEditorProps = {
template?: TemplateRow;
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-07 00:02:55 -04:00
workspaceHandle: string;
onSave: () => void;
};
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-07 00:02:55 -04:00
export function TemplateEditor({ template, workspaceHandle, onSave }: TemplateEditorProps) {
const [name, setName] = React.useState(template?.name ?? "");
const [targetType, setTargetType] = React.useState(
template?.targetType && TARGET_TYPES.includes(template.targetType as (typeof TARGET_TYPES)[number])
? template.targetType
: "task",
);
const [defaultContent, setDefaultContent] = React.useState(
(template?.schema as TemplateSchemaJson | null | undefined)?.defaultContent ?? "",
);
const [rows, setRows] = React.useState<PropertyRow[]>(() =>
template?.schema ? schemaToRows(template.schema as TemplateSchemaJson) : [newPropertyRow()],
);
React.useEffect(() => {
if (!template) return;
setName(template.name);
setTargetType(
TARGET_TYPES.includes(template.targetType as (typeof TARGET_TYPES)[number])
? template.targetType
: "task",
);
const sch = template.schema as TemplateSchemaJson | null | undefined;
setDefaultContent(sch?.defaultContent ?? "");
setRows(schemaToRows(sch));
}, [template]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const createMut = api.templates.create.useMutation({ onSuccess: onSave });
const updateMut = api.templates.update.useMutation({ onSuccess: onSave });
const pending = createMut.isPending || updateMut.isPending;
const updateRow = React.useCallback((id: string, patch: Partial<PropertyRow>) => {
setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}, []);
const removeRow = React.useCallback((id: string) => {
setRows((prev) => (prev.length <= 1 ? prev : prev.filter((r) => r.id !== id)));
}, []);
const onDragEnd = React.useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setRows((items) => {
const oldIndex = items.findIndex((i) => i.id === active.id);
const newIndex = items.findIndex((i) => i.id === over.id);
if (oldIndex < 0 || newIndex < 0) return items;
return arrayMove(items, oldIndex, newIndex);
});
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const schema = rowsToSchema(rows, defaultContent);
if (!name.trim()) return;
if (template?.id) {
updateMut.mutate({
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-07 00:02:55 -04:00
workspace: workspaceHandle,
id: template.id,
name: name.trim(),
schema,
});
} else {
createMut.mutate({
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-07 00:02:55 -04:00
workspace: workspaceHandle,
name: name.trim(),
targetType,
schema,
});
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium">Name</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Template name" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Target type</label>
<select
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
)}
value={targetType}
onChange={(e) => setTargetType(e.target.value)}
disabled={Boolean(template?.id)}
>
{TARGET_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
{template?.id ? (
<p className="text-xs text-muted-foreground">Target type cannot be changed after creation.</p>
) : null}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Properties</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setRows((r) => [...r, newPropertyRow()])}
>
Add property
</Button>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={rows.map((r) => r.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{rows.map((row) => (
<SortablePropertyRow
key={row.id}
row={row}
onChange={updateRow}
onRemove={removeRow}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
<Separator />
<div className="space-y-2">
<label className="text-sm font-medium">Default content</label>
<textarea
className={cn(
"flex min-h-[120px] 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",
)}
value={defaultContent}
onChange={(e) => setDefaultContent(e.target.value)}
placeholder="Initial body text or outline for new objects using this template"
/>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={pending}
onClick={() => {
if (template) {
setName(template.name);
setTargetType(
TARGET_TYPES.includes(
template.targetType as (typeof TARGET_TYPES)[number],
)
? template.targetType
: "task",
);
const sch = template.schema as TemplateSchemaJson | null | undefined;
setDefaultContent(sch?.defaultContent ?? "");
setRows(schemaToRows(sch));
} else {
setName("");
setTargetType("task");
setDefaultContent("");
setRows([newPropertyRow()]);
}
}}
>
Cancel
</Button>
<Button type="submit" disabled={pending || !name.trim()}>
Save
</Button>
</div>
</form>
);
}