Skip to content

Commit

Permalink
Merge pull request #815 from IPG-Automotive-UK/enhancement/on-node-se…
Browse files Browse the repository at this point in the history
…lect-only-on-child-node

Enhancement - TreeViewList improvements
  • Loading branch information
syedsalehinipg authored Feb 28, 2024
2 parents 2864788 + cb9f87d commit 611e0e6
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 27 deletions.
10 changes: 5 additions & 5 deletions src/TreeViewList/TreeViewList.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ test("should hide child when is collapsed", async ({ page }) => {
if (frame !== null) {
await page
.frameLocator('iframe[title="storybook-preview-iframe"]')
.getByText("Aerodynamics")
.getByText("Aerodynamics", { exact: true })
.click();
await expect(
page
Expand All @@ -151,7 +151,7 @@ test("should hide child when is collapsed", async ({ page }) => {
).toBeVisible();
await page
.frameLocator('iframe[title="storybook-preview-iframe"]')
.getByText("Aerodynamics")
.getByText("Aerodynamics", { exact: true })
.click();
await expect(
page
Expand All @@ -168,7 +168,7 @@ test("should expand the parent and child nodes that match the search term", asyn
page
}) => {
await page.goto(
"http://localhost:6006/?path=/story/lists-treeviewlist--default&args=enableSearch:true;expandSearchResults:true"
"http://localhost:6006/?path=/story/lists-treeviewlist--default&args=enableSearch:true;expandSearchResults:true;expandItems:2"
);

// Wait for the iframe to be attached in the DOM.
Expand All @@ -177,7 +177,7 @@ test("should expand the parent and child nodes that match the search term", asyn
await page
.frameLocator('iframe[title="storybook-preview-iframe"]')
.getByPlaceholder("Search")
.fill("rear");
.fill("front");

// Get the Frame object for the iframe.
const frameElement = await page.$('iframe[title="storybook-preview-iframe"]');
Expand All @@ -193,7 +193,7 @@ test("should expand the parent and child nodes that match the search term", asyn

const elementCount = liElements.length;

expect(elementCount).toBe(5);
expect(elementCount).toBe(2);
} else {
throw new Error("Frame object is null");
}
Expand Down
3 changes: 3 additions & 0 deletions src/TreeViewList/TreeViewList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const Template: StoryFn<TreeViewListProps> = args => {
export const Default = {
args: {
enableSearch: false,
expandItems: 1,
expandSearchResults: false,
items,
selected: "",
Expand All @@ -35,6 +36,7 @@ export const Default = {
export const WithSearch = {
args: {
enableSearch: true,
expandItems: 1,
expandSearchResults: true,
items,
selected: "",
Expand All @@ -47,6 +49,7 @@ export const WithSearch = {
export const CustomWidth = {
args: {
enableSearch: true,
expandItems: 1,
expandSearchResults: true,
items,
selected: "",
Expand Down
146 changes: 125 additions & 21 deletions src/TreeViewList/TreeViewList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box, Tooltip } from "@mui/material";
import { Box, Tooltip, debounce } from "@mui/material";
import React, { useEffect, useState } from "react";
import { TreeItem, TreeView } from "@mui/x-tree-view";
import { TreeNodeItem, TreeViewListProps } from "./TreeViewList.types";
Expand All @@ -13,6 +13,7 @@ import SearchBar from "../SearchBar/SearchBar";
* @param props - The properties for the tree view list.
* @property props.enableSearch - If true, the search input is displayed. Defaults to false.
* @property props.expandSearchResults - If true, the tree nodes will be automatically expanded when a search term is entered. Defaults to false.
* @property props.expandItems - The number of items to expand
* @property props.items - The items to display in the tree view list.
* @property props.onNodeSelect - The function to call when a node is selected.
* @property props.onNodeToggle - The function to call when a node is toggled.
Expand All @@ -23,6 +24,7 @@ import SearchBar from "../SearchBar/SearchBar";
const TreeViewList = ({
enableSearch = false,
expandSearchResults = false,
expandItems = 1,
items,
onNodeSelect,
onNodeToggle,
Expand All @@ -37,7 +39,13 @@ const TreeViewList = ({
const [searchValue, setSearchValue] = useState("");

// state for expanded nodes
const [defaultExpanded, setDefaultExpanded] = useState<string[]>([]);
const [expandedNodes, setExpandedNodes] = useState<string[]>([]);

// state for user expanded nodes
const [userExpanded, setUserExpanded] = useState<string[]>([]);

// state for the node we are hovered over
const [hoveredNode, setHoveredNode] = useState<string>("");

// update tree display items when the items prop changes or when the search input changes
useEffect(() => {
Expand All @@ -57,33 +65,81 @@ const TreeViewList = ({
}
}, [enableSearch, items, searchValue]);

// if search is enabled and expandSearchResults is true, expand all nodes when the tree display items change
useEffect(() => {
if (enableSearch && expandSearchResults && searchValue !== "") {
const expandedNodes: string[] = [];
const expandAllNodes = (items: TreeNodeItem[]) => {
// count the number of last children in the tree
const countLastChild = (items: TreeNodeItem[]) => {
let count = 0;

for (const item of items) {
// Check if the object has a 'children' property and it's not empty
if (item.children && item.children.length > 0) {
// If the object has children, recursively call the function on its children
count += countLastChild(item.children);
} else {
// If the object has no children, it is a last child
count += 1;
}
}

return count;
};

// expand the nodes when the search input changes
const expandNodes = () => {
let searchedNodes: string[] = [];
const expandChildNodes = (items: TreeNodeItem[]) => {
// If the condition is not met for the entire items array, return early
if (countLastChild(items) > expandItems) {
// reset the searched nodes array
searchedNodes = [];
return;
}

// if the number of last children is less than or equal to the expandItems, expand the nodes
if (countLastChild(items) <= expandItems) {
for (const item of items) {
expandedNodes.push(item.nodeId);
// if the node has children, expand it and call the function on its children
if (item.children) {
expandAllNodes(item.children);
searchedNodes.push(item.nodeId);
expandChildNodes(item.children);
}
}
};
expandAllNodes(treeDisplayItems);
setDefaultExpanded(expandedNodes);
}
};
expandChildNodes(treeDisplayItems);
// update the expanded nodes with the searched nodes
setExpandedNodes(prevState => [...prevState, ...searchedNodes]);
};

// debounce the expandNodes function to prevent it from being called too frequently
const debouncedExpandAllNodes = debounce(expandNodes, 100);

// if search is enabled and expandSearchResults is true, expand the nodes when the tree display items change
useEffect(() => {
if (enableSearch && expandSearchResults && searchValue !== "") {
debouncedExpandAllNodes();
} else {
setDefaultExpanded([]);
// if enableSearch is false and expandSearchResults is false and searchValue is empty, update the expanded nodes with the user expanded nodes
setExpandedNodes(userExpanded);
}
}, [enableSearch, expandSearchResults, searchValue, treeDisplayItems]);
}, [
userExpanded,
debouncedExpandAllNodes,
expandedNodes,
enableSearch,
expandSearchResults,
searchValue
]);

// render the tree nodes with optional tooltips
const renderTree = (nodes: TreeNodeItem[]) =>
nodes.map(node => (
<TooltipTreeItem
disabled={node.disabled}
hoveredNode={hoveredNode}
key={node.nodeId}
label={node.label}
nodeId={node.nodeId}
setHoveredNode={setHoveredNode}
tooltip={node.tooltip}
>
{Array.isArray(node.children) && node.children.length > 0
Expand All @@ -101,20 +157,54 @@ const TreeViewList = ({
/>
) : null}
<TreeView
key={`${searchValue} + ${defaultExpanded.length}`} // key to force re-render so that we can reset the expanded nodes when the search input changes but still allow user to expand/collapse nodes
defaultCollapseIcon={<RemoveIcon />}
defaultExpandIcon={<AddIcon />}
defaultExpanded={defaultExpanded}
expanded={expandedNodes}
selected={selected}
onNodeSelect={onNodeSelect}
onNodeToggle={onNodeToggle}
onNodeSelect={(event, nodeId) => {
const node = getNodeById(treeDisplayItems, nodeId);
const isChild = Boolean(
node && (!node.children || node.children.length === 0)
);
if (onNodeSelect) {
const nodeDetails = { isChild };
onNodeSelect(event, nodeId, nodeDetails);
}
}}
onNodeToggle={(event, nodeId) => {
setUserExpanded([...nodeId]);
}}
>
{renderTree(treeDisplayItems)}
</TreeView>
</Box>
);
};

/**
* Recursively finds a node by its ID.
*
* @param nodes - The nodes to search.
* @param nodeId - The ID of the node to find.
* @returns The node with the specified ID, or null if not found.
*/
const getNodeById = (
nodes: TreeNodeItem[],
nodeId: string
): TreeNodeItem | null => {
for (const node of nodes) {
if (node.nodeId === nodeId) {
return node;
} else if (node.children) {
const result = getNodeById(node.children, nodeId);
if (result) {
return result;
}
}
}
return null;
};

/**
* Creates a tree view list item with an optional tooltip.
*
Expand All @@ -128,20 +218,34 @@ const TreeViewList = ({
const TooltipTreeItem = (
props: Pick<TreeNodeItem, "disabled" | "label" | "nodeId" | "tooltip"> & {
children?: React.ReactNode;
hoveredNode: string;
setHoveredNode: (nodeId: string) => void;
}
) => {
// destructure the hoveredNode and setHoveredNode from the props and pass the rest of the props to the TreeItem
const { hoveredNode, setHoveredNode, ...rest } = props;

return (
<Tooltip
disableFocusListener
title={props.tooltip ? <>{props.tooltip}</> : ""}
placement="right"
placement="right-start"
open={props.hoveredNode === props.nodeId}
disableFocusListener
>
<TreeItem
{...props}
{...rest}
sx={theme => ({
color: theme.palette.text.primary,
padding: "5px"
})}
onMouseOver={event => {
event.stopPropagation();
setHoveredNode(props.nodeId);
}}
onMouseOut={event => {
event.stopPropagation();
setHoveredNode("");
}}
/>
</Tooltip>
);
Expand Down
18 changes: 17 additions & 1 deletion src/TreeViewList/TreeViewList.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ export type TreeNodeItem = {
tooltip?: string;
};

/**
* Callback fired when a node is selected.
*/
type OnNodeSelect = (
event: React.SyntheticEvent,
nodeId: string,
nodeDetails: {
isChild: boolean;
}
) => void;

/**
* Properties for the TreeViewList component.
*/
Expand All @@ -44,6 +55,11 @@ export type TreeViewListProps = {
*/
expandSearchResults?: boolean;

/**
* The number of items to automactically expand when expandSearchResults is true. Defaults to 1.
*/
expandItems?: number;

/**
* The items to display in the tree view list.
*/
Expand All @@ -52,7 +68,7 @@ export type TreeViewListProps = {
/**
* Callback fired when tree items are selected/unselected.
*/
onNodeSelect: TreeViewProps<false>["onNodeSelect"];
onNodeSelect: OnNodeSelect;

/**
* Callback fired when tree items are expanded/collapsed.
Expand Down
1 change: 1 addition & 0 deletions src/TreeViewList/example-items.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
{
"label": "Aerodynamics",
"nodeId": "AER",
"tooltip": "Aerodynamics description goes here",
"children": [
{
"label": "Drag Coefficient",
Expand Down

0 comments on commit 611e0e6

Please sign in to comment.