diff --git a/src/TreeViewList/TreeViewList.spec.tsx b/src/TreeViewList/TreeViewList.spec.tsx index 401a7e05..49a42756 100644 --- a/src/TreeViewList/TreeViewList.spec.tsx +++ b/src/TreeViewList/TreeViewList.spec.tsx @@ -10,17 +10,12 @@ test("should render the tree list", async ({ page }) => { await expect( page .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByRole("treeitem", { name: "BRK" }) + .getByRole("treeitem", { name: "Aerodynamics" }) ).toBeVisible(); await expect( page .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByRole("treeitem", { name: "ELE" }) - ).toBeVisible(); - await expect( - page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByRole("treeitem", { name: "TRM" }) + .getByRole("treeitem", { name: "Suspension" }) ).toBeVisible(); }); @@ -31,14 +26,17 @@ test("should display parameter value once expanded", async ({ page }) => { await page.goto( "http://localhost:6006/?path=/story/lists-treeviewlist--default" ); + await page .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("TRM") + .getByRole("treeitem", { name: "Aerodynamics" }) + .getByTestId("AddIcon") .click(); + await expect( page .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("Efficiency1D", { exact: true }) + .getByText("Drag Coefficient", { exact: true }) ).toBeVisible(); }); @@ -53,26 +51,24 @@ test("should display tooltip for the expanded parameter on hover", async ({ ); await page .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("BRK") + .getByText("Suspension") .click(); await page .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("MCBooster") + .getByText("Axle") .click(); await page .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("HydESPModel") + .getByText("Front") .click(); await page .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("PumpMaxDelivery", { exact: true }) + .getByText("Load", { exact: true }) .hover(); await expect( page .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText( - "BRK.MCBooster.HydESPModel.PumpMaxDeliveryMaximum delivery of the two hydraulic pumps (each responsible for one circuit) at 0 bar pressure difference. Parameter needed for CarMaker hydraulic ESC (Name: 'Pump.qMax')." - ) + .getByText("Preload of Front Suspension") ).toBeVisible(); }); @@ -85,28 +81,49 @@ test("should hide tooltip when not hovering over parameter", async ({ await page.goto( "http://localhost:6006/?path=/story/lists-treeviewlist--default" ); - await page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("TRM") - .click(); - await page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("GearSpred") - .hover(); - await expect( - page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("TRM.GearSpredEfficieny for") - ).toBeVisible(); - // Move the mouse to the top left corner of the page to simulate "unhovering" - await page.mouse.move(0, 0); + // Wait for the iframe to be attached in the DOM. + await page.waitForSelector('iframe[title="storybook-preview-iframe"]'); - await expect( - page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("TRM.GearSpred Efficieny for") - ).toBeHidden(); + // Get the Frame object for the iframe. + const frameElement = await page.$('iframe[title="storybook-preview-iframe"]'); + + if (frameElement !== null) { + const frame = await frameElement.contentFrame(); + + if (frame !== null) { + // Wait for the '#storybook-root ul li' to be attached in the DOM. + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("Suspension") + .click(); + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("Axle") + .click(); + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("Front") + .click(); + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("Load", { exact: true }) + .hover(); + + // Move the mouse to the top left corner of the page to simulate "unhovering" + await page.mouse.move(0, 0); + + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("Preload of Front Suspension") + ).toBeHidden(); + } else { + throw new Error("Frame object is null"); + } + } else { + throw new Error("Frame element is null"); + } }); /** @@ -116,68 +133,109 @@ test("should hide child when is collapsed", async ({ page }) => { await page.goto( "http://localhost:6006/?path=/story/lists-treeviewlist--default" ); - await page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("TRM") - .click(); - await expect( - page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("GearSpred", { exact: true }) - ).toBeVisible(); - await page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("TRM") - .click(); - await expect( - page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("GearSpred", { exact: true }) - ).toBeHidden(); + + const frameElement = await page.$('iframe[title="storybook-preview-iframe"]'); + + if (frameElement !== null) { + const frame = await frameElement.contentFrame(); + + if (frame !== null) { + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("Aerodynamics") + .click(); + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("Reference Length", { exact: true }) + ).toBeVisible(); + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("Aerodynamics") + .click(); + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("Reference Length", { exact: true }) + ).toBeHidden(); + } else { + throw new Error("Frame object is null"); + } + } else throw new Error("Frame element is null"); }); test("should expand the parent and child nodes that match the search term", async ({ page }) => { await page.goto( - "http://localhost:6006/?path=/story/lists-treeviewlist--default&args=expandSearchTerm:true;searchTerm:ratio" + "http://localhost:6006/?path=/story/lists-treeviewlist--default&args=enableSearch:true;expandSearchResults:true" ); - await expect( - page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("BRK") - ).toBeVisible(); - await expect( - page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("Pedal") - ).toBeVisible(); - await expect( - page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("Ratio", { exact: true }) - ).toBeVisible(); + + // Wait for the iframe to be attached in the DOM. + await page.waitForSelector('iframe[title="storybook-preview-iframe"]'); + + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByPlaceholder("Search") + .fill("rear"); + + // Get the Frame object for the iframe. + const frameElement = await page.$('iframe[title="storybook-preview-iframe"]'); + + if (frameElement !== null) { + const frame = await frameElement.contentFrame(); + + if (frame !== null) { + // Wait for the '#storybook-root ul li' to be attached in the DOM. + await frame.waitForSelector("#storybook-root ul li"); + + const liElements = await frame.$$("#storybook-root ul li"); + + const elementCount = liElements.length; + + expect(elementCount).toBe(5); + } else { + throw new Error("Frame object is null"); + } + } else { + throw new Error("Frame element is null"); + } }); test("should not expand the child nodes when expand for search is not enabled", async ({ page }) => { await page.goto( - "http://localhost:6006/?path=/story/lists-treeviewlist--default&args=expandSearchTerm:false;searchTerm:ratio" + "http://localhost:6006/?path=/story/lists-treeviewlist--default&args=enableSearch:true;expandSearchResults:false" ); - await expect( - page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("BRK") - ).toBeVisible(); - await expect( - page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("Pedal") - ).toBeHidden(); - await expect( - page - .frameLocator('iframe[title="storybook-preview-iframe"]') - .getByText("Ratio", { exact: true }) - ).toBeHidden(); + + // Wait for the iframe to be attached in the DOM. + await page.waitForSelector('iframe[title="storybook-preview-iframe"]'); + + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByPlaceholder("Search") + .fill("rear"); + + // Get the Frame object for the iframe. + const frameElement = await page.$('iframe[title="storybook-preview-iframe"]'); + + if (frameElement !== null) { + const frame = await frameElement.contentFrame(); + + if (frame !== null) { + // Wait for the '#storybook-root ul li' to be attached in the DOM. + await frame.waitForSelector("#storybook-root ul li"); + + const liElements = await frame.$$("#storybook-root ul li"); + + const elementCount = liElements.length; + + expect(elementCount).toBe(1); + } else { + throw new Error("Frame object is null"); + } + } else { + throw new Error("Frame element is null"); + } }); diff --git a/src/TreeViewList/TreeViewList.stories.tsx b/src/TreeViewList/TreeViewList.stories.tsx index 050a2810..1180faef 100644 --- a/src/TreeViewList/TreeViewList.stories.tsx +++ b/src/TreeViewList/TreeViewList.stories.tsx @@ -1,8 +1,9 @@ -import { Item, TreeViewListProps } from "./TreeViewList.types"; +import { Meta, StoryFn } from "@storybook/react"; -import { Meta } from "@storybook/react"; import React from "react"; import TreeViewList from "./TreeViewList"; +import { TreeViewListProps } from "./TreeViewList.types"; +import items from "./example-items.json"; /** * Story metadata @@ -14,93 +15,42 @@ const meta: Meta = { export default meta; // Story Template -const Template = (args: TreeViewListProps>) => { - return ; +const Template: StoryFn = args => { + return ; }; // Default export const Default = { args: { - items: [ - { - children: [ - { - children: [ - { - children: [ - { - name: "PumpMaxDelivery", - options: [], - tooltip: - "Maximum delivery of the two hydraulic pumps (each responsible for one circuit) at 0 bar pressure difference. Parameter needed for CarMaker hydraulic ESC (Name: 'Pump.qMax')." - } - ], - name: "HydESPModel" - }, - { - name: "BoreTravel", - options: [], - tooltip: - "Piston travel to close compensation bore inside master cylinder. Parameter needed for CarMaker hydraulic ESC (Name: 'MC.xCompBore')." - } - ], - name: "MCbooster" - }, - { - children: [ - { - name: "Ratio", - options: [], - tooltip: - "The pedal ratio amplifies the force of the brake pedal. Parameter needed for CarMaker hydraulic ESC (Name: 'Pedal.ratio')." - }, - { - name: "ResponseTime", - options: [], - tooltip: - "Period of time from pressing the pedal till the brake pressure begins to build up." - } - ], - name: "Pedal" - } - ], - id: "64f1b6fd511d08b5f6bc2a1e", - name: "BRK" - }, - { - children: [ - { - children: [ - { - children: [ - { - name: "Capacity", - options: [], - tooltip: - "This is the total capacity of the board high voltage battery" - } - ], - name: "HV" - } - ], - name: "Battery" - } - ], - name: "ELE" - }, - { - children: [ - { name: "Efficiency1D", options: [] }, - { - name: "GearSpred", - options: [], - tooltip: - "Efficieny for all gear numbers. If the number of efficiencies are less than the number of gears, the last entry in the table will be applied to all remaining gears." - } - ], - name: "TRM" - } - ] + enableSearch: false, + expandSearchResults: false, + items, + selected: "", + width: "100%" + }, + render: Template +}; + +// With Search +export const WithSearch = { + args: { + enableSearch: true, + expandSearchResults: true, + items, + selected: "", + width: "100%" + }, + render: Template +}; + +// With Custom width +export const CustomWidth = { + args: { + enableSearch: true, + expandSearchResults: true, + items, + selected: "", + width: "60%" }, render: Template }; diff --git a/src/TreeViewList/TreeViewList.tsx b/src/TreeViewList/TreeViewList.tsx index f279c67e..dcf60571 100644 --- a/src/TreeViewList/TreeViewList.tsx +++ b/src/TreeViewList/TreeViewList.tsx @@ -1,335 +1,222 @@ -import { - ChildData, - Item, - TooltipTreeItemProps, - TreeNode, - TreeViewListProps -} from "./TreeViewList.types"; -import React, { useCallback, useEffect, useState } from "react"; +import { Box, Tooltip } from "@mui/material"; +import React, { useEffect, useState } from "react"; import { TreeItem, TreeView } from "@mui/x-tree-view"; +import { TreeNodeItem, TreeViewListProps } from "./TreeViewList.types"; import AddIcon from "@mui/icons-material/Add"; import RemoveIcon from "@mui/icons-material/Remove"; -import { Tooltip } from "@mui/material"; -import { alpha } from "@mui/material/styles"; +import SearchBar from "../SearchBar/SearchBar"; /** * A component that renders a tree view list. * - * @template T The type of the options array elements. * @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.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. * @property props.selected - The ID of the currently selected item. - * @property props.searchTerm - The term to search for in the items. - * @property [props.defaultExpanded=[]] - The IDs of the items that should be expanded by default. - * @property props.expandSearchTerm - Weather to expand the tree when searching. * @property props.width - The width of the tree view list. - * @property props.onSelectionChange - The function to call when the selection changes. * @returns The tree view list component. */ -const TreeViewList = ({ +const TreeViewList = ({ + enableSearch = false, + expandSearchResults = false, items, + onNodeSelect, + onNodeToggle, selected, - searchTerm = "", - defaultExpanded = [], - expandSearchTerm = false, - width, - onSelectionChange -}: TreeViewListProps) => { - const [expanded, setExpanded] = useState(defaultExpanded); + width = "100%" +}: TreeViewListProps) => { + // state for items to display in the tree + const [treeDisplayItems, setTreeDisplayItems] = + useState(items); - // handle tree toggle - const handleToggle = ( - event: React.SyntheticEvent, - nodeIds: string[] - ) => { - setExpanded(nodeIds); - }; + // state for search input value + const [searchValue, setSearchValue] = useState(""); - // search tree callback - const searchTreeCallback = useCallback( - (parameters: TreeNode[], searchTerm: string) => - searchTree(parameters, searchTerm), - [] - ); + // state for expanded nodes + const [defaultExpanded, setDefaultExpanded] = useState([]); - // tooltip tree item - const TooltipTreeItem = (props: TooltipTreeItemProps) => { - const handleClick = () => { - if (props.children === null) { - onSelectionChange(props.node.name); + // update tree display items when the items prop changes or when the search input changes + useEffect(() => { + // if search is enabled and the search input is not empty, display only items that match the search terms, otherwise display all items + if (enableSearch && searchValue !== "") { + // split the search into individual words and filter out any empty strings + const searchTerms = searchValue.split(" ").filter(term => term); + + // filter the items to contain only those that match each search term + let filteredItems = items; + for (const term of searchTerms) { + filteredItems = filterBySearchTerm(filteredItems, term); } - }; - - return ( - - {props.node.name} -
- {props.tooltip} - - ) : ( - "" - ) - } - placement="right" - > - ({ - borderLeft: props.hasParent - ? `1px solid ${alpha(theme.palette.text.primary, 0.1)}` - : "none", - color: theme.palette.text.primary, - padding: "5px" - })} - /> -
- ); - }; - - // recursive search function - const applySearch = (search: string, parameters: Item[]): Item[] => { - // Split the search string into individual terms for comparison - const terms = search.toLowerCase().split(" "); - - // Function to recursively search and filter the items - const filterItems = (items: Item[]): Item[] => { - return items.reduce((acc: Item[], item) => { - const itemName = (item.name as string)?.toLowerCase() || ""; - - // Check if the item name matches any of the search terms - let matches = terms.some(term => itemName.toLowerCase().includes(term)); - - // If there are children, recursively filter them - if (Array.isArray(item.children) && item.children.length > 0) { - const filteredChildren = filterItems(item.children); + setTreeDisplayItems(filteredItems); + } else { + setTreeDisplayItems(items); + } + }, [enableSearch, items, searchValue]); - // If any children match, include this item in the result - if (filteredChildren.length > 0) { - matches = true; - // Update the item with the filtered children - item = { ...item, children: filteredChildren as T }; + // 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[]) => { + for (const item of items) { + expandedNodes.push(item.nodeId); + if (item.children) { + expandAllNodes(item.children); } } + }; + expandAllNodes(treeDisplayItems); + setDefaultExpanded(expandedNodes); + } else { + setDefaultExpanded([]); + } + }, [enableSearch, expandSearchResults, searchValue, treeDisplayItems]); - // If this item or any of its children match, add it to the accumulator - if (matches) { - acc.push(item); - } - - return acc; - }, []); - }; - - return filterItems(parameters); - }; - - // build tree nodes - const parameters = buildTree( - searchTerm !== undefined && searchTerm !== "" - ? applySearch(searchTerm, items) - : items - ); - - const renderTree = (nodes: TreeNode[], hasParent = false) => + // render the tree nodes with optional tooltips + const renderTree = (nodes: TreeNodeItem[]) => nodes.map(node => ( {Array.isArray(node.children) && node.children.length > 0 - ? renderTree(node.children, true) + ? renderTree(node.children) : null} )); - // expand nodes that match the search term - useEffect(() => { - if (expandSearchTerm) { - // Reset expanded ids - setExpanded([]); - - // if the search term is not empty, get the ids of the matching nodes and set them as expanded - if (searchTerm !== "") { - const ids = searchTreeCallback(parameters, searchTerm); - setExpanded(ids); - } - } - }, [expandSearchTerm, parameters, searchTerm, searchTreeCallback]); - return ( - } - defaultExpandIcon={} - expanded={expanded} - selected={selected} - onNodeToggle={handleToggle} - sx={{ width }} - > - {renderTree(parameters)} - + + {enableSearch ? ( + setSearchValue(event.target.value)} + /> + ) : null} + } + defaultExpandIcon={} + defaultExpanded={defaultExpanded} + selected={selected} + onNodeSelect={onNodeSelect} + onNodeToggle={onNodeToggle} + > + {renderTree(treeDisplayItems)} + + ); }; -export default TreeViewList; - /** - * Creates a new tree node with the given properties. + * Creates a tree view list item with an optional tooltip. * - * @param id - The ID of the new node. - * @param name - The name of the new node. - * @param tooltip - The tooltip of the new node. - * @param [disable=true] - Optional parameter that indicates whether the node is disabled. Defaults to true. - * @returns The newly created tree node. + * @param props - The properties for the tooltip tree item. + * @property props.disabled - If true, the tree item is disabled. + * @property props.label - The label of the tree item. + * @property props.nodeId - The unique ID of the tree item. + * @property props.tooltip - The tooltip to display. + * @returns The tree item wrapped in a tooltip. */ -function createNode( - id: string, - name: string, - tooltip?: string, - disable = true -): TreeNode { - return { - children: [], - disable, - id, - name, - tooltip - }; -} +const TooltipTreeItem = ( + props: Pick & { + children?: React.ReactNode; + } +) => { + return ( + {props.tooltip} : ""} + placement="right" + > + ({ + color: theme.palette.text.primary, + padding: "5px" + })} + /> + + ); +}; /** - * Builds a tree structure from a flat list of items. - * - * @template T The type of the options array elements. - * @param data - The flat list of items to transform into a tree. - * @returns The tree structure built from the input items. - * @example buildTree(items) // returns TreeNode[] + * Filter items to contain only those that match the searchTerm + * @param items - The items to filter. + * @param searchTerm - The search term to match. + * @returns The filtered items. + * @example filterBySearchTerm(items, "search term"); */ -function buildTree(data: Item[]): TreeNode[] { - const itemsTree: TreeNode[] = []; +const filterBySearchTerm = (items: TreeNodeItem[], searchTerm: string) => { + // take a copy of the items array to avoid mutating the original + const itemsCopy = JSON.parse(JSON.stringify(items)) as TreeNodeItem[]; - // iterate through each item - data.forEach(item => { - // create node for this root model - const name = item.name ? item.name.toString() : "default"; - const tooltip = item.tooltip ? item.tooltip.toString() : undefined; - const parentNode = createNode(name, name, tooltip); - itemsTree.push(parentNode); + // function to check if a node item matches the search term + const matchesSearch = (node: TreeNodeItem, searchTerm: string) => { + return node.label.toLowerCase().includes(searchTerm.toLowerCase()); + }; - // parse options and children recursively - if (Array.isArray(item.options)) { - item.options.forEach(childData => { - parseChild(parentNode, childData); - }); - } + // interim type to add the active property to the node item + type TreeNodeItemWithActive = TreeNodeItem & { active?: boolean }; - if (Array.isArray(item.children)) { - item.children.forEach(childData => { - parseChild(parentNode, childData); - }); + // function to recursively loop through all node items and check if they match the search term + const checkNodes = (items: TreeNodeItemWithActive[], searchTerm: string) => { + for (const item of items) { + if (matchesSearch(item, searchTerm)) { + item.active = true; + } else { + item.active = false; + } + if (item.children) { + checkNodes(item.children, searchTerm); + } } - }); - - return itemsTree; -} - -/** - * Type guard to check if a variable is of type ChildData. - * - * @template T The type of the options array elements. - * @param data - The variable to check. - * @returns A boolean indicating whether the variable is of type ChildData. - */ -function isChildData(data: any): data is ChildData { - return data.children !== undefined || data.options !== undefined; -} - -/** - * Parses a child node and adds it to the parent node. - * - * @template T The type of the options array elements. - * @param parentNode - The parent node to which the child node will be added. - * @param childData - The data of the child node. - */ -function parseChild(parentNode: TreeNode, childData: ChildData | T) { - // if childData is of type T and not ChildData, return - if (!isChildData(childData)) return; - - // ignore if child data is a leaf node, i.e. has no children or options - if (!childData.children && !childData.options) return; - - // set tooltip if it exists in child data else set to undefined - const tooltip = childData.tooltip ? childData.tooltip.toString() : undefined; - - // create node for this child - const thisNode = createNode( - childData.name, - parentNode.name + "." + childData.name, - tooltip - ); - parentNode.children.push(thisNode); + }; + checkNodes(itemsCopy, searchTerm); + + // function to recursively filter out all inactive items, making sure to keep parent items if they have active children + const removeInactiveItems = ( + items: TreeNodeItemWithActive[] + ): TreeNodeItemWithActive[] => { + return items.filter((item: TreeNodeItemWithActive) => { + // if the item is active, we want to keep it and all its children + if (item.active) { + return true; + } - // parse children of this child recursively - if (childData.children) { - childData.children.forEach((child: ChildData) => { - parseChild(thisNode, child); - }); - } + // if we have any children, we want to recursively call this function on them and keep the parent if it has any active children left + if (item.children) { + item.children = removeInactiveItems(item.children); + return item.children && item.children.length > 0; + } - // parse options of this child recursively - if (childData.options) { - childData.options.forEach((child: T) => { - parseChild(thisNode, child); + // otherwise we want to remove it + return false; }); - } -} - -/** - * Searches a tree for nodes that match the given search term. - * - * @param nodes - The nodes to search. - * @param term - The term to search for. - * @param isChildSearch - Weather we are searching within child nodes. - * @returns The IDs of the nodes that match the search term. - */ -const searchTree = ( - nodes: TreeNode[], - term: string, - isChildSearch: boolean = false // Additional parameter to indicate if we are searching within child nodes -): string[] => { - let result: string[] = []; - const terms = term.toLowerCase().split(" "); // Convert term to lower case and split into words - - nodes.forEach(node => { - const nodeNameLower = node.name.toLowerCase(); // Convert node name to lower case - const nodeMatched = terms.some(t => nodeNameLower.includes(t)); - - // If the current node matches, or if we're in a child search and there's a match in children, add the node ID - if (nodeMatched || isChildSearch) { - result.push(node.id); - } - - // If the node has children, search them too - if (node.children) { - const childResult = searchTree(node.children, term, true); - // If there's a match in children, ensure the parent node is included for expansion - if (childResult.length > 0 && !nodeMatched) { - result.push(node.id); + }; + const filteredItems = removeInactiveItems(itemsCopy); + + // remove the active property from the filtered items + const removeActiveProperty = (items: TreeNodeItemWithActive[]) => { + for (const item of items) { + delete item.active; + if (item.children) { + removeActiveProperty(item.children); } - // Concatenate the child results (which includes only matching children and their parents) - result = result.concat(childResult); } - }); + return items; + }; + removeActiveProperty(filteredItems); - // Remove duplicates and return - return Array.from(new Set(result)); + // return the filtered items + return filteredItems as TreeNodeItem[]; }; + +export default TreeViewList; diff --git a/src/TreeViewList/TreeViewList.types.ts b/src/TreeViewList/TreeViewList.types.ts index dd4afe9d..9cddc8f8 100644 --- a/src/TreeViewList/TreeViewList.types.ts +++ b/src/TreeViewList/TreeViewList.types.ts @@ -1,76 +1,28 @@ -/** - * Represents an item with arbitrary properties. - */ -export type Item = { - /** - * The value of the item. - */ - [key: string]: T; -}; - -/** - * Properties for the TreeViewList component. - */ -export type TreeViewListProps = { - /** - * The items to display in the tree view list. - */ - items: Item[]; - - /** - * The ID of the currently selected item. - */ - selected: string; - - /** - * The term to search for in the items. This is optional. - */ - searchTerm?: string; - - /** - * Flag indicating whether the search term should be expanded in the tree view list. This is optional. - */ - expandSearchTerm?: boolean; - - /** - * The IDs of the items that should be expanded by default. This is optional. - */ - defaultExpanded?: string[]; - - /** - * The function to call when the selection changes. - */ - onSelectionChange: (value: string) => void; - - /** - * The display width of the tree view list. This is optional. - */ - width?: string; -}; +import { TreeViewProps } from "@mui/x-tree-view"; /** * Represents a node in a tree structure. */ -export type TreeNode = { +export type TreeNodeItem = { /** - * The name of the node. + * The child nodes of the node. */ - name: string; + children?: TreeNodeItem[]; /** - * The ID of the node. + * If true, the node is disabled. */ - id: string; + disabled?: boolean; /** - * The child nodes of the node. + * The tree node label. */ - children: TreeNode[]; + label: string; /** - * Whether the node is disabled. + * The ID of the node. */ - disable?: boolean; + nodeId: string; /** * The tooltip of the node. @@ -79,63 +31,41 @@ export type TreeNode = { }; /** - * Properties for the TooltipTreeItem component. + * Properties for the TreeViewList component. */ -export type TooltipTreeItemProps = { - /** - * The tooltip of the tree item. - */ - tooltip: string; - +export type TreeViewListProps = { /** - * The ID of the node. + * If true, the search input is displayed. Defaults to false. */ - nodeId: string; + enableSearch?: boolean; /** - * The child nodes of the tree item. + * If true, the tree nodes will be automatically expanded when a search term is entered. Defaults to false. */ - children?: React.ReactNode; + expandSearchResults?: boolean; /** - * The label of the tree item. - */ - label: string; - - /** - * The tree node. + * The items to display in the tree view list. */ - node: TreeNode; + items: TreeNodeItem[]; /** - * Whether the tree item has a parent. + * Callback fired when tree items are selected/unselected. */ - hasParent?: boolean; -}; - -/** - * Type representing a child in a tree structure. - * - * @template T The type of the options array elements. - */ -export type ChildData = { - /** The name of the node. */ - name: string; + onNodeSelect: TreeViewProps["onNodeSelect"]; /** - * Optional array of child nodes. - * Each child is also a `ChildData` object, allowing for a nested tree structure. + * Callback fired when tree items are expanded/collapsed. */ - children?: ChildData[]; + onNodeToggle: TreeViewProps["onNodeToggle"]; /** - * Optional array of options. The options are displayed as the last child. - * The type of the elements in this array is defined by the generic parameter `T`. + * The ID of the currently selected node. */ - options?: T[]; + selected: TreeViewProps["selected"]; /** - * The tooltip of the child. This is optional. + * The display width of the tree view list. This is optional. */ - tooltip?: string; + width?: string; }; diff --git a/src/TreeViewList/example-items.json b/src/TreeViewList/example-items.json new file mode 100644 index 00000000..878914e3 --- /dev/null +++ b/src/TreeViewList/example-items.json @@ -0,0 +1,95 @@ +[ + { + "label": "Aerodynamics", + "nodeId": "AER", + "children": [ + { + "label": "Drag Coefficient", + "nodeId": "AER.DragCoefficient1D", + "tooltip": "Defines the drag coefficient of the entire vehicle as a function of wind angle of attack (tau)" + }, + { + "disabled": true, + "label": "Frontal Area", + "nodeId": "AER.FrontalArea", + "tooltip": "The vehicle reference area is the projected frontal area including tires and underbody parts" + }, + { + "label": "Reference Length", + "nodeId": "AER.ReferenceLength", + "tooltip": "This is the length between VEHICLE.AERODYNAMIC.CONSIDERATION POINT XYZ and the vehicle's rearmost point. It is used to calculate the torques applied to VEHICLE.AERODYNAMIC.RESULTANT FORCE POINT XYZ" + } + ] + }, + { + "label": "Suspension", + "nodeId": "SUS", + "children": [ + { + "label": "Axle", + "nodeId": "SUS.Axle", + "children": [ + { + "label": "Wheelbase", + "nodeId": "SUS.Axle.WheelBase", + "tooltip": "This is the vehicle wheelbase measured tire contact patch to tire contact patch" + }, + { + "label": "Front", + "nodeId": "SUS.Axle.Front", + "children": [ + { + "label": "Load", + "nodeId": "SUS.Axle.Front.Load", + "tooltip": "Preload of Front Suspension with gravity Amesim Parameter: Front SUSP Axle load" + }, + { + "label": "Track Width", + "nodeId": "SUS.Axle.Front.TrackWidth", + "tooltip": "Vehicle front track width measured at the tire contact patch" + } + ] + } + ] + }, + { + "label": "Damper", + "nodeId": "SUS.Damper", + "children": [ + { + "label": "Front", + "nodeId": "SUS.Damper.Front", + "children": [ + { + "label": "Damping", + "nodeId": "SUS.Damper.Front.Damping1D", + "tooltip": "Shock absorber damping force characteristic in a 1D table - front suspension" + }, + { + "label": "Mass", + "nodeId": "SUS.Damper.Front.Mass", + "tooltip": "This is the front mass of the shock absorber" + } + ] + }, + { + "label": "Rear", + "nodeId": "SUS.Damper.Rear", + "children": [ + { + "label": "Damping", + "nodeId": "SUS.Damper.Rear.Damping1D", + "tooltip": "Shock absorber damping characteristic in a 1D table - rear suspension" + }, + { + "label": "Mass", + "nodeId": "SUS.Damper.Rear.Mass", + "tooltip": "This is the rear mass of the shock absorber" + } + ] + } + ] + } + ] + } +]