Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions src/__testing__/ActionButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, PopperProps>((props, ref) => {
mockPopper(props);
return <actual.Popper {...props} ref={ref} />;
});
return {
__esModule: true,
...actual,
Popper: MockPopper,
default: MockPopper
};
});

describe('ActionButton Component', () => {
const mockOptions: Option[] = [
{
label: 'Validate',
icon: <span data-testid="icon-validate">V</span>,
onClick: jest.fn()
},
{
label: 'Dry Run',
icon: <span data-testid="icon-dryrun">D</span>,
onClick: jest.fn()
},
{
label: 'Deploy',
icon: <span data-testid="icon-deploy">Dep</span>,
onClick: jest.fn(),
disabled: true
},
{
label: 'Hidden Option',
icon: <span>H</span>,
onClick: jest.fn(),
show: false
},
{
label: 'Divider',
icon: null,
onClick: jest.fn(),
isDivider: true
},
{
label: 'Undeploy',
icon: <span data-testid="icon-undeploy">U</span>,
onClick: jest.fn()
}
];

beforeEach(() => {
jest.clearAllMocks();
});

it('renders with default label "Action" when no label is passed', () => {
render(<ActionButton options={mockOptions} />);
expect(screen.getByRole('button', { name: /^Action$/i })).not.toBeNull();
});

it('renders with custom label when provided', () => {
render(<ActionButton label="Actions" options={mockOptions} />);
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(
<ActionButton
label="Actions"
defaultActionClick={handleDefaultClick}
options={mockOptions}
/>
);

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(<ActionButton label="Actions" options={mockOptions} />);

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(<ActionButton label="Actions" options={mockOptions} />);

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(<ActionButton label="Actions" options={mockOptions} />);

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(<ActionButton label="Actions" options={mockOptions} />);

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(<ActionButton label="Actions" options={mockOptions} />);

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(<ActionButton label="Actions" defaultActionDisabled={true} options={mockOptions} />);

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(<ActionButton label="Actions" options={mockOptions} />);

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(<ActionButton label="Actions" options={mockOptions} />);

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(
<ActionButton label="Actions" options={mockOptions} placement="bottom-start" />
);

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'
})
);
});
});
92 changes: 77 additions & 15 deletions src/custom/ActionButton/ActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLLIElement, 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 | HTMLElement>(null);
const handleMenuItemClick = () => {
setOpen(false);
};
const anchorRef = React.useRef<HTMLDivElement>(null);

const handleToggle = (event: React.MouseEvent<HTMLButtonElement, 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<HTMLButtonElement, MouseEvent>) => {
if (defaultActionClick) {
defaultActionClick();
} else {
handleToggle(event);
}
};

return (
<React.Fragment>
<ButtonGroup
variant="contained"
style={{ boxShadow: 'none' }}
ref={anchorRef}
aria-label="Button group with a nested menu"
>
<Button onClick={defaultActionClick} variant="contained" disabled={defaultActionDisabled}>
<Button
onClick={handleMainClick}
variant="contained"
disabled={defaultActionDisabled}
permissionKey={permissionKey}
permissionAction={permissionAction}
>
{label}
</Button>
<Button size="small" onClick={handleToggle} variant="contained">
<Button
size="small"
onClick={handleToggle}
variant="contained"
aria-controls={open ? 'split-button-menu' : undefined}
aria-expanded={open ? 'true' : undefined}
aria-haspopup="menu"
>
<DropDownIcon />
</Button>
</ButtonGroup>
Expand All @@ -67,8 +122,9 @@ export default function ActionButton({
zIndex: 1
}}
open={open}
anchorEl={anchorEl}
anchorEl={anchorRef.current}
role={undefined}
placement={placement}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
Expand All @@ -77,12 +133,18 @@ export default function ActionButton({
.filter((option) => option?.show !== false)
.map((option, index) =>
option.isDivider ? (
<Divider />
<Divider key={index} />
) : (
<MenuItem
key={index}
disabled={option.disabled}
permissionKey={option.permissionKey}
permissionAction={option.permissionAction}
onClick={(event) => {
handleMenuItemClick();
if (option.disabled) {
return;
}
setOpen(false);
option.onClick(event, index);
}}
>
Expand Down
Loading