From 2dc49a913c5ee606bbdf96fed219829dd8d42c82 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 5 Aug 2026 13:29:58 -0700 Subject: [PATCH] refactor(ui): replace hand-rolled query-param routing with nuqs (#35871) * refactor(ui): replace hand-rolled query-param routing with nuqs The dashboard carried five copies of the same pushState-based detail routing hook plus a shared navigateWithParams helper, each with its own plumbing test and a copy-pasted reactive useSearchParams mock in component tests. nuqs provides the same shallow history-API routing behind useQueryState/useQueryStates, so the key, team and org hooks are deleted in favor of inline useQueryState at their single consumers, while the models and logs hooks keep their interfaces but drop their hand-rolled internals. Component tests now mount NuqsTestingAdapter (via renderWithProviders or locally) instead of patching window.history, and URL assertions go through onUrlUpdate spies that can additionally distinguish push from replace, which the old window.location checks could not * test(ui): assert browser back closes the log drawer after in-drawer selection Greptile flagged that the nuqs port of the switching-logs test stopped at asserting emitted push and replace modes. The test now replays those recorded modes against a history stack and performs the back step, so a regression to push-on-select or broken URL-derived drawer state fails the test instead of passing silently --- ui/litellm-dashboard/package-lock.json | 38 ++++ ui/litellm-dashboard/package.json | 1 + .../api-keys/detailNavigation.test.ts | 53 ------ .../(dashboard)/api-keys/detailNavigation.ts | 32 ---- .../detailNavigation.test.ts | 80 +++++---- .../models-and-endpoints/detailNavigation.ts | 44 +++-- .../panels/HealthStatusPanel.test.tsx | 9 +- .../src/app/(dashboard)/navigateWithParams.ts | 11 -- .../_components/OrganizationsPanel.test.tsx | 104 ++++++----- .../_components/OrganizationsPanel.tsx | 10 +- .../organizations/detailNavigation.test.ts | 53 ------ .../organizations/detailNavigation.ts | 32 ---- .../teams/detailNavigation.test.ts | 53 ------ .../app/(dashboard)/teams/detailNavigation.ts | 32 ---- ui/litellm-dashboard/src/app/layout.tsx | 14 +- .../src/components/Teams.test.tsx | 62 +++---- ui/litellm-dashboard/src/components/Teams.tsx | 10 +- .../VirtualKeysPage/VirtualKeysTable.test.tsx | 37 ++-- .../VirtualKeysPage/VirtualKeysTable.tsx | 10 +- .../view_logs/RequestLogsPanel.test.tsx | 163 +++++++++--------- .../components/view_logs/logDetailRouting.ts | 64 ++++--- ui/litellm-dashboard/tests/test-utils.tsx | 19 +- 22 files changed, 352 insertions(+), 579 deletions(-) delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.test.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/navigateWithParams.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.test.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.test.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.ts diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index e3a494746d7..ce7cc9a13db 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -28,6 +28,7 @@ "lucide-react": "0.513.0", "moment": "2.30.1", "next": "16.2.11", + "nuqs": "^2.9.4", "openai": "4.104.0", "openapi-fetch": "^0.17.0", "openapi-react-query": "^0.5.4", @@ -10509,6 +10510,43 @@ "dev": true, "license": "MIT" }, + "node_modules/nuqs": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.9.4.tgz", + "integrity": "sha512-lsz3NyCOKmuNAyW052i9RWqcTntoYb2Qm6FxSWnkTDwOJnGS6fzpXDAp0VcwTevw3xgnWebYpDr9rm6+o4DHbw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/franky47" + }, + "peerDependencies": { + "@remix-run/react": ">=2", + "@tanstack/react-router": "^1", + "next": ">=14.2.0", + "react": ">=18.2.0 || ^19.0.0-0", + "react-router": "^5 || ^6 || ^7 || ^8", + "react-router-dom": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "next": { + "optional": true + }, + "react-router": { + "optional": true + }, + "react-router-dom": { + "optional": true + } + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 98b8108f774..a9ac92cd023 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -40,6 +40,7 @@ "lucide-react": "0.513.0", "moment": "2.30.1", "next": "16.2.11", + "nuqs": "^2.9.4", "openai": "4.104.0", "openapi-fetch": "^0.17.0", "openapi-react-query": "^0.5.4", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.test.ts deleted file mode 100644 index a829d921600..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* @vitest-environment jsdom */ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useKeyDetailRouting } from "./detailNavigation"; - -vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); - -describe("useKeyDetailRouting", () => { - beforeEach(() => { - window.history.pushState(null, "", "/api-keys/"); - }); - - it("openKey sets ?key= via history.pushState (no full navigation)", () => { - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useKeyDetailRouting()); - act(() => result.current.openKey("88a145505dd6")); - expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("key=88a145505dd6")); - spy.mockRestore(); - }); - - it("openKey preserves unrelated query params like the legacy ?page=", () => { - window.history.pushState(null, "", "/?page=api-keys"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useKeyDetailRouting()); - act(() => result.current.openKey("88a145505dd6")); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("page=api-keys"); - expect(url).toContain("key=88a145505dd6"); - spy.mockRestore(); - }); - - it("close removes only the key param", () => { - window.history.pushState(null, "", "/?page=api-keys&key=88a145505dd6"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useKeyDetailRouting()); - act(() => result.current.close()); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("page=api-keys"); - expect(url).not.toContain("key="); - spy.mockRestore(); - }); - - it("exposes keyId from ?key=", () => { - window.history.pushState(null, "", "/api-keys/?key=88a145505dd6"); - const { result } = renderHook(() => useKeyDetailRouting()); - expect(result.current.keyId).toBe("88a145505dd6"); - }); - - it("keyId is null when no key param is present", () => { - const { result } = renderHook(() => useKeyDetailRouting()); - expect(result.current.keyId).toBeNull(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.ts deleted file mode 100644 index 85b3a6e046d..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useSearchParams } from "next/navigation"; -import { useCallback } from "react"; - -import { navigateWithParams } from "../navigateWithParams"; - -export interface KeyDetailRouting { - keyId: string | null; - openKey: (id: string) => void; - close: () => void; -} - -export function useKeyDetailRouting(): KeyDetailRouting { - const searchParams = useSearchParams(); - - const openKey = useCallback((id: string) => { - navigateWithParams((params) => { - params.set("key", id); - }); - }, []); - - const close = useCallback(() => { - navigateWithParams((params) => { - params.delete("key"); - }); - }, []); - - return { - keyId: searchParams?.get("key") ?? null, - openKey, - close, - }; -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts index 717fdc85e28..292b27618bd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts @@ -1,51 +1,55 @@ -/* @vitest-environment jsdom */ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { withNuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing"; +import { describe, expect, it, vi } from "vitest"; import { useModelDetailRouting } from "./detailNavigation"; -// The detail overlay is driven by ?model=/?team= on the current path. Under the -// /ui static mount a router.push to the same path (query-only change) is a no-op, -// so navigation goes through history.pushState (client-side, no full reload). -vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); - describe("useModelDetailRouting", () => { - beforeEach(() => { - window.history.pushState(null, "", "/models-and-endpoints/"); + it("openModel sets ?model= with a history push", async () => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + const { result } = renderHook(() => useModelDetailRouting(), { + wrapper: withNuqsTestingAdapter({ onUrlUpdate }), + }); + await act(async () => { + result.current.openModel("abc-1"); + }); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const event = onUrlUpdate.mock.calls.at(-1)?.[0]; + expect(event?.searchParams.get("model")).toBe("abc-1"); + expect(event?.options.history).toBe("push"); }); - it("openModel sets ?model= via history.pushState (no full navigation)", () => { - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useModelDetailRouting()); - act(() => result.current.openModel("abc-1")); - expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("model=abc-1")); - spy.mockRestore(); + it("openTeam sets ?team= and drops any model param", async () => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + const { result } = renderHook(() => useModelDetailRouting(), { + wrapper: withNuqsTestingAdapter({ searchParams: "?model=abc-1", onUrlUpdate }), + }); + await act(async () => { + result.current.openTeam("team-9"); + }); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const event = onUrlUpdate.mock.calls.at(-1)?.[0]; + expect(event?.searchParams.get("team")).toBe("team-9"); + expect(event?.searchParams.has("model")).toBe(false); }); - it("openTeam sets ?team= and drops any model param", () => { - window.history.pushState(null, "", "/models-and-endpoints/?model=abc-1"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useModelDetailRouting()); - act(() => result.current.openTeam("team-9")); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("team=team-9"); - expect(url).not.toContain("model="); - spy.mockRestore(); - }); - - it("close removes both model and team params", () => { - window.history.pushState(null, "", "/models-and-endpoints/?model=abc-1"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useModelDetailRouting()); - act(() => result.current.close()); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).not.toContain("model="); - expect(url).not.toContain("team="); - spy.mockRestore(); + it("close removes both model and team params", async () => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + const { result } = renderHook(() => useModelDetailRouting(), { + wrapper: withNuqsTestingAdapter({ searchParams: "?model=abc-1&team=team-9", onUrlUpdate }), + }); + await act(async () => { + result.current.close(); + }); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const event = onUrlUpdate.mock.calls.at(-1)?.[0]; + expect(event?.searchParams.has("model")).toBe(false); + expect(event?.searchParams.has("team")).toBe(false); }); it("reads modelId and teamId from the query string", () => { - window.history.pushState(null, "", "/models-and-endpoints/?model=xyz"); - const { result } = renderHook(() => useModelDetailRouting()); + const { result } = renderHook(() => useModelDetailRouting(), { + wrapper: withNuqsTestingAdapter({ searchParams: "?model=xyz" }), + }); expect(result.current.modelId).toBe("xyz"); expect(result.current.teamId).toBeNull(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts index 71f8bc82a07..2cfad341d25 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts @@ -1,8 +1,6 @@ -import { useSearchParams } from "next/navigation"; +import { parseAsString, useQueryStates } from "nuqs"; import { useCallback } from "react"; -import { navigateWithParams } from "../navigateWithParams"; - export interface ModelDetailRouting { modelId: string | null; teamId: string | null; @@ -12,32 +10,32 @@ export interface ModelDetailRouting { } export function useModelDetailRouting(): ModelDetailRouting { - const searchParams = useSearchParams(); + const [{ model, team }, setParams] = useQueryStates( + { model: parseAsString, team: parseAsString }, + { history: "push" }, + ); - const openModel = useCallback((id: string) => { - navigateWithParams((params) => { - params.delete("team"); - params.set("model", id); - }); - }, []); + const openModel = useCallback( + (id: string) => { + void setParams({ model: id, team: null }); + }, + [setParams], + ); - const openTeam = useCallback((id: string) => { - navigateWithParams((params) => { - params.delete("model"); - params.set("team", id); - }); - }, []); + const openTeam = useCallback( + (id: string) => { + void setParams({ model: null, team: id }); + }, + [setParams], + ); const close = useCallback(() => { - navigateWithParams((params) => { - params.delete("model"); - params.delete("team"); - }); - }, []); + void setParams({ model: null, team: null }); + }, [setParams]); return { - modelId: searchParams?.get("model") ?? null, - teamId: searchParams?.get("team") ?? null, + modelId: model, + teamId: team, openModel, openTeam, close, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.test.tsx index e0f35b5f3b8..56b7016a325 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.test.tsx @@ -1,14 +1,9 @@ /* @vitest-environment jsdom */ import { render } from "@testing-library/react"; +import { withNuqsTestingAdapter } from "nuqs/adapters/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; import HealthStatusPanel from "./HealthStatusPanel"; -vi.mock("next/navigation", () => ({ - usePathname: () => "/models-and-endpoints/health", - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - useSearchParams: () => new URLSearchParams(""), -})); - const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null); vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({ default: (props: { all_models_on_proxy?: string[] }) => { @@ -44,7 +39,7 @@ describe("HealthStatusPanel", () => { isLoading: false, }); - render(); + render(, { wrapper: withNuqsTestingAdapter() }); expect(mockHealthCheckComponent).toHaveBeenCalled(); const props = mockHealthCheckComponent.mock.calls[0][0]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/navigateWithParams.ts b/ui/litellm-dashboard/src/app/(dashboard)/navigateWithParams.ts deleted file mode 100644 index 5acf444a359..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/navigateWithParams.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function navigateWithParams(mutate: (params: URLSearchParams) => void, mode: "push" | "replace" = "push"): void { - const params = new URLSearchParams(window.location.search); - mutate(params); - const qs = params.toString(); - const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname; - if (mode === "replace") { - window.history.replaceState(null, "", url); - } else { - window.history.pushState(null, "", url); - } -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx index 3f9de478069..c15b9fcaddb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, render, screen } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { NuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type OrganizationsTableComponent from "./OrganizationsTable"; @@ -40,62 +41,66 @@ vi.mock("@/components/organization/organization_view", () => ({ }, })); -// The selected org is URL-derived (?org=) via useOrgDetailRouting. Next's real useSearchParams -// re-renders subscribers on history.pushState/replaceState; mirror that so URL changes propagate. -vi.mock("next/navigation", async () => { - const { useSyncExternalStore } = await import("react"); - const LOCATION_CHANGE_EVENT = "test-locationchange"; - for (const method of ["pushState", "replaceState"] as const) { - const original = window.history[method].bind(window.history); - window.history[method] = (...args: Parameters) => { - original(...args); - window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT)); - }; - } - const subscribe = (onChange: () => void) => { - window.addEventListener(LOCATION_CHANGE_EVENT, onChange); - window.addEventListener("popstate", onChange); - return () => { - window.removeEventListener(LOCATION_CHANGE_EVENT, onChange); - window.removeEventListener("popstate", onChange); - }; - }; - return { - useSearchParams: () => new URLSearchParams(useSyncExternalStore(subscribe, () => window.location.search)), - }; -}); - import OrganizationsPanel from "./OrganizationsPanel"; -const renderWithQueryClient = (ui: React.ReactElement) => { +const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + +interface RenderPanelOptions { + premiumUser?: boolean; + searchParams?: string; +} + +const renderPanel = ({ premiumUser = true, searchParams = "" }: RenderPanelOptions = {}) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - return render({ui}); + const url = { current: searchParams }; + const handleUrlUpdate = (event: UrlUpdateEvent) => { + onUrlUpdate(event); + url.current = event.queryString; + }; + const tree = (currentSearchParams: string) => ( + + + + + + ); + const { rerender } = render(tree(searchParams)); + return { + navigate: (nextSearchParams: string) => { + rerender(tree(url.current)); + rerender(tree(nextSearchParams)); + url.current = nextSearchParams; + }, + }; }; +const expectQueryString = (queryString: string) => + waitFor(() => expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString }))); + beforeEach(() => { capturedTableProps = null; mockOrgInfoView.mockClear(); - window.history.replaceState(null, "", "/organizations/"); + onUrlUpdate.mockClear(); }); describe("OrganizationsPanel", () => { it("gates non-premium users behind the enterprise notice", () => { - renderWithQueryClient(); + renderPanel({ premiumUser: false }); expect(screen.getByText(/LiteLLM Enterprise feature/i)).toBeInTheDocument(); expect(screen.queryByText("+ Create New Organization")).not.toBeInTheDocument(); }); it("shows the create button for a premium admin", () => { - renderWithQueryClient(); + renderPanel(); expect(screen.getByText("+ Create New Organization")).toBeInTheDocument(); }); it("resolves the loading skeleton to false when the query is disabled (no token)", () => { - renderWithQueryClient(); + renderPanel(); // A disabled React Query keeps isPending true forever; feeding isLoading avoids a stuck skeleton. expect(screen.getByTestId("organizations-table")).toHaveTextContent("isLoading:false"); @@ -103,18 +108,20 @@ describe("OrganizationsPanel", () => { }); describe("OrganizationsPanel - org detail deep link (?org=)", () => { - it("clicking an organization pushes ?org= and opens the detail view", () => { - renderWithQueryClient(); + it("clicking an organization pushes ?org= and opens the detail view", async () => { + renderPanel(); act(() => capturedTableProps?.onOrganizationClick("org-deep-link")); - expect(window.location.search).toContain("org=org-deep-link"); + await expectQueryString("?org=org-deep-link"); + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ options: expect.objectContaining({ history: "push" }) }), + ); expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ organizationId: "org-deep-link" })); }); it("opens the org detail directly from a ?org= deep link", () => { - window.history.replaceState(null, "", "/organizations/?org=org-from-url"); - renderWithQueryClient(); + renderPanel({ searchParams: "?org=org-from-url" }); expect(mockOrgInfoView).toHaveBeenLastCalledWith( expect.objectContaining({ organizationId: "org-from-url", editOrg: false }), @@ -122,37 +129,40 @@ describe("OrganizationsPanel - org detail deep link (?org=)", () => { expect(screen.queryByTestId("organizations-table")).not.toBeInTheDocument(); }); - it("closing the org detail removes ?org= and returns to the list", () => { - window.history.replaceState(null, "", "/organizations/?org=org-from-url"); - renderWithQueryClient(); + it("closing the org detail removes ?org= and returns to the list", async () => { + renderPanel({ searchParams: "?org=org-from-url" }); act(() => mockOrgInfoView.mock.calls.at(-1)?.[0].onClose()); - expect(window.location.search).not.toContain("org="); + await expectQueryString(""); expect(screen.queryByTestId("organization-info-view")).not.toBeInTheDocument(); expect(screen.getByTestId("organizations-table")).toBeInTheDocument(); }); - it("the edit action opens the detail in edit mode with ?org= set", () => { - renderWithQueryClient(); + it("the edit action opens the detail in edit mode with ?org= set", async () => { + renderPanel(); act(() => capturedTableProps?.onEditClick("org-edit")); - expect(window.location.search).toContain("org=org-edit"); + await expectQueryString("?org=org-edit"); expect(mockOrgInfoView).toHaveBeenLastCalledWith( expect.objectContaining({ organizationId: "org-edit", editOrg: true }), ); }); - it("a plain row click after leaving an edit view via browser history does not reopen in edit mode", () => { - renderWithQueryClient(); + it("a plain row click after leaving an edit view via browser history does not reopen in edit mode", async () => { + const { navigate } = renderPanel(); act(() => capturedTableProps?.onEditClick("org-edit")); + await expectQueryString("?org=org-edit"); expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ editOrg: true })); - act(() => window.history.pushState(null, "", "/organizations/")); + navigate(""); + expect(screen.getByTestId("organizations-table")).toBeInTheDocument(); + act(() => capturedTableProps?.onOrganizationClick("org-plain")); + await expectQueryString("?org=org-plain"); expect(mockOrgInfoView).toHaveBeenLastCalledWith( expect.objectContaining({ organizationId: "org-plain", editOrg: false }), ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx index a21c0669677..65be15294f6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx @@ -1,8 +1,8 @@ import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels"; -import { useOrgDetailRouting } from "@/app/(dashboard)/organizations/detailNavigation"; import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters"; import { useQueryClient } from "@tanstack/react-query"; +import { parseAsString, useQueryState } from "nuqs"; import React, { useState } from "react"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; @@ -20,7 +20,7 @@ interface OrganizationsPanelProps { } const OrganizationsPanel: React.FC = ({ userRole, accessToken, premiumUser }) => { - const { orgId: selectedOrgId, openOrg, close: closeOrgDetail } = useOrgDetailRouting(); + const [selectedOrgId, setSelectedOrgId] = useQueryState("org", parseAsString.withOptions({ history: "push" })); const [editOrg, setEditOrg] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [orgToDelete, setOrgToDelete] = useState(null); @@ -109,7 +109,7 @@ const OrganizationsPanel: React.FC = ({ userRole, acces { - closeOrgDetail(); + void setSelectedOrgId(null); setEditOrg(false); }} accessToken={accessToken} @@ -135,10 +135,10 @@ const OrganizationsPanel: React.FC = ({ userRole, acces searchActive={searchActive} onOrganizationClick={(organizationId) => { setEditOrg(false); - openOrg(organizationId); + void setSelectedOrgId(organizationId); }} onEditClick={(organizationId) => { - openOrg(organizationId); + void setSelectedOrgId(organizationId); setEditOrg(true); }} onDeleteClick={handleDelete} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.test.ts deleted file mode 100644 index 46b7c4313ea..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* @vitest-environment jsdom */ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useOrgDetailRouting } from "./detailNavigation"; - -vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); - -describe("useOrgDetailRouting", () => { - beforeEach(() => { - window.history.pushState(null, "", "/organizations/"); - }); - - it("openOrg sets ?org= via history.pushState (no full navigation)", () => { - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useOrgDetailRouting()); - act(() => result.current.openOrg("org-abc123")); - expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("org=org-abc123")); - spy.mockRestore(); - }); - - it("openOrg preserves unrelated query params", () => { - window.history.pushState(null, "", "/organizations/?foo=bar"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useOrgDetailRouting()); - act(() => result.current.openOrg("org-abc123")); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("foo=bar"); - expect(url).toContain("org=org-abc123"); - spy.mockRestore(); - }); - - it("close removes only the org param", () => { - window.history.pushState(null, "", "/organizations/?foo=bar&org=org-abc123"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useOrgDetailRouting()); - act(() => result.current.close()); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("foo=bar"); - expect(url).not.toContain("org="); - spy.mockRestore(); - }); - - it("exposes orgId from ?org=", () => { - window.history.pushState(null, "", "/organizations/?org=org-abc123"); - const { result } = renderHook(() => useOrgDetailRouting()); - expect(result.current.orgId).toBe("org-abc123"); - }); - - it("orgId is null when no org param is present", () => { - const { result } = renderHook(() => useOrgDetailRouting()); - expect(result.current.orgId).toBeNull(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.ts deleted file mode 100644 index 8c55c7b750c..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useSearchParams } from "next/navigation"; -import { useCallback } from "react"; - -import { navigateWithParams } from "../navigateWithParams"; - -export interface OrgDetailRouting { - orgId: string | null; - openOrg: (id: string) => void; - close: () => void; -} - -export function useOrgDetailRouting(): OrgDetailRouting { - const searchParams = useSearchParams(); - - const openOrg = useCallback((id: string) => { - navigateWithParams((params) => { - params.set("org", id); - }); - }, []); - - const close = useCallback(() => { - navigateWithParams((params) => { - params.delete("org"); - }); - }, []); - - return { - orgId: searchParams?.get("org") ?? null, - openOrg, - close, - }; -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.test.ts deleted file mode 100644 index e5d5b1a4073..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* @vitest-environment jsdom */ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useTeamDetailRouting } from "./detailNavigation"; - -vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); - -describe("useTeamDetailRouting", () => { - beforeEach(() => { - window.history.pushState(null, "", "/teams/"); - }); - - it("openTeam sets ?team= via history.pushState (no full navigation)", () => { - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useTeamDetailRouting()); - act(() => result.current.openTeam("team-abc123")); - expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("team=team-abc123")); - spy.mockRestore(); - }); - - it("openTeam preserves unrelated query params", () => { - window.history.pushState(null, "", "/teams/?foo=bar"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useTeamDetailRouting()); - act(() => result.current.openTeam("team-abc123")); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("foo=bar"); - expect(url).toContain("team=team-abc123"); - spy.mockRestore(); - }); - - it("close removes only the team param", () => { - window.history.pushState(null, "", "/teams/?foo=bar&team=team-abc123"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useTeamDetailRouting()); - act(() => result.current.close()); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("foo=bar"); - expect(url).not.toContain("team="); - spy.mockRestore(); - }); - - it("exposes teamId from ?team=", () => { - window.history.pushState(null, "", "/teams/?team=team-abc123"); - const { result } = renderHook(() => useTeamDetailRouting()); - expect(result.current.teamId).toBe("team-abc123"); - }); - - it("teamId is null when no team param is present", () => { - const { result } = renderHook(() => useTeamDetailRouting()); - expect(result.current.teamId).toBeNull(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.ts deleted file mode 100644 index d5208f094cb..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useSearchParams } from "next/navigation"; -import { useCallback } from "react"; - -import { navigateWithParams } from "../navigateWithParams"; - -export interface TeamDetailRouting { - teamId: string | null; - openTeam: (id: string) => void; - close: () => void; -} - -export function useTeamDetailRouting(): TeamDetailRouting { - const searchParams = useSearchParams(); - - const openTeam = useCallback((id: string) => { - navigateWithParams((params) => { - params.set("team", id); - }); - }, []); - - const close = useCallback(() => { - navigateWithParams((params) => { - params.delete("team"); - }); - }, []); - - return { - teamId: searchParams?.get("team") ?? null, - openTeam, - close, - }; -} diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx index a73921ce35b..3d6c6e4c2eb 100644 --- a/ui/litellm-dashboard/src/app/layout.tsx +++ b/ui/litellm-dashboard/src/app/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; + import AntdGlobalProvider from "@/contexts/AntdGlobalProvider"; import { AuthProvider } from "@/contexts/AuthContext"; import ReactQueryProvider from "@/contexts/ReactQueryProvider"; @@ -22,11 +24,13 @@ export default function RootLayout({ return ( - - - {children} - - + + + + {children} + + + ); diff --git a/ui/litellm-dashboard/src/components/Teams.test.tsx b/ui/litellm-dashboard/src/components/Teams.test.tsx index 2afc4bbc586..2bda0f72cec 100644 --- a/ui/litellm-dashboard/src/components/Teams.test.tsx +++ b/ui/litellm-dashboard/src/components/Teams.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { NuqsTestingAdapter, OnUrlUpdateFunction } from "nuqs/adapters/testing"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema"; @@ -78,31 +79,6 @@ vi.mock("@/components/team/TeamInfo", () => ({ }, })); -// The selected team is URL-derived (?team=) via useTeamDetailRouting. Next's real useSearchParams -// re-renders subscribers on history.pushState/replaceState; mirror that so URL changes propagate. -vi.mock("next/navigation", async () => { - const { useSyncExternalStore } = await import("react"); - const LOCATION_CHANGE_EVENT = "test-locationchange"; - for (const method of ["pushState", "replaceState"] as const) { - const original = window.history[method].bind(window.history); - window.history[method] = (...args: Parameters) => { - original(...args); - window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT)); - }; - } - const subscribe = (onChange: () => void) => { - window.addEventListener(LOCATION_CHANGE_EVENT, onChange); - window.addEventListener("popstate", onChange); - return () => { - window.removeEventListener(LOCATION_CHANGE_EVENT, onChange); - window.removeEventListener("popstate", onChange); - }; - }; - return { - useSearchParams: () => new URLSearchParams(useSyncExternalStore(subscribe, () => window.location.search)), - }; -}); - vi.mock("./ModelSelect/ModelSelect", () => { const ModelSelect = React.forwardRef(({ value, onChange, dataTestId, id }: any, ref: any) => { return ( @@ -182,15 +158,21 @@ const createQueryClient = () => { }); }; -const renderWithQueryClient = (component: React.ReactElement) => { +const renderWithQueryClient = ( + component: React.ReactElement, + options?: { searchParams?: string; onUrlUpdate?: OnUrlUpdateFunction }, +) => { const queryClient = createQueryClient(); - return render({component}); + return render( + + {component} + , + ); }; // Re-establish safe defaults before every test (clearAllMocks keeps return values, so restore them here). beforeEach(() => { mockTeamsTableProps = null; - window.history.replaceState(null, "", "/teams/"); }); describe("Teams - handleCreate organization handling", () => { @@ -479,32 +461,42 @@ describe("Teams - team detail deep link (?team=)", () => { }); it("selecting a team pushes ?team= to the URL", async () => { - renderWithQueryClient(); + const onUrlUpdate = vi.fn(); + renderWithQueryClient(, { onUrlUpdate }); await waitFor(() => expect(mockTeamsTableProps).not.toBeNull()); act(() => mockTeamsTableProps.onSelectTeam({ ...baseTableTeam, team_id: "team-deep-link" })); - expect(window.location.search).toContain("team=team-deep-link"); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0]; + expect(lastUpdate.searchParams.get("team")).toBe("team-deep-link"); + expect(lastUpdate.options.history).toBe("push"); + await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ teamId: "team-deep-link" })); }); it("opens the team detail view directly from a ?team= deep link", async () => { - window.history.replaceState(null, "", "/teams/?team=team-from-url"); - renderWithQueryClient(); + renderWithQueryClient(, { + searchParams: "?team=team-from-url", + }); await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ teamId: "team-from-url" })); }); it("closing the team detail view removes ?team= from the URL", async () => { - window.history.replaceState(null, "", "/teams/?team=team-from-url"); - renderWithQueryClient(); + const onUrlUpdate = vi.fn(); + renderWithQueryClient(, { + searchParams: "?team=team-from-url", + onUrlUpdate, + }); await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); act(() => mockTeamInfoView.mock.calls.at(-1)?.[0].onClose()); - expect(window.location.search).not.toContain("team="); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + expect(onUrlUpdate.mock.calls.at(-1)![0].searchParams.has("team")).toBe(false); await waitFor(() => expect(screen.queryByTestId("team-info-view")).not.toBeInTheDocument()); }); }); diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index fc89751db6a..edf376cb8d4 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -12,7 +12,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { PageHeader } from "@/components/shared/PageHeader"; import { Button as UIButton } from "@/components/ui/button"; import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams"; -import { useTeamDetailRouting } from "@/app/(dashboard)/teams/detailNavigation"; +import { parseAsString, useQueryState } from "nuqs"; import { TeamsTable } from "./TeamsPage/TeamsTable"; import AccessGroupSelector from "./common_components/AccessGroupSelector"; import MetadataKeyValueFields, { metadataPairsToObject } from "./common_components/MetadataKeyValueFields"; @@ -140,7 +140,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser const [editModalVisible, setEditModalVisible] = useState(false); const [selectedTeam, setSelectedTeam] = useState(null); - const { teamId: selectedTeamId, openTeam, close: closeTeamDetail } = useTeamDetailRouting(); + const [selectedTeamId, setSelectedTeamId] = useQueryState("team", parseAsString.withOptions({ history: "push" })); const [editTeam, setEditTeam] = useState(false); const [isTeamModalVisible, setIsTeamModalVisible] = useState(false); @@ -473,12 +473,12 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser userID={userID} onSelectTeam={(team) => { setSelectedTeam(team); - openTeam(team.team_id); + void setSelectedTeamId(team.team_id); setEditTeam(false); }} onEditTeam={(team) => { setSelectedTeam(team); - openTeam(team.team_id); + void setSelectedTeamId(team.team_id); setEditTeam(true); }} onDeleteTeam={handleDelete} @@ -538,7 +538,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser }} onClose={() => { setSelectedTeam(null); - closeTeamDetail(); + void setSelectedTeamId(null); setEditTeam(false); }} accessToken={accessToken} diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 708990647c8..0755ddb96fc 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -1,6 +1,7 @@ import { screen, waitFor, within, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { vi, it, expect, beforeEach, describe, MockedFunction } from "vitest"; +import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import { vi, it, expect, beforeEach, describe, Mock, MockedFunction } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; import { VirtualKeysTable } from "./VirtualKeysTable"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; @@ -8,8 +9,6 @@ import { useKeyInfo } from "@/app/(dashboard)/hooks/keys/useKeyInfo"; import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; -vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); - // Resolve debounced values synchronously so an applied filter lands in the useKeys query within the test tick. vi.mock("@tanstack/react-pacer/debouncer", async () => { const React = await vi.importActual("react"); @@ -169,11 +168,12 @@ const keysResult = (keys: KeyResponse[], data: Partial = {}, extra const openFilters = () => fireEvent.click(screen.getByRole("button", { name: "Filters" })); +const lastKeyParam = (onUrlUpdate: Mock) => + onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("key"); + beforeEach(() => { vi.clearAllMocks(); - window.history.pushState(null, "", "/"); - mockUseKeys.mockReturnValue(keysResult([mockKey])); mockUseKeyInfo.mockReturnValue(keyInfoResult(undefined)); @@ -359,7 +359,8 @@ it("sorts by spend ascending when 'Spend ascending' is chosen from the Spend / B }); it("clicking the key cell deep-links via ?key=", async () => { - renderWithProviders(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); await waitFor(() => { expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); @@ -367,13 +368,14 @@ it("clicking the key cell deep-links via ?key=", async () => { fireEvent.click(screen.getByText("Test Key Alias")); - expect(window.location.search).toContain(`key=${encodeURIComponent(mockKey.token)}`); + await waitFor(() => { + expect(lastKeyParam(onUrlUpdate)).toBe(mockKey.token); + }); }); it("renders KeyInfoView when the URL has ?key= for a key on the current page, without refetching it", async () => { - window.history.pushState(null, "", `/?key=${encodeURIComponent(mockKey.token)}`); - - renderWithProviders(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { searchParams: { key: mockKey.token }, onUrlUpdate }); await waitFor(() => { expect(screen.getByText("Back to Keys")).toBeInTheDocument(); @@ -383,16 +385,18 @@ it("renders KeyInfoView when the URL has ?key= for a key on the current page, wi fireEvent.click(screen.getByText("Back to Keys")); - expect(window.location.search).not.toContain("key="); + await waitFor(() => { + expect(lastKeyParam(onUrlUpdate)).toBeNull(); + }); + expect(screen.getByTestId("pagination-range")).toBeInTheDocument(); }); it("fetches the key by id when the URL has ?key= for a key not in the loaded page", async () => { - window.history.pushState(null, "", "/?key=other-key-hash"); mockUseKeyInfo.mockReturnValue( keyInfoResult({ ...mockKey, token: "other-key-hash", key_alias: "Fetched Key Alias" }), ); - renderWithProviders(); + renderWithProviders(, { searchParams: { key: "other-key-hash" } }); await waitFor(() => { expect(screen.getByText("Back to Keys")).toBeInTheDocument(); @@ -402,19 +406,16 @@ it("fetches the key by id when the URL has ?key= for a key not in the loaded pag }); it("shows a loading state while a deep-linked key is being fetched", () => { - window.history.pushState(null, "", "/?key=other-key-hash"); - - renderWithProviders(); + renderWithProviders(, { searchParams: { key: "other-key-hash" } }); expect(screen.getByText("Loading key...")).toBeInTheDocument(); expect(screen.queryByTestId("pagination-range")).not.toBeInTheDocument(); }); it("shows 'Key not found' when the deep-linked key fails to load", async () => { - window.history.pushState(null, "", "/?key=missing-key-hash"); mockUseKeyInfo.mockReturnValue(keyInfoResult(undefined, true)); - renderWithProviders(); + renderWithProviders(, { searchParams: { key: "missing-key-hash" } }); await waitFor(() => { expect(screen.getByText("Key not found")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index eb96136ed6b..fa0360c0dda 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -1,6 +1,5 @@ "use client"; -import { useKeyDetailRouting } from "@/app/(dashboard)/api-keys/detailNavigation"; import { useKeyInfo } from "@/app/(dashboard)/hooks/keys/useKeyInfo"; import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; @@ -18,6 +17,7 @@ import { Input } from "@/components/ui/input"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; import { KeyRound } from "lucide-react"; +import { parseAsString, useQueryState } from "nuqs"; import React, { useCallback, useMemo, useState } from "react"; import { Team } from "../key_team_helpers/key_list"; @@ -49,7 +49,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { const { data: fetchedTeams } = useAllTeams(); const allTeams = useMemo(() => fetchedTeams ?? [], [fetchedTeams]); - const { keyId: selectedKeyId, openKey, close: closeKeyDetail } = useKeyDetailRouting(); + const [selectedKeyId, setSelectedKeyId] = useQueryState("key", parseAsString.withOptions({ history: "push" })); const [sorting, setSorting] = useState(DEFAULT_SORTING); const [tablePagination, setTablePagination] = useState({ pageIndex: 0, pageSize: 50 }); const [columnFilters, setColumnFilters] = useState([]); @@ -105,8 +105,8 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { }, []); const columns = useMemo( - () => getKeyTableColumns({ allTeams, organizations, onSelectKey: (key) => openKey(key.token) }), - [allTeams, organizations, openKey], + () => getKeyTableColumns({ allTeams, organizations, onSelectKey: (key) => void setSelectedKeyId(key.token) }), + [allTeams, organizations, setSelectedKeyId], ); const selectedKeyFromList = useMemo( @@ -161,7 +161,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
void setSelectedKeyId(null)} keyData={selectedKey} teams={allTeams} onDelete={refetch} diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx index 6a66e845675..852f146c786 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx @@ -1,9 +1,11 @@ +import { QueryClientProvider } from "@tanstack/react-query"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import moment from "moment"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { NuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; +import { render, renderWithProviders, testQueryClient } from "../../../tests/test-utils"; import type { LogEntry } from "./columns"; import RequestLogsPanel from "./RequestLogsPanel"; @@ -52,45 +54,6 @@ vi.mock("./LogDetailsDrawer", () => ({ }, })); -vi.mock("next/navigation", async (importOriginal) => { - const actual = await importOriginal(); - const { useSyncExternalStore } = await import("react"); - return { - ...actual, - useSearchParams: () => { - const search = useSyncExternalStore( - (onChange: () => void) => { - window.addEventListener("test-locationchange", onChange); - window.addEventListener("popstate", onChange); - return () => { - window.removeEventListener("test-locationchange", onChange); - window.removeEventListener("popstate", onChange); - }; - }, - () => window.location.search, - ); - return new URLSearchParams(search); - }, - }; -}); - -const originalPushState = window.history.pushState.bind(window.history); -const originalReplaceState = window.history.replaceState.bind(window.history); -beforeAll(() => { - window.history.pushState = (data, unused, url) => { - originalPushState(data, unused, url); - window.dispatchEvent(new Event("test-locationchange")); - }; - window.history.replaceState = (data, unused, url) => { - originalReplaceState(data, unused, url); - window.dispatchEvent(new Event("test-locationchange")); - }; -}); -afterAll(() => { - window.history.pushState = originalPushState; - window.history.replaceState = originalReplaceState; -}); - import { uiSpendLogsCall } from "../networking"; const logEntry = (overrides: Partial): LogEntry => ({ @@ -132,12 +95,46 @@ const defaultProps = { const row = (requestId: string) => document.querySelector(`[data-row-id="${requestId}"]`); const lastCall = () => vi.mocked(uiSpendLogsCall).mock.calls.at(-1)?.[0]; +const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); +const renderPanel = (searchParams?: string) => + renderWithProviders(, { searchParams, onUrlUpdate }); + +const renderPanelWithHistory = () => { + const stack = [""]; + const handleUrlUpdate = (event: UrlUpdateEvent) => { + onUrlUpdate(event); + if (event.options.history === "push") { + stack.push(event.queryString); + } else { + stack[stack.length - 1] = event.queryString; + } + }; + const tree = (searchParams: string) => ( + + + + + + ); + const view = render(tree("")); + return { + goBack: () => { + const current = stack[stack.length - 1] ?? ""; + stack.pop(); + const target = stack[stack.length - 1] ?? ""; + view.rerender(tree(current)); + view.rerender(tree(target)); + }, + }; +}; +const urlParams = () => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams ?? new URLSearchParams(); +const historyModes = () => onUrlUpdate.mock.calls.map(([event]) => event.options.history); + describe("RequestLogsPanel", () => { beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); testQueryClient.clear(); - window.history.replaceState(null, "", "/logs/"); respondWith([]); }); @@ -150,7 +147,7 @@ describe("RequestLogsPanel", () => { it("collapses a multi-call session to a single representative row", async () => { respondWith(sessionRows); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-mcp") ?? row("req-llm") ?? row("req-llm-2")).not.toBeNull()); @@ -160,7 +157,7 @@ describe("RequestLogsPanel", () => { it("prefers an LLM call over an MCP call as the session's representative", async () => { respondWith(sessionRows); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-llm")).not.toBeNull()); expect(row("req-mcp")).toBeNull(); @@ -168,7 +165,7 @@ describe("RequestLogsPanel", () => { it("shows the session's call count and composition on the representative row", async () => { respondWith(sessionRows); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-llm")).not.toBeNull()); expect(within(row("req-llm") as HTMLElement).getByText("3")).toBeInTheDocument(); @@ -179,7 +176,7 @@ describe("RequestLogsPanel", () => { logEntry({ request_id: "req-solo-a", session_id: "sess-a", session_total_count: 1 }), logEntry({ request_id: "req-solo-b" }), ]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-solo-a")).not.toBeNull()); expect(row("req-solo-b")).not.toBeNull(); @@ -189,7 +186,7 @@ describe("RequestLogsPanel", () => { describe("search by request id (LIT-3981)", () => { it("sends the typed request id to the server on the first page instead of filtering the loaded rows", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); @@ -207,7 +204,7 @@ describe("RequestLogsPanel", () => { describe("time range", () => { it("requests a ~15 minute window when Last 15 Minutes is picked", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); await user.click(screen.getByRole("button", { name: /Last 24 Hours/i })); @@ -226,7 +223,7 @@ describe("RequestLogsPanel", () => { it("restores the default 24 hour window when filters are reset", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); await user.click(screen.getByRole("button", { name: /Last 24 Hours/i })); @@ -256,12 +253,13 @@ describe("RequestLogsPanel", () => { it("clicking a row writes ?log_id= to the URL and opens the drawer", async () => { const user = userEvent.setup(); respondWith([logEntry({ request_id: "req-1" })]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-1")).not.toBeNull()); await user.click(row("req-1") as HTMLElement); - expect(new URLSearchParams(window.location.search).get("log_id")).toBe("req-1"); + await waitFor(() => expect(urlParams().get("log_id")).toBe("req-1")); + expect(historyModes()).toEqual(["push"]); await waitFor(() => { expect(drawer()).toHaveTextContent("open"); expect(drawer()).toHaveAttribute("data-log-id", "req-1"); @@ -269,9 +267,8 @@ describe("RequestLogsPanel", () => { }); it("opens the drawer on load when ?log_id= matches a log in the loaded page", async () => { - window.history.replaceState(null, "", "/logs/?log_id=req-2"); respondWith([logEntry({ request_id: "req-1" }), logEntry({ request_id: "req-2" })]); - renderWithProviders(); + renderPanel("?log_id=req-2"); await waitFor(() => { expect(drawer()).toHaveTextContent("open"); @@ -280,13 +277,12 @@ describe("RequestLogsPanel", () => { }); it("fetches the log by request_id and opens the drawer when it is not in the loaded page", async () => { - window.history.replaceState(null, "", "/logs/?log_id=req-old"); vi.mocked(uiSpendLogsCall).mockImplementation(async ({ params }) => params?.request_id === "req-old" ? { data: [logEntry({ request_id: "req-old" })], total: 1, page: 1, page_size: 1, total_pages: 1 } : { data: [], total: 0, page: 1, page_size: 50, total_pages: 0 }, ); - renderWithProviders(); + renderPanel("?log_id=req-old"); await waitFor(() => { expect(drawer()).toHaveTextContent("open"); @@ -304,7 +300,7 @@ describe("RequestLogsPanel", () => { it("closing the drawer removes ?log_id= from the URL and closes the drawer", async () => { const user = userEvent.setup(); respondWith([logEntry({ request_id: "req-1" })]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-1")).not.toBeNull()); await user.click(row("req-1") as HTMLElement); @@ -312,14 +308,14 @@ describe("RequestLogsPanel", () => { await user.click(screen.getByRole("button", { name: "close-drawer" })); - expect(new URLSearchParams(window.location.search).get("log_id")).toBeNull(); + await waitFor(() => expect(urlParams().get("log_id")).toBeNull()); await waitFor(() => expect(drawer()).toHaveTextContent("closed")); }); it("switching logs inside the drawer replaces the URL, so back closes the drawer in one step", async () => { const user = userEvent.setup(); respondWith([logEntry({ request_id: "req-1" }), logEntry({ request_id: "req-2" })]); - renderWithProviders(); + const { goBack } = renderPanelWithHistory(); await waitFor(() => expect(row("req-1")).not.toBeNull()); await user.click(row("req-1") as HTMLElement); @@ -327,25 +323,25 @@ describe("RequestLogsPanel", () => { await user.click(screen.getByRole("button", { name: "select-next-log" })); await waitFor(() => expect(drawer()).toHaveAttribute("data-log-id", "req-2")); - expect(new URLSearchParams(window.location.search).get("log_id")).toBe("req-2"); - - window.history.back(); + expect(urlParams().get("log_id")).toBe("req-2"); + expect(historyModes()).toEqual(["push", "replace"]); + goBack(); await waitFor(() => expect(drawer()).toHaveTextContent("closed")); - expect(new URLSearchParams(window.location.search).get("log_id")).toBeNull(); + expect(drawer()).toHaveAttribute("data-log-id", ""); }); it("clicking a session id writes ?session_id= and ?log_id= and opens the session drawer", async () => { const user = userEvent.setup(); respondWith([logEntry({ request_id: "req-solo", session_id: "sess-solo", session_total_count: 1 })]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-solo")).not.toBeNull()); await user.click(within(row("req-solo") as HTMLElement).getByText("sess-solo")); - const params = new URLSearchParams(window.location.search); - expect(params.get("session_id")).toBe("sess-solo"); - expect(params.get("log_id")).toBe("req-solo"); + await waitFor(() => expect(urlParams().get("session_id")).toBe("sess-solo")); + expect(urlParams().get("log_id")).toBe("req-solo"); + expect(historyModes()).toEqual(["push"]); await waitFor(() => { expect(drawer()).toHaveTextContent("open"); expect(drawer()).toHaveAttribute("data-session-id", "sess-solo"); @@ -358,44 +354,43 @@ describe("RequestLogsPanel", () => { logEntry({ request_id: "req-a", session_id: "sess-a", session_total_count: 1 }), logEntry({ request_id: "req-b" }), ]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-a")).not.toBeNull()); await user.click(within(row("req-a") as HTMLElement).getByText("sess-a")); - await waitFor(() => expect(new URLSearchParams(window.location.search).get("session_id")).toBe("sess-a")); + await waitFor(() => expect(urlParams().get("session_id")).toBe("sess-a")); await user.click(row("req-b") as HTMLElement); - const params = new URLSearchParams(window.location.search); - expect(params.get("log_id")).toBe("req-b"); - expect(params.get("session_id")).toBeNull(); + await waitFor(() => expect(urlParams().get("log_id")).toBe("req-b")); + expect(urlParams().get("session_id")).toBeNull(); await waitFor(() => { expect(drawer()).toHaveAttribute("data-log-id", "req-b"); expect(drawer()).toHaveAttribute("data-session-id", ""); }); }); - it("browser back after opening via a session id closes the drawer", async () => { + it("closing a drawer opened via a session id clears both params", async () => { const user = userEvent.setup(); respondWith([logEntry({ request_id: "req-solo", session_id: "sess-solo", session_total_count: 1 })]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-solo")).not.toBeNull()); await user.click(within(row("req-solo") as HTMLElement).getByText("sess-solo")); await waitFor(() => expect(drawer()).toHaveTextContent("open")); - window.history.back(); + await user.click(screen.getByRole("button", { name: "close-drawer" })); await waitFor(() => expect(drawer()).toHaveTextContent("closed")); - expect(new URLSearchParams(window.location.search).get("session_id")).toBeNull(); + expect(urlParams().get("session_id")).toBeNull(); + expect(urlParams().get("log_id")).toBeNull(); }); it("opens a deep-linked multi-call session log in session mode", async () => { - window.history.replaceState(null, "", "/logs/?log_id=req-llm"); respondWith([ logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }), ]); - renderWithProviders(); + renderPanel("?log_id=req-llm"); await waitFor(() => { expect(drawer()).toHaveTextContent("open"); @@ -410,32 +405,30 @@ describe("RequestLogsPanel", () => { logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }), logEntry({ request_id: "req-llm-2", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }), ]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-llm")).not.toBeNull()); await user.click(row("req-llm") as HTMLElement); - const params = new URLSearchParams(window.location.search); - expect(params.get("session_id")).toBe("sess-1"); - expect(params.get("log_id")).toBe("req-llm"); + await waitFor(() => expect(urlParams().get("session_id")).toBe("sess-1")); + expect(urlParams().get("log_id")).toBe("req-llm"); await waitFor(() => expect(drawer()).toHaveAttribute("data-session-id", "sess-1")); }); it("selecting another log while a session view is open keeps the session open", async () => { const user = userEvent.setup(); - window.history.replaceState(null, "", "/logs/?log_id=req-llm"); respondWith([ logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }), logEntry({ request_id: "req-unenriched" }), ]); - renderWithProviders(); + renderPanel("?log_id=req-llm"); await waitFor(() => expect(drawer()).toHaveAttribute("data-session-id", "sess-1")); await user.click(screen.getByRole("button", { name: "select-next-log" })); await waitFor(() => expect(drawer()).toHaveAttribute("data-log-id", "req-unenriched")); - expect(new URLSearchParams(window.location.search).get("session_id")).toBe("sess-1"); + expect(urlParams().get("session_id")).toBe("sess-1"); expect(drawer()).toHaveAttribute("data-session-id", "sess-1"); }); }); @@ -443,7 +436,7 @@ describe("RequestLogsPanel", () => { describe("live tail", () => { it("shows the auto-refresh banner on the first page and hides it once stopped", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderPanel(); expect(await screen.findByText("Auto-refreshing every 15 seconds")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/view_logs/logDetailRouting.ts b/ui/litellm-dashboard/src/components/view_logs/logDetailRouting.ts index b37a4604585..967164053c3 100644 --- a/ui/litellm-dashboard/src/components/view_logs/logDetailRouting.ts +++ b/ui/litellm-dashboard/src/components/view_logs/logDetailRouting.ts @@ -1,8 +1,6 @@ -import { useSearchParams } from "next/navigation"; +import { parseAsString, useQueryStates } from "nuqs"; import { useCallback } from "react"; -import { navigateWithParams } from "@/app/(dashboard)/navigateWithParams"; - export const LOG_ID_QUERY_PARAM = "log_id"; export const SESSION_ID_QUERY_PARAM = "session_id"; @@ -16,45 +14,41 @@ export interface LogDetailRouting { } export function useLogDetailRouting(): LogDetailRouting { - const searchParams = useSearchParams(); + const [{ log_id, session_id }, setParams] = useQueryStates( + { log_id: parseAsString, session_id: parseAsString }, + { history: "push" }, + ); - const openLog = useCallback((requestId: string) => { - navigateWithParams((params) => { - params.set(LOG_ID_QUERY_PARAM, requestId); - params.delete(SESSION_ID_QUERY_PARAM); - }); - }, []); + const openLog = useCallback( + (requestId: string) => { + void setParams({ log_id: requestId, session_id: null }); + }, + [setParams], + ); - const openSession = useCallback((sessionId: string, requestId: string | null) => { - navigateWithParams((params) => { - params.set(SESSION_ID_QUERY_PARAM, sessionId); - if (requestId === null) { - params.delete(LOG_ID_QUERY_PARAM); - } else { - params.set(LOG_ID_QUERY_PARAM, requestId); - } - }); - }, []); + const openSession = useCallback( + (sessionId: string, requestId: string | null) => { + void setParams({ session_id: sessionId, log_id: requestId }); + }, + [setParams], + ); - const selectLog = useCallback((requestId: string, sessionId?: string | null) => { - navigateWithParams((params) => { - params.set(LOG_ID_QUERY_PARAM, requestId); - if (sessionId) { - params.set(SESSION_ID_QUERY_PARAM, sessionId); - } - }, "replace"); - }, []); + const selectLog = useCallback( + (requestId: string, sessionId?: string | null) => { + void setParams(sessionId ? { log_id: requestId, session_id: sessionId } : { log_id: requestId }, { + history: "replace", + }); + }, + [setParams], + ); const close = useCallback(() => { - navigateWithParams((params) => { - params.delete(LOG_ID_QUERY_PARAM); - params.delete(SESSION_ID_QUERY_PARAM); - }); - }, []); + void setParams({ log_id: null, session_id: null }); + }, [setParams]); return { - logId: searchParams?.get(LOG_ID_QUERY_PARAM) ?? null, - sessionId: searchParams?.get(SESSION_ID_QUERY_PARAM) ?? null, + logId: log_id, + sessionId: session_id, openLog, openSession, selectLog, diff --git a/ui/litellm-dashboard/tests/test-utils.tsx b/ui/litellm-dashboard/tests/test-utils.tsx index ba07af0f376..2573fbdfeac 100644 --- a/ui/litellm-dashboard/tests/test-utils.tsx +++ b/ui/litellm-dashboard/tests/test-utils.tsx @@ -1,6 +1,7 @@ import React, { PropsWithChildren } from "react"; import { render, RenderOptions } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NuqsTestingAdapter, OnUrlUpdateFunction } from "nuqs/adapters/testing"; // Create a client for testing export const testQueryClient = new QueryClient({ @@ -19,11 +20,19 @@ export const testQueryClient = new QueryClient({ }, }); -const Providers: React.FC = ({ children }) => { - return {children}; +interface ProviderOptions { + searchParams?: string | Record | URLSearchParams; + onUrlUpdate?: OnUrlUpdateFunction; +} + +export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions & ProviderOptions) => { + const { searchParams, onUrlUpdate, ...renderOptions } = options ?? {}; + const Providers: React.FC = ({ children }) => ( + + {children} + + ); + return render(ui, { wrapper: Providers, ...renderOptions }); }; -export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions) => - render(ui, { wrapper: Providers, ...options }); - export * from "@testing-library/react";