From 59f3914e76753831302e361e23b3a07a49a1ac78 Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Mon, 11 Mar 2024 16:07:56 -0400 Subject: [PATCH 1/3] feat(Select): add checkbox variant of the simple select template --- .../Select/CheckboxSelectSimple.test.tsx | 280 ++++++++++++++++++ .../Select/CheckboxSelectSimple.tsx | 113 +++++++ .../CheckboxSelectSimple.test.tsx.snap | 212 +++++++++++++ .../examples/CheckboxSelectSimpleDemo.tsx | 13 + .../Select/examples/SelectTemplates.md | 9 +- .../src/components/Select/index.ts | 1 + 6 files changed, 626 insertions(+), 2 deletions(-) create mode 100644 packages/react-templates/src/components/Select/CheckboxSelectSimple.test.tsx create mode 100644 packages/react-templates/src/components/Select/CheckboxSelectSimple.tsx create mode 100644 packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSimple.test.tsx.snap create mode 100644 packages/react-templates/src/components/Select/examples/CheckboxSelectSimpleDemo.tsx diff --git a/packages/react-templates/src/components/Select/CheckboxSelectSimple.test.tsx b/packages/react-templates/src/components/Select/CheckboxSelectSimple.test.tsx new file mode 100644 index 00000000000..ea1ed47fefa --- /dev/null +++ b/packages/react-templates/src/components/Select/CheckboxSelectSimple.test.tsx @@ -0,0 +1,280 @@ +import * as React from 'react'; +import { render, screen, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CheckboxSelectSimple } from './CheckboxSelectSimple'; +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'); +}); + +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/CheckboxSelectSimple.tsx b/packages/react-templates/src/components/Select/CheckboxSelectSimple.tsx new file mode 100644 index 00000000000..46fd86dfc2a --- /dev/null +++ b/packages/react-templates/src/components/Select/CheckboxSelectSimple.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { + Badge, + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, + SelectOptionProps +} from '@patternfly/react-core'; + +export interface CheckboxSelectSimpleOption extends Omit { + /** Content of the select option. */ + content: React.ReactNode; + /** Value of the select option. */ + value: string | number; +} + +export interface CheckboxSelectSimpleProps { + /** @hide Forwarded ref */ + innerRef?: React.Ref; + /** Initial options of the select. */ + initialOptions?: CheckboxSelectSimpleOption[]; + /** 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 CheckboxSelectSimpleBase: React.FunctionComponent = ({ + innerRef, + initialOptions, + isDisabled, + onSelect: passedOnSelect, + onToggle, + toggleContent, + ...props +}: CheckboxSelectSimpleProps) => { + 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 CheckboxSelectSimple = React.forwardRef((props: CheckboxSelectSimpleProps, ref: React.Ref) => ( + +)); diff --git a/packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSimple.test.tsx.snap b/packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSimple.test.tsx.snap new file mode 100644 index 00000000000..1cdb24bc826 --- /dev/null +++ b/packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSimple.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/CheckboxSelectSimpleDemo.tsx b/packages/react-templates/src/components/Select/examples/CheckboxSelectSimpleDemo.tsx new file mode 100644 index 00000000000..d698eed0bc1 --- /dev/null +++ b/packages/react-templates/src/components/Select/examples/CheckboxSelectSimpleDemo.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { CheckboxSelectSimple, CheckboxSelectSimpleOption } from '@patternfly/react-templates'; + +export const SelectBasic: React.FunctionComponent = () => { + const initialOptions: CheckboxSelectSimpleOption[] = [ + { 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..dcf30c9c314 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', 'CheckboxSelectSimple'] --- 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, CheckboxSelectSimple } from '@patternfly/react-templates'; ## Select template examples @@ -21,3 +21,8 @@ import { SelectSimple } from '@patternfly/react-templates'; ```ts file="SelectSimpleDemo.tsx" ``` + +### Checkbox + +```ts file="CheckboxSelectSimpleDemo.tsx" +``` diff --git a/packages/react-templates/src/components/Select/index.ts b/packages/react-templates/src/components/Select/index.ts index 9c0381bfb99..4d2e73a6b6c 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 './CheckboxSelectSimple'; From 58f765aacda2c19d5fa5648bb41345fe13812071 Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Wed, 27 Mar 2024 17:22:35 -0400 Subject: [PATCH 2/3] chore(Select): rename template --- ...imple.test.tsx => CheckboxSelect.test.tsx} | 30 +++++++++---------- ...boxSelectSimple.tsx => CheckboxSelect.tsx} | 14 ++++----- ....tsx.snap => CheckboxSelect.test.tsx.snap} | 12 ++++---- ...tSimpleDemo.tsx => CheckboxSelectDemo.tsx} | 6 ++-- .../Select/examples/SelectTemplates.md | 6 ++-- .../src/components/Select/index.ts | 2 +- 6 files changed, 35 insertions(+), 35 deletions(-) rename packages/react-templates/src/components/Select/{CheckboxSelectSimple.test.tsx => CheckboxSelect.test.tsx} (86%) rename packages/react-templates/src/components/Select/{CheckboxSelectSimple.tsx => CheckboxSelect.tsx} (85%) rename packages/react-templates/src/components/Select/__snapshots__/{CheckboxSelectSimple.test.tsx.snap => CheckboxSelect.test.tsx.snap} (93%) rename packages/react-templates/src/components/Select/examples/{CheckboxSelectSimpleDemo.tsx => CheckboxSelectDemo.tsx} (58%) diff --git a/packages/react-templates/src/components/Select/CheckboxSelectSimple.test.tsx b/packages/react-templates/src/components/Select/CheckboxSelect.test.tsx similarity index 86% rename from packages/react-templates/src/components/Select/CheckboxSelectSimple.test.tsx rename to packages/react-templates/src/components/Select/CheckboxSelect.test.tsx index ea1ed47fefa..d8abe438545 100644 --- a/packages/react-templates/src/components/Select/CheckboxSelectSimple.test.tsx +++ b/packages/react-templates/src/components/Select/CheckboxSelect.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { render, screen, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { CheckboxSelectSimple } from './CheckboxSelectSimple'; +import { CheckboxSelect } from './CheckboxSelect'; import styles from '@patternfly/react-styles/css/components/Badge/badge'; test('renders checkbox select with options', async () => { @@ -13,7 +13,7 @@ test('renders checkbox select with options', async () => { const user = userEvent.setup(); - render(); + render(); const toggle = screen.getByRole('button', { name: 'Filter by status' }); @@ -37,7 +37,7 @@ test('selects options when clicked', async () => { const user = userEvent.setup(); - render(); + render(); const toggle = screen.getByRole('button', { name: 'Filter by status' }); @@ -61,7 +61,7 @@ test('deselects options when an already selected option is clicked', async () => const user = userEvent.setup(); - render(); + render(); const toggle = screen.getByRole('button', { name: 'Filter by status' }); @@ -85,7 +85,7 @@ test('calls the onSelect callback with the selected value when an option is sele const user = userEvent.setup(); const onSelectMock = jest.fn(); - render(); + render(); const toggle = screen.getByRole('button', { name: 'Filter by status' }); @@ -109,7 +109,7 @@ test('does not call the onSelect callback when no options are selected', async ( const user = userEvent.setup(); const onSelectMock = jest.fn(); - render(); + render(); const toggle = screen.getByRole('button', { name: 'Filter by status' }); @@ -127,7 +127,7 @@ test('toggles the select menu when the toggle button is clicked', async () => { const user = userEvent.setup(); - render(); + render(); const toggleButton = screen.getByRole('button', { name: 'Filter by status' }); @@ -149,7 +149,7 @@ test('displays custom toggle content', async () => { { content: 'Option 3', value: 'option3' } ]; - render(); + render(); const toggleButton = screen.getByRole('button', { name: 'Custom Toggle' }); @@ -166,7 +166,7 @@ test('calls the onToggle callback when the select opens or closes', async () => const user = userEvent.setup(); const onToggleMock = jest.fn(); - render(); + render(); const toggle = screen.getByRole('button', { name: 'Filter by status' }); @@ -190,7 +190,7 @@ test('does not call the onToggle callback when the toggle is not clicked', async const onToggleMock = jest.fn(); - render(); + render(); expect(onToggleMock).not.toHaveBeenCalled(); }); @@ -204,7 +204,7 @@ test('disables the select when isDisabled prop is true', async () => { const user = userEvent.setup(); - render(); + render(); const toggleButton = screen.getByRole('button', { name: 'Filter by status' }); @@ -220,7 +220,7 @@ test('passes other SelectOption props to the SelectOption component', async () = const user = userEvent.setup(); - render(); + render(); const toggle = screen.getByRole('button', { name: 'Filter by status' }); @@ -240,7 +240,7 @@ test('displays the badge count when options are selected', async () => { const user = userEvent.setup(); - render(); + render(); const toggle = screen.getByRole('button', { name: 'Filter by status' }); @@ -256,7 +256,7 @@ test('displays the badge count when options are selected', async () => { }); test('checkbox select with no props snapshot', () => { - const { asFragment } = render(); + const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); }); @@ -270,7 +270,7 @@ test('opened checkbox select snapshot', async () => { const user = userEvent.setup(); - const { asFragment } = render(); + const { asFragment } = render(); const toggle = screen.getByRole('button', { name: 'Filter by status' }); diff --git a/packages/react-templates/src/components/Select/CheckboxSelectSimple.tsx b/packages/react-templates/src/components/Select/CheckboxSelect.tsx similarity index 85% rename from packages/react-templates/src/components/Select/CheckboxSelectSimple.tsx rename to packages/react-templates/src/components/Select/CheckboxSelect.tsx index 46fd86dfc2a..df62017676b 100644 --- a/packages/react-templates/src/components/Select/CheckboxSelectSimple.tsx +++ b/packages/react-templates/src/components/Select/CheckboxSelect.tsx @@ -9,18 +9,18 @@ import { SelectOptionProps } from '@patternfly/react-core'; -export interface CheckboxSelectSimpleOption extends Omit { +export interface CheckboxSelectOption extends Omit { /** Content of the select option. */ content: React.ReactNode; /** Value of the select option. */ value: string | number; } -export interface CheckboxSelectSimpleProps { +export interface CheckboxSelectProps { /** @hide Forwarded ref */ innerRef?: React.Ref; /** Initial options of the select. */ - initialOptions?: CheckboxSelectSimpleOption[]; + initialOptions?: CheckboxSelectOption[]; /** Callback triggered on selection. */ onSelect?: (_event: React.MouseEvent, value?: string | number) => void; /** Callback triggered when the select opens or closes. */ @@ -31,7 +31,7 @@ export interface CheckboxSelectSimpleProps { toggleContent?: React.ReactNode; } -const CheckboxSelectSimpleBase: React.FunctionComponent = ({ +const CheckboxSelectBase: React.FunctionComponent = ({ innerRef, initialOptions, isDisabled, @@ -39,7 +39,7 @@ const CheckboxSelectSimpleBase: React.FunctionComponent { +}: CheckboxSelectProps) => { const [isOpen, setIsOpen] = React.useState(false); const [selected, setSelected] = React.useState([]); @@ -108,6 +108,6 @@ const CheckboxSelectSimpleBase: React.FunctionComponent) => ( - +export const CheckboxSelect = React.forwardRef((props: CheckboxSelectProps, ref: React.Ref) => ( + )); diff --git a/packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSimple.test.tsx.snap b/packages/react-templates/src/components/Select/__snapshots__/CheckboxSelect.test.tsx.snap similarity index 93% rename from packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSimple.test.tsx.snap rename to packages/react-templates/src/components/Select/__snapshots__/CheckboxSelect.test.tsx.snap index 1cdb24bc826..8377f574f65 100644 --- a/packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSimple.test.tsx.snap +++ b/packages/react-templates/src/components/Select/__snapshots__/CheckboxSelect.test.tsx.snap @@ -97,7 +97,7 @@ exports[`opened checkbox select snapshot 1`] = ` >