diff --git a/cypress/component/DataViewCheckboxFilter.cy.tsx b/cypress/component/DataViewCheckboxFilter.cy.tsx new file mode 100644 index 0000000..eab26fb --- /dev/null +++ b/cypress/component/DataViewCheckboxFilter.cy.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { DataViewCheckboxFilter, DataViewCheckboxFilterProps } from '@patternfly/react-data-view/dist/dynamic/DataViewCheckboxFilter'; +import { DataViewToolbar } from '@patternfly/react-data-view/dist/dynamic/DataViewToolbar'; + +describe('DataViewCheckboxFilter component', () => { + const defaultProps: DataViewCheckboxFilterProps = { + filterId: 'test-checkbox-filter', + title: 'Test checkbox filter', + value: [ 'workspace-one' ], + options: [ + { label: 'Workspace one', value: 'workspace-one' }, + { label: 'Workspace two', value: 'workspace-two' }, + { label: 'Workspace three', value: 'workspace-three' }, + ], + }; + + it('renders a checkbox filter with options', () => { + const onChange = cy.stub().as('onChange'); + + cy.mount( + } /> + ); + + cy.get('[data-ouia-component-id="DataViewCheckboxFilter-toggle"]') + .contains('Test checkbox filter') + .should('be.visible'); + + cy.get('[data-ouia-component-id="DataViewCheckboxFilter-badge"]') + .should('exist') + .contains('1'); + + cy.get('[data-ouia-component-id="DataViewCheckboxFilter-toggle"]').click(); + cy.get('[data-ouia-component-id="DataViewCheckboxFilter-menu"]').should('be.visible'); + + cy.get('[data-ouia-component-id="DataViewCheckboxFilter-menu"]') + .find('li') + .should('have.length', 3) + .first() + .contains('Workspace one'); + + cy.get('[data-ouia-component-id="DataViewCheckboxFilter-menu"]') + .find('li') + .first() + .find('input[type="checkbox"]') + .should('be.checked'); + + cy.get('[data-ouia-component-id="DataViewCheckboxFilter-menu"]') + .find('li') + .eq(1) + .find('input[type="checkbox"]') + .click(); + + cy.get('@onChange').should('have.been.calledWith', Cypress.sinon.match.object, [ 'workspace-two', 'workspace-one' ]); + }); + + it('renders a checkbox filter with no options selected', () => { + const emptyProps = { ...defaultProps, value: [] }; + + cy.mount( + } /> + ); + + cy.get('[data-ouia-component-id="DataViewCheckboxFilter-toggle"]').contains('Test checkbox filter'); + cy.get('[data-ouia-component-id="DataViewCheckboxFilter-badge"]').should('not.exist'); + }); +}); diff --git a/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/FiltersExample.tsx b/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/FiltersExample.tsx index 46cec1e..afdbcac 100644 --- a/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/FiltersExample.tsx +++ b/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/FiltersExample.tsx @@ -5,8 +5,9 @@ import { useDataViewFilters, useDataViewPagination } from '@patternfly/react-dat import { DataView } from '@patternfly/react-data-view/dist/dynamic/DataView'; import { DataViewTable } from '@patternfly/react-data-view/dist/dynamic/DataViewTable'; import { DataViewToolbar } from '@patternfly/react-data-view/dist/dynamic/DataViewToolbar'; -import { DataViewFilters } from '@patternfly/react-data-view/dist/dynamic/DataViewFilters'; +import { DataViewFilterOption, DataViewFilters } from '@patternfly/react-data-view/dist/dynamic/DataViewFilters'; import { DataViewTextFilter } from '@patternfly/react-data-view/dist/dynamic/DataViewTextFilter'; +import { DataViewCheckboxFilter } from '@patternfly/react-data-view/dist/dynamic/DataViewCheckboxFilter'; const perPageOptions = [ { title: '5', value: 5 }, @@ -17,38 +18,51 @@ interface Repository { name: string; branch: string | null; prs: string | null; - workspaces: string; + workspace: string; lastCommit: string; } interface RepositoryFilters { name: string, - branch: string + branch: string, + workspace: string[] } const repositories: Repository[] = [ - { name: 'Repository one', branch: 'Branch one', prs: 'Pull request one', workspaces: 'Workspace one', lastCommit: 'Timestamp one' }, - { name: 'Repository two', branch: 'Branch two', prs: 'Pull request two', workspaces: 'Workspace two', lastCommit: 'Timestamp two' }, - { name: 'Repository three', branch: 'Branch three', prs: 'Pull request three', workspaces: 'Workspace three', lastCommit: 'Timestamp three' }, - { name: 'Repository four', branch: 'Branch four', prs: 'Pull request four', workspaces: 'Workspace four', lastCommit: 'Timestamp four' }, - { name: 'Repository five', branch: 'Branch five', prs: 'Pull request five', workspaces: 'Workspace five', lastCommit: 'Timestamp five' }, - { name: 'Repository six', branch: 'Branch six', prs: 'Pull request six', workspaces: 'Workspace six', lastCommit: 'Timestamp six' } + { name: 'Repository one', branch: 'Branch one', prs: 'Pull request one', workspace: 'Workspace one', lastCommit: 'Timestamp one' }, + { name: 'Repository two', branch: 'Branch two', prs: 'Pull request two', workspace: 'Workspace two', lastCommit: 'Timestamp two' }, + { name: 'Repository three', branch: 'Branch three', prs: 'Pull request three', workspace: 'Workspace one', lastCommit: 'Timestamp three' }, + { name: 'Repository four', branch: 'Branch four', prs: 'Pull request four', workspace: 'Workspace one', lastCommit: 'Timestamp four' }, + { name: 'Repository five', branch: 'Branch five', prs: 'Pull request five', workspace: 'Workspace two', lastCommit: 'Timestamp five' }, + { name: 'Repository six', branch: 'Branch six', prs: 'Pull request six', workspace: 'Workspace three', lastCommit: 'Timestamp six' } ]; -const columns = [ 'Name', 'Branch', 'Pull requests', 'Workspaces', 'Last commit' ]; +const filterOptions: DataViewFilterOption[] = [ + { label: 'Workspace one', value: 'workspace-one' }, + { label: 'Workspace two', value: 'workspace-two' }, + { label: 'Workspace three', value: 'workspace-three' } +]; + +const columns = [ 'Name', 'Branch', 'Pull requests', 'Workspace', 'Last commit' ]; const ouiaId = 'LayoutExample'; const MyTable: React.FunctionComponent = () => { const [ searchParams, setSearchParams ] = useSearchParams(); + const { filters, onSetFilters, clearAllFilters } = useDataViewFilters({ initialFilters: { name: '', branch: '', workspace: [] }, searchParams, setSearchParams }); const pagination = useDataViewPagination({ perPage: 5 }); const { page, perPage } = pagination; - const { filters, onSetFilters, clearAllFilters } = useDataViewFilters({ initialFilters: { name: '', branch: '' }, searchParams, setSearchParams }); - const pageRows = useMemo(() => repositories - .filter(item => (!filters.name || item.name?.toLocaleLowerCase().includes(filters.name?.toLocaleLowerCase())) && (!filters.branch || item.branch?.toLocaleLowerCase().includes(filters.branch?.toLocaleLowerCase()))) + const filteredData = useMemo(() => repositories.filter(item => + (!filters.name || item.name?.toLocaleLowerCase().includes(filters.name?.toLocaleLowerCase())) && + (!filters.branch || item.branch?.toLocaleLowerCase().includes(filters.branch?.toLocaleLowerCase())) && + (!filters.workspace || filters.workspace.length === 0 || filters.workspace.includes(String(filterOptions.find(option => option.label === item.workspace)?.value))) + ), [ filters ]); + + const pageRows = useMemo(() => filteredData .slice((page - 1) * perPage, ((page - 1) * perPage) + perPage) - .map(item => Object.values(item)), [ page, perPage, filters ]); + .map(item => Object.values(item)), + [ page, perPage, filteredData ]); return ( @@ -58,7 +72,7 @@ const MyTable: React.FunctionComponent = () => { pagination={ } @@ -66,6 +80,7 @@ const MyTable: React.FunctionComponent = () => { onSetFilters(values)} values={filters}> + } /> @@ -76,7 +91,7 @@ const MyTable: React.FunctionComponent = () => { } diff --git a/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/Functionality.md b/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/Functionality.md index cf26c55..1e31d79 100644 --- a/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/Functionality.md +++ b/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/Functionality.md @@ -11,7 +11,7 @@ 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 sortValue: 3 -propComponents: ['DataViewFilters', 'DataViewTextFilter'] +propComponents: ['DataViewFilters', 'DataViewTextFilter', 'DataViewCheckboxFilter'] sourceLink: https://github.com/patternfly/react-data-view/blob/main/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/Functionality.md --- import { useMemo } from 'react'; @@ -23,6 +23,7 @@ import { DataViewToolbar } from '@patternfly/react-data-view/dist/dynamic/DataVi import { DataViewTable } from '@patternfly/react-data-view/dist/dynamic/DataViewTable'; import { DataViewFilters } from '@patternfly/react-data-view/dist/dynamic/DataViewFilters'; import { DataViewTextFilter } from '@patternfly/react-data-view/dist/dynamic/DataViewTextFilter'; +import { DataViewCheckboxFilter } from '@patternfly/react-data-view/dist/dynamic/DataViewCheckboxFilter'; This is a list of functionality you can use to manage data displayed in the **data view**. @@ -92,7 +93,7 @@ The `useDataViewSelection` hook manages the selection state of the data view. Enables filtering of data records in the data view and displays the applied filter chips. ### Toolbar usage -The data view toolbar can include a set of filters by passing a React node to the `filters` property. You can use predefined components `DataViewFilters` and `DataViewTextFilter` to customize and handle filtering directly in the toolbar. The `DataViewFilters` is a wrapper allowing conditional filtering using multiple attributes. If you need just a single filter, you can use `DataViewTextFilter` or a different filter component alone. Props of these filter components are listed at the bottom of this page. +The data view toolbar can include a set of filters by passing a React node to the `filters` property. You can use predefined components `DataViewFilters`, `DataViewTextFilter` and `DataViewCheckboxFilter` to customize and handle filtering directly in the toolbar. The `DataViewFilters` is a wrapper allowing conditional filtering using multiple attributes. If you need just a single filter, you can use `DataViewTextFilter`, `DataViewCheckboxFilter` or a different filter component alone. Props of these filter components are listed at the bottom of this page. You can decide between passing `value` and `onChange` event to every filter separately or pass `values` and `onChange` to the `DataViewFilters` wrapper which make them available to its children. Props directly passed to child filters have a higher priority than the "inherited" ones. @@ -101,7 +102,7 @@ You can decide between passing `value` and `onChange` event to every filter sepa The `useDataViewFilters` hook manages the filter state of the data view. It allows you to define default filter values, synchronize filter state with URL parameters, and handle filter changes efficiently. **Initial values:** -- `initialFilters` object with default filter values +- `initialFilters` object with default filter values (if the filter param allows multiple values, pass an array) - optional `searchParams` object for managing URL-based filter state - optional `setSearchParams` function to update the URL when filters are modified diff --git a/packages/module/src/DataViewCheckboxFilter/DataViewCheckboxFilter.test.tsx b/packages/module/src/DataViewCheckboxFilter/DataViewCheckboxFilter.test.tsx new file mode 100644 index 0000000..2290651 --- /dev/null +++ b/packages/module/src/DataViewCheckboxFilter/DataViewCheckboxFilter.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import DataViewCheckboxFilter, { DataViewCheckboxFilterProps } from './DataViewCheckboxFilter'; +import DataViewToolbar from '../DataViewToolbar'; + +describe('DataViewCheckboxFilter component', () => { + const defaultProps: DataViewCheckboxFilterProps = { + filterId: 'test-checkbox-filter', + title: 'Test Checkbox Filter', + value: [ 'workspace-one' ], + options: [ + { label: 'Workspace one', value: 'workspace-one' }, + { label: 'Workspace two', value: 'workspace-two' }, + { label: 'Workspace three', value: 'workspace-three' }, + ], + }; + + it('should render correctly', () => { + const { container } = render( + } /> + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/module/src/DataViewCheckboxFilter/DataViewCheckboxFilter.tsx b/packages/module/src/DataViewCheckboxFilter/DataViewCheckboxFilter.tsx new file mode 100644 index 0000000..7235165 --- /dev/null +++ b/packages/module/src/DataViewCheckboxFilter/DataViewCheckboxFilter.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { + Badge, + Menu, + MenuContent, + MenuItem, + MenuList, + MenuProps, + MenuToggle, + Popper, + ToolbarChip, + ToolbarFilter, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; +import { DataViewFilterOption } from '../DataViewFilters'; + +const isToolbarChip = (chip: string | ToolbarChip): chip is ToolbarChip => + typeof chip === 'object' && 'key' in chip; + +export const isDataViewFilterOption = (obj: unknown): obj is DataViewFilterOption => + !!obj && + typeof obj === 'object' && + 'label' in obj && + 'value' in obj && + typeof (obj as DataViewFilterOption).value === 'string'; + +/** extends MenuProps */ +export interface DataViewCheckboxFilterProps extends Omit { + /** Unique key for the filter attribute */ + filterId: string; + /** Array of current filter values */ + value?: string[]; + /** Filter title displayed in the toolbar */ + title: string; + /** Placeholder text of the menu */ + placeholder?: string; + /** Filter options displayed */ + options: (DataViewFilterOption | string)[]; + /** Callback for updating when item selection changes. */ + onChange?: (event?: React.MouseEvent, values?: string[]) => void; + /** Controls visibility of the filter in the toolbar */ + showToolbarItem?: boolean; + /** Controls visibility of the filter icon */ + showIcon?: boolean; + /** Controls visibility of the selected items badge */ + showBadge?: boolean; + /** Custom OUIA ID */ + ouiaId?: string; +} + +export const DataViewCheckboxFilter: React.FC = ({ + filterId, + title, + value = [], + onChange, + placeholder, + options = [], + showToolbarItem, + showIcon = !placeholder, + showBadge = !placeholder, + ouiaId = 'DataViewCheckboxFilter', + ...props +}: DataViewCheckboxFilterProps) => { + const [ isOpen, setIsOpen ] = React.useState(false); + const toggleRef = React.useRef(null); + const menuRef = React.useRef(null); + const containerRef = React.useRef(null); + + const normalizeOptions = React.useMemo( + () => + options.map(option => + typeof option === 'string' + ? { label: option, value: option } + : option + ), + [ options ] + ); + + const handleToggleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + setTimeout(() => { + const firstElement = menuRef.current?.querySelector('li > button:not(:disabled)') as HTMLElement; + firstElement?.focus(); + }, 0); + setIsOpen(prev => !prev); + }; + + const handleSelect = (event?: React.MouseEvent, itemId?: string | number) => { + const activeItem = String(itemId); + const isSelected = value.includes(activeItem); + + onChange?.( + event, + isSelected ? value.filter(item => item !== activeItem) : [ activeItem, ...value ] + ); + }; + + const handleClickOutside = (event: MouseEvent) => + isOpen && + menuRef.current && toggleRef.current && + !menuRef.current.contains(event.target as Node) && !toggleRef.current.contains(event.target as Node) + && setIsOpen(false); + + + React.useEffect(() => { + window.addEventListener('click', handleClickOutside); + return () => { + window.removeEventListener('click', handleClickOutside); + }; + }, [ isOpen ]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + { + const activeOption = normalizeOptions.find(option => option.value === item); + return ({ key: activeOption?.value as string, node: activeOption?.label }) + })} + deleteChip={(_, chip) => + onChange?.(undefined, value.filter(item => item !== (isToolbarChip(chip) ? chip.key : chip))) + } + categoryName={title} + showToolbarItem={showToolbarItem} + > + : undefined} + badge={value.length > 0 && showBadge ? {value.length} : undefined} + style={{ width: '200px' }} + > + {placeholder ?? title} + + } + triggerRef={toggleRef} + popper={ + + + + {normalizeOptions.map(option => ( + + {option.label} + + ))} + + + + } + popperRef={menuRef} + appendTo={containerRef.current || undefined} + aria-label={`${title ?? filterId} filter`} + isVisible={isOpen} + /> + + ); +}; + +export default DataViewCheckboxFilter; diff --git a/packages/module/src/DataViewCheckboxFilter/__snapshots__/DataViewCheckboxFilter.test.tsx.snap b/packages/module/src/DataViewCheckboxFilter/__snapshots__/DataViewCheckboxFilter.test.tsx.snap new file mode 100644 index 0000000..e6cede5 --- /dev/null +++ b/packages/module/src/DataViewCheckboxFilter/__snapshots__/DataViewCheckboxFilter.test.tsx.snap @@ -0,0 +1,194 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataViewCheckboxFilter component should render correctly 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + Test Checkbox Filter + +
    +
  • +
    + + + Workspace one + + + + + +
    +
  • +
