perf(ui): page the member tables and look up budget rows once per render (#26667)

* perf(ui): look up a team member's budget row once per render

TeamMemberTab resolved each row's membership with Array.find() over
team_memberships inside every extra column's render and sortValue, so a
render walked the full membership list once per cell. Build the user_id ->
membership map once per team_memberships change and read from it instead.

* perf(ui): page the member tables instead of rendering every row

MemberTable rendered one row per member, so a team or an org with a couple of
thousand members paid for every row on each render. Its DataTable now runs
client-side pagination, like the other list tables in the dashboard.

* fix(ui): keep the member table on its page when the roster is refetched

* test(ui): cover a team member's spend and budget refreshing after a refetch

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
Rick 2026-10-16 16:01:06 +08:00 • committed by GitHub
parent 1b1ddef1b2
commit ecbea93381
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 221 additions and 21 deletions

View file

@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { describe, expect, it, vi } from "vitest";
@ -160,6 +160,66 @@ describe("MemberTable sorting", () => {
});
});
describe("MemberTable paging", () => {
const roster = (): Member[] =>
Array.from({ length: 30 }, (_, index) => ({
user_id: `u-${index}`,
user_email: `member${index}@example.com`,
user_alias: `Member ${String(index).padStart(2, "0")}`,
role: index === 0 ? "admin" : "user",
}));
const renderRoster = () => {
const props = { members: roster(), canEdit: true, onEdit: vi.fn(), onDelete: vi.fn() };
const view = renderWithProviders(<MemberTable {...props} />);
return { props, view };
};
const goToSecondPage = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByTestId("pagination-next"));
expect(screen.getByText("Showing 26-30 of 30")).toBeInTheDocument();
};
it("stays on the current page when the roster is refetched", async () => {
const user = userEvent.setup();
const { props, view } = renderRoster();
await goToSecondPage(user);
view.rerender(<MemberTable {...props} members={roster()} />);
await act(() => new Promise((resolve) => setTimeout(resolve, 20)));
expect(screen.getByText("Showing 26-30 of 30")).toBeInTheDocument();
});
it("returns to the first page when the sort order changes", async () => {
const user = userEvent.setup();
renderRoster();
await goToSecondPage(user);
await user.click(screen.getByTestId("sort-header-user_email"));
expect(await screen.findByText("Showing 1-25 of 30")).toBeInTheDocument();
});
it("returns to the first page when the search or the role filter changes", async () => {
const user = userEvent.setup();
renderRoster();
await goToSecondPage(user);
search("example.com");
expect(await screen.findByText("Showing 1-25 of 30")).toBeInTheDocument();
search("");
await goToSecondPage(user);
await user.click(screen.getByTestId("datatable-filters-trigger"));
await user.click(screen.getByTestId("filter-role"));
await user.click(await screen.findByRole("option", { name: "user" }));
await user.click(screen.getByTestId("filter-drawer-apply"));
expect(await screen.findByText("Showing 1-25 of 29")).toBeInTheDocument();
});
});
describe("MemberTable role filter", () => {
it("shows only members with the chosen role and clears on reset", async () => {
const user = userEvent.setup();

View file

@ -1,4 +1,4 @@
import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table";
import type { ColumnDef, ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { Crown, Info, User, UserPlus } from "lucide-react";
import React, { useState } from "react";
@ -9,6 +9,7 @@ import {
DataTableFilterField,
DataTableSortHeader,
DataTableToolbar,
DEFAULT_PAGE_SIZE_OPTIONS,
} from "@/components/shared/DataTable";
import { StatusBadge } from "@/components/shared/table_cells";
import { Button } from "@/components/ui/button";
@ -44,6 +45,8 @@ export interface MemberTableProps {
const ALL_ROLES = "all";
const DEFAULT_SORTING: SortingState = [{ id: "user_alias", desc: false }];
export const memberRowId = (member: Member): string => member.user_id ?? member.user_email ?? JSON.stringify(member);
export const memberRoleOptions = (members: readonly Member[]): string[] =>
@ -230,8 +233,21 @@ export default function MemberTable({
}: MemberTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: DEFAULT_PAGE_SIZE_OPTIONS[0],
});
const [filtersOpen, setFiltersOpen] = useState(false);
const backToFirstPage =
<T,>(onChange: OnChangeFn<T>): OnChangeFn<T> =>
(updaterOrValue) => {
onChange(updaterOrValue);
setPagination((current) => ({ ...current, pageIndex: 0 }));
};
const changeSearch = backToFirstPage(setGlobalFilter);
const columnDeps: MemberColumnDeps = {
canEdit,
onEdit,
@ -260,13 +276,17 @@ export default function MemberTable({
data={members}
columns={columns}
getRowId={memberRowId}
paginationMode="client"
pagination={pagination}
onPaginationChange={setPagination}
sortingMode="client"
defaultSorting={[{ id: "user_alias", desc: false }]}
sorting={sorting}
onSortingChange={backToFirstPage(setSorting)}
filterMode="client"
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
onColumnFiltersChange={backToFirstPage(setColumnFilters)}
globalFilter={globalFilter}
onGlobalFilterChange={setGlobalFilter}
onGlobalFilterChange={changeSearch}
noDataMessage={
<span className="text-muted-foreground">
{isNarrowed ? "No members match your search or filters" : emptyText ?? "No data"}
@ -277,7 +297,7 @@ export default function MemberTable({
<DataTableToolbar
table={table}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
onSearchChange={changeSearch}
searchPlaceholder="Search by name, email, or user ID"
onOpenFilters={() => setFiltersOpen(true)}
showViewOptions={false}

View file

@ -149,6 +149,39 @@ test("should display empty state when organization has no members", async () =>
});
});
test("keeps the members pager on its page when an admin opens a member's edit dialog", async () => {
const members = Array.from({ length: 30 }, (_, index) => ({
user_id: `user${index + 1}`,
user_email: `user${index + 1}@example.com`,
user_role: "internal_user",
spend: 0,
user: { user_alias: `alias${String(index + 1).padStart(2, "0")}` },
}));
mockUseOrganization.mockReturnValue({ data: { ...mockOrg, members }, isLoading: false } as unknown as ReturnType<
typeof useOrganization
>);
const user = userEvent.setup();
renderWithProviders(
<OrganizationInfoView
organizationId="org_123"
onClose={() => {}}
accessToken="test-token"
is_org_admin={true}
is_proxy_admin={false}
userModels={[]}
/>,
);
await user.click(screen.getByRole("tab", { name: "Members" }));
await user.click(screen.getByTestId("pagination-next"));
expect(screen.getByText("Showing 26-30 of 30")).toBeInTheDocument();
await user.click(within(screen.getByRole("row", { name: /alias27/ })).getByTestId("edit-member"));
expect(screen.getByText("Showing 26-30 of 30")).toBeInTheDocument();
});
test("should display team aliases when teams are available", async () => {
const orgWithTeams = {
...mockOrg,

View file

@ -316,6 +316,91 @@ describe("TeamMembersComponent", () => {
expect(screen.getByText("Jul 15, 2026")).toBeInTheDocument();
});
it("shows each member the values from its own membership row", () => {
const baseMembership = createMockTeamData().team_memberships[0];
const teamData = createMockTeamData({
team_memberships: [
{
...baseMembership,
spend: 1.25,
total_spend: 10,
litellm_budget_table: { ...baseMembership.litellm_budget_table, max_budget: 250 },
},
{
...baseMembership,
user_id: "user2@test.com",
spend: 7.5,
total_spend: 20,
litellm_budget_table: {
...baseMembership.litellm_budget_table,
max_budget: 2500,
rpm_limit: 7,
tpm_limit: 70,
},
},
],
});
renderWithProviders(
<TeamMembersComponent
teamData={teamData}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
/>,
);
const user1Row = screen.getByRole("row", { name: /user1@test\.com/ });
expect(within(user1Row).getByText("$1.25")).toBeInTheDocument();
expect(within(user1Row).getByText("$10.00")).toBeInTheDocument();
expect(within(user1Row).getByText("$250.00")).toBeInTheDocument();
expect(within(user1Row).getByText("100 RPM / 10000 TPM")).toBeInTheDocument();
const user2Row = screen.getByRole("row", { name: /user2@test\.com/ });
expect(within(user2Row).getByText("$7.50")).toBeInTheDocument();
expect(within(user2Row).getByText("$20.00")).toBeInTheDocument();
expect(within(user2Row).getByText("$2,500.00")).toBeInTheDocument();
expect(within(user2Row).getByText("7 RPM / 70 TPM")).toBeInTheDocument();
});
it("shows a member's new spend and budget once the team is refetched", () => {
const baseMembership = createMockTeamData().team_memberships[0];
const teamWith = (spend: number, maxBudget: number) =>
createMockTeamData({
team_memberships: [
{
...baseMembership,
spend,
litellm_budget_table: { ...baseMembership.litellm_budget_table, max_budget: maxBudget },
},
],
});
const renderTab = (teamData: TeamData) => (
<TeamMembersComponent
teamData={teamData}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
/>
);
const { rerender } = renderWithProviders(renderTab(teamWith(1.25, 250)));
rerender(renderTab(teamWith(3.5, 400)));
const row = screen.getByRole("row", { name: /user1@test\.com/ });
expect(within(row).getByText("$3.50")).toBeInTheDocument();
expect(within(row).getByText("$400.00")).toBeInTheDocument();
expect(within(row).queryByText("$1.25")).not.toBeInTheDocument();
});
it("should display formatted budget and Unlimited for member with no budget", () => {
renderWithProviders(
<TeamMembersComponent

View file

@ -15,7 +15,7 @@ import { toast } from "@/lib/toast";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
import { CircleHelp } from "lucide-react";
import { useState, type ComponentProps } from "react";
import { useMemo, useState, type ComponentProps } from "react";
import { TeamData, TeamMemberBudgetSource, TeamMembership } from "./TeamInfo";
const BUDGET_SOURCE_LABELS: Record<Exclude<TeamMemberBudgetSource, "none">, string> = {
@ -74,6 +74,14 @@ export default function TeamMemberTab({
const { mutate: resetMemberBudget, isPending: isResettingBudget } = useResetTeamMemberBudget();
const teamDefaultBudget = teamData.team_info.team_member_budget_table?.max_budget ?? null;
const membershipsByUserId = useMemo(
() => new Map(teamData.team_memberships.map((membership) => [membership.user_id, membership])),
[teamData.team_memberships],
);
const findMembership = (userId: string | null): TeamMembership | undefined =>
userId ? membershipsByUserId.get(userId) : undefined;
const formatNumber = (value: number | null): string => {
if (value === null || value === undefined) return "0";
@ -95,32 +103,28 @@ export default function TeamMemberTab({
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 findMembership(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 findMembership(userId)?.total_spend ?? 0;
};
const getUserBudgetSource = (userId: string | null): TeamMemberBudgetSource => {
if (!userId) return "none";
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
return membership?.budget_source ?? "none";
return findMembership(userId)?.budget_source ?? "none";
};
const getUserBudget = (userId: string | null): number | null => {
if (!userId) return null;
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
return membership?.litellm_budget_table?.max_budget ?? teamDefaultBudget;
return findMembership(userId)?.litellm_budget_table?.max_budget ?? teamDefaultBudget;
};
// 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 = findMembership(userId);
const rpmLimit = membership?.litellm_budget_table?.rpm_limit;
const tpmLimit = membership?.litellm_budget_table?.tpm_limit;
@ -139,15 +143,13 @@ export default function TeamMemberTab({
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;
const models = findMembership(userId)?.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 membership?.litellm_budget_table?.budget_reset_at ?? null;
return findMembership(userId)?.litellm_budget_table?.budget_reset_at ?? null;
};
const extraColumns: NonNullable<ComponentProps<typeof MemberTable>["extraColumns"]> = [
@ -312,7 +314,7 @@ export default function TeamMemberTab({
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 membership = findMembership(record.user_id);
setSelectedEditMember(
seedMemberBudgetFields(
record,