mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
32deaff015
commit
2dc49a913c
22 changed files with 352 additions and 579 deletions
38
ui/litellm-dashboard/package-lock.json
generated
38
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue