diff --git a/package-lock.json b/package-lock.json index 277d1e5f..a9f4eea8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3700,9 +3700,9 @@ } }, "node_modules/@patternfly/ast-helpers": { - "version": "1.4.0-alpha.146", - "resolved": "https://registry.npmjs.org/@patternfly/ast-helpers/-/ast-helpers-1.4.0-alpha.146.tgz", - "integrity": "sha512-gW5yMl/ZFsFDpN7gYvegw3crpbBUkcuO1GK2gZCKMGJEyGytAVQncxZxfwZxZt9Y7DFdwPBG3oyu432cL9jLmA==", + "version": "1.4.0-alpha.149", + "resolved": "https://registry.npmjs.org/@patternfly/ast-helpers/-/ast-helpers-1.4.0-alpha.149.tgz", + "integrity": "sha512-X/NovaiGapE9zOZuBJscuW8oLFYPwO7bG/apmIGAfXpEYkC+qwcRnXsqQB/mngmn+B1eLrdWxKR/xDWsi381GA==", "dev": true, "license": "MIT", "dependencies": { @@ -3714,9 +3714,9 @@ } }, "node_modules/@patternfly/documentation-framework": { - "version": "6.5.5", - "resolved": "https://registry.npmjs.org/@patternfly/documentation-framework/-/documentation-framework-6.5.5.tgz", - "integrity": "sha512-DxdcmM+XApOoPf/K0ZD68iOZq2Be+xvB3ohgDskUIav+3UuJrdk4qo2nHY1FjG++zLGwZA/6LS7tbJwEWWdG8w==", + "version": "6.5.8", + "resolved": "https://registry.npmjs.org/@patternfly/documentation-framework/-/documentation-framework-6.5.8.tgz", + "integrity": "sha512-cZC+9vXGDXVZnjLQfdlixikYKHGXWLgrsn+43a/VDbI2yne+oUTdzDCNA+R1H3hovjCO11gMM8DpGhoty4DHwA==", "dev": true, "license": "MIT", "dependencies": { @@ -3724,7 +3724,7 @@ "@babel/preset-env": "^7.24.3", "@babel/preset-react": "^7.24.1", "@mdx-js/util": "1.6.16", - "@patternfly/ast-helpers": "^1.4.0-alpha.146", + "@patternfly/ast-helpers": "^1.4.0-alpha.149", "@reach/router": "npm:@gatsbyjs/reach-router@1.3.9", "autoprefixer": "9.8.6", "babel-loader": "^9.1.3", @@ -28573,14 +28573,14 @@ "license": "MIT", "dependencies": { "@patternfly/react-component-groups": "^6.1.0", - "@patternfly/react-core": "^6.0.0", - "@patternfly/react-icons": "^6.0.0", - "@patternfly/react-table": "^6.0.0", + "@patternfly/react-core": "^6.1.0", + "@patternfly/react-icons": "^6.1.0", + "@patternfly/react-table": "^6.1.0", "clsx": "^2.1.1", "react-jss": "^10.10.0" }, "devDependencies": { - "@patternfly/documentation-framework": "^6.5.4", + "@patternfly/documentation-framework": "^6.5.8", "@patternfly/patternfly": "^6.0.0", "@patternfly/patternfly-a11y": "^5.0.0", "@patternfly/react-code-editor": "^6.0.0", @@ -30994,9 +30994,9 @@ "optional": true }, "@patternfly/ast-helpers": { - "version": "1.4.0-alpha.146", - "resolved": "https://registry.npmjs.org/@patternfly/ast-helpers/-/ast-helpers-1.4.0-alpha.146.tgz", - "integrity": "sha512-gW5yMl/ZFsFDpN7gYvegw3crpbBUkcuO1GK2gZCKMGJEyGytAVQncxZxfwZxZt9Y7DFdwPBG3oyu432cL9jLmA==", + "version": "1.4.0-alpha.149", + "resolved": "https://registry.npmjs.org/@patternfly/ast-helpers/-/ast-helpers-1.4.0-alpha.149.tgz", + "integrity": "sha512-X/NovaiGapE9zOZuBJscuW8oLFYPwO7bG/apmIGAfXpEYkC+qwcRnXsqQB/mngmn+B1eLrdWxKR/xDWsi381GA==", "dev": true, "requires": { "acorn": "^8.4.1", @@ -31007,16 +31007,16 @@ } }, "@patternfly/documentation-framework": { - "version": "6.5.5", - "resolved": "https://registry.npmjs.org/@patternfly/documentation-framework/-/documentation-framework-6.5.5.tgz", - "integrity": "sha512-DxdcmM+XApOoPf/K0ZD68iOZq2Be+xvB3ohgDskUIav+3UuJrdk4qo2nHY1FjG++zLGwZA/6LS7tbJwEWWdG8w==", + "version": "6.5.8", + "resolved": "https://registry.npmjs.org/@patternfly/documentation-framework/-/documentation-framework-6.5.8.tgz", + "integrity": "sha512-cZC+9vXGDXVZnjLQfdlixikYKHGXWLgrsn+43a/VDbI2yne+oUTdzDCNA+R1H3hovjCO11gMM8DpGhoty4DHwA==", "dev": true, "requires": { "@babel/core": "^7.24.3", "@babel/preset-env": "^7.24.3", "@babel/preset-react": "^7.24.1", "@mdx-js/util": "1.6.16", - "@patternfly/ast-helpers": "^1.4.0-alpha.146", + "@patternfly/ast-helpers": "^1.4.0-alpha.149", "@reach/router": "npm:@gatsbyjs/reach-router@1.3.9", "autoprefixer": "9.8.6", "babel-loader": "^9.1.3", @@ -31449,14 +31449,14 @@ "@patternfly/react-data-view": { "version": "file:packages/module", "requires": { - "@patternfly/documentation-framework": "^6.5.4", + "@patternfly/documentation-framework": "^6.5.8", "@patternfly/patternfly": "^6.0.0", "@patternfly/patternfly-a11y": "^5.0.0", "@patternfly/react-code-editor": "^6.0.0", "@patternfly/react-component-groups": "^6.1.0", - "@patternfly/react-core": "^6.0.0", - "@patternfly/react-icons": "^6.0.0", - "@patternfly/react-table": "^6.0.0", + "@patternfly/react-core": "^6.1.0", + "@patternfly/react-icons": "^6.1.0", + "@patternfly/react-table": "^6.1.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@types/react-router-dom": "^5.3.3", diff --git a/packages/module/package.json b/packages/module/package.json index 9539eaa6..cee5672b 100644 --- a/packages/module/package.json +++ b/packages/module/package.json @@ -32,9 +32,9 @@ }, "dependencies": { "@patternfly/react-component-groups": "^6.1.0", - "@patternfly/react-core": "^6.0.0", - "@patternfly/react-icons": "^6.0.0", - "@patternfly/react-table": "^6.0.0", + "@patternfly/react-core": "^6.1.0", + "@patternfly/react-icons": "^6.1.0", + "@patternfly/react-table": "^6.1.0", "clsx": "^2.1.1", "react-jss": "^10.10.0" }, @@ -43,7 +43,7 @@ "react-dom": "^17 || ^18" }, "devDependencies": { - "@patternfly/documentation-framework": "^6.5.4", + "@patternfly/documentation-framework": "^6.5.8", "@patternfly/patternfly": "^6.0.0", "@patternfly/react-code-editor": "^6.0.0", "@patternfly/patternfly-a11y": "^5.0.0", diff --git a/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/FiltersExample.tsx b/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/FiltersExample.tsx index afdbcac0..e841ec78 100644 --- a/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/FiltersExample.tsx +++ b/packages/module/patternfly-docs/content/extensions/data-view/examples/Toolbar/FiltersExample.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { Pagination } from '@patternfly/react-core'; +import { Pagination, TreeViewDataItem } from '@patternfly/react-core'; import { BrowserRouter, useSearchParams } from 'react-router-dom'; import { useDataViewFilters, useDataViewPagination } from '@patternfly/react-data-view/dist/dynamic/Hooks'; import { DataView } from '@patternfly/react-data-view/dist/dynamic/DataView'; @@ -8,6 +8,7 @@ import { DataViewToolbar } from '@patternfly/react-data-view/dist/dynamic/DataVi 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'; +import { DataViewTreeFilter } from '@patternfly/react-data-view/dist/dynamic/DataViewTreeFilter'; const perPageOptions = [ { title: '5', value: 5 }, @@ -43,6 +44,61 @@ const filterOptions: DataViewFilterOption[] = [ { label: 'Workspace three', value: 'workspace-three' } ]; +const statusOptions: TreeViewDataItem[] = [ + { + name: 'Ready', + id: 'ready', + checkProps: { checked: false }, + customBadgeContent: 1, + children: [ + { + name: 'Updated', + id: 'updated', + checkProps: { checked: false }, + customBadgeContent: 0 + }, + { + name: 'Waiting to update', + id: 'waiting', + checkProps: { checked: false }, + customBadgeContent: 0 + }, + { + name: 'Conditions degraded', + id: 'degraded', + checkProps: { checked: false }, + customBadgeContent: 1 + }, + { + name: 'Approval required', + id: 'approval', + checkProps: { checked: false }, + customBadgeContent: 0 + } + ] + }, + { + name: 'Not ready', + id: 'nr', + checkProps: { checked: false }, + customBadgeContent: 1, + children: [ + { + name: 'Conditions degraded', + id: 'nr-degraded', + checkProps: { checked: false }, + customBadgeContent: 1 + } + ] + }, + { + name: 'Updating', + id: 'updating', + checkProps: { checked: false }, + customBadgeContent: 0 + } +]; + const columns = [ 'Name', 'Branch', 'Pull requests', 'Workspace', 'Last commit' ]; const ouiaId = 'LayoutExample'; @@ -81,6 +137,7 @@ const MyTable: React.FunctionComponent = () => { + } /> 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 1901f424..91a77117 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 @@ -25,6 +25,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. diff --git a/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx b/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx new file mode 100644 index 00000000..d0db0588 --- /dev/null +++ b/packages/module/src/DataViewTreeFilter/DataViewTreeFilter.tsx @@ -0,0 +1,276 @@ +import React from 'react'; +import { + MenuProps, + ToolbarLabel, + ToolbarFilter, + TreeViewDataItem, + MenuContainer, + MenuToggle, + Panel, + PanelMain, + PanelMainBody, + Title, + TreeView, +} from '@patternfly/react-core'; + +const isToolbarLabel = (label: string | ToolbarLabel): label is ToolbarLabel => + typeof label === 'object' && 'key' in label; + +/** extends MenuProps */ +export interface DataViewTreeFilterProps 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: TreeViewDataItem[]; + /** 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 DataViewTreeFilter: React.FC = ({ + filterId, + title, + value = [], + onChange, + placeholder, + options = [], + showToolbarItem, + ouiaId = 'DataViewTreeFilter', + ...props +}: DataViewTreeFilterProps) => { + const [ isOpen, setIsOpen ] = React.useState(false); + const toggleRef = React.useRef(null); + const menuRef = React.useRef(null); + const [ checkedItems, setCheckedItems ] = React.useState([]); + + + // const containerRef = React.useRef(null); + + // 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 + + const isChecked = (dataItem: TreeViewDataItem) => checkedItems.some((item) => item.id === dataItem.id); + const areAllDescendantsChecked = (dataItem: TreeViewDataItem) => + dataItem.children ? dataItem.children.every((child) => areAllDescendantsChecked(child)) : isChecked(dataItem); + const areSomeDescendantsChecked = (dataItem: TreeViewDataItem) => + dataItem.children ? dataItem.children.some((child) => areSomeDescendantsChecked(child)) : isChecked(dataItem); + const flattenTree = (tree: TreeViewDataItem[]) => { + let result: TreeViewDataItem[] = []; + tree.forEach((item) => { + result.push(item); + if (item.children) { + result = result.concat(flattenTree(item.children)); + } + }); + return result; + }; + + const mapTree = (item: TreeViewDataItem) => { + const hasCheck = areAllDescendantsChecked(item); + item.checkProps = item.checkProps || {}; + // Reset checked properties to be updated + item.checkProps.checked = false; + + if (hasCheck) { + item.checkProps.checked = true; + } else { + const hasPartialCheck = areSomeDescendantsChecked(item); + if (hasPartialCheck) { + item.checkProps.checked = null; + } + } + + if (item.children) { + return { + ...item, + children: item.children.map(mapTree) + }; + } + return item; + }; + + const filterItems = (item: TreeViewDataItem, checkedItem: TreeViewDataItem) => { + if (item.id === checkedItem.id) { + return true; + } + + if (item.children) { + return ( + (item.children = item.children + .map((opt) => Object.assign({}, opt)) + .filter((child) => filterItems(child, checkedItem))).length > 0 + ); + } + }; + + const onCheck = (evt: React.ChangeEvent, treeViewItem: TreeViewDataItem, treeType: string) => { + const checked = (evt.target as HTMLInputElement).checked; + + + const checkedItemTree = options + .map((opt) => Object.assign({}, opt)) + .filter((item) => filterItems(item, treeViewItem)); + const flatCheckedItems = flattenTree(checkedItemTree); + setCheckedItems((prevCheckedItems) => + checked + ? prevCheckedItems.concat(flatCheckedItems.filter((item) => !prevCheckedItems.some((i) => i.id === item.id))) + : prevCheckedItems.filter((item) => !flatCheckedItems.some((i) => i.id === item.id)) + ); + }; + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const toggle = ( + + {isOpen ? 'Expanded' : 'Collapsed'} + + ); + const optionsMapped = options.map(mapTree); + + const menu = ( + + +
+ + + {title} + + + + onCheck(event, item, 'status')} + /> + +
+
+
+ ); + + + return ( + { + const activeOption = options.find(option => option.id === item); + return ({ key: activeOption?.id as string, node: activeOption?.id }) + })} + deleteLabel={(_, label) => + onChange?.(undefined, value.filter(item => item !== (isToolbarLabel(label) ? label.key : label))) + } + categoryName={title} + showToolbarItem={showToolbarItem} + > + {/* : undefined} + badge={value.length > 0 && showBadge ? {value.length} : undefined} + style={{ width: '200px' }} + > + {placeholder ?? title} + + } + triggerRef={toggleRef} + popper={ + + + + {options.map(option => ( + + {option.label} + + ))} + + + + } + popperRef={menuRef} + appendTo={containerRef.current || undefined} + aria-label={`${title ?? filterId} filter`} + isVisible={isOpen} + /> */} + setIsOpen(isOpen)} + onOpenChangeKeys={[ 'Escape' ]} + menu={menu} + menuRef={menuRef} + toggle={toggle} + toggleRef={toggleRef} + /> + + ); +}; + +export default DataViewTreeFilter; diff --git a/packages/module/src/DataViewTreeFilter/index.ts b/packages/module/src/DataViewTreeFilter/index.ts new file mode 100644 index 00000000..d292c7a6 --- /dev/null +++ b/packages/module/src/DataViewTreeFilter/index.ts @@ -0,0 +1,2 @@ +export { default } from './DataViewTreeFilter'; +export * from './DataViewTreeFilter'; 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';