Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhancement - TreeViewList improvements #815

Merged
merged 17 commits into from
Feb 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = () => {
syedsalehinipg marked this conversation as resolved.
Show resolved Hide resolved
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;
};

dmbarry86 marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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