Merge pull request #41331 from BerriAI/litellm_ui_url_state_foundation

feat(ui): shared URL-state layer for tables and tabs
This commit is contained in:
ryan-crabbe-berri 2026-09-16 16:07:45 -07:00 committed by GitHub
commit 8bb4317c75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1501 additions and 357 deletions

View file

@ -1,82 +0,0 @@
/* @vitest-environment jsdom */
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockPush, navState } = vi.hoisted(() => ({
mockPush: vi.fn(),
navState: { pathname: "/logs" },
}));
vi.mock("next/navigation", () => ({
usePathname: () => navState.pathname,
useRouter: () => ({ push: mockPush }),
}));
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
import { createTabRoutes } from "@/utils/tabRoutes";
import { useTabRouting } from "./useTabRouting";
const routes = createTabRoutes("logs", ["audit", "deleted-keys", "deleted-teams"] as const);
const render = (ready = true) => {
const config = {
routes,
baseTabKey: "request-logs",
visibleKeys: ["audit", "deleted-keys", "deleted-teams"],
ready,
};
return renderHook(() => useTabRouting(config));
};
describe("useTabRouting", () => {
beforeEach(() => {
navState.pathname = "/logs";
mockPush.mockClear();
});
it("maps the base path to the base tab key", () => {
const { result } = render();
expect(result.current.activeSlug).toBe("");
expect(result.current.activeKey).toBe("request-logs");
});
it("uses the slug itself as the active key for a known nested tab", () => {
navState.pathname = "/ui/logs/audit";
const { result } = render();
expect(result.current.activeKey).toBe("audit");
});
it("falls back to the base tab key for an unknown slug", () => {
navState.pathname = "/ui/logs/bogus";
const { result } = render();
expect(result.current.activeKey).toBe("request-logs");
});
it("redirects an unknown slug to the base href once ready", () => {
const replaceMock = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", { configurable: true, value: { replace: replaceMock } });
navState.pathname = "/ui/logs/bogus";
render(true);
expect(replaceMock).toHaveBeenCalledWith("/ui/logs/");
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
});
it("does not redirect while not ready (role/creds still loading)", () => {
const replaceMock = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", { configurable: true, value: { replace: replaceMock } });
navState.pathname = "/ui/logs/bogus";
render(false);
expect(replaceMock).not.toHaveBeenCalled();
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
});
it("pushes the tab href on change, mapping the base key back to the empty slug", () => {
const { result } = render();
result.current.onTabChange("audit");
expect(mockPush).toHaveBeenCalledWith("/ui/logs/audit/");
result.current.onTabChange("request-logs");
expect(mockPush).toHaveBeenCalledWith("/ui/logs/");
});
});

View file

@ -1,38 +0,0 @@
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import type { TabRoutes } from "@/utils/tabRoutes";
interface UseTabRoutingArgs {
routes: Pick<TabRoutes<string>, "tabHref" | "slugFromPathname">;
baseTabKey: string;
visibleKeys: readonly string[];
ready?: boolean;
}
interface TabRoutingState {
activeSlug: string;
activeKey: string;
onTabChange: (key: string) => void;
}
export function useTabRouting({ routes, baseTabKey, visibleKeys, ready = true }: UseTabRoutingArgs): TabRoutingState {
const { tabHref, slugFromPathname } = routes;
const pathname = usePathname();
const router = useRouter();
const activeSlug = slugFromPathname(pathname);
const isKnownSlug = activeSlug === "" || visibleKeys.includes(activeSlug);
const activeKey = isKnownSlug ? activeSlug || baseTabKey : baseTabKey;
useEffect(() => {
if (ready && activeSlug !== "" && !isKnownSlug) {
window.location.replace(tabHref(""));
}
}, [ready, activeSlug, isKnownSlug, tabHref]);
const onTabChange = (key: string) => {
router.push(tabHref(key === baseTabKey ? "" : key));
};
return { activeSlug, activeKey, onTabChange };
}

View file

@ -1,5 +1,8 @@
import { render, screen } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import PlaygroundPage from "./page";
const authState = { userRole: "Admin" };
@ -35,14 +38,17 @@ vi.mock("@/app/(dashboard)/playground/components/chat_ui/AgentBuilderView", () =
default: () => <div data-testid="agent-builder" />,
}));
describe("PlaygroundPage role guard", () => {
beforeEach(() => {
authState.userRole = "Admin";
});
const lastUrlUpdate = (onUrlUpdate: ReturnType<typeof vi.fn<OnUrlUpdateFunction>>) =>
onUrlUpdate.mock.calls.at(-1)?.[0];
beforeEach(() => {
authState.userRole = "Admin";
});
describe("PlaygroundPage role guard", () => {
it.each(["Internal Viewer", "Admin Viewer"])("blocks the entire playground for %s", (role) => {
authState.userRole = role;
render(<PlaygroundPage />);
renderWithProviders(<PlaygroundPage />);
expect(screen.getByText("Access Denied")).toBeInTheDocument();
expect(screen.queryByRole("tab")).not.toBeInTheDocument();
@ -54,10 +60,43 @@ describe("PlaygroundPage role guard", () => {
it.each(["Admin", "Internal User", "Org Admin"])("renders the playground for %s", (role) => {
authState.userRole = role;
render(<PlaygroundPage />);
renderWithProviders(<PlaygroundPage />);
expect(screen.queryByText("Access Denied")).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Chat" })).toBeInTheDocument();
expect(screen.getByTestId("chat-ui")).toBeInTheDocument();
});
});
describe("PlaygroundPage ?tab= deep link", () => {
it("opens on Chat when the URL has no tab", () => {
renderWithProviders(<PlaygroundPage />);
expect(screen.getByRole("tab", { name: "Chat" })).toHaveAttribute("aria-selected", "true");
});
it("activates the tab named in ?tab=", () => {
renderWithProviders(<PlaygroundPage />, { searchParams: { tab: "compare" } });
expect(screen.getByRole("tab", { name: "Compare" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Chat" })).toHaveAttribute("aria-selected", "false");
});
it("falls back to Chat when ?tab= is not a playground tab", () => {
renderWithProviders(<PlaygroundPage />, { searchParams: { tab: "settings" } });
expect(screen.getByRole("tab", { name: "Chat" })).toHaveAttribute("aria-selected", "true");
});
it("clicking a tab writes ?tab= with history replace", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<PlaygroundPage />, { onUrlUpdate });
await user.click(screen.getByRole("tab", { name: "Compliance" }));
expect(await screen.findByRole("tab", { name: "Compliance", selected: true })).toBeInTheDocument();
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("tab")).toBe("compliance"));
expect(lastUrlUpdate(onUrlUpdate)?.options.history).toBe("replace");
});
});

View file

@ -9,6 +9,9 @@ import { DeprecationBanner } from "@/components/DeprecationBanner";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { fetchProxySettings } from "@/utils/proxyUtils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useUrlTab } from "@/hooks/useUrlTab";
const PLAYGROUND_TABS = ["chat", "compare", "compliance", "agent-builder"] as const;
interface ProxySettings {
PROXY_BASE_URL?: string;
@ -18,6 +21,7 @@ interface ProxySettings {
export default function PlaygroundPage() {
const { accessToken, userRole, userId, disabledPersonalKeyCreation, token, isViewOnly } = useAuthorized();
const [proxySettings, setProxySettings] = useState<ProxySettings | undefined>(undefined);
const [activeTab, setActiveTab] = useUrlTab(PLAYGROUND_TABS, "chat");
useEffect(() => {
const initializeProxySettings = async () => {
@ -48,7 +52,11 @@ export default function PlaygroundPage() {
return (
<div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden">
<Tabs defaultValue="chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-0 overflow-hidden">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex min-h-0 min-w-0 flex-1 flex-col gap-0 overflow-hidden"
>
<TabsList variant="line" className="w-full shrink-0 justify-start overflow-x-auto pb-1">
<TabsTrigger value="chat" className="flex-none">
Chat

View file

@ -4,7 +4,7 @@ import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { vi, it, expect, beforeEach, describe, Mock, MockedFunction } from "vitest";
import { chooseSelectOption, renderWithProviders } from "../../../tests/test-utils";
import { VirtualKeysTable } from "./VirtualKeysTable";
import { KEY_TABLE_SORT_FIELDS } from "./keyTableColumns";
import { KEY_TABLE_HIDDEN_COLUMNS, KEY_TABLE_SORT_FIELDS } from "./keyTableColumns";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
import { useKeyInfo } from "@/app/(dashboard)/hooks/keys/useKeyInfo";
import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
@ -187,6 +187,7 @@ const lastHistoryMode = (onUrlUpdate: Mock<OnUrlUpdateFunction>) => onUrlUpdate.
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockUseKeys.mockReturnValue(keysResult([mockKey]));
mockUseKeyInfo.mockReturnValue(keyInfoResult(undefined));
@ -823,16 +824,27 @@ describe("table state lives in the URL so it survives leaving and returning to t
});
it("restores the drawer filters from the URL on mount", async () => {
renderWithProviders(<VirtualKeysTable />, { searchParams: { filter_team: "team-1", filter_user: "user-42" } });
const searchParams = {
filter_team: "team-1",
filter_org: "org-1",
filter_user: "user-42",
filter_key_id: mockKey.token,
};
const expectedKeyListOptions = {
teamID: "team-1",
organizationID: "org-1",
userID: "user-42",
keyHash: mockKey.token,
};
renderWithProviders(<VirtualKeysTable />, { searchParams });
await waitFor(() => {
expect(mockUseKeys).toHaveBeenLastCalledWith(
1,
50,
expect.objectContaining({ teamID: "team-1", userID: "user-42" }),
);
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining(expectedKeyListOptions));
});
expect(screen.getByTestId("filter-chip-team_id")).toHaveTextContent("Test Team");
expect(screen.getByTestId("filter-chip-org_id")).toHaveTextContent("Test Organization");
expect(screen.getByTestId("filter-chip-user_id")).toHaveTextContent("user-42");
expect(screen.getByTestId("filter-chip-key_hash")).toHaveTextContent(mockKey.token);
});
it("restores the status filter from the URL and sends it to /key/list", async () => {
@ -853,6 +865,21 @@ describe("table state lives in the URL so it survives leaving and returning to t
expect(screen.queryByTestId("filter-chip-status")).not.toBeInTheDocument();
});
it("drops a hand-edited status from the URL when another filter chip is removed", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, {
searchParams: { filter_status: "bogus", filter_user: "user-42" },
onUrlUpdate,
});
fireEvent.click(await screen.findByTestId("filter-chip-remove-user_id"));
await waitFor(() => {
expect(lastSearchParam(onUrlUpdate, "filter_user")).toBeNull();
});
expect(lastSearchParam(onUrlUpdate, "filter_status")).toBeNull();
});
it("writes the search term to the URL", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
@ -896,6 +923,40 @@ describe("table state lives in the URL so it survives leaving and returning to t
expect(screen.queryByTestId("filter-chip-user_id")).not.toBeInTheDocument();
});
it("writes the Organization and Key ID drawer filters to the URL and clears them again", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
openFilters();
await chooseSelectOption(user, await screen.findByPlaceholderText(/Select an organization/), /Test Organization/);
fireEvent.change(screen.getByPlaceholderText(/Enter Key ID/), { target: { value: mockKey.token } });
fireEvent.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => {
expect(lastSearchParam(onUrlUpdate, "filter_org")).toBe("org-1");
});
expect(lastSearchParam(onUrlUpdate, "filter_key_id")).toBe(mockKey.token);
expect(lastSearchParam(onUrlUpdate, "filter_org_id")).toBeNull();
expect(lastSearchParam(onUrlUpdate, "filter_key_hash")).toBeNull();
await waitFor(() => {
expect(mockUseKeys).toHaveBeenLastCalledWith(
1,
50,
expect.objectContaining({ organizationID: "org-1", keyHash: mockKey.token }),
);
});
fireEvent.click(screen.getByTestId("datatable-clear-filters"));
await waitFor(() => {
expect(lastSearchParam(onUrlUpdate, "filter_org")).toBeNull();
});
expect(lastSearchParam(onUrlUpdate, "filter_key_id")).toBeNull();
expect(screen.queryByTestId("filter-chip-org_id")).not.toBeInTheDocument();
expect(screen.queryByTestId("filter-chip-key_hash")).not.toBeInTheDocument();
});
it("returns to page 1 when the search term changes", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { searchParams: { page: "3" }, onUrlUpdate });
@ -953,14 +1014,16 @@ describe("table state lives in the URL so it survives leaving and returning to t
});
});
it("falls back to the default sort when the URL names a column the table cannot sort by", async () => {
renderWithProviders(<VirtualKeysTable />, { searchParams: { sort_by: "totally_unknown_field" } });
it("falls back to the default sort column, keeping the URL's direction, when the table cannot sort by sort_by", async () => {
renderWithProviders(<VirtualKeysTable />, {
searchParams: { sort_by: "totally_unknown_field", sort_order: "asc" },
});
await waitFor(() => {
expect(mockUseKeys).toHaveBeenLastCalledWith(
1,
50,
expect.objectContaining({ sortBy: "created_at", sortOrder: "desc" }),
expect.objectContaining({ sortBy: "created_at", sortOrder: "asc" }),
);
});
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
@ -1003,3 +1066,72 @@ describe("table state lives in the URL so it survives leaving and returning to t
});
});
});
describe("column choices survive a reload", () => {
const STORAGE_KEY = "litellm_table_columns_virtual-keys";
const storedColumns = () => JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null");
it("hides a column that was hidden on a previous visit while the default-hidden columns stay hidden", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ budget_reset_at: false }));
renderWithProviders(<VirtualKeysTable />);
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
expect(screen.queryByText("Budget Reset")).not.toBeInTheDocument();
expect(screen.queryByText("Created By")).not.toBeInTheDocument();
});
it("writes a column toggled on through the Columns menu to storage and shows it again on the next mount", async () => {
const user = userEvent.setup();
const { unmount } = renderWithProviders(<VirtualKeysTable />);
expect(screen.queryByText("Created By")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Columns" }));
await user.click(await screen.findByText("Created By"));
await user.keyboard("{Escape}");
expect(storedColumns()).toEqual({ ...KEY_TABLE_HIDDEN_COLUMNS, created_by: true });
unmount();
renderWithProviders(<VirtualKeysTable />);
expect(screen.getByText("Created By")).toBeInTheDocument();
});
});
describe("a failed keys fetch does not rewrite the URL", () => {
const renderOnPage3OfMany = async () => {
mockUseKeys.mockReturnValue(keysResult([mockKey], { total_count: 200, total_pages: 4 }));
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const view = renderWithProviders(<VirtualKeysTable />, { searchParams: { page: "3" }, onUrlUpdate });
await waitFor(() => {
expect(mockUseKeys).toHaveBeenLastCalledWith(3, 50, expect.anything());
});
return { ...view, onUrlUpdate };
};
it("keeps ?page=3 when the keys query errors, instead of snapping to page 1 on the empty count", async () => {
const { rerender, onUrlUpdate } = await renderOnPage3OfMany();
mockUseKeys.mockReturnValue(keysResult([], {}, { data: undefined, isError: true }));
rerender(<VirtualKeysTable />);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockUseKeys).toHaveBeenLastCalledWith(3, 50, expect.anything());
expect(onUrlUpdate).not.toHaveBeenCalled();
});
it("still snaps ?page=3 back to the first page when the keys query succeeds with no rows", async () => {
const { rerender, onUrlUpdate } = await renderOnPage3OfMany();
mockUseKeys.mockReturnValue(keysResult([]));
rerender(<VirtualKeysTable />);
await waitFor(() => {
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.anything());
});
await waitFor(() => {
expect(lastSearchParam(onUrlUpdate, "page")).toBeNull();
});
});
});

