refactor(ui): migrate available teams table onto shared DataTable

This commit is contained in:
Yuneng Jiang 2026-07-20 20:08:42 -07:00
parent 9ad8698aab
commit 99f215df68
No known key found for this signature in database
8 changed files with 365 additions and 282 deletions

View file

@ -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

View file

@ -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";

View file

@ -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> = {}): 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(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
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(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
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(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
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(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
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(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
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(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
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(<AvailableTeamsPanel accessToken={null} userID="user-123" />);
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<AvailableTeam[]>((resolve) => {
resolveFetch = resolve;
});
vi.mocked(networking.availableTeamListCall).mockReturnValue(pending);
renderWithProviders(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
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();
});
});
});

View file

@ -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<AvailableTeamsProps> = ({ accessToken, userID }) => {
const [availableTeams, setAvailableTeams] = useState<AvailableTeam[]>([]);
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 <AvailableTeamsTable teams={availableTeams} isLoading={isLoading} onJoinTeam={handleJoinTeam} />;
};
export default AvailableTeamsPanel;

View file

@ -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 (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Users className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No available teams to join</div>
<div className="text-sm text-muted-foreground">
See how to set available teams{" "}
<a
href="https://docs.litellm.ai/docs/proxy/self_serve#all-settings-for-self-serve--sso-flow"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
here
</a>
</div>
</div>
);
}
const AvailableTeamsTable: React.FC<AvailableTeamsTableProps> = ({ teams, isLoading, onJoinTeam }) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(() => getAvailableTeamsTableColumns({ onJoinTeam }), [onJoinTeam]);
return (
<DataTable
data={teams}
columns={columns}
getRowId={(team, index) => team.team_id || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading available teams…"
noDataMessage={<EmptyState />}
size="compact"
/>
);
};
export default AvailableTeamsTable;

View file

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open team actions"
data-testid={`available-team-actions-${team.team_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem data-testid="available-team-action-join" onClick={() => onJoinTeam(team.team_id)}>
<UserPlus />
Join team
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface AvailableTeamsTableColumnsDeps {
onJoinTeam: (teamId: string) => void;
}
export const getAvailableTeamsTableColumns = ({
onJoinTeam,
}: AvailableTeamsTableColumnsDeps): ColumnDef<AvailableTeam>[] => [
{
id: "team_alias",
accessorKey: "team_alias",
meta: { title: "Team Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Team Name" />,
size: 220,
enableSorting: true,
cell: ({ row }) => (
<IdentityCell title={row.original.team_alias} className="max-w-72" titleClassName="font-medium" />
),
},
{
id: "description",
accessorKey: "description",
meta: { title: "Description" },
header: "Description",
size: 280,
enableSorting: false,
cell: ({ row }) => {
const description = row.original.description;
return (
<span className="block max-w-72 truncate text-sm text-muted-foreground" title={description || undefined}>
{description || "No description available"}
</span>
);
},
},
{
id: "members",
accessorFn: (team) => team.members_with_roles.length,
meta: { title: "Members" },
header: ({ column }) => <DataTableSortHeader column={column} title="Members" />,
size: 120,
enableSorting: true,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">{row.original.members_with_roles.length} members</span>
),
},
{
id: "models",
meta: { title: "Models" },
header: "Models",
size: 260,
enableSorting: false,
cell: ({ row }) => <ModelsCell models={row.original.models} />,
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<AvailableTeamRowActions team={row.original} onJoinTeam={onJoinTeam} />
</div>
),
},
];

View file

@ -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(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
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(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
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(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
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(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
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(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
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(<AvailableTeamsPanel accessToken="token-123" userID="user-123" />);
await waitFor(() => {
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument();
});
});
});

View file

@ -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<AvailableTeamsProps> = ({ accessToken, userID }) => {
const [availableTeams, setAvailableTeams] = useState<AvailableTeam[]>([]);
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 (
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]">
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Team Name</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell>Members</TableHeaderCell>
<TableHeaderCell>Models</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{availableTeams.map((team) => (
<TableRow key={team.team_id}>
<TableCell>
<Text>{team.team_alias}</Text>
</TableCell>
<TableCell>
<Text>{team.description || "No description available"}</Text>
</TableCell>
<TableCell>
<Text>{team.members_with_roles.length} members</Text>
</TableCell>
<TableCell>
<div className="flex flex-col">
{!team.models || team.models.length === 0 ? (
<Badge size="xs" color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
team.models.map((model, index) => (
<Badge key={index} size="xs" className="mb-1" color="blue">
<Text>{model.length > 30 ? `${model.slice(0, 30)}...` : model}</Text>
</Badge>
))
)}
</div>
</TableCell>
<TableCell>
<Button size="xs" variant="secondary" onClick={() => handleJoinTeam(team.team_id)}>
Join Team
</Button>
</TableCell>
</TableRow>
))}
{availableTeams.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center">
<Text>
No available teams to join. See how to set available teams{" "}
<a
href="https://docs.litellm.ai/docs/proxy/self_serve#all-settings-for-self-serve--sso-flow"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700 underline"
>
here
</a>
.
</Text>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
);
};
export default AvailableTeamsPanel;