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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
155
frontend/src/components/board-groups/BoardGroupsTable.tsx
Normal file
155
frontend/src/components/board-groups/BoardGroupsTable.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
187
frontend/src/components/boards/BoardsTable.tsx
Normal file
187
frontend/src/components/boards/BoardsTable.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
160
frontend/src/components/gateways/GatewaysTable.tsx
Normal file
160
frontend/src/components/gateways/GatewaysTable.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/organization/BoardAccessTable.tsx
Normal file
106
frontend/src/components/organization/BoardAccessTable.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
254
frontend/src/components/organization/MembersInvitesTable.tsx
Normal file
254
frontend/src/components/organization/MembersInvitesTable.tsx
Normal 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"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
168
frontend/src/components/tables/DataTable.test.tsx
Normal file
168
frontend/src/components/tables/DataTable.test.tsx
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
219
frontend/src/components/tables/DataTable.tsx
Normal file
219
frontend/src/components/tables/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/tables/cell-formatters.test.tsx
Normal file
74
frontend/src/components/tables/cell-formatters.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
89
frontend/src/components/tables/cell-formatters.tsx
Normal file
89
frontend/src/components/tables/cell-formatters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user