View file

@ -10,15 +10,18 @@ import {
DataTableFilterDrawer,
DataTableFilterField,
DataTableToolbar,
usePersistedColumnVisibility,
useUrlTableState,
type UrlTableStateOptions,
} from "@/components/shared/DataTable";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { PageHeader } from "@/components/shared/PageHeader";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { ColumnFiltersState, functionalUpdate, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { ColumnFiltersState, functionalUpdate, OnChangeFn } from "@tanstack/react-table";
import { KeyRound } from "lucide-react";
import { createParser, parseAsInteger, parseAsString, parseAsStringLiteral, useQueryState, useQueryStates } from "nuqs";
import { parseAsString, useQueryState } from "nuqs";
import React, { useCallback, useMemo, useState } from "react";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
@ -56,44 +59,30 @@ const STATUS_FILTER_ITEMS = [
...KEY_STATUS_VALUES.map((value) => ({ value, label: KEY_STATUS_LABELS[value] })),
];
const isKeyStatusFilter = (value: string): value is KeyStatusFilter =>
(KEY_STATUS_VALUES as readonly string[]).includes(value);
const isKeyStatusFilter = (value: unknown): value is KeyStatusFilter =>
(KEY_STATUS_VALUES as readonly unknown[]).includes(value);
const DEFAULT_SORT_BY = "created_at";
const DEFAULT_SORT_ORDER = "desc";
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 100;
const MAX_PAGE = 100_000;
const isUsableFilter = (filter: ColumnFiltersState[number]): boolean =>
filter.id !== "status" || isKeyStatusFilter(filter.value);
const boundedInteger = (min: number, max: number, fallback: number) =>
createParser({
parse: (value: string) => {
const parsed = parseAsInteger.parse(value);
return parsed === null ? null : Math.min(Math.max(parsed, min), max);
},
serialize: String,
}).withDefault(fallback);
// The filters carry a prefix because /api-keys also takes team_id, key_alias and key_type
// as create-key prefills; an unprefixed filter would hijack those deep links.
const TABLE_STATE = {
key_search: parseAsString.withDefault(""),
sort_by: parseAsString.withDefault(DEFAULT_SORT_BY),
sort_order: parseAsStringLiteral(["asc", "desc"] as const).withDefault(DEFAULT_SORT_ORDER),
page: boundedInteger(1, MAX_PAGE, 1),
page_size: boundedInteger(1, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE),
filter_team: parseAsString.withDefault(""),
filter_org: parseAsString.withDefault(""),
filter_user: parseAsString.withDefault(""),
filter_key_id: parseAsString.withDefault(""),
filter_status: parseAsString.withDefault(""),
const TABLE_STATE_OPTIONS: UrlTableStateOptions<FilterColumn> = {
sortFields: KEY_TABLE_SORT_FIELDS,
defaultSort: { id: "created_at", desc: true },
defaultPageSize: 50,
maxPageSize: 100,
filterColumns: FILTER_COLUMNS,
urlKeys: {
search: "key_search",
filter_team_id: "filter_team",
filter_org_id: "filter_org",
filter_user_id: "filter_user",
filter_key_hash: "filter_key_id",
},
};
const toSortOrder = (active: SortingState[number]): "asc" | "desc" => (active.desc ? "desc" : "asc");
const filterValue = (filters: ColumnFiltersState, column: FilterColumn): string | null => {
const appliedFilter = (filters: ColumnFiltersState, column: FilterColumn): string | undefined => {
const value = filters.find((filter) => filter.id === column)?.value;
return (typeof value === "string" ? value.trim() : "") || null;
return typeof value === "string" ? value : undefined;
};
export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
@ -103,50 +92,38 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
const allTeams = useMemo<Team[]>(() => fetchedTeams ?? [], [fetchedTeams]);
const [selectedKeyId, setSelectedKeyId] = useQueryState("key", parseAsString.withOptions({ history: "push" }));
const [tableState, setTableState] = useQueryStates(TABLE_STATE);
const {
search: searchInput,
setSearch,
sorting,
onSortingChange,
pagination,
onPaginationChange,
columnFilters: urlColumnFilters,
onColumnFiltersChange: setUrlColumnFilters,
} = useUrlTableState(TABLE_STATE_OPTIONS);
const columnFilters = useMemo(() => urlColumnFilters.filter(isUsableFilter), [urlColumnFilters]);
const onColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>(
(updaterOrValue) => setUrlColumnFilters(functionalUpdate(updaterOrValue, columnFilters)),
[columnFilters, setUrlColumnFilters],
);
const { columnVisibility, onColumnVisibilityChange } = usePersistedColumnVisibility(
"virtual-keys",
KEY_TABLE_HIDDEN_COLUMNS,
);
const [filtersOpen, setFiltersOpen] = useState(false);
const searchInput = tableState.key_search;
const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS });
// A hand-edited sort_by the table cannot sort by would 400 at /key/list and leave the page loading.
const sortBy = KEY_TABLE_SORT_FIELDS.includes(tableState.sort_by) ? tableState.sort_by : DEFAULT_SORT_BY;
const sorting = useMemo<SortingState>(
() => [{ id: sortBy, desc: tableState.sort_order === "desc" }],
[sortBy, tableState.sort_order],
);
const tablePagination = useMemo<PaginationState>(
() => ({ pageIndex: tableState.page - 1, pageSize: tableState.page_size }),
[tableState.page, tableState.page_size],
);
const { filter_team, filter_org, filter_user, filter_key_id, filter_status } = tableState;
const appliedFilters = useMemo(
() => ({
team_id: filter_team.trim(),
org_id: filter_org.trim(),
user_id: filter_user.trim(),
key_hash: filter_key_id.trim(),
status: isKeyStatusFilter(filter_status) ? filter_status : "",
}),
[filter_team, filter_org, filter_user, filter_key_id, filter_status],
);
const columnFilters = useMemo<ColumnFiltersState>(
() =>
FILTER_COLUMNS.filter((column) => appliedFilters[column]).map((column) => ({
id: column,
value: appliedFilters[column],
})),
[appliedFilters],
);
const [activeSort] = sorting;
const keyListOptions = {
teamID: appliedFilters.team_id || undefined,
organizationID: appliedFilters.org_id || undefined,
teamID: appliedFilter(columnFilters, "team_id"),
organizationID: appliedFilter(columnFilters, "org_id"),
search: searchQuery.trim() || undefined,
userID: appliedFilters.user_id || undefined,
keyHash: appliedFilters.key_hash || undefined,
status: appliedFilters.status || undefined,
sortBy,
sortOrder: tableState.sort_order,
userID: appliedFilter(columnFilters, "user_id"),
keyHash: appliedFilter(columnFilters, "key_hash"),
status: appliedFilter(columnFilters, "status"),
sortBy: activeSort.id,
sortOrder: activeSort.desc ? "desc" : "asc",
expand: "user",
};
@ -155,55 +132,13 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
isPending,
isPlaceholderData,
isFetching,
isError,
refetch,
} = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, keyListOptions);
} = useKeys(pagination.pageIndex + 1, pagination.pageSize, keyListOptions);
const keyList = useMemo(() => keys?.keys ?? [], [keys]);
const rowCount = keys?.total_count ?? 0;
const handleSearchChange = useCallback(
(value: string) => {
void setTableState({ key_search: value || null, page: null });
},
[setTableState],
);
const handleSortingChange = useCallback<OnChangeFn<SortingState>>(
(updaterOrValue) => {
const active = functionalUpdate(updaterOrValue, sorting)[0];
void setTableState({
sort_by: active?.id ?? null,
sort_order: active ? toSortOrder(active) : null,
page: null,
});
},
[sorting, setTableState],
);
const handleColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>(
(updaterOrValue) => {
const next = functionalUpdate(updaterOrValue, columnFilters);
const nextFilters = {
filter_team: filterValue(next, "team_id"),
filter_org: filterValue(next, "org_id"),
filter_user: filterValue(next, "user_id"),
filter_key_id: filterValue(next, "key_hash"),
filter_status: filterValue(next, "status"),
page: null,
};
void setTableState(nextFilters);
},
[columnFilters, setTableState],
);
const handlePaginationChange = useCallback<OnChangeFn<PaginationState>>(
(updaterOrValue) => {
const next = functionalUpdate(updaterOrValue, tablePagination);
void setTableState({ page: next.pageIndex + 1, page_size: next.pageSize });
},
[tablePagination, setTableState],
);
const columns = useMemo(
() => getKeyTableColumns({ allTeams, organizations, onSelectKey: (key) => void setSelectedKeyId(key.token) }),
[allTeams, organizations, setSelectedKeyId],
@ -296,20 +231,22 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
data={keyList}
columns={columns}
getRowId={(row) => row.token}
defaultColumnVisibility={KEY_TABLE_HIDDEN_COLUMNS}
columnVisibility={columnVisibility}
onColumnVisibilityChange={onColumnVisibilityChange}
sortingMode="server"
sorting={sorting}
onSortingChange={handleSortingChange}
onSortingChange={onSortingChange}
paginationMode="server"
pagination={tablePagination}
onPaginationChange={handlePaginationChange}
pagination={pagination}
onPaginationChange={onPaginationChange}
rowCount={rowCount}
filterMode="server"
columnFilters={columnFilters}
onColumnFiltersChange={handleColumnFiltersChange}
onColumnFiltersChange={onColumnFiltersChange}
enableColumnResizing
columnResizeMode="onChange"
isLoading={isPending || isPlaceholderData}
isError={isError}
loadingMessage="Loading keys..."
noDataMessage="No keys found"
fillHeight
@ -319,7 +256,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
<DataTableToolbar
table={table}
searchValue={searchInput}
onSearchChange={handleSearchChange}
onSearchChange={setSearch}
searchPlaceholder="Search by key alias or ID…"
onRefresh={() => refetch?.()}
isRefreshing={isFetching}

View file

@ -20,25 +20,39 @@ describe("networking - expired session handling", () => {
global.fetch = originalFetch;
});
it("should call clearTokenCookies on expired session", async () => {
const errorData = "Authentication Error - Expired Key";
const { toast } = await import("@/lib/toast");
const loadFreshHandleError = async () => {
vi.resetModules();
const fresh = await import("./networking");
return fresh.handleError;
};
if (errorData.includes("Authentication Error - Expired Key")) {
toast.info("UI Session Expired. Logging out.");
clearTokenCookies();
}
const stubLocation = (pathname: string, search: string, hash: string) => {
const location = { pathname, search, hash, href: "" };
vi.stubGlobal("window", { location });
return location;
};
afterEach(() => {
vi.unstubAllGlobals();
});
it("keeps the query string and hash on the redirect after session expiry", async () => {
const handleError = await loadFreshHandleError();
const location = stubLocation("/ui/api-keys/", "?filter_team=t1&page=2", "#row-3");
await handleError("Authentication Error - Expired Key");
expect(location.href).toBe("/ui/api-keys/?filter_team=t1&page=2#row-3");
expect(clearTokenCookies).toHaveBeenCalledOnce();
});
it("should not clear cookies for non-authentication errors", () => {
const errorData = "Some other error";
it("does not navigate or clear cookies for other errors", async () => {
const handleError = await loadFreshHandleError();
const location = stubLocation("/ui/api-keys/", "?filter_team=t1&page=2", "");
if (errorData.includes("Authentication Error - Expired Key")) {
clearTokenCookies();
}
await handleError("Some other error");
expect(location.href).toBe("");
expect(clearTokenCookies).not.toHaveBeenCalled();
});

View file

@ -383,7 +383,7 @@ export const handleError = async (errorData: string | any) => {
clearTokenCookies();
const browserLocation = getWindowLocation();
if (browserLocation) {
window.location.href = browserLocation.pathname;
window.location.href = browserLocation.pathname + browserLocation.search + browserLocation.hash;
}
}
lastErrorTime = currentTime;

View file

@ -1,4 +1,10 @@
import type { ColumnDef, PaginationState, RowSelectionState, SortingState } from "@tanstack/react-table";
import type {
ColumnDef,
PaginationState,
RowSelectionState,
SortingState,
VisibilityState,
} from "@tanstack/react-table";
import { DataTable } from "./DataTable";
@ -12,6 +18,7 @@ const columns: ColumnDef<Row, unknown>[] = [];
const sorting: SortingState = [{ id: "name", desc: false }];
const pagination: PaginationState = { pageIndex: 0, pageSize: 10 };
const rowSelection: RowSelectionState = { r1: true };
const columnVisibility: VisibilityState = { name: false };
const noop = () => {};
export const uncontrolled = <DataTable data={data} columns={columns} defaultSorting={sorting} />;
@ -32,6 +39,8 @@ export const controlled = (
onColumnFiltersChange={noop}
rowSelection={rowSelection}
onRowSelectionChange={noop}
columnVisibility={columnVisibility}
onColumnVisibilityChange={noop}
/>
);
@ -65,3 +74,19 @@ export const selectionWithoutHandler = (
// @ts-expect-error a controlled `rowSelection` needs `onRowSelectionChange` or selection changes are dropped
<DataTable data={data} columns={columns} rowSelection={rowSelection} />
);
export const visibilityWithoutHandler = (
// @ts-expect-error a controlled `columnVisibility` needs `onColumnVisibilityChange` or Columns-menu toggles are dropped
<DataTable data={data} columns={columns} columnVisibility={columnVisibility} />
);
export const bothVisibilitySources = (
// @ts-expect-error `defaultColumnVisibility` seeds uncontrolled visibility, so it cannot pair with a controlled `columnVisibility`
<DataTable
data={data}
columns={columns}
defaultColumnVisibility={columnVisibility}
columnVisibility={columnVisibility}
onColumnVisibilityChange={noop}
/>
);

View file

@ -1,4 +1,4 @@
import type { ColumnDef, ExpandedState, OnChangeFn, PaginationState } from "@tanstack/react-table";
import type { ColumnDef, ExpandedState, OnChangeFn, PaginationState, VisibilityState } from "@tanstack/react-table";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
@ -278,11 +278,18 @@ describe("DataTable pagination", () => {
type ServerPageHarnessProps = {
rowCount: number;
isLoading?: boolean;
isError?: boolean;
initialPageIndex: number;
onChange: (next: PaginationState) => void;
};
function ServerPageHarness({ rowCount, isLoading = false, initialPageIndex, onChange }: ServerPageHarnessProps) {
function ServerPageHarness({
rowCount,
isLoading = false,
isError = false,
initialPageIndex,
onChange,
}: ServerPageHarnessProps) {
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: initialPageIndex, pageSize: 10 });
const handleChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
@ -298,6 +305,7 @@ describe("DataTable pagination", () => {
onPaginationChange={handleChange}
rowCount={rowCount}
isLoading={isLoading}
isError={isError}
/>
);
}
@ -339,6 +347,102 @@ describe("DataTable pagination", () => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
});
it("server mode keeps a deep-linked page when the fetch failed, instead of snapping to page 1 on rowCount 0", async () => {
const onChange = vi.fn();
render(<ServerPageHarness rowCount={0} isError initialPageIndex={2} onChange={onChange} />);
expect(screen.getByText("Page 3 of 1")).toBeInTheDocument();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(onChange).not.toHaveBeenCalled();
});
type ClientPageHarnessProps = {
data: Person[];
isLoading?: boolean;
initialPageIndex: number;
onChange: (next: PaginationState) => void;
};
function ClientPageHarness({ data, isLoading = false, initialPageIndex, onChange }: ClientPageHarnessProps) {
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: initialPageIndex, pageSize: 2 });
const handleChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
onChange(next);
setPagination(next);
};
return (
<DataTable
data={data}
columns={nameCellColumns}
paginationMode="client"
pageSizeOptions={[2]}
pagination={pagination}
onPaginationChange={handleChange}
isLoading={isLoading}
/>
);
}
it("client mode keeps a controlled page when rows arrive after loading and when they are refetched", async () => {
const onChange = vi.fn();
const { rerender } = render(<ClientPageHarness data={[]} isLoading initialPageIndex={1} onChange={onChange} />);
rerender(<ClientPageHarness data={fivePeople} initialPageIndex={1} onChange={onChange} />);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(names()).toEqual(["P2", "P3"]);
rerender(<ClientPageHarness data={[...fivePeople]} initialPageIndex={1} onChange={onChange} />);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(names()).toEqual(["P2", "P3"]);
expect(onChange).not.toHaveBeenCalled();
});
it("client mode snaps a controlled page past the end back to the last page", async () => {
const onChange = vi.fn();
render(<ClientPageHarness data={fivePeople} initialPageIndex={5} onChange={onChange} />);
await waitFor(() => expect(onChange).toHaveBeenCalledWith({ pageIndex: 2, pageSize: 2 }));
expect(onChange).toHaveBeenCalledTimes(1);
expect(names()).toEqual(["P4"]);
});
it("client mode leaves a controlled page alone while there are no rows to page through", async () => {
const onChange = vi.fn();
render(<ClientPageHarness data={[]} initialPageIndex={3} onChange={onChange} />);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(onChange).not.toHaveBeenCalled();
});
it("client mode without a controlled page still returns to the first page when the rows change", async () => {
const user = userEvent.setup();
const { rerender } = render(
<DataTable data={fivePeople} columns={nameCellColumns} paginationMode="client" pageSizeOptions={[2]} />,
);
await user.click(screen.getByTestId("pagination-next"));
expect(names()).toEqual(["P2", "P3"]);
rerender(
<DataTable data={[...fivePeople]} columns={nameCellColumns} paginationMode="client" pageSizeOptions={[2]} />,
);
await waitFor(() => expect(names()).toEqual(["P0", "P1"]));
});
it("server mode resumes clamping once the error clears and a real rowCount arrives", async () => {
const onChange = vi.fn();
const { rerender } = render(<ServerPageHarness rowCount={0} isError initialPageIndex={2} onChange={onChange} />);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(onChange).not.toHaveBeenCalled();
rerender(<ServerPageHarness rowCount={15} initialPageIndex={2} onChange={onChange} />);
await waitFor(() => expect(onChange).toHaveBeenCalledWith({ pageIndex: 1, pageSize: 10 }));
expect(onChange).toHaveBeenCalledTimes(1);
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
});
});
describe("DataTable filtering", () => {
@ -555,6 +659,69 @@ describe("DataTable column visibility", () => {
expect(await screen.findByTestId("view-option-email")).toBeInTheDocument();
expect(screen.queryByTestId("view-option-name")).not.toBeInTheDocument();
});
it("uncontrolled mode seeds hidden columns from defaultColumnVisibility and still toggles internally", async () => {
const user = userEvent.setup();
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={nameEmailColumns}
defaultColumnVisibility={{ email: false }}
toolbar={(table) => <DataTableViewOptions table={table} />}
/>,
);
expect(screen.queryByRole("columnheader", { name: "Email" })).not.toBeInTheDocument();
await user.click(screen.getByTestId("view-options-trigger"));
await user.click(await screen.findByTestId("view-option-email"));
expect(await screen.findByRole("columnheader", { name: "Email" })).toBeInTheDocument();
});
it("controlled mode hides columns from the prop and reports toggles without changing them locally", async () => {
const user = userEvent.setup();
const onColumnVisibilityChange = vi.fn<OnChangeFn<VisibilityState>>();
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={nameEmailColumns}
columnVisibility={{ email: false }}
onColumnVisibilityChange={onColumnVisibilityChange}
toolbar={(table) => <DataTableViewOptions table={table} />}
/>,
);
expect(screen.queryByRole("columnheader", { name: "Email" })).not.toBeInTheDocument();
await user.click(screen.getByTestId("view-options-trigger"));
await user.click(await screen.findByTestId("view-option-email"));
expect(onColumnVisibilityChange).toHaveBeenCalledTimes(1);
const updater = onColumnVisibilityChange.mock.calls[0]?.[0];
const next = typeof updater === "function" ? updater({ email: false }) : updater;
expect(next).toEqual({ email: true });
expect(screen.queryByRole("columnheader", { name: "Email" })).not.toBeInTheDocument();
});
it("controlled mode reveals the column once the parent applies the reported change", async () => {
const user = userEvent.setup();
const Harness = () => {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ email: false });
return (
<DataTable
data={CHARLIE_ALICE_BOB}
columns={nameEmailColumns}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
toolbar={(table) => <DataTableViewOptions table={table} />}
/>
);
};
render(<Harness />);
expect(screen.queryByRole("columnheader", { name: "Email" })).not.toBeInTheDocument();
await user.click(screen.getByTestId("view-options-trigger"));
await user.click(await screen.findByTestId("view-option-email"));
expect(await screen.findByRole("columnheader", { name: "Email" })).toBeInTheDocument();
});
});
describe("DataTable pinned columns", () => {

View file

@ -425,7 +425,7 @@ function useControllable<T>(
return { value: internal, onChange: setInternal };
}
function useServerPageClamp(
function usePageClamp(
active: boolean,
rowCount: number | undefined,
pagination: { value: PaginationState; onChange: OnChangeFn<PaginationState> },
@ -457,6 +457,7 @@ function useDataTableInstance<TData extends RowData, TValue>(
onPaginationChange,
rowCount,
isLoading = false,
isError,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
filterMode = "none",
columnFilters,
@ -466,6 +467,8 @@ function useDataTableInstance<TData extends RowData, TValue>(
onGlobalFilterChange,
enableColumnResizing = false,
columnResizeMode = "onEnd",
columnVisibility,
onColumnVisibilityChange,
defaultColumnVisibility,
getRowCanExpand,
renderSubComponent,
@ -481,7 +484,6 @@ function useDataTableInstance<TData extends RowData, TValue>(
pageIndex: 0,
pageSize: pageSizeOptions[0] ?? 25,
});
useServerPageClamp(paginationMode === "server" && !isLoading, rowCount, paginationState);
const filterState = useControllable<ColumnFiltersState>(
columnFilters,
onColumnFiltersChange,
@ -490,7 +492,11 @@ function useDataTableInstance<TData extends RowData, TValue>(
const globalFilterState = useControllable<string>(globalFilter, onGlobalFilterChange, "");
const expandedState = useControllable<ExpandedState>(expanded, onExpandedChange, {});
const rowSelectionState = useControllable<RowSelectionState>(rowSelection, onRowSelectionChange, {});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(defaultColumnVisibility ?? {});
const columnVisibilityState = useControllable<VisibilityState>(
columnVisibility,
onColumnVisibilityChange,
defaultColumnVisibility ?? {},
);
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
const columnPinning = React.useMemo(() => derivePinning(columns), [columns]);
const expansionGuard = renderSubComponent !== undefined ? getRowCanExpand : undefined;
@ -505,7 +511,7 @@ function useDataTableInstance<TData extends RowData, TValue>(
globalFilter: globalFilterState.value,
expanded: expandedState.value,
rowSelection: rowSelectionState.value,
columnVisibility,
columnVisibility: columnVisibilityState.value,
columnSizing,
},
initialState: { columnPinning },
@ -521,7 +527,7 @@ function useDataTableInstance<TData extends RowData, TValue>(
onGlobalFilterChange: globalFilterState.onChange,
onExpandedChange: expandedState.onChange,
onRowSelectionChange: rowSelectionState.onChange,
onColumnVisibilityChange: setColumnVisibility,
onColumnVisibilityChange: columnVisibilityState.onChange,
onColumnSizingChange: setColumnSizing,
getColumnCanGlobalFilter: (column) => columnCanGlobalFilter(data[0], column),
getCoreRowModel: getCoreRowModel(),
@ -529,9 +535,38 @@ function useDataTableInstance<TData extends RowData, TValue>(
...(getRowId !== undefined ? { getRowId } : {}),
...(enableRowSelection !== undefined ? { enableRowSelection } : {}),
...(paginationMode === "server" && rowCount !== undefined ? { rowCount } : {}),
autoResetPageIndex: pagination === undefined && paginationMode !== "server",
};
return useReactTable(tableOptions);
const table = useReactTable(tableOptions);
const clampOptions: SettledPageClampOptions = {
paginationMode,
controlled: pagination !== undefined,
settled: !isLoading && !isError,
rowCount,
pagination: paginationState,
};
useSettledPageClamp(table, clampOptions);
return table;
}
type SettledPageClampOptions = {
paginationMode: PaginationMode;
controlled: boolean;
settled: boolean;
rowCount: number | undefined;
pagination: { value: PaginationState; onChange: OnChangeFn<PaginationState> };
};
function useSettledPageClamp<TData extends RowData>(table: Table<TData>, options: SettledPageClampOptions): void {
const { paginationMode, controlled, settled, rowCount, pagination } = options;
const clientRowCount = paginationMode === "client" ? table.getPrePaginationRowModel().rows.length : 0;
const clientPageIsClampable = paginationMode === "client" && controlled && clientRowCount > 0;
usePageClamp(
settled && (paginationMode === "server" || clientPageIsClampable),
paginationMode === "server" ? rowCount : clientRowCount,
pagination,
);
}
export function DataTable<TData extends RowData, TValue>(props: DataTableProps<TData, TValue>) {

View file

@ -12,6 +12,8 @@ export {
type DataTableSortVariant,
type DataTableSortField,
} from "./DataTableSortHeader";
export { usePersistedColumnVisibility } from "./usePersistedColumnVisibility";
export { useUrlTableState, type UrlTableState, type UrlTableStateOptions } from "./useUrlTableState";
export type { DataTablePaginationProps } from "./DataTablePagination";
export type {
ColumnPinnedSide,

View file

@ -27,6 +27,7 @@ export interface DataTableResolvedProps<TData extends RowData, TValue> {
getRowId?: (row: TData, index: number, parent?: Row<TData>) => string;
isLoading?: boolean;
isError?: boolean;
loadingMessage?: string;
skeletonRowCount?: number;
noDataMessage?: React.ReactNode;
@ -53,6 +54,8 @@ export interface DataTableResolvedProps<TData extends RowData, TValue> {
enableColumnResizing?: boolean;
columnResizeMode?: ColumnResizeMode;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
defaultColumnVisibility?: VisibilityState;
getRowCanExpand?: (row: Row<TData>) => boolean;
@ -96,6 +99,9 @@ type DataTableBaseProps<TData extends RowData, TValue> = Omit<
| "columnFilters"
| "onColumnFiltersChange"
| "defaultColumnFilters"
| "columnVisibility"
| "onColumnVisibilityChange"
| "defaultColumnVisibility"
| "rowSelection"
| "onRowSelectionChange"
>;
@ -142,6 +148,18 @@ type FilterProps =
defaultColumnFilters?: ColumnFiltersState;
};
type ColumnVisibilityProps =
| {
columnVisibility: VisibilityState;
onColumnVisibilityChange: OnChangeFn<VisibilityState>;
defaultColumnVisibility?: never;
}
| {
columnVisibility?: never;
onColumnVisibilityChange?: never;
defaultColumnVisibility?: VisibilityState;
};
type RowSelectionProps =
| { rowSelection: RowSelectionState; onRowSelectionChange: OnChangeFn<RowSelectionState> }
| { rowSelection?: never; onRowSelectionChange?: OnChangeFn<RowSelectionState> };
@ -150,4 +168,5 @@ export type DataTableProps<TData extends RowData, TValue> = DataTableBaseProps<T
SortingProps &
PaginationProps &
FilterProps &
ColumnVisibilityProps &
RowSelectionProps;

View file

@ -0,0 +1,194 @@
import type { VisibilityState } from "@tanstack/react-table";
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { usePersistedColumnVisibility } from "./usePersistedColumnVisibility";
const keyFor = (tableId: string): string => `litellm_table_columns_${tableId}`;
const stored = (tableId: string): unknown => {
const raw = localStorage.getItem(keyFor(tableId));
return raw === null ? null : JSON.parse(raw);
};
const showEveryColumn = (previous: VisibilityState): VisibilityState =>
Object.fromEntries(Object.keys(previous).map((column) => [column, true]));
describe("usePersistedColumnVisibility", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
it("layers the stored choices over the defaults, so a default added after the snapshot still applies", () => {
localStorage.setItem(keyFor("keys"), JSON.stringify({ email: false, spend: true }));
const { result } = renderHook(() => usePersistedColumnVisibility("keys", { spend: false, name: false }));
expect(result.current.columnVisibility).toEqual({ email: false, spend: true, name: false });
});
it("falls back to the defaults when nothing is stored, and to {} without defaults", () => {
const withDefaults = renderHook(() => usePersistedColumnVisibility("keys", { spend: false }));
expect(withDefaults.result.current.columnVisibility).toEqual({ spend: false });
const bare = renderHook(() => usePersistedColumnVisibility("keys"));
expect(bare.result.current.columnVisibility).toEqual({});
});
it("writes an object update to state and storage", () => {
const { result } = renderHook(() => usePersistedColumnVisibility("keys"));
act(() => result.current.onColumnVisibilityChange({ email: false }));
expect(result.current.columnVisibility).toEqual({ email: false });
expect(stored("keys")).toEqual({ email: false });
});
it("resolves a function updater against the current state before persisting", () => {
localStorage.setItem(keyFor("keys"), JSON.stringify({ email: false }));
const { result } = renderHook(() => usePersistedColumnVisibility("keys"));
act(() => result.current.onColumnVisibilityChange((previous) => ({ ...previous, name: false })));
expect(result.current.columnVisibility).toEqual({ email: false, name: false });
expect(stored("keys")).toEqual({ email: false, name: false });
});
it("hands a function updater the default-hidden columns, so showing every column sticks", () => {
const { result } = renderHook(() => usePersistedColumnVisibility("keys", { spend: false }));
act(() => result.current.onColumnVisibilityChange(showEveryColumn));
expect(result.current.columnVisibility).toEqual({ spend: true });
expect(stored("keys")).toEqual({ spend: true });
});
it.each([
["truncated JSON", '{"email":fal'],
["a JSON scalar", "42"],
["a JSON array", "[true]"],
["non-boolean values", JSON.stringify({ email: "no" })],
])("falls back to the defaults when storage holds %s", (_label, raw) => {
localStorage.setItem(keyFor("keys"), raw);
const { result } = renderHook(() => usePersistedColumnVisibility("keys", { spend: false }));
expect(result.current.columnVisibility).toEqual({ spend: false });
});
it("keeps distinct tableIds isolated in state and storage", () => {
const keys = renderHook(() => usePersistedColumnVisibility("keys"));
const teams = renderHook(() => usePersistedColumnVisibility("teams"));
act(() => keys.result.current.onColumnVisibilityChange({ email: false }));
expect(keys.result.current.columnVisibility).toEqual({ email: false });
expect(teams.result.current.columnVisibility).toEqual({});
expect(stored("keys")).toEqual({ email: false });
expect(stored("teams")).toBeNull();
});
it("reads and writes the new table's columns after the tableId changes", () => {
localStorage.setItem(keyFor("keys"), JSON.stringify({ email: false }));
localStorage.setItem(keyFor("teams"), JSON.stringify({ spend: false }));
const { result, rerender } = renderHook(({ tableId }) => usePersistedColumnVisibility(tableId), {
initialProps: { tableId: "keys" },
});
rerender({ tableId: "teams" });
expect(result.current.columnVisibility).toEqual({ spend: false });
act(() => result.current.onColumnVisibilityChange((previous) => ({ ...previous, name: false })));
expect(stored("teams")).toEqual({ spend: false, name: false });
expect(stored("keys")).toEqual({ email: false });
});
it("applies new defaults passed after mount", () => {
const initialProps: { defaults: VisibilityState } = { defaults: { spend: false } };
const { result, rerender } = renderHook(({ defaults }) => usePersistedColumnVisibility("keys", defaults), {
initialProps,
});
rerender({ defaults: { name: false } });
expect(result.current.columnVisibility).toEqual({ name: false });
});
it("shows a change another tab saved for the same table", () => {
const { result } = renderHook(() => usePersistedColumnVisibility("keys"));
act(() => {
localStorage.setItem(keyFor("keys"), JSON.stringify({ email: false }));
window.dispatchEvent(new StorageEvent("storage", { key: keyFor("keys") }));
});
expect(result.current.columnVisibility).toEqual({ email: false });
});
it("keeps a toggle that storage refused, and saves the next one once storage accepts it", () => {
localStorage.setItem(keyFor("full"), JSON.stringify({ spend: false }));
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(Storage.prototype, "setItem").mockImplementationOnce(() => {
throw new Error("QuotaExceededError");
});
const { result } = renderHook(() => usePersistedColumnVisibility("full"));
act(() => result.current.onColumnVisibilityChange({ email: false }));
expect(result.current.columnVisibility).toEqual({ email: false });
expect(stored("full")).toEqual({ spend: false });
act(() => result.current.onColumnVisibilityChange({ name: false }));
expect(result.current.columnVisibility).toEqual({ name: false });
expect(stored("full")).toEqual({ name: false });
});
it("shows another tab's save over a toggle this tab could not save", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(Storage.prototype, "setItem").mockImplementationOnce(() => {
throw new Error("QuotaExceededError");
});
const { result } = renderHook(() => usePersistedColumnVisibility("shadowed"));
act(() => result.current.onColumnVisibilityChange({ email: false }));
act(() => {
localStorage.setItem(keyFor("shadowed"), JSON.stringify({ name: false }));
window.dispatchEvent(new StorageEvent("storage", { key: keyFor("shadowed") }));
});
expect(result.current.columnVisibility).toEqual({ name: false });
});
it("drops a toggle this tab could not save once another tab clears storage", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(Storage.prototype, "setItem").mockImplementationOnce(() => {
throw new Error("QuotaExceededError");
});
const { result } = renderHook(() => usePersistedColumnVisibility("cleared", { spend: false }));
act(() => result.current.onColumnVisibilityChange({ email: false }));
act(() => window.dispatchEvent(new StorageEvent("storage", { key: null })));
expect(result.current.columnVisibility).toEqual({ spend: false });
});
it("returns the defaults without throwing when storage is unavailable", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("SecurityError");
});
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("SecurityError");
});
const { result } = renderHook(() => usePersistedColumnVisibility("blocked", { spend: false }));
expect(result.current.columnVisibility).toEqual({ spend: false });
act(() => result.current.onColumnVisibilityChange((previous) => ({ ...previous, email: false })));
expect(result.current.columnVisibility).toEqual({ spend: false, email: false });
});
});

View file

@ -0,0 +1,96 @@
import type { OnChangeFn, VisibilityState } from "@tanstack/react-table";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import {
LOCAL_STORAGE_EVENT,
emitLocalStorageChange,
getLocalStorageItem,
setLocalStorageItem,
} from "@/utils/localStorageUtils";
const STORAGE_KEY_PREFIX = "litellm_table_columns_";
const EMPTY_VISIBILITY: VisibilityState = {};
const unsavedWrites = new Map<string, string>();
function storageKey(tableId: string): string {
return `${STORAGE_KEY_PREFIX}${tableId}`;
}
function forgetUnsavedWrite(event: StorageEvent): void {
if (event.key === null) {
unsavedWrites.clear();
return;
}
unsavedWrites.delete(event.key);
}
function subscribe(onChange: () => void): () => void {
const onStorage = (event: StorageEvent): void => {
forgetUnsavedWrite(event);
onChange();
};
window.addEventListener("storage", onStorage);
window.addEventListener(LOCAL_STORAGE_EVENT, onChange);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(LOCAL_STORAGE_EVENT, onChange);
};
}
function readRaw(key: string): string | null {
return unsavedWrites.get(key) ?? getLocalStorageItem(key);
}
function writeRaw(key: string, raw: string): void {
setLocalStorageItem(key, raw);
if (getLocalStorageItem(key) === raw) {
unsavedWrites.delete(key);
} else {
unsavedWrites.set(key, raw);
}
emitLocalStorageChange(key);
}
function isVisibilityState(value: unknown): value is VisibilityState {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
return Object.values(value).every((visible) => typeof visible === "boolean");
}
function parseVisibility(raw: string | null, defaults: VisibilityState): VisibilityState {
if (raw === null) {
return defaults;
}
try {
const parsed: unknown = JSON.parse(raw);
return isVisibilityState(parsed) ? { ...defaults, ...parsed } : defaults;
} catch {
return defaults;
}
}
export function usePersistedColumnVisibility(
tableId: string,
defaults: VisibilityState = EMPTY_VISIBILITY,
): { columnVisibility: VisibilityState; onColumnVisibilityChange: OnChangeFn<VisibilityState> } {
const key = storageKey(tableId);
const raw = useSyncExternalStore(
subscribe,
() => readRaw(key),
() => null,
);
const columnVisibility = useMemo(() => parseVisibility(raw, defaults), [raw, defaults]);
const onColumnVisibilityChange = useCallback<OnChangeFn<VisibilityState>>(
(updater) => {
const next = typeof updater === "function" ? updater(parseVisibility(readRaw(key), defaults)) : updater;
writeRaw(key, JSON.stringify(next));
},
[key, defaults],
);
return { columnVisibility, onColumnVisibilityChange };
}

View file

@ -0,0 +1,325 @@
import { SortingState } from "@tanstack/react-table";
import { act, renderHook, waitFor } from "@testing-library/react";
import { withNuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { describe, expect, it, Mock, vi } from "vitest";
import { useUrlTableState, type UrlTableStateOptions } from "./useUrlTableState";
const FILTER_COLUMNS = ["team_id", "user_id"] as const;
type FilterColumn = (typeof FILTER_COLUMNS)[number];
const BASE_OPTIONS: UrlTableStateOptions<FilterColumn> = {
sortFields: ["created_at", "spend", "key_alias"],
defaultSort: { id: "created_at", desc: true },
defaultPageSize: 50,
filterColumns: FILTER_COLUMNS,
};
const PREFIXED_AND_UNPREFIXED_PARAMS = {
audit_page: "2",
audit_page_size: "10",
audit_search: "prefixed",
audit_sort_by: "spend",
audit_sort_order: "asc",
audit_filter_team_id: "team-1",
page: "5",
search: "unprefixed",
filter_team_id: "other-team",
};
const RENAMED_AND_DEFAULT_PARAMS = {
key_search: "prod",
filter_team: "team-1",
search: "ignored",
filter_team_id: "ignored",
};
const flipDirection = (previous: SortingState): SortingState => previous.map((sort) => ({ ...sort, desc: !sort.desc }));
const renderTableState = (
searchParams: Record<string, string> = {},
overrides: Partial<UrlTableStateOptions<FilterColumn>> = {},
) => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const options = { ...BASE_OPTIONS, ...overrides };
const hook = renderHook(() => useUrlTableState(options), {
wrapper: withNuqsTestingAdapter({ searchParams, onUrlUpdate, hasMemory: true }),
});
return { ...hook, onUrlUpdate };
};
const lastUrl = (onUrlUpdate: Mock<OnUrlUpdateFunction>) => {
const event = onUrlUpdate.mock.calls.at(-1)?.[0];
if (!event) throw new Error("no URL update was emitted");
return event;
};
const flushUrl = async (onUrlUpdate: Mock<OnUrlUpdateFunction>, write: () => void) => {
const callsBefore = onUrlUpdate.mock.calls.length;
await act(async () => {
write();
});
await waitFor(() => expect(onUrlUpdate.mock.calls.length).toBeGreaterThan(callsBefore));
return lastUrl(onUrlUpdate).searchParams;
};
describe("reading table state from the URL", () => {
it("falls back to the defaults when the URL carries no table state", () => {
const { result } = renderTableState();
expect(result.current.search).toBe("");
expect(result.current.sorting).toEqual([{ id: "created_at", desc: true }]);
expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 50 });
expect(result.current.columnFilters).toEqual([]);
});
it("maps the 1-based page and page_size onto TanStack pagination", () => {
const { result } = renderTableState({ page: "3", page_size: "25" });
expect(result.current.pagination).toEqual({ pageIndex: 2, pageSize: 25 });
});
it.each(["0", "-3", "not-a-number"])("clamps a page of %s up to the first page", (page) => {
const { result } = renderTableState({ page });
expect(result.current.pagination.pageIndex).toBe(0);
});
it.each([
["1000", undefined, 100],
["1000", 20, 20],
["0", undefined, 1],
])("clamps a page_size of %s with maxPageSize %s to %s", (pageSize, maxPageSize, expected) => {
const { result } = renderTableState({ page_size: pageSize }, { maxPageSize });
expect(result.current.pagination.pageSize).toBe(expected);
});
it("reads a sortable sort_by and its sort_order", () => {
const { result } = renderTableState({ sort_by: "spend", sort_order: "asc" });
expect(result.current.sorting).toEqual([{ id: "spend", desc: false }]);
});
it("resolves a sort_by outside the allow-list to the default column while keeping the URL's direction", () => {
const { result } = renderTableState({ sort_by: "totally_unknown", sort_order: "asc" });
expect(result.current.sorting).toEqual([{ id: "created_at", desc: false }]);
});
it("maps filter_<column> params onto columnFilters, trimming whitespace and dropping blanks", () => {
const { result } = renderTableState({ filter_team_id: "team-1", filter_user_id: " " });
expect(result.current.columnFilters).toEqual([{ id: "team_id", value: "team-1" }]);
const trimmed = renderTableState({ filter_user_id: " user-42 " });
expect(trimmed.result.current.columnFilters).toEqual([{ id: "user_id", value: "user-42" }]);
});
it("reads the search term verbatim so the input can hold trailing spaces", () => {
const { result } = renderTableState({ search: "prod " });
expect(result.current.search).toBe("prod ");
});
it("reads every key under keyPrefix and ignores the unprefixed ones", () => {
const { result } = renderTableState(PREFIXED_AND_UNPREFIXED_PARAMS, { keyPrefix: "audit_" });
expect(result.current.pagination).toEqual({ pageIndex: 1, pageSize: 10 });
expect(result.current.search).toBe("prefixed");
expect(result.current.sorting).toEqual([{ id: "spend", desc: false }]);
expect(result.current.columnFilters).toEqual([{ id: "team_id", value: "team-1" }]);
});
it("reads renamed keys from urlKeys and ignores the default names", () => {
const { result } = renderTableState(RENAMED_AND_DEFAULT_PARAMS, {
urlKeys: { search: "key_search", filter_team_id: "filter_team" },
});
expect(result.current.search).toBe("prod");
expect(result.current.columnFilters).toEqual([{ id: "team_id", value: "team-1" }]);
});
it("applies keyPrefix in front of a renamed key", () => {
const { result } = renderTableState(
{ audit_key_search: "prod", key_search: "ignored" },
{ keyPrefix: "audit_", urlKeys: { search: "key_search" } },
);
expect(result.current.search).toBe("prod");
});
});
describe("writing table state to the URL", () => {
it("resolves a function updater against the current pagination and replaces history", async () => {
const { result, onUrlUpdate } = renderTableState({ page: "2" });
const url = await flushUrl(onUrlUpdate, () =>
result.current.onPaginationChange((previous) => ({ ...previous, pageIndex: previous.pageIndex + 1 })),
);
expect(url.get("page")).toBe("3");
expect(url.has("page_size")).toBe(false);
expect(lastUrl(onUrlUpdate).options.history).toBe("replace");
expect(result.current.pagination).toEqual({ pageIndex: 2, pageSize: 50 });
});
it("writes page_size and drops it again once it returns to the default", async () => {
const { result, onUrlUpdate } = renderTableState();
const withSize = await flushUrl(onUrlUpdate, () =>
result.current.onPaginationChange({ pageIndex: 0, pageSize: 25 }),
);
expect(withSize.get("page_size")).toBe("25");
expect(withSize.has("page")).toBe(false);
const backToDefault = await flushUrl(onUrlUpdate, () =>
result.current.onPaginationChange({ pageIndex: 0, pageSize: 50 }),
);
expect(backToDefault.has("page_size")).toBe(false);
});
it("setSearch writes the term and returns to the first page", async () => {
const { result, onUrlUpdate } = renderTableState({ page: "3" });
const url = await flushUrl(onUrlUpdate, () => result.current.setSearch("prod"));
expect(url.get("search")).toBe("prod");
expect(url.has("page")).toBe(false);
expect(result.current.search).toBe("prod");
expect(result.current.pagination.pageIndex).toBe(0);
});
it("setSearch with an empty string removes the key", async () => {
const { result, onUrlUpdate } = renderTableState({ search: "prod" });
const url = await flushUrl(onUrlUpdate, () => result.current.setSearch(""));
expect(url.has("search")).toBe(false);
expect(result.current.search).toBe("");
});
it("onSortingChange writes sort_by and sort_order and returns to the first page", async () => {
const { result, onUrlUpdate } = renderTableState({ page: "3" });
const url = await flushUrl(onUrlUpdate, () => result.current.onSortingChange([{ id: "spend", desc: false }]));
expect(url.get("sort_by")).toBe("spend");
expect(url.get("sort_order")).toBe("asc");
expect(url.has("page")).toBe(false);
expect(result.current.sorting).toEqual([{ id: "spend", desc: false }]);
});
it("onSortingChange drops the keys when the sort matches the default or is cleared", async () => {
const { result, onUrlUpdate } = renderTableState({ sort_by: "spend", sort_order: "asc" });
const explicitDefault = await flushUrl(onUrlUpdate, () =>
result.current.onSortingChange([{ id: "created_at", desc: true }]),
);
expect(explicitDefault.has("sort_by")).toBe(false);
expect(explicitDefault.has("sort_order")).toBe(false);
await flushUrl(onUrlUpdate, () => result.current.onSortingChange([{ id: "key_alias", desc: false }]));
const cleared = await flushUrl(onUrlUpdate, () => result.current.onSortingChange([]));
expect(cleared.has("sort_by")).toBe(false);
expect(cleared.has("sort_order")).toBe(false);
expect(result.current.sorting).toEqual([{ id: "created_at", desc: true }]);
});
it("onSortingChange resolves a function updater against the current sort", async () => {
const { result, onUrlUpdate } = renderTableState({ sort_by: "spend" });
const url = await flushUrl(onUrlUpdate, () => result.current.onSortingChange(flipDirection));
expect(url.get("sort_by")).toBe("spend");
expect(url.get("sort_order")).toBe("asc");
expect(result.current.sorting).toEqual([{ id: "spend", desc: false }]);
});
it("onColumnFiltersChange writes trimmed filter_<column> keys and returns to the first page", async () => {
const { result, onUrlUpdate } = renderTableState({ page: "3" });
const url = await flushUrl(onUrlUpdate, () =>
result.current.onColumnFiltersChange([{ id: "team_id", value: " team-1 " }]),
);
expect(url.get("filter_team_id")).toBe("team-1");
expect(url.has("page")).toBe(false);
expect(result.current.columnFilters).toEqual([{ id: "team_id", value: "team-1" }]);
});
it("onColumnFiltersChange removes the key for an empty value and for a filter no longer present", async () => {
const { result, onUrlUpdate } = renderTableState({ filter_team_id: "team-1", filter_user_id: "user-42" });
const url = await flushUrl(onUrlUpdate, () => result.current.onColumnFiltersChange([{ id: "team_id", value: "" }]));
expect(url.has("filter_team_id")).toBe(false);
expect(url.has("filter_user_id")).toBe(false);
expect(result.current.columnFilters).toEqual([]);
});
it("onColumnFiltersChange ignores a non-string filter value", async () => {
const { result, onUrlUpdate } = renderTableState({ filter_team_id: "team-1" });
const url = await flushUrl(onUrlUpdate, () =>
result.current.onColumnFiltersChange([{ id: "team_id", value: ["team-1", "team-2"] }]),
);
expect(url.has("filter_team_id")).toBe(false);
});
it("onColumnFiltersChange resolves a function updater against the current filters", async () => {
const { result, onUrlUpdate } = renderTableState({ filter_team_id: "team-1" });
const url = await flushUrl(onUrlUpdate, () =>
result.current.onColumnFiltersChange((previous) => [...previous, { id: "user_id", value: "user-42" }]),
);
expect(url.get("filter_team_id")).toBe("team-1");
expect(url.get("filter_user_id")).toBe("user-42");
});
it("writes prefixed and renamed keys only", async () => {
const { result, onUrlUpdate } = renderTableState(
{},
{ keyPrefix: "audit_", urlKeys: { search: "key_search", filter_team_id: "filter_team" } },
);
await flushUrl(onUrlUpdate, () => result.current.setSearch("prod"));
await flushUrl(onUrlUpdate, () => result.current.onSortingChange([{ id: "spend", desc: false }]));
const url = await flushUrl(onUrlUpdate, () =>
result.current.onColumnFiltersChange([{ id: "team_id", value: "team-1" }]),
);
expect(url.get("audit_key_search")).toBe("prod");
expect(url.get("audit_sort_by")).toBe("spend");
expect(url.get("audit_filter_team")).toBe("team-1");
expect([...url.keys()].filter((key) => !key.startsWith("audit_"))).toEqual([]);
expect(url.has("audit_search")).toBe(false);
expect(url.has("audit_filter_team_id")).toBe(false);
});
});
describe("referential stability", () => {
it("keeps the TanStack state and the page-clamp handler stable across rerenders while the URL is unchanged", () => {
const { result, rerender } = renderTableState({ page: "2", filter_team_id: "team-1", sort_by: "spend" });
const first = result.current;
rerender();
expect(result.current.sorting).toBe(first.sorting);
expect(result.current.pagination).toBe(first.pagination);
expect(result.current.columnFilters).toBe(first.columnFilters);
expect(result.current.onPaginationChange).toBe(first.onPaginationChange);
});
it("hands out new pagination and untouched sorting after a page change", async () => {
const { result, onUrlUpdate } = renderTableState({ sort_by: "spend" });
const first = result.current;
await flushUrl(onUrlUpdate, () => result.current.onPaginationChange({ pageIndex: 4, pageSize: 50 }));
expect(result.current.pagination).not.toBe(first.pagination);
expect(result.current.pagination.pageIndex).toBe(4);
expect(result.current.sorting).toBe(first.sorting);
});
});

View file

@ -0,0 +1,232 @@
import { ColumnFiltersState, functionalUpdate, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { createParser, Nullable, parseAsInteger, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
import { useCallback, useMemo } from "react";
const SORT_ORDERS = ["asc", "desc"] as const;
type SortOrder = (typeof SORT_ORDERS)[number];
const STANDARD_KEYS = ["search", "sort_by", "sort_order", "page", "page_size"] as const;
type StandardKey = (typeof STANDARD_KEYS)[number];
type FilterStateKey<F extends string> = `filter_${F}`;
type StateKey<F extends string> = StandardKey | FilterStateKey<F>;
const MAX_PAGE = 100_000;
const DEFAULT_MAX_PAGE_SIZE = 100;
export interface UrlTableStateOptions<F extends string> {
sortFields: readonly string[];
defaultSort: { id: string; desc: boolean };
defaultPageSize: number;
maxPageSize?: number;
filterColumns: readonly F[];
keyPrefix?: string;
urlKeys?: Partial<Record<StateKey<F>, string>>;
}
export interface UrlTableState {
search: string;
setSearch: (value: string) => void;
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
columnFilters: ColumnFiltersState;
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>;
}
const boundedInteger = (min: number, max: number, fallback: number) =>
createParser({
parse: (value: string) => {
const parsed = parseAsInteger.parse(value);
return parsed === null ? null : Math.min(Math.max(parsed, min), max);
},
serialize: String,
}).withDefault(fallback);
const optionalString = parseAsString.withDefault("");
type OptionalStringParser = typeof optionalString;
const sortOrderParser = (fallback: SortOrder) => parseAsStringLiteral(SORT_ORDERS).withDefault(fallback);
interface StandardValues {
search: string;
sort_by: string;
sort_order: SortOrder;
page: number;
page_size: number;
}
type FilterValues<F extends string> = Record<FilterStateKey<F>, string>;
type StandardUpdate = Partial<Nullable<StandardValues>>;
type FilterUpdate<F extends string> = Record<FilterStateKey<F>, string | null> & Pick<Nullable<StandardValues>, "page">;
type SetTableValues<F extends string> = (update: StandardUpdate | FilterUpdate<F> | null) => Promise<URLSearchParams>;
interface TableQueryState<F extends string> {
values: StandardValues;
filters: FilterValues<F>;
setValues: SetTableValues<F>;
}
type TableParsers<F extends string> = {
search: OptionalStringParser;
sort_by: OptionalStringParser;
sort_order: ReturnType<typeof sortOrderParser>;
page: ReturnType<typeof boundedInteger>;
page_size: ReturnType<typeof boundedInteger>;
} & Record<FilterStateKey<F>, OptionalStringParser>;
const useTableQueryStates = <F extends string>(
parsers: TableParsers<F>,
urlKeys: Record<StateKey<F>, string>,
): TableQueryState<F> => {
const [state, setState] = useQueryStates(parsers, { urlKeys });
return useMemo(
() => ({
values: state as StandardValues,
filters: state as FilterValues<F>,
setValues: setState as SetTableValues<F>,
}),
[state, setState],
);
};
const filterStateKey = <F extends string>(column: F): FilterStateKey<F> => `filter_${column}`;
const filterParsers = <F extends string>(filterColumns: readonly F[]) =>
Object.fromEntries(filterColumns.map((column) => [filterStateKey(column), optionalString])) as Record<
FilterStateKey<F>,
OptionalStringParser
>;
const resolveUrlKeys = <F extends string>(
filterColumns: readonly F[],
keyPrefix: string,
renamed: Partial<Record<StateKey<F>, string>>,
) => {
const stateKeys: readonly StateKey<F>[] = [
...STANDARD_KEYS,
...filterColumns.map((column) => filterStateKey(column)),
];
return Object.fromEntries(stateKeys.map((key) => [key, `${keyPrefix}${renamed[key] ?? key}`])) as Record<
StateKey<F>,
string
>;
};
const filterValue = (filters: ColumnFiltersState, column: string): string | null => {
const value = filters.find((filter) => filter.id === column)?.value;
return (typeof value === "string" ? value.trim() : "") || null;
};
const filterUpdates = <F extends string>(filterColumns: readonly F[], filters: ColumnFiltersState) =>
Object.fromEntries(filterColumns.map((column) => [filterStateKey(column), filterValue(filters, column)])) as Record<
FilterStateKey<F>,
string | null
>;
const toSortOrder = (active: SortingState[number]): SortOrder => (active.desc ? "desc" : "asc");
export function useUrlTableState<F extends string>(options: UrlTableStateOptions<F>): UrlTableState {
const {
sortFields,
defaultSort,
defaultPageSize,
maxPageSize = DEFAULT_MAX_PAGE_SIZE,
filterColumns,
keyPrefix = "",
urlKeys: renamedKeys,
} = options;
const defaultSortId = defaultSort.id;
const defaultSortOrder: SortOrder = defaultSort.desc ? "desc" : "asc";
const parsers = useMemo<TableParsers<F>>(
() => ({
search: optionalString,
sort_by: parseAsString.withDefault(defaultSortId),
sort_order: sortOrderParser(defaultSortOrder),
page: boundedInteger(1, MAX_PAGE, 1),
page_size: boundedInteger(1, maxPageSize, defaultPageSize),
...filterParsers(filterColumns),
}),
[defaultSortId, defaultSortOrder, defaultPageSize, maxPageSize, filterColumns],
);
const urlKeys = useMemo(
() => resolveUrlKeys(filterColumns, keyPrefix, renamedKeys ?? {}),
[filterColumns, keyPrefix, renamedKeys],
);
const { values, filters, setValues } = useTableQueryStates(parsers, urlKeys);
const sortBy = sortFields.includes(values.sort_by) ? values.sort_by : defaultSortId;
const sortDesc = values.sort_order === "desc";
const sorting = useMemo<SortingState>(() => [{ id: sortBy, desc: sortDesc }], [sortBy, sortDesc]);
const pagination = useMemo<PaginationState>(
() => ({ pageIndex: values.page - 1, pageSize: values.page_size }),
[values.page, values.page_size],
);
const columnFilters = useMemo<ColumnFiltersState>(
() =>
filterColumns.flatMap((column) => {
const value = filters[filterStateKey(column)].trim();
return value ? [{ id: column, value }] : [];
}),
[filterColumns, filters],
);
const setSearch = useCallback(
(value: string) => {
void setValues({ search: value || null, page: null });
},
[setValues],
);
const onSortingChange = useCallback<OnChangeFn<SortingState>>(
(updaterOrValue) => {
const active = functionalUpdate(updaterOrValue, sorting)[0];
void setValues({
sort_by: active?.id ?? null,
sort_order: active ? toSortOrder(active) : null,
page: null,
});
},
[setValues, sorting],
);
const onPaginationChange = useCallback<OnChangeFn<PaginationState>>(
(updaterOrValue) => {
const next = functionalUpdate(updaterOrValue, pagination);
void setValues({ page: next.pageIndex + 1, page_size: next.pageSize });
},
[pagination, setValues],
);
const onColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>(
(updaterOrValue) => {
const next = functionalUpdate(updaterOrValue, columnFilters);
void setValues({ ...filterUpdates(filterColumns, next), page: null });
},
[columnFilters, filterColumns, setValues],
);
return useMemo<UrlTableState>(
() => ({
search: values.search,
setSearch,
sorting,
onSortingChange,
pagination,
onPaginationChange,
columnFilters,
onColumnFiltersChange,
}),
[
values.search,
setSearch,
sorting,
onSortingChange,
pagination,
onPaginationChange,
columnFilters,
onColumnFiltersChange,
],
);
}

View file

@ -0,0 +1,100 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { useUrlTab } from "./useUrlTab";
const TABS = ["chat", "compare", "compliance"] as const;
type Tab = (typeof TABS)[number];
interface RenderArgs {
searchParams?: string;
onUrlUpdate?: OnUrlUpdateFunction;
key?: string;
}
const initialProps: { values: readonly Tab[] } = { values: TABS };
const renderUrlTab = ({ searchParams, onUrlUpdate, key }: RenderArgs = {}) =>
renderHook(({ values }: { values: readonly Tab[] }) => useUrlTab(values, "chat", key), {
initialProps,
wrapper: ({ children }: { children: ReactNode }) => (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onUrlUpdate}
hasMemory
resetUrlUpdateQueueOnMount={false}
>
{children}
</NuqsTestingAdapter>
),
});
const lastUrlUpdate = (onUrlUpdate: ReturnType<typeof vi.fn<OnUrlUpdateFunction>>) =>
onUrlUpdate.mock.calls.at(-1)?.[0];
describe("useUrlTab", () => {
it("reads the active tab from the URL", () => {
const { result } = renderUrlTab({ searchParams: "?tab=compare" });
expect(result.current[0]).toBe("compare");
});
it("resolves a URL value outside the allowed tabs to the fallback and drops it from the URL", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const { result } = renderUrlTab({ searchParams: "?tab=settings&other=1", onUrlUpdate });
expect(result.current[0]).toBe("chat");
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("tab")).toBe(false);
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("other")).toBe("1");
});
it("leaves a URL that names an allowed tab untouched", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderUrlTab({ searchParams: "?tab=compare", onUrlUpdate });
await new Promise((resolve) => setTimeout(resolve, 100));
expect(onUrlUpdate).not.toHaveBeenCalled();
});
it("reads from the caller's key instead of the default one", () => {
const { result } = renderUrlTab({ searchParams: "?view=compliance&tab=compare", key: "view" });
expect(result.current[0]).toBe("compliance");
});
it("writes ?tab= with history replace when a tab is selected", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const { result } = renderUrlTab({ onUrlUpdate });
act(() => result.current[1]("compare"));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("tab")).toBe("compare"));
expect(lastUrlUpdate(onUrlUpdate)?.options.history).toBe("replace");
expect(result.current[0]).toBe("compare");
});
it("removes the param when the fallback tab is selected", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const { result } = renderUrlTab({ searchParams: "?tab=compare", onUrlUpdate });
act(() => result.current[1]("chat"));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("tab")).toBe(false);
expect(result.current[0]).toBe("chat");
});
it("falls back and clears the param when the current tab is no longer among the allowed values", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const { result, rerender } = renderUrlTab({ searchParams: "?tab=compliance", onUrlUpdate });
expect(result.current[0]).toBe("compliance");
rerender({ values: ["chat", "compare"] });
expect(result.current[0]).toBe("chat");
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("tab")).toBe(false);
});
});

