From 0e40ad7a6fa0d50c34bff0583ed3af5b7dda41ea Mon Sep 17 00:00:00 2001 From: Filip Hlavac <50696716+fhlavac@users.noreply.github.com> Date: Tue, 11 Jun 2024 22:41:51 +0200 Subject: [PATCH] Add BulkSelect component (#146) --- cypress/component/BulkSelect.cy.tsx | 97 ++++++++++ .../examples/BulkSelect/BulkSelect.md | 37 ++++ .../BulkSelect/BulkSelectAllExample.tsx | 28 +++ .../examples/BulkSelect/BulkSelectExample.tsx | 26 +++ .../module/src/BulkSelect/BulkSelect.test.tsx | 18 ++ packages/module/src/BulkSelect/BulkSelect.tsx | 136 +++++++++++++ .../__snapshots__/BulkSelect.test.tsx.snap | 178 ++++++++++++++++++ packages/module/src/BulkSelect/index.ts | 2 + packages/module/src/index.ts | 3 + 9 files changed, 525 insertions(+) create mode 100644 cypress/component/BulkSelect.cy.tsx create mode 100644 packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelect.md create mode 100644 packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelectAllExample.tsx create mode 100644 packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelectExample.tsx create mode 100644 packages/module/src/BulkSelect/BulkSelect.test.tsx create mode 100644 packages/module/src/BulkSelect/BulkSelect.tsx create mode 100644 packages/module/src/BulkSelect/__snapshots__/BulkSelect.test.tsx.snap create mode 100644 packages/module/src/BulkSelect/index.ts diff --git a/cypress/component/BulkSelect.cy.tsx b/cypress/component/BulkSelect.cy.tsx new file mode 100644 index 00000000..1a1e3da1 --- /dev/null +++ b/cypress/component/BulkSelect.cy.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import BulkSelect, { BulkSelectProps, BulkSelectValue } from '../../packages/module/dist/dynamic/BulkSelect'; + +interface DataItem { + name: string +}; + +const BulkSelectTestComponent = ({ canSelectAll, isDataPaginated }: Omit) => { + const [ selected, setSelected ] = useState([]); + + const allData = [ { name: '1' }, { name: '2' }, { name: '3' }, { name: '4' }, { name: '5' }, { name: '6' } ]; + const pageData = [ { name: '1' }, { name: '2' }, { name: '3' }, { name: '4' }, { name: '5' } ]; + const pageDataNames = pageData.map((item) => item.name); + const pageSelected = pageDataNames.every(item => selected.find(selectedItem => selectedItem.name === item)); + + const handleBulkSelect = (value: BulkSelectValue) => { + value === BulkSelectValue.none && setSelected([]); + value === BulkSelectValue.page && setSelected(pageData); + value === BulkSelectValue.all && setSelected(allData); + value === BulkSelectValue.nonePage && setSelected(selected.filter(item => !pageDataNames.includes(item.name)))}; + + return ( + selected.find(selectedItem => selectedItem.name === item)) && !pageSelected} + onSelect={handleBulkSelect} + /> + ); +}; + +describe('BulkSelect', () => { + it('renders the bulk select without all', () => { + cy.mount( + + ); + cy.get('[data-ouia-component-id="BulkSelect-checkbox"]').should('exist'); + cy.get('[data-ouia-component-id="BulkSelect-toggle"]').click(); + cy.get('[data-ouia-component-id="BulkSelect-select-all"]').should('not.exist'); + cy.get('[data-ouia-component-id="BulkSelect-select-page"]').should('exist'); + cy.get('[data-ouia-component-id="BulkSelect-select-none"]').should('exist'); + + cy.contains('0 selected').should('not.exist'); + }); + + it('renders the bulk select with all and without page', () => { + cy.mount( + + ); + cy.get('[data-ouia-component-id="BulkSelect-checkbox"]').should('exist'); + cy.get('[data-ouia-component-id="BulkSelect-toggle"]').click(); + cy.get('[data-ouia-component-id="BulkSelect-select-all"]').should('exist'); + cy.get('[data-ouia-component-id="BulkSelect-select-page"]').should('not.exist'); + cy.get('[data-ouia-component-id="BulkSelect-select-none"]').should('exist'); + + cy.contains('0 selected').should('not.exist'); + }); + + it('renders the bulk select with data', () => { + cy.mount( + + ); + + // Initial state + cy.get('input[type="checkbox"]').each(($checkbox) => { + cy.wrap($checkbox).should('not.be.checked'); + }); + + // Checkbox select + cy.get('[data-ouia-component-id="BulkSelect-checkbox"]').first().click(); + cy.get('input[type="checkbox"]').should('be.checked'); + cy.contains('5 selected').should('exist'); + + // Select none + cy.get('[data-ouia-component-id="BulkSelect-toggle"]').first().click({ force: true }); + cy.get('[data-ouia-component-id="BulkSelect-select-none"]').first().click(); + cy.get('input[type="checkbox"]').should('not.be.checked'); + + // Select all + cy.get('[data-ouia-component-id="BulkSelect-toggle"]').first().click({ force: true }); + cy.get('[data-ouia-component-id="BulkSelect-select-all"]').first().click(); + cy.contains('6 selected').should('exist'); + + // Checkbox deselect + cy.get('[data-ouia-component-id="BulkSelect-checkbox"]').first().click({ force: true }); + cy.contains('1 selected').should('exist'); + + // Select page + cy.get('[data-ouia-component-id="BulkSelect-toggle"]').first().click({ force: true }); + cy.get('[data-ouia-component-id="BulkSelect-select-page"]').first().click(); + cy.contains('5 selected').should('exist'); + }); +}); \ No newline at end of file diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelect.md b/packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelect.md new file mode 100644 index 00000000..6281f23f --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelect.md @@ -0,0 +1,37 @@ +--- +# Sidenav top-level section +# should be the same for all markdown files +section: extensions +subsection: Component groups +# Sidenav secondary level section +# should be the same for all markdown files +id: Bulk select +# Tab (react | react-demos | html | html-demos | design-guidelines | accessibility) +source: react +# If you use typescript, the name of the interface to display props for +# These are found through the sourceProps function provided in patternfly-docs.source.js +propComponents: ['BulkSelect'] +sourceLink: https://github.com/patternfly/react-component-groups/blob/main/packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelect.md +--- +import { useState } from 'react'; +import { BulkSelect, BulkSelectValue } from '@patternfly/react-component-groups/dist/dynamic/BulkSelect'; + +The **bulk select** provides a way of selecting data records in batches. You can select all data at once, all data on current page or deselect all. + +## Examples + +### Basic paginated bulk select + +To display a default bulk select, you need to pass number of selected items using `selectedCount`, the `onSelect` callback accepting bulk select option values and selecting data accordingly, `pageCount` defining number of items on the current page, `pageSelected` and `pagePartiallySelected` boolean flags to define the state os the bulk select checkbox.. + +```js file="./BulkSelectExample.tsx" + +``` + +### Bulk select with all option + +To display an option for selecting all data at once, pass `canSelectAll` flag together with `totalCount` of data entries. You can also remove the page select option by setting `isDataPaginated` to `false`, + +```js file="./BulkSelectAllExample.tsx" + +``` diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelectAllExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelectAllExample.tsx new file mode 100644 index 00000000..b1dda1a1 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelectAllExample.tsx @@ -0,0 +1,28 @@ +import React, { useState } from 'react'; +import { BulkSelect, BulkSelectValue } from '@patternfly/react-component-groups/dist/dynamic/BulkSelect'; + +const allData = [ "Item 1", "Item 2" , "Item 3", "Item4", "Item 5" ]; +const pageData = [ "Item 1", "Item 2" ]; + +export const BasicExample: React.FunctionComponent = () => { + const [ selected, setSelected ] = useState(pageData); + + const handleBulkSelect = (value: BulkSelectValue) => { + value === BulkSelectValue.none && setSelected([]); + value === BulkSelectValue.all && setSelected(allData); + value === BulkSelectValue.nonePage && setSelected(selected.filter(item => !pageData.includes(item))); + value === BulkSelectValue.page && setSelected(pageData); + }; + + return ( + selected.includes(item))} + pagePartiallySelected={pageData.some(item => selected.includes(item)) && !pageData.every(item => selected.includes(item))} + /> + ); +} \ No newline at end of file diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelectExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelectExample.tsx new file mode 100644 index 00000000..a69a21df --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/BulkSelect/BulkSelectExample.tsx @@ -0,0 +1,26 @@ +import React, { useState } from 'react'; +import { BulkSelect, BulkSelectValue } from '@patternfly/react-component-groups/dist/dynamic/BulkSelect'; + +const allData = [ "Item 1", "Item 2" , "Item 3", "Item4", "Item 5" ]; +const pageData = [ "Item 1", "Item 2" ]; + +export const BasicExample: React.FunctionComponent = () => { + const [ selected, setSelected ] = useState([]); + + const handleBulkSelect = (value: BulkSelectValue) => { + value === BulkSelectValue.none && setSelected([]); + value === BulkSelectValue.all && setSelected(allData); + value === BulkSelectValue.nonePage && setSelected(selected.filter(item => !pageData.includes(item))); + value === BulkSelectValue.page && setSelected(pageData); + }; + + return ( + selected.includes(item))} + pagePartiallySelected={pageData.some(item => selected.includes(item)) && !pageData.every(item => selected.includes(item))} + /> + ); +} \ No newline at end of file diff --git a/packages/module/src/BulkSelect/BulkSelect.test.tsx b/packages/module/src/BulkSelect/BulkSelect.test.tsx new file mode 100644 index 00000000..cb1e678f --- /dev/null +++ b/packages/module/src/BulkSelect/BulkSelect.test.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import BulkSelect from './BulkSelect'; + +describe('BulkSelect component', () => { + test('should render', () => { + expect(render( + null} + />)).toMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/packages/module/src/BulkSelect/BulkSelect.tsx b/packages/module/src/BulkSelect/BulkSelect.tsx new file mode 100644 index 00000000..a2bd4ce3 --- /dev/null +++ b/packages/module/src/BulkSelect/BulkSelect.tsx @@ -0,0 +1,136 @@ +import React, { useMemo, useState } from 'react'; +import { + Dropdown, + DropdownItem, + DropdownList, + DropdownProps, + MenuToggle, + MenuToggleCheckbox, + MenuToggleCheckboxProps, + MenuToggleElement, + Text +} from '@patternfly/react-core'; + +export const BulkSelectValue = { + all: 'all', + none: 'none', + page: 'page', + nonePage: 'nonePage' +} as const; + +export type BulkSelectValue = typeof BulkSelectValue[keyof typeof BulkSelectValue]; + +export interface BulkSelectProps extends Omit { + /** BulkSelect className */ + className?: string; + /** Indicates whether selectable items are paginated */ + isDataPaginated?: boolean; + /** Indicates whether "Select all" option should be available */ + canSelectAll?: boolean; + /** Number of entries present in current page */ + pageCount?: number; + /** Number of selected entries */ + selectedCount: number; + /** Number of all entries */ + totalCount?: number; + /** Indicates if ALL current page items are selected */ + pageSelected?: boolean; + /** Indicates if ONLY some current page items are selected */ + pagePartiallySelected?: boolean; + /** Callback called on item select */ + onSelect: (value: BulkSelectValue) => void; + /** Custom OUIA ID */ + ouiaId?: string; + /** Additional props for MenuToggleCheckbox */ + menuToggleCheckboxProps?: Omit; +} + +export const BulkSelect: React.FC = ({ + isDataPaginated = true, + canSelectAll, + pageSelected, + pagePartiallySelected, + pageCount, + selectedCount = 0, + totalCount, + ouiaId = 'BulkSelect', + onSelect, + menuToggleCheckboxProps, + ...props +}: BulkSelectProps) => { + const [ isOpen, setOpen ] = useState(false); + + const splitButtonDropdownItems = useMemo( + () => ( + <> + + Select none (0) + + {isDataPaginated && ( + + {`Select page${pageCount ? ` (${pageCount})` : ''}`} + + )} + {canSelectAll && ( + + {`Select all${totalCount ? ` (${totalCount})` : ''}`} + + )} + + ), + [ isDataPaginated, canSelectAll, ouiaId, pageCount, totalCount ] + ); + + const allOption = isDataPaginated ? BulkSelectValue.page : BulkSelectValue.all; + const noneOption = isDataPaginated ? BulkSelectValue.nonePage : BulkSelectValue.none; + + return ( + { + setOpen(!isOpen); + onSelect?.(value as BulkSelectValue); + }} + isOpen={isOpen} + onOpenChange={(isOpen: boolean) => setOpen(isOpen)} + toggle={(toggleRef: React.Ref) => ( + setOpen(!isOpen)} + aria-label="Bulk select toggle" + data-ouia-component-id={`${ouiaId}-toggle`} + splitButtonOptions={{ + items: [ + 0) + ? null + : pageSelected || selectedCount === totalCount + } + onChange={(checked) => onSelect?.(!checked || checked === null ? noneOption : allOption)} + {...menuToggleCheckboxProps} + />, + selectedCount > 0 ? ( + + {`${selectedCount} selected`} + + ) : null + ] + }} + /> + )} + {...props} + > + {splitButtonDropdownItems} + + ); +}; + +export default BulkSelect; diff --git a/packages/module/src/BulkSelect/__snapshots__/BulkSelect.test.tsx.snap b/packages/module/src/BulkSelect/__snapshots__/BulkSelect.test.tsx.snap new file mode 100644 index 00000000..6ea114cf --- /dev/null +++ b/packages/module/src/BulkSelect/__snapshots__/BulkSelect.test.tsx.snap @@ -0,0 +1,178 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BulkSelect component should render 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+ +

+ 2 selected +

+ +
+
+ , + "container":
+
+ +

+ 2 selected +

+ +
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/module/src/BulkSelect/index.ts b/packages/module/src/BulkSelect/index.ts new file mode 100644 index 00000000..1ffd7343 --- /dev/null +++ b/packages/module/src/BulkSelect/index.ts @@ -0,0 +1,2 @@ +export { default } from './BulkSelect'; +export * from './BulkSelect'; diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index 03a83e87..eefe1ab4 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -12,6 +12,9 @@ export * from './Ansible'; export { default as Battery } from './Battery'; export * from './Battery'; +export { default as BulkSelect } from './BulkSelect'; +export * from './BulkSelect'; + export { default as CloseButton } from './CloseButton'; export * from './CloseButton';