diff --git a/src/TreeViewList/TreeViewList.spec.tsx b/src/TreeViewList/TreeViewList.spec.tsx index 49a42756..5f5b6288 100644 --- a/src/TreeViewList/TreeViewList.spec.tsx +++ b/src/TreeViewList/TreeViewList.spec.tsx @@ -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 @@ -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 @@ -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. @@ -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"]'); @@ -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"); } diff --git a/src/TreeViewList/TreeViewList.stories.tsx b/src/TreeViewList/TreeViewList.stories.tsx index 1180faef..3625ad2b 100644 --- a/src/TreeViewList/TreeViewList.stories.tsx +++ b/src/TreeViewList/TreeViewList.stories.tsx @@ -23,6 +23,7 @@ const Template: StoryFn = args => { export const Default = { args: { enableSearch: false, + expandItems: 1, expandSearchResults: false, items, selected: "", @@ -35,6 +36,7 @@ export const Default = { export const WithSearch = { args: { enableSearch: true, + expandItems: 1, expandSearchResults: true, items, selected: "", @@ -47,6 +49,7 @@ export const WithSearch = { export const CustomWidth = { args: { enableSearch: true, + expandItems: 1, expandSearchResults: true, items, selected: "", diff --git a/src/TreeViewList/TreeViewList.tsx b/src/TreeViewList/TreeViewList.tsx index dcf60571..3b6446d3 100644 --- a/src/TreeViewList/TreeViewList.tsx +++ b/src/TreeViewList/TreeViewList.tsx @@ -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"; @@ -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. @@ -23,6 +24,7 @@ import SearchBar from "../SearchBar/SearchBar"; const TreeViewList = ({ enableSearch = false, expandSearchResults = false, + expandItems = 1, items, onNodeSelect, onNodeToggle, @@ -37,7 +39,13 @@ const TreeViewList = ({ const [searchValue, setSearchValue] = useState(""); // state for expanded nodes - const [defaultExpanded, setDefaultExpanded] = useState([]); + const [expandedNodes, setExpandedNodes] = useState([]); + + // state for user expanded nodes + const [userExpanded, setUserExpanded] = useState([]); + + // state for the node we are hovered over + const [hoveredNode, setHoveredNode] = useState(""); // update tree display items when the items prop changes or when the search input changes useEffect(() => { @@ -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 => ( {Array.isArray(node.children) && node.children.length > 0 @@ -101,13 +157,23 @@ const TreeViewList = ({ /> ) : null} } defaultExpandIcon={} - 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)} @@ -115,6 +181,30 @@ const TreeViewList = ({ ); }; +/** + * 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. * @@ -128,20 +218,34 @@ const TreeViewList = ({ const TooltipTreeItem = ( props: Pick & { 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 ( {props.tooltip} : ""} - placement="right" + placement="right-start" + open={props.hoveredNode === props.nodeId} + disableFocusListener > ({ color: theme.palette.text.primary, padding: "5px" })} + onMouseOver={event => { + event.stopPropagation(); + setHoveredNode(props.nodeId); + }} + onMouseOut={event => { + event.stopPropagation(); + setHoveredNode(""); + }} /> ); diff --git a/src/TreeViewList/TreeViewList.types.ts b/src/TreeViewList/TreeViewList.types.ts index 9cddc8f8..c995aaf8 100644 --- a/src/TreeViewList/TreeViewList.types.ts +++ b/src/TreeViewList/TreeViewList.types.ts @@ -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. */ @@ -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. */ @@ -52,7 +68,7 @@ export type TreeViewListProps = { /** * Callback fired when tree items are selected/unselected. */ - onNodeSelect: TreeViewProps["onNodeSelect"]; + onNodeSelect: OnNodeSelect; /** * Callback fired when tree items are expanded/collapsed. diff --git a/src/TreeViewList/example-items.json b/src/TreeViewList/example-items.json index 878914e3..fa1eb083 100644 --- a/src/TreeViewList/example-items.json +++ b/src/TreeViewList/example-items.json @@ -2,6 +2,7 @@ { "label": "Aerodynamics", "nodeId": "AER", + "tooltip": "Aerodynamics description goes here", "children": [ { "label": "Drag Coefficient",