mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
feat(ui): shared URL-state layer for tables and tabs
Add useUrlTableState (search, sort, page, page size and filter_<column> in the query string via one nuqs useQueryStates call, with keyPrefix and urlKeys for routes that host two tables or need legacy key names) and useUrlTab (validated ?tab= param with a role-aware fallback). Migrate the Virtual Keys table onto useUrlTableState with a byte-identical URL contract and bind the Playground tab strip to ?tab=. DataTable gains controlled columnVisibility/onColumnVisibilityChange plus usePersistedColumnVisibility (localStorage per table id), and an isError prop that keeps the server page clamp from rewriting a deep-linked ?page= after a failed fetch. Virtual Keys uses both. The expired-session redirect in handleError now keeps the query string and hash so the return URL captured on re-login restores the filtered view instead of the bare list. Delete useTabRouting and tabRoutes, the pathname tab router left over from the reverted path-per-tab attempt (#34327, reverted in #34629); tab persistence has to be a query param on the static export.
This commit is contained in:
parent
9357491f91
commit
eda81fff59
21 changed files with 1260 additions and 355 deletions
|
|
@ -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/");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,29 @@ 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();
|
||||
});
|
||||
|
||||
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 +586,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", () => {
|
||||
|
|
|
|||
|
|
@ -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,7 @@ function useDataTableInstance<TData extends RowData, TValue>(
|
|||
pageIndex: 0,
|
||||
pageSize: pageSizeOptions[0] ?? 25,
|
||||
});
|
||||
useServerPageClamp(paginationMode === "server" && !isLoading, rowCount, paginationState);
|
||||
useServerPageClamp(paginationMode === "server" && !isLoading && !isError, rowCount, paginationState);
|
||||
const filterState = useControllable<ColumnFiltersState>(
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
|
|
@ -490,7 +493,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 +512,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 +528,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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
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);
|
||||
};
|
||||
|
||||
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.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("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("QuotaExceededError");
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePersistedColumnVisibility("keys", { spend: false }));
|
||||
expect(result.current.columnVisibility).toEqual({ spend: false });
|
||||
|
||||
act(() => result.current.onColumnVisibilityChange({ email: false }));
|
||||
expect(result.current.columnVisibility).toEqual({ email: false });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { OnChangeFn, VisibilityState } from "@tanstack/react-table";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
|
||||
|
||||
const STORAGE_KEY_PREFIX = "litellm_table_columns_";
|
||||
|
||||
const EMPTY_VISIBILITY: VisibilityState = {};
|
||||
|
||||
function storageKey(tableId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${tableId}`;
|
||||
}
|
||||
|
||||
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 readStoredVisibility(tableId: string, defaults: VisibilityState): VisibilityState {
|
||||
const raw = getLocalStorageItem(storageKey(tableId));
|
||||
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 [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() =>
|
||||
readStoredVisibility(tableId, defaults),
|
||||
);
|
||||
|
||||
const onColumnVisibilityChange = useCallback<OnChangeFn<VisibilityState>>(
|
||||
(updater) => {
|
||||
setColumnVisibility((previous) => {
|
||||
const next = typeof updater === "function" ? updater(previous) : updater;
|
||||
setLocalStorageItem(storageKey(tableId), JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[tableId],
|
||||
);
|
||||
|
||||
return { columnVisibility, onColumnVisibilityChange };
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
100
ui/litellm-dashboard/src/hooks/useUrlTab.test.tsx
Normal file
100
ui/litellm-dashboard/src/hooks/useUrlTab.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
12
ui/litellm-dashboard/src/hooks/useUrlTab.ts
Normal file
12
ui/litellm-dashboard/src/hooks/useUrlTab.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue