From 5c863e25ee7660cfdf0ebe921ae8128cb47e7b47 Mon Sep 17 00:00:00 2001 From: Jakub007d Date: Thu, 18 Dec 2025 13:27:52 +0100 Subject: [PATCH 1/3] feat: adding data view tree filter --- .../data-view/examples/Toolbar/Toolbar.md | 9 +- .../examples/Toolbar/TreeFilterExample.tsx | 266 +++++++++++++ .../module/patternfly-docs/generated/index.js | 4 +- .../src/DataViewFilters/DataViewFilters.tsx | 2 +- .../DataViewTreeFilter.test.tsx | 50 +++ .../DataViewTreeFilter/DataViewTreeFilter.tsx | 348 ++++++++++++++++++ .../DataViewTreeFilter.test.tsx.snap | 167 +++++++++ .../module/src/DataViewTreeFilter/index.ts | 2 + packages/module/src/index.ts | 3 + 9 files changed, 847 insertions(+), 4 deletions(-) create mode 100644 packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/TreeFilterExample.tsx create mode 100644 packages/module/src/DataViewTreeFilter/DataViewTreeFilter.test.tsx create mode 100644 packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx create mode 100644 packages/module/src/DataViewTreeFilter/__snapshots__/DataViewTreeFilter.test.tsx.snap 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..0ac0f3db --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/TreeFilterExample.tsx @@ -0,0 +1,266 @@ +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: '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}> + { + console.log('Selected OS items:', selectedItems); + }} + /> + { + console.log('Selected tag items:', selectedItems); + }} + /> + + } + /> + + + } + /> + + ); +}; + +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/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 && diff --git a/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.test.tsx b/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.test.tsx new file mode 100644 index 00000000..cf5eeb6e --- /dev/null +++ b/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.test.tsx @@ -0,0 +1,50 @@ +import { render } from '@testing-library/react'; +import DataViewTreeFilter, { DataViewTreeFilterProps } from './DataViewTreeFilter'; +import DataViewToolbar from '../DataViewToolbar'; +import { TreeViewDataItem } from '@patternfly/react-core'; + +describe('DataViewTreeFilter component', () => { + const treeItems: 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: 'Operating Systems', + id: 'os-parent', + checkProps: { 'aria-label': 'os-check', checked: false }, + children: [ + { + name: 'Linux', + id: 'os-linux', + checkProps: { checked: false } + }, + { + name: 'Windows', + id: 'os-windows', + checkProps: { checked: false } + } + ] + } + ]; + + const defaultProps: DataViewTreeFilterProps = { + filterId: 'test-tree-filter', + title: 'Test Tree Filter', + value: ['Linux'], + items: treeItems + }; + + it('should render correctly', () => { + const { container } = render( + } /> + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx b/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx new file mode 100644 index 00000000..27400907 --- /dev/null +++ b/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx @@ -0,0 +1,348 @@ +import { Dropdown, MenuToggle, MenuToggleElement, ToolbarFilter, ToolbarFilterProps, TreeView, TreeViewDataItem } from '@patternfly/react-core' +import React, { FC, useState, useRef, useEffect } from 'react' +import { createUseStyles } from 'react-jss' + +/** This style is needed so the tree filter dropdown looks like the basic filter dropdow */ +const useStyles = createUseStyles({ + dataViewTreeFilterTreeView: { + '& .pf-v6-c-tree-view__node::after': { + borderRadius: 0, + borderRightStyle: 'none', + borderLeftStyle: 'none' + }, + '& .pf-v6-c-tree-view__content': { + borderRadius: 0 + } + } +}) + +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; + /** 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[]; + /** When true, expands all tree nodes by default */ + defaultExpanded?: boolean; + /** Callback for when tree items are selected/deselected, provides all currently selected nodes */ + onSelect?: (selectedItems: TreeViewDataItem[]) => void; + /** Array of pre-selected item id's to be checked on initial render */ + defaultSelected?: string[]; +} + +export const DataViewTreeFilter: FC = ({ + filterId, + title, + value = [], + onChange, + showToolbarItem, + ouiaId = 'DataViewTreeFilter', + items, + defaultExpanded = false, + onSelect, + defaultSelected = [] +}: DataViewTreeFilterProps) => { + const classes = useStyles(); + const [isOpen, setIsOpen] = useState(false); + const [treeData, setTreeData] = useState(items || []); + const menuRef = useRef(null); + const isInitialMount = useRef(true); + const hasCalledInitialOnChange = useRef(false); + + // Helper function to expand all nodes in the tree + const expandAllNodes = (items: TreeViewDataItem[]): TreeViewDataItem[] => { + return items.map(item => ({ + ...item, + defaultExpanded: true, + children: item.children ? expandAllNodes(item.children) : undefined + })); + }; + + // Helper function to set pre-selected items + const setPreSelectedItems = (items: TreeViewDataItem[], selectedIds: string[]): TreeViewDataItem[] => { + return items.map(item => { + const isSelected = selectedIds.includes(String(item.id)); + const hasSelectedChildren = item.children?.some(child => selectedIds.includes(String(child.id))) ?? false; + + return { + ...item, + checkProps: item.checkProps ? { + ...item.checkProps, + checked: isSelected || hasSelectedChildren + } : undefined, + children: item.children ? setPreSelectedItems(item.children, selectedIds) : undefined + }; + }); + }; + + // Generic helper to collect items from tree based on predicate + const collectTreeItems = ( + items: TreeViewDataItem[], + predicate: (item: TreeViewDataItem) => boolean, + leafOnly = false + ): TreeViewDataItem[] => { + const collected: TreeViewDataItem[] = []; + + const collect = (item: TreeViewDataItem) => { + const isLeaf = !item.children || item.children.length === 0; + + if (predicate(item) && (!leafOnly || isLeaf)) { + collected.push(item); + } + + item.children?.forEach(child => collect(child)); + }; + + items.forEach(item => collect(item)); + return collected; + }; + + // Helper function to get all checked items (not just leaf nodes) + const getAllCheckedItems = (items: TreeViewDataItem[]): TreeViewDataItem[] => { + return collectTreeItems(items, item => item.checkProps?.checked === true, false); + }; + + // Initialize tree data with defaultExpanded and defaultSelected (only on first mount) + useEffect(() => { + if (!items) return; + + let initializedData = [...items]; + + // Apply default expansion + if (defaultExpanded) { + initializedData = expandAllNodes(initializedData); + } + + // Apply pre-selected items only on initial mount + if (isInitialMount.current && defaultSelected.length > 0) { + initializedData = setPreSelectedItems(initializedData, defaultSelected); + } + + setTreeData(initializedData); + + if (isInitialMount.current) { + isInitialMount.current = false; + } + }, [items, defaultExpanded]); + + // Call onChange and onSelect after tree data is initialized with default selections + useEffect(() => { + if (!hasCalledInitialOnChange.current && defaultSelected.length > 0 && treeData.length > 0) { + const selectedValues = getAllCheckedLeafItems(treeData); + + // Only call if there are actually selected values + if (selectedValues.length > 0) { + // Defer the callbacks to avoid updating parent during render + queueMicrotask(() => { + if (onChange) { + onChange(undefined, selectedValues); + } + + if (onSelect) { + const selectedItems = getAllCheckedItems(treeData); + onSelect(selectedItems); + } + }); + + hasCalledInitialOnChange.current = true; + } + } + }, [treeData]); + + // Sync tree checkboxes when value prop changes (when clearAllFilters is called) + useEffect(() => { + if (value.length === 0 && treeData.length > 0) { + const currentCheckedItems = getAllCheckedLeafItems(treeData); + + // Only update if there are checked items that need to be unchecked + if (currentCheckedItems.length > 0) { + const uncheckRecursive = (items: TreeViewDataItem[]): TreeViewDataItem[] => { + return items.map(item => ({ + ...item, + checkProps: item.checkProps ? { ...item.checkProps, checked: false } : undefined, + children: item.children ? uncheckRecursive(item.children) : undefined + })); + }; + + setTreeData(uncheckRecursive(treeData)); + } + } + }, [value]); + + // Check if all children are checked (recursive) + const areAllChildrenChecked = (item: TreeViewDataItem): boolean => { + if (!item.children?.length) { + return item.checkProps?.checked === true; + } + return item.children.every(child => areAllChildrenChecked(child)); + }; + + // Check if some children are checked (recursive) + const areSomeChildrenChecked = (item: TreeViewDataItem): boolean => { + if (!item.children?.length) { + return item.checkProps?.checked === true; + } + return item.children.some(child => areSomeChildrenChecked(child)); + }; + + // Find tree item by name + const findItemByName = (items: TreeViewDataItem[], name: string): TreeViewDataItem | null => { + for (const item of items) { + if (item.name === name) { + return item; + } + if (item.children) { + const found = findItemByName(item.children, name); + if (found) return found; + } + } + return null; + }; + + // Find parent item by child ID + const findParentById = (items: TreeViewDataItem[], childId: string): TreeViewDataItem | null => { + for (const item of items) { + if (item.children?.some(child => child.id === childId)) { + return item; + } + if (item.children) { + const found = findParentById(item.children, childId); + if (found) return found; + } + } + return null; + }; + + // Get all checked leaf items (returns array of names) + const getAllCheckedLeafItems = (items: TreeViewDataItem[]): string[] => { + return collectTreeItems( + items, + item => item.checkProps?.checked === true, + true + ).map(item => String(item.name)); + }; + + // Update parent checkbox states based on children (recursive) + const onCheckParentHandle = (childId: string): void => { + const parent = findParentById(treeData, childId); + if (!parent) return; + + if (parent.checkProps) { + const allChildrenChecked = areAllChildrenChecked(parent); + const someChildrenChecked = areSomeChildrenChecked(parent); + + parent.checkProps.checked = allChildrenChecked ? true : someChildrenChecked ? null : false; + } + + if (parent.id) { + onCheckParentHandle(parent.id); + } + }; + + // Check/uncheck item and all its children (recursive) + const onCheckHandle = (treeViewItem: TreeViewDataItem, checked: boolean): void => { + if (treeViewItem.checkProps) { + treeViewItem.checkProps.checked = checked; + } + + treeViewItem.children?.forEach(child => onCheckHandle(child, checked)); + }; + + // Handle checkbox change event + const onCheck = (event: React.ChangeEvent, treeViewItem: TreeViewDataItem) => { + const checked = (event.target as HTMLInputElement).checked; + + onCheckHandle(treeViewItem, checked); + + if (treeViewItem.id) { + onCheckParentHandle(treeViewItem.id); + } + + setTreeData(prev => [...prev]); + + const selectedValues = getAllCheckedLeafItems(treeData); + onChange?.(event as any, selectedValues); + + if (onSelect) { + const selectedItems = getAllCheckedItems(treeData); + onSelect(selectedItems); + } + }; + + // Clear a specific filter by name (when label chip is removed) + const onFilterSelectorClear = (itemName: string) => { + const treeViewItem = findItemByName(treeData, itemName); + if (!treeViewItem) return; + + onCheckHandle(treeViewItem, false); + if (treeViewItem.id) { + onCheckParentHandle(treeViewItem.id); + } + }; + + // Uncheck all items in the tree + const uncheckAllItems = () => { + const uncheckRecursive = (items: TreeViewDataItem[]): TreeViewDataItem[] => { + return items.map(item => ({ + ...item, + checkProps: item.checkProps ? { ...item.checkProps, checked: false } : undefined, + children: item.children ? uncheckRecursive(item.children) : undefined + })); + }; + + const updatedTreeData = uncheckRecursive(treeData); + setTreeData(updatedTreeData); + onChange?.(undefined, []); + }; + + 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)); + onFilterSelectorClear(labelKey); + }} + deleteLabelGroup={uncheckAllItems} + categoryName={title} + showToolbarItem={showToolbarItem}> + {dropdown} + + ) +} + +export default DataViewTreeFilter; \ No newline at end of file diff --git a/packages/module/src/DataViewTreeFilter/__snapshots__/DataViewTreeFilter.test.tsx.snap b/packages/module/src/DataViewTreeFilter/__snapshots__/DataViewTreeFilter.test.tsx.snap new file mode 100644 index 00000000..0eae4a4a --- /dev/null +++ b/packages/module/src/DataViewTreeFilter/__snapshots__/DataViewTreeFilter.test.tsx.snap @@ -0,0 +1,167 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataViewTreeFilter component should render correctly 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
    +
  • + + + + Linux + + + + + + +
  • +
+
+
+
+
+
+
+ +
+
+
+
+
+`; 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 536676f175145def39fe0f7b449d2b239ed39eec Mon Sep 17 00:00:00 2001 From: Jakub007d Date: Mon, 12 Jan 2026 12:31:30 +0100 Subject: [PATCH 2/3] fix: fixing lint errors --- .../examples/Toolbar/TreeFilterExample.tsx | 18 -- .../DataViewTreeFilter/DataViewTreeFilter.tsx | 216 ++++++++++-------- .../DataViewTreeFilter.test.tsx.snap | 32 +++ 3 files changed, 149 insertions(+), 117 deletions(-) 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 index 0ac0f3db..2d1e813e 100644 --- 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 @@ -41,18 +41,6 @@ const repositories: Repository[] = [ { name: 'Server-009', workspace: 'Testing Workspace', tags: ['test', 'backend'], os: 'Fedora 38', lastSeen: '4 hours ago' } ]; -const treeOptions: TreeViewDataItem[] = [ - { - 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[] = [ { @@ -231,9 +219,6 @@ const MyTable: React.FunctionComponent = () => { title="Operating System" items={osOptions} defaultExpanded={true} - onSelect={(selectedItems: TreeViewDataItem[]) => { - console.log('Selected OS items:', selectedItems); - }} /> { items={tagOptions} defaultExpanded={false} defaultSelected={['tag-web', 'tag-api']} - onSelect={(selectedItems: TreeViewDataItem[]) => { - console.log('Selected tag items:', selectedItems); - }} /> } diff --git a/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx b/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx index 27400907..043fd7d5 100644 --- a/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx +++ b/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx @@ -16,6 +16,64 @@ const useStyles = createUseStyles({ } }) +// Generic helper to collect items from tree based on predicate +const collectTreeItems = ( + items: TreeViewDataItem[], + predicate: (item: TreeViewDataItem) => boolean, + leafOnly = false +): TreeViewDataItem[] => { + const collected: TreeViewDataItem[] = []; + + const collect = (item: TreeViewDataItem) => { + const isLeaf = !item.children || item.children.length === 0; + + if (predicate(item) && (!leafOnly || isLeaf)) { + collected.push(item); + } + + item.children?.forEach(child => collect(child)); + }; + + items.forEach(item => collect(item)); + return collected; +}; + +// Helper function to get all checked items (not just leaf nodes) +const getAllCheckedItems = (items: TreeViewDataItem[]): TreeViewDataItem[] => + collectTreeItems(items, item => item.checkProps?.checked === true, false); + +// Get all checked leaf items (returns array of names) +const getAllCheckedLeafItems = (items: TreeViewDataItem[]): string[] => + collectTreeItems( + items, + item => item.checkProps?.checked === true, + true + ).map(item => String(item.name)); + +// Helper function to expand all nodes in the tree +const expandAllNodes = (items: TreeViewDataItem[]): TreeViewDataItem[] => + items.map(item => ({ + ...item, + defaultExpanded: true, + children: item.children ? expandAllNodes(item.children) : undefined + })); + +// Helper function to set pre-selected items +const setPreSelectedItems = (items: TreeViewDataItem[], selectedIds: string[]): TreeViewDataItem[] => + items.map(item => { + const isSelected = selectedIds.includes(String(item.id)); + const hasSelectedChildren = item.children?.some(child => selectedIds.includes(String(child.id))) ?? false; + + return { + ...item, + checkProps: item.checkProps ? { + ...item.checkProps, + checked: isSelected || hasSelectedChildren + } : undefined, + children: item.children ? setPreSelectedItems(item.children, selectedIds) : undefined + }; + }); + export interface DataViewTreeFilterProps { /** Unique key for the filter attribute */ filterId: string; @@ -58,62 +116,11 @@ export const DataViewTreeFilter: FC = ({ const isInitialMount = useRef(true); const hasCalledInitialOnChange = useRef(false); - // Helper function to expand all nodes in the tree - const expandAllNodes = (items: TreeViewDataItem[]): TreeViewDataItem[] => { - return items.map(item => ({ - ...item, - defaultExpanded: true, - children: item.children ? expandAllNodes(item.children) : undefined - })); - }; - - // Helper function to set pre-selected items - const setPreSelectedItems = (items: TreeViewDataItem[], selectedIds: string[]): TreeViewDataItem[] => { - return items.map(item => { - const isSelected = selectedIds.includes(String(item.id)); - const hasSelectedChildren = item.children?.some(child => selectedIds.includes(String(child.id))) ?? false; - - return { - ...item, - checkProps: item.checkProps ? { - ...item.checkProps, - checked: isSelected || hasSelectedChildren - } : undefined, - children: item.children ? setPreSelectedItems(item.children, selectedIds) : undefined - }; - }); - }; - - // Generic helper to collect items from tree based on predicate - const collectTreeItems = ( - items: TreeViewDataItem[], - predicate: (item: TreeViewDataItem) => boolean, - leafOnly = false - ): TreeViewDataItem[] => { - const collected: TreeViewDataItem[] = []; - - const collect = (item: TreeViewDataItem) => { - const isLeaf = !item.children || item.children.length === 0; - - if (predicate(item) && (!leafOnly || isLeaf)) { - collected.push(item); - } - - item.children?.forEach(child => collect(child)); - }; - - items.forEach(item => collect(item)); - return collected; - }; - - // Helper function to get all checked items (not just leaf nodes) - const getAllCheckedItems = (items: TreeViewDataItem[]): TreeViewDataItem[] => { - return collectTreeItems(items, item => item.checkProps?.checked === true, false); - }; - // Initialize tree data with defaultExpanded and defaultSelected (only on first mount) useEffect(() => { - if (!items) return; + if (!items) { + return; + } let initializedData = [...items]; @@ -160,21 +167,28 @@ export const DataViewTreeFilter: FC = ({ // Sync tree checkboxes when value prop changes (when clearAllFilters is called) useEffect(() => { - if (value.length === 0 && treeData.length > 0) { - const currentCheckedItems = getAllCheckedLeafItems(treeData); - - // Only update if there are checked items that need to be unchecked - if (currentCheckedItems.length > 0) { - const uncheckRecursive = (items: TreeViewDataItem[]): TreeViewDataItem[] => { - return items.map(item => ({ - ...item, - checkProps: item.checkProps ? { ...item.checkProps, checked: false } : undefined, - children: item.children ? uncheckRecursive(item.children) : undefined - })); - }; - - setTreeData(uncheckRecursive(treeData)); - } + if (value.length === 0) { + setTreeData(currentTreeData => { + if (currentTreeData.length === 0) { + return currentTreeData; + } + + const currentCheckedItems = getAllCheckedLeafItems(currentTreeData); + + // Only update if there are checked items that need to be unchecked + if (currentCheckedItems.length > 0) { + const uncheckRecursive = (items: TreeViewDataItem[]): TreeViewDataItem[] => + items.map(item => ({ + ...item, + checkProps: item.checkProps ? { ...item.checkProps, checked: false } : undefined, + children: item.children ? uncheckRecursive(item.children) : undefined + })); + + return uncheckRecursive(currentTreeData); + } + + return currentTreeData; + }); } }, [value]); @@ -202,7 +216,9 @@ export const DataViewTreeFilter: FC = ({ } if (item.children) { const found = findItemByName(item.children, name); - if (found) return found; + if (found) { + return found; + } } } return null; @@ -216,31 +232,32 @@ export const DataViewTreeFilter: FC = ({ } if (item.children) { const found = findParentById(item.children, childId); - if (found) return found; + if (found) { + return found; + } } } return null; }; - // Get all checked leaf items (returns array of names) - const getAllCheckedLeafItems = (items: TreeViewDataItem[]): string[] => { - return collectTreeItems( - items, - item => item.checkProps?.checked === true, - true - ).map(item => String(item.name)); - }; - // Update parent checkbox states based on children (recursive) const onCheckParentHandle = (childId: string): void => { const parent = findParentById(treeData, childId); - if (!parent) return; + if (!parent) { + return; + } if (parent.checkProps) { const allChildrenChecked = areAllChildrenChecked(parent); const someChildrenChecked = areSomeChildrenChecked(parent); - parent.checkProps.checked = allChildrenChecked ? true : someChildrenChecked ? null : false; + if (allChildrenChecked) { + parent.checkProps.checked = true; + } else if (someChildrenChecked) { + parent.checkProps.checked = null; + } else { + parent.checkProps.checked = false; + } } if (parent.id) { @@ -281,7 +298,9 @@ export const DataViewTreeFilter: FC = ({ // Clear a specific filter by name (when label chip is removed) const onFilterSelectorClear = (itemName: string) => { const treeViewItem = findItemByName(treeData, itemName); - if (!treeViewItem) return; + if (!treeViewItem) { + return; + } onCheckHandle(treeViewItem, false); if (treeViewItem.id) { @@ -291,13 +310,12 @@ export const DataViewTreeFilter: FC = ({ // Uncheck all items in the tree const uncheckAllItems = () => { - const uncheckRecursive = (items: TreeViewDataItem[]): TreeViewDataItem[] => { - return items.map(item => ({ + const uncheckRecursive = (items: TreeViewDataItem[]): TreeViewDataItem[] => + items.map(item => ({ ...item, checkProps: item.checkProps ? { ...item.checkProps, checked: false } : undefined, children: item.children ? uncheckRecursive(item.children) : undefined })); - }; const updatedTreeData = uncheckRecursive(treeData); setTreeData(updatedTreeData); @@ -329,19 +347,19 @@ export const DataViewTreeFilter: FC = ({ return ( ({ key: item, node: item }))} - deleteLabel={(_, label) => { - const labelKey = typeof label === 'string' ? label : label.key; - onChange?.(undefined, value.filter(item => item !== labelKey)); - onFilterSelectorClear(labelKey); - }} - deleteLabelGroup={uncheckAllItems} - categoryName={title} - showToolbarItem={showToolbarItem}> - {dropdown} - + key={filterId} + data-ouia-component-id={ouiaId} + labels={value.map(item => ({ key: item, node: item }))} + deleteLabel={(_, label) => { + const labelKey = typeof label === 'string' ? label : label.key; + onChange?.(undefined, value.filter(item => item !== labelKey)); + onFilterSelectorClear(labelKey); + }} + deleteLabelGroup={uncheckAllItems} + categoryName={title} + showToolbarItem={showToolbarItem}> + {dropdown} + ) } diff --git a/packages/module/src/DataViewTreeFilter/__snapshots__/DataViewTreeFilter.test.tsx.snap b/packages/module/src/DataViewTreeFilter/__snapshots__/DataViewTreeFilter.test.tsx.snap index 0eae4a4a..439f9583 100644 --- a/packages/module/src/DataViewTreeFilter/__snapshots__/DataViewTreeFilter.test.tsx.snap +++ b/packages/module/src/DataViewTreeFilter/__snapshots__/DataViewTreeFilter.test.tsx.snap @@ -137,6 +137,38 @@ exports[`DataViewTreeFilter component should render correctly 1`] = ` +
+ +
From 4b1ed54f116bdfb5d4e98b4124b7df136fe694c0 Mon Sep 17 00:00:00 2001 From: Jakub007d Date: Tue, 13 Jan 2026 12:10:53 +0100 Subject: [PATCH 3/3] fix: extracting uncheckRecursive to avoid duplicate code --- .../DataViewTreeFilter/DataViewTreeFilter.tsx | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx b/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx index 043fd7d5..5510edd7 100644 --- a/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx +++ b/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx @@ -74,6 +74,14 @@ const setPreSelectedItems = (items: TreeViewDataItem[], selectedIds: string[]): }; }); +// Helper function to uncheck all items recursively +const uncheckRecursive = (items: TreeViewDataItem[]): TreeViewDataItem[] => + items.map(item => ({ + ...item, + checkProps: item.checkProps ? { ...item.checkProps, checked: false } : undefined, + children: item.children ? uncheckRecursive(item.children) : undefined + })); + export interface DataViewTreeFilterProps { /** Unique key for the filter attribute */ filterId: string; @@ -177,13 +185,6 @@ export const DataViewTreeFilter: FC = ({ // Only update if there are checked items that need to be unchecked if (currentCheckedItems.length > 0) { - const uncheckRecursive = (items: TreeViewDataItem[]): TreeViewDataItem[] => - items.map(item => ({ - ...item, - checkProps: item.checkProps ? { ...item.checkProps, checked: false } : undefined, - children: item.children ? uncheckRecursive(item.children) : undefined - })); - return uncheckRecursive(currentTreeData); } @@ -310,13 +311,6 @@ export const DataViewTreeFilter: FC = ({ // Uncheck all items in the tree const uncheckAllItems = () => { - const uncheckRecursive = (items: TreeViewDataItem[]): TreeViewDataItem[] => - items.map(item => ({ - ...item, - checkProps: item.checkProps ? { ...item.checkProps, checked: false } : undefined, - children: item.children ? uncheckRecursive(item.children) : undefined - })); - const updatedTreeData = uncheckRecursive(treeData); setTreeData(updatedTreeData); onChange?.(undefined, []);