feat: add cell formatters and tables for boards, agents, and member invites
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{agentsQuery.error ? (
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{groupsQuery.error ? (
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{boardsQuery.error ? (
|
||||
|
||||
@@ -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,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 (
|
||||
<DashboardPageLayout
|
||||
signedOut={{
|
||||
message: "Sign in to view a gateway.",
|
||||
forceRedirectUrl: `/gateways/${gatewayId}`,
|
||||
}}
|
||||
title={title}
|
||||
description="Gateway configuration and connection details."
|
||||
headerActions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => router.push("/gateways")}>
|
||||
Back to gateways
|
||||
</Button>
|
||||
{isAdmin && gatewayId ? (
|
||||
<Button onClick={() => router.push(`/gateways/${gatewayId}/edit`)}>
|
||||
Edit gateway
|
||||
<>
|
||||
<DashboardPageLayout
|
||||
signedOut={{
|
||||
message: "Sign in to view a gateway.",
|
||||
forceRedirectUrl: `/gateways/${gatewayId}`,
|
||||
}}
|
||||
title={title}
|
||||
description="Gateway configuration and connection details."
|
||||
headerActions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => router.push("/gateways")}>
|
||||
Back to gateways
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
isAdmin={isAdmin}
|
||||
adminOnlyMessage="Only organization owners and admins can access gateways."
|
||||
>
|
||||
{gatewayQuery.isLoading ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
|
||||
Loading gateway…
|
||||
</div>
|
||||
) : gatewayQuery.error ? (
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-700">
|
||||
{gatewayQuery.error.message}
|
||||
</div>
|
||||
) : gateway ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{isAdmin && gatewayId ? (
|
||||
<Button
|
||||
onClick={() => router.push(`/gateways/${gatewayId}/edit`)}
|
||||
>
|
||||
Edit gateway
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
isAdmin={isAdmin}
|
||||
adminOnlyMessage="Only organization owners and admins can access gateways."
|
||||
>
|
||||
{gatewayQuery.isLoading ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
|
||||
Loading gateway…
|
||||
</div>
|
||||
) : gatewayQuery.error ? (
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-700">
|
||||
{gatewayQuery.error.message}
|
||||
</div>
|
||||
) : gateway ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Connection
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
statusQuery.isLoading
|
||||
? "bg-slate-300"
|
||||
: isConnected
|
||||
? "bg-emerald-500"
|
||||
: "bg-rose-500"
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
{statusQuery.isLoading
|
||||
? "Checking"
|
||||
: isConnected
|
||||
? "Online"
|
||||
: "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 text-sm text-slate-700">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">
|
||||
Gateway URL
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{gateway.url}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">Token</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{maskToken(gateway.token)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Runtime
|
||||
</p>
|
||||
<div className="mt-4 space-y-3 text-sm text-slate-700">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">
|
||||
Workspace root
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{gateway.workspace_root}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<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="mt-1 text-sm font-medium text-slate-900">
|
||||
{formatTimestamp(gateway.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Connection
|
||||
Agents
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
statusQuery.isLoading
|
||||
? "bg-slate-300"
|
||||
: isConnected
|
||||
? "bg-emerald-500"
|
||||
: "bg-rose-500"
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
{statusQuery.isLoading
|
||||
? "Checking"
|
||||
: isConnected
|
||||
? "Online"
|
||||
: "Offline"}
|
||||
{agentsQuery.isLoading ? (
|
||||
<span className="text-xs text-slate-500">Loading…</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">
|
||||
{agents.length} total
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 text-sm text-slate-700">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">
|
||||
Gateway URL
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{gateway.url}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">Token</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{maskToken(gateway.token)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Runtime
|
||||
</p>
|
||||
<div className="mt-4 space-y-3 text-sm text-slate-700">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">
|
||||
Workspace root
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{gateway.workspace_root}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<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="mt-1 text-sm font-medium text-slate-900">
|
||||
{formatTimestamp(gateway.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Agents
|
||||
</p>
|
||||
{agentsQuery.isLoading ? (
|
||||
<span className="text-xs text-slate-500">Loading…</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">
|
||||
{agents.length} total
|
||||
</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>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<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>
|
||||
<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",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{gatewaysQuery.error ? (
|
||||
|
||||
@@ -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)}
|
||||
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>
|
||||
<BoardAccessTable
|
||||
boards={boards}
|
||||
access={access}
|
||||
onToggleRead={handleBoardReadToggle}
|
||||
onToggleWrite={handleBoardWriteToggle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</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={() =>
|
||||
revokeInviteMutation.mutate({
|
||||
inviteId: invite.id,
|
||||
})
|
||||
}
|
||||
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>
|
||||
<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,
|
||||
})
|
||||
}
|
||||
isRevoking={revokeInviteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
145
frontend/src/components/agents/AgentsTable.test.tsx
Normal file
145
frontend/src/components/agents/AgentsTable.test.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import type React from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AgentRead, BoardRead } from "@/api/generated/model";
|
||||
import { AgentsTable } from "./AgentsTable";
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
type LinkProps = React.PropsWithChildren<{
|
||||
href: string | { pathname?: string };
|
||||
}> &
|
||||
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">;
|
||||
|
||||
return {
|
||||
default: ({ href, children, ...props }: LinkProps) => (
|
||||
<a href={typeof href === "string" ? href : "#"} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const buildAgent = (overrides: Partial<AgentRead> = {}): AgentRead => ({
|
||||
id: "agent-1",
|
||||
name: "Ava",
|
||||
gateway_id: "gateway-1",
|
||||
board_id: "board-1",
|
||||
status: "online",
|
||||
openclaw_session_id: "session-1234",
|
||||
last_seen_at: "2026-01-01T00:00:00Z",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildBoard = (overrides: Partial<BoardRead> = {}): BoardRead => ({
|
||||
id: "board-1",
|
||||
name: "Ops Board",
|
||||
slug: "ops-board",
|
||||
organization_id: "org-1",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("AgentsTable", () => {
|
||||
it("renders linked board name and default row actions", () => {
|
||||
const onDelete = vi.fn();
|
||||
const agent = buildAgent();
|
||||
const board = buildBoard();
|
||||
|
||||
render(
|
||||
<AgentsTable agents={[agent]} boards={[board]} onDelete={onDelete} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Ava ID agent-1/i }),
|
||||
).toHaveAttribute("href", "/agents/agent-1");
|
||||
expect(screen.getByRole("link", { name: "Ops Board" })).toHaveAttribute(
|
||||
"href",
|
||||
"/boards/board-1",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Edit" })).toHaveAttribute(
|
||||
"href",
|
||||
"/agents/agent-1/edit",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(onDelete).toHaveBeenCalledWith(agent);
|
||||
});
|
||||
|
||||
it("hides row actions when showActions is false", () => {
|
||||
render(
|
||||
<AgentsTable
|
||||
agents={[buildAgent()]}
|
||||
boards={[buildBoard()]}
|
||||
showActions={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "Edit" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Delete" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports hiddenColumns and columnOrder", () => {
|
||||
render(
|
||||
<AgentsTable
|
||||
agents={[buildAgent()]}
|
||||
boards={[buildBoard()]}
|
||||
showActions={false}
|
||||
hiddenColumns={["status", "openclaw_session_id"]}
|
||||
columnOrder={["updated_at", "name", "board_id", "last_seen_at"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("columnheader", { name: "Status" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("columnheader", { name: "Session" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const headers = screen
|
||||
.getAllByRole("columnheader")
|
||||
.map((header) => header.textContent?.replace(/[↑↓↕]/g, "").trim());
|
||||
expect(headers.slice(0, 4)).toEqual([
|
||||
"Updated",
|
||||
"Agent",
|
||||
"Board",
|
||||
"Last seen",
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports disableSorting and preserves input order", () => {
|
||||
const zulu = buildAgent({ id: "agent-z", name: "Zulu" });
|
||||
const alpha = buildAgent({ id: "agent-a", name: "Alpha" });
|
||||
|
||||
const { rerender } = render(
|
||||
<AgentsTable
|
||||
agents={[zulu, alpha]}
|
||||
boards={[buildBoard()]}
|
||||
showActions={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Default behavior applies name sorting.
|
||||
expect(screen.getAllByRole("row")[1]).toHaveTextContent("Alpha");
|
||||
|
||||
rerender(
|
||||
<AgentsTable
|
||||
agents={[zulu, alpha]}
|
||||
boards={[buildBoard()]}
|
||||
showActions={false}
|
||||
disableSorting
|
||||
/>,
|
||||
);
|
||||
|
||||
// disableSorting keeps incoming data order.
|
||||
expect(screen.getAllByRole("row")[1]).toHaveTextContent("Zulu");
|
||||
});
|
||||
});
|
||||
204
frontend/src/components/agents/AgentsTable.tsx
Normal file
204
frontend/src/components/agents/AgentsTable.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type OnChangeFn,
|
||||
type SortingState,
|
||||
type Updater,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { type AgentRead, type BoardRead } from "@/api/generated/model";
|
||||
import { DataTable } from "@/components/tables/DataTable";
|
||||
import {
|
||||
dateCell,
|
||||
linkifyCell,
|
||||
pillCell,
|
||||
} from "@/components/tables/cell-formatters";
|
||||
import { truncateText as truncate } from "@/lib/formatters";
|
||||
|
||||
type AgentsTableEmptyState = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode;
|
||||
actionHref?: string;
|
||||
actionLabel?: string;
|
||||
};
|
||||
|
||||
type AgentsTableProps = {
|
||||
agents: AgentRead[];
|
||||
boards?: BoardRead[];
|
||||
isLoading?: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: OnChangeFn<SortingState>;
|
||||
showActions?: boolean;
|
||||
hiddenColumns?: string[];
|
||||
columnOrder?: string[];
|
||||
disableSorting?: boolean;
|
||||
stickyHeader?: boolean;
|
||||
emptyMessage?: string;
|
||||
emptyState?: AgentsTableEmptyState;
|
||||
onDelete?: (agent: AgentRead) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_EMPTY_ICON = (
|
||||
<svg
|
||||
className="h-16 w-16 text-slate-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function AgentsTable({
|
||||
agents,
|
||||
boards = [],
|
||||
isLoading = false,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
showActions = true,
|
||||
hiddenColumns,
|
||||
columnOrder,
|
||||
disableSorting = false,
|
||||
stickyHeader = false,
|
||||
emptyMessage = "No agents found.",
|
||||
emptyState,
|
||||
onDelete,
|
||||
}: AgentsTableProps) {
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
]);
|
||||
const resolvedSorting = sorting ?? internalSorting;
|
||||
const handleSortingChange: OnChangeFn<SortingState> =
|
||||
onSortingChange ??
|
||||
((updater: Updater<SortingState>) => {
|
||||
setInternalSorting(updater);
|
||||
});
|
||||
|
||||
const sortedAgents = useMemo(() => [...agents], [agents]);
|
||||
const columnVisibility = useMemo<VisibilityState>(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
(hiddenColumns ?? []).map((columnId) => [columnId, false]),
|
||||
),
|
||||
[hiddenColumns],
|
||||
);
|
||||
const boardNameById = useMemo(
|
||||
() => new Map(boards.map((board) => [board.id, board.name])),
|
||||
[boards],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnDef<AgentRead>[]>(() => {
|
||||
const baseColumns: ColumnDef<AgentRead>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Agent",
|
||||
cell: ({ row }) =>
|
||||
linkifyCell({
|
||||
href: `/agents/${row.original.id}`,
|
||||
label: row.original.name,
|
||||
subtitle: `ID ${row.original.id}`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => pillCell(row.original.status),
|
||||
},
|
||||
{
|
||||
accessorKey: "openclaw_session_id",
|
||||
header: "Session",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{truncate(row.original.openclaw_session_id)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "board_id",
|
||||
header: "Board",
|
||||
cell: ({ row }) => {
|
||||
const boardId = row.original.board_id;
|
||||
if (!boardId) {
|
||||
return <span className="text-sm text-slate-700">—</span>;
|
||||
}
|
||||
const boardName = boardNameById.get(boardId) ?? boardId;
|
||||
return linkifyCell({
|
||||
href: `/boards/${boardId}`,
|
||||
label: boardName,
|
||||
block: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "last_seen_at",
|
||||
header: "Last seen",
|
||||
cell: ({ row }) =>
|
||||
dateCell(row.original.last_seen_at, { relative: true }),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => dateCell(row.original.updated_at),
|
||||
},
|
||||
];
|
||||
|
||||
return baseColumns;
|
||||
}, [boardNameById]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: sortedAgents,
|
||||
columns,
|
||||
enableSorting: !disableSorting,
|
||||
state: {
|
||||
...(!disableSorting ? { sorting: resolvedSorting } : {}),
|
||||
...(columnOrder ? { columnOrder } : {}),
|
||||
columnVisibility,
|
||||
},
|
||||
...(disableSorting ? {} : { onSortingChange: handleSortingChange }),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
...(disableSorting ? {} : { getSortedRowModel: getSortedRowModel() }),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
stickyHeader={stickyHeader}
|
||||
rowActions={
|
||||
showActions
|
||||
? {
|
||||
getEditHref: (agent) => `/agents/${agent.id}/edit`,
|
||||
onDelete,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowClassName="hover:bg-slate-50"
|
||||
cellClassName="px-6 py-4"
|
||||
emptyState={
|
||||
emptyState
|
||||
? {
|
||||
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
|
||||
title: emptyState.title,
|
||||
description: emptyState.description,
|
||||
actionHref: emptyState.actionHref,
|
||||
actionLabel: emptyState.actionLabel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
155
frontend/src/components/board-groups/BoardGroupsTable.tsx
Normal file
155
frontend/src/components/board-groups/BoardGroupsTable.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type OnChangeFn,
|
||||
type SortingState,
|
||||
type Updater,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { type BoardGroupRead } from "@/api/generated/model";
|
||||
import {
|
||||
DataTable,
|
||||
type DataTableEmptyState,
|
||||
} from "@/components/tables/DataTable";
|
||||
import { dateCell, linkifyCell } from "@/components/tables/cell-formatters";
|
||||
|
||||
type BoardGroupsTableProps = {
|
||||
groups: BoardGroupRead[];
|
||||
isLoading?: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: OnChangeFn<SortingState>;
|
||||
stickyHeader?: boolean;
|
||||
showActions?: boolean;
|
||||
hiddenColumns?: string[];
|
||||
columnOrder?: string[];
|
||||
disableSorting?: boolean;
|
||||
onDelete?: (group: BoardGroupRead) => void;
|
||||
emptyMessage?: string;
|
||||
emptyState?: Omit<DataTableEmptyState, "icon"> & {
|
||||
icon?: DataTableEmptyState["icon"];
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_EMPTY_ICON = (
|
||||
<svg
|
||||
className="h-16 w-16 text-slate-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 7h8" />
|
||||
<path d="M3 17h8" />
|
||||
<path d="M13 7h8" />
|
||||
<path d="M13 17h8" />
|
||||
<path d="M3 12h18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function BoardGroupsTable({
|
||||
groups,
|
||||
isLoading = false,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
stickyHeader = false,
|
||||
showActions = true,
|
||||
hiddenColumns,
|
||||
columnOrder,
|
||||
disableSorting = false,
|
||||
onDelete,
|
||||
emptyMessage = "No groups found.",
|
||||
emptyState,
|
||||
}: BoardGroupsTableProps) {
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
]);
|
||||
const resolvedSorting = sorting ?? internalSorting;
|
||||
const handleSortingChange: OnChangeFn<SortingState> =
|
||||
onSortingChange ??
|
||||
((updater: Updater<SortingState>) => {
|
||||
setInternalSorting(updater);
|
||||
});
|
||||
const columnVisibility = useMemo<VisibilityState>(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
(hiddenColumns ?? []).map((columnId) => [columnId, false]),
|
||||
),
|
||||
[hiddenColumns],
|
||||
);
|
||||
const columns = useMemo<ColumnDef<BoardGroupRead>[]>(() => {
|
||||
const baseColumns: ColumnDef<BoardGroupRead>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Group",
|
||||
cell: ({ row }) =>
|
||||
linkifyCell({
|
||||
href: `/board-groups/${row.original.id}`,
|
||||
label: row.original.name,
|
||||
subtitle: row.original.description ?? "No description",
|
||||
subtitleClassName: row.original.description
|
||||
? "mt-1 line-clamp-2 text-xs text-slate-500"
|
||||
: "mt-1 text-xs text-slate-400",
|
||||
}),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => dateCell(row.original.updated_at),
|
||||
},
|
||||
];
|
||||
|
||||
return baseColumns;
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: groups,
|
||||
columns,
|
||||
enableSorting: !disableSorting,
|
||||
state: {
|
||||
...(!disableSorting ? { sorting: resolvedSorting } : {}),
|
||||
...(columnOrder ? { columnOrder } : {}),
|
||||
columnVisibility,
|
||||
},
|
||||
...(disableSorting ? {} : { onSortingChange: handleSortingChange }),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
...(disableSorting ? {} : { getSortedRowModel: getSortedRowModel() }),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
stickyHeader={stickyHeader}
|
||||
emptyMessage={emptyMessage}
|
||||
rowClassName="transition hover:bg-slate-50"
|
||||
cellClassName="px-6 py-4 align-top"
|
||||
rowActions={
|
||||
showActions
|
||||
? {
|
||||
getEditHref: (group) => `/board-groups/${group.id}/edit`,
|
||||
onDelete,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyState={
|
||||
emptyState
|
||||
? {
|
||||
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
|
||||
title: emptyState.title,
|
||||
description: emptyState.description,
|
||||
actionHref: emptyState.actionHref,
|
||||
actionLabel: emptyState.actionLabel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
187
frontend/src/components/boards/BoardsTable.tsx
Normal file
187
frontend/src/components/boards/BoardsTable.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type OnChangeFn,
|
||||
type SortingState,
|
||||
type Updater,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { type BoardGroupRead, type BoardRead } from "@/api/generated/model";
|
||||
import {
|
||||
DataTable,
|
||||
type DataTableEmptyState,
|
||||
} from "@/components/tables/DataTable";
|
||||
import { dateCell, linkifyCell } from "@/components/tables/cell-formatters";
|
||||
|
||||
type BoardsTableProps = {
|
||||
boards: BoardRead[];
|
||||
boardGroups?: BoardGroupRead[];
|
||||
isLoading?: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: OnChangeFn<SortingState>;
|
||||
stickyHeader?: boolean;
|
||||
showActions?: boolean;
|
||||
hiddenColumns?: string[];
|
||||
columnOrder?: string[];
|
||||
disableSorting?: boolean;
|
||||
onDelete?: (board: BoardRead) => void;
|
||||
emptyMessage?: string;
|
||||
emptyState?: Omit<DataTableEmptyState, "icon"> & {
|
||||
icon?: DataTableEmptyState["icon"];
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_EMPTY_ICON = (
|
||||
<svg
|
||||
className="h-16 w-16 text-slate-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const compactId = (value: string) =>
|
||||
value.length > 8 ? `${value.slice(0, 8)}…` : value;
|
||||
|
||||
export function BoardsTable({
|
||||
boards,
|
||||
boardGroups = [],
|
||||
isLoading = false,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
stickyHeader = false,
|
||||
showActions = true,
|
||||
hiddenColumns,
|
||||
columnOrder,
|
||||
disableSorting = false,
|
||||
onDelete,
|
||||
emptyMessage = "No boards found.",
|
||||
emptyState,
|
||||
}: BoardsTableProps) {
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
]);
|
||||
const resolvedSorting = sorting ?? internalSorting;
|
||||
const handleSortingChange: OnChangeFn<SortingState> =
|
||||
onSortingChange ??
|
||||
((updater: Updater<SortingState>) => {
|
||||
setInternalSorting(updater);
|
||||
});
|
||||
const columnVisibility = useMemo<VisibilityState>(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
(hiddenColumns ?? []).map((columnId) => [columnId, false]),
|
||||
),
|
||||
[hiddenColumns],
|
||||
);
|
||||
const groupById = useMemo(() => {
|
||||
const map = new Map<string, BoardGroupRead>();
|
||||
for (const group of boardGroups) {
|
||||
map.set(group.id, group);
|
||||
}
|
||||
return map;
|
||||
}, [boardGroups]);
|
||||
|
||||
const columns = useMemo<ColumnDef<BoardRead>[]>(() => {
|
||||
const baseColumns: ColumnDef<BoardRead>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Board",
|
||||
cell: ({ row }) =>
|
||||
linkifyCell({
|
||||
href: `/boards/${row.original.id}`,
|
||||
label: row.original.name,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "group",
|
||||
accessorFn: (row) => {
|
||||
const groupId = row.board_group_id;
|
||||
if (!groupId) return "";
|
||||
return groupById.get(groupId)?.name ?? groupId;
|
||||
},
|
||||
header: "Group",
|
||||
cell: ({ row }) => {
|
||||
const groupId = row.original.board_group_id;
|
||||
if (!groupId) {
|
||||
return <span className="text-sm text-slate-400">—</span>;
|
||||
}
|
||||
const group = groupById.get(groupId);
|
||||
const label = group?.name ?? compactId(groupId);
|
||||
const title = group?.name ?? groupId;
|
||||
return linkifyCell({
|
||||
href: `/board-groups/${groupId}`,
|
||||
label,
|
||||
title,
|
||||
block: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => dateCell(row.original.updated_at),
|
||||
},
|
||||
];
|
||||
|
||||
return baseColumns;
|
||||
}, [groupById]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: boards,
|
||||
columns,
|
||||
enableSorting: !disableSorting,
|
||||
state: {
|
||||
...(!disableSorting ? { sorting: resolvedSorting } : {}),
|
||||
...(columnOrder ? { columnOrder } : {}),
|
||||
columnVisibility,
|
||||
},
|
||||
...(disableSorting ? {} : { onSortingChange: handleSortingChange }),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
...(disableSorting ? {} : { getSortedRowModel: getSortedRowModel() }),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
stickyHeader={stickyHeader}
|
||||
emptyMessage={emptyMessage}
|
||||
rowClassName="transition hover:bg-slate-50"
|
||||
cellClassName="px-6 py-4 align-top"
|
||||
rowActions={
|
||||
showActions
|
||||
? {
|
||||
getEditHref: (board) => `/boards/${board.id}/edit`,
|
||||
onDelete,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyState={
|
||||
emptyState
|
||||
? {
|
||||
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
|
||||
title: emptyState.title,
|
||||
description: emptyState.description,
|
||||
actionHref: emptyState.actionHref,
|
||||
actionLabel: emptyState.actionLabel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
160
frontend/src/components/gateways/GatewaysTable.tsx
Normal file
160
frontend/src/components/gateways/GatewaysTable.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type OnChangeFn,
|
||||
type SortingState,
|
||||
type Updater,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { type GatewayRead } from "@/api/generated/model";
|
||||
import {
|
||||
DataTable,
|
||||
type DataTableEmptyState,
|
||||
} from "@/components/tables/DataTable";
|
||||
import { dateCell, linkifyCell } from "@/components/tables/cell-formatters";
|
||||
import { truncateText as truncate } from "@/lib/formatters";
|
||||
|
||||
type GatewaysTableProps = {
|
||||
gateways: GatewayRead[];
|
||||
isLoading?: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: OnChangeFn<SortingState>;
|
||||
stickyHeader?: boolean;
|
||||
showActions?: boolean;
|
||||
hiddenColumns?: string[];
|
||||
columnOrder?: string[];
|
||||
disableSorting?: boolean;
|
||||
onDelete?: (gateway: GatewayRead) => void;
|
||||
emptyMessage?: string;
|
||||
emptyState?: Omit<DataTableEmptyState, "icon"> & {
|
||||
icon?: DataTableEmptyState["icon"];
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_EMPTY_ICON = (
|
||||
<svg
|
||||
className="h-16 w-16 text-slate-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function GatewaysTable({
|
||||
gateways,
|
||||
isLoading = false,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
stickyHeader = false,
|
||||
showActions = true,
|
||||
hiddenColumns,
|
||||
columnOrder,
|
||||
disableSorting = false,
|
||||
onDelete,
|
||||
emptyMessage = "No gateways found.",
|
||||
emptyState,
|
||||
}: GatewaysTableProps) {
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
]);
|
||||
const resolvedSorting = sorting ?? internalSorting;
|
||||
const handleSortingChange: OnChangeFn<SortingState> =
|
||||
onSortingChange ??
|
||||
((updater: Updater<SortingState>) => {
|
||||
setInternalSorting(updater);
|
||||
});
|
||||
|
||||
const sortedGateways = useMemo(() => [...gateways], [gateways]);
|
||||
const columnVisibility = useMemo<VisibilityState>(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
(hiddenColumns ?? []).map((columnId) => [columnId, false]),
|
||||
),
|
||||
[hiddenColumns],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnDef<GatewayRead>[]>(() => {
|
||||
const baseColumns: ColumnDef<GatewayRead>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Gateway",
|
||||
cell: ({ row }) =>
|
||||
linkifyCell({
|
||||
href: `/gateways/${row.original.id}`,
|
||||
label: row.original.name,
|
||||
subtitle: truncate(row.original.url, 36),
|
||||
}),
|
||||
},
|
||||
{
|
||||
accessorKey: "workspace_root",
|
||||
header: "Workspace root",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{truncate(row.original.workspace_root, 28)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => dateCell(row.original.updated_at),
|
||||
},
|
||||
];
|
||||
|
||||
return baseColumns;
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: sortedGateways,
|
||||
columns,
|
||||
enableSorting: !disableSorting,
|
||||
state: {
|
||||
...(!disableSorting ? { sorting: resolvedSorting } : {}),
|
||||
...(columnOrder ? { columnOrder } : {}),
|
||||
columnVisibility,
|
||||
},
|
||||
...(disableSorting ? {} : { onSortingChange: handleSortingChange }),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
...(disableSorting ? {} : { getSortedRowModel: getSortedRowModel() }),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
stickyHeader={stickyHeader}
|
||||
emptyMessage={emptyMessage}
|
||||
rowActions={
|
||||
showActions
|
||||
? {
|
||||
getEditHref: (gateway) => `/gateways/${gateway.id}/edit`,
|
||||
onDelete,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyState={
|
||||
emptyState
|
||||
? {
|
||||
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
|
||||
title: emptyState.title,
|
||||
description: emptyState.description,
|
||||
actionHref: emptyState.actionHref,
|
||||
actionLabel: emptyState.actionLabel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/organization/BoardAccessTable.tsx
Normal file
106
frontend/src/components/organization/BoardAccessTable.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { type BoardRead } from "@/api/generated/model";
|
||||
import { linkifyCell } from "@/components/tables/cell-formatters";
|
||||
import { DataTable } from "@/components/tables/DataTable";
|
||||
|
||||
type BoardAccessState = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
type BoardAccessTableProps = {
|
||||
boards: BoardRead[];
|
||||
access: BoardAccessState;
|
||||
onToggleRead: (boardId: string) => void;
|
||||
onToggleWrite: (boardId: string) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function BoardAccessTable({
|
||||
boards,
|
||||
access,
|
||||
onToggleRead,
|
||||
onToggleWrite,
|
||||
disabled = false,
|
||||
}: BoardAccessTableProps) {
|
||||
const columns = useMemo<ColumnDef<BoardRead>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Board",
|
||||
cell: ({ row }) =>
|
||||
linkifyCell({
|
||||
href: `/boards/${row.original.id}`,
|
||||
label: row.original.name,
|
||||
subtitle: row.original.slug,
|
||||
subtitleClassName: "mt-1 text-xs text-slate-500",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "read",
|
||||
header: "Read",
|
||||
cell: ({ row }) => {
|
||||
const entry = access[row.original.id] ?? {
|
||||
read: false,
|
||||
write: false,
|
||||
};
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={entry.read}
|
||||
onChange={() => onToggleRead(row.original.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "write",
|
||||
header: "Write",
|
||||
cell: ({ row }) => {
|
||||
const entry = access[row.original.id] ?? {
|
||||
read: false,
|
||||
write: false,
|
||||
};
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={entry.write}
|
||||
onChange={() => onToggleWrite(row.original.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[access, disabled, onToggleRead, onToggleWrite],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: boards,
|
||||
columns,
|
||||
enableSorting: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
rowClassName="border-t border-slate-200 hover:bg-slate-50"
|
||||
headerClassName="bg-slate-50 text-[11px] uppercase tracking-wide text-slate-500"
|
||||
headerCellClassName="px-4 py-2 font-medium"
|
||||
cellClassName="px-4 py-3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
254
frontend/src/components/organization/MembersInvitesTable.tsx
Normal file
254
frontend/src/components/organization/MembersInvitesTable.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Copy } from "lucide-react";
|
||||
|
||||
import type {
|
||||
OrganizationInviteRead,
|
||||
OrganizationMemberRead,
|
||||
} from "@/api/generated/model";
|
||||
import { DataTable } from "@/components/tables/DataTable";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatTimestamp } from "@/lib/formatters";
|
||||
|
||||
type MemberInviteRow =
|
||||
| { kind: "member"; member: OrganizationMemberRead }
|
||||
| { kind: "invite"; invite: OrganizationInviteRead };
|
||||
|
||||
type MembersInvitesTableProps = {
|
||||
members: OrganizationMemberRead[];
|
||||
invites: OrganizationInviteRead[];
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
copiedInviteId: string | null;
|
||||
onManageAccess: (memberId: string) => void;
|
||||
onCopyInvite: (invite: OrganizationInviteRead) => void;
|
||||
onRevokeInvite: (inviteId: string) => void;
|
||||
isRevoking: boolean;
|
||||
};
|
||||
|
||||
const roleBadgeVariant = (role: string) => {
|
||||
if (role === "admin" || role === "owner") return "accent" as const;
|
||||
return "outline" as const;
|
||||
};
|
||||
|
||||
const initialsFrom = (value?: string | null) => {
|
||||
if (!value) return "?";
|
||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
};
|
||||
|
||||
const summarizeAccess = (allRead: boolean, allWrite: boolean) => {
|
||||
if (allRead || allWrite) {
|
||||
if (allRead && allWrite) return "All boards: read + write";
|
||||
if (allWrite) return "All boards: write";
|
||||
return "All boards: read";
|
||||
}
|
||||
return "Selected boards";
|
||||
};
|
||||
|
||||
const memberDisplay = (member: OrganizationMemberRead) => {
|
||||
const primary =
|
||||
member.user?.name ||
|
||||
member.user?.preferred_name ||
|
||||
member.user?.email ||
|
||||
member.user_id;
|
||||
const secondary = member.user?.email ?? "No email on file";
|
||||
return {
|
||||
primary,
|
||||
secondary,
|
||||
initials: initialsFrom(primary),
|
||||
};
|
||||
};
|
||||
|
||||
export function MembersInvitesTable({
|
||||
members,
|
||||
invites,
|
||||
isLoading,
|
||||
isAdmin,
|
||||
copiedInviteId,
|
||||
onManageAccess,
|
||||
onCopyInvite,
|
||||
onRevokeInvite,
|
||||
isRevoking,
|
||||
}: MembersInvitesTableProps) {
|
||||
const rows = useMemo<MemberInviteRow[]>(
|
||||
() => [
|
||||
...members.map((member) => ({ kind: "member" as const, member })),
|
||||
...invites.map((invite) => ({ kind: "invite" as const, invite })),
|
||||
],
|
||||
[invites, members],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnDef<MemberInviteRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "member",
|
||||
header: "Member",
|
||||
cell: ({ row }) => {
|
||||
if (row.original.kind === "member") {
|
||||
const display = memberDisplay(row.original.member);
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-500 text-xs font-semibold text-white">
|
||||
{display.initials}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{display.primary}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{display.secondary}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-200 text-xs font-semibold text-slate-600">
|
||||
{initialsFrom(row.original.invite.invited_email)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{row.original.invite.invited_email}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Invited {formatTimestamp(row.original.invite.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
if (row.original.kind === "member") {
|
||||
return (
|
||||
<Badge variant={roleBadgeVariant(row.original.member.role)}>
|
||||
{row.original.member.role}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="warning">Pending</Badge>
|
||||
<Badge variant={roleBadgeVariant(row.original.invite.role)}>
|
||||
{row.original.invite.role}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "access",
|
||||
header: "Access",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-slate-600">
|
||||
{row.original.kind === "member"
|
||||
? summarizeAccess(
|
||||
row.original.member.all_boards_read,
|
||||
row.original.member.all_boards_write,
|
||||
)
|
||||
: summarizeAccess(
|
||||
row.original.invite.all_boards_read,
|
||||
row.original.invite.all_boards_write,
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
if (row.original.kind === "member") {
|
||||
const member = row.original.member;
|
||||
if (!isAdmin) {
|
||||
return <span className="text-xs text-slate-400">Admin only</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onManageAccess(member.id)}
|
||||
>
|
||||
Manage access
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const invite = row.original.invite;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onCopyInvite(invite)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
{copiedInviteId === invite.id ? "Copied" : "Copy link"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRevokeInvite(invite.id)}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
copiedInviteId,
|
||||
isAdmin,
|
||||
isRevoking,
|
||||
onCopyInvite,
|
||||
onManageAccess,
|
||||
onRevokeInvite,
|
||||
],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
enableSorting: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
loadingLabel="Loading members..."
|
||||
emptyMessage="No members or invites yet."
|
||||
headerClassName="bg-slate-50 text-[11px] uppercase tracking-wide text-slate-500"
|
||||
headerCellClassName="px-5 py-3 text-left font-medium"
|
||||
cellClassName="px-5 py-4"
|
||||
rowClassName={(row) =>
|
||||
row.original.kind === "invite"
|
||||
? "border-t border-slate-200 bg-slate-50/60"
|
||||
: "border-t border-slate-200 hover:bg-slate-50"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
168
frontend/src/components/tables/DataTable.test.tsx
Normal file
168
frontend/src/components/tables/DataTable.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import type React from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DataTable } from "./DataTable";
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
type LinkProps = React.PropsWithChildren<{
|
||||
href: string | { pathname?: string };
|
||||
}> &
|
||||
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">;
|
||||
|
||||
return {
|
||||
default: ({ href, children, ...props }: LinkProps) => (
|
||||
<a href={typeof href === "string" ? href : "#"} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type HarnessProps = {
|
||||
rows: Row[];
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
emptyState?: React.ComponentProps<typeof DataTable<Row>>["emptyState"];
|
||||
rowActions?: React.ComponentProps<typeof DataTable<Row>>["rowActions"];
|
||||
};
|
||||
|
||||
function DataTableHarness({
|
||||
rows,
|
||||
isLoading = false,
|
||||
emptyMessage,
|
||||
emptyState,
|
||||
rowActions,
|
||||
}: HarnessProps) {
|
||||
const columns: ColumnDef<Row>[] = [{ accessorKey: "name", header: "Name" }];
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
emptyState={emptyState}
|
||||
rowActions={rowActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("DataTable", () => {
|
||||
it("renders default Edit/Delete row actions", () => {
|
||||
const onDelete = vi.fn();
|
||||
const row = { id: "row-1", name: "Alpha" };
|
||||
|
||||
render(
|
||||
<DataTableHarness
|
||||
rows={[row]}
|
||||
rowActions={{
|
||||
getEditHref: (current) => `/items/${current.id}/edit`,
|
||||
onDelete,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editLink = screen.getByRole("link", { name: "Edit" });
|
||||
expect(editLink).toHaveAttribute("href", "/items/row-1/edit");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(onDelete).toHaveBeenCalledWith(row);
|
||||
});
|
||||
|
||||
it("uses custom row actions when provided", () => {
|
||||
const onArchive = vi.fn();
|
||||
const row = { id: "row-1", name: "Alpha" };
|
||||
|
||||
render(
|
||||
<DataTableHarness
|
||||
rows={[row]}
|
||||
rowActions={{
|
||||
getEditHref: (current) => `/items/${current.id}/edit`,
|
||||
onDelete: vi.fn(),
|
||||
actions: [
|
||||
{
|
||||
key: "view",
|
||||
label: "View",
|
||||
href: (current) => `/items/${current.id}`,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
onClick: onArchive,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "View" })).toHaveAttribute(
|
||||
"href",
|
||||
"/items/row-1",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "Edit" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Delete" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Archive" }));
|
||||
expect(onArchive).toHaveBeenCalledWith(row);
|
||||
});
|
||||
|
||||
it("renders loading and empty states", () => {
|
||||
const { rerender } = render(
|
||||
<DataTableHarness rows={[]} isLoading={true} />,
|
||||
);
|
||||
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<DataTableHarness
|
||||
rows={[]}
|
||||
isLoading={false}
|
||||
emptyMessage="No rows yet"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("No rows yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom empty state", () => {
|
||||
render(
|
||||
<DataTableHarness
|
||||
rows={[]}
|
||||
emptyState={{
|
||||
icon: <span data-testid="empty-icon">icon</span>,
|
||||
title: "No records",
|
||||
description: "Create one to continue.",
|
||||
actionHref: "/new",
|
||||
actionLabel: "Create",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("empty-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("No records")).toBeInTheDocument();
|
||||
expect(screen.getByText("Create one to continue.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Create" })).toHaveAttribute(
|
||||
"href",
|
||||
"/new",
|
||||
);
|
||||
});
|
||||
});
|
||||
219
frontend/src/components/tables/DataTable.tsx
Normal file
219
frontend/src/components/tables/DataTable.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { type Row, type Table, flexRender } from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
TableEmptyStateRow,
|
||||
TableLoadingRow,
|
||||
} from "@/components/ui/table-state";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
export type DataTableEmptyState = {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
actionHref?: string;
|
||||
actionLabel?: string;
|
||||
};
|
||||
|
||||
export type DataTableRowAction<TData> = {
|
||||
key: string;
|
||||
label: string;
|
||||
href?: (row: TData) => string | null;
|
||||
onClick?: (row: TData) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type DataTableRowActions<TData> = {
|
||||
header?: ReactNode;
|
||||
actions?: DataTableRowAction<TData>[];
|
||||
getEditHref?: (row: TData) => string | null;
|
||||
onDelete?: (row: TData) => void;
|
||||
cellClassName?: string;
|
||||
};
|
||||
|
||||
type DataTableProps<TData> = {
|
||||
table: Table<TData>;
|
||||
isLoading?: boolean;
|
||||
loadingLabel?: string;
|
||||
emptyMessage?: string;
|
||||
emptyState?: DataTableEmptyState;
|
||||
rowActions?: DataTableRowActions<TData>;
|
||||
stickyHeader?: boolean;
|
||||
tableClassName?: string;
|
||||
headerClassName?: string;
|
||||
headerCellClassName?: string;
|
||||
bodyClassName?: string;
|
||||
rowClassName?: string | ((row: Row<TData>) => string);
|
||||
cellClassName?: string;
|
||||
};
|
||||
|
||||
export function DataTable<TData>({
|
||||
table,
|
||||
isLoading = false,
|
||||
loadingLabel = "Loading…",
|
||||
emptyMessage = "No rows found.",
|
||||
emptyState,
|
||||
rowActions,
|
||||
stickyHeader = false,
|
||||
tableClassName = "w-full text-left text-sm",
|
||||
headerClassName,
|
||||
headerCellClassName = "px-6 py-3",
|
||||
bodyClassName = "divide-y divide-slate-100",
|
||||
rowClassName = "hover:bg-slate-50",
|
||||
cellClassName = "px-6 py-4",
|
||||
}: DataTableProps<TData>) {
|
||||
const resolvedRowActions = rowActions
|
||||
? (rowActions.actions ??
|
||||
[
|
||||
rowActions.getEditHref
|
||||
? ({
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
href: rowActions.getEditHref,
|
||||
} as DataTableRowAction<TData>)
|
||||
: null,
|
||||
rowActions.onDelete
|
||||
? ({
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
onClick: rowActions.onDelete,
|
||||
} as DataTableRowAction<TData>)
|
||||
: null,
|
||||
].filter((value): value is DataTableRowAction<TData> => value !== null))
|
||||
: [];
|
||||
const hasRowActions = resolvedRowActions.length > 0;
|
||||
const colSpan =
|
||||
(table.getVisibleLeafColumns().length || 1) + (hasRowActions ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className={tableClassName}>
|
||||
<thead
|
||||
className={
|
||||
headerClassName ??
|
||||
`${stickyHeader ? "sticky top-0 z-10 " : ""}bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500`
|
||||
}
|
||||
>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id} className={headerCellClassName}>
|
||||
{header.isPlaceholder ? null : header.column.getCanSort() ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
className="inline-flex items-center gap-1 text-left"
|
||||
>
|
||||
<span>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</span>
|
||||
{header.column.getIsSorted() === "asc" ? (
|
||||
"↑"
|
||||
) : header.column.getIsSorted() === "desc" ? (
|
||||
"↓"
|
||||
) : (
|
||||
<span className="text-slate-300">↕</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
{hasRowActions ? (
|
||||
<th className={headerCellClassName}>
|
||||
{rowActions?.header ?? ""}
|
||||
</th>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className={bodyClassName}>
|
||||
{isLoading ? (
|
||||
<TableLoadingRow colSpan={colSpan} label={loadingLabel} />
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={
|
||||
typeof rowClassName === "function"
|
||||
? rowClassName(row)
|
||||
: rowClassName
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className={cellClassName}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
{hasRowActions ? (
|
||||
<td className={rowActions?.cellClassName ?? cellClassName}>
|
||||
<div className="flex justify-end gap-2">
|
||||
{resolvedRowActions.map((action) => {
|
||||
const href = action.href?.(row.original) ?? null;
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
key={action.key}
|
||||
href={href}
|
||||
className={
|
||||
action.className ??
|
||||
buttonVariants({ variant: "ghost", size: "sm" })
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (action.onClick) {
|
||||
return (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={action.className}
|
||||
onClick={() => action.onClick?.(row.original)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))
|
||||
) : emptyState ? (
|
||||
<TableEmptyStateRow
|
||||
colSpan={colSpan}
|
||||
icon={emptyState.icon}
|
||||
title={emptyState.title}
|
||||
description={emptyState.description}
|
||||
actionHref={emptyState.actionHref}
|
||||
actionLabel={emptyState.actionLabel}
|
||||
/>
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={colSpan}
|
||||
className="px-6 py-8 text-sm text-slate-500"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/tables/cell-formatters.test.tsx
Normal file
74
frontend/src/components/tables/cell-formatters.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import type React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { dateCell, linkifyCell, pillCell } from "./cell-formatters";
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
type LinkProps = React.PropsWithChildren<{
|
||||
href: string | { pathname?: string };
|
||||
}> &
|
||||
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">;
|
||||
|
||||
return {
|
||||
default: ({ href, children, ...props }: LinkProps) => (
|
||||
<a href={typeof href === "string" ? href : "#"} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
describe("cell formatters", () => {
|
||||
it("renders linkifyCell in block mode with subtitle", () => {
|
||||
render(
|
||||
linkifyCell({
|
||||
href: "/agents/agent-1",
|
||||
label: "Agent One",
|
||||
subtitle: "ID agent-1",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: /agent one/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/agents/agent-1",
|
||||
);
|
||||
expect(screen.getByText("ID agent-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders linkifyCell in inline mode", () => {
|
||||
render(
|
||||
linkifyCell({
|
||||
href: "/boards/board-1",
|
||||
label: "Board One",
|
||||
block: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Board One" })).toHaveAttribute(
|
||||
"href",
|
||||
"/boards/board-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders pillCell and default fallback", () => {
|
||||
const { rerender } = render(pillCell("in_progress"));
|
||||
expect(screen.getByText("in progress")).toBeInTheDocument();
|
||||
|
||||
rerender(pillCell(null));
|
||||
expect(screen.getByText("unknown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders dateCell relative and fallback states", () => {
|
||||
const now = new Date("2026-01-01T01:00:00Z").getTime();
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const { rerender } = render(
|
||||
dateCell("2026-01-01T00:00:00Z", { relative: true }),
|
||||
);
|
||||
expect(screen.getByText("1h ago")).toBeInTheDocument();
|
||||
|
||||
rerender(dateCell(null));
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
89
frontend/src/components/tables/cell-formatters.tsx
Normal file
89
frontend/src/components/tables/cell-formatters.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { StatusPill } from "@/components/atoms/StatusPill";
|
||||
import {
|
||||
formatRelativeTimestamp as formatRelative,
|
||||
formatTimestamp,
|
||||
} from "@/lib/formatters";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type LinkifyCellOptions = {
|
||||
href: string;
|
||||
label: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
title?: string;
|
||||
block?: boolean;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
subtitleClassName?: string;
|
||||
};
|
||||
|
||||
type DateCellOptions = {
|
||||
relative?: boolean;
|
||||
className?: string;
|
||||
fallback?: ReactNode;
|
||||
};
|
||||
|
||||
export function linkifyCell({
|
||||
href,
|
||||
label,
|
||||
subtitle,
|
||||
title,
|
||||
block = subtitle != null,
|
||||
className,
|
||||
labelClassName,
|
||||
subtitleClassName,
|
||||
}: LinkifyCellOptions) {
|
||||
if (block) {
|
||||
return (
|
||||
<Link href={href} title={title} className={cn("group block", className)}>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium text-slate-900 group-hover:text-blue-600",
|
||||
labelClassName,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
{subtitle != null ? (
|
||||
<p className={cn("text-xs text-slate-500", subtitleClassName)}>
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
className={cn(
|
||||
"text-sm font-medium text-slate-700 hover:text-blue-600",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function pillCell(
|
||||
value: string | null | undefined,
|
||||
fallback = "unknown",
|
||||
) {
|
||||
return <StatusPill status={value ?? fallback} />;
|
||||
}
|
||||
|
||||
export function dateCell(
|
||||
value: string | null | undefined,
|
||||
{ relative = false, className, fallback = "—" }: DateCellOptions = {},
|
||||
) {
|
||||
const display = relative ? formatRelative(value) : formatTimestamp(value);
|
||||
return (
|
||||
<span className={cn("text-sm text-slate-700", className)}>
|
||||
{display ?? fallback}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
79
frontend/src/lib/list-delete.test.ts
Normal file
79
frontend/src/lib/list-delete.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
101
frontend/src/lib/list-delete.ts
Normal file
101
frontend/src/lib/list-delete.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
119
frontend/src/lib/use-url-sorting.test.tsx
Normal file
119
frontend/src/lib/use-url-sorting.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
157
frontend/src/lib/use-url-sorting.ts
Normal file
157
frontend/src/lib/use-url-sorting.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user