Reusable Table Icon Button

This commit is contained in:
yuneng-jiang 2025-12-05 20:59:36 -08:00
parent 4d39a1a18f
commit 08d3b9f58b
5 changed files with 184 additions and 24 deletions

View file

@ -3,7 +3,7 @@ import TeamInfoView from "@/components/team/team_info";
import TeamSSOSettings from "@/components/TeamSSOSettings";
import { isProxyAdminRole } from "@/utils/roles";
import { InfoCircleOutlined } from "@ant-design/icons";
import { ChevronDownIcon, ChevronRightIcon, PencilAltIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline";
import { ChevronDownIcon, ChevronRightIcon, RefreshIcon } from "@heroicons/react/outline";
import {
Accordion,
AccordionBody,
@ -33,6 +33,7 @@ import {
import { Button as Button2, Form, Input, Modal, Select as Select2, Switch, Tooltip, Typography } from "antd";
import React, { useEffect, useState } from "react";
import { formatNumberWithCommas } from "../utils/dataUtils";
import AgentSelector from "./agent_management/AgentSelector";
import { fetchTeams } from "./common_components/fetch_teams";
import ModelAliasManager from "./common_components/ModelAliasManager";
import PremiumLoggingSettings from "./common_components/PremiumLoggingSettings";
@ -44,7 +45,6 @@ import {
import type { KeyResponse, Team } from "./key_team_helpers/key_list";
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
import AgentSelector from "./agent_management/AgentSelector";
import NotificationsManager from "./molecules/notifications_manager";
import { Organization, fetchMCPAccessGroups, getGuardrailsList, teamDeleteCall } from "./networking";
import NumericalInput from "./shared/numerical_input";
@ -78,6 +78,7 @@ interface EditTeamModalProps {
import { updateExistingKeys } from "@/utils/dataUtils";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import { Member, teamCreateCall, v2TeamListCall } from "./networking";
interface TeamInfo {
@ -947,28 +948,21 @@ const Teams: React.FC<TeamProps> = ({
<TableCell>
{userRole == "Admin" ? (
<>
<Tooltip title="Edit team">
{" "}
<Icon
icon={PencilAltIcon}
size="sm"
className="cursor-pointer hover:text-blue-600"
onClick={() => {
setSelectedTeamId(team.team_id);
setEditTeam(true);
}}
/>
</Tooltip>
<Tooltip title="Delete team">
{" "}
<Icon
onClick={() => handleDelete(team)}
icon={TrashIcon}
size="sm"
className="cursor-pointer hover:text-red-600"
data-testid="delete-team-button"
/>
</Tooltip>
<TableIconActionButton
variant="Edit"
onClick={() => {
setSelectedTeamId(team.team_id);
setEditTeam(true);
}}
dataTestId="edit-team-button"
tooltipText="Edit team"
/>
<TableIconActionButton
variant="Delete"
onClick={() => handleDelete(team)}
dataTestId="delete-team-button"
tooltipText="Delete team"
/>
</>
) : null}
</TableCell>

View file

@ -0,0 +1,24 @@
import { PencilAltIcon } from "@heroicons/react/outline";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import BaseActionButton from "./BaseActionButton";
describe("BaseActionButton", () => {
it("should render", () => {
const onClick = vi.fn();
render(<BaseActionButton icon={PencilAltIcon} onClick={onClick} dataTestId="test-button" />);
expect(screen.getByTestId("test-button")).toBeInTheDocument();
});
it("should call onClick when clicked", () => {
const onClick = vi.fn();
render(<BaseActionButton icon={PencilAltIcon} onClick={onClick} dataTestId="test-button" />);
const button = screen.getByTestId("test-button");
act(() => {
fireEvent.click(button);
});
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,25 @@
import { cx } from "@/lib/cva.config";
import { Icon } from "@tremor/react";
import React from "react";
interface BaseActionButtonProps {
icon: React.ComponentType<React.ComponentProps<"svg">>;
onClick: () => void;
className?: string;
disabled?: boolean;
dataTestId?: string;
}
export default function BaseActionButton({ icon, onClick, className, disabled, dataTestId }: BaseActionButtonProps) {
return disabled ? (
<Icon icon={icon} size="sm" className={"opacity-50 cursor-not-allowed"} data-testid={dataTestId} />
) : (
<Icon
icon={icon}
size="sm"
onClick={onClick}
className={cx("cursor-pointer", className)}
data-testid={dataTestId}
/>
);
}

View file

@ -0,0 +1,69 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import TableIconActionButton, { TableIconActionButtonMap } from "./TableIconActionButton";
describe("TableIconActionButton", () => {
Object.keys(TableIconActionButtonMap).forEach((variant) => {
it(`should render ${variant} button`, () => {
render(<TableIconActionButton variant={variant} onClick={() => {}} dataTestId="test-button" />);
expect(screen.getByTestId("test-button")).toBeInTheDocument();
expect(screen.getByTestId("test-button")).toHaveClass(TableIconActionButtonMap[variant].className!);
});
});
it("should have a tooltip", () => {
render(<TableIconActionButton variant="Edit" onClick={() => {}} dataTestId="test-button" tooltipText="Edit" />);
const button = screen.getByTestId("test-button");
const tooltipWrapper = button.closest("span");
expect(tooltipWrapper).toBeInTheDocument();
});
it("should show tooltip when tooltipText is provided", async () => {
render(
<TableIconActionButton variant="Edit" onClick={() => {}} dataTestId="test-button" tooltipText="Edit item" />,
);
const button = screen.getByTestId("test-button");
const buttonWrapper = button.closest("span");
act(() => {
fireEvent.mouseEnter(buttonWrapper!);
});
await waitFor(() => {
expect(screen.getByText("Edit item")).toBeInTheDocument();
});
});
it("should render disabled state with disabled styling", () => {
render(
<TableIconActionButton variant="Edit" onClick={() => {}} dataTestId="test-button" disabled tooltipText="Edit" />,
);
const button = screen.getByTestId("test-button");
expect(button).toHaveClass("opacity-50");
expect(button).toHaveClass("cursor-not-allowed");
});
it("should show disabledTooltipText when disabled and disabledTooltipText is provided", async () => {
render(
<TableIconActionButton
variant="Edit"
onClick={() => {}}
dataTestId="test-button"
disabled
tooltipText="Edit"
disabledTooltipText="Cannot edit"
/>,
);
const button = screen.getByTestId("test-button");
const buttonWrapper = button.closest("span");
act(() => {
fireEvent.mouseEnter(buttonWrapper!);
});
await waitFor(() => {
expect(screen.getByText("Cannot edit")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,48 @@
import { PencilAltIcon, PlayIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline";
import { Tooltip } from "antd";
import BaseActionButton from "../BaseActionButton";
export interface TableIconActionButtonProps {
onClick: () => void;
tooltipText?: string;
disabled?: boolean;
disabledTooltipText?: string;
dataTestId?: string;
variant: keyof typeof TableIconActionButtonMap;
}
export interface TableIconActionButtonBaseProps {
icon: React.ComponentType<React.ComponentProps<"svg">>;
className?: string;
}
export const TableIconActionButtonMap: Record<string, TableIconActionButtonBaseProps> = {
Edit: { icon: PencilAltIcon, className: "hover:text-blue-600" },
Delete: { icon: TrashIcon, className: "hover:text-red-600" },
Test: { icon: PlayIcon, className: "hover:text-blue-600" },
Regenerate: { icon: RefreshIcon, className: "hover:text-green-600" },
};
export default function TableIconActionButton({
onClick,
tooltipText,
disabled = false,
disabledTooltipText,
dataTestId,
variant,
}: TableIconActionButtonProps) {
const { icon, className } = TableIconActionButtonMap[variant];
return (
<Tooltip title={disabled ? disabledTooltipText : tooltipText}>
<span>
<BaseActionButton
icon={icon}
onClick={onClick}
className={className}
disabled={disabled}
dataTestId={dataTestId}
/>
</span>
</Tooltip>
);
}