From 5f1e8de1e1045dcb7d84173e242e345cad6ae864 Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Sat, 7 Dec 2024 13:54:06 -0800 Subject: [PATCH] Re-work `toHierarchy` utility Refactor `toHiearchy` to be more generic, performant - Use callback to "pick" generic parent ID property instead of requiring that `pid` be hardcoded` - Account for edge cases of an invalid parent ID - Use Map to store node children for lookups - Use one pass instead of removing empty nodes at the very end - DX: use generics to type `toHierarchy` - Make `toHierarchy` even more generic (reusable with `RecursiveList`) Co-Authored-By: Bram --- COMPONENT_INDEX.md | 29 +++-- docs/package-lock.json | 2 +- docs/src/COMPONENT_API.json | 14 +-- docs/src/pages/components/RecursiveList.svx | 9 +- docs/src/pages/components/TreeView.svx | 10 +- .../RecursiveListFlatArray.svelte | 20 ++++ .../framed/TreeView/TreeViewFlatArray.svelte | 75 +++---------- src/TreeView/TreeView.svelte | 11 +- src/TreeView/index.d.ts | 1 - src/TreeView/index.js | 1 - src/TreeView/treeview.d.ts | 9 -- src/TreeView/treeview.js | 40 ------- src/index.js | 2 +- src/utils/toHierarchy.d.ts | 21 ++++ src/utils/toHierarchy.js | 49 ++++++++ tests/App.test.svelte | 6 + .../RecursiveList.hierarchy.test.svelte | 24 ++++ .../RecursiveList.test.svelte | 8 +- tests/RecursiveList/RecursiveList.test.ts | 47 ++++++++ tests/TreeView/TreeView.hierarchy.test.svelte | 61 ++++++++++ tests/TreeView/TreeView.test.svelte | 12 -- tests/TreeView/TreeView.test.ts | 14 ++- tests/TreeView/TreeViewFlatArray.test.svelte | 77 ------------- tests/TreeView/toHierarchy.test.ts | 105 ++++++++++++++++++ types/TreeView/TreeView.svelte.d.ts | 7 +- types/TreeView/index.d.ts | 1 - types/TreeView/treeview.d.ts | 9 -- types/index.d.ts | 2 +- types/utils/toHierarchy.d.ts | 21 ++++ 29 files changed, 414 insertions(+), 273 deletions(-) create mode 100644 docs/src/pages/framed/RecursiveList/RecursiveListFlatArray.svelte delete mode 100644 src/TreeView/treeview.d.ts delete mode 100644 src/TreeView/treeview.js create mode 100644 src/utils/toHierarchy.d.ts create mode 100644 src/utils/toHierarchy.js create mode 100644 tests/RecursiveList/RecursiveList.hierarchy.test.svelte rename tests/{ => RecursiveList}/RecursiveList.test.svelte (83%) create mode 100644 tests/RecursiveList/RecursiveList.test.ts create mode 100644 tests/TreeView/TreeView.hierarchy.test.svelte delete mode 100644 tests/TreeView/TreeViewFlatArray.test.svelte create mode 100644 tests/TreeView/toHierarchy.test.ts delete mode 100644 types/TreeView/treeview.d.ts create mode 100644 types/utils/toHierarchy.d.ts diff --git a/COMPONENT_INDEX.md b/COMPONENT_INDEX.md index ab581107..3cd48491 100644 --- a/COMPONENT_INDEX.md +++ b/COMPONENT_INDEX.md @@ -4699,21 +4699,20 @@ export interface TreeNode { ### Props -| Prop name | Required | Kind | Reactive | Type | Default value | Description | -| :------------ | :------- | :-------------------- | :------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| expandedIds | No | let | Yes | ReadonlyArray | [] | Set the node ids to be expanded | -| selectedIds | No | let | Yes | ReadonlyArray | [] | Set the node ids to be selected | -| activeId | No | let | Yes | TreeNodeId | "" | Set the current active node id
Only one node can be active | -| nodes | No | let | No | Array | [] | Provide a nested array of nodes to render | -| size | No | let | No | "default" | "compact" | "default" | Specify the TreeView size | -| labelText | No | let | No | string | "" | Specify the label text | -| hideLabel | No | let | No | boolean | false | Set to `true` to visually hide the label text | -| expandAll | No | function | No | () => void | () => { expandedIds = [...nodeIds]; } | Programmatically expand all nodes | -| collapseAll | No | function | No | () => void | () => { expandedIds = []; } | Programmatically collapse all nodes | -| toHierarchy | No | function | No | (flatArray: TreeNode[] & { pid?: any }[]) => TreeNode[] | () => { return th(flatArray); } | Create a nested array from a flat array | -| expandNodes | No | function | No | (filterId?: (node: TreeNode) => boolean) => void | () => { expandedIds = flattenedNodes .filter( (node) => filterNode(node) || node.nodes?.some((child) => filterNode(child) && child.nodes), ) .map((node) => node.id); } | Programmatically expand a subset of nodes.
Expands all nodes if no argument is provided | -| collapseNodes | No | function | No | (filterId?: (node: TreeNode) => boolean) => void | () => { expandedIds = flattenedNodes .filter((node) => expandedIds.includes(node.id) && !filterNode(node)) .map((node) => node.id); } | Programmatically collapse a subset of nodes.
Collapses all nodes if no argument is provided | -| showNode | No | function | No | (id: TreeNodeId) => void | () => { for (const child of nodes) { const nodes = findNodeById(child, id); if (nodes) { const ids = nodes.map((node) => node.id); const nodeIds = new Set(ids); expandNodes((node) => nodeIds.has(node.id)); const lastId = ids[ids.length - 1]; activeId = lastId; selectedIds = [lastId]; tick().then(() => { ref?.querySelector(\`[id="${lastId}"]\`)?.focus(); }); break; } } } | Programmatically show a node by `id`.
The matching node will be expanded, selected, and focused | +| Prop name | Required | Kind | Reactive | Type | Default value | Description | +| :------------ | :------- | :-------------------- | :------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| expandedIds | No | let | Yes | ReadonlyArray | [] | Set the node ids to be expanded | +| selectedIds | No | let | Yes | ReadonlyArray | [] | Set the node ids to be selected | +| activeId | No | let | Yes | TreeNodeId | "" | Set the current active node id
Only one node can be active | +| nodes | No | let | No | Array | [] | Provide an array of nodes to render | +| size | No | let | No | "default" | "compact" | "default" | Specify the TreeView size | +| labelText | No | let | No | string | "" | Specify the label text | +| hideLabel | No | let | No | boolean | false | Set to `true` to visually hide the label text | +| expandAll | No | function | No | () => void | () => { expandedIds = [...nodeIds]; } | Programmatically expand all nodes | +| collapseAll | No | function | No | () => void | () => { expandedIds = []; } | Programmatically collapse all nodes | +| expandNodes | No | function | No | (filterId?: (node: TreeNode) => boolean) => void | () => { expandedIds = flattenedNodes .filter( (node) => filterNode(node) || node.nodes?.some((child) => filterNode(child) && child.nodes), ) .map((node) => node.id); } | Programmatically expand a subset of nodes.
Expands all nodes if no argument is provided | +| collapseNodes | No | function | No | (filterId?: (node: TreeNode) => boolean) => void | () => { expandedIds = flattenedNodes .filter((node) => expandedIds.includes(node.id) && !filterNode(node)) .map((node) => node.id); } | Programmatically collapse a subset of nodes.
Collapses all nodes if no argument is provided | +| showNode | No | function | No | (id: TreeNodeId) => void | () => { for (const child of nodes) { const nodes = findNodeById(child, id); if (nodes) { const ids = nodes.map((node) => node.id); const nodeIds = new Set(ids); expandNodes((node) => nodeIds.has(node.id)); const lastId = ids[ids.length - 1]; activeId = lastId; selectedIds = [lastId]; tick().then(() => { ref?.querySelector(\`[id="${lastId}"]\`)?.focus(); }); break; } } } | Programmatically show a node by `id`.
The matching node will be expanded, selected, and focused | ### Slots diff --git a/docs/package-lock.json b/docs/package-lock.json index 81fdc549..aa37ed80 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -24,7 +24,7 @@ } }, "..": { - "version": "0.86.2", + "version": "0.86.1", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", diff --git a/docs/src/COMPONENT_API.json b/docs/src/COMPONENT_API.json index 962830d4..c6ceb31d 100644 --- a/docs/src/COMPONENT_API.json +++ b/docs/src/COMPONENT_API.json @@ -17770,7 +17770,7 @@ { "name": "nodes", "kind": "let", - "description": "Provide a nested array of nodes to render", + "description": "Provide an array of nodes to render", "type": "Array", "value": "[]", "isFunction": false, @@ -17875,18 +17875,6 @@ "constant": false, "reactive": false }, - { - "name": "toHierarchy", - "kind": "function", - "description": "Create a nested array from a flat array", - "type": "(flatArray: TreeNode[] & { pid?: any }[]) => TreeNode[]", - "value": "() => {\n return th(flatArray);\n}", - "isFunction": true, - "isFunctionDeclaration": true, - "isRequired": false, - "constant": false, - "reactive": false - }, { "name": "expandNodes", "kind": "function", diff --git a/docs/src/pages/components/RecursiveList.svx b/docs/src/pages/components/RecursiveList.svx index d5941bda..fba581d1 100644 --- a/docs/src/pages/components/RecursiveList.svx +++ b/docs/src/pages/components/RecursiveList.svx @@ -37,4 +37,11 @@ Set `type` to `"ordered"` to use the ordered list variant. Set `type` to `"ordered-native"` to use the native styles for an ordered list. - \ No newline at end of file + + +## Flat data structure + +If working with a flat data structure, use the `toHierarchy` utility +to convert a flat data structure into a hierarchical array accepted by the `nodes` prop. + + diff --git a/docs/src/pages/components/TreeView.svx b/docs/src/pages/components/TreeView.svx index 5c639924..15e22015 100644 --- a/docs/src/pages/components/TreeView.svx +++ b/docs/src/pages/components/TreeView.svx @@ -64,7 +64,6 @@ Expanded nodes can be set using `expandedIds`. - ## Initial multiple selected nodes Initial multiple selected nodes can be set using `selectedIds`. @@ -111,10 +110,7 @@ If a matching node is found, it will be expanded, selected, and focused. ## Flat data structure -Use the `toHierarchy` method to provide a flat data structure to the `nodes` property. +If working with a flat data structure, use the `toHierarchy` utility +to convert a flat data structure into a hierarchical array accepted by the `nodes` prop. -This method will transform a flat array of objects into the hierarchical array as expected by `nodes`. -The child objects in the flat array need to have a `pid` property to reference its parent. -When `pid` is not provided the object is assumed to be at the root of the tree. - - \ No newline at end of file + diff --git a/docs/src/pages/framed/RecursiveList/RecursiveListFlatArray.svelte b/docs/src/pages/framed/RecursiveList/RecursiveListFlatArray.svelte new file mode 100644 index 00000000..356ffb45 --- /dev/null +++ b/docs/src/pages/framed/RecursiveList/RecursiveListFlatArray.svelte @@ -0,0 +1,20 @@ + + + node.pid)} /> diff --git a/docs/src/pages/framed/TreeView/TreeViewFlatArray.svelte b/docs/src/pages/framed/TreeView/TreeViewFlatArray.svelte index 0f14cc36..415aee4c 100644 --- a/docs/src/pages/framed/TreeView/TreeViewFlatArray.svelte +++ b/docs/src/pages/framed/TreeView/TreeViewFlatArray.svelte @@ -1,69 +1,28 @@ console.log("select", detail)} - on:toggle={({ detail }) => console.log("toggle", detail)} - on:focus={({ detail }) => console.log("focus", detail)} + nodes={toHierarchy(nodesFlat, (node) => node.pid)} /> - -
Active node id: {activeId}
-
Selected ids: {JSON.stringify(selectedIds)}
- - diff --git a/src/TreeView/TreeView.svelte b/src/TreeView/TreeView.svelte index 7f3a028e..31e3f267 100644 --- a/src/TreeView/TreeView.svelte +++ b/src/TreeView/TreeView.svelte @@ -37,7 +37,7 @@ */ /** - * Provide a nested array of nodes to render + * Provide an array of nodes to render * @type {Array} */ export let nodes = []; @@ -89,14 +89,6 @@ expandedIds = []; } - /** - * Create a nested array from a flat array - * @type {(flatArray: TreeNode[] & { pid?: any }[]) => TreeNode[]} - */ - export function toHierarchy(flatArray) { - return th(flatArray); - } - /** * Programmatically expand a subset of nodes. * Expands all nodes if no argument is provided @@ -155,7 +147,6 @@ import { createEventDispatcher, setContext, onMount, tick } from "svelte"; import { writable } from "svelte/store"; import TreeViewNodeList from "./TreeViewNodeList.svelte"; - import { toHierarchy as th } from "./treeview"; const dispatch = createEventDispatcher(); const labelId = `label-${Math.random().toString(36)}`; diff --git a/src/TreeView/index.d.ts b/src/TreeView/index.d.ts index 52e3204e..59f96be0 100644 --- a/src/TreeView/index.d.ts +++ b/src/TreeView/index.d.ts @@ -1,2 +1 @@ export { default as TreeView } from "./TreeView.svelte"; -export { toHierarchy } from "./treeview"; diff --git a/src/TreeView/index.js b/src/TreeView/index.js index 52e3204e..59f96be0 100644 --- a/src/TreeView/index.js +++ b/src/TreeView/index.js @@ -1,2 +1 @@ export { default as TreeView } from "./TreeView.svelte"; -export { toHierarchy } from "./treeview"; diff --git a/src/TreeView/treeview.d.ts b/src/TreeView/treeview.d.ts deleted file mode 100644 index 9506bbdc..00000000 --- a/src/TreeView/treeview.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type TreeNode } from "./TreeView.svelte"; -/** - * Create a nested array from a flat array - */ -export function toHierarchy( - flatArray: TreeNode[] & { pid?: any }[], -): TreeNode[]; - -export default toHierarchy; diff --git a/src/TreeView/treeview.js b/src/TreeView/treeview.js deleted file mode 100644 index 5804ae92..00000000 --- a/src/TreeView/treeview.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Create a nested array from a flat array - * @type {(flatArray: TreeNode[] & { pid?: any }[]) => TreeNode[]} - */ -export function toHierarchy(flatArray) { - /** @type TreeNode[] */ - const tree = []; - /** @type TreeNode[] */ - const childrenOf = []; - - flatArray.forEach((dstItem) => { - const { id, pid } = dstItem; - childrenOf[id] = childrenOf[id] || []; - dstItem["nodes"] = childrenOf[id]; - - if (pid) { - // objects without pid are root level objects. - childrenOf[pid] = childrenOf[pid] || []; - delete dstItem.pid; // TreeNode type doesn't have pid. - childrenOf[pid].push(dstItem); - } else { - delete dstItem.pid; - tree.push(dstItem); - } - }); - - // Remove the empty nodes props that make TreeView render a twistie. - function removeEmptyNodes(element) { - element.forEach((elmt) => { - if (elmt.nodes?.length === 0) delete elmt.nodes; - else { - removeEmptyNodes(elmt.nodes); - } - }); - } - removeEmptyNodes(tree); - return tree; -} - -export default toHierarchy; diff --git a/src/index.js b/src/index.js index 75a2c8dc..e28120a4 100644 --- a/src/index.js +++ b/src/index.js @@ -127,7 +127,6 @@ export { Tooltip, TooltipFooter } from "./Tooltip"; export { TooltipDefinition } from "./TooltipDefinition"; export { TooltipIcon } from "./TooltipIcon"; export { TreeView } from "./TreeView"; -export { toHierarchy } from "./TreeView/treeview"; export { Truncate } from "./Truncate"; export { default as truncate } from "./Truncate/truncate"; export { @@ -153,3 +152,4 @@ export { HeaderSearch, } from "./UIShell"; export { UnorderedList } from "./UnorderedList"; +export { toHierarchy } from "./utils/toHierarchy"; diff --git a/src/utils/toHierarchy.d.ts b/src/utils/toHierarchy.d.ts new file mode 100644 index 00000000..a805f899 --- /dev/null +++ b/src/utils/toHierarchy.d.ts @@ -0,0 +1,21 @@ +type NodeLike = { + id: string | number; + nodes?: NodeLike[]; + [key: string]: any; +}; + +/** Create a hierarchical tree from a flat array. */ +export function toHierarchy< + T extends NodeLike, + K extends keyof Omit, +>( + flatArray: T[] | readonly T[], + /** + * Function that returns the parent ID for a given node. + * @example + * toHierarchy(flatArray, (node) => node.parentId); + */ + getParentId: (node: T) => T[K] | null, +): (T & { nodes?: (T & { nodes?: T[] })[] })[]; + +export default toHierarchy; diff --git a/src/utils/toHierarchy.js b/src/utils/toHierarchy.js new file mode 100644 index 00000000..39f47b9e --- /dev/null +++ b/src/utils/toHierarchy.js @@ -0,0 +1,49 @@ +// @ts-check +/** + * Create a nested array from a flat array. + * @typedef {Object} NodeLike + * @property {string | number} id - Unique identifier for the node + * @property {NodeLike[]} [nodes] - Optional array of child nodes + * @property {Record} [additionalProperties] - Any additional properties + * + * @param {NodeLike[]} flatArray - Array of flat nodes to convert + * @param {function(NodeLike): (string|number|null)} getParentId - Function to get parent ID for a node + * @returns {NodeLike[]} Hierarchical tree structure + */ +export function toHierarchy(flatArray, getParentId) { + /** @type {NodeLike[]} */ + const tree = []; + const childrenOf = new Map(); + const itemsMap = new Map(flatArray.map((item) => [item.id, item])); + + flatArray.forEach((item) => { + const parentId = getParentId(item); + + // Only create nodes array if we have children. + const children = childrenOf.get(item.id); + if (children) { + item.nodes = children; + } + + // Check if parentId exists using Map instead of array lookup. + const parentExists = parentId && itemsMap.has(parentId); + + if (parentId && parentExists) { + if (!childrenOf.has(parentId)) { + childrenOf.set(parentId, []); + } + childrenOf.get(parentId).push(item); + + const parent = itemsMap.get(parentId); + if (parent) { + parent.nodes = childrenOf.get(parentId); + } + } else { + tree.push(item); + } + }); + + return tree; +} + +export default toHierarchy; diff --git a/tests/App.test.svelte b/tests/App.test.svelte index de04ad2f..96c876d1 100644 --- a/tests/App.test.svelte +++ b/tests/App.test.svelte @@ -1,6 +1,7 @@ + + diff --git a/tests/RecursiveList.test.svelte b/tests/RecursiveList/RecursiveList.test.svelte similarity index 83% rename from tests/RecursiveList.test.svelte rename to tests/RecursiveList/RecursiveList.test.svelte index 4aeda094..cb1e9f98 100644 --- a/tests/RecursiveList.test.svelte +++ b/tests/RecursiveList/RecursiveList.test.svelte @@ -14,18 +14,14 @@ { text: "Item 2", nodes: [ - { - href: "https://svelte.dev/", - }, + { href: "https://svelte.dev/" }, { href: "https://svelte.dev/", text: "Link with custom text", }, ], }, - { - text: "Item 3", - }, + { text: "Item 3" }, ]; diff --git a/tests/RecursiveList/RecursiveList.test.ts b/tests/RecursiveList/RecursiveList.test.ts new file mode 100644 index 00000000..1614c154 --- /dev/null +++ b/tests/RecursiveList/RecursiveList.test.ts @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/svelte"; +import RecursiveListHierarchyTest from "./RecursiveList.hierarchy.test.svelte"; +import RecursiveListTest from "./RecursiveList.test.svelte"; + +const testCases = [ + { name: "RecursiveList", component: RecursiveListTest }, + { name: "RecursiveList hierarchy", component: RecursiveListHierarchyTest }, +]; + +describe.each(testCases)("$name", ({ component }) => { + it("renders all top-level items", () => { + render(component); + + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(screen.getByText("Item 2")).toBeInTheDocument(); + expect(screen.getByText("Item 3")).toBeInTheDocument(); + + expect(screen.getAllByRole("list")).toHaveLength(4); + + // Nested items + expect(screen.getByText("Item 1a")).toBeInTheDocument(); + }); + + it("renders HTML content", () => { + render(component); + + const htmlContent = screen.getByText("HTML content"); + expect(htmlContent.tagName).toBe("H5"); + }); + + it("renders links correctly", () => { + render(component); + + const links = screen.getAllByRole("link"); + expect(links).toHaveLength(2); + + // Link with custom text + const customLink = screen.getByText("Link with custom text"); + expect(customLink).toHaveAttribute("href", "https://svelte.dev/"); + + // Plain link + const plainLink = links.find( + (link) => link.textContent === "https://svelte.dev/", + ); + expect(plainLink).toHaveAttribute("href", "https://svelte.dev/"); + }); +}); diff --git a/tests/TreeView/TreeView.hierarchy.test.svelte b/tests/TreeView/TreeView.hierarchy.test.svelte new file mode 100644 index 00000000..93229866 --- /dev/null +++ b/tests/TreeView/TreeView.hierarchy.test.svelte @@ -0,0 +1,61 @@ + + + console.log("select", detail)} + on:toggle={({ detail }) => console.log("toggle", detail)} + on:focus={({ detail }) => console.log("focus", detail)} + let:node +> + {node.text} + + + + diff --git a/tests/TreeView/TreeView.test.svelte b/tests/TreeView/TreeView.test.svelte index fb89bb49..38873616 100644 --- a/tests/TreeView/TreeView.test.svelte +++ b/tests/TreeView/TreeView.test.svelte @@ -50,18 +50,6 @@ ]; $: console.log("selectedIds", selectedIds); - - /* $: if (treeview) { - treeview.expandAll(); - treeview.expandNodes((node) => { - return +node.id > 0; - }); - treeview.collapseAll(); - treeview.collapseNodes((node) => { - return node.disabled === true; - }); - treeview.showNode(1); - } */ { +const testCases = [ + { name: "TreeView", component: TreeView }, + { name: "TreeView hierarchy", component: TreeViewHierarchy }, +]; + +describe.each(testCases)("$name", ({ component }) => { const getItemByName = (name: RegExp) => { return screen.getByRole("treeitem", { name, @@ -30,7 +36,7 @@ describe("TreeView", () => { it("can select a node", async () => { const consoleLog = vi.spyOn(console, "log"); - render(TreeView); + render(component); const firstItem = getItemByName(/AI \/ Machine learning/); expect(firstItem).toBeInTheDocument(); @@ -49,7 +55,7 @@ describe("TreeView", () => { }); it("can expand all nodes", async () => { - render(TreeView); + render(component); noExpandedItems(); @@ -60,7 +66,7 @@ describe("TreeView", () => { }); it("can expand some nodes", async () => { - render(TreeView); + render(component); noExpandedItems(); diff --git a/tests/TreeView/TreeViewFlatArray.test.svelte b/tests/TreeView/TreeViewFlatArray.test.svelte deleted file mode 100644 index 00539472..00000000 --- a/tests/TreeView/TreeViewFlatArray.test.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - - console.log("select", detail)} - on:toggle={({ detail }) => console.log("toggle", detail)} - on:focus={({ detail }) => console.log("focus", detail)} - let:node -> - {node.text} - - - diff --git a/tests/TreeView/toHierarchy.test.ts b/tests/TreeView/toHierarchy.test.ts new file mode 100644 index 00000000..66f946c1 --- /dev/null +++ b/tests/TreeView/toHierarchy.test.ts @@ -0,0 +1,105 @@ +import { toHierarchy } from "../../src/utils/toHierarchy"; + +describe("toHierarchy", () => { + test("should create a flat hierarchy when no items have parents", () => { + const input = [ + { id: 1, name: "Item 1" }, + { id: 2, name: "Item 2", parentId: "invalid" }, + ]; + const result = toHierarchy(input, (item) => item.parentId); + + expect(result).toEqual([ + { id: 1, name: "Item 1" }, + { id: 2, name: "Item 2", parentId: "invalid" }, + ]); + }); + + test("should create a nested hierarchy with parent-child relationships", () => { + const input = [ + { id: 1, name: "Parent" }, + { id: 2, name: "Child", pid: 1, randomKey: "randomValue" }, + { id: 3, name: "Grandchild", pid: 2 }, + ]; + const result = toHierarchy(input, (item) => item.pid); + + expect(result).toEqual([ + { + id: 1, + name: "Parent", + nodes: [ + { + id: 2, + name: "Child", + pid: 1, + nodes: [ + { + id: 3, + name: "Grandchild", + pid: 2, + }, + ], + randomKey: "randomValue", + }, + ], + }, + ]); + }); + + test("should handle multiple root nodes with children", () => { + const input = [ + { id: 1, name: "Root 1" }, + { id: 2, name: "Root 2" }, + { id: 3, name: "Child 1", pid: 1 }, + { id: 4, name: "Child 2", pid: 2 }, + ]; + const result = toHierarchy(input, (item) => item.pid); + + expect(result).toEqual([ + { + id: 1, + name: "Root 1", + nodes: [ + { + id: 3, + name: "Child 1", + pid: 1, + }, + ], + }, + { + id: 2, + name: "Root 2", + nodes: [ + { + id: 4, + name: "Child 2", + pid: 2, + }, + ], + }, + ]); + }); + + test("should remove empty nodes arrays", () => { + const input = [ + { id: 1, name: "Root" }, + { id: 2, name: "Leaf", pid: 1 }, + ]; + const result = toHierarchy(input, (item) => item.pid); + expect(result).toEqual([ + { + id: 1, + name: "Root", + nodes: [ + { + id: 2, + name: "Leaf", + pid: 1, + }, + ], + }, + ]); + + expect(result[0].nodes?.[0]).not.toHaveProperty("nodes"); + }); +}); diff --git a/types/TreeView/TreeView.svelte.d.ts b/types/TreeView/TreeView.svelte.d.ts index b70f2119..342f24c9 100644 --- a/types/TreeView/TreeView.svelte.d.ts +++ b/types/TreeView/TreeView.svelte.d.ts @@ -15,7 +15,7 @@ type $RestProps = SvelteHTMLElements["ul"]; type $Props = { /** - * Provide a nested array of nodes to render + * Provide an array of nodes to render * @default [] */ nodes?: Array; @@ -94,11 +94,6 @@ export default class TreeView extends SvelteComponentTyped< */ collapseAll: () => void; - /** - * Create a nested array from a flat array - */ - toHierarchy: (flatArray: TreeNode[] & { pid?: any }[]) => TreeNode[]; - /** * Programmatically expand a subset of nodes. * Expands all nodes if no argument is provided diff --git a/types/TreeView/index.d.ts b/types/TreeView/index.d.ts index 52e3204e..59f96be0 100644 --- a/types/TreeView/index.d.ts +++ b/types/TreeView/index.d.ts @@ -1,2 +1 @@ export { default as TreeView } from "./TreeView.svelte"; -export { toHierarchy } from "./treeview"; diff --git a/types/TreeView/treeview.d.ts b/types/TreeView/treeview.d.ts deleted file mode 100644 index 9506bbdc..00000000 --- a/types/TreeView/treeview.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type TreeNode } from "./TreeView.svelte"; -/** - * Create a nested array from a flat array - */ -export function toHierarchy( - flatArray: TreeNode[] & { pid?: any }[], -): TreeNode[]; - -export default toHierarchy; diff --git a/types/index.d.ts b/types/index.d.ts index d48b80fc..be215d97 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -143,7 +143,6 @@ export { default as TooltipFooter } from "./Tooltip/TooltipFooter.svelte"; export { default as TooltipDefinition } from "./TooltipDefinition/TooltipDefinition.svelte"; export { default as TooltipIcon } from "./TooltipIcon/TooltipIcon.svelte"; export { default as TreeView } from "./TreeView/TreeView.svelte"; -export { default as toHierarchy } from "./TreeView/treeview"; export { default as Truncate } from "./Truncate/Truncate.svelte"; export { default as truncate } from "./Truncate/truncate"; export { default as Header } from "./UIShell/Header.svelte"; @@ -167,3 +166,4 @@ export { default as SkipToContent } from "./UIShell/SkipToContent.svelte"; export { default as HeaderGlobalAction } from "./UIShell/HeaderGlobalAction.svelte"; export { default as HeaderSearch } from "./UIShell/HeaderSearch.svelte"; export { default as UnorderedList } from "./UnorderedList/UnorderedList.svelte"; +export { default as toHierarchy } from "./utils/toHierarchy"; diff --git a/types/utils/toHierarchy.d.ts b/types/utils/toHierarchy.d.ts new file mode 100644 index 00000000..a805f899 --- /dev/null +++ b/types/utils/toHierarchy.d.ts @@ -0,0 +1,21 @@ +type NodeLike = { + id: string | number; + nodes?: NodeLike[]; + [key: string]: any; +}; + +/** Create a hierarchical tree from a flat array. */ +export function toHierarchy< + T extends NodeLike, + K extends keyof Omit, +>( + flatArray: T[] | readonly T[], + /** + * Function that returns the parent ID for a given node. + * @example + * toHierarchy(flatArray, (node) => node.parentId); + */ + getParentId: (node: T) => T[K] | null, +): (T & { nodes?: (T & { nodes?: T[] })[] })[]; + +export default toHierarchy;