From 99f215df68046175fc689b4aae33b002e65849ca Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 20 Jul 2026 20:08:42 -0700 Subject: [PATCH] refactor(ui): migrate available teams table onto shared DataTable --- ui/litellm-dashboard/eslint-suppressions.json | 5 - ui/litellm-dashboard/src/components/Teams.tsx | 2 +- .../team/AvailableTeamsPanel.test.tsx | 133 +++++++++++++++++ .../components/team/AvailableTeamsPanel.tsx | 58 ++++++++ .../components/team/AvailableTeamsTable.tsx | 62 ++++++++ .../team/AvailableTeamsTableColumns.tsx | 111 ++++++++++++++ .../components/team/available_teams.test.tsx | 139 ------------------ .../src/components/team/available_teams.tsx | 137 ----------------- 8 files changed, 365 insertions(+), 282 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/team/AvailableTeamsPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/components/team/AvailableTeamsPanel.tsx create mode 100644 ui/litellm-dashboard/src/components/team/AvailableTeamsTable.tsx create mode 100644 ui/litellm-dashboard/src/components/team/AvailableTeamsTableColumns.tsx delete mode 100644 ui/litellm-dashboard/src/components/team/available_teams.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/team/available_teams.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 837b22ee761..f935af8907d 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2147,11 +2147,6 @@ "count": 1 } }, - "src/components/team/available_teams.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/team/member_permissions.tsx": { "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index 2627f2c1d7f..20e9e78e7e4 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -1,5 +1,5 @@ import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; -import AvailableTeamsPanel from "@/components/team/available_teams"; +import AvailableTeamsPanel from "@/components/team/AvailableTeamsPanel"; import TeamInfoView from "@/components/team/TeamInfo"; import TeamSSOSettings from "@/components/TeamSSOSettings"; import { isProxyAdminRole } from "@/utils/roles"; diff --git a/ui/litellm-dashboard/src/components/team/AvailableTeamsPanel.test.tsx b/ui/litellm-dashboard/src/components/team/AvailableTeamsPanel.test.tsx new file mode 100644 index 00000000000..3a9da215aed --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/AvailableTeamsPanel.test.tsx @@ -0,0 +1,133 @@ +import * as networking from "@/components/networking"; +import { act, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import AvailableTeamsPanel from "./AvailableTeamsPanel"; +import type { AvailableTeam } from "./AvailableTeamsTableColumns"; + +vi.mock("@/components/networking", () => ({ + availableTeamListCall: vi.fn(), + teamMemberAddCall: vi.fn(), +})); + +const team = (overrides: Partial = {}): AvailableTeam => ({ + team_id: "team-1", + team_alias: "Test Team 1", + description: "Test Description 1", + models: ["gpt-4"], + members_with_roles: [{ user_id: "user-1", user_email: "user1@test.com", role: "admin" }], + ...overrides, +}); + +describe("AvailableTeamsPanel", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render the column headers", async () => { + vi.mocked(networking.availableTeamListCall).mockResolvedValue([team()]); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Team Name")).toBeInTheDocument(); + }); + expect(screen.getByText("Models")).toBeInTheDocument(); + }); + + it("should display teams when available", async () => { + const mockTeams = [ + team({ team_id: "team-1", team_alias: "Test Team 1" }), + team({ team_id: "team-2", team_alias: "Test Team 2", models: [] }), + ]; + + vi.mocked(networking.availableTeamListCall).mockResolvedValue(mockTeams); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Test Team 1")).toBeInTheDocument(); + expect(screen.getByText("Test Team 2")).toBeInTheDocument(); + }); + }); + + it("should display the empty state when no teams are available", async () => { + vi.mocked(networking.availableTeamListCall).mockResolvedValue([]); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText(/No available teams to join/i)).toBeInTheDocument(); + }); + expect(screen.getByText(/See how to set available teams/i)).toBeInTheDocument(); + }); + + it("should call teamMemberAddCall when the Join team menu item is clicked", async () => { + const user = userEvent.setup(); + vi.mocked(networking.availableTeamListCall).mockResolvedValue([team({ team_id: "team-1" })]); + vi.mocked(networking.teamMemberAddCall).mockResolvedValue({}); + + renderWithProviders(); + + await user.click(await screen.findByTestId("available-team-actions-team-1")); + await user.click(await screen.findByTestId("available-team-action-join")); + + await waitFor(() => { + expect(networking.teamMemberAddCall).toHaveBeenCalledWith("token-123", "team-1", { + user_id: "user-123", + role: "user", + }); + }); + }); + + it("should show the All Proxy Models badge when a team has no models", async () => { + vi.mocked(networking.availableTeamListCall).mockResolvedValue([team({ models: [] })]); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); + }); + }); + + it("should show model badges when a team has models", async () => { + vi.mocked(networking.availableTeamListCall).mockResolvedValue([team({ models: ["gpt-4", "gpt-3.5-turbo"] })]); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument(); + }); + }); + + it("should resolve to the empty state without fetching when there is no access token", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText(/No available teams to join/i)).toBeInTheDocument(); + }); + expect(networking.availableTeamListCall).not.toHaveBeenCalled(); + }); + + it("should hold the loading skeleton until the fetch settles", async () => { + let resolveFetch: (teams: AvailableTeam[]) => void = () => {}; + const pending = new Promise((resolve) => { + resolveFetch = resolve; + }); + vi.mocked(networking.availableTeamListCall).mockReturnValue(pending); + + renderWithProviders(); + + expect(screen.queryByText(/No available teams to join/i)).not.toBeInTheDocument(); + + await act(async () => { + resolveFetch([]); + }); + + await waitFor(() => { + expect(screen.getByText(/No available teams to join/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/team/AvailableTeamsPanel.tsx b/ui/litellm-dashboard/src/components/team/AvailableTeamsPanel.tsx new file mode 100644 index 00000000000..30d0f067be5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/AvailableTeamsPanel.tsx @@ -0,0 +1,58 @@ +import React, { useState, useEffect } from "react"; + +import { availableTeamListCall, teamMemberAddCall } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +import AvailableTeamsTable from "./AvailableTeamsTable"; +import { AvailableTeam } from "./AvailableTeamsTableColumns"; + +interface AvailableTeamsProps { + accessToken: string | null; + userID: string | null; +} + +const AvailableTeamsPanel: React.FC = ({ accessToken, userID }) => { + const [availableTeams, setAvailableTeams] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchAvailableTeams = async () => { + if (!accessToken || !userID) { + setIsLoading(false); + return; + } + + try { + const response = await availableTeamListCall(accessToken); + setAvailableTeams(response); + } catch (error) { + console.error("Error fetching available teams:", error); + } finally { + setIsLoading(false); + } + }; + + fetchAvailableTeams(); + }, [accessToken, userID]); + + const handleJoinTeam = async (teamId: string) => { + if (!accessToken || !userID) return; + + try { + await teamMemberAddCall(accessToken, teamId, { + user_id: userID, + role: "user", + }); + + NotificationsManager.success("Successfully joined team"); + setAvailableTeams((teams) => teams.filter((team) => team.team_id !== teamId)); + } catch (error) { + console.error("Error joining team:", error); + NotificationsManager.fromBackend("Failed to join team"); + } + }; + + return ; +}; + +export default AvailableTeamsPanel; diff --git a/ui/litellm-dashboard/src/components/team/AvailableTeamsTable.tsx b/ui/litellm-dashboard/src/components/team/AvailableTeamsTable.tsx new file mode 100644 index 00000000000..6719cc09780 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/AvailableTeamsTable.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { SortingState } from "@tanstack/react-table"; +import { Users } from "lucide-react"; +import React, { useMemo, useState } from "react"; + +import { DataTable } from "@/components/shared/DataTable"; + +import { AvailableTeam, getAvailableTeamsTableColumns } from "./AvailableTeamsTableColumns"; + +interface AvailableTeamsTableProps { + teams: AvailableTeam[]; + isLoading: boolean; + onJoinTeam: (teamId: string) => void; +} + +const DEFAULT_SORTING: SortingState = [{ id: "team_alias", desc: false }]; + +function EmptyState() { + return ( +
+
+ +
+
No available teams to join
+
+ See how to set available teams{" "} + + here + +
+
+ ); +} + +const AvailableTeamsTable: React.FC = ({ teams, isLoading, onJoinTeam }) => { + const [sorting, setSorting] = useState(DEFAULT_SORTING); + + const columns = useMemo(() => getAvailableTeamsTableColumns({ onJoinTeam }), [onJoinTeam]); + + return ( + team.team_id || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + isLoading={isLoading} + loadingMessage="Loading available teams…" + noDataMessage={} + size="compact" + /> + ); +}; + +export default AvailableTeamsTable; diff --git a/ui/litellm-dashboard/src/components/team/AvailableTeamsTableColumns.tsx b/ui/litellm-dashboard/src/components/team/AvailableTeamsTableColumns.tsx new file mode 100644 index 00000000000..d6811fcc9e9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/AvailableTeamsTableColumns.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { MoreHorizontal, UserPlus } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { IdentityCell, ModelsCell } from "@/components/shared/table_cells"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; + +export interface AvailableTeam { + team_id: string; + team_alias: string; + description?: string; + models: string[]; + members_with_roles: { user_id?: string; user_email?: string; role: string }[]; +} + +function AvailableTeamRowActions({ team, onJoinTeam }: { team: AvailableTeam; onJoinTeam: (teamId: string) => void }) { + return ( + + + + + + onJoinTeam(team.team_id)}> + + Join team + + + + ); +} + +interface AvailableTeamsTableColumnsDeps { + onJoinTeam: (teamId: string) => void; +} + +export const getAvailableTeamsTableColumns = ({ + onJoinTeam, +}: AvailableTeamsTableColumnsDeps): ColumnDef[] => [ + { + id: "team_alias", + accessorKey: "team_alias", + meta: { title: "Team Name" }, + header: ({ column }) => , + size: 220, + enableSorting: true, + cell: ({ row }) => ( + + ), + }, + { + id: "description", + accessorKey: "description", + meta: { title: "Description" }, + header: "Description", + size: 280, + enableSorting: false, + cell: ({ row }) => { + const description = row.original.description; + return ( + + {description || "No description available"} + + ); + }, + }, + { + id: "members", + accessorFn: (team) => team.members_with_roles.length, + meta: { title: "Members" }, + header: ({ column }) => , + size: 120, + enableSorting: true, + cell: ({ row }) => ( + {row.original.members_with_roles.length} members + ), + }, + { + id: "models", + meta: { title: "Models" }, + header: "Models", + size: 260, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/team/available_teams.test.tsx b/ui/litellm-dashboard/src/components/team/available_teams.test.tsx deleted file mode 100644 index 254a5a9bc8e..00000000000 --- a/ui/litellm-dashboard/src/components/team/available_teams.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import * as networking from "@/components/networking"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../../tests/test-utils"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import AvailableTeamsPanel from "./available_teams"; - -vi.mock("@/components/networking", () => ({ - availableTeamListCall: vi.fn(), - teamMemberAddCall: vi.fn(), -})); - -describe("AvailableTeamsPanel", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render", async () => { - vi.mocked(networking.availableTeamListCall).mockResolvedValue([]); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Team Name")).toBeInTheDocument(); - }); - }); - - it("should display teams when available", async () => { - const mockTeams = [ - { - team_id: "team-1", - team_alias: "Test Team 1", - description: "Test Description 1", - models: ["gpt-4"], - members_with_roles: [{ user_id: "user-1", user_email: "user1@test.com", role: "admin" }], - }, - { - team_id: "team-2", - team_alias: "Test Team 2", - description: "Test Description 2", - models: [], - members_with_roles: [{ user_id: "user-2", user_email: "user2@test.com", role: "user" }], - }, - ]; - - vi.mocked(networking.availableTeamListCall).mockResolvedValue(mockTeams); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Test Team 1")).toBeInTheDocument(); - expect(screen.getByText("Test Team 2")).toBeInTheDocument(); - }); - }); - - it("should display empty state when no teams are available", async () => { - vi.mocked(networking.availableTeamListCall).mockResolvedValue([]); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText(/No available teams to join/i)).toBeInTheDocument(); - expect(screen.getByText(/See how to set available teams/i)).toBeInTheDocument(); - }); - }); - - it("should call teamMemberAddCall when join team button is clicked", async () => { - const mockTeams = [ - { - team_id: "team-1", - team_alias: "Test Team 1", - description: "Test Description 1", - models: ["gpt-4"], - members_with_roles: [{ user_id: "user-1", user_email: "user1@test.com", role: "admin" }], - }, - ]; - - vi.mocked(networking.availableTeamListCall).mockResolvedValue(mockTeams); - vi.mocked(networking.teamMemberAddCall).mockResolvedValue({}); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Test Team 1")).toBeInTheDocument(); - }); - - const joinButtons = screen.getAllByRole("button", { name: /join team/i }); - await act(async () => { - fireEvent.click(joinButtons[0]); - }); - - await waitFor(() => { - expect(networking.teamMemberAddCall).toHaveBeenCalledWith("token-123", "team-1", { - user_id: "user-123", - role: "user", - }); - }); - }); - - it("should display All Proxy Models badge when team has no models", async () => { - const mockTeams = [ - { - team_id: "team-1", - team_alias: "Test Team 1", - description: "Test Description 1", - models: [], - members_with_roles: [{ user_id: "user-1", user_email: "user1@test.com", role: "admin" }], - }, - ]; - - vi.mocked(networking.availableTeamListCall).mockResolvedValue(mockTeams); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); - }); - }); - - it("should display model badges when team has models", async () => { - const mockTeams = [ - { - team_id: "team-1", - team_alias: "Test Team 1", - description: "Test Description 1", - models: ["gpt-4", "gpt-3.5-turbo"], - members_with_roles: [{ user_id: "user-1", user_email: "user1@test.com", role: "admin" }], - }, - ]; - - vi.mocked(networking.availableTeamListCall).mockResolvedValue(mockTeams); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument(); - }); - }); -}); diff --git a/ui/litellm-dashboard/src/components/team/available_teams.tsx b/ui/litellm-dashboard/src/components/team/available_teams.tsx deleted file mode 100644 index f1c7ab07818..00000000000 --- a/ui/litellm-dashboard/src/components/team/available_teams.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - Card, - Button, - Text, - Badge, -} from "@tremor/react"; -import { availableTeamListCall, teamMemberAddCall } from "../networking"; -import NotificationsManager from "../molecules/notifications_manager"; - -interface AvailableTeam { - team_id: string; - team_alias: string; - description?: string; - models: string[]; - members_with_roles: { user_id?: string; user_email?: string; role: string }[]; -} - -interface AvailableTeamsProps { - accessToken: string | null; - userID: string | null; -} - -const AvailableTeamsPanel: React.FC = ({ accessToken, userID }) => { - const [availableTeams, setAvailableTeams] = useState([]); - - useEffect(() => { - const fetchAvailableTeams = async () => { - if (!accessToken || !userID) return; - - try { - const response = await availableTeamListCall(accessToken); - - setAvailableTeams(response); - } catch (error) { - console.error("Error fetching available teams:", error); - } - }; - - fetchAvailableTeams(); - }, [accessToken, userID]); - - const handleJoinTeam = async (teamId: string) => { - if (!accessToken || !userID) return; - - try { - const response = await teamMemberAddCall(accessToken, teamId, { - user_id: userID, - role: "user", - }); - - NotificationsManager.success("Successfully joined team"); - // Update available teams list - setAvailableTeams((teams) => teams.filter((team) => team.team_id !== teamId)); - } catch (error) { - console.error("Error joining team:", error); - NotificationsManager.fromBackend("Failed to join team"); - } - }; - - return ( - - - - - Team Name - Description - Members - Models - Actions - - - - {availableTeams.map((team) => ( - - - {team.team_alias} - - - {team.description || "No description available"} - - - {team.members_with_roles.length} members - - -
- {!team.models || team.models.length === 0 ? ( - - All Proxy Models - - ) : ( - team.models.map((model, index) => ( - - {model.length > 30 ? `${model.slice(0, 30)}...` : model} - - )) - )} -
-
- - - -
- ))} - {availableTeams.length === 0 && ( - - - - No available teams to join. See how to set available teams{" "} - - here - - . - - - - )} -
-
-
- ); -}; - -export default AvailableTeamsPanel;