From 7a33b34cf54b205248ddcfd71e11918331cb3e58 Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Thu, 28 Mar 2024 16:56:15 -0400 Subject: [PATCH] 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 --- .../components/Select/CheckboxSelect.test.tsx | 256 ++++++++++++++++++ .../src/components/Select/CheckboxSelect.tsx | 113 ++++++++ .../Select/CheckboxSelectSnapshots.test.tsx | 32 +++ .../CheckboxSelectSnapshots.test.tsx.snap | 212 +++++++++++++++ .../Select/examples/CheckboxSelectDemo.tsx | 13 + .../Select/examples/SelectTemplates.md | 9 +- .../src/components/Select/index.ts | 1 + 7 files changed, 634 insertions(+), 2 deletions(-) create mode 100644 packages/react-templates/src/components/Select/CheckboxSelect.test.tsx create mode 100644 packages/react-templates/src/components/Select/CheckboxSelect.tsx create mode 100644 packages/react-templates/src/components/Select/CheckboxSelectSnapshots.test.tsx create mode 100644 packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSnapshots.test.tsx.snap create mode 100644 packages/react-templates/src/components/Select/examples/CheckboxSelectDemo.tsx diff --git a/packages/react-templates/src/components/Select/CheckboxSelect.test.tsx b/packages/react-templates/src/components/Select/CheckboxSelect.test.tsx new file mode 100644 index 00000000000..e47a40783fe --- /dev/null +++ b/packages/react-templates/src/components/Select/CheckboxSelect.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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'); +}); diff --git a/packages/react-templates/src/components/Select/CheckboxSelect.tsx b/packages/react-templates/src/components/Select/CheckboxSelect.tsx new file mode 100644 index 00000000000..df62017676b --- /dev/null +++ b/packages/react-templates/src/components/Select/CheckboxSelect.tsx @@ -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 { + /** 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; + /** Initial options of the select. */ + initialOptions?: CheckboxSelectOption[]; + /** Callback triggered on selection. */ + onSelect?: (_event: React.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 = ({ + innerRef, + initialOptions, + isDisabled, + onSelect: passedOnSelect, + onToggle, + toggleContent, + ...props +}: CheckboxSelectProps) => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState([]); + + const checkboxSelectOptions = initialOptions?.map((option) => { + const { content, value, ...props } = option; + const isSelected = selected.includes(`${value}`); + return ( + + {content} + + ); + }); + + const onToggleClick = () => { + onToggle && onToggle(!isOpen); + setIsOpen(!isOpen); + }; + + const onSelect = (event: React.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 && {selected.length}} + + ); + + const toggle = (toggleRef: React.Ref) => ( + + {toggleContent || defaultToggleContent} + + ); + + return ( + + ); +}; + +export const CheckboxSelect = React.forwardRef((props: CheckboxSelectProps, ref: React.Ref) => ( + +)); diff --git a/packages/react-templates/src/components/Select/CheckboxSelectSnapshots.test.tsx b/packages/react-templates/src/components/Select/CheckboxSelectSnapshots.test.tsx new file mode 100644 index 00000000000..5e305d00042 --- /dev/null +++ b/packages/react-templates/src/components/Select/CheckboxSelectSnapshots.test.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CheckboxSelect } from './CheckboxSelect'; + +jest.mock('@patternfly/react-core/dist/js/helpers/GenerateId/GenerateId', () => ({ + GenerateId: ({ children }) => children('generated-id') +})); + +test('checkbox select with no props snapshot', () => { + const { asFragment } = render(); + + expect(asFragment()).toMatchSnapshot(); +}); + +test('opened checkbox select snapshot', async () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + const user = userEvent.setup(); + + const { asFragment } = render(); + + const toggle = screen.getByRole('button', { name: 'Filter by status' }); + + await user.click(toggle); + + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSnapshots.test.tsx.snap b/packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSnapshots.test.tsx.snap new file mode 100644 index 00000000000..6d8189bf009 --- /dev/null +++ b/packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSnapshots.test.tsx.snap @@ -0,0 +1,212 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`checkbox select with no props snapshot 1`] = ` + + + +`; + +exports[`opened checkbox select snapshot 1`] = ` + + +
+
+ +
+
+
+`; diff --git a/packages/react-templates/src/components/Select/examples/CheckboxSelectDemo.tsx b/packages/react-templates/src/components/Select/examples/CheckboxSelectDemo.tsx new file mode 100644 index 00000000000..676bd922868 --- /dev/null +++ b/packages/react-templates/src/components/Select/examples/CheckboxSelectDemo.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { CheckboxSelect, CheckboxSelectOption } from '@patternfly/react-templates'; + +export const SelectBasic: React.FunctionComponent = () => { + const initialOptions: CheckboxSelectOption[] = [ + { content: 'Option 1', value: 'option-1' }, + { content: 'Option 2', value: 'option-2' }, + { content: 'Option 3', value: 'option-3', isDisabled: true }, + { content: 'Option 4', value: 'option-4' } + ]; + + return ; +}; diff --git a/packages/react-templates/src/components/Select/examples/SelectTemplates.md b/packages/react-templates/src/components/Select/examples/SelectTemplates.md index 89cf3616271..db7af312d29 100644 --- a/packages/react-templates/src/components/Select/examples/SelectTemplates.md +++ b/packages/react-templates/src/components/Select/examples/SelectTemplates.md @@ -4,7 +4,7 @@ section: components subsection: menus template: true beta: true -propComponents: ['SimpleSelect'] +propComponents: ['SelectSimple', 'CheckboxSelect'] --- Note: Templates live in their own package at [@patternfly/react-templates](https://www.npmjs.com/package/@patternfly/react-templates)! @@ -12,7 +12,7 @@ Note: Templates live in their own package at [@patternfly/react-templates](https For custom use cases, please see the select component suite from [@patternfly/react-core](https://www.npmjs.com/package/@patternfly/react-core). import { SelectOption, Checkbox } from '@patternfly/react-core'; -import { SelectSimple } from '@patternfly/react-templates'; +import { SelectSimple, CheckboxSelect } from '@patternfly/react-templates'; ## Select template examples @@ -21,3 +21,8 @@ import { SelectSimple } from '@patternfly/react-templates'; ```ts file="SelectSimpleDemo.tsx" ``` + +### Checkbox + +```ts file="CheckboxSelectDemo.tsx" +``` diff --git a/packages/react-templates/src/components/Select/index.ts b/packages/react-templates/src/components/Select/index.ts index 9c0381bfb99..c8752c8faa5 100644 --- a/packages/react-templates/src/components/Select/index.ts +++ b/packages/react-templates/src/components/Select/index.ts @@ -1 +1,2 @@ export * from './SelectSimple'; +export * from './CheckboxSelect';