mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
[Feature] UI - Teams: Modernize teams table with AntD, server-side pagination, and v2 API
Migrate the OldTeams table from Tremor to Ant Design components, matching the Access Groups page pattern. Switch from /team/list to /v2/team/list for server-side pagination, filtering, and sorting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e5baa2232f
commit
883611aa91
6 changed files with 589 additions and 536 deletions
|
|
@ -9,7 +9,7 @@ import AgentsPanel from "@/components/agents";
|
|||
import BudgetPanel from "@/components/budgets/budget_panel";
|
||||
import CacheDashboard from "@/components/cache_dashboard";
|
||||
import ClaudeCodePluginsPanel from "@/components/claude_code_plugins";
|
||||
import { fetchTeams } from "@/components/common_components/fetch_teams";
|
||||
import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import LoadingScreen from "@/components/common_components/LoadingScreen";
|
||||
import { CostTrackingSettings } from "@/components/CostTrackingSettings";
|
||||
import GeneralSettings from "@/components/general_settings";
|
||||
|
|
@ -339,7 +339,9 @@ function CreateKeyPageContent() {
|
|||
fetchUserModels(userID, userRole, accessToken, setUserModels);
|
||||
}
|
||||
if (accessToken && userID && userRole) {
|
||||
fetchTeams(accessToken, userID, userRole, null, setTeams);
|
||||
v2TeamListCall(accessToken, 1, 100, {
|
||||
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
|
||||
}).then((response) => setTeams(response.teams ?? [])).catch(console.error);
|
||||
}
|
||||
if (accessToken) {
|
||||
fetchOrganizations(accessToken, setOrganizations);
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ vi.mock("./networking", () => ({
|
|||
getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("./common_components/fetch_teams", () => ({
|
||||
fetchTeams: vi.fn(),
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
teamListCall: vi.fn().mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("./molecules/notifications_manager", () => ({
|
||||
|
|
@ -375,6 +375,9 @@ describe("OldTeams - handleCreate organization handling", () => {
|
|||
organizations={[]}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("delete-team-button")).toBeInTheDocument();
|
||||
});
|
||||
const deleteTeamButton = screen.getByTestId("delete-team-button");
|
||||
act(() => {
|
||||
fireEvent.click(deleteTeamButton);
|
||||
|
|
@ -389,7 +392,7 @@ describe("OldTeams - empty state", () => {
|
|||
mockUseOrganizations.mockReturnValue({ data: [] });
|
||||
});
|
||||
|
||||
it("should display empty state message when teams array is empty", () => {
|
||||
it("should display empty state message when teams array is empty", async () => {
|
||||
renderWithQueryClient(
|
||||
<OldTeams
|
||||
teams={[]}
|
||||
|
|
@ -402,11 +405,13 @@ describe("OldTeams - empty state", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No teams found")).toBeInTheDocument();
|
||||
expect(screen.getByText("Adjust your filters or create a new team")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No teams yet")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Create your first team to organize members and manage access to models.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display empty state message when teams is null", () => {
|
||||
it("should display empty state message when teams is null", async () => {
|
||||
renderWithQueryClient(
|
||||
<OldTeams
|
||||
teams={null}
|
||||
|
|
@ -419,11 +424,13 @@ describe("OldTeams - empty state", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No teams found")).toBeInTheDocument();
|
||||
expect(screen.getByText("Adjust your filters or create a new team")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No teams yet")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Create your first team to organize members and manage access to models.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display empty state when teams array has items", () => {
|
||||
it("should not display empty state when teams array has items", async () => {
|
||||
renderWithQueryClient(
|
||||
<OldTeams
|
||||
teams={[
|
||||
|
|
@ -451,9 +458,11 @@ describe("OldTeams - empty state", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("No teams found")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Adjust your filters or create a new team")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("No teams yet")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Create your first team to organize members and manage access to models.")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -621,12 +630,9 @@ describe("OldTeams - premium props", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const truncatedTeamId = "team-123456789".slice(0, 7);
|
||||
const teamButton = await screen.findByRole("button", {
|
||||
name: new RegExp(`${truncatedTeamId}\\.\\.\\.`),
|
||||
});
|
||||
const teamIdElement = await screen.findByText("team-123456789");
|
||||
act(() => {
|
||||
fireEvent.click(teamButton);
|
||||
fireEvent.click(teamIdElement);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled());
|
||||
|
|
@ -798,7 +804,7 @@ describe("OldTeams - access_group_ids in team create", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const createButton = screen.getByRole("button", { name: /create new team/i });
|
||||
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
|
||||
act(() => {
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
|
@ -823,7 +829,8 @@ describe("OldTeams - access_group_ids in team create", () => {
|
|||
const accessGroupInput = screen.getByTestId("access-group-selector");
|
||||
fireEvent.change(accessGroupInput, { target: { value: "ag-1,ag-2" } });
|
||||
|
||||
const createTeamSubmitButton = screen.getByRole("button", { name: /create team/i });
|
||||
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
|
||||
const createTeamSubmitButton = createTeamSubmitButtons[createTeamSubmitButtons.length - 1];
|
||||
fireEvent.click(createTeamSubmitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -865,7 +872,7 @@ describe("OldTeams - models dropdown options", () => {
|
|||
expect(fetchAvailableModelsForTeamOrKey).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const createButton = screen.getByRole("button", { name: /create new team/i });
|
||||
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
|
||||
act(() => {
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
|
@ -884,7 +891,7 @@ describe("OldTeams - organization alias display", () => {
|
|||
mockUseOrganizations.mockReturnValue({ data: [] });
|
||||
});
|
||||
|
||||
it("should display organization alias instead of organization id", () => {
|
||||
it("should display organization alias instead of organization id", async () => {
|
||||
const mockOrganizations = [
|
||||
{
|
||||
organization_id: "org-123",
|
||||
|
|
@ -934,11 +941,13 @@ describe("OldTeams - organization alias display", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Organization")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Organization")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("org-123")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display organization id when alias is not found", () => {
|
||||
it("should display organization id when alias is not found", async () => {
|
||||
mockUseOrganizations.mockReturnValue({ data: [] });
|
||||
|
||||
renderWithQueryClient(
|
||||
|
|
@ -968,10 +977,12 @@ describe("OldTeams - organization alias display", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("org-unknown")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("org-unknown")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display N/A when organization_id is null", () => {
|
||||
it("should display N/A when organization_id is null", async () => {
|
||||
mockUseOrganizations.mockReturnValue({ data: [] });
|
||||
|
||||
renderWithQueryClient(
|
||||
|
|
@ -1001,6 +1012,9 @@ describe("OldTeams - organization alias display", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("N/A")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
// When organization_id is null, the table shows "—" in the Organization column
|
||||
expect(screen.getAllByText("—").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ import {
|
|||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
ClipboardCopyIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { Tooltip } from "antd";
|
||||
import BaseActionButton from "../BaseActionButton";
|
||||
|
|
@ -32,6 +33,7 @@ export const TableIconActionButtonMap: Record<string, TableIconActionButtonBaseP
|
|||
Up: { icon: ChevronUpIcon, className: "hover:text-blue-600" },
|
||||
Down: { icon: ChevronDownIcon, className: "hover:text-blue-600" },
|
||||
Open: { icon: ExternalLinkIcon, className: "hover:text-green-600" },
|
||||
Copy: { icon: ClipboardCopyIcon, className: "hover:text-blue-600" },
|
||||
};
|
||||
|
||||
export default function TableIconActionButton({
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import React from "react";
|
||||
import { Select } from "antd";
|
||||
import { Select, Typography } from "antd";
|
||||
import { Organization } from "../networking";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface OrganizationDropdownProps {
|
||||
organizations?: Organization[] | null;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
|
||||
|
|
@ -16,16 +19,18 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
|
|||
onChange,
|
||||
disabled,
|
||||
loading,
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Search or select an organization"
|
||||
placeholder="All Organizations"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
allowClear
|
||||
style={{ minWidth: 280, ...style }}
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
const org = organizations?.find((o) => o.organization_id === option.key);
|
||||
|
|
@ -37,12 +42,11 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
|
|||
|
||||
return orgAlias.includes(searchTerm) || orgId.includes(searchTerm);
|
||||
}}
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{organizations?.map((org) => (
|
||||
<Select.Option key={org.organization_id} value={org.organization_id}>
|
||||
<span className="font-medium">{org.organization_alias}</span>{" "}
|
||||
<span className="text-gray-500">({org.organization_id})</span>
|
||||
<Text type="secondary">({org.organization_id})</Text>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import { Spin } from "antd";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
|
||||
interface AntDLoadingSpinnerProps {
|
||||
size?: "small" | "default" | "large";
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
export function AntDLoadingSpinner({ size, fontSize }: AntDLoadingSpinnerProps) {
|
||||
const indicator = <LoadingOutlined style={fontSize ? { fontSize } : undefined} spin />;
|
||||
return <Spin indicator={indicator} size={size} />;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue