diff --git a/package-lock.json b/package-lock.json index d030f1341..e23526c80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@juggle/resize-observer": "^3.4.0", "@mui/icons-material": "^5.14.8", "@mui/x-data-grid": "^6.10.2", + "@mui/x-tree-view": "^6.17.0", "@types/css-mediaquery": "^0.1.2", "@types/plotly.js": "^2.12.26", "@types/react-plotly.js": "^2.6.0", @@ -143,7 +144,6 @@ "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", - "devOptional": true, "dependencies": { "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" @@ -386,7 +386,6 @@ "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", - "devOptional": true, "dependencies": { "@babel/types": "^7.22.15" }, @@ -508,7 +507,6 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", - "devOptional": true, "engines": { "node": ">=6.9.0" } @@ -517,7 +515,6 @@ "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "devOptional": true, "engines": { "node": ">=6.9.0" } @@ -563,7 +560,6 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "devOptional": true, "dependencies": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -2367,7 +2363,6 @@ "version": "7.23.6", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", - "devOptional": true, "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", @@ -2455,7 +2450,6 @@ "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", - "devOptional": true, "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", @@ -2485,14 +2479,12 @@ "node_modules/@emotion/hash": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", - "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==", - "devOptional": true + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" }, "node_modules/@emotion/is-prop-valid": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", - "devOptional": true, "dependencies": { "@emotion/memoize": "^0.8.1" } @@ -2506,7 +2498,6 @@ "version": "11.11.3", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", - "devOptional": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -2530,7 +2521,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.3.tgz", "integrity": "sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==", - "devOptional": true, "dependencies": { "@emotion/hash": "^0.9.1", "@emotion/memoize": "^0.8.1", @@ -2548,7 +2538,6 @@ "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", - "devOptional": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -2570,14 +2559,12 @@ "node_modules/@emotion/unitless": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", - "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==", - "devOptional": true + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", - "devOptional": true, "peerDependencies": { "react": ">=16.8.0" } @@ -4392,6 +4379,35 @@ "react-dom": "^17.0.0 || ^18.0.0" } }, + "node_modules/@mui/x-tree-view": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-6.17.0.tgz", + "integrity": "sha512-09dc2D+Rjg2z8KOaxbUXyPi0aw7fm2jurEtV8Xw48xJ00joLWd5QJm1/v4CarEvaiyhTQzHImNqdgeJW8ZQB6g==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@mui/base": "^5.0.0-beta.20", + "@mui/utils": "^5.14.14", + "@types/react-transition-group": "^4.4.8", + "clsx": "^2.0.0", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.8.6", + "@mui/system": "^5.8.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, "node_modules/@ndelangen/get-tarball": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@ndelangen/get-tarball/-/get-tarball-3.0.9.tgz", @@ -9164,7 +9180,6 @@ }, "node_modules/@types/parse-json": { "version": "4.0.0", - "devOptional": true, "license": "MIT" }, "node_modules/@types/plotly.js": { @@ -10588,7 +10603,6 @@ }, "node_modules/ansi-styles": { "version": "3.2.1", - "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^1.9.0" @@ -11241,7 +11255,6 @@ }, "node_modules/babel-plugin-macros": { "version": "3.1.0", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", @@ -12018,7 +12031,6 @@ }, "node_modules/callsites": { "version": "3.1.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -12099,7 +12111,6 @@ }, "node_modules/chalk": { "version": "2.4.2", - "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", @@ -12112,7 +12123,6 @@ }, "node_modules/chalk/node_modules/escape-string-regexp": { "version": "1.0.5", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.0" @@ -12479,7 +12489,6 @@ }, "node_modules/color-convert": { "version": "1.9.3", - "devOptional": true, "license": "MIT", "dependencies": { "color-name": "1.1.3" @@ -12874,7 +12883,6 @@ }, "node_modules/convert-source-map": { "version": "1.9.0", - "devOptional": true, "license": "MIT" }, "node_modules/cookie": { @@ -12987,7 +12995,6 @@ }, "node_modules/cosmiconfig": { "version": "7.1.0", - "devOptional": true, "license": "MIT", "dependencies": { "@types/parse-json": "^4.0.0", @@ -15427,7 +15434,6 @@ }, "node_modules/error-ex": { "version": "1.3.2", - "devOptional": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -15703,7 +15709,6 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -17616,8 +17621,7 @@ "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "devOptional": true + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" }, "node_modules/find-up": { "version": "5.0.0", @@ -19281,7 +19285,6 @@ }, "node_modules/has-flag": { "version": "3.0.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -19440,7 +19443,6 @@ }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", - "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "react-is": "^16.7.0" @@ -19448,7 +19450,6 @@ }, "node_modules/hoist-non-react-statics/node_modules/react-is": { "version": "16.13.1", - "devOptional": true, "license": "MIT" }, "node_modules/hoopy": { @@ -19853,7 +19854,6 @@ }, "node_modules/import-fresh": { "version": "3.3.0", - "devOptional": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -19868,7 +19868,6 @@ }, "node_modules/import-fresh/node_modules/resolve-from": { "version": "4.0.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -20108,7 +20107,6 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", - "devOptional": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -23977,7 +23975,6 @@ }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", - "devOptional": true, "license": "MIT" }, "node_modules/json-schema": { @@ -24170,7 +24167,6 @@ }, "node_modules/lines-and-columns": { "version": "1.2.4", - "devOptional": true, "license": "MIT" }, "node_modules/load-json-file": { @@ -26463,7 +26459,6 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "devOptional": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -26479,7 +26474,6 @@ }, "node_modules/parse-json": { "version": "5.2.0", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -26616,7 +26610,6 @@ }, "node_modules/path-type": { "version": "4.0.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -33830,7 +33823,6 @@ }, "node_modules/source-map": { "version": "0.5.7", - "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -34658,7 +34650,6 @@ }, "node_modules/supports-color": { "version": "5.5.0", - "devOptional": true, "license": "MIT", "dependencies": { "has-flag": "^3.0.0" @@ -35404,7 +35395,6 @@ }, "node_modules/to-fast-properties": { "version": "2.0.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -37813,7 +37803,6 @@ }, "node_modules/yaml": { "version": "1.10.2", - "devOptional": true, "license": "ISC", "engines": { "node": ">= 6" diff --git a/package.json b/package.json index 22d06856e..9bc4db818 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,8 @@ "react-plotly.js": "^2.6.0", "react-text-mask": "^5.5.0", "use-debouncy": "^4.4.0", - "zxcvbn": "^4.4.2" + "zxcvbn": "^4.4.2", + "@mui/x-tree-view": "^6.17.0" }, "peerDependencies": { "@emotion/react": "^11.11.1", diff --git a/src/TreeViewList/TreeViewList.spec.tsx b/src/TreeViewList/TreeViewList.spec.tsx new file mode 100644 index 000000000..1165fb2cf --- /dev/null +++ b/src/TreeViewList/TreeViewList.spec.tsx @@ -0,0 +1,136 @@ +import { expect, test } from "@playwright/test"; + +/** + * Test to check that the tree list is rendered correctly. + */ +test("should render the tree list", async ({ page }) => { + await page.goto( + "http://localhost:6006/?path=/story/lists-treeviewlist--default" + ); + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByRole("treeitem", { name: "AER" }) + ).toBeVisible(); + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByRole("treeitem", { name: "BRK" }) + ).toBeVisible(); +}); + +/** + * Test to check if the parameter value is displayed once a tree item is expanded. + */ +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("AER") + .click(); + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("ConsiderationPointPosition", { exact: true }) + ).toBeVisible(); +}); + +/** + * Test to check if the tooltip is visible when a tree item is hovered over. + */ +test("should display tooltip for the expanded parameter on hover", async ({ + page +}) => { + await page.goto( + "http://localhost:6006/?path=/story/lists-treeviewlist--default" + ); + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("BRK") + .click(); + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("MCBooster") + .click(); + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("HydESPModel") + .click(); + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("PumpMaxDelivery", { 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')." + ) + ).toBeVisible(); +}); + +/** + * Test to check if the tooltip is hidden when the mouse is moved away from a tree item. + */ +test("should hide tooltip when not hovering over parameter", async ({ + page +}) => { + await page.goto( + "http://localhost:6006/?path=/story/lists-treeviewlist--default" + ); + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("AER") + .click(); + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("ConsiderationPointPosition", { exact: true }) + .hover(); + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText( + "AER.ConsiderationPointPositionWhen the vehicle is gripped by side wind, the wind starts to take effect only from a certain point on, e.g. if only the bumper is attacked by side wind the driver will usually not recognize the effects. Ahead of this point, the vehicle body does not offer enough contact surface to the wind to take effect." + ) + ).toBeVisible(); + + // 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( + "AER.ConsiderationPointPositionWhen the vehicle is gripped by side wind, the wind starts to take effect only from a certain point on, e.g. if only the bumper is attacked by side wind the driver will usually not recognize the effects. Ahead of this point, the vehicle body does not offer enough contact surface to the wind to take effect." + ) + ).toBeHidden(); +}); + +/** + * Test to check if a tree item's children are hidden when the item is collapsed. + */ +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("AER") + .click(); + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("ConsiderationPointPosition", { exact: true }) + ).toBeVisible(); + await page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("AER") + .click(); + await expect( + page + .frameLocator('iframe[title="storybook-preview-iframe"]') + .getByText("ConsiderationPointPosition", { exact: true }) + ).toBeHidden(); +}); diff --git a/src/TreeViewList/TreeViewList.stories.tsx b/src/TreeViewList/TreeViewList.stories.tsx new file mode 100644 index 000000000..ed0c7442a --- /dev/null +++ b/src/TreeViewList/TreeViewList.stories.tsx @@ -0,0 +1,128 @@ +import { Item, TreeViewListProps } from "./TreeViewList.types"; + +import { Meta } from "@storybook/react"; +import React from "react"; +import TreeViewList from "./TreeViewList"; + +/** + * Story metadata + */ +const meta: Meta = { + component: TreeViewList, + title: "Lists/TreeViewList" +}; +export default meta; + +// Story Template +const Template = (args: TreeViewListProps>) => { + return ; +}; + +// Default +export const Default = { + args: { + items: [ + { + __v: 0, + _id: "64f1b6fd511d08b5f6bc2a20", + c1_data_mgmt_score: "", + c1_data_mgmt_score_comment: "", + c1_expected_gate: "", + c2_data_mgmt_score: "", + c2_data_mgmt_score_comment: "", + c2_expected_gate: "", + c3_data_mgmt_score: "", + c3_data_mgmt_score_comment: "", + c3_expected_gate: "", + characteristic: "ConsiderationPointPosition", + contributor1: "Aerodynamics", + contributor2: "IPG Automotive", + contributor3: "", + data_type: "Vector", + description: + "When the vehicle is gripped by side wind, the wind starts to take effect only from a certain point on, e.g. if only the bumper is attacked by side wind the driver will usually not recognize the effects. Ahead of this point, the vehicle body does not offer enough contact surface to the wind to take effect.", + dimension: 0, + display_name: "AER.ConsiderationPointPosition", + label1: "Position X", + label2: "Position Y", + label3: "Position Z", + length: 3, + level1: "AER", + level2: "", + level3: "", + level4: "", + level5: "", + lower_limit1: null, + lower_limit_inequality1: "", + name: "AER.ConsiderationPointPosition", + unit1: "m", + unit2: "m", + unit3: "m", + upper_limit1: null, + upper_limit_inequality1: "" + }, + { + __v: 0, + _id: "64f1b6fd511d08b5f6bc2a1f", + characteristic: "DragCoefficient1D", + contributor1: "Aerodynamics", + contributor2: "IPG Automotive", + contributor3: "", + data_type: "1D Lookup Table", + description: + "Defines the drag coefficient of the entire vehicle as a function of wind angle of attack (tau)", + dimension: 1, + display_name: "AER.DragCoefficient1D", + label1: "tau", + label2: "cD", + label3: "", + length: 1, + level1: "AER", + level2: "", + level3: "", + level4: "", + level5: "", + lower_limit1: null, + lower_limit_inequality1: "", + name: "AER.DragCoefficient1D", + unit1: "rad", + unit2: "-", + unit3: "", + upper_limit1: null, + upper_limit_inequality1: "" + }, + { + __v: 0, + _id: "64f1b6fd511d08b5f6bc2a21", + c3_expected_gate: "", + characteristic: "PumpMaxDelivery", + contributor1: "Brakes", + contributor2: "IPG Automotive", + contributor3: "", + data_type: "Scalar", + description: + "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').", + dimension: 0, + display_name: "BRK.MCBooster.HydESPModel.PumpMaxDelivery", + label1: "Conductance", + label2: "", + label3: "", + length: 1, + level1: "BRK", + level2: "MCBooster", + level3: "HydESPModel", + level4: "", + level5: "", + lower_limit1: null, + lower_limit_inequality1: "", + name: "BRK.MCBooster.HydESPModel.PumpMaxDelivery", + unit1: "m3/s*Pa", + unit2: "", + unit3: "", + upper_limit1: null, + upper_limit_inequality1: "" + } + ] + }, + render: Template +}; diff --git a/src/TreeViewList/TreeViewList.tsx b/src/TreeViewList/TreeViewList.tsx new file mode 100644 index 000000000..c5879c873 --- /dev/null +++ b/src/TreeViewList/TreeViewList.tsx @@ -0,0 +1,227 @@ +import { + FindOrCreateNodeProps, + Item, + ParseChildProps, + TooltipTreeItemProps, + TreeNode, + TreeViewListProps +} from "./TreeViewList.types"; +import React, { useState } from "react"; +import { TreeItem, TreeView } from "@mui/x-tree-view"; + +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"; + +/** + * A component that renders a tree view list. + * + * @param props - The properties for the tree view list. + * @property props.items - The items to display in the tree view list. + * @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 (selection: string) props.onSelectionChange - The function to call when the selection changes. + * @returns The tree view list component. + */ +const TreeViewList = ({ + items, + selected, + searchTerm, + defaultExpanded = [], + onSelectionChange +}: TreeViewListProps) => { + const [expanded, setExpanded] = useState(defaultExpanded); + + // handle tree toggle + const handleToggle = ( + event: React.SyntheticEvent, + nodeIds: string[] + ) => { + setExpanded(nodeIds); + }; + + // tooltip tree item + const TooltipTreeItem = (props: TooltipTreeItemProps) => { + const handleClick = () => { + if (props.children === null) { + onSelectionChange(props.node.name); + } + }; + + return ( + + {props.node.name} +
+ {props.tooltip} + + ) : ( + "" + ) + } + placement="bottom-start" + > + ({ + borderLeft: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`, + color: theme.palette.text.primary, + padding: "5px" + })} + /> +
+ ); + }; + + // search function + const applySearch = (search: string, parameters: Item[]) => { + const terms = search + .toUpperCase() + .trim() + .split(/(?:\.| )+/); + return parameters.filter(opt => + terms.every(term => + typeof opt.name === "string" + ? opt.name.toUpperCase().includes(term) + : false + ) + ); + }; + // build tree nodes + const parameters = buildParameterTree( + searchTerm !== undefined && searchTerm !== "" + ? applySearch(searchTerm, items) + : items + ); + const renderTree = (nodes: TreeNode[]) => + nodes.map(node => ( + + {Array.isArray(node.children) && node.children.length > 0 + ? renderTree(node.children) + : null} + + )); + + return ( + } + defaultExpandIcon={} + expanded={expanded} + selected={selected} + onNodeToggle={handleToggle} + > + {renderTree(parameters)} + + ); +}; + +export default TreeViewList; + +/** + * Creates a new tree node with the given properties. + * + * @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 TreeNode The newly created tree node. + */ +function createNode( + id: string, + name: string, + tooltip: string, + disable = true +): TreeNode { + return { + children: [], + disable, + id, + name, + tooltip + }; +} + +/** + * Builds a tree structure from a flat list of items. + * + * @param Item[] parameterMapping - The flat list of items to transform into a tree. + * @returns TreeNode[] The tree structure built from the input items. + * @example buildParameterTree(items) // returns TreeNode[] + */ +function buildParameterTree(parameterMapping: Item[]): TreeNode[] { + const itemsTree: TreeNode[] = []; + + // Find or create a node in the tree + const findOrCreateNode = ({ + nodes, + id, + name, + tooltip = "" + }: FindOrCreateNodeProps) => { + let node = nodes.find(n => n.id === id); + if (!node) { + node = createNode(id, name, tooltip); + nodes.push(node); + } + return node; + }; + + // Iterate through each item + parameterMapping.forEach(item => { + // Start with the root level + let currentLevelNodes = itemsTree; + + // Iterate through each level + for (const level in item) { + if (level.startsWith("level") && item[level]) { + const node = findOrCreateNode({ + id: item[level] as string, + name: item.name as string, + nodes: currentLevelNodes + }); + currentLevelNodes = node.children; + } + } + + // Add characteristic to the last node in the hierarchy + if (currentLevelNodes !== itemsTree && item.characteristic) { + parseChild({ + characteristic: item.characteristic as string, + name: item.name as string, + nodes: currentLevelNodes, + tooltip: item.description as string + }); + } + }); + + return itemsTree; +} + +/** + * Parses a child node and adds it to the provided nodes array. + * + * @param props - The properties for parsing a child node. + * @property props.nodes - The array of nodes to add the new child node to. + * @property props.characteristic - The characteristic value of the new child node. + * @property props.name - The name of the new child node. + * @property props.tooltip - The tooltip of the new child node. + */ +function parseChild({ nodes, characteristic, name, tooltip }: ParseChildProps) { + if (!characteristic) return; + + const newId = characteristic; + const thisNode = createNode(newId, name, tooltip); + nodes.push(thisNode); +} diff --git a/src/TreeViewList/TreeViewList.types.ts b/src/TreeViewList/TreeViewList.types.ts new file mode 100644 index 000000000..acb279d92 --- /dev/null +++ b/src/TreeViewList/TreeViewList.types.ts @@ -0,0 +1,149 @@ +/** + * 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. + */ + searchTerm: string; + + /** + * The IDs of the items that should be expanded by default. + */ + defaultExpanded?: string[]; + + /** + * The function to call when the selection changes. + */ + onSelectionChange: (value: string) => void; +}; + +/** + * Represents a node in a tree structure. + */ +export type TreeNode = { + /** + * The name of the node. + */ + name: string; + + /** + * The ID of the node. + */ + id: string; + + /** + * The child nodes of the node. + */ + children: TreeNode[]; + + /** + * Whether the node is disabled. + */ + disable?: boolean; + + /** + * The tooltip of the node. + */ + tooltip?: string; +}; + +/** + * Properties for the TooltipTreeItem component. + */ +export type TooltipTreeItemProps = { + /** + * The tooltip of the tree item. + */ + tooltip: string; + + /** + * The ID of the node. + */ + nodeId: string; + + /** + * The child nodes of the tree item. + */ + children?: React.ReactNode; + + /** + * The label of the tree item. + */ + label: string; + + /** + * The tree node. + */ + node: TreeNode; +}; + +/** + * Properties for the parseChild function. + */ +export type ParseChildProps = { + /** + * The nodes to add the new child node to. + */ + nodes: TreeNode[]; + + /** + * The characteristic value of the new child node. This would be the last child displayed. + */ + characteristic: string; + + /** + * The name of the new child node. + */ + name: string; + + /** + * The tooltip of the new child node. + */ + tooltip: string; +}; + +/** + * Properties for the findOrCreateNode function. + */ +export type FindOrCreateNodeProps = { + /** + * The nodes to find or create a node in. + */ + nodes: TreeNode[]; + + /** + * The ID of the node to find or create. + */ + id: string; + + /** + * The name of the node to find or create. + */ + name: string; + + /** + * The tooltip of the node to find or create. + */ + tooltip?: string; +}; diff --git a/src/TreeViewList/index.ts b/src/TreeViewList/index.ts new file mode 100644 index 000000000..c9809952f --- /dev/null +++ b/src/TreeViewList/index.ts @@ -0,0 +1,2 @@ +export { default } from "./TreeViewList"; +export type { TreeViewListProps } from "./TreeViewList.types";