feat: add cell formatters and tables for boards, agents, and member invites

This commit is contained in:
Abhimanyu Saharan
2026-02-11 11:41:51 +05:30
parent c3490630a4
commit 18d958b3e3
21 changed files with 2618 additions and 1208 deletions

View File

@@ -0,0 +1,145 @@
import type React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { AgentRead, BoardRead } from "@/api/generated/model";
import { AgentsTable } from "./AgentsTable";
vi.mock("next/link", () => {
type LinkProps = React.PropsWithChildren<{
href: string | { pathname?: string };
}> &
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">;
return {
default: ({ href, children, ...props }: LinkProps) => (
<a href={typeof href === "string" ? href : "#"} {...props}>
{children}
</a>
),
};
});
const buildAgent = (overrides: Partial<AgentRead> = {}): AgentRead => ({
id: "agent-1",
name: "Ava",
gateway_id: "gateway-1",
board_id: "board-1",
status: "online",
openclaw_session_id: "session-1234",
last_seen_at: "2026-01-01T00:00:00Z",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...overrides,
});
const buildBoard = (overrides: Partial<BoardRead> = {}): BoardRead => ({
id: "board-1",
name: "Ops Board",
slug: "ops-board",
organization_id: "org-1",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...overrides,
});
describe("AgentsTable", () => {
it("renders linked board name and default row actions", () => {
const onDelete = vi.fn();
const agent = buildAgent();
const board = buildBoard();
render(
<AgentsTable agents={[agent]} boards={[board]} onDelete={onDelete} />,
);
expect(
screen.getByRole("link", { name: /Ava ID agent-1/i }),
).toHaveAttribute("href", "/agents/agent-1");
expect(screen.getByRole("link", { name: "Ops Board" })).toHaveAttribute(
"href",
"/boards/board-1",
);
expect(screen.getByRole("link", { name: "Edit" })).toHaveAttribute(
"href",
"/agents/agent-1/edit",
);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(onDelete).toHaveBeenCalledWith(agent);
});
it("hides row actions when showActions is false", () => {
render(
<AgentsTable
agents={[buildAgent()]}
boards={[buildBoard()]}
showActions={false}
/>,
);
expect(
screen.queryByRole("link", { name: "Edit" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Delete" }),
).not.toBeInTheDocument();
});
it("supports hiddenColumns and columnOrder", () => {
render(
<AgentsTable
agents={[buildAgent()]}
boards={[buildBoard()]}
showActions={false}
hiddenColumns={["status", "openclaw_session_id"]}
columnOrder={["updated_at", "name", "board_id", "last_seen_at"]}
/>,
);
expect(
screen.queryByRole("columnheader", { name: "Status" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("columnheader", { name: "Session" }),
).not.toBeInTheDocument();
const headers = screen
.getAllByRole("columnheader")
.map((header) => header.textContent?.replace(/[↑↓↕]/g, "").trim());
expect(headers.slice(0, 4)).toEqual([
"Updated",
"Agent",
"Board",
"Last seen",
]);
});
it("supports disableSorting and preserves input order", () => {
const zulu = buildAgent({ id: "agent-z", name: "Zulu" });
const alpha = buildAgent({ id: "agent-a", name: "Alpha" });
const { rerender } = render(
<AgentsTable
agents={[zulu, alpha]}
boards={[buildBoard()]}
showActions={false}
/>,
);
// Default behavior applies name sorting.
expect(screen.getAllByRole("row")[1]).toHaveTextContent("Alpha");
rerender(
<AgentsTable
agents={[zulu, alpha]}
boards={[buildBoard()]}
showActions={false}
disableSorting
/>,
);
// disableSorting keeps incoming data order.
expect(screen.getAllByRole("row")[1]).toHaveTextContent("Zulu");
});
});

View File

@@ -0,0 +1,204 @@
import { type ReactNode, useMemo, useState } from "react";
import {
type ColumnDef,
type OnChangeFn,
type SortingState,
type Updater,
type VisibilityState,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { type AgentRead, type BoardRead } from "@/api/generated/model";
import { DataTable } from "@/components/tables/DataTable";
import {
dateCell,
linkifyCell,
pillCell,
} from "@/components/tables/cell-formatters";
import { truncateText as truncate } from "@/lib/formatters";
type AgentsTableEmptyState = {
title: string;
description: string;
icon?: ReactNode;
actionHref?: string;
actionLabel?: string;
};
type AgentsTableProps = {
agents: AgentRead[];
boards?: BoardRead[];
isLoading?: boolean;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
showActions?: boolean;
hiddenColumns?: string[];
columnOrder?: string[];
disableSorting?: boolean;
stickyHeader?: boolean;
emptyMessage?: string;
emptyState?: AgentsTableEmptyState;
onDelete?: (agent: AgentRead) => void;
};
const DEFAULT_EMPTY_ICON = (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
export function AgentsTable({
agents,
boards = [],
isLoading = false,
sorting,
onSortingChange,
showActions = true,
hiddenColumns,
columnOrder,
disableSorting = false,
stickyHeader = false,
emptyMessage = "No agents found.",
emptyState,
onDelete,
}: AgentsTableProps) {
const [internalSorting, setInternalSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const resolvedSorting = sorting ?? internalSorting;
const handleSortingChange: OnChangeFn<SortingState> =
onSortingChange ??
((updater: Updater<SortingState>) => {
setInternalSorting(updater);
});
const sortedAgents = useMemo(() => [...agents], [agents]);
const columnVisibility = useMemo<VisibilityState>(
() =>
Object.fromEntries(
(hiddenColumns ?? []).map((columnId) => [columnId, false]),
),
[hiddenColumns],
);
const boardNameById = useMemo(
() => new Map(boards.map((board) => [board.id, board.name])),
[boards],
);
const columns = useMemo<ColumnDef<AgentRead>[]>(() => {
const baseColumns: ColumnDef<AgentRead>[] = [
{
accessorKey: "name",
header: "Agent",
cell: ({ row }) =>
linkifyCell({
href: `/agents/${row.original.id}`,
label: row.original.name,
subtitle: `ID ${row.original.id}`,
}),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => pillCell(row.original.status),
},
{
accessorKey: "openclaw_session_id",
header: "Session",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{truncate(row.original.openclaw_session_id)}
</span>
),
},
{
accessorKey: "board_id",
header: "Board",
cell: ({ row }) => {
const boardId = row.original.board_id;
if (!boardId) {
return <span className="text-sm text-slate-700"></span>;
}
const boardName = boardNameById.get(boardId) ?? boardId;
return linkifyCell({
href: `/boards/${boardId}`,
label: boardName,
block: false,
});
},
},
{
accessorKey: "last_seen_at",
header: "Last seen",
cell: ({ row }) =>
dateCell(row.original.last_seen_at, { relative: true }),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => dateCell(row.original.updated_at),
},
];
return baseColumns;
}, [boardNameById]);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: sortedAgents,
columns,
enableSorting: !disableSorting,
state: {
...(!disableSorting ? { sorting: resolvedSorting } : {}),
...(columnOrder ? { columnOrder } : {}),
columnVisibility,
},
...(disableSorting ? {} : { onSortingChange: handleSortingChange }),
getCoreRowModel: getCoreRowModel(),
...(disableSorting ? {} : { getSortedRowModel: getSortedRowModel() }),
});
return (
<DataTable
table={table}
isLoading={isLoading}
emptyMessage={emptyMessage}
stickyHeader={stickyHeader}
rowActions={
showActions
? {
getEditHref: (agent) => `/agents/${agent.id}/edit`,
onDelete,
}
: undefined
}
rowClassName="hover:bg-slate-50"
cellClassName="px-6 py-4"
emptyState={
emptyState
? {
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
title: emptyState.title,
description: emptyState.description,
actionHref: emptyState.actionHref,
actionLabel: emptyState.actionLabel,
}
: undefined
}
/>
);
}

View File

@@ -0,0 +1,155 @@
import { useMemo, useState } from "react";
import {
type ColumnDef,
type OnChangeFn,
type SortingState,
type Updater,
type VisibilityState,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { type BoardGroupRead } from "@/api/generated/model";
import {
DataTable,
type DataTableEmptyState,
} from "@/components/tables/DataTable";
import { dateCell, linkifyCell } from "@/components/tables/cell-formatters";
type BoardGroupsTableProps = {
groups: BoardGroupRead[];
isLoading?: boolean;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
stickyHeader?: boolean;
showActions?: boolean;
hiddenColumns?: string[];
columnOrder?: string[];
disableSorting?: boolean;
onDelete?: (group: BoardGroupRead) => void;
emptyMessage?: string;
emptyState?: Omit<DataTableEmptyState, "icon"> & {
icon?: DataTableEmptyState["icon"];
};
};
const DEFAULT_EMPTY_ICON = (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 7h8" />
<path d="M3 17h8" />
<path d="M13 7h8" />
<path d="M13 17h8" />
<path d="M3 12h18" />
</svg>
);
export function BoardGroupsTable({
groups,
isLoading = false,
sorting,
onSortingChange,
stickyHeader = false,
showActions = true,
hiddenColumns,
columnOrder,
disableSorting = false,
onDelete,
emptyMessage = "No groups found.",
emptyState,
}: BoardGroupsTableProps) {
const [internalSorting, setInternalSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const resolvedSorting = sorting ?? internalSorting;
const handleSortingChange: OnChangeFn<SortingState> =
onSortingChange ??
((updater: Updater<SortingState>) => {
setInternalSorting(updater);
});
const columnVisibility = useMemo<VisibilityState>(
() =>
Object.fromEntries(
(hiddenColumns ?? []).map((columnId) => [columnId, false]),
),
[hiddenColumns],
);
const columns = useMemo<ColumnDef<BoardGroupRead>[]>(() => {
const baseColumns: ColumnDef<BoardGroupRead>[] = [
{
accessorKey: "name",
header: "Group",
cell: ({ row }) =>
linkifyCell({
href: `/board-groups/${row.original.id}`,
label: row.original.name,
subtitle: row.original.description ?? "No description",
subtitleClassName: row.original.description
? "mt-1 line-clamp-2 text-xs text-slate-500"
: "mt-1 text-xs text-slate-400",
}),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => dateCell(row.original.updated_at),
},
];
return baseColumns;
}, []);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: groups,
columns,
enableSorting: !disableSorting,
state: {
...(!disableSorting ? { sorting: resolvedSorting } : {}),
...(columnOrder ? { columnOrder } : {}),
columnVisibility,
},
...(disableSorting ? {} : { onSortingChange: handleSortingChange }),
getCoreRowModel: getCoreRowModel(),
...(disableSorting ? {} : { getSortedRowModel: getSortedRowModel() }),
});
return (
<DataTable
table={table}
isLoading={isLoading}
stickyHeader={stickyHeader}
emptyMessage={emptyMessage}
rowClassName="transition hover:bg-slate-50"
cellClassName="px-6 py-4 align-top"
rowActions={
showActions
? {
getEditHref: (group) => `/board-groups/${group.id}/edit`,
onDelete,
}
: undefined
}
emptyState={
emptyState
? {
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
title: emptyState.title,
description: emptyState.description,
actionHref: emptyState.actionHref,
actionLabel: emptyState.actionLabel,
}
: undefined
}
/>
);
}

View File

@@ -0,0 +1,187 @@
import { useMemo, useState } from "react";
import {
type ColumnDef,
type OnChangeFn,
type SortingState,
type Updater,
type VisibilityState,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { type BoardGroupRead, type BoardRead } from "@/api/generated/model";
import {
DataTable,
type DataTableEmptyState,
} from "@/components/tables/DataTable";
import { dateCell, linkifyCell } from "@/components/tables/cell-formatters";
type BoardsTableProps = {
boards: BoardRead[];
boardGroups?: BoardGroupRead[];
isLoading?: boolean;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
stickyHeader?: boolean;
showActions?: boolean;
hiddenColumns?: string[];
columnOrder?: string[];
disableSorting?: boolean;
onDelete?: (board: BoardRead) => void;
emptyMessage?: string;
emptyState?: Omit<DataTableEmptyState, "icon"> & {
icon?: DataTableEmptyState["icon"];
};
};
const DEFAULT_EMPTY_ICON = (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
);
const compactId = (value: string) =>
value.length > 8 ? `${value.slice(0, 8)}` : value;
export function BoardsTable({
boards,
boardGroups = [],
isLoading = false,
sorting,
onSortingChange,
stickyHeader = false,
showActions = true,
hiddenColumns,
columnOrder,
disableSorting = false,
onDelete,
emptyMessage = "No boards found.",
emptyState,
}: BoardsTableProps) {
const [internalSorting, setInternalSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const resolvedSorting = sorting ?? internalSorting;
const handleSortingChange: OnChangeFn<SortingState> =
onSortingChange ??
((updater: Updater<SortingState>) => {
setInternalSorting(updater);
});
const columnVisibility = useMemo<VisibilityState>(
() =>
Object.fromEntries(
(hiddenColumns ?? []).map((columnId) => [columnId, false]),
),
[hiddenColumns],
);
const groupById = useMemo(() => {
const map = new Map<string, BoardGroupRead>();
for (const group of boardGroups) {
map.set(group.id, group);
}
return map;
}, [boardGroups]);
const columns = useMemo<ColumnDef<BoardRead>[]>(() => {
const baseColumns: ColumnDef<BoardRead>[] = [
{
accessorKey: "name",
header: "Board",
cell: ({ row }) =>
linkifyCell({
href: `/boards/${row.original.id}`,
label: row.original.name,
}),
},
{
id: "group",
accessorFn: (row) => {
const groupId = row.board_group_id;
if (!groupId) return "";
return groupById.get(groupId)?.name ?? groupId;
},
header: "Group",
cell: ({ row }) => {
const groupId = row.original.board_group_id;
if (!groupId) {
return <span className="text-sm text-slate-400"></span>;
}
const group = groupById.get(groupId);
const label = group?.name ?? compactId(groupId);
const title = group?.name ?? groupId;
return linkifyCell({
href: `/board-groups/${groupId}`,
label,
title,
block: false,
});
},
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => dateCell(row.original.updated_at),
},
];
return baseColumns;
}, [groupById]);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: boards,
columns,
enableSorting: !disableSorting,
state: {
...(!disableSorting ? { sorting: resolvedSorting } : {}),
...(columnOrder ? { columnOrder } : {}),
columnVisibility,
},
...(disableSorting ? {} : { onSortingChange: handleSortingChange }),
getCoreRowModel: getCoreRowModel(),
...(disableSorting ? {} : { getSortedRowModel: getSortedRowModel() }),
});
return (
<DataTable
table={table}
isLoading={isLoading}
stickyHeader={stickyHeader}
emptyMessage={emptyMessage}
rowClassName="transition hover:bg-slate-50"
cellClassName="px-6 py-4 align-top"
rowActions={
showActions
? {
getEditHref: (board) => `/boards/${board.id}/edit`,
onDelete,
}
: undefined
}
emptyState={
emptyState
? {
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
title: emptyState.title,
description: emptyState.description,
actionHref: emptyState.actionHref,
actionLabel: emptyState.actionLabel,
}
: undefined
}
/>
);
}

View File

@@ -0,0 +1,160 @@
import { useMemo, useState } from "react";
import {
type ColumnDef,
type OnChangeFn,
type SortingState,
type Updater,
type VisibilityState,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { type GatewayRead } from "@/api/generated/model";
import {
DataTable,
type DataTableEmptyState,
} from "@/components/tables/DataTable";
import { dateCell, linkifyCell } from "@/components/tables/cell-formatters";
import { truncateText as truncate } from "@/lib/formatters";
type GatewaysTableProps = {
gateways: GatewayRead[];
isLoading?: boolean;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
stickyHeader?: boolean;
showActions?: boolean;
hiddenColumns?: string[];
columnOrder?: string[];
disableSorting?: boolean;
onDelete?: (gateway: GatewayRead) => void;
emptyMessage?: string;
emptyState?: Omit<DataTableEmptyState, "icon"> & {
icon?: DataTableEmptyState["icon"];
};
};
const DEFAULT_EMPTY_ICON = (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</svg>
);
export function GatewaysTable({
gateways,
isLoading = false,
sorting,
onSortingChange,
stickyHeader = false,
showActions = true,
hiddenColumns,
columnOrder,
disableSorting = false,
onDelete,
emptyMessage = "No gateways found.",
emptyState,
}: GatewaysTableProps) {
const [internalSorting, setInternalSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const resolvedSorting = sorting ?? internalSorting;
const handleSortingChange: OnChangeFn<SortingState> =
onSortingChange ??
((updater: Updater<SortingState>) => {
setInternalSorting(updater);
});
const sortedGateways = useMemo(() => [...gateways], [gateways]);
const columnVisibility = useMemo<VisibilityState>(
() =>
Object.fromEntries(
(hiddenColumns ?? []).map((columnId) => [columnId, false]),
),
[hiddenColumns],
);
const columns = useMemo<ColumnDef<GatewayRead>[]>(() => {
const baseColumns: ColumnDef<GatewayRead>[] = [
{
accessorKey: "name",
header: "Gateway",
cell: ({ row }) =>
linkifyCell({
href: `/gateways/${row.original.id}`,
label: row.original.name,
subtitle: truncate(row.original.url, 36),
}),
},
{
accessorKey: "workspace_root",
header: "Workspace root",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{truncate(row.original.workspace_root, 28)}
</span>
),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => dateCell(row.original.updated_at),
},
];
return baseColumns;
}, []);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: sortedGateways,
columns,
enableSorting: !disableSorting,
state: {
...(!disableSorting ? { sorting: resolvedSorting } : {}),
...(columnOrder ? { columnOrder } : {}),
columnVisibility,
},
...(disableSorting ? {} : { onSortingChange: handleSortingChange }),
getCoreRowModel: getCoreRowModel(),
...(disableSorting ? {} : { getSortedRowModel: getSortedRowModel() }),
});
return (
<DataTable
table={table}
isLoading={isLoading}
stickyHeader={stickyHeader}
emptyMessage={emptyMessage}
rowActions={
showActions
? {
getEditHref: (gateway) => `/gateways/${gateway.id}/edit`,
onDelete,
}
: undefined
}
emptyState={
emptyState
? {
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
title: emptyState.title,
description: emptyState.description,
actionHref: emptyState.actionHref,
actionLabel: emptyState.actionLabel,
}
: undefined
}
/>
);
}

View File

@@ -0,0 +1,106 @@
import { useMemo } from "react";
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { type BoardRead } from "@/api/generated/model";
import { linkifyCell } from "@/components/tables/cell-formatters";
import { DataTable } from "@/components/tables/DataTable";
type BoardAccessState = Record<string, { read: boolean; write: boolean }>;
type BoardAccessTableProps = {
boards: BoardRead[];
access: BoardAccessState;
onToggleRead: (boardId: string) => void;
onToggleWrite: (boardId: string) => void;
disabled?: boolean;
};
export function BoardAccessTable({
boards,
access,
onToggleRead,
onToggleWrite,
disabled = false,
}: BoardAccessTableProps) {
const columns = useMemo<ColumnDef<BoardRead>[]>(
() => [
{
accessorKey: "name",
header: "Board",
cell: ({ row }) =>
linkifyCell({
href: `/boards/${row.original.id}`,
label: row.original.name,
subtitle: row.original.slug,
subtitleClassName: "mt-1 text-xs text-slate-500",
}),
},
{
id: "read",
header: "Read",
cell: ({ row }) => {
const entry = access[row.original.id] ?? {
read: false,
write: false,
};
return (
<div className="flex justify-center">
<input
type="checkbox"
className="h-4 w-4"
checked={entry.read}
onChange={() => onToggleRead(row.original.id)}
disabled={disabled}
/>
</div>
);
},
},
{
id: "write",
header: "Write",
cell: ({ row }) => {
const entry = access[row.original.id] ?? {
read: false,
write: false,
};
return (
<div className="flex justify-center">
<input
type="checkbox"
className="h-4 w-4"
checked={entry.write}
onChange={() => onToggleWrite(row.original.id)}
disabled={disabled}
/>
</div>
);
},
},
],
[access, disabled, onToggleRead, onToggleWrite],
);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: boards,
columns,
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
});
return (
<DataTable
table={table}
rowClassName="border-t border-slate-200 hover:bg-slate-50"
headerClassName="bg-slate-50 text-[11px] uppercase tracking-wide text-slate-500"
headerCellClassName="px-4 py-2 font-medium"
cellClassName="px-4 py-3"
/>
);
}

View File

@@ -0,0 +1,254 @@
import { useMemo } from "react";
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Copy } from "lucide-react";
import type {
OrganizationInviteRead,
OrganizationMemberRead,
} from "@/api/generated/model";
import { DataTable } from "@/components/tables/DataTable";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatTimestamp } from "@/lib/formatters";
type MemberInviteRow =
| { kind: "member"; member: OrganizationMemberRead }
| { kind: "invite"; invite: OrganizationInviteRead };
type MembersInvitesTableProps = {
members: OrganizationMemberRead[];
invites: OrganizationInviteRead[];
isLoading: boolean;
isAdmin: boolean;
copiedInviteId: string | null;
onManageAccess: (memberId: string) => void;
onCopyInvite: (invite: OrganizationInviteRead) => void;
onRevokeInvite: (inviteId: string) => void;
isRevoking: boolean;
};
const roleBadgeVariant = (role: string) => {
if (role === "admin" || role === "owner") return "accent" as const;
return "outline" as const;
};
const initialsFrom = (value?: string | null) => {
if (!value) return "?";
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
};
const summarizeAccess = (allRead: boolean, allWrite: boolean) => {
if (allRead || allWrite) {
if (allRead && allWrite) return "All boards: read + write";
if (allWrite) return "All boards: write";
return "All boards: read";
}
return "Selected boards";
};
const memberDisplay = (member: OrganizationMemberRead) => {
const primary =
member.user?.name ||
member.user?.preferred_name ||
member.user?.email ||
member.user_id;
const secondary = member.user?.email ?? "No email on file";
return {
primary,
secondary,
initials: initialsFrom(primary),
};
};
export function MembersInvitesTable({
members,
invites,
isLoading,
isAdmin,
copiedInviteId,
onManageAccess,
onCopyInvite,
onRevokeInvite,
isRevoking,
}: MembersInvitesTableProps) {
const rows = useMemo<MemberInviteRow[]>(
() => [
...members.map((member) => ({ kind: "member" as const, member })),
...invites.map((invite) => ({ kind: "invite" as const, invite })),
],
[invites, members],
);
const columns = useMemo<ColumnDef<MemberInviteRow>[]>(
() => [
{
id: "member",
header: "Member",
cell: ({ row }) => {
if (row.original.kind === "member") {
const display = memberDisplay(row.original.member);
return (
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-500 text-xs font-semibold text-white">
{display.initials}
</div>
<div>
<div className="text-sm font-semibold text-slate-900">
{display.primary}
</div>
<div className="text-xs text-slate-500">
{display.secondary}
</div>
</div>
</div>
);
}
return (
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-200 text-xs font-semibold text-slate-600">
{initialsFrom(row.original.invite.invited_email)}
</div>
<div>
<div className="text-sm font-semibold text-slate-900">
{row.original.invite.invited_email}
</div>
<div className="text-xs text-slate-500">
Invited {formatTimestamp(row.original.invite.created_at)}
</div>
</div>
</div>
);
},
},
{
id: "status",
header: "Status",
cell: ({ row }) => {
if (row.original.kind === "member") {
return (
<Badge variant={roleBadgeVariant(row.original.member.role)}>
{row.original.member.role}
</Badge>
);
}
return (
<div className="flex flex-wrap items-center gap-2">
<Badge variant="warning">Pending</Badge>
<Badge variant={roleBadgeVariant(row.original.invite.role)}>
{row.original.invite.role}
</Badge>
</div>
);
},
},
{
id: "access",
header: "Access",
cell: ({ row }) => (
<span className="text-slate-600">
{row.original.kind === "member"
? summarizeAccess(
row.original.member.all_boards_read,
row.original.member.all_boards_write,
)
: summarizeAccess(
row.original.invite.all_boards_read,
row.original.invite.all_boards_write,
)}
</span>
),
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => {
if (row.original.kind === "member") {
const member = row.original.member;
if (!isAdmin) {
return <span className="text-xs text-slate-400">Admin only</span>;
}
return (
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onManageAccess(member.id)}
>
Manage access
</Button>
</div>
);
}
const invite = row.original.invite;
return (
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onCopyInvite(invite)}
>
<Copy className="h-4 w-4" />
{copiedInviteId === invite.id ? "Copied" : "Copy link"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRevokeInvite(invite.id)}
disabled={isRevoking}
>
Revoke
</Button>
</div>
);
},
},
],
[
copiedInviteId,
isAdmin,
isRevoking,
onCopyInvite,
onManageAccess,
onRevokeInvite,
],
);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: rows,
columns,
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
});
return (
<DataTable
table={table}
isLoading={isLoading}
loadingLabel="Loading members..."
emptyMessage="No members or invites yet."
headerClassName="bg-slate-50 text-[11px] uppercase tracking-wide text-slate-500"
headerCellClassName="px-5 py-3 text-left font-medium"
cellClassName="px-5 py-4"
rowClassName={(row) =>
row.original.kind === "invite"
? "border-t border-slate-200 bg-slate-50/60"
: "border-t border-slate-200 hover:bg-slate-50"
}
/>
);
}

