feat: add cell formatters and tables for boards, agents, and member invites
This commit is contained in:
145
frontend/src/components/agents/AgentsTable.test.tsx
Normal file
145
frontend/src/components/agents/AgentsTable.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
204
frontend/src/components/agents/AgentsTable.tsx
Normal file
204
frontend/src/components/agents/AgentsTable.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user