mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
c56e657097
commit
bb769702b1
6 changed files with 190 additions and 6 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue