diff --git a/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx b/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx index 418f27c8188..3264c3a99ea 100644 --- a/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx @@ -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(); + return { props, view }; + }; + + const goToSecondPage = async (user: ReturnType) => { + 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(); + 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(); diff --git a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx index 24dd86c9e2c..0acba9ad21d 100644 --- a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx +++ b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx @@ -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([]); + const [sorting, setSorting] = useState(DEFAULT_SORTING); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE_OPTIONS[0], + }); const [filtersOpen, setFiltersOpen] = useState(false); + const backToFirstPage = + (onChange: OnChangeFn): OnChangeFn => + (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={ {isNarrowed ? "No members match your search or filters" : emptyText ?? "No data"} @@ -277,7 +297,7 @@ export default function MemberTable({ setFiltersOpen(true)} showViewOptions={false} diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx index ca792084f2a..88d98fac60e 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx @@ -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( + {}} + 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, diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx index b8f292222ed..cd51bcedb7b 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx @@ -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( + , + ); + + 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) => ( + + ); + + 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( , 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["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,