mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(ui): deep link organization detail page via ?org= query param (#35117)
* feat(ui): deep link organization detail page via ?org= query param The organizations page kept the selected organization in React state, so an org detail page had no URL: it could not be shared, bookmarked, or opened from another page, and the browser back button dropped you out of the page instead of closing the detail view Adds useOrgDetailRouting reading ?org= (same pattern as the api-keys, models, logs, and teams deep links) and derives the open organization in OrganizationsPanel from the URL * fix(ui): reset org edit mode on plain row selection and type test mocks Greptile P1: with the selected org now URL-derived, browser Back leaves the detail view without running onClose, so a stale editOrg=true made the next plain row click open on the Settings tab. Reset the flag on row selection, matching the teams page Greptile P2: type the panel test's captured table and detail-view props from the real components instead of any
This commit is contained in:
parent
bb769702b1
commit
ba7d8ae17f
4 changed files with 201 additions and 9 deletions
|
|
@ -1,7 +1,9 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type OrganizationsTableComponent from "./OrganizationsTable";
|
||||
import type OrganizationInfoViewComponent from "@/components/organization/organization_view";
|
||||
|
||||
vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({
|
||||
__esModule: true,
|
||||
|
|
@ -18,12 +20,50 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
|||
userRole: null,
|
||||
}),
|
||||
}));
|
||||
type OrganizationsTableProps = React.ComponentProps<typeof OrganizationsTableComponent>;
|
||||
type OrganizationInfoViewProps = React.ComponentProps<typeof OrganizationInfoViewComponent>;
|
||||
|
||||
let capturedTableProps: OrganizationsTableProps | null = null;
|
||||
vi.mock("./OrganizationsTable", () => ({
|
||||
__esModule: true,
|
||||
default: (props: { isLoading: boolean }) => (
|
||||
<div data-testid="organizations-table">isLoading:{String(props.isLoading)}</div>
|
||||
),
|
||||
default: (props: OrganizationsTableProps) => {
|
||||
capturedTableProps = props;
|
||||
return <div data-testid="organizations-table">isLoading:{String(props.isLoading)}</div>;
|
||||
},
|
||||
}));
|
||||
const mockOrgInfoView = vi.fn<(props: OrganizationInfoViewProps) => void>();
|
||||
vi.mock("@/components/organization/organization_view", () => ({
|
||||
__esModule: true,
|
||||
default: (props: OrganizationInfoViewProps) => {
|
||||
mockOrgInfoView(props);
|
||||
return <div data-testid="organization-info-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";
|
||||
|
||||
|
|
@ -34,6 +74,12 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
|||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
capturedTableProps = null;
|
||||
mockOrgInfoView.mockClear();
|
||||
window.history.replaceState(null, "", "/organizations/");
|
||||
});
|
||||
|
||||
describe("OrganizationsPanel", () => {
|
||||
it("gates non-premium users behind the enterprise notice", () => {
|
||||
renderWithQueryClient(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={false} />);
|
||||
|
|
@ -55,3 +101,60 @@ describe("OrganizationsPanel", () => {
|
|||
expect(screen.getByTestId("organizations-table")).toHaveTextContent("isLoading:false");
|
||||
});
|
||||
});
|
||||
|
||||
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} />);
|
||||
|
||||
act(() => capturedTableProps?.onOrganizationClick("org-deep-link"));
|
||||
|
||||
expect(window.location.search).toContain("org=org-deep-link");
|
||||
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} />);
|
||||
|
||||
expect(mockOrgInfoView).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ organizationId: "org-from-url", editOrg: false }),
|
||||
);
|
||||
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} />);
|
||||
|
||||
act(() => mockOrgInfoView.mock.calls.at(-1)?.[0].onClose());
|
||||
|
||||
expect(window.location.search).not.toContain("org=");
|
||||
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} />);
|
||||
|
||||
act(() => capturedTableProps?.onEditClick("org-edit"));
|
||||
|
||||
expect(window.location.search).toContain("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} />);
|
||||
|
||||
act(() => capturedTableProps?.onEditClick("org-edit"));
|
||||
expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ editOrg: true }));
|
||||
|
||||
act(() => window.history.pushState(null, "", "/organizations/"));
|
||||
act(() => capturedTableProps?.onOrganizationClick("org-plain"));
|
||||
|
||||
expect(mockOrgInfoView).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ organizationId: "org-plain", editOrg: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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 React, { useState } from "react";
|
||||
|
|
@ -19,7 +20,7 @@ interface OrganizationsPanelProps {
|
|||
}
|
||||
|
||||
const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, accessToken, premiumUser }) => {
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
||||
const { orgId: selectedOrgId, openOrg, close: closeOrgDetail } = useOrgDetailRouting();
|
||||
const [editOrg, setEditOrg] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [orgToDelete, setOrgToDelete] = useState<string | null>(null);
|
||||
|
|
@ -108,7 +109,7 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
|
|||
<OrganizationInfoView
|
||||
organizationId={selectedOrgId}
|
||||
onClose={() => {
|
||||
setSelectedOrgId(null);
|
||||
closeOrgDetail();
|
||||
setEditOrg(false);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
|
|
@ -132,9 +133,12 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
|
|||
isLoading={isLoading}
|
||||
userRole={userRole}
|
||||
searchActive={searchActive}
|
||||
onOrganizationClick={setSelectedOrgId}
|
||||
onOrganizationClick={(organizationId) => {
|
||||
setEditOrg(false);
|
||||
openOrg(organizationId);
|
||||
}}
|
||||
onEditClick={(organizationId) => {
|
||||
setSelectedOrgId(organizationId);
|
||||
openOrg(organizationId);
|
||||
setEditOrg(true);
|
||||
}}
|
||||
onDeleteClick={handleDelete}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
/* @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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue