feat(ui): deep link team detail page via ?team= query param (#35112)

The teams page kept the selected team in React state, so a team 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 useTeamDetailRouting reading ?team= (same pattern as the api-keys,
models, and logs deep links) and derives the open team in Teams.tsx from
the URL. TeamInfo now also derives team-admin rights from the fetched
team data, so team admins arriving via a deep link are not stuck with a
read-only view
This commit is contained in:
ryan-crabbe-berri 2026-07-29 11:35:06 -07:00 • committed by GitHub
parent c56e657097
commit bb769702b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 190 additions and 6 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 { useTeamDetailRouting } from "./detailNavigation";
vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) }));
describe("useTeamDetailRouting", () => {
beforeEach(() => {
window.history.pushState(null, "", "/teams/");
});
it("openTeam sets ?team= via history.pushState (no full navigation)", () => {
const spy = vi.spyOn(window.history, "pushState");
const { result } = renderHook(() => useTeamDetailRouting());
act(() => result.current.openTeam("team-abc123"));
expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("team=team-abc123"));
spy.mockRestore();
});
it("openTeam preserves unrelated query params", () => {
window.history.pushState(null, "", "/teams/?foo=bar");
const spy = vi.spyOn(window.history, "pushState");
const { result } = renderHook(() => useTeamDetailRouting());
act(() => result.current.openTeam("team-abc123"));
const url = spy.mock.calls.at(-1)?.[2] as string;
expect(url).toContain("foo=bar");
expect(url).toContain("team=team-abc123");
spy.mockRestore();
});
it("close removes only the team param", () => {
window.history.pushState(null, "", "/teams/?foo=bar&team=team-abc123");
const spy = vi.spyOn(window.history, "pushState");
const { result } = renderHook(() => useTeamDetailRouting());
act(() => result.current.close());
const url = spy.mock.calls.at(-1)?.[2] as string;
expect(url).toContain("foo=bar");
expect(url).not.toContain("team=");
spy.mockRestore();
});
it("exposes teamId from ?team=", () => {
window.history.pushState(null, "", "/teams/?team=team-abc123");
const { result } = renderHook(() => useTeamDetailRouting());
expect(result.current.teamId).toBe("team-abc123");
});
it("teamId is null when no team param is present", () => {
const { result } = renderHook(() => useTeamDetailRouting());
expect(result.current.teamId).toBeNull();
});
});

View file

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

View file

@ -72,6 +72,31 @@ 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 (
@ -159,6 +184,7 @@ 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.replaceState(null, "", "/teams/");
});
describe("Teams - handleCreate organization handling", () => {
@ -436,6 +462,47 @@ describe("Teams - premium props", () => {
});
});
describe("Teams - team detail deep link (?team=)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTeamInfoView.mockClear();
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]);
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
mockUseOrganizations.mockReturnValue({ data: [] });
});
it("selecting a team pushes ?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, team_id: "team-deep-link" }));
expect(window.location.search).toContain("team=team-deep-link");
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" />);
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" />);
await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled());
act(() => mockTeamInfoView.mock.calls.at(-1)?.[0].onClose());
expect(window.location.search).not.toContain("team=");
await waitFor(() => expect(screen.queryByTestId("team-info-view")).not.toBeInTheDocument());
});
});
describe("Teams - Create Team CTA is grouped with the tabs on the left", () => {
beforeEach(() => {
vi.clearAllMocks();

View file

@ -12,6 +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 { TeamsTable } from "./TeamsPage/TeamsTable";
import AccessGroupSelector from "./common_components/AccessGroupSelector";
import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector";
@ -135,7 +136,7 @@ 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 { teamId: selectedTeamId, openTeam, close: closeTeamDetail } = useTeamDetailRouting();
const [editTeam, setEditTeam] = useState<boolean>(false);
const [isTeamModalVisible, setIsTeamModalVisible] = useState(false);
@ -482,12 +483,12 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
userID={userID}
onSelectTeam={(team) => {
setSelectedTeam(team);
setSelectedTeamId(team.team_id);
openTeam(team.team_id);
setEditTeam(false);
}}
onEditTeam={(team) => {
setSelectedTeam(team);
setSelectedTeamId(team.team_id);
openTeam(team.team_id);
setEditTeam(true);
}}
onDeleteTeam={handleDelete}
@ -547,11 +548,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(selectedTeam?.team_id === selectedTeamId ? selectedTeam : null)}
is_proxy_admin={userRole == "Admin"}
userModels={userModels}
editTeam={editTeam}

View file

@ -444,6 +444,29 @@ describe("TeamInfoView", () => {
});
});
it("shows edit tabs when the fetched team data marks the session user as team admin, even without the is_team_admin prop", async () => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
members_with_roles: [
{
user_id: "user-1",
user_email: "admin@test.com",
role: "admin",
spend: 0,
budget_id: "budget1",
},
],
}),
);
renderWithProviders(<TeamInfoView {...defaultProps} is_team_admin={false} is_proxy_admin={false} />);
await waitFor(() => {
expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument();
});
expect(screen.getByRole("tab", { name: "Members" })).toBeInTheDocument();
});
it("should navigate to settings tab when clicked", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());

View file

@ -225,7 +225,15 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
return unfurlWildcardModelsInList(selected, userModels);
}, [selectedModelsInForm, teamData, userModels]);
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam;
const isTeamAdminFromTeamData = useMemo(
() =>
teamData?.team_info?.members_with_roles?.some(
(member) => member.user_id != null && member.user_id === userId && member.role === "admin",
) ?? false,
[teamData, userId],
);
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam || isTeamAdminFromTeamData;
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);