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
}
/>
);
}