From f0415ee033c81fe2b051e32991542f42a9641208 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:31:04 -0700 Subject: [PATCH] fix(ui): explain why team member reset spend is unavailable instead of hiding it (#44629) Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../TableIconActionButton.test.tsx | 17 ++++++++ .../TableIconActionButton.tsx | 2 +- .../common_components/MemberTable.tsx | 31 +++++++++----- .../components/team/TeamMemberTab.test.tsx | 42 +++++++++++++------ .../src/components/team/TeamMemberTab.tsx | 10 +++-- 5 files changed, 75 insertions(+), 27 deletions(-) diff --git a/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.test.tsx b/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.test.tsx index 9793a0e19ab..ff3907ffe51 100644 --- a/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.test.tsx @@ -84,6 +84,23 @@ describe("TableIconActionButton", () => { await user.hover(screen.getByTestId("test-button")); + expect(await screen.findByText("Cannot edit")).toBeInTheDocument(); + }); + it("should show disabledTooltipText on keyboard focus when disabled", async () => { + const user = userEvent.setup(); + render( + {}} + dataTestId="test-button" + disabled + tooltipText="Edit" + disabledTooltipText="Cannot edit" + />, + ); + + await user.tab(); + expect(await screen.findByText("Cannot edit")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx b/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx index 9da9c2dc702..167746bdd00 100644 --- a/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx +++ b/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx @@ -58,7 +58,7 @@ export default function TableIconActionButton({ return ( - }>{button} + }>{button} {title} diff --git a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx index 3cbe59bd839..24dd86c9e2c 100644 --- a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx +++ b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx @@ -38,7 +38,7 @@ export interface MemberTableProps { extraColumns?: MemberTableColumn[]; showDeleteForMember?: (member: Member) => boolean; onResetSpend?: (member: Member) => void; - showResetSpendForMember?: (member: Member) => boolean; + resetSpendDisabledReason?: (member: Member) => string | null; emptyText?: string; } @@ -66,6 +66,19 @@ function RoleHeaderTitle({ title, tooltip }: { title: string; tooltip?: string } ); } +function ResetSpendAction({ disabledReason, onClick }: { disabledReason: string | null; onClick: () => void }) { + return ( + + ); +} + const ACTIONS_COLUMN_WIDTH = 120; interface MemberColumnDeps { @@ -77,7 +90,7 @@ interface MemberColumnDeps { extraColumns: MemberTableColumn[]; showDeleteForMember?: (member: Member) => boolean; onResetSpend?: (member: Member) => void; - showResetSpendForMember?: (member: Member) => boolean; + resetSpendDisabledReason?: (member: Member) => string | null; } const extraColumnDef = (column: MemberTableColumn): ColumnDef => { @@ -113,7 +126,7 @@ const buildColumns = ({ extraColumns, showDeleteForMember, onResetSpend, - showResetSpendForMember, + resetSpendDisabledReason, }: MemberColumnDeps): ColumnDef[] => [ { id: "user_alias", @@ -182,11 +195,9 @@ const buildColumns = ({ dataTestId="edit-member" onClick={() => onEdit(row.original)} /> - {onResetSpend && (showResetSpendForMember?.(row.original) ?? true) && ( - onResetSpend(row.original)} /> )} @@ -214,7 +225,7 @@ export default function MemberTable({ extraColumns = [], showDeleteForMember, onResetSpend, - showResetSpendForMember, + resetSpendDisabledReason, emptyText, }: MemberTableProps) { const [globalFilter, setGlobalFilter] = useState(""); @@ -230,7 +241,7 @@ export default function MemberTable({ extraColumns, showDeleteForMember, onResetSpend, - showResetSpendForMember, + resetSpendDisabledReason, }; const columns = buildColumns(columnDeps); const roleFilterItems = [ diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx index 1d50a9a4670..8953a06796f 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx @@ -578,7 +578,7 @@ describe("TeamMembersComponent", () => { POST.mockRejectedValue(new Error("Cannot reset your own spend. Ask a proxy admin.")); renderEditableTab(); - await user.click(screen.getByTestId("reset-member-spend")); + await user.click(within(screen.getByRole("row", { name: /user1@test\.com/ })).getByTestId("reset-member-spend")); const dialog = await screen.findByRole("dialog", { name: "Reset Team Member Spend" }); await user.click(within(dialog).getByRole("button", { name: "Reset" })); @@ -591,7 +591,7 @@ describe("TeamMembersComponent", () => { const user = userEvent.setup(); renderEditableTab(); - await user.click(screen.getByTestId("reset-member-spend")); + await user.click(within(screen.getByRole("row", { name: /user1@test\.com/ })).getByTestId("reset-member-spend")); const dialog = await screen.findByRole("dialog", { name: "Reset Team Member Spend" }); await user.click(within(dialog).getByRole("button", { name: "Cancel" })); @@ -599,31 +599,47 @@ describe("TeamMembersComponent", () => { expect(POST).not.toHaveBeenCalled(); }); - it("only offers the reset on members that have current cycle spend", () => { + it("disables the reset with a reason on members that have no current cycle spend", async () => { + const user = userEvent.setup(); renderEditableTab(); - expect( - within(screen.getByRole("row", { name: /user1@test\.com/ })).getByTestId("reset-member-spend"), - ).toBeVisible(); - expect( - within(screen.getByRole("row", { name: /user2@test\.com/ })).queryByTestId("reset-member-spend"), - ).not.toBeInTheDocument(); + const resetButton = within(screen.getByRole("row", { name: /user2@test\.com/ })).getByTestId( + "reset-member-spend", + ); + await user.hover(resetButton); + expect(await screen.findByText("No current cycle spend to reset")).toBeInTheDocument(); + + await user.click(resetButton); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - it("hides the reset on the caller's own row for a team admin, since the backend rejects it", () => { + it("disables the reset with a reason on the caller's own row for a team admin, since the backend rejects it", async () => { + const user = userEvent.setup(); vi.mocked(useAuthorized).mockReturnValue({ userId: "user1@test.com", userRole: "Internal User" } as never); vi.mocked(isProxyAdminRole).mockReturnValue(false); renderEditableTab(); - expect(screen.queryByTestId("reset-member-spend")).not.toBeInTheDocument(); + const resetButton = within(screen.getByRole("row", { name: /user1@test\.com/ })).getByTestId( + "reset-member-spend", + ); + await user.hover(resetButton); + expect(await screen.findByText("Ask a proxy admin to reset your own spend")).toBeInTheDocument(); + + await user.click(resetButton); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - it("shows the reset on the caller's own row for a proxy admin", () => { + it("allows the reset on the caller's own row for a proxy admin", async () => { + const user = userEvent.setup(); vi.mocked(useAuthorized).mockReturnValue({ userId: "user1@test.com", userRole: "Admin" } as never); vi.mocked(isProxyAdminRole).mockReturnValue(true); renderEditableTab(); - expect(screen.getByTestId("reset-member-spend")).toBeVisible(); + await user.click(within(screen.getByRole("row", { name: /user1@test\.com/ })).getByTestId("reset-member-spend")); + + expect(await screen.findByRole("dialog", { name: "Reset Team Member Spend" })).toHaveTextContent( + "user1@test.com", + ); }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index a24d1b1e7cd..6778b40e93c 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -291,6 +291,12 @@ export default function TeamMemberTab({ ); }; + const resetSpendDisabledReason = (member: Member): string | null => { + if (!isProxyAdmin && member.user_id === userId) return "Ask a proxy admin to reset your own spend"; + if (getUserCurrentCycleSpend(member.user_id) <= 0) return "No current cycle spend to reset"; + return null; + }; + return ( <> - getUserCurrentCycleSpend(record.user_id) > 0 && (isProxyAdmin || record.user_id !== userId) - } + resetSpendDisabledReason={resetSpendDisabledReason} /> !open && setMemberToResetSpend(null)}>