fix(ui): explain why team member reset spend is unavailable instead of hiding it (#44629)

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-05 14:31:04 -07:00 • committed by GitHub
parent cad87a900f
commit f0415ee033
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 75 additions and 27 deletions

View file

@ -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(
<TableIconActionButton
variant="Edit"
onClick={() => {}}
dataTestId="test-button"
disabled
tooltipText="Edit"
disabledTooltipText="Cannot edit"
/>,
);
await user.tab();
expect(await screen.findByText("Cannot edit")).toBeInTheDocument();
});
});

View file

@ -58,7 +58,7 @@ export default function TableIconActionButton({
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span />}>{button}</TooltipTrigger>
<TooltipTrigger render={<span tabIndex={disabled ? 0 : undefined} />}>{button}</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
</TooltipProvider>

View file

@ -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 (
<TableIconActionButton
variant="Reset"
tooltipText="Reset spend"
disabled={disabledReason !== null}
disabledTooltipText={disabledReason ?? undefined}
dataTestId="reset-member-spend"
onClick={onClick}
/>
);
}
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<Member> => {
@ -113,7 +126,7 @@ const buildColumns = ({
extraColumns,
showDeleteForMember,
onResetSpend,
showResetSpendForMember,
resetSpendDisabledReason,
}: MemberColumnDeps): ColumnDef<Member>[] => [
{
id: "user_alias",
@ -182,11 +195,9 @@ const buildColumns = ({
dataTestId="edit-member"
onClick={() => onEdit(row.original)}
/>
{onResetSpend && (showResetSpendForMember?.(row.original) ?? true) && (
<TableIconActionButton
variant="Reset"
tooltipText="Reset spend"
dataTestId="reset-member-spend"
{onResetSpend && (
<ResetSpendAction
disabledReason={resetSpendDisabledReason?.(row.original) ?? null}
onClick={() => 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 = [

View file

@ -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",
);
});
});

View file

@ -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 (
<>
<MemberTable
@ -311,9 +317,7 @@ export default function TeamMemberTab({
isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)
}
onResetSpend={setMemberToResetSpend}
showResetSpendForMember={(record) =>
getUserCurrentCycleSpend(record.user_id) > 0 && (isProxyAdmin || record.user_id !== userId)
}
resetSpendDisabledReason={resetSpendDisabledReason}
/>
<Dialog open={memberToResetSpend !== null} onOpenChange={(open) => !open && setMemberToResetSpend(null)}>
<DialogContent>