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:
ryan-crabbe-berri 2026-07-29 11:56:37 -07:00 committed by GitHub
parent bb769702b1
commit ba7d8ae17f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 201 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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