mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
feat(ui): team members tab pagination and search filter
- MemberTable: add top-right antd Pagination (showSizeChanger + showQuickJumper), withPagination prop replaces old pagination object prop - TeamMemberTab: replace O(n²) Array.find() with O(1) useMemo Map lookup - TeamMemberTab: client-side pagination (50/page default, 10/25/50/100 options) - TeamMemberTab: search bar (email/user_id) + role filter dropdown; key reset on filter change Co-Authored-By: Claude Sonnet 4 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
084acdadad
commit
c201ae3ba9
3 changed files with 192 additions and 71 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import { Member } from "@/components/networking";
|
||||
import { CrownOutlined, InfoCircleOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Button, Space, Table, Tag, Tooltip, Typography } from "antd";
|
||||
import { Button, Pagination, Space, Table, Tag, Tooltip, Typography } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import TableIconActionButton from "./IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
|
@ -18,6 +18,10 @@ export interface MemberTableProps {
|
|||
extraColumns?: ColumnsType<Member>;
|
||||
showDeleteForMember?: (member: Member) => boolean;
|
||||
emptyText?: string;
|
||||
loading?: boolean;
|
||||
/** When true, renders top-right pagination controls instead of the default antd bottom pagination. */
|
||||
withPagination?: boolean;
|
||||
defaultPageSize?: number;
|
||||
}
|
||||
|
||||
export default function MemberTable({
|
||||
|
|
@ -31,7 +35,17 @@ export default function MemberTable({
|
|||
extraColumns = [],
|
||||
showDeleteForMember,
|
||||
emptyText,
|
||||
loading,
|
||||
withPagination = false,
|
||||
defaultPageSize = 50,
|
||||
}: MemberTableProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||
|
||||
const total = members.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const pagedMembers = withPagination ? members.slice((safePage - 1) * pageSize, safePage * pageSize) : members;
|
||||
const baseColumns: ColumnsType<Member> = [
|
||||
{
|
||||
title: "User Email",
|
||||
|
|
@ -104,14 +118,29 @@ export default function MemberTable({
|
|||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<span className="inline-flex text-sm text-gray-700">
|
||||
{members.length} Member{members.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="text-sm text-gray-700">
|
||||
{total} Member{total !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{withPagination && (
|
||||
<Pagination
|
||||
size="small"
|
||||
current={safePage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger
|
||||
pageSizeOptions={["10", "25", "50", "100"]}
|
||||
showQuickJumper
|
||||
onChange={(p, ps) => { setPage(p); if (ps !== pageSize) { setPageSize(ps); setPage(1); } }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Table
|
||||
columns={baseColumns}
|
||||
dataSource={members}
|
||||
dataSource={pagedMembers}
|
||||
rowKey={(record) => record.user_id ?? record.user_email ?? JSON.stringify(record)}
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
size="small"
|
||||
scroll={{ x: "max-content" }}
|
||||
locale={emptyText ? { emptyText } : undefined}
|
||||
|
|
|
|||
|
|
@ -368,7 +368,69 @@ describe("TeamMembersComponent", () => {
|
|||
expect(screen.getAllByTestId("edit-member")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should hide action buttons when canEditTeam is false", () => {
|
||||
it("should filter members by email search", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search by email or user id/i);
|
||||
await user.type(searchInput, "user1");
|
||||
|
||||
expect(screen.getAllByText("user1@test.com").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByText("user2@test.com")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should filter members by role", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
const roleSelect = screen.getByRole("combobox");
|
||||
await user.click(roleSelect);
|
||||
await user.click(screen.getByText("Admin"));
|
||||
|
||||
expect(screen.getAllByText("user2@test.com").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByText("user1@test.com")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show all members when search is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search by email or user id/i);
|
||||
await user.type(searchInput, "user1");
|
||||
await user.clear(searchInput);
|
||||
|
||||
expect(screen.getAllByText("user1@test.com").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("user2@test.com").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import { formatBudgetReset } from "@/utils/budgetUtils";
|
|||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Space, Tooltip, Typography } from "antd";
|
||||
import { Input, Select, Space, Tooltip, Typography } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import MemberTable from "@/components/common_components/MemberTable";
|
||||
import { TeamData } from "./TeamInfo";
|
||||
import { TeamData, TeamMembership } from "./TeamInfo";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
interface TeamMemberTabProps {
|
||||
teamData: TeamData;
|
||||
|
|
@ -19,6 +20,7 @@ interface TeamMemberTabProps {
|
|||
setIsAddMemberModalVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
|
||||
export default function TeamMemberTab({
|
||||
teamData,
|
||||
canEditTeam,
|
||||
|
|
@ -27,80 +29,87 @@ export default function TeamMemberTab({
|
|||
setIsEditMemberModalVisible,
|
||||
setIsAddMemberModalVisible,
|
||||
}: TeamMemberTabProps) {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState<string | null>(null);
|
||||
|
||||
// O(1) lookup instead of O(n) find() per member per column
|
||||
const membershipsMap = useMemo(
|
||||
() =>
|
||||
new Map<string, TeamMembership>(
|
||||
teamData.team_memberships
|
||||
.filter((tm) => tm.user_id)
|
||||
.map((tm) => [tm.user_id, tm]),
|
||||
),
|
||||
[teamData.team_memberships],
|
||||
);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
const q = searchText.trim().toLowerCase();
|
||||
return teamData.team_info.members_with_roles.filter((m) => {
|
||||
if (roleFilter && m.role?.toLowerCase() !== roleFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
m.user_email?.toLowerCase().includes(q) ||
|
||||
m.user_id?.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [teamData.team_info.members_with_roles, searchText, roleFilter]);
|
||||
|
||||
const formatNumber = (value: number | null): string => {
|
||||
if (value === null || value === undefined) return "0";
|
||||
|
||||
if (typeof value === "number") {
|
||||
// Convert scientific notation to normal decimal
|
||||
const normalNumber = Number(value);
|
||||
|
||||
// If it's a whole number, return it without decimals
|
||||
if (normalNumber === Math.floor(normalNumber)) {
|
||||
return normalNumber.toString();
|
||||
}
|
||||
|
||||
// For decimal numbers, use toFixed and remove trailing zeros
|
||||
if (normalNumber === Math.floor(normalNumber)) return normalNumber.toString();
|
||||
return formatNumberWithCommas(normalNumber, 8).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
return "0";
|
||||
};
|
||||
|
||||
const getUserCurrentCycleSpend = (userId: string | null): number => {
|
||||
if (!userId) return 0;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
return membership?.spend ?? 0;
|
||||
return membershipsMap.get(userId)?.spend ?? 0;
|
||||
};
|
||||
|
||||
const getUserTotalSpend = (userId: string | null): number => {
|
||||
if (!userId) return 0;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
return membership?.total_spend ?? 0;
|
||||
return membershipsMap.get(userId)?.total_spend ?? 0;
|
||||
};
|
||||
|
||||
const getUserBudget = (userId: string | null): string | null => {
|
||||
if (!userId) return null;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
const maxBudget = membership?.litellm_budget_table?.max_budget;
|
||||
if (maxBudget === null || maxBudget === undefined) {
|
||||
return null;
|
||||
}
|
||||
const maxBudget = membershipsMap.get(userId)?.litellm_budget_table?.max_budget;
|
||||
if (maxBudget === null || maxBudget === undefined) return null;
|
||||
return formatNumber(maxBudget);
|
||||
};
|
||||
|
||||
// Helper function to get rate limits for a user
|
||||
const getUserRateLimits = (userId: string | null): string => {
|
||||
if (!userId) return "No Limits";
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
const membership = membershipsMap.get(userId);
|
||||
const rpmLimit = membership?.litellm_budget_table?.rpm_limit;
|
||||
const tpmLimit = membership?.litellm_budget_table?.tpm_limit;
|
||||
|
||||
const rpmText = rpmLimit ? `${formatNumber(rpmLimit)} RPM` : null;
|
||||
const tpmText = tpmLimit ? `${formatNumber(tpmLimit)} TPM` : null;
|
||||
|
||||
const limits = [rpmText, tpmText].filter(Boolean);
|
||||
return limits.length > 0 ? limits.join(" / ") : "No Limits";
|
||||
};
|
||||
|
||||
const getUserAllowedModels = (userId: string | null): string[] | null => {
|
||||
if (!userId) return null;
|
||||
const models = membershipsMap.get(userId)?.litellm_budget_table?.allowed_models;
|
||||
return models && models.length > 0 ? models : null;
|
||||
};
|
||||
|
||||
const getUserBudgetReset = (userId: string | null): string | null => {
|
||||
if (!userId) return null;
|
||||
return formatBudgetReset(membershipsMap.get(userId)?.litellm_budget_table?.budget_reset_at);
|
||||
};
|
||||
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
const { userId, userRole } = useAuthorized();
|
||||
const disableTeamAdminDeleteTeamUser = Boolean(uiSettingsData?.values?.disable_team_admin_delete_team_user);
|
||||
const isUserTeamAdmin = isUserTeamAdminForSingleTeam(teamData.team_info.members_with_roles, userId || "");
|
||||
const isProxyAdmin = isProxyAdminRole(userRole || "");
|
||||
|
||||
const getUserAllowedModels = (userId: string | null): string[] | null => {
|
||||
if (!userId) return null;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
const models = membership?.litellm_budget_table?.allowed_models;
|
||||
return models && models.length > 0 ? models : null;
|
||||
};
|
||||
|
||||
const getUserBudgetReset = (userId: string | null): string | null => {
|
||||
if (!userId) return null;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
return formatBudgetReset(membership?.litellm_budget_table?.budget_reset_at);
|
||||
};
|
||||
|
||||
const extraColumns: ColumnsType<Member> = [
|
||||
{
|
||||
title: (
|
||||
|
|
@ -202,31 +211,52 @@ export default function TeamMemberTab({
|
|||
];
|
||||
|
||||
return (
|
||||
<MemberTable
|
||||
members={teamData.team_info.members_with_roles}
|
||||
canEdit={canEditTeam}
|
||||
onEdit={(record) => {
|
||||
const membership = teamData.team_memberships.find(
|
||||
(tm) => tm.user_id === record.user_id
|
||||
);
|
||||
const enhancedMember = {
|
||||
...record,
|
||||
max_budget_in_team: membership?.litellm_budget_table?.max_budget || null,
|
||||
tpm_limit: membership?.litellm_budget_table?.tpm_limit || null,
|
||||
rpm_limit: membership?.litellm_budget_table?.rpm_limit || null,
|
||||
allowed_models: membership?.litellm_budget_table?.allowed_models || [],
|
||||
};
|
||||
setSelectedEditMember(enhancedMember);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
onDelete={handleMemberDelete}
|
||||
onAddMember={() => setIsAddMemberModalVisible(true)}
|
||||
roleColumnTitle="Team Role"
|
||||
roleTooltip="This role applies only to this team and is independent from the user's proxy-level role."
|
||||
extraColumns={extraColumns}
|
||||
showDeleteForMember={() =>
|
||||
isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)
|
||||
}
|
||||
/>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="Search by email or user ID"
|
||||
allowClear
|
||||
style={{ width: 280 }}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onSearch={(v) => setSearchText(v)}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Filter by role"
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
options={[
|
||||
{ value: "admin", label: "Admin" },
|
||||
{ value: "user", label: "User" },
|
||||
]}
|
||||
onChange={(v) => setRoleFilter(v ?? null)}
|
||||
/>
|
||||
</Space>
|
||||
<MemberTable
|
||||
key={`${searchText}::${roleFilter}`}
|
||||
members={filteredMembers}
|
||||
canEdit={canEditTeam}
|
||||
withPagination
|
||||
onEdit={(record) => {
|
||||
const membership = membershipsMap.get(record.user_id ?? "");
|
||||
const enhancedMember = {
|
||||
...record,
|
||||
max_budget_in_team: membership?.litellm_budget_table?.max_budget || null,
|
||||
tpm_limit: membership?.litellm_budget_table?.tpm_limit || null,
|
||||
rpm_limit: membership?.litellm_budget_table?.rpm_limit || null,
|
||||
allowed_models: membership?.litellm_budget_table?.allowed_models || [],
|
||||
};
|
||||
setSelectedEditMember(enhancedMember);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
onDelete={handleMemberDelete}
|
||||
onAddMember={() => setIsAddMemberModalVisible(true)}
|
||||
roleColumnTitle="Team Role"
|
||||
roleTooltip="This role applies only to this team and is independent from the user's proxy-level role."
|
||||
extraColumns={extraColumns}
|
||||
showDeleteForMember={() =>
|
||||
isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue