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

@@ -3,28 +3,15 @@
export const dynamic = "force-dynamic";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import {
type ColumnDef,
type SortingState,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useQueryClient } from "@tanstack/react-query";
import { StatusPill } from "@/components/atoms/StatusPill";
import { AgentsTable } from "@/components/agents/AgentsTable";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button, buttonVariants } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import {
TableEmptyStateRow,
TableLoadingRow,
} from "@/components/ui/table-state";
import { ApiError } from "@/api/mutator";
import {
@@ -38,13 +25,19 @@ import {
getListBoardsApiV1BoardsGetQueryKey,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import {
formatRelativeTimestamp as formatRelative,
formatTimestamp,
truncateText as truncate,
} from "@/lib/formatters";
import { type AgentRead } from "@/api/generated/model";
import { createOptimisticListDeleteMutation } from "@/lib/list-delete";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import type { AgentRead } from "@/api/generated/model";
import { useUrlSorting } from "@/lib/use-url-sorting";
const AGENT_SORTABLE_COLUMNS = [
"name",
"status",
"openclaw_session_id",
"board_id",
"last_seen_at",
"updated_at",
];
export default function AgentsPage() {
const { isSignedIn } = useAuth();
@@ -52,10 +45,11 @@ export default function AgentsPage() {
const router = useRouter();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const { sorting, onSortingChange } = useUrlSorting({
allowedColumnIds: AGENT_SORTABLE_COLUMNS,
defaultSorting: [{ id: "name", desc: false }],
paramPrefix: "agents",
});
const [deleteTarget, setDeleteTarget] = useState<AgentRead | null>(null);
@@ -104,150 +98,29 @@ export default function AgentsPage() {
{ previous?: listAgentsApiV1AgentsGetResponse }
>(
{
mutation: {
onMutate: async ({ agentId }) => {
await queryClient.cancelQueries({ queryKey: agentsKey });
const previous =
queryClient.getQueryData<listAgentsApiV1AgentsGetResponse>(
agentsKey,
);
if (previous && previous.status === 200) {
const nextItems = previous.data.items.filter(
(agent) => agent.id !== agentId,
);
const removedCount = previous.data.items.length - nextItems.length;
queryClient.setQueryData<listAgentsApiV1AgentsGetResponse>(
agentsKey,
{
...previous,
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
},
},
);
}
return { previous };
},
onError: (_error, _agent, context) => {
if (context?.previous) {
queryClient.setQueryData(agentsKey, context.previous);
}
},
mutation: createOptimisticListDeleteMutation<
AgentRead,
listAgentsApiV1AgentsGetResponse,
{ agentId: string }
>({
queryClient,
queryKey: agentsKey,
getItemId: (agent) => agent.id,
getDeleteId: ({ agentId }) => agentId,
onSuccess: () => {
setDeleteTarget(null);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: agentsKey });
queryClient.invalidateQueries({ queryKey: boardsKey });
},
},
invalidateQueryKeys: [agentsKey, boardsKey],
}),
},
queryClient,
);
const sortedAgents = useMemo(() => [...agents], [agents]);
const handleDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate({ agentId: deleteTarget.id });
};
const columns = useMemo<ColumnDef<AgentRead>[]>(() => {
const resolveBoardName = (agent: AgentRead) =>
boards.find((board) => board.id === agent.board_id)?.name ?? "—";
return [
{
accessorKey: "name",
header: "Agent",
cell: ({ row }) => (
<Link href={`/agents/${row.original.id}`} className="group block">
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
{row.original.name}
</p>
<p className="text-xs text-slate-500">ID {row.original.id}</p>
</Link>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<StatusPill status={row.original.status ?? "unknown"} />
),
},
{
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 }) => (
<span className="text-sm text-slate-700">
{resolveBoardName(row.original)}
</span>
),
},
{
accessorKey: "last_seen_at",
header: "Last seen",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{formatRelative(row.original.last_seen_at)}
</span>
),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{formatTimestamp(row.original.updated_at)}
</span>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div className="flex justify-end gap-2">
<Link
href={`/agents/${row.original.id}/edit`}
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Edit
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
),
},
];
}, [boards]);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: sortedAgents,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<>
<DashboardPageLayout
@@ -270,68 +143,23 @@ export default function AgentsPage() {
stickyHeader
>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="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="px-6 py-3">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{agentsQuery.isLoading ? (
<TableLoadingRow colSpan={columns.length} />
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<TableEmptyStateRow
colSpan={columns.length}
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>
}
title="No agents yet"
description="Create your first agent to start executing tasks on this board."
actionHref="/agents/new"
actionLabel="Create your first agent"
<AgentsTable
agents={agents}
boards={boards}
isLoading={agentsQuery.isLoading}
sorting={sorting}
onSortingChange={onSortingChange}
showActions
stickyHeader
onDelete={setDeleteTarget}
emptyState={{
title: "No agents yet",
description:
"Create your first agent to start executing tasks on this board.",
actionHref: "/agents/new",
actionLabel: "Create your first agent",
}}
/>
)}
</tbody>
</table>
</div>
</div>
{agentsQuery.error ? (

View File

@@ -6,12 +6,6 @@ import { useMemo, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/auth/clerk";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
@@ -21,19 +15,24 @@ import {
useDeleteBoardGroupApiV1BoardGroupsGroupIdDelete,
useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups";
import { BoardGroupsTable } from "@/components/board-groups/BoardGroupsTable";
import type { BoardGroupRead } from "@/api/generated/model";
import { createOptimisticListDeleteMutation } from "@/lib/list-delete";
import { useUrlSorting } from "@/lib/use-url-sorting";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button, buttonVariants } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { formatTimestamp } from "@/lib/formatters";
import {
TableEmptyStateRow,
TableLoadingRow,
} from "@/components/ui/table-state";
const BOARD_GROUP_SORTABLE_COLUMNS = ["name", "updated_at"];
export default function BoardGroupsPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const { sorting, onSortingChange } = useUrlSorting({
allowedColumnIds: BOARD_GROUP_SORTABLE_COLUMNS,
defaultSorting: [{ id: "name", desc: false }],
paramPrefix: "board_groups",
});
const [deleteTarget, setDeleteTarget] = useState<BoardGroupRead | null>(null);
const groupsKey = getListBoardGroupsApiV1BoardGroupsGetQueryKey();
@@ -61,44 +60,20 @@ export default function BoardGroupsPage() {
{ previous?: listBoardGroupsApiV1BoardGroupsGetResponse }
>(
{
mutation: {
onMutate: async ({ groupId }) => {
await queryClient.cancelQueries({ queryKey: groupsKey });
const previous =
queryClient.getQueryData<listBoardGroupsApiV1BoardGroupsGetResponse>(
groupsKey,
);
if (previous && previous.status === 200) {
const nextItems = previous.data.items.filter(
(group) => group.id !== groupId,
);
const removedCount = previous.data.items.length - nextItems.length;
queryClient.setQueryData<listBoardGroupsApiV1BoardGroupsGetResponse>(
groupsKey,
{
...previous,
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
},
},
);
}
return { previous };
},
onError: (_error, _group, context) => {
if (context?.previous) {
queryClient.setQueryData(groupsKey, context.previous);
}
},
mutation: createOptimisticListDeleteMutation<
BoardGroupRead,
listBoardGroupsApiV1BoardGroupsGetResponse,
{ groupId: string }
>({
queryClient,
queryKey: groupsKey,
getItemId: (group) => group.id,
getDeleteId: ({ groupId }) => groupId,
onSuccess: () => {
setDeleteTarget(null);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: groupsKey });
},
},
invalidateQueryKeys: [groupsKey],
}),
},
queryClient,
);
@@ -108,70 +83,6 @@ export default function BoardGroupsPage() {
deleteMutation.mutate({ groupId: deleteTarget.id });
};
const columns = useMemo<ColumnDef<BoardGroupRead>[]>(
() => [
{
accessorKey: "name",
header: "Group",
cell: ({ row }) => (
<Link
href={`/board-groups/${row.original.id}`}
className="group block"
>
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
{row.original.name}
</p>
{row.original.description ? (
<p className="mt-1 text-xs text-slate-500 line-clamp-2">
{row.original.description}
</p>
) : (
<p className="mt-1 text-xs text-slate-400">No description</p>
)}
</Link>
),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{formatTimestamp(row.original.updated_at)}
</span>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div className="flex items-center justify-end gap-2">
<Link
href={`/board-groups/${row.original.id}/edit`}
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Edit
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
),
},
],
[],
);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: groups,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<DashboardPageLayout
@@ -192,72 +103,22 @@ export default function BoardGroupsPage() {
stickyHeader
>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left font-semibold"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{groupsQuery.isLoading ? (
<TableLoadingRow colSpan={columns.length} />
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="transition hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 align-top">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<TableEmptyStateRow
colSpan={columns.length}
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>
}
title="No groups yet"
description="Create a board group to increase cross-board visibility for agents."
actionHref="/board-groups/new"
actionLabel="Create your first group"
<BoardGroupsTable
groups={groups}
isLoading={groupsQuery.isLoading}
sorting={sorting}
onSortingChange={onSortingChange}
showActions
stickyHeader
onDelete={setDeleteTarget}
emptyState={{
title: "No groups yet",
description:
"Create a board group to increase cross-board visibility for agents.",
actionHref: "/board-groups/new",
actionLabel: "Create your first group",
}}
/>
)}
</tbody>
</table>
</div>
</div>
{groupsQuery.error ? (

View File

@@ -6,12 +6,6 @@ import { useMemo, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/auth/clerk";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
@@ -25,23 +19,25 @@ import {
type listBoardGroupsApiV1BoardGroupsGetResponse,
useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups";
import { formatTimestamp } from "@/lib/formatters";
import { createOptimisticListDeleteMutation } from "@/lib/list-delete";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import type { BoardGroupRead, BoardRead } from "@/api/generated/model";
import { useUrlSorting } from "@/lib/use-url-sorting";
import type { BoardRead } from "@/api/generated/model";
import { BoardsTable } from "@/components/boards/BoardsTable";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button, buttonVariants } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import {
TableEmptyStateRow,
TableLoadingRow,
} from "@/components/ui/table-state";
const compactId = (value: string) =>
value.length > 8 ? `${value.slice(0, 8)}` : value;
const BOARD_SORTABLE_COLUMNS = ["name", "group", "updated_at"];
export default function BoardsPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const { sorting, onSortingChange } = useUrlSorting({
allowedColumnIds: BOARD_SORTABLE_COLUMNS,
defaultSorting: [{ id: "name", desc: false }],
paramPrefix: "boards",
});
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [deleteTarget, setDeleteTarget] = useState<BoardRead | null>(null);
@@ -80,62 +76,30 @@ export default function BoardsPage() {
[boardsQuery.data],
);
const groups = useMemo<BoardGroupRead[]>(() => {
const groups = useMemo(() => {
if (groupsQuery.data?.status !== 200) return [];
return groupsQuery.data.data.items ?? [];
}, [groupsQuery.data]);
const groupById = useMemo(() => {
const map = new Map<string, BoardGroupRead>();
for (const group of groups) {
map.set(group.id, group);
}
return map;
}, [groups]);
const deleteMutation = useDeleteBoardApiV1BoardsBoardIdDelete<
ApiError,
{ previous?: listBoardsApiV1BoardsGetResponse }
>(
{
mutation: {
onMutate: async ({ boardId }) => {
await queryClient.cancelQueries({ queryKey: boardsKey });
const previous =
queryClient.getQueryData<listBoardsApiV1BoardsGetResponse>(
boardsKey,
);
if (previous && previous.status === 200) {
const nextItems = previous.data.items.filter(
(board) => board.id !== boardId,
);
const removedCount = previous.data.items.length - nextItems.length;
queryClient.setQueryData<listBoardsApiV1BoardsGetResponse>(
boardsKey,
{
...previous,
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
},
},
);
}
return { previous };
},
onError: (_error, _board, context) => {
if (context?.previous) {
queryClient.setQueryData(boardsKey, context.previous);
}
},
mutation: createOptimisticListDeleteMutation<
BoardRead,
listBoardsApiV1BoardsGetResponse,
{ boardId: string }
>({
queryClient,
queryKey: boardsKey,
getItemId: (board) => board.id,
getDeleteId: ({ boardId }) => boardId,
onSuccess: () => {
setDeleteTarget(null);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: boardsKey });
},
},
invalidateQueryKeys: [boardsKey],
}),
},
queryClient,
);
@@ -145,82 +109,6 @@ export default function BoardsPage() {
deleteMutation.mutate({ boardId: deleteTarget.id });
};
const columns = useMemo<ColumnDef<BoardRead>[]>(
() => [
{
accessorKey: "name",
header: "Board",
cell: ({ row }) => (
<Link href={`/boards/${row.original.id}`} className="group block">
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
{row.original.name}
</p>
</Link>
),
},
{
id: "group",
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 (
<Link
href={`/board-groups/${groupId}`}
className="text-sm font-medium text-slate-700 hover:text-blue-600"
title={title}
>
{label}
</Link>
);
},
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{formatTimestamp(row.original.updated_at)}
</span>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div className="flex items-center justify-end gap-2">
<Link
href={`/boards/${row.original.id}/edit`}
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Edit
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
),
},
],
[groupById],
);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: boards,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<DashboardPageLayout
@@ -247,71 +135,23 @@ export default function BoardsPage() {
stickyHeader
>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left font-semibold"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{boardsQuery.isLoading ? (
<TableLoadingRow colSpan={columns.length} />
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="transition hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 align-top">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<TableEmptyStateRow
colSpan={columns.length}
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>
}
title="No boards yet"
description="Create your first board to start routing tasks and monitoring work across agents."
actionHref="/boards/new"
actionLabel="Create your first board"
<BoardsTable
boards={boards}
boardGroups={groups}
isLoading={boardsQuery.isLoading}
sorting={sorting}
onSortingChange={onSortingChange}
showActions
stickyHeader
onDelete={setDeleteTarget}
emptyState={{
title: "No boards yet",
description:
"Create your first board to start routing tasks and monitoring work across agents.",
actionHref: "/boards/new",
actionLabel: "Create your first board",
}}
/>
)}
</tbody>
</table>
</div>
</div>
{boardsQuery.error ? (

View File

@@ -2,12 +2,21 @@
export const dynamic = "force-dynamic";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { AgentsTable } from "@/components/agents/AgentsTable";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { ApiError } from "@/api/mutator";
import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import {
type gatewaysStatusApiV1GatewaysStatusGetResponse,
type getGatewayApiV1GatewaysGatewayIdGetResponse,
@@ -16,12 +25,14 @@ import {
} from "@/api/generated/gateways/gateways";
import {
type listAgentsApiV1AgentsGetResponse,
getListAgentsApiV1AgentsGetQueryKey,
useDeleteAgentApiV1AgentsAgentIdDelete,
useListAgentsApiV1AgentsGet,
} from "@/api/generated/agents/agents";
import { type AgentRead } from "@/api/generated/model";
import { formatTimestamp } from "@/lib/formatters";
import { createOptimisticListDeleteMutation } from "@/lib/list-delete";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button";
const maskToken = (value?: string | null) => {
if (!value) return "—";
@@ -31,6 +42,7 @@ const maskToken = (value?: string | null) => {
export default function GatewayDetailPage() {
const router = useRouter();
const queryClient = useQueryClient();
const params = useParams();
const { isSignedIn } = useAuth();
const gatewayIdParam = params?.gatewayId;
@@ -39,6 +51,10 @@ export default function GatewayDetailPage() {
: gatewayIdParam;
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [deleteTarget, setDeleteTarget] = useState<AgentRead | null>(null);
const agentsKey = getListAgentsApiV1AgentsGetQueryKey(
gatewayId ? { gateway_id: gatewayId } : undefined,
);
const gatewayQuery = useGetGatewayApiV1GatewaysGatewayIdGet<
getGatewayApiV1GatewaysGatewayIdGetResponse,
@@ -53,6 +69,16 @@ export default function GatewayDetailPage() {
const gateway =
gatewayQuery.data?.status === 200 ? gatewayQuery.data.data : null;
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 30_000,
},
});
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
@@ -62,6 +88,28 @@ export default function GatewayDetailPage() {
refetchInterval: 15_000,
},
});
const deleteMutation = useDeleteAgentApiV1AgentsAgentIdDelete<
ApiError,
{ previous?: listAgentsApiV1AgentsGetResponse }
>(
{
mutation: createOptimisticListDeleteMutation<
AgentRead,
listAgentsApiV1AgentsGetResponse,
{ agentId: string }
>({
queryClient,
queryKey: agentsKey,
getItemId: (agent) => agent.id,
getDeleteId: ({ agentId }) => agentId,
onSuccess: () => {
setDeleteTarget(null);
},
invalidateQueryKeys: [agentsKey],
}),
},
queryClient,
);
const statusParams = gateway
? {
@@ -87,6 +135,13 @@ export default function GatewayDetailPage() {
: [],
[agentsQuery.data],
);
const boards = useMemo(
() =>
boardsQuery.data?.status === 200
? (boardsQuery.data.data.items ?? [])
: [],
[boardsQuery.data],
);
const status =
statusQuery.data?.status === 200 ? statusQuery.data.data : null;
@@ -96,8 +151,13 @@ export default function GatewayDetailPage() {
() => (gateway?.name ? gateway.name : "Gateway"),
[gateway?.name],
);
const handleDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate({ agentId: deleteTarget.id });
};
return (
<>
<DashboardPageLayout
signedOut={{
message: "Sign in to view a gateway.",
@@ -111,7 +171,9 @@ export default function GatewayDetailPage() {
Back to gateways
</Button>
{isAdmin && gatewayId ? (
<Button onClick={() => router.push(`/gateways/${gatewayId}/edit`)}>
<Button
onClick={() => router.push(`/gateways/${gatewayId}/edit`)}
>
Edit gateway
</Button>
) : null}
@@ -188,13 +250,17 @@ export default function GatewayDetailPage() {
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs uppercase text-slate-400">Created</p>
<p className="text-xs uppercase text-slate-400">
Created
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{formatTimestamp(gateway.created_at)}
</p>
</div>
<div>
<p className="text-xs uppercase text-slate-400">Updated</p>
<p className="text-xs uppercase text-slate-400">
Updated
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{formatTimestamp(gateway.updated_at)}
</p>
@@ -217,53 +283,38 @@ export default function GatewayDetailPage() {
</span>
)}
</div>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-3">Agent</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Last seen</th>
<th className="px-4 py-3">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{agents.length === 0 && !agentsQuery.isLoading ? (
<tr>
<td
colSpan={4}
className="px-4 py-6 text-center text-xs text-slate-500"
>
No agents assigned to this gateway.
</td>
</tr>
) : (
agents.map((agent) => (
<tr key={agent.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<p className="text-sm font-medium text-slate-900">
{agent.name}
</p>
<p className="text-xs text-slate-500">{agent.id}</p>
</td>
<td className="px-4 py-3 text-sm text-slate-700">
{agent.status}
</td>
<td className="px-4 py-3 text-xs text-slate-500">
{formatTimestamp(agent.last_seen_at ?? null)}
</td>
<td className="px-4 py-3 text-xs text-slate-500">
{formatTimestamp(agent.updated_at)}
</td>
</tr>
))
)}
</tbody>
</table>
<div className="mt-4">
<AgentsTable
agents={agents}
boards={boards}
isLoading={agentsQuery.isLoading}
onDelete={setDeleteTarget}
emptyMessage="No agents assigned to this gateway."
/>
</div>
</div>
</div>
) : null}
</DashboardPageLayout>
<ConfirmActionDialog
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) {
setDeleteTarget(null);
}
}}
ariaLabel="Delete agent"
title="Delete agent"
description={
<>
This will remove {deleteTarget?.name}. This action cannot be undone.
</>
}
errorMessage={deleteMutation.error?.message}
onConfirm={handleDelete}
isConfirming={deleteMutation.isPending}
/>
</>
);
}

View File

@@ -6,23 +6,12 @@ import { useMemo, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/auth/clerk";
import {
type ColumnDef,
type SortingState,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useQueryClient } from "@tanstack/react-query";
import { GatewaysTable } from "@/components/gateways/GatewaysTable";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button, buttonVariants } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import {
TableEmptyStateRow,
TableLoadingRow,
} from "@/components/ui/table-state";
import { ApiError } from "@/api/mutator";
import {
@@ -31,18 +20,23 @@ import {
useDeleteGatewayApiV1GatewaysGatewayIdDelete,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import { formatTimestamp, truncateText as truncate } from "@/lib/formatters";
import { createOptimisticListDeleteMutation } from "@/lib/list-delete";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import type { GatewayRead } from "@/api/generated/model";
import { useUrlSorting } from "@/lib/use-url-sorting";
const GATEWAY_SORTABLE_COLUMNS = ["name", "workspace_root", "updated_at"];
export default function GatewaysPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const { sorting, onSortingChange } = useUrlSorting({
allowedColumnIds: GATEWAY_SORTABLE_COLUMNS,
defaultSorting: [{ id: "name", desc: false }],
paramPrefix: "gateways",
});
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const [deleteTarget, setDeleteTarget] = useState<GatewayRead | null>(null);
const gatewaysKey = getListGatewaysApiV1GatewaysGetQueryKey();
@@ -64,51 +58,26 @@ export default function GatewaysPage() {
: [],
[gatewaysQuery.data],
);
const sortedGateways = useMemo(() => [...gateways], [gateways]);
const deleteMutation = useDeleteGatewayApiV1GatewaysGatewayIdDelete<
ApiError,
{ previous?: listGatewaysApiV1GatewaysGetResponse }
>(
{
mutation: {
onMutate: async ({ gatewayId }) => {
await queryClient.cancelQueries({ queryKey: gatewaysKey });
const previous =
queryClient.getQueryData<listGatewaysApiV1GatewaysGetResponse>(
gatewaysKey,
);
if (previous && previous.status === 200) {
const nextItems = previous.data.items.filter(
(gateway) => gateway.id !== gatewayId,
);
const removedCount = previous.data.items.length - nextItems.length;
queryClient.setQueryData<listGatewaysApiV1GatewaysGetResponse>(
gatewaysKey,
{
...previous,
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
},
},
);
}
return { previous };
},
onError: (_error, _gateway, context) => {
if (context?.previous) {
queryClient.setQueryData(gatewaysKey, context.previous);
}
},
mutation: createOptimisticListDeleteMutation<
GatewayRead,
listGatewaysApiV1GatewaysGetResponse,
{ gatewayId: string }
>({
queryClient,
queryKey: gatewaysKey,
getItemId: (gateway) => gateway.id,
getDeleteId: ({ gatewayId }) => gatewayId,
onSuccess: () => {
setDeleteTarget(null);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: gatewaysKey });
},
},
invalidateQueryKeys: [gatewaysKey],
}),
},
queryClient,
);
@@ -118,75 +87,6 @@ export default function GatewaysPage() {
deleteMutation.mutate({ gatewayId: deleteTarget.id });
};
const columns = useMemo<ColumnDef<GatewayRead>[]>(
() => [
{
accessorKey: "name",
header: "Gateway",
cell: ({ row }) => (
<Link href={`/gateways/${row.original.id}`} className="group block">
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
{row.original.name}
</p>
<p className="text-xs text-slate-500">
{truncate(row.original.url, 36)}
</p>
</Link>
),
},
{
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 }) => (
<span className="text-sm text-slate-700">
{formatTimestamp(row.original.updated_at)}
</span>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div className="flex justify-end gap-2">
<Link
href={`/gateways/${row.original.id}/edit`}
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Edit
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
),
},
],
[],
);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: sortedGateways,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<>
<DashboardPageLayout
@@ -214,73 +114,22 @@ export default function GatewaysPage() {
stickyHeader
>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="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="px-6 py-3">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{gatewaysQuery.isLoading ? (
<TableLoadingRow colSpan={columns.length} />
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<TableEmptyStateRow
colSpan={columns.length}
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"
<GatewaysTable
gateways={gateways}
isLoading={gatewaysQuery.isLoading}
sorting={sorting}
onSortingChange={onSortingChange}
showActions
stickyHeader
onDelete={setDeleteTarget}
emptyState={{
title: "No gateways yet",
description:
"Create your first gateway to connect boards and start managing your OpenClaw connections.",
actionHref: "/gateways/new",
actionLabel: "Create your first gateway",
}}
/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</svg>
}
title="No gateways yet"
description="Create your first gateway to connect boards and start managing your OpenClaw connections."
actionHref="/gateways/new"
actionLabel="Create your first gateway"
/>
)}
</tbody>
</table>
</div>
</div>
{gatewaysQuery.error ? (

View File

@@ -7,7 +7,7 @@ import { useRouter } from "next/navigation";
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, Copy, UserPlus, Users } from "lucide-react";
import { Building2, UserPlus, Users } from "lucide-react";
import { ApiError, customFetch } from "@/api/mutator";
import {
@@ -38,9 +38,10 @@ import type {
BoardRead,
OrganizationBoardAccessSpec,
OrganizationInviteRead,
OrganizationMemberRead,
} from "@/api/generated/model";
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
import { BoardAccessTable } from "@/components/organization/BoardAccessTable";
import { MembersInvitesTable } from "@/components/organization/MembersInvitesTable";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -62,7 +63,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { formatTimestamp } from "@/lib/formatters";
import { cn } from "@/lib/utils";
type AccessScope = "all" | "custom";
@@ -80,30 +80,8 @@ const buildAccessList = (
can_write: entry.write,
}));
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 roleBadgeVariant = (role: string) => {
if (role === "admin" || role === "owner") return "accent" as const;
return "outline" as const;
};
const defaultBoardAccess: BoardAccessState = {};
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();
};
function BoardAccessEditor({
boards,
scope,
@@ -246,56 +224,13 @@ function BoardAccessEditor({
</div>
) : (
<div className="overflow-hidden rounded-xl border border-slate-200">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-2 text-left font-medium">Board</th>
<th className="px-4 py-2 text-center font-medium">Read</th>
<th className="px-4 py-2 text-center font-medium">Write</th>
</tr>
</thead>
<tbody>
{boards.map((board) => {
const entry = access[board.id] ?? {
read: false,
write: false,
};
return (
<tr
key={board.id}
className="border-t border-slate-200 hover:bg-slate-50"
>
<td className="px-4 py-3">
<div className="text-sm font-semibold text-slate-900">
{board.name}
</div>
<div className="text-xs text-slate-500">
{board.slug}
</div>
</td>
<td className="px-4 py-3 text-center">
<input
type="checkbox"
className="h-4 w-4"
checked={entry.read}
onChange={() => handleBoardReadToggle(board.id)}
<BoardAccessTable
boards={boards}
access={access}
onToggleRead={handleBoardReadToggle}
onToggleWrite={handleBoardWriteToggle}
disabled={disabled}
/>
</td>
<td className="px-4 py-3 text-center">
<input
type="checkbox"
className="h-4 w-4"
checked={entry.write}
onChange={() => handleBoardWriteToggle(board.id)}
disabled={disabled}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
@@ -756,23 +691,6 @@ export default function OrganizationPage() {
removeMemberMutation.mutate({ memberId: activeMemberId });
};
const memberAccessSummary = (member: OrganizationMemberRead) =>
summarizeAccess(member.all_boards_read, member.all_boards_write);
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),
};
};
return (
<DashboardShell>
<SignedOut>
@@ -874,178 +792,24 @@ export default function OrganizationPage() {
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wide text-slate-500">
<tr>
<th className="px-5 py-3 text-left font-medium">
Member
</th>
<th className="px-5 py-3 text-left font-medium">
Status
</th>
<th className="px-5 py-3 text-left font-medium">
Access
</th>
<th className="px-5 py-3 text-right font-medium">
Actions
</th>
</tr>
</thead>
<tbody>
{membersQuery.isLoading ? (
<tr>
<td
colSpan={4}
className="px-5 py-6 text-center text-sm text-slate-500"
>
Loading members...
</td>
</tr>
) : null}
{members.map((member) => {
const display = memberDisplay(member);
return (
<tr
key={member.id}
className="border-t border-slate-200 hover:bg-slate-50"
>
<td className="px-5 py-4">
<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>
</td>
<td className="px-5 py-4">
<Badge variant={roleBadgeVariant(member.role)}>
{member.role}
</Badge>
</td>
<td className="px-5 py-4 text-slate-600">
{memberAccessSummary(member)}
</td>
<td className="px-5 py-4 text-right">
{isAdmin ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => openAccessDialog(member.id)}
>
Manage access
</Button>
) : (
<span className="text-xs text-slate-400">
Admin only
</span>
)}
</td>
</tr>
);
})}
{isAdmin && invitesQuery.isLoading ? (
<tr>
<td
colSpan={4}
className="px-5 py-6 text-center text-sm text-slate-500"
>
Loading invites...
</td>
</tr>
) : null}
{isAdmin
? invites.map((invite) => (
<tr
key={invite.id}
className="border-t border-slate-200 bg-slate-50/60"
>
<td className="px-5 py-4">
<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(invite.invited_email)}
</div>
<div>
<div className="text-sm font-semibold text-slate-900">
{invite.invited_email}
</div>
<div className="text-xs text-slate-500">
Invited {formatTimestamp(invite.created_at)}
</div>
</div>
</div>
</td>
<td className="px-5 py-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="warning">Pending</Badge>
<Badge variant={roleBadgeVariant(invite.role)}>
{invite.role}
</Badge>
</div>
</td>
<td className="px-5 py-4 text-slate-600">
{summarizeAccess(
invite.all_boards_read,
invite.all_boards_write,
)}
</td>
<td className="px-5 py-4 text-right">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleCopyInvite(invite)}
>
<Copy className="h-4 w-4" />
{copiedInviteId === invite.id
? "Copied"
: "Copy link"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
<MembersInvitesTable
members={members}
invites={isAdmin ? invites : []}
isLoading={
membersQuery.isLoading ||
(isAdmin && invitesQuery.isLoading)
}
isAdmin={isAdmin}
copiedInviteId={copiedInviteId}
onManageAccess={openAccessDialog}
onCopyInvite={handleCopyInvite}
onRevokeInvite={(inviteId) =>
revokeInviteMutation.mutate({
inviteId: invite.id,
inviteId,
})
}
disabled={revokeInviteMutation.isPending}
>
Revoke
</Button>
</div>
</td>
</tr>
))
: null}
{!membersQuery.isLoading &&
(!isAdmin || !invitesQuery.isLoading) &&
members.length === 0 &&
(!isAdmin || invites.length === 0) ? (
<tr>
<td
colSpan={4}
className="px-5 py-6 text-center text-sm text-slate-500"
>
No members or invites yet.
</td>
</tr>
) : null}
</tbody>
</table>
isRevoking={revokeInviteMutation.isPending}
/>
</div>
</div>
</div>

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

View File

@@ -0,0 +1,79 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { createOptimisticListDeleteMutation } from "./list-delete";
type Item = { id: string; label: string };
type Response = {
status: number;
data: {
items: Item[];
total: number;
};
};
describe("createOptimisticListDeleteMutation", () => {
it("optimistically removes an item and restores on error", async () => {
const queryClient = new QueryClient();
const key = ["items"];
const previous: Response = {
status: 200,
data: {
items: [
{ id: "a", label: "A" },
{ id: "b", label: "B" },
],
total: 2,
},
};
queryClient.setQueryData(key, previous);
const callbacks = createOptimisticListDeleteMutation<
Item,
Response,
{ id: string }
>({
queryClient,
queryKey: key,
getItemId: (item) => item.id,
getDeleteId: ({ id }) => id,
});
const context = await callbacks.onMutate({ id: "a" });
const updated = queryClient.getQueryData<Response>(key);
expect(updated?.data.items.map((item) => item.id)).toEqual(["b"]);
expect(updated?.data.total).toBe(1);
callbacks.onError(new Error("boom"), { id: "a" }, context);
expect(queryClient.getQueryData<Response>(key)).toEqual(previous);
});
it("runs success callback and invalidates configured query keys", async () => {
const queryClient = new QueryClient();
const keyA = ["items"];
const keyB = ["boards"];
const onSuccess = vi.fn();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const callbacks = createOptimisticListDeleteMutation<
Item,
Response,
{ id: string }
>({
queryClient,
queryKey: keyA,
getItemId: (item) => item.id,
getDeleteId: ({ id }) => id,
onSuccess,
invalidateQueryKeys: [keyA, keyB],
});
callbacks.onSuccess();
callbacks.onSettled();
expect(onSuccess).toHaveBeenCalledTimes(1);
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: keyA });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: keyB });
});
});

View File

@@ -0,0 +1,101 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
type ListPayload<TItem> = {
items: TItem[];
total: number;
};
export type OptimisticListDeleteContext<TResponse> = {
previous?: TResponse;
};
type CreateOptimisticListDeleteMutationOptions<TItem, TVariables> = {
queryClient: QueryClient;
queryKey: QueryKey;
getItemId: (item: TItem) => string;
getDeleteId: (variables: TVariables) => string;
onSuccess?: () => void;
invalidateQueryKeys?: QueryKey[];
};
function isListPayload<TItem>(value: unknown): value is ListPayload<TItem> {
if (!value || typeof value !== "object") {
return false;
}
const maybe = value as { items?: unknown; total?: unknown };
return Array.isArray(maybe.items) && typeof maybe.total === "number";
}
function getListPayload<TItem>(response: unknown): ListPayload<TItem> | null {
if (!response || typeof response !== "object") {
return null;
}
const data = (response as { data?: unknown }).data;
return isListPayload<TItem>(data) ? data : null;
}
export function createOptimisticListDeleteMutation<
TItem,
TResponse extends { status: number },
TVariables,
>({
queryClient,
queryKey,
getItemId,
getDeleteId,
onSuccess,
invalidateQueryKeys,
}: CreateOptimisticListDeleteMutationOptions<TItem, TVariables>) {
const keysToInvalidate =
invalidateQueryKeys && invalidateQueryKeys.length > 0
? invalidateQueryKeys
: [queryKey];
return {
onMutate: async (
variables: TVariables,
): Promise<OptimisticListDeleteContext<TResponse>> => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TResponse>(queryKey);
if (previous && previous.status === 200) {
const payload = getListPayload<TItem>(previous);
if (!payload) {
return { previous };
}
const deleteId = getDeleteId(variables);
const nextItems = payload.items.filter(
(item) => getItemId(item) !== deleteId,
);
const removedCount = payload.items.length - nextItems.length;
queryClient.setQueryData<TResponse>(queryKey, {
...(previous as object),
data: {
...payload,
items: nextItems,
total: Math.max(0, payload.total - removedCount),
},
} as unknown as TResponse);
}
return { previous };
},
onError: (
_error: unknown,
_variables: TVariables,
context?: OptimisticListDeleteContext<TResponse>,
) => {
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
},
onSuccess: () => {
onSuccess?.();
},
onSettled: () => {
for (const key of keysToInvalidate) {
queryClient.invalidateQueries({ queryKey: key });
}
},
};
}

View File

@@ -0,0 +1,119 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { useUrlSorting } from "./use-url-sorting";
const replaceMock = vi.fn();
let mockPathname = "/agents";
vi.mock("next/navigation", () => ({
useRouter: () => ({
replace: replaceMock,
}),
usePathname: () => mockPathname,
}));
describe("useUrlSorting", () => {
beforeEach(() => {
replaceMock.mockReset();
mockPathname = "/agents";
window.history.replaceState({}, "", "/agents");
});
it("uses default sorting when no params are present", () => {
const { result } = renderHook(() =>
useUrlSorting({
allowedColumnIds: ["name", "status"],
defaultSorting: [{ id: "name", desc: false }],
paramPrefix: "agents",
}),
);
expect(result.current.sorting).toEqual([{ id: "name", desc: false }]);
});
it("reads sorting from URL params", () => {
window.history.replaceState(
{},
"",
"/agents?agents_sort=status&agents_dir=desc",
);
const { result } = renderHook(() =>
useUrlSorting({
allowedColumnIds: ["name", "status"],
defaultSorting: [{ id: "name", desc: false }],
paramPrefix: "agents",
}),
);
expect(result.current.sorting).toEqual([{ id: "status", desc: true }]);
});
it("writes updated sorting to URL and preserves unrelated params", () => {
window.history.replaceState({}, "", "/agents?foo=1");
const { result } = renderHook(() =>
useUrlSorting({
allowedColumnIds: ["name", "status"],
defaultSorting: [{ id: "name", desc: false }],
paramPrefix: "agents",
}),
);
act(() => {
result.current.onSortingChange([{ id: "status", desc: true }]);
});
expect(replaceMock).toHaveBeenCalledWith(
"/agents?foo=1&agents_sort=status&agents_dir=desc",
{
scroll: false,
},
);
});
it("removes sorting params when returning to default sorting", () => {
window.history.replaceState(
{},
"",
"/agents?foo=1&agents_sort=status&agents_dir=desc",
);
const { result } = renderHook(() =>
useUrlSorting({
allowedColumnIds: ["name", "status"],
defaultSorting: [{ id: "name", desc: false }],
paramPrefix: "agents",
}),
);
act(() => {
result.current.onSortingChange([{ id: "name", desc: false }]);
});
expect(replaceMock).toHaveBeenCalledWith("/agents?foo=1", {
scroll: false,
});
});
it("supports explicit no-sorting state via sentinel", () => {
window.history.replaceState({}, "", "/agents?agents_sort=none");
const { result } = renderHook(() =>
useUrlSorting({
allowedColumnIds: ["name", "status"],
defaultSorting: [{ id: "name", desc: false }],
paramPrefix: "agents",
}),
);
expect(result.current.sorting).toEqual([]);
act(() => {
result.current.onSortingChange([]);
});
expect(replaceMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,157 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import {
type OnChangeFn,
type SortingState,
functionalUpdate,
} from "@tanstack/react-table";
const SORT_NONE_SENTINEL = "none";
type UseUrlSortingOptions = {
allowedColumnIds: string[];
defaultSorting?: SortingState;
paramPrefix?: string;
};
type UseUrlSortingResult = {
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
};
const resolveSortParam = (paramPrefix?: string) =>
paramPrefix ? `${paramPrefix}_sort` : "sort";
const resolveDirectionParam = (paramPrefix?: string) =>
paramPrefix ? `${paramPrefix}_dir` : "dir";
const normalizeSorting = (
value: SortingState,
allowedColumnIds: Set<string>,
): SortingState => {
for (const sort of value) {
if (!allowedColumnIds.has(sort.id)) continue;
return [{ id: sort.id, desc: Boolean(sort.desc) }];
}
return [];
};
const isSameSorting = (a: SortingState, b: SortingState) => {
if (a.length !== b.length) return false;
if (!a.length) return true;
return a[0]?.id === b[0]?.id && Boolean(a[0]?.desc) === Boolean(b[0]?.desc);
};
export function useUrlSorting({
allowedColumnIds,
defaultSorting = [],
paramPrefix,
}: UseUrlSortingOptions): UseUrlSortingResult {
const router = useRouter();
const pathname = usePathname();
const [searchParamsString, setSearchParamsString] = useState(() => {
if (typeof window === "undefined") {
return "";
}
return window.location.search.replace(/^\?/, "");
});
const allowedSet = useMemo(
() => new Set(allowedColumnIds),
[allowedColumnIds],
);
const normalizedDefaultSorting = useMemo(
() => normalizeSorting(defaultSorting, allowedSet),
[defaultSorting, allowedSet],
);
const sortParam = resolveSortParam(paramPrefix);
const directionParam = resolveDirectionParam(paramPrefix);
useEffect(() => {
const syncFromLocation = () => {
setSearchParamsString(window.location.search.replace(/^\?/, ""));
};
syncFromLocation();
window.addEventListener("popstate", syncFromLocation);
return () => {
window.removeEventListener("popstate", syncFromLocation);
};
}, [pathname]);
const sorting = useMemo(() => {
const searchParams = new URLSearchParams(searchParamsString);
const sortValue = searchParams.get(sortParam);
if (!sortValue) {
return normalizedDefaultSorting;
}
if (sortValue === SORT_NONE_SENTINEL) {
return [];
}
if (!allowedSet.has(sortValue)) {
return normalizedDefaultSorting;
}
return [
{
id: sortValue,
desc: searchParams.get(directionParam) === "desc",
},
];
}, [
allowedSet,
directionParam,
normalizedDefaultSorting,
searchParamsString,
sortParam,
]);
const onSortingChange = useCallback<OnChangeFn<SortingState>>(
(updater) => {
const nextSorting = normalizeSorting(
functionalUpdate(updater, sorting),
allowedSet,
);
if (isSameSorting(nextSorting, sorting)) {
return;
}
const nextParams = new URLSearchParams(searchParamsString);
if (nextSorting.length === 0) {
nextParams.set(sortParam, SORT_NONE_SENTINEL);
nextParams.delete(directionParam);
} else if (isSameSorting(nextSorting, normalizedDefaultSorting)) {
nextParams.delete(sortParam);
nextParams.delete(directionParam);
} else {
const [primary] = nextSorting;
nextParams.set(sortParam, primary.id);
nextParams.set(directionParam, primary.desc ? "desc" : "asc");
}
const query = nextParams.toString();
setSearchParamsString(query);
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
},
[
allowedSet,
directionParam,
normalizedDefaultSorting,
pathname,
router,
searchParamsString,
sortParam,
sorting,
],
);
return { sorting, onSortingChange };
}