diff --git a/.eslintrc.json b/.eslintrc.json index f8d9c211b7e..623da7fbeec 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -120,5 +120,13 @@ "spaced-comment": "error", "use-isnan": "error", "patternfly-react/no-layout-effect": "error" - } + }, + "overrides": [ + { + "files": ["**/examples/*"], + "rules": { + "patternfly-react/no-anonymous-functions": "off" + } + } + ] } diff --git a/packages/react-core/src/components/DualListSelector/DualListSelectorTree.tsx b/packages/react-core/src/components/DualListSelector/DualListSelectorTree.tsx index 0ff9d9df671..df7d1f889b7 100644 --- a/packages/react-core/src/components/DualListSelector/DualListSelectorTree.tsx +++ b/packages/react-core/src/components/DualListSelector/DualListSelectorTree.tsx @@ -37,7 +37,7 @@ export interface DualListSelectorTreeItemData { export interface DualListSelectorTreeProps { /** Data of the tree view */ - data: DualListSelectorTreeItemData[]; + data: DualListSelectorTreeItemData[] | (() => DualListSelectorTreeItemData[]); /** ID of the tree view */ id?: string; /** @hide Flag indicating if the list is nested */ @@ -64,7 +64,8 @@ export const DualListSelectorTree: React.FunctionComponent { - const tree = data.map(item => ( + const dataToRender = typeof data === 'function' ? data() : data; + const tree = dataToRender.map(item => ( = ({ +const DualListSelectorTreeItemBase: React.FunctionComponent = ({ onOptionCheck, children, className, @@ -51,12 +53,18 @@ export const DualListSelectorTreeItem: React.FunctionComponent { const ref = React.useRef(null); const [isExpanded, setIsExpanded] = React.useState(defaultExpanded || false); const { setFocusedOption } = React.useContext(DualListSelectorListContext); + React.useEffect(() => { + setIsExpanded(defaultExpanded); + }, [defaultExpanded]); + return (
  • ); }; + +export const DualListSelectorTreeItem = React.memo(DualListSelectorTreeItemBase, (prevProps, nextProps) => { + if (!nextProps.useMemo) { + return false; + } + + if ( + prevProps.className !== nextProps.className || + prevProps.text !== nextProps.text || + prevProps.id !== nextProps.id || + prevProps.defaultExpanded !== nextProps.defaultExpanded || + prevProps.checkProps !== nextProps.checkProps || + prevProps.hasBadge !== nextProps.hasBadge || + prevProps.badgeProps !== nextProps.badgeProps || + prevProps.isChecked !== nextProps.isChecked || + prevProps.itemData !== nextProps.itemData + ) { + return false; + } + + return true; +}); + DualListSelectorTreeItem.displayName = 'DualListSelectorTreeItem'; diff --git a/packages/react-core/src/components/DualListSelector/__tests__/__snapshots__/DualListSelector.test.tsx.snap b/packages/react-core/src/components/DualListSelector/__tests__/__snapshots__/DualListSelector.test.tsx.snap index dd28b265b6a..6a7c0564a4e 100644 --- a/packages/react-core/src/components/DualListSelector/__tests__/__snapshots__/DualListSelector.test.tsx.snap +++ b/packages/react-core/src/components/DualListSelector/__tests__/__snapshots__/DualListSelector.test.tsx.snap @@ -4487,6 +4487,7 @@ exports[`DualListSelector with tree 1`] = ` key="O1" onOptionCheck={[Function]} text="Opt1" + useMemo={true} >
  • = ({ data }: ExampleProps) => { + const [checkedLeafIds, setCheckedLeafIds] = React.useState([]); + const [chosenLeafIds, setChosenLeafIds] = React.useState(['beans', 'beef', 'chicken', 'tofu']); + const [chosenFilter, setChosenFilter] = React.useState(''); + const [availableFilter, setAvailableFilter] = React.useState(''); + let hiddenChosen: string[] = []; + let hiddenAvailable: string[] = []; + + // helper function to build memoized lists + const buildTextById = (node: FoodNode): { [key: string]: string } => { + let textById = {}; + if (!node) { + return textById; + } + textById[node.id] = node.text; + if (node.children) { + node.children.forEach(child => { + textById = { ...textById, ...buildTextById(child) }; + }); + } + return textById; + }; + + // helper function to build memoized lists + const getDescendantLeafIds = (node: FoodNode): string[] => { + if (!node.children || !node.children.length) { + return [node.id]; + } else { + let childrenIds = []; + node.children.forEach(child => { + childrenIds = [...childrenIds, ...getDescendantLeafIds(child)]; + }); + return childrenIds; + } + }; + + // helper function to build memoized lists + const getLeavesById = (node: FoodNode): { [key: string]: string[] } => { + let leavesById = {}; + if (!node.children || !node.children.length) { + leavesById[node.id] = [node.id]; + } else { + node.children.forEach(child => { + leavesById[node.id] = getDescendantLeafIds(node); + leavesById = { ...leavesById, ...getLeavesById(child) }; + }); + } + return leavesById; + }; + + // Builds a map of child leaf nodes by node id - memoized so that it only rebuilds the list if the data changes. + const { memoizedLeavesById, memoizedAllLeaves, memoizedNodeText } = React.useMemo(() => { + let leavesById = {}; + let allLeaves = []; + let nodeTexts = {}; + data.forEach(foodNode => { + nodeTexts = { ...nodeTexts, ...buildTextById(foodNode) }; + leavesById = { ...leavesById, ...getLeavesById(foodNode) }; + allLeaves = [...allLeaves, ...getDescendantLeafIds(foodNode)]; + }); + return { + memoizedLeavesById: leavesById, + memoizedAllLeaves: allLeaves, + memoizedNodeText: nodeTexts + }; + }, [data]); + + const moveChecked = (toChosen: boolean) => { + setChosenLeafIds( + prevChosenIds => + toChosen + ? [...prevChosenIds, ...checkedLeafIds] // add checked ids to chosen list + : [...prevChosenIds.filter(x => !checkedLeafIds.includes(x))] // remove checked ids from chosen list + ); + + // uncheck checked ids that just moved + setCheckedLeafIds(prevChecked => + toChosen + ? [...prevChecked.filter(x => chosenLeafIds.includes(x))] + : [...prevChecked.filter(x => !chosenLeafIds.includes(x))] + ); + }; + + const moveAll = (toChosen: boolean) => { + if (toChosen) { + setChosenLeafIds(memoizedAllLeaves); + } else { + setChosenLeafIds([]); + } + }; + + const areAllDescendantsSelected = (node: FoodNode, isChosen: boolean) => + memoizedLeavesById[node.id].every( + id => checkedLeafIds.includes(id) && (isChosen ? chosenLeafIds.includes(id) : !chosenLeafIds.includes(id)) + ); + const areSomeDescendantsSelected = (node: FoodNode, isChosen: boolean) => + memoizedLeavesById[node.id].some( + id => checkedLeafIds.includes(id) && (isChosen ? chosenLeafIds.includes(id) : !chosenLeafIds.includes(id)) + ); + + const isNodeChecked = (node: FoodNode, isChosen: boolean) => { + if (areAllDescendantsSelected(node, isChosen)) { + return true; + } + if (areSomeDescendantsSelected(node, isChosen)) { + return null; + } + return false; + }; + + const onOptionCheck = ( + event: React.MouseEvent | React.ChangeEvent | React.KeyboardEvent, + isChecked: boolean, + node: DualListSelectorTreeItemData, + isChosen: boolean + ) => { + const nodeIdsToCheck = memoizedLeavesById[node.id].filter(id => + isChosen + ? chosenLeafIds.includes(id) && !hiddenChosen.includes(id) + : !chosenLeafIds.includes(id) && !hiddenAvailable.includes(id) + ); + if (isChosen) { + hiddenChosen = []; + } else { + hiddenAvailable = []; + } + setCheckedLeafIds(prevChecked => { + const otherCheckedNodeNames = prevChecked.filter(id => !nodeIdsToCheck.includes(id)); + return !isChecked ? otherCheckedNodeNames : [...otherCheckedNodeNames, ...nodeIdsToCheck]; + }); + }; + + // builds a search input - used in each dual list selector pane + const buildSearchInput = (isChosen: boolean) => { + const onChange = value => (isChosen ? setChosenFilter(value) : setAvailableFilter(value)); + + return ( + onChange('')} /> + ); + }; + + // Builds the DualListSelectorTreeItems from the FoodNodes + const buildOptions = ( + isChosen: boolean, + [node, ...remainingNodes]: FoodNode[], + hasParentMatch: boolean + ): DualListSelectorTreeItemData[] => { + if (!node) { + return []; + } + + const isChecked = isNodeChecked(node, isChosen); + + const filterValue = isChosen ? chosenFilter : availableFilter; + const descendentLeafIds = memoizedLeavesById[node.id]; + const descendentsOnThisPane = isChosen + ? descendentLeafIds.filter(id => chosenLeafIds.includes(id)) + : descendentLeafIds.filter(id => !chosenLeafIds.includes(id)); + + const hasMatchingChildren = + filterValue && descendentsOnThisPane.some(id => memoizedNodeText[id].includes(filterValue)); + const isFilterMatch = filterValue && node.text.includes(filterValue) && descendentsOnThisPane.length > 0; + + // A node is displayed if either of the following is true: + // - There is no filter value and this node or its descendents belong on this pane + // - There is a filter value and this node or one of this node's descendents or ancestors match on this pane + const isDisplayed = + (!filterValue && descendentsOnThisPane.length > 0) || + hasMatchingChildren || + (hasParentMatch && descendentsOnThisPane.length > 0) || + isFilterMatch; + + if (!isDisplayed) { + if (isChosen) { + hiddenChosen.push(node.id); + } else { + hiddenAvailable.push(node.id); + } + } + + return [ + ...(isDisplayed + ? [ + { + id: node.id, + text: node.text, + isChecked, + checkProps: { 'aria-label': `Select ${node.text}` }, + hasBadge: node.children && node.children.length > 0, + badgeProps: { isRead: true }, + defaultExpanded: isChosen ? !!chosenFilter : !!availableFilter, + children: node.children + ? buildOptions(isChosen, node.children, isFilterMatch || hasParentMatch) + : undefined + } + ] + : []), + ...(!isDisplayed && node.children && node.children.length + ? buildOptions(isChosen, node.children, hasParentMatch) + : []), + ...(remainingNodes ? buildOptions(isChosen, remainingNodes, hasParentMatch) : []) + ]; + }; + + const buildPane = (isChosen: boolean): React.ReactNode => { + const options: DualListSelectorTreeItemData[] = buildOptions(isChosen, data, false); + const numOptions = isChosen ? chosenLeafIds.length : memoizedAllLeaves.length - chosenLeafIds.length; + const numSelected = checkedLeafIds.filter(id => + isChosen ? chosenLeafIds.includes(id) : !chosenLeafIds.includes(id) + ).length; + const status = `${numSelected} of ${numOptions} options selected`; + return ( + + + onOptionCheck(e, isChecked, itemData, isChosen)} + /> + + + ); + }; + + return ( + + {buildPane(false)} + + !chosenLeafIds.includes(x)).length} + onClick={() => moveChecked(true)} + aria-label="Add selected" + > + + + moveAll(true)} + aria-label="Add all" + > + + + moveAll(false)} + aria-label="Remove all" + > + + + moveChecked(false)} + isDisabled={!checkedLeafIds.filter(x => !!chosenLeafIds.includes(x)).length} + aria-label="Remove selected" + > + + + + {buildPane(true)} + + ); +}; + +export const ComposableDualListSelectorTreeExample: React.FunctionComponent = () => ( + +); diff --git a/packages/react-core/src/components/DualListSelector/examples/DualListSelector.md b/packages/react-core/src/components/DualListSelector/examples/DualListSelector.md index 3a46255f42a..5fd83020a46 100644 --- a/packages/react-core/src/components/DualListSelector/examples/DualListSelector.md +++ b/packages/react-core/src/components/DualListSelector/examples/DualListSelector.md @@ -8,7 +8,6 @@ propComponents: [ 'DualListSelectorControl', 'DualListSelectorControlsWrapper', 'DualListSelectorTree', - 'DualListSelectorListItem', 'DualListSelectorTreeItemData' ] beta: true @@ -822,3 +821,8 @@ const ComposableDualListSelector = () => { ); } ``` + +### Composable dual list selector tree +```ts file="ComposableTree.tsx" +``` +