View File

@@ -0,0 +1,168 @@
import type React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { describe, expect, it, vi } from "vitest";
import { DataTable } from "./DataTable";
vi.mock("next/link", () => {
type LinkProps = React.PropsWithChildren<{
href: string | { pathname?: string };
}> &
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">;
return {
default: ({ href, children, ...props }: LinkProps) => (
<a href={typeof href === "string" ? href : "#"} {...props}>
{children}
</a>
),
};
});
type Row = {
id: string;
name: string;
};
type HarnessProps = {
rows: Row[];
isLoading?: boolean;
emptyMessage?: string;
emptyState?: React.ComponentProps<typeof DataTable<Row>>["emptyState"];
rowActions?: React.ComponentProps<typeof DataTable<Row>>["rowActions"];
};
function DataTableHarness({
rows,
isLoading = false,
emptyMessage,
emptyState,
rowActions,
}: HarnessProps) {
const columns: ColumnDef<Row>[] = [{ accessorKey: "name", header: "Name" }];
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<DataTable
table={table}
isLoading={isLoading}
emptyMessage={emptyMessage}
emptyState={emptyState}
rowActions={rowActions}
/>
);
}
describe("DataTable", () => {
it("renders default Edit/Delete row actions", () => {
const onDelete = vi.fn();
const row = { id: "row-1", name: "Alpha" };
render(
<DataTableHarness
rows={[row]}
rowActions={{
getEditHref: (current) => `/items/${current.id}/edit`,
onDelete,
}}
/>,
);
const editLink = screen.getByRole("link", { name: "Edit" });
expect(editLink).toHaveAttribute("href", "/items/row-1/edit");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(onDelete).toHaveBeenCalledWith(row);
});
it("uses custom row actions when provided", () => {
const onArchive = vi.fn();
const row = { id: "row-1", name: "Alpha" };
render(
<DataTableHarness
rows={[row]}
rowActions={{
getEditHref: (current) => `/items/${current.id}/edit`,
onDelete: vi.fn(),
actions: [
{
key: "view",
label: "View",
href: (current) => `/items/${current.id}`,
},
{
key: "archive",
label: "Archive",
onClick: onArchive,
},
],
}}
/>,
);
expect(screen.getByRole("link", { name: "View" })).toHaveAttribute(
"href",
"/items/row-1",
);
expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: "Edit" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Delete" }),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Archive" }));
expect(onArchive).toHaveBeenCalledWith(row);
});
it("renders loading and empty states", () => {
const { rerender } = render(
<DataTableHarness rows={[]} isLoading={true} />,
);
expect(screen.getByText("Loading…")).toBeInTheDocument();
rerender(
<DataTableHarness
rows={[]}
isLoading={false}
emptyMessage="No rows yet"
/>,
);
expect(screen.getByText("No rows yet")).toBeInTheDocument();
});
it("renders custom empty state", () => {
render(
<DataTableHarness
rows={[]}
emptyState={{
icon: <span data-testid="empty-icon">icon</span>,
title: "No records",
description: "Create one to continue.",
actionHref: "/new",
actionLabel: "Create",
}}
/>,
);
expect(screen.getByTestId("empty-icon")).toBeInTheDocument();
expect(screen.getByText("No records")).toBeInTheDocument();
expect(screen.getByText("Create one to continue.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Create" })).toHaveAttribute(
"href",
"/new",
);
});
});