View file

@ -0,0 +1,12 @@
import { parseAsString, useQueryState } from "nuqs";
import { useCallback, useEffect } from "react";
export function useUrlTab<T extends string>(values: readonly T[], fallback: T, key = "tab"): [T, (tab: T) => void] {
const [urlTab, setUrlTab] = useQueryState(key, parseAsString.withDefault(fallback));
const tab = values.find((value) => value === urlTab) ?? fallback;
useEffect(() => {
if (urlTab !== tab) void setUrlTab(null);
}, [urlTab, tab, setUrlTab]);
const setTab = useCallback((next: T) => void setUrlTab(next), [setUrlTab]);
return [tab, setTab];
}

View file

@ -1,47 +0,0 @@
/* @vitest-environment jsdom */
import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
import { createTabRoutes } from "./tabRoutes";
const routes = createTabRoutes("logs", ["audit", "deleted-keys", "deleted-teams"] as const);
describe("createTabRoutes.slugFromPathname", () => {
it("returns empty string for the base path with or without a trailing slash", () => {
expect(routes.slugFromPathname("/logs")).toBe("");
expect(routes.slugFromPathname("/logs/")).toBe("");
});
it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => {
expect(routes.slugFromPathname("/logs/audit")).toBe("audit");
expect(routes.slugFromPathname("/ui/logs/deleted-teams/")).toBe("deleted-teams");
});
it("returns the raw segment for an unknown tab so the caller can redirect to base", () => {
expect(routes.slugFromPathname("/ui/logs/bogus")).toBe("bogus");
});
it("returns empty string when the base segment is not in the path", () => {
expect(routes.slugFromPathname("/teams")).toBe("");
});
});
describe("createTabRoutes.tabHref", () => {
it("builds the trailing-slash base href for the empty slug", () => {
expect(routes.tabHref("")).toBe("/ui/logs/");
});
it("builds a trailing-slash href for every tab slug (required by static export)", () => {
for (const slug of routes.slugs) {
expect(routes.tabHref(slug)).toBe(`/ui/logs/${slug}/`);
}
});
});
describe("createTabRoutes metadata", () => {
it("preserves the base segment and slug tuple", () => {
expect(routes.baseSegment).toBe("logs");
expect(routes.slugs).toEqual(["audit", "deleted-keys", "deleted-teams"]);
});
});

View file

@ -1,26 +0,0 @@
import { uiHref } from "@/utils/uiHref";
export interface TabRoutes<Slug extends string> {
baseSegment: string;
slugs: readonly Slug[];
tabHref: (slug: string) => string;
slugFromPathname: (pathname: string) => string;
}
export function createTabRoutes<Slug extends string>(baseSegment: string, slugs: readonly Slug[]): TabRoutes<Slug> {
const tabHref = (slug: string): string => {
const base = uiHref(baseSegment);
return slug ? `${base}/${slug}/` : `${base}/`;
};
const slugFromPathname = (pathname: string): string => {
const parts = pathname.split("/").filter(Boolean);
const idx = parts.indexOf(baseSegment);
if (idx === -1) {
return "";
}
return parts[idx + 1] ?? "";
};
return { baseSegment, slugs, tabHref, slugFromPathname };
}