mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
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:
parent
fc44c89f61
commit
06d9802ed6
5 changed files with 213 additions and 26 deletions
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue