-
Notifications
You must be signed in to change notification settings - Fork 353
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(Select): add checkbox variant of the simple select template (#10159
) * 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
1 parent
51ee95e
commit 7a33b34
Showing
7 changed files
with
634 additions
and
2 deletions.
There are no files selected for viewing
256 changes: 256 additions & 0 deletions
256
packages/react-templates/src/components/Select/CheckboxSelect.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
113
packages/react-templates/src/components/Select/CheckboxSelect.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} /> | ||
)); |
Oops, something went wrong.