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

448 lines
13 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import * as React from "react";
import { useParams } from "next/navigation";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronDown,
ChevronRight,
ListTodo,
Plus,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
TooltipProvider,
} from "@/components/ui/tooltip";
import type { ViewConfig, ViewObject, ViewSort } 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 {
getDueDateValue,
getPriorityValue,
ListItem,
LIST_ROW_GRID,
} from "./list-item";
type SortField = "status" | "title" | "assignees" | "priority" | "dueDate";
function groupByField(
objects: ViewObject[],
groupBy: string,
): Record<string, ViewObject[]> {
const groups: Record<string, ViewObject[]> = {};
for (const obj of objects) {
const key = String(
(obj as unknown as Record<string, unknown>)[groupBy] ?? "No Value",
);
if (!groups[key]) groups[key] = [];
groups[key].push(obj);
}
return groups;
}
function rankPriority(p: string): number {
const order = ["High", "Medium", "Low"];
const i = order.indexOf(p);
return i === -1 ? 999 : i;
}
function firstAssigneeName(o: ViewObject): string {
return o.assignees?.[0]?.user?.name ?? "";
}
function compareDue(a: string, b: string): number {
if (a === "—" && b === "—") return 0;
if (a === "—") return 1;
if (b === "—") return -1;
const ta = new Date(a).getTime();
const tb = new Date(b).getTime();
if (Number.isNaN(ta) || Number.isNaN(tb)) return a.localeCompare(b);
return ta - tb;
}
function sortItemsByField(items: ViewObject[], sort: ViewSort): ViewObject[] {
const dir = sort.direction === "asc" ? 1 : -1;
const copy = [...items];
copy.sort((a, b) => {
let cmp = 0;
switch (sort.field) {
case "priority": {
const pa = getPriorityValue(a);
const pb = getPriorityValue(b);
cmp = rankPriority(pa) - rankPriority(pb);
if (cmp === 0) cmp = pa.localeCompare(pb);
break;
}
case "dueDate":
cmp = compareDue(getDueDateValue(a), getDueDateValue(b));
break;
case "assignees":
cmp = firstAssigneeName(a).localeCompare(firstAssigneeName(b));
break;
case "title":
cmp = a.title.localeCompare(b.title);
break;
case "status":
cmp = String(a.status ?? "").localeCompare(String(b.status ?? ""));
break;
default:
cmp = 0;
}
return cmp * dir;
});
return copy;
}
function refineSort(items: ViewObject[], sorts: ViewSort[]): ViewObject[] {
if (sorts.length === 0) return items;
const s = sorts[0];
if (s.field === "title" || s.field === "status") {
return items;
}
return sortItemsByField(items, s);
}
function nextSort(field: SortField, sorts: ViewSort[]): ViewSort[] {
const first = sorts[0];
if (first?.field === field) {
return [{ field, direction: first.direction === "asc" ? "desc" : "asc" }];
}
return [{ field, direction: "asc" }];
}
function SortHeaderButton({
label,
field,
sorts,
onSort,
}: {
label: string;
field: SortField;
sorts: ViewSort[];
onSort: (field: SortField) => void;
}) {
const active = sorts[0]?.field === field;
const dir = active ? sorts[0].direction : null;
return (
<button
type="button"
onClick={() => onSort(field)}
className={cn(
"inline-flex items-center gap-1 truncate text-left text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground",
active && "text-foreground",
)}
>
<span className="truncate">{label}</span>
{active ? (
dir === "asc" ? (
<ArrowUp className="h-3 w-3 shrink-0 opacity-80" />
) : (
<ArrowDown className="h-3 w-3 shrink-0 opacity-80" />
)
) : (
<ArrowUpDown className="h-3 w-3 shrink-0 opacity-40" />
)}
</button>
);
}
function ListSkeleton() {
return (
<div className="flex flex-col">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className={cn(
LIST_ROW_GRID,
"h-9 border-b border-border/40",
i % 2 === 1 && "bg-muted/15",
)}
>
<div className="flex justify-center">
<div className="h-3.5 w-3.5 animate-pulse rounded bg-muted" />
</div>
<div className="flex justify-center">
<div className="h-2.5 w-2.5 animate-pulse rounded-full bg-muted" />
</div>
<div className="h-3 animate-pulse rounded bg-muted/80" />
<div className="flex gap-1">
<div className="h-6 w-6 animate-pulse rounded-full bg-muted" />
</div>
<div className="h-5 w-14 animate-pulse rounded-full bg-muted" />
<div className="h-3 w-16 animate-pulse rounded bg-muted/80" />
<div />
</div>
))}
</div>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center gap-4 px-6 py-20 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-dashed border-border bg-muted/30">
<ListTodo className="h-10 w-10 text-muted-foreground/70" strokeWidth={1.25} />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">No items yet</p>
<p className="max-w-sm text-xs text-muted-foreground">
Create a task or adjust filters to see work here.
</p>
</div>
</div>
);
}
export interface ListViewProps {
config: ViewConfig;
}
export function ListView({ config }: ListViewProps) {
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 [sorts, setSorts] = React.useState<ViewSort[]>(config.sorts);
React.useEffect(() => {
setSorts(config.sorts);
}, [config.sorts]);
const effectiveConfig = React.useMemo(
() => ({ ...config, sorts }),
[config, sorts],
);
const { items, 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 displayItems = React.useMemo(
() => refineSort(items, sorts),
[items, sorts],
);
const displayGrouped = React.useMemo(() => {
if (!config.groupBy) return null;
return groupByField(displayItems, config.groupBy);
}, [displayItems, config.groupBy]);
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const toggleSelect = React.useCallback((id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const [collapsed, setCollapsed] = React.useState<Record<string, boolean>>({});
const toggleGroup = React.useCallback((key: string) => {
setCollapsed((c) => ({ ...c, [key]: !c[key] }));
}, []);
const [isCreating, setIsCreating] = React.useState(false);
const [newTitle, setNewTitle] = React.useState("");
const utils = api.useUtils();
const createObject = api.objects.create.useMutation({
onSuccess: () => {
utils.objects.list.invalidate();
setNewTitle("");
setIsCreating(false);
},
});
const onHeaderSort = React.useCallback((field: SortField) => {
setSorts((s) => nextSort(field, s));
}, []);
const isGrouped = Boolean(config.groupBy);
const headerRow = (
<div
className={cn(
LIST_ROW_GRID,
"sticky top-0 z-10 h-9 shrink-0 border-b border-border bg-background/95 py-1 backdrop-blur supports-[backdrop-filter]:bg-background/80",
)}
>
<div />
<SortHeaderButton
label="Status"
field="status"
sorts={sorts}
onSort={onHeaderSort}
/>
<SortHeaderButton
label="Title"
field="title"
sorts={sorts}
onSort={onHeaderSort}
/>
<SortHeaderButton
label="Assignees"
field="assignees"
sorts={sorts}
onSort={onHeaderSort}
/>
<SortHeaderButton
label="Priority"
field="priority"
sorts={sorts}
onSort={onHeaderSort}
/>
<SortHeaderButton
label="Due date"
field="dueDate"
sorts={sorts}
onSort={onHeaderSort}
/>
<div />
</div>
);
let body: React.ReactNode = null;
if (isLoading) {
body = <ListSkeleton />;
} else if (total === 0) {
body = <EmptyState />;
} else if (isGrouped && displayGrouped) {
const keys = Object.keys(displayGrouped).sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: "base" }),
);
body = (
<div className="flex flex-col">
{keys.map((groupKey, gi) => {
const groupItems = displayGrouped[groupKey] ?? [];
const expanded = !collapsed[groupKey];
return (
<div key={groupKey} className="border-b border-border/50">
<button
type="button"
onClick={() => toggleGroup(groupKey)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-accent/40"
>
{expanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="text-sm font-semibold text-foreground">
{groupKey}
</span>
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-semibold">
{groupItems.length}
</Badge>
</button>
{expanded && (
<div>
{groupItems.map((obj, i) => (
<ListItem
key={obj.id}
object={obj}
onSelect={toggleSelect}
selected={selected.has(obj.id)}
rowIndex={i + gi}
/>
))}
</div>
)}
</div>
);
})}
</div>
);
} else {
body = (
<div className="flex flex-col">
{displayItems.map((obj, i) => (
<ListItem
key={obj.id}
object={obj}
onSelect={toggleSelect}
selected={selected.has(obj.id)}
rowIndex={i}
/>
))}
</div>
);
}
return (
<TooltipProvider delayDuration={300}>
<div className="flex h-full min-h-0 flex-col rounded-md border border-border bg-card text-card-foreground shadow-sm">
{headerRow}
<Separator />
<ScrollArea className="min-h-0 flex-1">
<div className="pb-2">
{body}
{!isLoading && (
<>
{isCreating ? (
<div className="flex items-center gap-2 border-t border-border px-4 py-2">
<input
autoFocus
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="Task title…"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
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 (e.key === "Enter" && newTitle.trim() && workspaceHandle) {
e.preventDefault();
createObject.mutate({
type: "task",
title: 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
workspace: workspaceHandle,
parentId: parentId ?? undefined,
});
}
if (e.key === "Escape") {
setIsCreating(false);
setNewTitle("");
}
}}
onBlur={() => {
if (createObject.isPending) return;
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 (newTitle.trim() && workspaceHandle) {
createObject.mutate({
type: "task",
title: 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
workspace: workspaceHandle,
parentId: parentId ?? undefined,
});
} else {
setIsCreating(false);
}
}}
/>
</div>
) : (
<button
type="button"
className="flex w-full items-center gap-2 border-t border-border px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={() => setIsCreating(true)}
>
<Plus className="size-4 shrink-0" />
Add task
</button>
)}
</>
)}
</div>
</ScrollArea>
</div>
</TooltipProvider>
);
}