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
142 changes: 142 additions & 0 deletions src/__testing__/ActionButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { ActionButton, Option } from '../custom/ActionButton';

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('Validate')).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();
expect(screen.getByText('Validate')).not.toBeNull();
expect(screen.getByText('Dry Run')).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('Validate');
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('Deploy');
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('Hidden Option')).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);
});
});
49 changes: 38 additions & 11 deletions src/custom/ActionButton/ActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,55 +10,77 @@ import {
Popper
} from '../../base';
import { DropDownIcon } from '../../icons';

export interface Option {
icon: React.ReactNode;
label: string;
onClick: (event: React.MouseEvent<HTMLLIElement, MouseEvent>, index: number) => void;
isDivider?: boolean;
show?: boolean;
disabled?: boolean;
}

export interface ActionButtonProps {
defaultActionClick: () => void;
defaultActionClick?: () => void;
defaultActionDisabled?: boolean;
options: Option[];
label: string;
label?: string;
placement?: 'bottom-start' | 'bottom' | 'bottom-end' | 'top-start' | 'top' | 'top-end';
}

export default function ActionButton({
defaultActionClick,
defaultActionDisabled = false,
options,
label
label = 'Action',
placement = 'bottom-start'
}: ActionButtonProps): JSX.Element {
const [open, setOpen] = React.useState(false);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const anchorRef = React.useRef<HTMLDivElement>(null);

const handleMenuItemClick = () => {
setOpen(false);
};

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}>
{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 +89,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,11 +100,15 @@ export default function ActionButton({
.filter((option) => option?.show !== false)
.map((option, index) =>
option.isDivider ? (
<Divider />
<Divider key={index} />
) : (
<MenuItem
key={index}
disabled={option.disabled}
onClick={(event) => {
if (option.disabled) {
return;
}
handleMenuItemClick();
option.onClick(event, index);
}}
Expand Down
Loading