View File

@@ -0,0 +1,219 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { type Row, type Table, flexRender } from "@tanstack/react-table";
import {
TableEmptyStateRow,
TableLoadingRow,
} from "@/components/ui/table-state";
import { Button, buttonVariants } from "@/components/ui/button";
export type DataTableEmptyState = {
icon: ReactNode;
title: string;
description: string;
actionHref?: string;
actionLabel?: string;
};
export type DataTableRowAction<TData> = {
key: string;
label: string;
href?: (row: TData) => string | null;
onClick?: (row: TData) => void;
className?: string;
};
export type DataTableRowActions<TData> = {
header?: ReactNode;
actions?: DataTableRowAction<TData>[];
getEditHref?: (row: TData) => string | null;
onDelete?: (row: TData) => void;
cellClassName?: string;
};
type DataTableProps<TData> = {
table: Table<TData>;
isLoading?: boolean;
loadingLabel?: string;
emptyMessage?: string;
emptyState?: DataTableEmptyState;
rowActions?: DataTableRowActions<TData>;
stickyHeader?: boolean;
tableClassName?: string;
headerClassName?: string;
headerCellClassName?: string;
bodyClassName?: string;
rowClassName?: string | ((row: Row<TData>) => string);
cellClassName?: string;
};
export function DataTable<TData>({
table,
isLoading = false,
loadingLabel = "Loading…",
emptyMessage = "No rows found.",
emptyState,
rowActions,
stickyHeader = false,
tableClassName = "w-full text-left text-sm",
headerClassName,
headerCellClassName = "px-6 py-3",
bodyClassName = "divide-y divide-slate-100",
rowClassName = "hover:bg-slate-50",
cellClassName = "px-6 py-4",
}: DataTableProps<TData>) {
const resolvedRowActions = rowActions
? (rowActions.actions ??
[
rowActions.getEditHref
? ({
key: "edit",
label: "Edit",
href: rowActions.getEditHref,
} as DataTableRowAction<TData>)
: null,
rowActions.onDelete
? ({
key: "delete",
label: "Delete",
onClick: rowActions.onDelete,
} as DataTableRowAction<TData>)
: null,
].filter((value): value is DataTableRowAction<TData> => value !== null))
: [];
const hasRowActions = resolvedRowActions.length > 0;
const colSpan =
(table.getVisibleLeafColumns().length || 1) + (hasRowActions ? 1 : 0);
return (
<div className="overflow-x-auto">
<table className={tableClassName}>
<thead
className={
headerClassName ??
`${stickyHeader ? "sticky top-0 z-10 " : ""}bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500`
}
>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className={headerCellClassName}>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<button
type="button"
onClick={header.column.getToggleSortingHandler()}
className="inline-flex items-center gap-1 text-left"
>
<span>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
{header.column.getIsSorted() === "asc" ? (
"↑"
) : header.column.getIsSorted() === "desc" ? (
"↓"
) : (
<span className="text-slate-300"></span>
)}
</button>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</th>
))}
{hasRowActions ? (
<th className={headerCellClassName}>
{rowActions?.header ?? ""}
</th>
) : null}
</tr>
))}
</thead>
<tbody className={bodyClassName}>
{isLoading ? (
<TableLoadingRow colSpan={colSpan} label={loadingLabel} />
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className={
typeof rowClassName === "function"
? rowClassName(row)
: rowClassName
}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className={cellClassName}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
{hasRowActions ? (
<td className={rowActions?.cellClassName ?? cellClassName}>
<div className="flex justify-end gap-2">
{resolvedRowActions.map((action) => {
const href = action.href?.(row.original) ?? null;
if (href) {
return (
<Link
key={action.key}
href={href}
className={
action.className ??
buttonVariants({ variant: "ghost", size: "sm" })
}
>
{action.label}
</Link>
);
}
if (action.onClick) {
return (
<Button
key={action.key}
variant="ghost"
size="sm"
className={action.className}
onClick={() => action.onClick?.(row.original)}
>
{action.label}
</Button>
);
}
return null;
})}
</div>
</td>
) : null}
</tr>
))
) : emptyState ? (
<TableEmptyStateRow
colSpan={colSpan}
icon={emptyState.icon}
title={emptyState.title}
description={emptyState.description}
actionHref={emptyState.actionHref}
actionLabel={emptyState.actionLabel}
/>
) : (
<tr>
<td
colSpan={colSpan}
className="px-6 py-8 text-sm text-slate-500"
>
{emptyMessage}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import type React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { dateCell, linkifyCell, pillCell } from "./cell-formatters";
vi.mock("next/link", () => {
type LinkProps = React.PropsWithChildren<{
href: string | { pathname?: string };
}> &
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">;
return {
default: ({ href, children, ...props }: LinkProps) => (
<a href={typeof href === "string" ? href : "#"} {...props}>
{children}
</a>
),
};
});
describe("cell formatters", () => {
it("renders linkifyCell in block mode with subtitle", () => {
render(
linkifyCell({
href: "/agents/agent-1",
label: "Agent One",
subtitle: "ID agent-1",
}),
);
expect(screen.getByRole("link", { name: /agent one/i })).toHaveAttribute(
"href",
"/agents/agent-1",
);
expect(screen.getByText("ID agent-1")).toBeInTheDocument();
});
it("renders linkifyCell in inline mode", () => {
render(
linkifyCell({
href: "/boards/board-1",
label: "Board One",
block: false,
}),
);
expect(screen.getByRole("link", { name: "Board One" })).toHaveAttribute(
"href",
"/boards/board-1",
);
});
it("renders pillCell and default fallback", () => {
const { rerender } = render(pillCell("in_progress"));
expect(screen.getByText("in progress")).toBeInTheDocument();
rerender(pillCell(null));
expect(screen.getByText("unknown")).toBeInTheDocument();
});
it("renders dateCell relative and fallback states", () => {
const now = new Date("2026-01-01T01:00:00Z").getTime();
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
const { rerender } = render(
dateCell("2026-01-01T00:00:00Z", { relative: true }),
);
expect(screen.getByText("1h ago")).toBeInTheDocument();
rerender(dateCell(null));
expect(screen.getByText("—")).toBeInTheDocument();
nowSpy.mockRestore();
});
});

View File

@@ -0,0 +1,89 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { StatusPill } from "@/components/atoms/StatusPill";
import {
formatRelativeTimestamp as formatRelative,
formatTimestamp,
} from "@/lib/formatters";
import { cn } from "@/lib/utils";
type LinkifyCellOptions = {
href: string;
label: ReactNode;
subtitle?: ReactNode;
title?: string;
block?: boolean;
className?: string;
labelClassName?: string;
subtitleClassName?: string;
};
type DateCellOptions = {
relative?: boolean;
className?: string;
fallback?: ReactNode;
};
export function linkifyCell({
href,
label,
subtitle,
title,
block = subtitle != null,
className,
labelClassName,
subtitleClassName,
}: LinkifyCellOptions) {
if (block) {
return (
<Link href={href} title={title} className={cn("group block", className)}>
<p
className={cn(
"text-sm font-medium text-slate-900 group-hover:text-blue-600",
labelClassName,
)}
>
{label}
</p>
{subtitle != null ? (
<p className={cn("text-xs text-slate-500", subtitleClassName)}>
{subtitle}
</p>
) : null}
</Link>
);
}
return (
<Link
href={href}
title={title}
className={cn(
"text-sm font-medium text-slate-700 hover:text-blue-600",
className,
)}
>
{label}
</Link>
);
}
export function pillCell(
value: string | null | undefined,
fallback = "unknown",
) {
return <StatusPill status={value ?? fallback} />;
}
export function dateCell(
value: string | null | undefined,
{ relative = false, className, fallback = "—" }: DateCellOptions = {},
) {
const display = relative ? formatRelative(value) : formatTimestamp(value);
return (
<span className={cn("text-sm text-slate-700", className)}>
{display ?? fallback}
</span>
);
}