feat(ui): persist tool policies view and table state in the URL

The selected tool is now ?tool=<name> (pushed, so Back returns to the list),
and the tool table keeps search, the input/output policy, team and key
filters, sort and pagination in the query string.
This commit is contained in:
ryan-crabbe-berri 2026-09-16 11:07:15 -07:00
parent fc44c89f61
commit 06d9802ed6
5 changed files with 213 additions and 26 deletions

View file

@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { focusManager, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NuqsTestingAdapter } from "nuqs/adapters/testing";
import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../tests/test-utils";
import type { ToolRow } from "@/components/networking";
@ -131,9 +132,11 @@ describe("ToolPoliciesPanel data loading", () => {
it("should not refetch when the window regains focus", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<ToolPoliciesPanel accessToken="sk-token" onSelectTool={vi.fn()} />
</QueryClientProvider>,
<NuqsTestingAdapter>
<QueryClientProvider client={client}>
<ToolPoliciesPanel accessToken="sk-token" onSelectTool={vi.fn()} />
</QueryClientProvider>
</NuqsTestingAdapter>,
);
await waitForRows();

View file

@ -2,6 +2,7 @@ import React from "react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { renderWithProviders } from "../../../tests/test-utils";
import type { ToolRow } from "@/components/networking";
@ -42,7 +43,10 @@ const TOOLS: ToolRow[] = [
},
];
const renderTable = (overrides: Partial<React.ComponentProps<typeof ToolPoliciesTable>> = {}) => {
const renderTable = (
overrides: Partial<React.ComponentProps<typeof ToolPoliciesTable>> = {},
urlOptions: Parameters<typeof renderWithProviders>[1] = {},
) => {
const props = {
data: TOOLS,
isLoading: false,
@ -55,7 +59,7 @@ const renderTable = (overrides: Partial<React.ComponentProps<typeof ToolPolicies
onOutputPolicyChange: vi.fn(),
...overrides,
};
renderWithProviders(<ToolPoliciesTable {...props} />);
renderWithProviders(<ToolPoliciesTable {...props} />, urlOptions);
return props;
};
@ -201,3 +205,115 @@ describe("ToolPoliciesTable chrome", () => {
expect(screen.queryByText("No tools discovered")).not.toBeInTheDocument();
});
});
const renderWithUrl = (searchParams: string, data: ToolRow[] = TOOLS) => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderTable({ data }, { searchParams, onUrlUpdate });
return onUrlUpdate;
};
const lastUrlUpdate = (onUrlUpdate: ReturnType<typeof vi.fn<OnUrlUpdateFunction>>) =>
onUrlUpdate.mock.calls.at(-1)?.[0];
const MANY_TOOLS: ToolRow[] = Array.from({ length: 55 }, (_, index) => ({
tool_id: `bulk-${String(index).padStart(2, "0")}`,
tool_name: `bulk_tool_${index}`,
input_policy: "untrusted",
output_policy: "untrusted",
call_count: index,
key_hash: `hash-${index}`,
created_at: new Date(Date.UTC(2026, 6, 1, 0, 0, 55 - index)).toISOString(),
}));
describe("ToolPoliciesTable URL state", () => {
it("applies the search from the URL", () => {
renderWithUrl("?search=weather");
expect(rowIds()).toEqual(["tool-1"]);
expect(screen.getByTestId("datatable-search")).toHaveValue("weather");
});
it("writes the search to the URL", async () => {
const onUrlUpdate = renderWithUrl("");
fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "search" } });
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("search")).toBe("search"));
expect(rowIds()).toEqual(["tool-2"]);
});
it.each([
["filter_input_policy=blocked", "input_policy", ["tool-3"]],
["filter_output_policy=trusted", "output_policy", ["tool-2"]],
["filter_team_id=team-alpha", "team_id", ["tool-1"]],
["filter_key_alias=dev-key", "key_alias", ["tool-2"]],
])("applies ?%s as a column filter", (query, columnId, expected) => {
renderWithUrl(`?${query}`);
expect(rowIds()).toEqual(expected);
expect(screen.getByTestId(`filter-chip-${columnId}`)).toBeInTheDocument();
});
it("writes the filters applied in the drawer to the URL", async () => {
const user = userEvent.setup();
const onUrlUpdate = renderWithUrl("");
await user.click(screen.getByTestId("datatable-filters-trigger"));
await pickFilter(user, "filter-output-policy", "untrusted");
await pickFilter(user, "filter-key-alias", "prod-key");
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("filter_output_policy")).toBe("untrusted"));
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("filter_key_alias")).toBe("prod-key");
expect(rowIds()).toEqual(["tool-1"]);
});
it("drops a filter from the URL when its chip is removed", async () => {
const user = userEvent.setup();
const onUrlUpdate = renderWithUrl("?filter_team_id=team-alpha");
await user.click(screen.getByTestId("filter-chip-remove-team_id"));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("filter_team_id")).toBe(false));
expect(rowIds()).toEqual(["tool-1", "tool-2", "tool-3"]);
});
it("orders rows by the sort in the URL", () => {
renderWithUrl("?sort_by=call_count&sort_order=desc");
expect(rowIds()).toEqual(["tool-3", "tool-1", "tool-2"]);
});
it("falls back to newest first for an unknown sort column", () => {
renderWithUrl("?sort_by=user_agent&sort_order=desc");
expect(rowIds()).toEqual(["tool-1", "tool-2", "tool-3"]);
});
it("writes the sort to the URL when a header is clicked", async () => {
const user = userEvent.setup();
const onUrlUpdate = renderWithUrl("");
await user.click(screen.getByTestId("sort-header-tool_name"));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("sort_by")).toBe("tool_name"));
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("sort_order")).toBe("asc");
});
it("opens the page named in the URL", () => {
renderWithUrl("?page=2", MANY_TOOLS);
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2");
expect(rowIds()).toEqual(["bulk-50", "bulk-51", "bulk-52", "bulk-53", "bulk-54"]);
});
it("writes the page to the URL when paging forward", async () => {
const user = userEvent.setup();
const onUrlUpdate = renderWithUrl("", MANY_TOOLS);
await user.click(screen.getByRole("button", { name: "Go to next page" }));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("page")).toBe("2"));
expect(rowIds()[0]).toBe("bulk-50");
});
});

