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
This commit is contained in:
ryan-crabbe-berri 2026-08-05 13:29:58 -07:00 committed by GitHub
parent 32deaff015
commit 2dc49a913c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 352 additions and 579 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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();
});
});

View file

@ -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,
};
}

View file

@ -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();
});

View file

@ -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,

View file

@ -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(<HealthStatusPanel />);
render(<HealthStatusPanel />, { wrapper: withNuqsTestingAdapter() });
expect(mockHealthCheckComponent).toHaveBeenCalled();
const props = mockHealthCheckComponent.mock.calls[0][0];

View file

@ -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);
}
}

View file

@ -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<History["pushState"]>) => {
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(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
const url = { current: searchParams };
const handleUrlUpdate = (event: UrlUpdateEvent) => {
onUrlUpdate(event);
url.current = event.queryString;
};
const tree = (currentSearchParams: string) => (
<NuqsTestingAdapter searchParams={currentSearchParams} onUrlUpdate={handleUrlUpdate} hasMemory>
<QueryClientProvider client={queryClient}>
<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={premiumUser} />
</QueryClientProvider>
</NuqsTestingAdapter>
);
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(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={false} />);
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(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
renderPanel();
expect(screen.getByText("+ Create New Organization")).toBeInTheDocument();
});
it("resolves the loading skeleton to false when the query is disabled (no token)", () => {
renderWithQueryClient(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
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(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
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(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
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(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
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(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
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(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
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 }),
);

View file

@ -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<OrganizationsPanelProps> = ({ 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<string | null>(null);
@ -109,7 +109,7 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
<OrganizationInfoView
organizationId={selectedOrgId}
onClose={() => {
closeOrgDetail();
void setSelectedOrgId(null);
setEditOrg(false);
}}
accessToken={accessToken}
@ -135,10 +135,10 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
searchActive={searchActive}
onOrganizationClick={(organizationId) => {
setEditOrg(false);
openOrg(organizationId);
void setSelectedOrgId(organizationId);
}}
onEditClick={(organizationId) => {
openOrg(organizationId);
void setSelectedOrgId(organizationId);
setEditOrg(true);
}}
onDeleteClick={handleDelete}

View file

@ -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();
});
});

View file

@ -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,
};
}

View file

@ -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();
});
});

View file

@ -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,
};
}

View file

@ -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 (
<html lang="en">
<body className={inter.className}>
<ReactQueryProvider>
<AntdGlobalProvider>
<AuthProvider>{children}</AuthProvider>
</AntdGlobalProvider>
</ReactQueryProvider>
<NuqsAdapter>
<ReactQueryProvider>
<AntdGlobalProvider>
<AuthProvider>{children}</AuthProvider>
</AntdGlobalProvider>
</ReactQueryProvider>
</NuqsAdapter>
</body>
</html>
);

View file

@ -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<History["pushState"]>) => {
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(<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>);
return render(
<NuqsTestingAdapter searchParams={options?.searchParams} onUrlUpdate={options?.onUrlUpdate} hasMemory>
<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>
</NuqsTestingAdapter>,
);
};
// 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(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
const onUrlUpdate = vi.fn();
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />, { 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(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />, {
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(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
const onUrlUpdate = vi.fn();
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />, {
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());
});
});

View file

@ -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<TeamProps> = ({ accessToken, userID, userRole, premiumUser
const [editModalVisible, setEditModalVisible] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const { teamId: selectedTeamId, openTeam, close: closeTeamDetail } = useTeamDetailRouting();
const [selectedTeamId, setSelectedTeamId] = useQueryState("team", parseAsString.withOptions({ history: "push" }));
const [editTeam, setEditTeam] = useState<boolean>(false);
const [isTeamModalVisible, setIsTeamModalVisible] = useState(false);
@ -473,12 +473,12 @@ const Teams: React.FC<TeamProps> = ({ 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<TeamProps> = ({ accessToken, userID, userRole, premiumUser
}}
onClose={() => {
setSelectedTeam(null);
closeTeamDetail();
void setSelectedTeamId(null);
setEditTeam(false);
}}
accessToken={accessToken}

View file

@ -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<typeof import("react")>("react");
@ -169,11 +168,12 @@ const keysResult = (keys: KeyResponse[], data: Partial<KeysResponse> = {}, extra
const openFilters = () => fireEvent.click(screen.getByRole("button", { name: "Filters" }));
const lastKeyParam = (onUrlUpdate: Mock<OnUrlUpdateFunction>) =>
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(<VirtualKeysTable />);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { 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(<VirtualKeysTable />);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { 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(<VirtualKeysTable />);
renderWithProviders(<VirtualKeysTable />, { 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(<VirtualKeysTable />);
renderWithProviders(<VirtualKeysTable />, { 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(<VirtualKeysTable />);
renderWithProviders(<VirtualKeysTable />, { searchParams: { key: "missing-key-hash" } });
await waitFor(() => {
expect(screen.getByText("Key not found")).toBeInTheDocument();

View file

@ -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<Team[]>(() => fetchedTeams ?? [], [fetchedTeams]);
const { keyId: selectedKeyId, openKey, close: closeKeyDetail } = useKeyDetailRouting();
const [selectedKeyId, setSelectedKeyId] = useQueryState("key", parseAsString.withOptions({ history: "push" }));
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const [tablePagination, setTablePagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 50 });
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@ -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) {
<div className="w-full h-full overflow-hidden">
<KeyInfoView
keyId={selectedKeyId}
onClose={closeKeyDetail}
onClose={() => void setSelectedKeyId(null)}
keyData={selectedKey}
teams={allTeams}
onDelete={refetch}

View file

@ -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<typeof import("next/navigation")>();
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>): 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(<RequestLogsPanel {...defaultProps} />, { 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) => (
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={handleUrlUpdate} hasMemory>
<QueryClientProvider client={testQueryClient}>
<RequestLogsPanel {...defaultProps} />
</QueryClientProvider>
</NuqsTestingAdapter>
);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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=<request_id> to the URL and opens the drawer", async () => {
const user = userEvent.setup();
respondWith([logEntry({ request_id: "req-1" })]);
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
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(<RequestLogsPanel {...defaultProps} />);
renderPanel();
expect(await screen.findByText("Auto-refreshing every 15 seconds")).toBeInTheDocument();

View file

@ -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,

View file

@ -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<PropsWithChildren> = ({ children }) => {
return <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>;
interface ProviderOptions {
searchParams?: string | Record<string, string> | URLSearchParams;
onUrlUpdate?: OnUrlUpdateFunction;
}
export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions & ProviderOptions) => {
const { searchParams, onUrlUpdate, ...renderOptions } = options ?? {};
const Providers: React.FC<PropsWithChildren> = ({ children }) => (
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate} hasMemory>
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
</NuqsTestingAdapter>
);
return render(ui, { wrapper: Providers, ...renderOptions });
};
export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions) =>
render(ui, { wrapper: Providers, ...options });
export * from "@testing-library/react";