From 5c41aabab01f03271dd5550e754e3df9ab046fbf Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 15 Jun 2026 17:42:25 -0700 Subject: [PATCH] feat(ui): cut the teams page over to the /ui/teams path route (#30343) * feat(ui): cut the teams page over to the /ui/teams path route OldTeams renders from app/(dashboard)/teams/page.tsx via useAuthorized. The component already fetched its own paginated, filtered team list through v2TeamListCall; setTeams was only a round-trip back into the shell's lifted state, so it becomes internal useState. The organizations prop was redundant with the useOrganizations hook the component already calls, and searchParams was never read, so both are dropped. The shell keeps its own teams state and fetch for the still-coupled api-keys and models arms. Tests no longer inject teams through a prop; they mock teamListCall to drive what the component renders. * test(ui): drop unnecessary as-any casts on teamListCall mocks teamListCall returns Promise, so mockResolvedValue already accepts the payload untyped. The casts pushed the repo-wide no-explicit-any lint budget over its ceiling in CI. * test(ui): drop redundant as-any casts from team mock fixtures The mocked teamListCall resolves an any-typed payload, so the inner team fixtures no longer need casts to carry a null organization_id, a keys_count field, or partial key objects. --- .../e2e_tests/fixtures/migratedPages.ts | 1 + .../src/app/(dashboard)/page.tsx | 12 - .../src/app/(dashboard)/teams/page.tsx | 9 + .../src/components/OldTeams.test.tsx | 642 ++++++++---------- .../src/components/OldTeams.tsx | 22 +- .../src/utils/migratedPages.test.ts | 7 + .../src/utils/migratedPages.ts | 1 + 7 files changed, 310 insertions(+), 384 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/migratedPages.ts b/ui/litellm-dashboard/e2e_tests/fixtures/migratedPages.ts index 00c4982529e..154badac021 100644 --- a/ui/litellm-dashboard/e2e_tests/fixtures/migratedPages.ts +++ b/ui/litellm-dashboard/e2e_tests/fixtures/migratedPages.ts @@ -40,6 +40,7 @@ export const MIGRATED_E2E_PAGES: Record = { agents: "agents", "router-settings": "router-settings", users: "users", + teams: "teams", organizations: "organizations", }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index 2369e130eee..c99b6eb9b40 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -6,7 +6,6 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings" import LoadingScreen from "@/components/common_components/LoadingScreen"; import { Team } from "@/components/key_team_helpers/key_list"; import { Organization, proxyBaseUrl, getInProductNudgesCall } from "@/components/networking"; -import OldTeams from "@/components/OldTeams"; import { CreateKeyPrefillData } from "@/components/organisms/create_key_button"; import { fetchOrganizations } from "@/components/organizations"; import PassThroughSettings from "@/components/pass_through_settings"; @@ -318,17 +317,6 @@ function CreateKeyPageContent() { premiumUser={premiumUser} teams={teams} /> - ) : page == "teams" ? ( - ) : page == "pass-through-settings" ? ( ; +} diff --git a/ui/litellm-dashboard/src/components/OldTeams.test.tsx b/ui/litellm-dashboard/src/components/OldTeams.test.tsx index 7447432b876..4b076bbfb3c 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.test.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key"; import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking"; import OldTeams from "./OldTeams"; +import { teamListCall } from "@/app/(dashboard)/hooks/teams/useTeams"; const mockTeamInfoView = vi.fn(); const mockUseOrganizations = vi.fn(); @@ -349,32 +350,29 @@ describe("OldTeams - handleCreate organization handling", () => { it("should clear the delete modal when the cancel button is clicked", async () => { mockUseOrganizations.mockReturnValue({ data: [] }); - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ + teams: [ + { + team_id: "1", + team_alias: "Test Team", + organization_id: "org-123", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + members_with_roles: [], + spend: 0, + }, + ], + total: 1, + page: 1, + page_size: 100, + total_pages: 1, + }); + renderWithQueryClient(); await waitFor(() => { expect(screen.getByTestId("delete-team-button")).toBeInTheDocument(); }); @@ -393,17 +391,8 @@ describe("OldTeams - empty state", () => { }); it("should display empty state message when teams array is empty", async () => { - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); + renderWithQueryClient(); await waitFor(() => { expect(screen.getByText("No teams yet")).toBeInTheDocument(); @@ -414,17 +403,8 @@ describe("OldTeams - empty state", () => { }); it("should display empty state message when teams is null", async () => { - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); + renderWithQueryClient(); await waitFor(() => { expect(screen.getByText("No teams yet")).toBeInTheDocument(); @@ -435,32 +415,29 @@ describe("OldTeams - empty state", () => { }); it("should not display empty state when teams array has items", async () => { - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ + teams: [ + { + team_id: "1", + team_alias: "Test Team", + organization_id: "org-123", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + members_with_roles: [], + spend: 0, + }, + ], + total: 1, + page: 1, + page_size: 100, + total_pages: 1, + }); + renderWithQueryClient(); await waitFor(() => { expect(screen.getByText("Test Team")).toBeInTheDocument(); @@ -608,33 +585,29 @@ describe("OldTeams - premium props", () => { }); it("passes premiumUser flag to TeamInfoView", async () => { - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ + teams: [ + { + team_id: "team-123456789", + team_alias: "Premium Team", + organization_id: "org-123", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + members_with_roles: [], + spend: 0, + }, + ], + total: 1, + page: 1, + page_size: 100, + total_pages: 1, + }); + renderWithQueryClient(); const teamIdElement = await screen.findByText("team-123456789"); act(() => { @@ -654,125 +627,113 @@ describe("OldTeams - Default Team Settings tab visibility", () => { }); it("should show Default Team Settings tab for Admin role", () => { - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ + teams: [ + { + team_id: "1", + team_alias: "Test Team", + organization_id: "org-123", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + members_with_roles: [], + spend: 0, + }, + ], + total: 1, + page: 1, + page_size: 100, + total_pages: 1, + }); + renderWithQueryClient(); expect(screen.getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument(); }); it("should show Default Team Settings tab for proxy_admin role", () => { - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ + teams: [ + { + team_id: "1", + team_alias: "Test Team", + organization_id: "org-123", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + members_with_roles: [], + spend: 0, + }, + ], + total: 1, + page: 1, + page_size: 100, + total_pages: 1, + }); + renderWithQueryClient(); expect(screen.getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument(); }); it("should not show Default Team Settings tab for proxy_admin_viewer role", () => { - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ + teams: [ + { + team_id: "1", + team_alias: "Test Team", + organization_id: "org-123", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + members_with_roles: [], + spend: 0, + }, + ], + total: 1, + page: 1, + page_size: 100, + total_pages: 1, + }); + renderWithQueryClient(); expect(screen.queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument(); }); it("should not show Default Team Settings tab for Admin Viewer role", () => { - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ + teams: [ + { + team_id: "1", + team_alias: "Test Team", + organization_id: "org-123", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + members_with_roles: [], + spend: 0, + }, + ], + total: 1, + page: 1, + page_size: 100, + total_pages: 1, + }); + renderWithQueryClient(); expect(screen.queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument(); }); @@ -793,24 +754,15 @@ describe("OldTeams - access_group_ids in team create", () => { keys: [], members_with_roles: [], spend: 0, - } as any); + }); mockUseOrganizations.mockReturnValue({ data: [{ organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] }], }); }); it("should pass access_group_ids to teamCreateCall when creating team", async () => { - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); + renderWithQueryClient(); const createButton = screen.getAllByRole("button", { name: /create team/i })[0]; act(() => { @@ -864,17 +816,8 @@ describe("OldTeams - models dropdown options", () => { it("should not render all-proxy-models option in models select", async () => { vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4", "gpt-3.5-turbo"]); - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); + renderWithQueryClient(); await waitFor(() => { expect(fetchAvailableModelsForTeamOrKey).toHaveBeenCalled(); @@ -922,32 +865,29 @@ describe("OldTeams - organization alias display", () => { mockUseOrganizations.mockReturnValue({ data: mockOrganizations }); - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ + teams: [ + { + team_id: "1", + team_alias: "Test Team", + organization_id: "org-123", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + members_with_roles: [], + spend: 0, + }, + ], + total: 1, + page: 1, + page_size: 100, + total_pages: 1, + }); + renderWithQueryClient(); await waitFor(() => { expect(screen.getByText("Test Organization")).toBeInTheDocument(); @@ -958,32 +898,29 @@ describe("OldTeams - organization alias display", () => { it("should display organization id when alias is not found", async () => { mockUseOrganizations.mockReturnValue({ data: [] }); - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ + teams: [ + { + team_id: "1", + team_alias: "Test Team", + organization_id: "org-unknown", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + members_with_roles: [], + spend: 0, + }, + ], + total: 1, + page: 1, + page_size: 100, + total_pages: 1, + }); + renderWithQueryClient(); await waitFor(() => { expect(screen.getByText("org-unknown")).toBeInTheDocument(); @@ -993,32 +930,29 @@ describe("OldTeams - organization alias display", () => { it("should display N/A when organization_id is null", async () => { mockUseOrganizations.mockReturnValue({ data: [] }); - renderWithQueryClient( - , - ); + vi.mocked(teamListCall).mockResolvedValue({ + teams: [ + { + team_id: "1", + team_alias: "Test Team", + organization_id: null, + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + members_with_roles: [], + spend: 0, + }, + ], + total: 1, + page: 1, + page_size: 100, + total_pages: 1, + }); + renderWithQueryClient(); await waitFor(() => { // When organization_id is null, the table shows "—" in the Organization column @@ -1034,32 +968,31 @@ describe("OldTeams - Resources column keys badge", () => { }); it("renders keys_count from the v2 payload in the Resources badge", async () => { + vi.mocked(teamListCall).mockResolvedValue({ + teams: [ + { + team_id: "1", + team_alias: "Team With Keys", + organization_id: "org-123", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + keys_count: 3, + members_with_roles: [], + spend: 0, + }, + ], + total: 1, + page: 1, + page_size: 100, + total_pages: 1, + }); const { container } = renderWithQueryClient( - , + , ); await waitFor(() => { @@ -1071,31 +1004,30 @@ describe("OldTeams - Resources column keys badge", () => { }); it("falls back to keys.length when keys_count is absent", async () => { + vi.mocked(teamListCall).mockResolvedValue({ + teams: [ + { + team_id: "2", + team_alias: "Legacy Team", + organization_id: "org-123", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [{ token: "t1" }, { token: "t2" }], + members_with_roles: [], + spend: 0, + }, + ], + total: 1, + page: 1, + page_size: 100, + total_pages: 1, + }); const { container } = renderWithQueryClient( - , + , ); await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index 4d045780c30..c7a2ae0e61a 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -54,13 +54,9 @@ import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; import SearchToolSelector from "./SearchTools/SearchToolSelector"; interface TeamProps { - teams: Team[] | null; - searchParams: any; accessToken: string | null; - setTeams: React.Dispatch>; userID: string | null; userRole: string | null; - organizations: Organization[] | null; premiumUser?: boolean; } @@ -165,18 +161,10 @@ const getOrganizationAlias = ( }; // @deprecated -const Teams: React.FC = ({ - teams, - searchParams, - accessToken, - setTeams, - userID, - userRole, - organizations, - premiumUser = false, -}) => { - console.log(`organizations: ${JSON.stringify(organizations)}`); +const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser = false }) => { const { data: organizationsData } = useOrganizations(); + const organizations = organizationsData ?? null; + const [teams, setTeams] = useState(null); const [isLoading, setIsLoading] = useState(true); const [fetchError, setFetchError] = useState(null); const [currentPage, setCurrentPage] = useState(1); @@ -721,7 +709,7 @@ const Teams: React.FC = ({ width: 160, ellipsis: true, render: (_: unknown, record: Team) => { - const orgAlias = getOrganizationAlias(record.organization_id, organizationsData || organizations); + const orgAlias = getOrganizationAlias(record.organization_id, organizations); return record.organization_id ? ( {orgAlias} @@ -860,7 +848,7 @@ const Teams: React.FC = ({ ), }, ], - [userRole, perTeamInfo, organizationsData, organizations], + [userRole, perTeamInfo, organizations], ); const displayTeams = useMemo(() => teams ?? [], [teams]); diff --git a/ui/litellm-dashboard/src/utils/migratedPages.test.ts b/ui/litellm-dashboard/src/utils/migratedPages.test.ts index 3afd5a81a9a..c3cbb72161a 100644 --- a/ui/litellm-dashboard/src/utils/migratedPages.test.ts +++ b/ui/litellm-dashboard/src/utils/migratedPages.test.ts @@ -127,6 +127,13 @@ describe("migratedHref / legacyPageHref", () => { expect(MIGRATED_PAGES.users).toBe("users"); }); + it("maps the teams id to its route", async () => { + vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); + const { MIGRATED_PAGES } = await import("./migratedPages"); + + expect(MIGRATED_PAGES.teams).toBe("teams"); + }); + it("maps the organizations id to its route", async () => { vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); const { MIGRATED_PAGES } = await import("./migratedPages"); diff --git a/ui/litellm-dashboard/src/utils/migratedPages.ts b/ui/litellm-dashboard/src/utils/migratedPages.ts index e9e6c527513..9c08aa1960b 100644 --- a/ui/litellm-dashboard/src/utils/migratedPages.ts +++ b/ui/litellm-dashboard/src/utils/migratedPages.ts @@ -43,6 +43,7 @@ export const MIGRATED_PAGES: Record = { agents: "agents", "router-settings": "router-settings", users: "users", + teams: "teams", organizations: "organizations", };