View file

@ -1,6 +1,6 @@
"use client";
import { ColumnFiltersState } from "@tanstack/react-table";
import { functionalUpdate } from "@tanstack/react-table";
import { Wrench } from "lucide-react";
import { useMemo, useState } from "react";
@ -10,6 +10,8 @@ import {
DataTableFilterDrawer,
DataTableFilterField,
DataTableToolbar,
useUrlTableState,
type UrlTableStateOptions,
} from "@/components/shared/DataTable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -18,6 +20,15 @@ import { getToolPoliciesTableColumns } from "./ToolPoliciesTableColumns";
const ALL_VALUE = "all";
type ToolFilterColumn = "input_policy" | "output_policy" | "team_id" | "key_alias";
const TABLE_STATE_OPTIONS: UrlTableStateOptions<ToolFilterColumn> = {
sortFields: ["created_at", "tool_name", "input_policy", "output_policy", "call_count", "team_id", "key_alias"],
defaultSort: { id: "created_at", desc: true },
defaultPageSize: 50,
filterColumns: ["input_policy", "output_policy", "team_id", "key_alias"],
};
const INPUT_POLICY_FILTER_ITEMS = [
{ value: ALL_VALUE, label: "All Input Policies" },
...INPUT_POLICY_OPTIONS.map((option) => ({ value: option.value, label: option.label })),
@ -76,8 +87,16 @@ export function ToolPoliciesTable({
onInputPolicyChange,
onOutputPolicyChange,
}: ToolPoliciesTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const {
search,
setSearch,
sorting,
onSortingChange,
pagination,
onPaginationChange,
columnFilters,
onColumnFiltersChange,
} = useUrlTableState(TABLE_STATE_OPTIONS);
const [filtersOpen, setFiltersOpen] = useState(false);
const columns = useMemo(() => {
@ -108,24 +127,27 @@ export function ToolPoliciesTable({
columns={columns}
getRowId={(row) => row.tool_id}
sortingMode="client"
defaultSorting={[{ id: "created_at", desc: true }]}
sorting={sorting}
onSortingChange={onSortingChange}
paginationMode="client"
pagination={pagination}
onPaginationChange={onPaginationChange}
pageSizeOptions={[50, 100]}
filterMode="client"
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
globalFilter={globalFilter}
onGlobalFilterChange={setGlobalFilter}
onColumnFiltersChange={onColumnFiltersChange}
globalFilter={search}
onGlobalFilterChange={(updater) => setSearch(functionalUpdate(updater, search))}
isLoading={isLoading}
loadingMessage="Loading tools…"
noDataMessage={<ToolPoliciesEmptyState filtered={columnFilters.length > 0 || globalFilter !== ""} />}
noDataMessage={<ToolPoliciesEmptyState filtered={columnFilters.length > 0 || search !== ""} />}
size="compact"
toolbar={(table) => (
<>
<DataTableToolbar
table={table}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="Search by Tool Name"
onRefresh={onRefresh}
isRefreshing={isRefreshing}

View file

@ -2,6 +2,7 @@ import React from "react";
import { beforeEach, describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { renderWithProviders } from "../../tests/test-utils";
import ToolPoliciesView from "./ToolPoliciesView";
@ -69,4 +70,52 @@ describe("ToolPoliciesView", () => {
expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument();
expect(screen.queryByText("Detail: my-tool")).not.toBeInTheDocument();
});
describe("?tool= deep link", () => {
const lastUrlUpdate = (onUrlUpdate: ReturnType<typeof vi.fn<OnUrlUpdateFunction>>) =>
onUrlUpdate.mock.calls.at(-1)?.[0];
it("should open the detail for the tool named in the URL", () => {
renderWithProviders(<ToolPoliciesView accessToken="token" />, { searchParams: "?tool=get_weather" });
expect(screen.getByText("Detail: get_weather")).toBeInTheDocument();
expect(screen.queryByText("Tool Policies Overview")).not.toBeInTheDocument();
});
it("should show the overview when the tool param is empty", () => {
renderWithProviders(<ToolPoliciesView accessToken="token" />, { searchParams: "?tool=" });
expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument();
});
it("should push the selected tool to the URL", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<ToolPoliciesView accessToken="token" />, { onUrlUpdate });
await user.click(screen.getByRole("button", { name: /select tool/i }));
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("tool")).toBe("my-tool");
expect(lastUrlUpdate(onUrlUpdate)?.options.history).toBe("push");
});
it("should clear the tool from the URL when going back", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<ToolPoliciesView accessToken="token" />, { searchParams: "?tool=get_weather", onUrlUpdate });
await user.click(screen.getByRole("button", { name: /back/i }));
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("tool")).toBe(false);
expect(await screen.findByText("Tool Policies Overview")).toBeInTheDocument();
});
it("should keep the admin-only notice for a caller without access even with a tool in the URL", () => {
can.mockReturnValue(false);
renderWithProviders(<ToolPoliciesView accessToken="token" />, { searchParams: "?tool=get_weather" });
expect(screen.getByText(/only available to admin users/i)).toBeInTheDocument();
expect(screen.queryByText("Detail: get_weather")).not.toBeInTheDocument();
});
});
});

View file

@ -1,11 +1,12 @@
"use client";
import React, { useState } from "react";
import { parseAsString, useQueryState } from "nuqs";
import React, { useCallback } from "react";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { ToolDetail } from "@/components/ToolDetail";
import { ToolPoliciesPanel } from "@/components/ToolPolicies/ToolPoliciesPanel";
type View = { type: "overview" } | { type: "detail"; toolName: string };
const TOOL_PARAM = parseAsString.withOptions({ history: "push" });
interface ToolPoliciesViewProps {
accessToken: string | null;
@ -13,15 +14,11 @@ interface ToolPoliciesViewProps {
export default function ToolPoliciesView({ accessToken }: ToolPoliciesViewProps) {
const canViewToolPolicies = useCan("viewToolPolicies");
const [view, setView] = useState<View>({ type: "overview" });
const [toolName, setToolName] = useQueryState("tool", TOOL_PARAM);
const handleSelectTool = (toolName: string) => {
setView({ type: "detail", toolName });
};
const handleSelectTool = useCallback((name: string) => void setToolName(name), [setToolName]);
const handleBack = () => {
setView({ type: "overview" });
};
const handleBack = useCallback(() => void setToolName(null), [setToolName]);
if (!canViewToolPolicies) {
return (
@ -34,8 +31,8 @@ export default function ToolPoliciesView({ accessToken }: ToolPoliciesViewProps)
return (
<div className="p-6 w-full min-w-0 flex-1">
{view.type === "detail" ? (
<ToolDetail toolName={view.toolName} onBack={handleBack} accessToken={accessToken} />
{toolName ? (
<ToolDetail key={toolName} toolName={toolName} onBack={handleBack} accessToken={accessToken} />
) : (
<ToolPoliciesPanel accessToken={accessToken} onSelectTool={handleSelectTool} />
)}