+
+
+
+
+
+ +
+
+
+
+`; diff --git a/packages/module/src/DataViewCheckboxFilter/index.ts b/packages/module/src/DataViewCheckboxFilter/index.ts new file mode 100644 index 0000000..218b16f --- /dev/null +++ b/packages/module/src/DataViewCheckboxFilter/index.ts @@ -0,0 +1,2 @@ +export { default } from './DataViewCheckboxFilter'; +export * from './DataViewCheckboxFilter'; diff --git a/packages/module/src/DataViewFilters/DataViewFilters.tsx b/packages/module/src/DataViewFilters/DataViewFilters.tsx index 29e950e..addc63f 100644 --- a/packages/module/src/DataViewFilters/DataViewFilters.tsx +++ b/packages/module/src/DataViewFilters/DataViewFilters.tsx @@ -1,9 +1,16 @@ -import React, { useMemo, useState, useRef, useEffect, ReactElement } from 'react'; +import React, { useMemo, useState, useRef, useEffect, ReactElement, ReactNode } from 'react'; import { Menu, MenuContent, MenuItem, MenuList, MenuToggle, Popper, ToolbarGroup, ToolbarToggleGroup, ToolbarToggleGroupProps, } from '@patternfly/react-core'; import { FilterIcon } from '@patternfly/react-icons'; +export interface DataViewFilterOption { + /** Filter option label */ + label: ReactNode; + /** Filter option value */ + value: string; +} + // helper interface to generate attribute menu interface DataViewFilterIdentifiers { filterId: string; @@ -57,6 +64,19 @@ export const DataViewFilters = ({ filterItems.length > 0 && setActiveAttributeMenu(filterItems[0].title); }, [ filterItems ]); + const handleClickOutside = (event: MouseEvent) => + isAttributeMenuOpen && + !attributeMenuRef.current?.contains(event.target as Node) && + !attributeToggleRef.current?.contains(event.target as Node) + && setIsAttributeMenuOpen(false); + + useEffect(() => { + window.addEventListener('click', handleClickOutside); + return () => { + window.removeEventListener('click', handleClickOutside); + }; + }, [ isAttributeMenuOpen ]); // eslint-disable-line react-hooks/exhaustive-deps + const attributeToggle = ( ({ isVisible={isAttributeMenuOpen} /> - {React.Children.map(children, (child) => ( - React.isValidElement(child) ? ( - React.cloneElement(child as ReactElement<{ + {React.Children.map(children, (child) => + React.isValidElement(child) + ? React.cloneElement(child as ReactElement<{ showToolbarItem: boolean; onChange: (_e: unknown, values: unknown) => void; value: unknown; @@ -114,9 +134,8 @@ export const DataViewFilters = ({ value: values?.[child.props.filterId], ...child.props }) - ) : child - ))} - + : child + )} ); diff --git a/packages/module/src/DataViewTextFilter/DataViewTextFilter.tsx b/packages/module/src/DataViewTextFilter/DataViewTextFilter.tsx index e2d1e43..ce49a3d 100644 --- a/packages/module/src/DataViewTextFilter/DataViewTextFilter.tsx +++ b/packages/module/src/DataViewTextFilter/DataViewTextFilter.tsx @@ -31,6 +31,7 @@ export const DataViewTextFilter: React.FC = ({ ...props }: DataViewTextFilterProps) => ( 0 ? [ { key: title, node: value } ] : []} deleteChip={() => onChange?.(undefined, '')} diff --git a/packages/module/src/Hooks/filters.ts b/packages/module/src/Hooks/filters.ts index 4f81e17..177e5f3 100644 --- a/packages/module/src/Hooks/filters.ts +++ b/packages/module/src/Hooks/filters.ts @@ -16,15 +16,19 @@ export const useDataViewFilters = ({ }: UseDataViewFiltersProps) => { const isUrlSyncEnabled = useMemo(() => searchParams && !!setSearchParams, [ searchParams, setSearchParams ]); - const getInitialFilters = useCallback((): T => isUrlSyncEnabled ? Object.keys(initialFilters).reduce((loadedFilters, key) => { - const urlValue = searchParams?.get(key); - loadedFilters[key as keyof T] = urlValue - ? (urlValue as T[keyof T] | T[keyof T]) - : initialFilters[key as keyof T]; - return loadedFilters; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, { ...initialFilters }) : initialFilters, [ isUrlSyncEnabled, JSON.stringify(initialFilters), searchParams?.toString() ]); + const getInitialFilters = useCallback((): T => isUrlSyncEnabled + ? Object.keys(initialFilters).reduce((loadedFilters, key) => { + const urlValue = searchParams?.get(key); + const isArrayFilter = Array.isArray(initialFilters[key]); + // eslint-disable-next-line no-nested-ternary + loadedFilters[key] = urlValue + ? (isArrayFilter && !Array.isArray(urlValue) ? [ urlValue ] : urlValue) + : initialFilters[key]; + + return loadedFilters; + }, { ...initialFilters }) + : initialFilters, [ isUrlSyncEnabled, initialFilters, searchParams ]); const [ filters, setFilters ] = useState(getInitialFilters()); const updateSearchParams = useCallback( @@ -32,11 +36,8 @@ export const useDataViewFilters = ({ if (isUrlSyncEnabled) { const params = new URLSearchParams(searchParams); Object.entries(newFilters).forEach(([ key, value ]) => { - if (value) { - params.set(key, Array.isArray(value) ? value.join(',') : value); - } else { - params.delete(key); - } + params.delete(key); + (Array.isArray(value) ? value : [ value ]).forEach((val) => value && params.append(key, val)); }); setSearchParams?.(params); } diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index a34586c..aca7f76 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -25,5 +25,8 @@ export * from './DataViewTable'; export { default as DataViewEventsContext } from './DataViewEventsContext'; export * from './DataViewEventsContext'; +export { default as DataViewCheckboxFilter } from './DataViewCheckboxFilter'; +export * from './DataViewCheckboxFilter'; + export { default as DataView } from './DataView'; export * from './DataView';