diff --git a/src/__testing__/ActionButton.test.tsx b/src/__testing__/ActionButton.test.tsx new file mode 100644 index 000000000..09f0fd2b3 --- /dev/null +++ b/src/__testing__/ActionButton.test.tsx @@ -0,0 +1,235 @@ +import { type PopperProps } from '@mui/material'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { ActionButton, type Option } from '../custom/ActionButton'; + +const mockPopper = jest.fn(); + +jest.mock('../base/Popper', () => { + const actual = jest.requireActual('../base/Popper'); + const MockPopper = React.forwardRef((props, ref) => { + mockPopper(props); + return ; + }); + return { + __esModule: true, + ...actual, + Popper: MockPopper, + default: MockPopper + }; +}); + +describe('ActionButton Component', () => { + const mockOptions: Option[] = [ + { + label: 'Validate', + icon: V, + onClick: jest.fn() + }, + { + label: 'Dry Run', + icon: D, + onClick: jest.fn() + }, + { + label: 'Deploy', + icon: Dep, + onClick: jest.fn(), + disabled: true + }, + { + label: 'Hidden Option', + icon: H, + onClick: jest.fn(), + show: false + }, + { + label: 'Divider', + icon: null, + onClick: jest.fn(), + isDivider: true + }, + { + label: 'Undeploy', + icon: U, + onClick: jest.fn() + } + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with default label "Action" when no label is passed', () => { + render(); + expect(screen.getByRole('button', { name: /^Action$/i })).not.toBeNull(); + }); + + it('renders with custom label when provided', () => { + render(); + expect(screen.getByRole('button', { name: /^Actions$/i })).not.toBeNull(); + }); + + it('executes defaultActionClick when primary button is clicked and callback provided', () => { + const handleDefaultClick = jest.fn(); + render( + + ); + + const mainButton = screen.getByRole('button', { name: /^Actions$/i }); + fireEvent.click(mainButton); + expect(handleDefaultClick).toHaveBeenCalledTimes(1); + }); + + it('toggles dropdown menu when primary button is clicked and defaultActionClick is not provided', () => { + render(); + + expect(screen.queryByRole('menu')).toBeNull(); + const mainButton = screen.getByRole('button', { name: /^Actions$/i }); + fireEvent.click(mainButton); + + expect(screen.getByRole('menu')).not.toBeNull(); + expect(screen.getByText(mockOptions[0].label)).not.toBeNull(); + }); + + it('toggles dropdown menu when the dropdown arrow button is clicked', () => { + render(); + + expect(screen.queryByRole('menu')).toBeNull(); + + const buttons = screen.getAllByRole('button'); + const dropdownArrowButton = buttons[1]; + + // Open menu + fireEvent.click(dropdownArrowButton); + expect(screen.getByRole('menu')).not.toBeNull(); + + mockOptions.forEach((option) => { + if (option.show !== false && !option.isDivider) { + expect(screen.getByText(option.label)).not.toBeNull(); + } + }); + + // Toggle menu closed + fireEvent.click(dropdownArrowButton); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('calls option onClick handler and closes menu when an option is clicked', () => { + render(); + + const buttons = screen.getAllByRole('button'); + const dropdownArrowButton = buttons[1]; + fireEvent.click(dropdownArrowButton); + + const validateItem = screen.getByText(mockOptions[0].label); + fireEvent.click(validateItem); + + expect(mockOptions[0].onClick).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('does not invoke onClick for disabled options', () => { + render(); + + const buttons = screen.getAllByRole('button'); + fireEvent.click(buttons[1]); + + const deployItem = screen.getByText(mockOptions[2].label); + fireEvent.click(deployItem); + + expect(mockOptions[2].onClick).not.toHaveBeenCalled(); + }); + + it('does not render options with show set to false', () => { + render(); + + const buttons = screen.getAllByRole('button'); + fireEvent.click(buttons[1]); + + expect(screen.queryByText(mockOptions[3].label)).toBeNull(); + }); + + it('disables primary button when defaultActionDisabled is true', () => { + render(); + + const mainButton = screen.getByRole('button', { name: /^Actions$/i }); + expect(mainButton.hasAttribute('disabled')).toBe(true); + }); + + it('anchors popper to ButtonGroup with default placement bottom-end when clicking dropdown arrow', () => { + render(); + + const buttonGroup = screen.getByRole('group', { name: /button group with a nested menu/i }); + const buttons = screen.getAllByRole('button'); + const dropdownArrowButton = buttons[1]; + + fireEvent.click(dropdownArrowButton); + + const menu = screen.getByRole('menu'); + expect(menu).not.toBeNull(); + + const popper = menu.closest('[data-popper-placement]'); + expect(popper).not.toBeNull(); + expect(popper?.getAttribute('data-popper-placement')).toBe('bottom-end'); + + expect(mockPopper).toHaveBeenLastCalledWith( + expect.objectContaining({ + open: true, + anchorEl: buttonGroup, + placement: 'bottom-end' + }) + ); + }); + + it('anchors popper to ButtonGroup when opened via primary button without defaultActionClick', () => { + render(); + + const buttonGroup = screen.getByRole('group', { name: /button group with a nested menu/i }); + const mainButton = screen.getByRole('button', { name: /^Actions$/i }); + + fireEvent.click(mainButton); + + const menu = screen.getByRole('menu'); + expect(menu).not.toBeNull(); + + expect(mockPopper).toHaveBeenLastCalledWith( + expect.objectContaining({ + open: true, + anchorEl: buttonGroup, + placement: 'bottom-end' + }) + ); + }); + + it('anchors popper with custom placement when provided', () => { + render( + + ); + + const buttonGroup = screen.getByRole('group', { name: /button group with a nested menu/i }); + const buttons = screen.getAllByRole('button'); + const dropdownArrowButton = buttons[1]; + + fireEvent.click(dropdownArrowButton); + + const menu = screen.getByRole('menu'); + expect(menu).not.toBeNull(); + + const popper = menu.closest('[data-popper-placement]'); + expect(popper).not.toBeNull(); + expect(popper?.getAttribute('data-popper-placement')).toBe('bottom-start'); + + expect(mockPopper).toHaveBeenLastCalledWith( + expect.objectContaining({ + open: true, + anchorEl: buttonGroup, + placement: 'bottom-start' + }) + ); + }); +}); diff --git a/src/custom/ActionButton/ActionButton.tsx b/src/custom/ActionButton/ActionButton.tsx index 791f72e97..d98d78167 100644 --- a/src/custom/ActionButton/ActionButton.tsx +++ b/src/custom/ActionButton/ActionButton.tsx @@ -10,55 +10,110 @@ import { Popper } from '../../base'; import { DropDownIcon } from '../../icons'; +import type { PermissionAction, PermissionKeySpec } from '../PermissionProvider'; + +/** + * Represents an individual menu item in the ActionButton dropdown. + */ export interface Option { + /** Leading icon rendered next to the menu item label. */ icon: React.ReactNode; + /** Display text for the menu option. */ label: string; + /** Click callback triggered when the option is selected. */ onClick: (event: React.MouseEvent, index: number) => void; + /** When true, renders a horizontal Divider instead of a MenuItem. */ isDivider?: boolean; + /** When false, the option is filtered out and not rendered. */ show?: boolean; + /** When true, disables the menu option from user interaction. */ + disabled?: boolean; + /** Permission key specification for RBAC CASL gating. */ + permissionKey?: PermissionKeySpec; + /** Permission action ('showShield' | 'hide') for CASL gating. To disable a menu option, use the `disabled` property. */ + permissionAction?: PermissionAction; } +/** + * Props for the ActionButton split-button component. + */ export interface ActionButtonProps { - defaultActionClick: () => void; + /** Optional click handler for the primary button. If omitted, clicking toggles the dropdown. */ + defaultActionClick?: () => void; + /** Whether the primary action button is disabled. */ defaultActionDisabled?: boolean; + /** List of dropdown menu options. */ options: Option[]; - label: string; + /** Primary button label text; defaults to `'Action'`. */ + label?: string; + /** Popper placement relative to the button group; defaults to `'bottom-end'`. */ + placement?: 'bottom-start' | 'bottom' | 'bottom-end' | 'top-start' | 'top' | 'top-end'; + /** Permission key passed down to the primary Button for CASL gating. */ + permissionKey?: PermissionKeySpec; + /** Permission action for the primary Button. */ + permissionAction?: PermissionAction; } +/** + * ActionButton — Split-button component featuring a primary action and a nested dropdown menu. + */ export default function ActionButton({ defaultActionClick, defaultActionDisabled = false, options, - label + label = 'Action', + placement = 'bottom-end', + permissionKey, + permissionAction }: ActionButtonProps): JSX.Element { const [open, setOpen] = React.useState(false); - const [anchorEl, setAnchorEl] = React.useState(null); - const handleMenuItemClick = () => { - setOpen(false); - }; + const anchorRef = React.useRef(null); const handleToggle = (event: React.MouseEvent) => { event.stopPropagation(); - setAnchorEl(event.currentTarget); setOpen((prevOpen) => !prevOpen); }; - const handleClose = () => { - setAnchorEl(null); + const handleClose = (event: MouseEvent | TouchEvent) => { + if (anchorRef.current && anchorRef.current.contains(event.target as Node)) { + return; + } setOpen(false); }; + const handleMainClick = (event: React.MouseEvent) => { + if (defaultActionClick) { + defaultActionClick(); + } else { + handleToggle(event); + } + }; + return ( - - @@ -67,8 +122,9 @@ export default function ActionButton({ zIndex: 1 }} open={open} - anchorEl={anchorEl} + anchorEl={anchorRef.current} role={undefined} + placement={placement} > @@ -77,12 +133,18 @@ export default function ActionButton({ .filter((option) => option?.show !== false) .map((option, index) => option.isDivider ? ( - + ) : ( { - handleMenuItemClick(); + if (option.disabled) { + return; + } + setOpen(false); option.onClick(event, index); }} >