From 48afd18e5e01c2839024b8ddb31038267bcedeb8 Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Mon, 9 Dec 2024 12:22:36 -0800 Subject: [PATCH] feat: add `toHierarchy` utility for `TreeView`, `RecursiveList` (#2072) Co-authored-by: Bram --- docs/src/pages/components/RecursiveList.svx | 9 +- docs/src/pages/components/TreeView.svx | 7 ++ .../RecursiveListFlatArray.svelte | 20 ++++ .../framed/TreeView/TreeViewFlatArray.svelte | 28 +++++ src/TreeView/index.d.ts | 1 + src/index.js | 1 + 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/toHierarchy.test.ts | 105 ++++++++++++++++++ types/TreeView/index.d.ts | 1 + types/index.d.ts | 1 + types/utils/toHierarchy.d.ts | 21 ++++ 19 files changed, 413 insertions(+), 23 deletions(-) create mode 100644 docs/src/pages/framed/RecursiveList/RecursiveListFlatArray.svelte create mode 100644 docs/src/pages/framed/TreeView/TreeViewFlatArray.svelte create mode 100644 src/TreeView/index.d.ts 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 create mode 100644 tests/TreeView/toHierarchy.test.ts create mode 100644 types/TreeView/index.d.ts create mode 100644 types/utils/toHierarchy.d.ts diff --git a/docs/src/pages/components/RecursiveList.svx b/docs/src/pages/components/RecursiveList.svx index d5941bdafe..fba581d110 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 c7c97c92de..15e22015f6 100644 --- a/docs/src/pages/components/TreeView.svx +++ b/docs/src/pages/components/TreeView.svx @@ -107,3 +107,10 @@ Use the `TreeView.showNode` method to show a specific node. If a matching node is found, it will be expanded, selected, and focused. + +## 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/framed/RecursiveList/RecursiveListFlatArray.svelte b/docs/src/pages/framed/RecursiveList/RecursiveListFlatArray.svelte new file mode 100644 index 0000000000..356ffb45d1 --- /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 new file mode 100644 index 0000000000..415aee4c3c --- /dev/null +++ b/docs/src/pages/framed/TreeView/TreeViewFlatArray.svelte @@ -0,0 +1,28 @@ + + + node.pid)} +/> diff --git a/src/TreeView/index.d.ts b/src/TreeView/index.d.ts new file mode 100644 index 0000000000..59f96be0f3 --- /dev/null +++ b/src/TreeView/index.d.ts @@ -0,0 +1 @@ +export { default as TreeView } from "./TreeView.svelte"; diff --git a/src/index.js b/src/index.js index ea1a84cf46..e28120a430 100644 --- a/src/index.js +++ b/src/index.js @@ -152,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 0000000000..a805f8999c --- /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 0000000000..39f47b9eb7 --- /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 de04ad2fe1..96c876d1f2 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 4aeda09427..cb1e9f980d 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 0000000000..1614c1545a --- /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 0000000000..932298664c --- /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 fb89bb49fe..3887361674 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/toHierarchy.test.ts b/tests/TreeView/toHierarchy.test.ts new file mode 100644 index 0000000000..66f946c190 --- /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/index.d.ts b/types/TreeView/index.d.ts new file mode 100644 index 0000000000..59f96be0f3 --- /dev/null +++ b/types/TreeView/index.d.ts @@ -0,0 +1 @@ +export { default as TreeView } from "./TreeView.svelte"; diff --git a/types/index.d.ts b/types/index.d.ts index 1174ce26c1..be215d974d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -166,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 0000000000..a805f8999c --- /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;