Skip to content

Commit

Permalink
feat(Select): add checkbox variant of the simple select template (#10159
Browse files Browse the repository at this point in the history
)

* feat(Select): add checkbox variant of the simple select template

* chore(Select): rename template

* fix(Select): mock generated id in CheckboxSelect snapshot tests
  • Loading branch information
wise-king-sullyman authored Mar 28, 2024
1 parent 51ee95e commit 7a33b34
Show file tree
Hide file tree
Showing 7 changed files with 634 additions and 2 deletions.
256 changes: 256 additions & 0 deletions packages/react-templates/src/components/Select/CheckboxSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import * as React from 'react';
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CheckboxSelect } from './CheckboxSelect';
import styles from '@patternfly/react-styles/css/components/Badge/badge';

test('renders checkbox select with options', async () => {
const initialOptions = [
{ content: 'Option 1', value: 'option1' },
{ content: 'Option 2', value: 'option2' },
{ content: 'Option 3', value: 'option3' }
];

const user = userEvent.setup();

render(<CheckboxSelect initialOptions={initialOptions} />);

const toggle = screen.getByRole('button', { name: 'Filter by status' });

await user.click(toggle);

const option1 = screen.getByRole('checkbox', { name: 'Option 1' });
const option2 = screen.getByRole('checkbox', { name: 'Option 2' });
const option3 = screen.getByRole('checkbox', { name: 'Option 3' });

expect(option1).toBeInTheDocument();
expect(option2).toBeInTheDocument();
expect(option3).toBeInTheDocument();
});

test('selects options when clicked', async () => {
const initialOptions = [
{ content: 'Option 1', value: 'option1' },
{ content: 'Option 2', value: 'option2' },
{ content: 'Option 3', value: 'option3' }
];

const user = userEvent.setup();

render(<CheckboxSelect initialOptions={initialOptions} />);

const toggle = screen.getByRole('button', { name: 'Filter by status' });

await user.click(toggle);

const option1 = screen.getByRole('checkbox', { name: 'Option 1' });

expect(option1).not.toBeChecked();

await user.click(option1);

expect(option1).toBeChecked();
});

test('deselects options when an already selected option is clicked', async () => {
const initialOptions = [
{ content: 'Option 1', value: 'option1' },
{ content: 'Option 2', value: 'option2' },
{ content: 'Option 3', value: 'option3' }
];

const user = userEvent.setup();

render(<CheckboxSelect initialOptions={initialOptions} />);

const toggle = screen.getByRole('button', { name: 'Filter by status' });

await user.click(toggle);

const option1 = screen.getByRole('checkbox', { name: 'Option 1' });

await user.click(option1);
await user.click(option1);

expect(option1).not.toBeChecked();
});

test('calls the onSelect callback with the selected value when an option is selected', async () => {
const initialOptions = [
{ content: 'Option 1', value: 'option1' },
{ content: 'Option 2', value: 'option2' },
{ content: 'Option 3', value: 'option3' }
];

const user = userEvent.setup();
const onSelectMock = jest.fn();

render(<CheckboxSelect initialOptions={initialOptions} onSelect={onSelectMock} />);

const toggle = screen.getByRole('button', { name: 'Filter by status' });

await user.click(toggle);

const option1 = screen.getByRole('checkbox', { name: 'Option 1' });

await user.click(option1);

expect(onSelectMock).toHaveBeenCalledTimes(1);
expect(onSelectMock).toHaveBeenCalledWith(expect.anything(), 'option1');
});

test('does not call the onSelect callback when no options are selected', async () => {
const initialOptions = [
{ content: 'Option 1', value: 'option1' },
{ content: 'Option 2', value: 'option2' },
{ content: 'Option 3', value: 'option3' }
];

const user = userEvent.setup();
const onSelectMock = jest.fn();

render(<CheckboxSelect initialOptions={initialOptions} onSelect={onSelectMock} />);

const toggle = screen.getByRole('button', { name: 'Filter by status' });

await user.click(toggle);

expect(onSelectMock).not.toHaveBeenCalled();
});

test('toggles the select menu when the toggle button is clicked', async () => {
const initialOptions = [
{ content: 'Option 1', value: 'option1' },
{ content: 'Option 2', value: 'option2' },
{ content: 'Option 3', value: 'option3' }
];

const user = userEvent.setup();

render(<CheckboxSelect initialOptions={initialOptions} />);

const toggleButton = screen.getByRole('button', { name: 'Filter by status' });

await user.click(toggleButton);

expect(screen.getByRole('menu')).toBeInTheDocument();

await user.click(toggleButton);

await waitForElementToBeRemoved(() => screen.queryByRole('menu'));

expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});

test('displays custom toggle content', async () => {
const initialOptions = [
{ content: 'Option 1', value: 'option1' },
{ content: 'Option 2', value: 'option2' },
{ content: 'Option 3', value: 'option3' }
];

render(<CheckboxSelect initialOptions={initialOptions} toggleContent="Custom Toggle" />);

const toggleButton = screen.getByRole('button', { name: 'Custom Toggle' });

expect(toggleButton).toBeInTheDocument();
});

test('calls the onToggle callback when the select opens or closes', async () => {
const initialOptions = [
{ content: 'Option 1', value: 'option1' },
{ content: 'Option 2', value: 'option2' },
{ content: 'Option 3', value: 'option3' }
];

const user = userEvent.setup();
const onToggleMock = jest.fn();

render(<CheckboxSelect initialOptions={initialOptions} onToggle={onToggleMock} />);

const toggle = screen.getByRole('button', { name: 'Filter by status' });

await user.click(toggle);

expect(onToggleMock).toHaveBeenCalledTimes(1);
expect(onToggleMock).toHaveBeenCalledWith(true);

await user.click(toggle);

expect(onToggleMock).toHaveBeenCalledTimes(2);
expect(onToggleMock).toHaveBeenCalledWith(false);
});

