Skip to content

Commit 38ae12e

Browse files
authored
Merge pull request #17573 from BerriAI/litellm_ui_reusable_table_action_button
[Refactor] Reusable Table Icon Button
2 parents 816854e + 906ca6a commit 38ae12e

File tree

5 files changed

+184
-24
lines changed

5 files changed

+184
-24
lines changed

ui/litellm-dashboard/src/components/OldTeams.tsx

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import TeamInfoView from "@/components/team/team_info";
33
import TeamSSOSettings from "@/components/TeamSSOSettings";
44
import { isProxyAdminRole } from "@/utils/roles";
55
import { InfoCircleOutlined } from "@ant-design/icons";
6-
import { ChevronDownIcon, ChevronRightIcon, PencilAltIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline";
6+
import { ChevronDownIcon, ChevronRightIcon, RefreshIcon } from "@heroicons/react/outline";
77
import {
88
Accordion,
99
AccordionBody,
@@ -33,6 +33,7 @@ import {
3333
import { Button as Button2, Form, Input, Modal, Select as Select2, Switch, Tooltip, Typography } from "antd";
3434
import React, { useEffect, useState } from "react";
3535
import { formatNumberWithCommas } from "../utils/dataUtils";
36+
import AgentSelector from "./agent_management/AgentSelector";
3637
import { fetchTeams } from "./common_components/fetch_teams";
3738
import ModelAliasManager from "./common_components/ModelAliasManager";
3839
import PremiumLoggingSettings from "./common_components/PremiumLoggingSettings";
@@ -44,7 +45,6 @@ import {
4445
import type { KeyResponse, Team } from "./key_team_helpers/key_list";
4546
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
4647
import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
47-
import AgentSelector from "./agent_management/AgentSelector";
4848
import NotificationsManager from "./molecules/notifications_manager";
4949
import { Organization, fetchMCPAccessGroups, getGuardrailsList, teamDeleteCall } from "./networking";
5050
import NumericalInput from "./shared/numerical_input";
@@ -78,6 +78,7 @@ interface EditTeamModalProps {
7878

7979
import { updateExistingKeys } from "@/utils/dataUtils";
8080
import DeleteResourceModal from "./common_components/DeleteResourceModal";
81+
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
8182
import { Member, teamCreateCall, v2TeamListCall } from "./networking";
8283

8384
interface TeamInfo {
@@ -947,28 +948,21 @@ const Teams: React.FC<TeamProps> = ({
947948
<TableCell>
948949
{userRole == "Admin" ? (
949950
<>
950-
<Tooltip title="Edit team">
951-
{" "}
952-
<Icon
953-
icon={PencilAltIcon}
954-
size="sm"
955-
className="cursor-pointer hover:text-blue-600"
956-
onClick={() => {
957-
setSelectedTeamId(team.team_id);
958-
setEditTeam(true);
959-
}}
960-
/>
961-
</Tooltip>
962-
<Tooltip title="Delete team">
963-
{" "}
964-
<Icon
965-
onClick={() => handleDelete(team)}
966-
icon={TrashIcon}
967-
size="sm"
968-
className="cursor-pointer hover:text-red-600"
969-
data-testid="delete-team-button"
970-
/>
971-
</Tooltip>
951+
<TableIconActionButton
952+
variant="Edit"
953+
onClick={() => {
954+
setSelectedTeamId(team.team_id);
955+
setEditTeam(true);
956+
}}
957+
dataTestId="edit-team-button"
958+
tooltipText="Edit team"
959+
/>
960+
<TableIconActionButton
961+
variant="Delete"
962+
onClick={() => handleDelete(team)}
963+
dataTestId="delete-team-button"
964+
tooltipText="Delete team"
965+
/>
972966
</>
973967
) : null}
974968
</TableCell>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { PencilAltIcon } from "@heroicons/react/outline";
2+
import { act, fireEvent, render, screen } from "@testing-library/react";
3+
import { describe, expect, it, vi } from "vitest";
4+
import BaseActionButton from "./BaseActionButton";
5+
6+
describe("BaseActionButton", () => {
7+
it("should render", () => {
8+
const onClick = vi.fn();
9+
render(<BaseActionButton icon={PencilAltIcon} onClick={onClick} dataTestId="test-button" />);
10+
expect(screen.getByTestId("test-button")).toBeInTheDocument();
11+
});
12+
13+
it("should call onClick when clicked", () => {
14+
const onClick = vi.fn();
15+
render(<BaseActionButton icon={PencilAltIcon} onClick={onClick} dataTestId="test-button" />);
16+
const button = screen.getByTestId("test-button");
17+
18+
act(() => {
19+
fireEvent.click(button);
20+
});
21+
22+
expect(onClick).toHaveBeenCalledTimes(1);
23+
});
24+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { cx } from "@/lib/cva.config";
2+
import { Icon } from "@tremor/react";
3+
import React from "react";
4+
5+
interface BaseActionButtonProps {
6+
icon: React.ComponentType<React.ComponentProps<"svg">>;
7+
onClick: () => void;
8+
className?: string;
9+
disabled?: boolean;
10+
dataTestId?: string;
11+
}
12+
13+
export default function BaseActionButton({ icon, onClick, className, disabled, dataTestId }: BaseActionButtonProps) {
14+
return disabled ? (
15+
<Icon icon={icon} size="sm" className={"opacity-50 cursor-not-allowed"} data-testid={dataTestId} />
16+
) : (
17+
<Icon
18+
icon={icon}
19+
size="sm"
20+
onClick={onClick}
21+
className={cx("cursor-pointer", className)}
22+
data-testid={dataTestId}
23+
/>
24+
);
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import TableIconActionButton, { TableIconActionButtonMap } from "./TableIconActionButton";
4+
5+
describe("TableIconActionButton", () => {
6+
Object.keys(TableIconActionButtonMap).forEach((variant) => {
7+
it(`should render ${variant} button`, () => {
8+
render(<TableIconActionButton variant={variant} onClick={() => {}} dataTestId="test-button" />);
9+
expect(screen.getByTestId("test-button")).toBeInTheDocument();
10+
11+
expect(screen.getByTestId("test-button")).toHaveClass(TableIconActionButtonMap[variant].className!);
12+
});
13+
});
14+
15+
it("should have a tooltip", () => {
16+
render(<TableIconActionButton variant="Edit" onClick={() => {}} dataTestId="test-button" tooltipText="Edit" />);
17+
const button = screen.getByTestId("test-button");
18+
const tooltipWrapper = button.closest("span");
19+
expect(tooltipWrapper).toBeInTheDocument();
20+
});
21+
22+
it("should show tooltip when tooltipText is provided", async () => {
23+
render(
24+
<TableIconActionButton variant="Edit" onClick={() => {}} dataTestId="test-button" tooltipText="Edit item" />,
25+
);
26+
const button = screen.getByTestId("test-button");
27+
const buttonWrapper = button.closest("span");
28+
29+
act(() => {
30+
fireEvent.mouseEnter(buttonWrapper!);
31+
});
32+
33+
await waitFor(() => {
34+
expect(screen.getByText("Edit item")).toBeInTheDocument();
35+
});
36+
});
37+
38+
it("should render disabled state with disabled styling", () => {
39+
render(
40+
<TableIconActionButton variant="Edit" onClick={() => {}} dataTestId="test-button" disabled tooltipText="Edit" />,
41+
);
42+
const button = screen.getByTestId("test-button");
43+
expect(button).toHaveClass("opacity-50");
44+
expect(button).toHaveClass("cursor-not-allowed");
45+
});
46+
47+
it("should show disabledTooltipText when disabled and disabledTooltipText is provided", async () => {
48+
render(
49+
<TableIconActionButton
50+
variant="Edit"
51+
onClick={() => {}}
52+
dataTestId="test-button"
53+
disabled
54+
tooltipText="Edit"
55+
disabledTooltipText="Cannot edit"
56+
/>,
57+
);
58+
const button = screen.getByTestId("test-button");
59+
const buttonWrapper = button.closest("span");
60+
61+
act(() => {
62+
fireEvent.mouseEnter(buttonWrapper!);
63+
});
64+
65+
await waitFor(() => {
66+
expect(screen.getByText("Cannot edit")).toBeInTheDocument();
67+
});
68+
});
69+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { PencilAltIcon, PlayIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline";
2+
import { Tooltip } from "antd";
3+
import BaseActionButton from "../BaseActionButton";
4+
5+
export interface TableIconActionButtonProps {
6+
onClick: () => void;
7+
tooltipText?: string;
8+
disabled?: boolean;
9+
disabledTooltipText?: string;
10+
dataTestId?: string;
11+
variant: keyof typeof TableIconActionButtonMap;
12+
}
13+
14+
export interface TableIconActionButtonBaseProps {
15+
icon: React.ComponentType<React.ComponentProps<"svg">>;
16+
className?: string;
17+
}
18+
19+
export const TableIconActionButtonMap: Record<string, TableIconActionButtonBaseProps> = {
20+
Edit: { icon: PencilAltIcon, className: "hover:text-blue-600" },
21+
Delete: { icon: TrashIcon, className: "hover:text-red-600" },
22+
Test: { icon: PlayIcon, className: "hover:text-blue-600" },
23+
Regenerate: { icon: RefreshIcon, className: "hover:text-green-600" },
24+
};
25+
26+
export default function TableIconActionButton({
27+
onClick,
28+
tooltipText,
29+
disabled = false,
30+
disabledTooltipText,
31+
dataTestId,
32+
variant,
33+
}: TableIconActionButtonProps) {
34+
const { icon, className } = TableIconActionButtonMap[variant];
35+
return (
36+
<Tooltip title={disabled ? disabledTooltipText : tooltipText}>
37+
<span>
38+
<BaseActionButton
39+
icon={icon}
40+
onClick={onClick}
41+
className={className}
42+
disabled={disabled}
43+
dataTestId={dataTestId}
44+
/>
45+
</span>
46+
</Tooltip>
47+
);
48+
}

0 commit comments

Comments
 (0)