From 18d958b3e32cabb855b0e9b464f751a1140d56b7 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 11:41:51 +0530 Subject: [PATCH] feat: add cell formatters and tables for boards, agents, and member invites --- frontend/src/app/agents/page.tsx | 266 +++---------- frontend/src/app/board-groups/page.tsx | 215 ++-------- frontend/src/app/boards/page.tsx | 240 ++---------- .../src/app/gateways/[gatewayId]/page.tsx | 369 ++++++++++-------- frontend/src/app/gateways/page.tsx | 227 ++--------- frontend/src/app/organization/page.tsx | 292 ++------------ .../components/agents/AgentsTable.test.tsx | 145 +++++++ .../src/components/agents/AgentsTable.tsx | 204 ++++++++++ .../board-groups/BoardGroupsTable.tsx | 155 ++++++++ .../src/components/boards/BoardsTable.tsx | 187 +++++++++ .../src/components/gateways/GatewaysTable.tsx | 160 ++++++++ .../organization/BoardAccessTable.tsx | 106 +++++ .../organization/MembersInvitesTable.tsx | 254 ++++++++++++ .../src/components/tables/DataTable.test.tsx | 168 ++++++++ frontend/src/components/tables/DataTable.tsx | 219 +++++++++++ .../tables/cell-formatters.test.tsx | 74 ++++ .../src/components/tables/cell-formatters.tsx | 89 +++++ frontend/src/lib/list-delete.test.ts | 79 ++++ frontend/src/lib/list-delete.ts | 101 +++++ frontend/src/lib/use-url-sorting.test.tsx | 119 ++++++ frontend/src/lib/use-url-sorting.ts | 157 ++++++++ 21 files changed, 2618 insertions(+), 1208 deletions(-) create mode 100644 frontend/src/components/agents/AgentsTable.test.tsx create mode 100644 frontend/src/components/agents/AgentsTable.tsx create mode 100644 frontend/src/components/board-groups/BoardGroupsTable.tsx create mode 100644 frontend/src/components/boards/BoardsTable.tsx create mode 100644 frontend/src/components/gateways/GatewaysTable.tsx create mode 100644 frontend/src/components/organization/BoardAccessTable.tsx create mode 100644 frontend/src/components/organization/MembersInvitesTable.tsx create mode 100644 frontend/src/components/tables/DataTable.test.tsx create mode 100644 frontend/src/components/tables/DataTable.tsx create mode 100644 frontend/src/components/tables/cell-formatters.test.tsx create mode 100644 frontend/src/components/tables/cell-formatters.tsx create mode 100644 frontend/src/lib/list-delete.test.ts create mode 100644 frontend/src/lib/list-delete.ts create mode 100644 frontend/src/lib/use-url-sorting.test.tsx create mode 100644 frontend/src/lib/use-url-sorting.ts diff --git a/frontend/src/app/agents/page.tsx b/frontend/src/app/agents/page.tsx index 47ff24c4..fdffc915 100644 --- a/frontend/src/app/agents/page.tsx +++ b/frontend/src/app/agents/page.tsx @@ -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([ - { id: "name", desc: false }, - ]); + const { sorting, onSortingChange } = useUrlSorting({ + allowedColumnIds: AGENT_SORTABLE_COLUMNS, + defaultSorting: [{ id: "name", desc: false }], + paramPrefix: "agents", + }); const [deleteTarget, setDeleteTarget] = useState(null); @@ -104,150 +98,29 @@ export default function AgentsPage() { { previous?: listAgentsApiV1AgentsGetResponse } >( { - mutation: { - onMutate: async ({ agentId }) => { - await queryClient.cancelQueries({ queryKey: agentsKey }); - const previous = - queryClient.getQueryData( - 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( - 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[]>(() => { - const resolveBoardName = (agent: AgentRead) => - boards.find((board) => board.id === agent.board_id)?.name ?? "—"; - - return [ - { - accessorKey: "name", - header: "Agent", - cell: ({ row }) => ( - -

- {row.original.name} -

-

ID {row.original.id}

- - ), - }, - { - accessorKey: "status", - header: "Status", - cell: ({ row }) => ( - - ), - }, - { - accessorKey: "openclaw_session_id", - header: "Session", - cell: ({ row }) => ( - - {truncate(row.original.openclaw_session_id)} - - ), - }, - { - accessorKey: "board_id", - header: "Board", - cell: ({ row }) => ( - - {resolveBoardName(row.original)} - - ), - }, - { - accessorKey: "last_seen_at", - header: "Last seen", - cell: ({ row }) => ( - - {formatRelative(row.original.last_seen_at)} - - ), - }, - { - accessorKey: "updated_at", - header: "Updated", - cell: ({ row }) => ( - - {formatTimestamp(row.original.updated_at)} - - ), - }, - { - id: "actions", - header: "", - cell: ({ row }) => ( -
- - Edit - - -
- ), - }, - ]; - }, [boards]); - - // eslint-disable-next-line react-hooks/incompatible-library - const table = useReactTable({ - data: sortedAgents, - columns, - state: { sorting }, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - }); - return ( <>
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {agentsQuery.isLoading ? ( - - ) : table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - )) - ) : ( - - - - - - - } - title="No agents yet" - description="Create your first agent to start executing tasks on this board." - actionHref="/agents/new" - actionLabel="Create your first agent" - /> - )} - -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
- {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} -
-
+
{agentsQuery.error ? ( diff --git a/frontend/src/app/board-groups/page.tsx b/frontend/src/app/board-groups/page.tsx index 6739c285..c05f8937 100644 --- a/frontend/src/app/board-groups/page.tsx +++ b/frontend/src/app/board-groups/page.tsx @@ -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(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( - 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( - 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[]>( - () => [ - { - accessorKey: "name", - header: "Group", - cell: ({ row }) => ( - -

- {row.original.name} -

- {row.original.description ? ( -

- {row.original.description} -

- ) : ( -

No description

- )} - - ), - }, - { - accessorKey: "updated_at", - header: "Updated", - cell: ({ row }) => ( - - {formatTimestamp(row.original.updated_at)} - - ), - }, - { - id: "actions", - header: "", - cell: ({ row }) => ( -
- - Edit - - -
- ), - }, - ], - [], - ); - - // eslint-disable-next-line react-hooks/incompatible-library - const table = useReactTable({ - data: groups, - columns, - getCoreRowModel: getCoreRowModel(), - }); - return ( <>
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {groupsQuery.isLoading ? ( - - ) : table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - )) - ) : ( - - - - - - - - } - 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" - /> - )} - -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
- {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} -
-
+
{groupsQuery.error ? ( diff --git a/frontend/src/app/boards/page.tsx b/frontend/src/app/boards/page.tsx index d9e8d237..a01bba39 100644 --- a/frontend/src/app/boards/page.tsx +++ b/frontend/src/app/boards/page.tsx @@ -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(null); @@ -80,62 +76,30 @@ export default function BoardsPage() { [boardsQuery.data], ); - const groups = useMemo(() => { + const groups = useMemo(() => { if (groupsQuery.data?.status !== 200) return []; return groupsQuery.data.data.items ?? []; }, [groupsQuery.data]); - const groupById = useMemo(() => { - const map = new Map(); - 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( - 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( - 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[]>( - () => [ - { - accessorKey: "name", - header: "Board", - cell: ({ row }) => ( - -

- {row.original.name} -

- - ), - }, - { - id: "group", - header: "Group", - cell: ({ row }) => { - const groupId = row.original.board_group_id; - if (!groupId) { - return ; - } - const group = groupById.get(groupId); - const label = group?.name ?? compactId(groupId); - const title = group?.name ?? groupId; - return ( - - {label} - - ); - }, - }, - { - accessorKey: "updated_at", - header: "Updated", - cell: ({ row }) => ( - - {formatTimestamp(row.original.updated_at)} - - ), - }, - { - id: "actions", - header: "", - cell: ({ row }) => ( -
- - Edit - - -
- ), - }, - ], - [groupById], - ); - - // eslint-disable-next-line react-hooks/incompatible-library - const table = useReactTable({ - data: boards, - columns, - getCoreRowModel: getCoreRowModel(), - }); - return ( <>
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {boardsQuery.isLoading ? ( - - ) : table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - )) - ) : ( - - - - - - - } - 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" - /> - )} - -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
- {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} -
-
+
{boardsQuery.error ? ( diff --git a/frontend/src/app/gateways/[gatewayId]/page.tsx b/frontend/src/app/gateways/[gatewayId]/page.tsx index dcc7701b..b3f4a94e 100644 --- a/frontend/src/app/gateways/[gatewayId]/page.tsx +++ b/frontend/src/app/gateways/[gatewayId]/page.tsx @@ -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(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,174 +151,170 @@ export default function GatewayDetailPage() { () => (gateway?.name ? gateway.name : "Gateway"), [gateway?.name], ); + const handleDelete = () => { + if (!deleteTarget) return; + deleteMutation.mutate({ agentId: deleteTarget.id }); + }; return ( - - - {isAdmin && gatewayId ? ( - - ) : null} - - } - isAdmin={isAdmin} - adminOnlyMessage="Only organization owners and admins can access gateways." - > - {gatewayQuery.isLoading ? ( -
- Loading gateway… -
- ) : gatewayQuery.error ? ( -
- {gatewayQuery.error.message} -
- ) : gateway ? ( -
-
+ {isAdmin && gatewayId ? ( + + ) : null} +
+ } + isAdmin={isAdmin} + adminOnlyMessage="Only organization owners and admins can access gateways." + > + {gatewayQuery.isLoading ? ( +
+ Loading gateway… +
+ ) : gatewayQuery.error ? ( +
+ {gatewayQuery.error.message} +
+ ) : gateway ? ( +
+
+
+
+

+ Connection +

+
+ + + {statusQuery.isLoading + ? "Checking" + : isConnected + ? "Online" + : "Offline"} + +
+
+
+
+

+ Gateway URL +

+

+ {gateway.url} +

+
+
+

Token

+

+ {maskToken(gateway.token)} +

+
+
+
+ +
+

+ Runtime +

+
+
+

+ Workspace root +

+

+ {gateway.workspace_root} +

+
+
+
+

+ Created +

+

+ {formatTimestamp(gateway.created_at)} +

+
+
+

+ Updated +

+

+ {formatTimestamp(gateway.updated_at)} +

+
+
+
+
+
+

- Connection + Agents

-
- - - {statusQuery.isLoading - ? "Checking" - : isConnected - ? "Online" - : "Offline"} + {agentsQuery.isLoading ? ( + Loading… + ) : ( + + {agents.length} total -
+ )}
-
-
-

- Gateway URL -

-

- {gateway.url} -

-
-
-

Token

-

- {maskToken(gateway.token)} -

-
-
-
- -
-

- Runtime -

-
-
-

- Workspace root -

-

- {gateway.workspace_root} -

-
-
-
-

Created

-

- {formatTimestamp(gateway.created_at)} -

-
-
-

Updated

-

- {formatTimestamp(gateway.updated_at)} -

-
-
+
+
+ ) : null} + -
-
-

- Agents -

- {agentsQuery.isLoading ? ( - Loading… - ) : ( - - {agents.length} total - - )} -
-
- - - - - - - - - - - {agents.length === 0 && !agentsQuery.isLoading ? ( - - - - ) : ( - agents.map((agent) => ( - - - - - - - )) - )} - -
AgentStatusLast seenUpdated
- No agents assigned to this gateway. -
-

- {agent.name} -

-

{agent.id}

-
- {agent.status} - - {formatTimestamp(agent.last_seen_at ?? null)} - - {formatTimestamp(agent.updated_at)} -
-
-
-
- ) : null} - + { + 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} + /> + ); } diff --git a/frontend/src/app/gateways/page.tsx b/frontend/src/app/gateways/page.tsx index ca8513e5..c46789dd 100644 --- a/frontend/src/app/gateways/page.tsx +++ b/frontend/src/app/gateways/page.tsx @@ -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([ - { id: "name", desc: false }, - ]); const [deleteTarget, setDeleteTarget] = useState(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( - 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( - 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[]>( - () => [ - { - accessorKey: "name", - header: "Gateway", - cell: ({ row }) => ( - -

- {row.original.name} -

-

- {truncate(row.original.url, 36)} -

- - ), - }, - { - accessorKey: "workspace_root", - header: "Workspace root", - cell: ({ row }) => ( - - {truncate(row.original.workspace_root, 28)} - - ), - }, - { - accessorKey: "updated_at", - header: "Updated", - cell: ({ row }) => ( - - {formatTimestamp(row.original.updated_at)} - - ), - }, - { - id: "actions", - header: "", - cell: ({ row }) => ( -
- - Edit - - -
- ), - }, - ], - [], - ); - - // eslint-disable-next-line react-hooks/incompatible-library - const table = useReactTable({ - data: sortedGateways, - columns, - state: { sorting }, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - }); - return ( <>
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {gatewaysQuery.isLoading ? ( - - ) : table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - )) - ) : ( - - - - - } - 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" - /> - )} - -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
- {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} -
-
+
{gatewaysQuery.error ? ( diff --git a/frontend/src/app/organization/page.tsx b/frontend/src/app/organization/page.tsx index 194df025..23e2e420 100644 --- a/frontend/src/app/organization/page.tsx +++ b/frontend/src/app/organization/page.tsx @@ -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({
) : (
- - - - - - - - - - {boards.map((board) => { - const entry = access[board.id] ?? { - read: false, - write: false, - }; - return ( - - - - - - ); - })} - -
BoardReadWrite
-
- {board.name} -
-
- {board.slug} -
-
- handleBoardReadToggle(board.id)} - disabled={disabled} - /> - - handleBoardWriteToggle(board.id)} - disabled={disabled} - /> -
+
)} @@ -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 ( @@ -874,178 +792,24 @@ export default function OrganizationPage() {
- - - - - - - - - - - {membersQuery.isLoading ? ( - - - - ) : null} - - {members.map((member) => { - const display = memberDisplay(member); - return ( - - - - - - - ); - })} - - {isAdmin && invitesQuery.isLoading ? ( - - - - ) : null} - - {isAdmin - ? invites.map((invite) => ( - - - - - - - )) - : null} - - {!membersQuery.isLoading && - (!isAdmin || !invitesQuery.isLoading) && - members.length === 0 && - (!isAdmin || invites.length === 0) ? ( - - - - ) : null} - -
- Member - - Status - - Access - - Actions -
- Loading members... -
-
-
- {display.initials} -
-
-
- {display.primary} -
-
- {display.secondary} -
-
-
-
- - {member.role} - - - {memberAccessSummary(member)} - - {isAdmin ? ( - - ) : ( - - Admin only - - )} -
- Loading invites... -
-
-
- {initialsFrom(invite.invited_email)} -
-
-
- {invite.invited_email} -
-
- Invited {formatTimestamp(invite.created_at)} -
-
-
-
-
- Pending - - {invite.role} - -
-
- {summarizeAccess( - invite.all_boards_read, - invite.all_boards_write, - )} - -
- - -
-
- No members or invites yet. -
+ + revokeInviteMutation.mutate({ + inviteId, + }) + } + isRevoking={revokeInviteMutation.isPending} + />
diff --git a/frontend/src/components/agents/AgentsTable.test.tsx b/frontend/src/components/agents/AgentsTable.test.tsx new file mode 100644 index 00000000..2e06d3db --- /dev/null +++ b/frontend/src/components/agents/AgentsTable.test.tsx @@ -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, "href">; + + return { + default: ({ href, children, ...props }: LinkProps) => ( + + {children} + + ), + }; +}); + +const buildAgent = (overrides: Partial = {}): 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 => ({ + 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( + , + ); + + 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( + , + ); + + expect( + screen.queryByRole("link", { name: "Edit" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Delete" }), + ).not.toBeInTheDocument(); + }); + + it("supports hiddenColumns and columnOrder", () => { + render( + , + ); + + 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( + , + ); + + // Default behavior applies name sorting. + expect(screen.getAllByRole("row")[1]).toHaveTextContent("Alpha"); + + rerender( + , + ); + + // disableSorting keeps incoming data order. + expect(screen.getAllByRole("row")[1]).toHaveTextContent("Zulu"); + }); +}); diff --git a/frontend/src/components/agents/AgentsTable.tsx b/frontend/src/components/agents/AgentsTable.tsx new file mode 100644 index 00000000..9123ad8e --- /dev/null +++ b/frontend/src/components/agents/AgentsTable.tsx @@ -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; + showActions?: boolean; + hiddenColumns?: string[]; + columnOrder?: string[]; + disableSorting?: boolean; + stickyHeader?: boolean; + emptyMessage?: string; + emptyState?: AgentsTableEmptyState; + onDelete?: (agent: AgentRead) => void; +}; + +const DEFAULT_EMPTY_ICON = ( + + + + + + +); + +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([ + { id: "name", desc: false }, + ]); + const resolvedSorting = sorting ?? internalSorting; + const handleSortingChange: OnChangeFn = + onSortingChange ?? + ((updater: Updater) => { + setInternalSorting(updater); + }); + + const sortedAgents = useMemo(() => [...agents], [agents]); + const columnVisibility = useMemo( + () => + Object.fromEntries( + (hiddenColumns ?? []).map((columnId) => [columnId, false]), + ), + [hiddenColumns], + ); + const boardNameById = useMemo( + () => new Map(boards.map((board) => [board.id, board.name])), + [boards], + ); + + const columns = useMemo[]>(() => { + const baseColumns: ColumnDef[] = [ + { + 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 }) => ( + + {truncate(row.original.openclaw_session_id)} + + ), + }, + { + accessorKey: "board_id", + header: "Board", + cell: ({ row }) => { + const boardId = row.original.board_id; + if (!boardId) { + return ; + } + 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 ( + `/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 + } + /> + ); +} diff --git a/frontend/src/components/board-groups/BoardGroupsTable.tsx b/frontend/src/components/board-groups/BoardGroupsTable.tsx new file mode 100644 index 00000000..40a0b90e --- /dev/null +++ b/frontend/src/components/board-groups/BoardGroupsTable.tsx @@ -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; + stickyHeader?: boolean; + showActions?: boolean; + hiddenColumns?: string[]; + columnOrder?: string[]; + disableSorting?: boolean; + onDelete?: (group: BoardGroupRead) => void; + emptyMessage?: string; + emptyState?: Omit & { + icon?: DataTableEmptyState["icon"]; + }; +}; + +const DEFAULT_EMPTY_ICON = ( + + + + + + + +); + +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([ + { id: "name", desc: false }, + ]); + const resolvedSorting = sorting ?? internalSorting; + const handleSortingChange: OnChangeFn = + onSortingChange ?? + ((updater: Updater) => { + setInternalSorting(updater); + }); + const columnVisibility = useMemo( + () => + Object.fromEntries( + (hiddenColumns ?? []).map((columnId) => [columnId, false]), + ), + [hiddenColumns], + ); + const columns = useMemo[]>(() => { + const baseColumns: ColumnDef[] = [ + { + 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 ( + `/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 + } + /> + ); +} diff --git a/frontend/src/components/boards/BoardsTable.tsx b/frontend/src/components/boards/BoardsTable.tsx new file mode 100644 index 00000000..c57e1ddb --- /dev/null +++ b/frontend/src/components/boards/BoardsTable.tsx @@ -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; + stickyHeader?: boolean; + showActions?: boolean; + hiddenColumns?: string[]; + columnOrder?: string[]; + disableSorting?: boolean; + onDelete?: (board: BoardRead) => void; + emptyMessage?: string; + emptyState?: Omit & { + icon?: DataTableEmptyState["icon"]; + }; +}; + +const DEFAULT_EMPTY_ICON = ( + + + + + + +); + +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([ + { id: "name", desc: false }, + ]); + const resolvedSorting = sorting ?? internalSorting; + const handleSortingChange: OnChangeFn = + onSortingChange ?? + ((updater: Updater) => { + setInternalSorting(updater); + }); + const columnVisibility = useMemo( + () => + Object.fromEntries( + (hiddenColumns ?? []).map((columnId) => [columnId, false]), + ), + [hiddenColumns], + ); + const groupById = useMemo(() => { + const map = new Map(); + for (const group of boardGroups) { + map.set(group.id, group); + } + return map; + }, [boardGroups]); + + const columns = useMemo[]>(() => { + const baseColumns: ColumnDef[] = [ + { + 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 ; + } + 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 ( + `/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 + } + /> + ); +} diff --git a/frontend/src/components/gateways/GatewaysTable.tsx b/frontend/src/components/gateways/GatewaysTable.tsx new file mode 100644 index 00000000..e2a4abd2 --- /dev/null +++ b/frontend/src/components/gateways/GatewaysTable.tsx @@ -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; + stickyHeader?: boolean; + showActions?: boolean; + hiddenColumns?: string[]; + columnOrder?: string[]; + disableSorting?: boolean; + onDelete?: (gateway: GatewayRead) => void; + emptyMessage?: string; + emptyState?: Omit & { + icon?: DataTableEmptyState["icon"]; + }; +}; + +const DEFAULT_EMPTY_ICON = ( + + + + +); + +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([ + { id: "name", desc: false }, + ]); + const resolvedSorting = sorting ?? internalSorting; + const handleSortingChange: OnChangeFn = + onSortingChange ?? + ((updater: Updater) => { + setInternalSorting(updater); + }); + + const sortedGateways = useMemo(() => [...gateways], [gateways]); + const columnVisibility = useMemo( + () => + Object.fromEntries( + (hiddenColumns ?? []).map((columnId) => [columnId, false]), + ), + [hiddenColumns], + ); + + const columns = useMemo[]>(() => { + const baseColumns: ColumnDef[] = [ + { + 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 }) => ( + + {truncate(row.original.workspace_root, 28)} + + ), + }, + { + 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 ( + `/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 + } + /> + ); +} diff --git a/frontend/src/components/organization/BoardAccessTable.tsx b/frontend/src/components/organization/BoardAccessTable.tsx new file mode 100644 index 00000000..6b74f8f5 --- /dev/null +++ b/frontend/src/components/organization/BoardAccessTable.tsx @@ -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; + +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[]>( + () => [ + { + 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 ( +
+ onToggleRead(row.original.id)} + disabled={disabled} + /> +
+ ); + }, + }, + { + id: "write", + header: "Write", + cell: ({ row }) => { + const entry = access[row.original.id] ?? { + read: false, + write: false, + }; + return ( +
+ onToggleWrite(row.original.id)} + disabled={disabled} + /> +
+ ); + }, + }, + ], + [access, disabled, onToggleRead, onToggleWrite], + ); + + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data: boards, + columns, + enableSorting: false, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + + ); +} diff --git a/frontend/src/components/organization/MembersInvitesTable.tsx b/frontend/src/components/organization/MembersInvitesTable.tsx new file mode 100644 index 00000000..82e64f30 --- /dev/null +++ b/frontend/src/components/organization/MembersInvitesTable.tsx @@ -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( + () => [ + ...members.map((member) => ({ kind: "member" as const, member })), + ...invites.map((invite) => ({ kind: "invite" as const, invite })), + ], + [invites, members], + ); + + const columns = useMemo[]>( + () => [ + { + id: "member", + header: "Member", + cell: ({ row }) => { + if (row.original.kind === "member") { + const display = memberDisplay(row.original.member); + return ( +
+
+ {display.initials} +
+
+
+ {display.primary} +
+
+ {display.secondary} +
+
+
+ ); + } + + return ( +
+
+ {initialsFrom(row.original.invite.invited_email)} +
+
+
+ {row.original.invite.invited_email} +
+
+ Invited {formatTimestamp(row.original.invite.created_at)} +
+
+
+ ); + }, + }, + { + id: "status", + header: "Status", + cell: ({ row }) => { + if (row.original.kind === "member") { + return ( + + {row.original.member.role} + + ); + } + + return ( +
+ Pending + + {row.original.invite.role} + +
+ ); + }, + }, + { + id: "access", + header: "Access", + cell: ({ row }) => ( + + {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, + )} + + ), + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + if (row.original.kind === "member") { + const member = row.original.member; + if (!isAdmin) { + return Admin only; + } + return ( +
+ +
+ ); + } + + const invite = row.original.invite; + return ( +
+ + +
+ ); + }, + }, + ], + [ + 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 ( + + row.original.kind === "invite" + ? "border-t border-slate-200 bg-slate-50/60" + : "border-t border-slate-200 hover:bg-slate-50" + } + /> + ); +} diff --git a/frontend/src/components/tables/DataTable.test.tsx b/frontend/src/components/tables/DataTable.test.tsx new file mode 100644 index 00000000..cb3761ea --- /dev/null +++ b/frontend/src/components/tables/DataTable.test.tsx @@ -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, "href">; + + return { + default: ({ href, children, ...props }: LinkProps) => ( + + {children} + + ), + }; +}); + +type Row = { + id: string; + name: string; +}; + +type HarnessProps = { + rows: Row[]; + isLoading?: boolean; + emptyMessage?: string; + emptyState?: React.ComponentProps>["emptyState"]; + rowActions?: React.ComponentProps>["rowActions"]; +}; + +function DataTableHarness({ + rows, + isLoading = false, + emptyMessage, + emptyState, + rowActions, +}: HarnessProps) { + const columns: ColumnDef[] = [{ accessorKey: "name", header: "Name" }]; + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data: rows, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + + ); +} + +describe("DataTable", () => { + it("renders default Edit/Delete row actions", () => { + const onDelete = vi.fn(); + const row = { id: "row-1", name: "Alpha" }; + + render( + `/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( + `/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( + , + ); + expect(screen.getByText("Loading…")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("No rows yet")).toBeInTheDocument(); + }); + + it("renders custom empty state", () => { + render( + icon, + 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", + ); + }); +}); diff --git a/frontend/src/components/tables/DataTable.tsx b/frontend/src/components/tables/DataTable.tsx new file mode 100644 index 00000000..8685f798 --- /dev/null +++ b/frontend/src/components/tables/DataTable.tsx @@ -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 = { + key: string; + label: string; + href?: (row: TData) => string | null; + onClick?: (row: TData) => void; + className?: string; +}; + +export type DataTableRowActions = { + header?: ReactNode; + actions?: DataTableRowAction[]; + getEditHref?: (row: TData) => string | null; + onDelete?: (row: TData) => void; + cellClassName?: string; +}; + +type DataTableProps = { + table: Table; + isLoading?: boolean; + loadingLabel?: string; + emptyMessage?: string; + emptyState?: DataTableEmptyState; + rowActions?: DataTableRowActions; + stickyHeader?: boolean; + tableClassName?: string; + headerClassName?: string; + headerCellClassName?: string; + bodyClassName?: string; + rowClassName?: string | ((row: Row) => string); + cellClassName?: string; +}; + +export function DataTable({ + 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) { + const resolvedRowActions = rowActions + ? (rowActions.actions ?? + [ + rowActions.getEditHref + ? ({ + key: "edit", + label: "Edit", + href: rowActions.getEditHref, + } as DataTableRowAction) + : null, + rowActions.onDelete + ? ({ + key: "delete", + label: "Delete", + onClick: rowActions.onDelete, + } as DataTableRowAction) + : null, + ].filter((value): value is DataTableRowAction => value !== null)) + : []; + const hasRowActions = resolvedRowActions.length > 0; + const colSpan = + (table.getVisibleLeafColumns().length || 1) + (hasRowActions ? 1 : 0); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + {hasRowActions ? ( + + ) : null} + + ))} + + + {isLoading ? ( + + ) : table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + {hasRowActions ? ( + + ) : null} + + )) + ) : emptyState ? ( + + ) : ( + + + + )} + +
+ {header.isPlaceholder ? null : header.column.getCanSort() ? ( + + ) : ( + flexRender( + header.column.columnDef.header, + header.getContext(), + ) + )} + + {rowActions?.header ?? ""} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} + +
+ {resolvedRowActions.map((action) => { + const href = action.href?.(row.original) ?? null; + if (href) { + return ( + + {action.label} + + ); + } + if (action.onClick) { + return ( + + ); + } + return null; + })} +
+
+ {emptyMessage} +
+
+ ); +} diff --git a/frontend/src/components/tables/cell-formatters.test.tsx b/frontend/src/components/tables/cell-formatters.test.tsx new file mode 100644 index 00000000..ea7fc0a1 --- /dev/null +++ b/frontend/src/components/tables/cell-formatters.test.tsx @@ -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, "href">; + + return { + default: ({ href, children, ...props }: LinkProps) => ( + + {children} + + ), + }; +}); + +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(); + }); +}); diff --git a/frontend/src/components/tables/cell-formatters.tsx b/frontend/src/components/tables/cell-formatters.tsx new file mode 100644 index 00000000..5107c630 --- /dev/null +++ b/frontend/src/components/tables/cell-formatters.tsx @@ -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 ( + +

+ {label} +

+ {subtitle != null ? ( +

+ {subtitle} +

+ ) : null} + + ); + } + + return ( + + {label} + + ); +} + +export function pillCell( + value: string | null | undefined, + fallback = "unknown", +) { + return ; +} + +export function dateCell( + value: string | null | undefined, + { relative = false, className, fallback = "—" }: DateCellOptions = {}, +) { + const display = relative ? formatRelative(value) : formatTimestamp(value); + return ( + + {display ?? fallback} + + ); +} diff --git a/frontend/src/lib/list-delete.test.ts b/frontend/src/lib/list-delete.test.ts new file mode 100644 index 00000000..da2416cf --- /dev/null +++ b/frontend/src/lib/list-delete.test.ts @@ -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(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(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 }); + }); +}); diff --git a/frontend/src/lib/list-delete.ts b/frontend/src/lib/list-delete.ts new file mode 100644 index 00000000..b5ee834e --- /dev/null +++ b/frontend/src/lib/list-delete.ts @@ -0,0 +1,101 @@ +import type { QueryClient, QueryKey } from "@tanstack/react-query"; + +type ListPayload = { + items: TItem[]; + total: number; +}; + +export type OptimisticListDeleteContext = { + previous?: TResponse; +}; + +type CreateOptimisticListDeleteMutationOptions = { + queryClient: QueryClient; + queryKey: QueryKey; + getItemId: (item: TItem) => string; + getDeleteId: (variables: TVariables) => string; + onSuccess?: () => void; + invalidateQueryKeys?: QueryKey[]; +}; + +function isListPayload(value: unknown): value is ListPayload { + 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(response: unknown): ListPayload | null { + if (!response || typeof response !== "object") { + return null; + } + const data = (response as { data?: unknown }).data; + return isListPayload(data) ? data : null; +} + +export function createOptimisticListDeleteMutation< + TItem, + TResponse extends { status: number }, + TVariables, +>({ + queryClient, + queryKey, + getItemId, + getDeleteId, + onSuccess, + invalidateQueryKeys, +}: CreateOptimisticListDeleteMutationOptions) { + const keysToInvalidate = + invalidateQueryKeys && invalidateQueryKeys.length > 0 + ? invalidateQueryKeys + : [queryKey]; + + return { + onMutate: async ( + variables: TVariables, + ): Promise> => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + + if (previous && previous.status === 200) { + const payload = getListPayload(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(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, + ) => { + if (context?.previous) { + queryClient.setQueryData(queryKey, context.previous); + } + }, + onSuccess: () => { + onSuccess?.(); + }, + onSettled: () => { + for (const key of keysToInvalidate) { + queryClient.invalidateQueries({ queryKey: key }); + } + }, + }; +} diff --git a/frontend/src/lib/use-url-sorting.test.tsx b/frontend/src/lib/use-url-sorting.test.tsx new file mode 100644 index 00000000..1ed0b5d2 --- /dev/null +++ b/frontend/src/lib/use-url-sorting.test.tsx @@ -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(); + }); +}); diff --git a/frontend/src/lib/use-url-sorting.ts b/frontend/src/lib/use-url-sorting.ts new file mode 100644 index 00000000..81157be5 --- /dev/null +++ b/frontend/src/lib/use-url-sorting.ts @@ -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; +}; + +const resolveSortParam = (paramPrefix?: string) => + paramPrefix ? `${paramPrefix}_sort` : "sort"; + +const resolveDirectionParam = (paramPrefix?: string) => + paramPrefix ? `${paramPrefix}_dir` : "dir"; + +const normalizeSorting = ( + value: SortingState, + allowedColumnIds: Set, +): 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>( + (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 }; +}