From 08d3b9f58b0af13386262d425e2f1d64b990b161 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 5 Dec 2025 20:59:36 -0800 Subject: [PATCH] Reusable Table Icon Button --- .../src/components/OldTeams.tsx | 42 +++++------ .../BaseActionButton.test.tsx | 24 +++++++ .../IconActionButton/BaseActionButton.tsx | 25 +++++++ .../TableIconActionButton.test.tsx | 69 +++++++++++++++++++ .../TableIconActionButton.tsx | 48 +++++++++++++ 5 files changed, 184 insertions(+), 24 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/IconActionButton/BaseActionButton.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/IconActionButton/BaseActionButton.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index b1cc7fcc6aa..e66fe92045d 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -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 = ({ {userRole == "Admin" ? ( <> - - {" "} - { - setSelectedTeamId(team.team_id); - setEditTeam(true); - }} - /> - - - {" "} - handleDelete(team)} - icon={TrashIcon} - size="sm" - className="cursor-pointer hover:text-red-600" - data-testid="delete-team-button" - /> - + { + setSelectedTeamId(team.team_id); + setEditTeam(true); + }} + dataTestId="edit-team-button" + tooltipText="Edit team" + /> + handleDelete(team)} + dataTestId="delete-team-button" + tooltipText="Delete team" + /> ) : null} diff --git a/ui/litellm-dashboard/src/components/common_components/IconActionButton/BaseActionButton.test.tsx b/ui/litellm-dashboard/src/components/common_components/IconActionButton/BaseActionButton.test.tsx new file mode 100644 index 00000000000..bf9f207341c --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/IconActionButton/BaseActionButton.test.tsx @@ -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(); + expect(screen.getByTestId("test-button")).toBeInTheDocument(); + }); + + it("should call onClick when clicked", () => { + const onClick = vi.fn(); + render(); + const button = screen.getByTestId("test-button"); + + act(() => { + fireEvent.click(button); + }); + + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/IconActionButton/BaseActionButton.tsx b/ui/litellm-dashboard/src/components/common_components/IconActionButton/BaseActionButton.tsx new file mode 100644 index 00000000000..1d6aa0ec73e --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/IconActionButton/BaseActionButton.tsx @@ -0,0 +1,25 @@ +import { cx } from "@/lib/cva.config"; +import { Icon } from "@tremor/react"; +import React from "react"; + +interface BaseActionButtonProps { + icon: React.ComponentType>; + onClick: () => void; + className?: string; + disabled?: boolean; + dataTestId?: string; +} + +export default function BaseActionButton({ icon, onClick, className, disabled, dataTestId }: BaseActionButtonProps) { + return disabled ? ( + + ) : ( + + ); +} 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 new file mode 100644 index 00000000000..718c458b455 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.test.tsx @@ -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( {}} dataTestId="test-button" />); + expect(screen.getByTestId("test-button")).toBeInTheDocument(); + + expect(screen.getByTestId("test-button")).toHaveClass(TableIconActionButtonMap[variant].className!); + }); + }); + + it("should have a tooltip", () => { + render( {}} 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( + {}} 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( + {}} 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( + {}} + 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(); + }); + }); +}); 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 new file mode 100644 index 00000000000..7259f763ca5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx @@ -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>; + className?: string; +} + +export const TableIconActionButtonMap: Record = { + 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 ( + + + + + + ); +}