From 6d40b679f13b7e93312100cda58394fa8b738650 Mon Sep 17 00:00:00 2001 From: Jakub007d Date: Thu, 18 Dec 2025 13:27:52 +0100 Subject: [PATCH 1/2] feat: adding DataViewTreeFilter --- .../data-view/examples/Toolbar/Toolbar.md | 9 +- .../examples/Toolbar/TreeFilterExample.tsx | 267 ++++++++++++++++++ .../module/patternfly-docs/generated/index.js | 4 +- .../DataViewTreeFilter/DataViewTreeFilter.tsx | 172 +++++++++++ .../module/src/DataViewTreeFilter/index.ts | 2 + packages/module/src/index.ts | 3 + 6 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/TreeFilterExample.tsx create mode 100644 packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx create mode 100644 packages/module/src/DataViewTreeFilter/index.ts diff --git a/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/Toolbar.md b/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/Toolbar.md index d209d228..4419dd4c 100644 --- a/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/Toolbar.md +++ b/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/Toolbar.md @@ -12,7 +12,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: 2 -propComponents: ['DataViewToolbar', 'DataViewFilters', 'DataViewTextFilter', 'DataViewCheckboxFilter'] +propComponents: ['DataViewToolbar', 'DataViewFilters', 'DataViewTextFilter', 'DataViewCheckboxFilter', 'DataViewTreeFilter'] sourceLink: https://github.com/patternfly/react-data-view/blob/main/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/Toolbar.md --- import { useMemo, useState, useEffect } from 'react'; @@ -26,6 +26,7 @@ import { DataViewTable } from '@patternfly/react-data-view/dist/dynamic/DataView 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'; +import { DataViewTreeFilter } from '@patternfly/react-data-view/dist/dynamic/DataViewTreeFilter'; The **data view toolbar** component renders a default opinionated data view toolbar above or below the data section. @@ -143,6 +144,12 @@ This example demonstrates the setup and usage of filters within the data view. I ``` +### Tree filter example +This example demonstrates the usage of a tree filter with hierarchical data. The tree filter allows users to select items from a nested structure, making it ideal for categorized or grouped filtering options. + +```js file="./TreeFilterExample.tsx" + +``` ## All/selected data switch All/selected data switch allows users to toggle between displaying the entire table (All) and only the rows they have selected (Selected). If the user deselects the last selected row, the filter automatically switches back to All, displaying all table rows again. Until at least one row is selected, a tooltip with guidance is displayed, and the Selected button does not perform any action. The Selected button is intentionally not disabled for accessibility reasons but instead has `aria-disabled` set. diff --git a/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/TreeFilterExample.tsx b/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/TreeFilterExample.tsx new file mode 100644 index 00000000..2313870e --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/TreeFilterExample.tsx @@ -0,0 +1,267 @@ +import React, { useMemo } from 'react'; +import { Pagination } from '@patternfly/react-core'; +import { BrowserRouter, useSearchParams } from 'react-router-dom'; +import { TreeViewDataItem } from '@patternfly/react-core'; +import { useDataViewFilters, useDataViewPagination } from '@patternfly/react-data-view/dist/dynamic/Hooks'; +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 { DataViewTreeFilter } from '@patternfly/react-data-view/dist/dynamic/DataViewTreeFilter'; + +const perPageOptions = [ + { title: '5', value: 5 }, + { title: '10', value: 10 } +]; + +interface Repository { + name: string; + workspace: string; + tags: string[]; + os: string; + lastSeen: string; +} + +interface RepositoryFilters { + name: string; + workspace: string[]; + os: string[]; + tags: string[]; +} + +const repositories: Repository[] = [ + { name: 'Server-001', workspace: 'Development Workspace', tags: ['web', 'frontend'], os: 'Ubuntu 22.04', lastSeen: '2 hours ago' }, + { name: 'Server-002', workspace: 'Development Workspace', tags: ['api', 'backend'], os: 'RHEL 9', lastSeen: '5 hours ago' }, + { name: 'Server-003', workspace: 'Development Workspace', tags: ['database'], os: 'Windows Server 2022', lastSeen: '1 day ago' }, + { name: 'Server-004', workspace: 'Production Workspace', tags: ['web', 'frontend'], os: 'Ubuntu 22.04', lastSeen: '30 minutes ago' }, + { name: 'Server-005', workspace: 'Production Workspace', tags: ['api', 'backend'], os: 'Debian 12', lastSeen: '1 hour ago' }, + { name: 'Server-006', workspace: 'Production Workspace', tags: ['monitoring'], os: 'macOS Ventura', lastSeen: '3 hours ago' }, + { name: 'Server-007', workspace: 'Production Workspace', tags: ['cache'], os: 'macOS Sonoma', lastSeen: '2 days ago' }, + { name: 'Server-008', workspace: 'Testing Workspace', tags: ['test', 'frontend'], os: 'CentOS 8', lastSeen: '6 hours ago' }, + { name: 'Server-009', workspace: 'Testing Workspace', tags: ['test', 'backend'], os: 'Fedora 38', lastSeen: '4 hours ago' } +]; + +const treeOptions: TreeViewDataItem[] = [ + { + name: 'Development Workspace', + id: 'workspace-dev', + checkProps: { 'aria-label': 'dev-workspace-check', checked: false } + }, + { + name: 'Production Workspace', + id: 'workspace-prod', + checkProps: { 'aria-label': 'prod-workspace-check', checked: false } + }, + { + name: 'Testing Workspace', + id: 'workspace-test', + checkProps: { 'aria-label': 'test-workspace-check', checked: false } + } +]; + +const osOptions: TreeViewDataItem[] = [ + { + name: 'Linux', + id: 'os-linux', + checkProps: { 'aria-label': 'linux-check', checked: false }, + children: [ + { + name: 'Ubuntu 22.04', + id: 'os-ubuntu', + checkProps: { checked: false } + }, + { + name: 'RHEL 9', + id: 'os-rhel', + checkProps: { checked: false } + }, + { + name: 'Debian 12', + id: 'os-debian', + checkProps: { checked: false } + }, + { + name: 'CentOS 8', + id: 'os-centos', + checkProps: { checked: false } + }, + { + name: 'Fedora 38', + id: 'os-fedora', + checkProps: { checked: false } + } + ], + defaultExpanded: true + }, + { + name: 'Windows', + id: 'os-windows', + checkProps: { 'aria-label': 'windows-check', checked: false }, + children: [ + { + name: 'Windows Server 2022', + id: 'os-windows-2022', + checkProps: { checked: false } + } + ] + }, + { + name: 'macOS', + id: 'os-macos', + checkProps: { 'aria-label': 'macos-check', checked: false }, + children: [ + { + name: 'macOS Ventura', + id: 'os-macos-ventura', + checkProps: { checked: false } + }, + { + name: 'macOS Sonoma', + id: 'os-macos-sonoma', + checkProps: { checked: false } + } + ] + } +]; + +const tagOptions: TreeViewDataItem[] = [ + { + name: 'Environment', + id: 'tags-env', + checkProps: { 'aria-label': 'env-check', checked: false }, + children: [ + { + name: 'web', + id: 'tag-web', + checkProps: { checked: false } + }, + { + name: 'api', + id: 'tag-api', + checkProps: { checked: false } + }, + { + name: 'database', + id: 'tag-database', + checkProps: { checked: false } + } + ], + defaultExpanded: true + }, + { + name: 'Layer', + id: 'tags-layer', + checkProps: { 'aria-label': 'layer-check', checked: false }, + children: [ + { + name: 'frontend', + id: 'tag-frontend', + checkProps: { checked: false } + }, + { + name: 'backend', + id: 'tag-backend', + checkProps: { checked: false } + } + ] + }, + { + name: 'Other', + id: 'tags-other', + checkProps: { 'aria-label': 'other-check', checked: false }, + children: [ + { + name: 'monitoring', + id: 'tag-monitoring', + checkProps: { checked: false } + }, + { + name: 'cache', + id: 'tag-cache', + checkProps: { checked: false } + }, + { + name: 'test', + id: 'tag-test', + checkProps: { checked: false } + } + ] + } +]; + +const columns = ['Name', 'Workspace', 'Tags', 'OS', 'Last seen']; + +const ouiaId = 'TreeFilterExample'; + +const MyTable: React.FunctionComponent = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const { filters, onSetFilters, clearAllFilters } = useDataViewFilters({ + initialFilters: { name: '', workspace: [], os: [], tags: [] }, + searchParams, + setSearchParams + }); + const pagination = useDataViewPagination({ perPage: 5 }); + const { page, perPage } = pagination; + + const filteredData = useMemo( + () => + repositories.filter( + (item) => + (!filters.name || item.name?.toLocaleLowerCase().includes(filters.name?.toLocaleLowerCase())) && + (!filters.workspace || filters.workspace.length === 0 || filters.workspace.includes(item.workspace)) && + (!filters.os || filters.os.length === 0 || filters.os.includes(item.os)) && + (!filters.tags || filters.tags.length === 0 || filters.tags.some(tag => item.tags.includes(tag))) + ), + [filters] + ); + + const pageRows = useMemo( + () => + filteredData + .slice((page - 1) * perPage, (page - 1) * perPage + perPage) + .map((item) => [item.name, item.workspace, item.tags.join(', '), item.os, item.lastSeen]), + [page, perPage, filteredData] + ); + + return ( + + } + filters={ + onSetFilters(values)} values={filters}> + + + + + } + /> + + + } + /> + + ); +}; + +export const TreeFilterExample: React.FunctionComponent = () => ( + + + +); diff --git a/packages/module/patternfly-docs/generated/index.js b/packages/module/patternfly-docs/generated/index.js index be42c242..7d67102f 100644 --- a/packages/module/patternfly-docs/generated/index.js +++ b/packages/module/patternfly-docs/generated/index.js @@ -2,8 +2,8 @@ module.exports = { '/extensions/data-view/toolbar/react': { id: "Toolbar", title: "Data view toolbar", - toc: [[{"text":"Toolbar example"}],{"text":"Toolbar actions"},[{"text":"Actions example"}],{"text":"Pagination"},[{"text":"Pagination state"},{"text":"Pagination example"}],{"text":"Selection"},[{"text":"Selection state"},{"text":"Selection example"}],{"text":"Filters"},[{"text":"Filters state"},{"text":"Filtering example"}],{"text":"All/selected data switch"},[{"text":"All/selected example"}]], - examples: ["Toolbar example","Actions example","Pagination example","Selection example","Filtering example","All/selected example"], + toc: [[{"text":"Toolbar example"}],{"text":"Toolbar actions"},[{"text":"Actions example"}],{"text":"Pagination"},[{"text":"Pagination state"},{"text":"Pagination example"}],{"text":"Selection"},[{"text":"Selection state"},{"text":"Selection example"}],{"text":"Filters"},[{"text":"Filters state"},{"text":"Filtering example"},{"text":"Tree filter example"}],{"text":"All/selected data switch"},[{"text":"All/selected example"}]], + examples: ["Toolbar example","Actions example","Pagination example","Selection example","Filtering example","Tree filter example","All/selected example"], section: "extensions", subsection: "Data view", source: "react", diff --git a/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx b/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx new file mode 100644 index 00000000..a93d0e5a --- /dev/null +++ b/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx @@ -0,0 +1,172 @@ +import { Dropdown, MenuToggle, MenuToggleElement, ToolbarFilter, ToolbarFilterProps, TreeView, TreeViewDataItem } from '@patternfly/react-core' +import React, { FC, useState, useRef } from 'react' + +export interface DataViewTreeFilterProps { + /** Unique key for the filter attribute */ + filterId: string; + /** Array of current filter values */ + value?: string[]; + /** Filter title displayed in the toolbar */ + title: string; + /** Callback for when the selection changes */ + onChange?: (event?: React.MouseEvent, values?: string[]) => void; + /** Callback for when filters are cleared */ + onClear?: () => void; + /** Controls visibility of the filter in the toolbar */ + showToolbarItem?: ToolbarFilterProps['showToolbarItem']; + /** Custom OUIA ID */ + ouiaId?: string; + /** Hierarchical data items for the tree structure */ + items?: TreeViewDataItem[]; +} + +const DataViewTreeFilter: FC = ({ + filterId, + title, + value = [], + onChange, + showToolbarItem, + ouiaId = 'DataViewTreeFilter', + items +}: DataViewTreeFilterProps) => { + const [isOpen, setIsOpen] = useState(false); + const [treeData, setTreeData] = useState(items || []); + const menuRef = useRef(null); + + const areAllChildrenChecked = (item: TreeViewDataItem): boolean => { + if (!item.children || item.children.length === 0) { + return item.checkProps?.checked === true; + } + return item.children.every(child => areAllChildrenChecked(child)); + }; + + const findParentById = (items: TreeViewDataItem[], childId: string): TreeViewDataItem | null => { + for (const item of items) { + if (item.children) { + if (item.children.some(child => child.id === childId)) { + return item; + } + const found = findParentById(item.children, childId); + if (found) return found; + } + } + return null; + }; + + const areSomeChildrenChecked = (item: TreeViewDataItem): boolean => { + if (!item.children || item.children.length === 0) { + return item.checkProps?.checked === true; + } + return item.children.some(child => areSomeChildrenChecked(child)); + }; + + const getAllCheckedLeafItems = (items: TreeViewDataItem[]): string[] => { + const checkedLeafNames: string[] = []; + + const collectCheckedLeafs = (item: TreeViewDataItem) => { + if (!item.children || item.children.length === 0) { + if (item.checkProps?.checked === true) { + checkedLeafNames.push(String(item.name)); + } + } else { + item.children.forEach(child => collectCheckedLeafs(child)); + } + }; + + items.forEach(item => collectCheckedLeafs(item)); + return checkedLeafNames; + }; + + const onCheckParentHandle = (childId: string) => { + const parent = findParentById(treeData, childId); + + if (!parent) { + return; + } + + const allChildrenChecked = areAllChildrenChecked(parent); + const someChildrenChecked = areSomeChildrenChecked(parent); + + if (parent.checkProps) { + if (allChildrenChecked) { + parent.checkProps.checked = true; + } else if (someChildrenChecked) { + parent.checkProps.checked = null; + } else { + parent.checkProps.checked = false; + } + } + + if (parent.id) { + onCheckParentHandle(parent.id); + } + }; + const onCheckHandle = (treeViewItem: TreeViewDataItem, checked: boolean) =>{ + if(treeViewItem.children) + { + treeViewItem.children.forEach(child =>{ + onCheckHandle(child,checked) + }) + } + if(treeViewItem.checkProps) { + treeViewItem.checkProps.checked = checked; + } + }; + + const onCheck = (event: React.ChangeEvent, treeViewItem: TreeViewDataItem) => { + const checked = (event.target as HTMLInputElement).checked; + + onCheckHandle(treeViewItem, checked); + + if (treeViewItem.id) { + onCheckParentHandle(treeViewItem.id); + } + const updatedTreeData = [...treeData]; + setTreeData(updatedTreeData); + + const selectedValues = getAllCheckedLeafItems(updatedTreeData); + if (onChange) { + onChange(event as any, selectedValues); + } + }; + + const dropdown = ( + setIsOpen(isOpen)} + toggle={(toggleRef: React.Ref) => ( + setIsOpen(!isOpen)} isExpanded={isOpen}> + {title} + + )} + ouiaId={ouiaId} + shouldFocusToggleOnSelect + > + + + ); + + return ( + ({ key: item, node: item }))} + deleteLabel={(_, label) => { + const labelKey = typeof label === 'string' ? label : label.key; + onChange?.(undefined, value.filter(item => item !== labelKey)); + }} + categoryName={title} + showToolbarItem={showToolbarItem}> + {dropdown} + + ) +} + +export { DataViewTreeFilter }; +export default DataViewTreeFilter; \ No newline at end of file diff --git a/packages/module/src/DataViewTreeFilter/index.ts b/packages/module/src/DataViewTreeFilter/index.ts new file mode 100644 index 00000000..7faa9ae6 --- /dev/null +++ b/packages/module/src/DataViewTreeFilter/index.ts @@ -0,0 +1,2 @@ +export { default } from './DataViewTreeFilter'; +export * from './DataViewTreeFilter'; \ No newline at end of file diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index aca7f761..d09fbc5c 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -4,6 +4,9 @@ export { default as InternalContext } from './InternalContext'; export * from './InternalContext'; export * from './Hooks'; +export { default as DataViewTreeFilter } from './DataViewTreeFilter'; +export * from './DataViewTreeFilter'; + export { default as DataViewToolbar } from './DataViewToolbar'; export * from './DataViewToolbar'; From 48ae3b79b8487f349eeab042b286e1c65de0030e Mon Sep 17 00:00:00 2001 From: Jakub007d Date: Thu, 18 Dec 2025 13:41:56 +0100 Subject: [PATCH 2/2] fix: fixing unwanted value change in treefilter --- packages/module/src/DataViewFilters/DataViewFilters.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/module/src/DataViewFilters/DataViewFilters.tsx b/packages/module/src/DataViewFilters/DataViewFilters.tsx index dc0dc218..d750452e 100644 --- a/packages/module/src/DataViewFilters/DataViewFilters.tsx +++ b/packages/module/src/DataViewFilters/DataViewFilters.tsx @@ -62,7 +62,7 @@ export const DataViewFilters = ({ useEffect(() => { filterItems.length > 0 && setActiveAttributeMenu(filterItems[0].title); - }, [ filterItems ]); + }, [ filterItems.length ]); const handleClickOutside = (event: MouseEvent) => isAttributeMenuOpen &&