ubiquitous-invention/apps/web/components/views/board/board-view.tsx

368 lines
11 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import * as React from "react";
import { useParams } from "next/navigation";
import {
DndContext,
DragOverlay,
PointerSensor,
closestCorners,
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import type { ViewConfig, ViewObject } from "@/lib/hooks/use-view-data";
import { useViewData } from "@/lib/hooks/use-view-data";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { BoardCardPreview } from "./board-card";
import { BoardColumn } from "./board-column";
const STATUS_ORDER = ["open", "in_progress", "done", "closed"] as const;
const COLUMN_THEME: Record<
string,
{ dot: string; borderTop: string; label: string }
> = {
open: {
dot: "bg-muted-foreground/55",
borderTop: "border-t-[3px] border-t-muted-foreground/45",
label: "Open",
},
in_progress: {
dot: "bg-blue-500",
borderTop: "border-t-[3px] border-t-blue-500",
label: "In progress",
},
done: {
dot: "bg-green-500",
borderTop: "border-t-[3px] border-t-green-500",
label: "Done",
},
closed: {
dot: "bg-slate-500",
borderTop: "border-t-[3px] border-t-slate-500",
label: "Closed",
},
};
function getColumnTheme(columnId: string) {
return (
COLUMN_THEME[columnId] ?? {
dot: "bg-muted-foreground/50",
borderTop: "border-t-[3px] border-t-muted-foreground/35",
label: columnId.replace(/_/g, " "),
}
);
}
function getColumnKeys(
groupBy: string | null,
grouped: Record<string, ViewObject[]>,
): string[] {
if (groupBy === "status" || groupBy === null) {
return [...STATUS_ORDER];
}
return Object.keys(grouped).sort();
}
function buildColumnsFromGrouped(
columnKeys: string[],
grouped: Record<string, ViewObject[]>,
): Record<string, ViewObject[]> {
const out: Record<string, ViewObject[]> = {};
for (const k of columnKeys) {
out[k] = grouped[k] ? [...grouped[k]] : [];
}
return out;
}
function findContainer(
id: string,
cols: Record<string, ViewObject[]>,
): string | undefined {
if (id in cols) return id;
for (const key of Object.keys(cols)) {
if (cols[key].some((o) => o.id === id)) return key;
}
return undefined;
}
function patchObjectForColumn(
object: ViewObject,
columnId: string,
groupField: string,
): ViewObject {
const field = groupField === "status" ? "status" : groupField;
if (field === "status") {
return { ...object, status: columnId };
}
return { ...object, [field]: columnId } as ViewObject;
}
export interface BoardViewProps {
config: ViewConfig;
className?: string;
}
export function BoardView({ config, className }: BoardViewProps) {
const params = useParams();
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
const workspaceHandle =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const parentId =
typeof params?.projectId === "string" ? params.projectId : undefined;
const effectiveConfig = React.useMemo(
() => ({
...config,
groupBy: config.groupBy ?? "status",
}),
[config],
);
const { grouped, isLoading, total } = useViewData(
effectiveConfig,
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,
parentId,
);
const groupField = effectiveConfig.groupBy ?? "status";
const columnKeys = React.useMemo(
() => getColumnKeys(effectiveConfig.groupBy, grouped),
[effectiveConfig.groupBy, grouped],
);
const initialColumns = React.useMemo(
() => buildColumnsFromGrouped(columnKeys, grouped),
[columnKeys, grouped],
);
const [columns, setColumns] =
React.useState<Record<string, ViewObject[]>>(initialColumns);
const [activeId, setActiveId] = React.useState<string | null>(null);
const [creatingColumnId, setCreatingColumnId] = React.useState<string | null>(null);
const [newTitle, setNewTitle] = React.useState("");
const utils = api.useUtils();
const createObject = api.objects.create.useMutation({
onSuccess: () => {
utils.objects.list.invalidate();
setNewTitle("");
setCreatingColumnId(null);
},
});
React.useEffect(() => {
setColumns(initialColumns);
}, [initialColumns]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
);
const activeObject = React.useMemo(() => {
if (!activeId) return null;
for (const list of Object.values(columns)) {
const found = list.find((o) => o.id === activeId);
if (found) return found;
}
return null;
}, [activeId, columns]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(String(event.active.id));
};
const handleDragCancel = () => {
setActiveId(null);
};
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeIdStr = String(active.id);
const overIdStr = String(over.id);
if (activeIdStr === overIdStr) return;
setColumns((prev) => {
const activeContainer = findContainer(activeIdStr, prev);
const overContainer = findContainer(overIdStr, prev);
if (!activeContainer || !overContainer) return prev;
if (activeContainer === overContainer) return prev;
const activeItems = [...prev[activeContainer]];
const overItems = [...prev[overContainer]];
const activeIndex = activeItems.findIndex((i) => i.id === activeIdStr);
if (activeIndex === -1) return prev;
let newIndex: number;
if (overIdStr in prev) {
newIndex = overItems.length;
} else {
const overItemIndex = overItems.findIndex((i) => i.id === overIdStr);
const isBelowOverItem =
over.rect &&
active.rect.current.translated &&
active.rect.current.translated.top > over.rect.top + over.rect.height;
const modifier = isBelowOverItem ? 1 : 0;
newIndex =
overItemIndex >= 0 ? overItemIndex + modifier : overItems.length;
}
const [removed] = activeItems.splice(activeIndex, 1);
const patched = patchObjectForColumn(removed, overContainer, groupField);
const nextOver = [...overItems];
nextOver.splice(newIndex, 0, patched);
return {
...prev,
[activeContainer]: activeItems,
[overContainer]: nextOver,
};
});
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const activeIdStr = String(active.id);
const overIdStr = String(over.id);
setColumns((prev) => {
const activeContainer = findContainer(activeIdStr, prev);
const overContainer = findContainer(overIdStr, prev);
if (!activeContainer || !overContainer) return prev;
if (activeContainer !== overContainer) {
const activeItems = [...prev[activeContainer]];
const overItems = [...prev[overContainer]];
const activeIndex = activeItems.findIndex((i) => i.id === activeIdStr);
if (activeIndex === -1) return prev;
const [removed] = activeItems.splice(activeIndex, 1);
const patched = patchObjectForColumn(removed, overContainer, groupField);
let newIndex = overItems.length;
if (!(overIdStr in prev)) {
const overItemIndex = overItems.findIndex((i) => i.id === overIdStr);
if (overItemIndex >= 0) newIndex = overItemIndex;
}
const nextOver = [...overItems];
nextOver.splice(newIndex, 0, patched);
return {
...prev,
[activeContainer]: activeItems,
[overContainer]: nextOver,
};
}
const list = [...prev[activeContainer]];
const oldIndex = list.findIndex((i) => i.id === activeIdStr);
if (oldIndex === -1) return prev;
if (overIdStr in prev) {
return prev;
}
const newIndex = list.findIndex((i) => i.id === overIdStr);
if (newIndex === -1 || oldIndex === newIndex) return prev;
return {
...prev,
[activeContainer]: arrayMove(list, oldIndex, newIndex),
};
});
};
if (isLoading) {
return (
<div className={cn("flex flex-1 items-center justify-center p-8", className)}>
<p className="text-sm text-muted-foreground">Loading board</p>
</div>
);
}
return (
<div className={cn("flex min-h-0 flex-1 flex-col gap-3", className)}>
<div className="shrink-0 px-1 text-xs text-muted-foreground">
{total} task{total === 1 ? "" : "s"}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-hidden pb-2">
<div className="flex h-full min-h-[min(420px,70vh)] gap-3 px-1 pb-1">
{columnKeys.map((columnId) => {
const theme = getColumnTheme(columnId);
return (
<BoardColumn
key={columnId}
columnId={columnId}
label={theme.label}
items={columns[columnId] ?? []}
dotClass={theme.dot}
borderTopClass={theme.borderTop}
inlineCreate={{
isCreating: creatingColumnId === columnId,
newTitle,
onNewTitleChange: setNewTitle,
onOpen: () => {
setCreatingColumnId(columnId);
setNewTitle("");
},
onSubmit: () => {
const t = newTitle.trim();
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
if (!t || !workspaceHandle || createObject.isPending) return;
createObject.mutate({
type: "task",
title: t,
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,
parentId: parentId ?? undefined,
...(groupField === "status" ? { status: columnId } : {}),
});
},
onCancel: () => {
setCreatingColumnId(null);
setNewTitle("");
},
isPending: createObject.isPending,
}}
/>
);
})}
</div>
</div>
<DragOverlay dropAnimation={null}>
{activeObject ? (
<div className="w-[min(100%,290px)] min-w-[260px] max-w-[300px] cursor-grabbing">
<BoardCardPreview object={activeObject} />
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
);
}