test('does not call the onToggle callback when the toggle is not clicked', async () => {
const initialOptions = [
{ content: 'Option 1', value: 'option1' },
{ content: 'Option 2', value: 'option2' },
{ content: 'Option 3', value: 'option3' }
];

const onToggleMock = jest.fn();

render(<CheckboxSelect initialOptions={initialOptions} onToggle={onToggleMock} />);

expect(onToggleMock).not.toHaveBeenCalled();
});

test('disables the select when isDisabled prop is true', async () => {
const initialOptions = [
{ content: 'Option 1', value: 'option1' },
{ content: 'Option 2', value: 'option2' },
{ content: 'Option 3', value: 'option3' }
];

const user = userEvent.setup();

render(<CheckboxSelect initialOptions={initialOptions} isDisabled={true} />);

const toggleButton = screen.getByRole('button', { name: 'Filter by status' });

expect(toggleButton).toBeDisabled();

await user.click(toggleButton);

expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});

test('passes other SelectOption props to the SelectOption component', async () => {
const initialOptions = [{ content: 'Option 1', value: 'option1', isDisabled: true }];

const user = userEvent.setup();

render(<CheckboxSelect initialOptions={initialOptions} />);

const toggle = screen.getByRole('button', { name: 'Filter by status' });

await user.click(toggle);

const option1 = screen.getByRole('checkbox', { name: 'Option 1' });

expect(option1).toBeDisabled();
});

test('displays the badge count when options are selected', async () => {
const initialOptions = [
{ content: 'Option 1', value: 'option1' },
{ content: 'Option 2', value: 'option2' },
{ content: 'Option 3', value: 'option3' }
];

const user = userEvent.setup();

render(<CheckboxSelect initialOptions={initialOptions} />);

const toggle = screen.getByRole('button', { name: 'Filter by status' });

await user.click(toggle);

const option1 = screen.getByRole('checkbox', { name: 'Option 1' });

expect(screen.queryByText('1')).not.toBeInTheDocument();

await user.click(option1);

expect(screen.getByText('1')).toHaveClass(styles.badge, 'pf-m-read');
});
113 changes: 113 additions & 0 deletions packages/react-templates/src/components/Select/CheckboxSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React from 'react';
import {
Badge,
MenuToggle,
MenuToggleElement,
Select,
SelectList,
SelectOption,
SelectOptionProps
} from '@patternfly/react-core';

export interface CheckboxSelectOption extends Omit<SelectOptionProps, 'content'> {
/** Content of the select option. */
content: React.ReactNode;
/** Value of the select option. */
value: string | number;
}

export interface CheckboxSelectProps {
/** @hide Forwarded ref */
innerRef?: React.Ref<any>;
/** Initial options of the select. */
initialOptions?: CheckboxSelectOption[];
/** Callback triggered on selection. */
onSelect?: (_event: React.MouseEvent<Element, MouseEvent>, value?: string | number) => void;
/** Callback triggered when the select opens or closes. */
onToggle?: (nextIsOpen: boolean) => void;
/** Flag indicating the select should be disabled. */
isDisabled?: boolean;
/** Content of the toggle. Defaults to the selected option. */
toggleContent?: React.ReactNode;
}

const CheckboxSelectBase: React.FunctionComponent<CheckboxSelectProps> = ({
innerRef,
initialOptions,
isDisabled,
onSelect: passedOnSelect,
onToggle,
toggleContent,
...props
}: CheckboxSelectProps) => {
const [isOpen, setIsOpen] = React.useState(false);
const [selected, setSelected] = React.useState<string[]>([]);

const checkboxSelectOptions = initialOptions?.map((option) => {
const { content, value, ...props } = option;
const isSelected = selected.includes(`${value}`);
return (
<SelectOption {...props} value={value} key={value} hasCheckbox isSelected={isSelected}>
{content}
</SelectOption>
);
});

const onToggleClick = () => {
onToggle && onToggle(!isOpen);
setIsOpen(!isOpen);
};

const onSelect = (event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
const valueString = `${value}`;
if (selected.includes(valueString)) {
setSelected((prevSelected) => prevSelected.filter((item) => item !== valueString));
} else {
setSelected((prevSelected) => [...prevSelected, valueString]);
}
passedOnSelect && passedOnSelect(event, value);
};

const defaultToggleContent = (
<>
Filter by status
{selected.length > 0 && <Badge isRead>{selected.length}</Badge>}
</>
);

const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
onClick={onToggleClick}
isExpanded={isOpen}
isDisabled={isDisabled}
style={
{
width: '200px'
} as React.CSSProperties
}
>
{toggleContent || defaultToggleContent}
</MenuToggle>
);

return (
<Select
id="checkbox-select"
isOpen={isOpen}
selected={selected}
onSelect={onSelect}
onOpenChange={(isOpen) => setIsOpen(isOpen)}
toggle={toggle}
ref={innerRef}
role="menu"
{...props}
>
<SelectList>{checkboxSelectOptions}</SelectList>
</Select>
);
};

export const CheckboxSelect = React.forwardRef((props: CheckboxSelectProps, ref: React.Ref<any>) => (
<CheckboxSelectBase {...props} innerRef={ref} />
));
Loading

0 comments on commit 7a33b34

Please sign in to comment.