feat(ui): deep-link team, user, and org detail views via query params

Extends the ?key=/?model= deep-link pattern to the Teams (?team=),
Internal Users (?user=), and Organizations (?org=) pages through a
shared useDetailParam hook, so those detail views survive reloads and
can be shared as URLs. Teams derives the full team object from
/team/info on deep link so is_team_admin stays correct
This commit is contained in:
ryan-crabbe-berri 2026-07-25 10:06:31 -07:00
parent 57894b5b5e
commit 3857931c08
8 changed files with 248 additions and 23 deletions

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 { useDetailParam } from "./useDetailParam";
vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) }));
describe("useDetailParam", () => {
beforeEach(() => {
window.history.pushState(null, "", "/teams/");
});
it("open sets the param via history.pushState (no full navigation)", () => {
const spy = vi.spyOn(window.history, "pushState");
const { result } = renderHook(() => useDetailParam("team"));
act(() => result.current.open("team-1"));
expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("team=team-1"));
spy.mockRestore();
});
it("open preserves unrelated query params like the legacy ?page=", () => {
window.history.pushState(null, "", "/?page=teams");
const spy = vi.spyOn(window.history, "pushState");
const { result } = renderHook(() => useDetailParam("team"));
act(() => result.current.open("team-1"));
const url = spy.mock.calls.at(-1)?.[2] as string;
expect(url).toContain("page=teams");
expect(url).toContain("team=team-1");
spy.mockRestore();
});
it("close removes only its own param", () => {
window.history.pushState(null, "", "/?page=teams&team=team-1");
const spy = vi.spyOn(window.history, "pushState");
const { result } = renderHook(() => useDetailParam("team"));
act(() => result.current.close());
const url = spy.mock.calls.at(-1)?.[2] as string;
expect(url).toContain("page=teams");
expect(url).not.toContain("team=");
spy.mockRestore();
});
it("exposes the id from the param", () => {
window.history.pushState(null, "", "/users/?user=user-7");
const { result } = renderHook(() => useDetailParam("user"));
expect(result.current.id).toBe("user-7");
});
it("id is null when the param is absent", () => {
const { result } = renderHook(() => useDetailParam("org"));
expect(result.current.id).toBeNull();
});
});

View file

@ -0,0 +1,35 @@
import { useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { navigateWithParams } from "../navigateWithParams";
export interface DetailParam {
id: string | null;
open: (id: string) => void;
close: () => void;
}
export function useDetailParam(param: string): DetailParam {
const searchParams = useSearchParams();
const open = useCallback(
(id: string) => {
navigateWithParams((params) => {
params.set(param, id);
});
},
[param],
);
const close = useCallback(() => {
navigateWithParams((params) => {
params.delete(param);
});
}, [param]);
return {
id: searchParams?.get(param) ?? null,
open,
close,
};
}

View file

@ -1,7 +1,15 @@
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";
const mockOrganizationInfoView = vi.fn();
let mockOrganizationsTableProps: any = null;
vi.mock("next/navigation", async (importOriginal) => ({
...(await importOriginal<typeof import("next/navigation")>()),
useSearchParams: () => new URLSearchParams(window.location.search),
}));
vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({
__esModule: true,
@ -18,11 +26,19 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
userRole: null,
}),
}));
vi.mock("@/components/organization/organization_view", () => ({
__esModule: true,
default: (props: any) => {
mockOrganizationInfoView(props);
return <div data-testid="organization-info-view" />;
},
}));
vi.mock("./OrganizationsTable", () => ({
__esModule: true,
default: (props: { isLoading: boolean }) => (
<div data-testid="organizations-table">isLoading:{String(props.isLoading)}</div>
),
default: (props: { isLoading: boolean }) => {
mockOrganizationsTableProps = props;
return <div data-testid="organizations-table">isLoading:{String(props.isLoading)}</div>;
},
}));
import OrganizationsPanel from "./OrganizationsPanel";
@ -35,6 +51,12 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
};
describe("OrganizationsPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
mockOrganizationsTableProps = null;
window.history.pushState(null, "", "/");
});
it("gates non-premium users behind the enterprise notice", () => {
renderWithQueryClient(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={false} />);
@ -54,4 +76,32 @@ describe("OrganizationsPanel", () => {
// A disabled React Query keeps isPending true forever; feeding isLoading avoids a stuck skeleton.
expect(screen.getByTestId("organizations-table")).toHaveTextContent("isLoading:false");
});
it("clicking an organization deep-links via ?org=", () => {
renderWithQueryClient(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
act(() => mockOrganizationsTableProps.onOrganizationClick("org-42"));
expect(window.location.search).toContain("org=org-42");
});
it("renders OrganizationInfoView from a ?org= URL on load", () => {
window.history.pushState(null, "", "/?org=org-42");
renderWithQueryClient(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
expect(screen.getByTestId("organization-info-view")).toBeInTheDocument();
expect(mockOrganizationInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ organizationId: "org-42" }));
expect(screen.queryByTestId("organizations-table")).not.toBeInTheDocument();
});
it("closing the detail view clears ?org=", () => {
window.history.pushState(null, "", "/?org=org-42");
renderWithQueryClient(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
act(() => mockOrganizationInfoView.mock.calls.at(-1)?.[0].onClose());
expect(window.location.search).not.toContain("org=");
});
});

View file

@ -1,4 +1,5 @@
import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useDetailParam } from "@/app/(dashboard)/hooks/useDetailParam";
import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels";
import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters";
import { useQueryClient } from "@tanstack/react-query";
@ -19,7 +20,7 @@ interface OrganizationsPanelProps {
}
const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, accessToken, premiumUser }) => {
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const { id: selectedOrgId, open: openOrg, close: closeOrg } = useDetailParam("org");
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);
closeOrg();
setEditOrg(false);
}}
accessToken={accessToken}
@ -132,9 +133,9 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
isLoading={isLoading}
userRole={userRole}
searchActive={searchActive}
onOrganizationClick={setSelectedOrgId}
onOrganizationClick={openOrg}
onEditClick={(organizationId) => {
setSelectedOrgId(organizationId);
openOrg(organizationId);
setEditOrg(true);
}}
onDeleteClick={handleDelete}

View file

@ -9,6 +9,11 @@ import ViewUserDashboard from "./view_users";
const userListCall = vi.fn();
vi.mock("next/navigation", async (importOriginal) => ({
...(await importOriginal<typeof import("next/navigation")>()),
useSearchParams: () => new URLSearchParams(window.location.search),
}));
// Mock the networking module
vi.mock("@/components/networking", () => ({
userListCall: (...args: unknown[]) => userListCall(...args),
@ -88,6 +93,7 @@ const renderDashboard = () =>
describe("ViewUserDashboard", () => {
beforeEach(() => {
vi.clearAllMocks();
window.history.pushState(null, "", "/");
userListCall.mockResolvedValue({
users: [makeUser("user-1", "test@example.com")],
total: 1,
@ -129,7 +135,7 @@ describe("ViewUserDashboard", () => {
expect(screen.getAllByText("user-1").length).toBeGreaterThan(0);
});
it("should swap to the detail view when the identity cell is clicked", async () => {
it("clicking the identity cell deep-links via ?user=", async () => {
const user = userEvent.setup();
renderDashboard();
@ -139,6 +145,14 @@ describe("ViewUserDashboard", () => {
await user.click(screen.getByRole("button", { name: /user-1/ }));
expect(window.location.search).toContain("user=user-1");
});
it("renders the detail view from a ?user= URL on load", async () => {
window.history.pushState(null, "", "/?user=user-1");
renderDashboard();
expect(await screen.findByTestId("user-info-view")).toHaveTextContent("detail:user-1:false");
expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
});

View file

@ -15,6 +15,7 @@ import {
} from "@/components/networking";
import OnboardingModal, { InvitationLink } from "@/components/onboarding_link";
import { useDetailParam } from "@/app/(dashboard)/hooks/useDetailParam";
import { updateExistingKeys } from "@/utils/dataUtils";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { isAdminRole, isProxyAdminRole } from "@/utils/roles";
@ -72,7 +73,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
const [selectionMode, setSelectionMode] = useState(false);
const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const { id: selectedUserId, open: openUser, close: closeUser } = useDetailParam("user");
const [openInEditMode, setOpenInEditMode] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
@ -139,15 +140,18 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
setRowSelection({});
}, []);
const handleUserClick = useCallback((userId: string, openInEdit: boolean = false) => {
setSelectedUserId(userId);
setOpenInEditMode(openInEdit);
}, []);
const handleUserClick = useCallback(
(userId: string, openInEdit: boolean = false) => {
openUser(userId);
setOpenInEditMode(openInEdit);
},
[openUser],
);
const handleCloseUserInfo = useCallback(() => {
setSelectedUserId(null);
closeUser();
setOpenInEditMode(false);
}, []);
}, [closeUser]);
const handleDelete = useCallback((user: UserInfo) => {
setUserToDelete(user);

View file

@ -8,6 +8,12 @@ import Teams from "./Teams";
const mockTeamInfoView = vi.fn();
const mockUseOrganizations = vi.fn();
const mockUseTeam = vi.fn();
vi.mock("next/navigation", async (importOriginal) => ({
...(await importOriginal<typeof import("next/navigation")>()),
useSearchParams: () => new URLSearchParams(window.location.search),
}));
// The teams grid is unit-tested in TeamsPage/TeamsTable.test.tsx. Here we stub it and drive its callbacks
// directly so we can test the Teams shell wiring (delete modal, detail view) without the real DataTable.
@ -31,6 +37,7 @@ vi.mock("./networking", () => ({
// Teams invalidates teamsTableKeys on mutations; the selected team is passed up from the table.
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
teamsTableKeys: { all: ["teamsTable"] },
useTeam: (teamId?: string) => mockUseTeam(teamId),
}));
vi.mock("./molecules/notifications_manager", () => ({
@ -159,6 +166,8 @@ const renderWithQueryClient = (component: React.ReactElement) => {
// Re-establish safe defaults before every test (clearAllMocks keeps return values, so restore them here).
beforeEach(() => {
mockTeamsTableProps = null;
window.history.pushState(null, "", "/");
mockUseTeam.mockReturnValue({ data: undefined });
});
describe("Teams - handleCreate organization handling", () => {
@ -659,3 +668,56 @@ describe("Teams - LIT-2530 organization stays optional for proxy admin with a si
});
});
});
describe("Teams - ?team= deep link", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTeamInfoView.mockClear();
mockUseOrganizations.mockReturnValue({ data: [] });
});
it("clicking a team writes ?team= to the URL", async () => {
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => expect(mockTeamsTableProps).not.toBeNull());
act(() => mockTeamsTableProps.onSelectTeam(baseTableTeam));
expect(window.location.search).toContain(`team=${baseTableTeam.team_id}`);
});
it("renders TeamInfoView from a ?team= URL on load", async () => {
window.history.pushState(null, "", "/?team=team-deeplink");
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled());
expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ teamId: "team-deeplink" }));
});
it("derives is_team_admin from the fetched /team/info envelope on deep link", async () => {
window.history.pushState(null, "", "/?team=team-deeplink");
mockUseTeam.mockReturnValue({
data: {
team_id: "team-deeplink",
team_info: { team_id: "team-deeplink", members_with_roles: [{ user_id: "user-123", role: "admin" }] },
},
});
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Internal User" />);
await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled());
expect(mockUseTeam).toHaveBeenCalledWith("team-deeplink");
expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ is_team_admin: true }));
});
it("closing the detail view clears ?team=", async () => {
window.history.pushState(null, "", "/?team=team-deeplink");
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled());
act(() => mockTeamInfoView.mock.calls.at(-1)?.[0].onClose());
expect(window.location.search).not.toContain("team=");
});
});

View file

@ -11,7 +11,8 @@ import React, { useEffect, useState } from "react";
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 { teamsTableKeys, useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useDetailParam } from "@/app/(dashboard)/hooks/useDetailParam";
import { TeamsTable } from "./TeamsPage/TeamsTable";
import AccessGroupSelector from "./common_components/AccessGroupSelector";
import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector";
@ -135,9 +136,14 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
const [editModalVisible, setEditModalVisible] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const { id: selectedTeamId, open: openTeamDetail, close: closeTeamDetail } = useDetailParam("team");
const [editTeam, setEditTeam] = useState<boolean>(false);
const clickedTeam = selectedTeam?.team_id === selectedTeamId ? selectedTeam : null;
const { data: teamInfoData } = useTeam(clickedTeam ? undefined : selectedTeamId ?? undefined);
const activeTeam =
clickedTeam ?? (teamInfoData as { team_info?: Team } | undefined)?.team_info ?? teamInfoData ?? null;
const [isTeamModalVisible, setIsTeamModalVisible] = useState(false);
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
@ -482,12 +488,12 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
userID={userID}
onSelectTeam={(team) => {
setSelectedTeam(team);
setSelectedTeamId(team.team_id);
openTeamDetail(team.team_id);
setEditTeam(false);
}}
onEditTeam={(team) => {
setSelectedTeam(team);
setSelectedTeamId(team.team_id);
openTeamDetail(team.team_id);
setEditTeam(true);
}}
onDeleteTeam={handleDelete}
@ -547,11 +553,11 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
}}
onClose={() => {
setSelectedTeam(null);
setSelectedTeamId(null);
closeTeamDetail();
setEditTeam(false);
}}
accessToken={accessToken}
is_team_admin={is_team_admin(selectedTeam)}
is_team_admin={is_team_admin(activeTeam)}
is_proxy_admin={userRole == "Admin"}
userModels={userModels}
editTeam={editTeam}