Skip to content

Commit 48afd18

Browse files
metonymbhavers
andauthored
feat: add toHierarchy utility for TreeView, RecursiveList (#2072)
Co-authored-by: Bram <[email protected]>
1 parent f1a27ec commit 48afd18

19 files changed

+413
-23
lines changed

docs/src/pages/components/RecursiveList.svx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,11 @@ Set `type` to `"ordered"` to use the ordered list variant.
3737

3838
Set `type` to `"ordered-native"` to use the native styles for an ordered list.
3939

40-
<FileSource src="/framed/RecursiveList/RecursiveListOrderedNative" />
40+
<FileSource src="/framed/RecursiveList/RecursiveListOrderedNative" />
41+
42+
## Flat data structure
43+
44+
If working with a flat data structure, use the `toHierarchy` utility
45+
to convert a flat data structure into a hierarchical array accepted by the `nodes` prop.
46+
47+
<FileSource src="/framed/RecursiveList/RecursiveListFlatArray" />

docs/src/pages/components/TreeView.svx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,10 @@ Use the `TreeView.showNode` method to show a specific node.
107107
If a matching node is found, it will be expanded, selected, and focused.
108108

109109
<FileSource src="/framed/TreeView/TreeViewShowNode" />
110+
111+
## Flat data structure
112+
113+
If working with a flat data structure, use the `toHierarchy` utility
114+
to convert a flat data structure into a hierarchical array accepted by the `nodes` prop.
115+
116+
<FileSource src="/framed/TreeView/TreeViewFlatArray" />
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script>
2+
import { RecursiveList, toHierarchy } from "carbon-components-svelte";
3+
4+
const nodesFlat = [
5+
{ id: 1, text: "Item 1" },
6+
{ id: 2, text: "Item 1a", pid: 1 },
7+
{ id: 3, html: "<h5>HTML content</h5>", pid: 2 },
8+
{ id: 4, text: "Item 2" },
9+
{ id: 5, href: "https://svelte.dev/", pid: 4 },
10+
{
11+
id: 6,
12+
href: "https://svelte.dev/",
13+
text: "Link with custom text",
14+
pid: 4,
15+
},
16+
{ id: 7, text: "Item 3" },
17+
];
18+
</script>
19+
20+
<RecursiveList nodes={toHierarchy(nodesFlat, (node) => node.pid)} />
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<script>
2+
import { TreeView, toHierarchy } from "carbon-components-svelte";
3+
import Analytics from "carbon-icons-svelte/lib/Analytics.svelte";
4+
5+
let nodesFlat = [
6+
{ id: 0, text: "AI / Machine learning", icon: Analytics },
7+
{ id: 1, text: "Analytics" },
8+
{ id: 2, text: "IBM Analytics Engine", pid: 1 },
9+
{ id: 3, text: "Apache Spark", pid: 2 },
10+
{ id: 4, text: "Hadoop", pid: 2 },
11+
{ id: 5, text: "IBM Cloud SQL Query", pid: 1 },
12+
{ id: 6, text: "IBM Db2 Warehouse on Cloud", pid: 1 },
13+
{ id: 7, text: "Blockchain" },
14+
{ id: 8, text: "IBM Blockchain Platform", pid: 7 },
15+
{ id: 9, text: "Databases" },
16+
{ id: 10, text: "IBM Cloud Databases for Elasticsearch", pid: 9 },
17+
{ id: 11, text: "IBM Cloud Databases for Enterprise DB", pid: 9 },
18+
{ id: 12, text: "IBM Cloud Databases for MongoDB", pid: 9 },
19+
{ id: 13, text: "IBM Cloud Databases for PostgreSQL", pid: 9 },
20+
{ id: 14, text: "Integration", disabled: true },
21+
{ id: 15, text: "IBM API Connect", disabled: true, pid: 14 },
22+
];
23+
</script>
24+
25+
<TreeView
26+
labelText="Cloud Products"
27+
nodes={toHierarchy(nodesFlat, (node) => node.pid)}
28+
/>

src/TreeView/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as TreeView } from "./TreeView.svelte";

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,4 @@ export {
152152
HeaderSearch,
153153
} from "./UIShell";
154154
export { UnorderedList } from "./UnorderedList";
155+
export { toHierarchy } from "./utils/toHierarchy";

src/utils/toHierarchy.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
type NodeLike = {
2+
id: string | number;
3+
nodes?: NodeLike[];
4+
[key: string]: any;
5+
};
6+
7+
/** Create a hierarchical tree from a flat array. */
8+
export function toHierarchy<
9+
T extends NodeLike,
10+
K extends keyof Omit<T, "id" | "nodes">,
11+
>(
12+
flatArray: T[] | readonly T[],
13+
/**
14+
* Function that returns the parent ID for a given node.
15+
* @example
16+
* toHierarchy(flatArray, (node) => node.parentId);
17+
*/
18+
getParentId: (node: T) => T[K] | null,
19+
): (T & { nodes?: (T & { nodes?: T[] })[] })[];
20+
21+
export default toHierarchy;

src/utils/toHierarchy.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// @ts-check
2+
/**
3+
* Create a nested array from a flat array.
4+
* @typedef {Object} NodeLike
5+
* @property {string | number} id - Unique identifier for the node
6+
* @property {NodeLike[]} [nodes] - Optional array of child nodes
7+
* @property {Record<string, any>} [additionalProperties] - Any additional properties
8+
*
9+
* @param {NodeLike[]} flatArray - Array of flat nodes to convert
10+
* @param {function(NodeLike): (string|number|null)} getParentId - Function to get parent ID for a node
11+
* @returns {NodeLike[]} Hierarchical tree structure
12+
*/
13+
export function toHierarchy(flatArray, getParentId) {
14+
/** @type {NodeLike[]} */
15+
const tree = [];
16+
const childrenOf = new Map();
17+
const itemsMap = new Map(flatArray.map((item) => [item.id, item]));
18+
19+
flatArray.forEach((item) => {
20+
const parentId = getParentId(item);
21+
22+
// Only create nodes array if we have children.
23+
const children = childrenOf.get(item.id);
24+
if (children) {
25+
item.nodes = children;
26+
}
27+
28+
// Check if parentId exists using Map instead of array lookup.
29+
const parentExists = parentId && itemsMap.has(parentId);
30+
31+
if (parentId && parentExists) {
32+
if (!childrenOf.has(parentId)) {
33+
childrenOf.set(parentId, []);
34+
}
35+
childrenOf.get(parentId).push(item);
36+
37+
const parent = itemsMap.get(parentId);
38+
if (parent) {
39+
parent.nodes = childrenOf.get(parentId);
40+
}
41+
} else {
42+
tree.push(item);
43+
}
44+
});
45+
46+
return tree;
47+
}
48+
49+
export default toHierarchy;

tests/App.test.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import { TreeView as TreeViewNav } from "carbon-components-svelte";
33
import TreeView from "./TreeView/TreeView.test.svelte";
4+
import TreeViewHierarchy from "./TreeView/TreeView.hierarchy.test.svelte";
45
import { onMount } from "svelte";
56
67
const routes = [
@@ -9,6 +10,11 @@
910
name: "TreeView",
1011
component: TreeView,
1112
},
13+
{
14+
path: "/treeview-hierarchy",
15+
name: "TreeViewHierarchy",
16+
component: TreeViewHierarchy,
17+
},
1218
] as const;
1319
1420
let currentPath = window.location.pathname;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script lang="ts">
2+
import { RecursiveList } from "carbon-components-svelte";
3+
import toHierarchy from "../../src/utils/toHierarchy";
4+
5+
let nodes = toHierarchy(
6+
[
7+
{ id: 1, text: "Item 1" },
8+
{ id: 2, text: "Item 1a", pid: 1 },
9+
{ id: 3, html: "<h5>HTML content</h5>", pid: 2 },
10+
{ id: 4, text: "Item 2" },
11+
{ id: 5, href: "https://svelte.dev/", pid: 4 },
12+
{
13+
id: 6,
14+
href: "https://svelte.dev/",
15+
text: "Link with custom text",
16+
pid: 4,
17+
},
18+
{ id: 7, text: "Item 3" },
19+
],
20+
(node) => node.pid,
21+
);
22+
</script>
23+
24+
<RecursiveList type="ordered" {nodes} />

tests/RecursiveList.test.svelte renamed to tests/RecursiveList/RecursiveList.test.svelte

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,14 @@
1414
{
1515
text: "Item 2",
1616
nodes: [
17-
{
18-
href: "https://svelte.dev/",
19-
},
17+
{ href: "https://svelte.dev/" },
2018
{
2119
href: "https://svelte.dev/",
2220
text: "Link with custom text",
2321
},
2422
],
2523
},
26-
{
27-
text: "Item 3",
28-
},
24+
{ text: "Item 3" },
2925
];
3026
</script>
3127

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { render, screen } from "@testing-library/svelte";
2+
import RecursiveListHierarchyTest from "./RecursiveList.hierarchy.test.svelte";
3+
import RecursiveListTest from "./RecursiveList.test.svelte";
4+
5+
const testCases = [
6+
{ name: "RecursiveList", component: RecursiveListTest },
7+
{ name: "RecursiveList hierarchy", component: RecursiveListHierarchyTest },
8+
];
9+
10+
describe.each(testCases)("$name", ({ component }) => {
11+
it("renders all top-level items", () => {
12+
render(component);
13+
14+
expect(screen.getByText("Item 1")).toBeInTheDocument();
15+
expect(screen.getByText("Item 2")).toBeInTheDocument();
16+
expect(screen.getByText("Item 3")).toBeInTheDocument();
17+
18+
expect(screen.getAllByRole("list")).toHaveLength(4);
19+
20+
// Nested items
21+
expect(screen.getByText("Item 1a")).toBeInTheDocument();
22+
});
23+
24+
it("renders HTML content", () => {
25+
render(component);
26+
27+
const htmlContent = screen.getByText("HTML content");
28+
expect(htmlContent.tagName).toBe("H5");
29+
});
30+
31+
it("renders links correctly", () => {
32+
render(component);
33+
34+
const links = screen.getAllByRole("link");
35+
expect(links).toHaveLength(2);
36+
37+
// Link with custom text
38+
const customLink = screen.getByText("Link with custom text");
39+
expect(customLink).toHaveAttribute("href", "https://svelte.dev/");
40+
41+
// Plain link
42+
const plainLink = links.find(
43+
(link) => link.textContent === "https://svelte.dev/",
44+
);
45+
expect(plainLink).toHaveAttribute("href", "https://svelte.dev/");
46+
});
47+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<script lang="ts">
2+
import { Button, TreeView } from "carbon-components-svelte";
3+
import { toHierarchy } from "../../src/utils/toHierarchy";
4+
import type { TreeNodeId } from "carbon-components-svelte/TreeView/TreeView.svelte";
5+
import Analytics from "carbon-icons-svelte/lib/Analytics.svelte";
6+
7+
let treeview: TreeView;
8+
let activeId: TreeNodeId = "";
9+
let selectedIds: TreeNodeId[] = [];
10+
let expandedIds: TreeNodeId[] = [];
11+
let nodes = toHierarchy(
12+
[
13+
{ id: 0, text: "AI / Machine learning", icon: Analytics },
14+
{ id: 1, text: "Analytics" },
15+
{ id: 2, text: "IBM Analytics Engine", pid: 1 },
16+
{ id: 3, text: "Apache Spark", pid: 2 },
17+
{ id: 4, text: "Hadoop", pid: 2 },
18+
{ id: 5, text: "IBM Cloud SQL Query", pid: 1 },
19+
{ id: 6, text: "IBM Db2 Warehouse on Cloud", pid: 1 },
20+
{ id: 7, text: "Blockchain" },
21+
{ id: 8, text: "IBM Blockchain Platform", pid: 7 },
22+
{ id: 9, text: "Databases" },
23+
{ id: 10, text: "IBM Cloud Databases for Elasticsearch", pid: 9 },
24+
{ id: 11, text: "IBM Cloud Databases for Enterprise DB", pid: 9 },
25+
{ id: 12, text: "IBM Cloud Databases for MongoDB", pid: 9 },
26+
{ id: 13, text: "IBM Cloud Databases for PostgreSQL", pid: 9 },
27+
{ id: 14, text: "Integration", disabled: true },
28+
{ id: 15, text: "IBM API Connect", disabled: true, pid: 14 },
29+
],
30+
(node) => node.pid,
31+
);
32+
33+
$: console.log("selectedIds", selectedIds);
34+
</script>
35+
36+
<TreeView
37+
bind:this={treeview}
38+
size="compact"
39+
labelText="Cloud Products"
40+
{nodes}
41+
bind:activeId
42+
bind:selectedIds
43+
bind:expandedIds
44+
on:select={({ detail }) => console.log("select", detail)}
45+
on:toggle={({ detail }) => console.log("toggle", detail)}
46+
on:focus={({ detail }) => console.log("focus", detail)}
47+
let:node
48+
>
49+
{node.text}
50+
</TreeView>
51+
52+
<Button on:click={treeview.expandAll}>Expand all</Button>
53+
<Button
54+
on:click={() => {
55+
treeview.expandNodes((node) => {
56+
return /^IBM/.test(node.text);
57+
});
58+
}}
59+
>
60+
Expand some nodes
61+
</Button>

tests/TreeView/TreeView.test.svelte

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,6 @@
5050
];
5151
5252
$: console.log("selectedIds", selectedIds);
53-
54-
/* $: if (treeview) {
55-
treeview.expandAll();
56-
treeview.expandNodes((node) => {
57-
return +node.id > 0;
58-
});
59-
treeview.collapseAll();
60-
treeview.collapseNodes((node) => {
61-
return node.disabled === true;
62-
});
63-
treeview.showNode(1);
64-
} */
6553
</script>
6654

6755
<TreeView

tests/TreeView/TreeView.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { render, screen } from "@testing-library/svelte";
22
import { user } from "../setup-tests";
3+
import TreeViewHierarchy from "./TreeView.hierarchy.test.svelte";
34
import TreeView from "./TreeView.test.svelte";
45

5-
describe("TreeView", () => {
6+
const testCases = [
7+
{ name: "TreeView", component: TreeView },
8+
{ name: "TreeView hierarchy", component: TreeViewHierarchy },
9+
];
10+
11+
describe.each(testCases)("$name", ({ component }) => {
612
const getItemByName = (name: RegExp) => {
713
return screen.getByRole("treeitem", {
814
name,
@@ -30,7 +36,7 @@ describe("TreeView", () => {
3036
it("can select a node", async () => {
3137
const consoleLog = vi.spyOn(console, "log");
3238

33-
render(TreeView);
39+
render(component);
3440

3541
const firstItem = getItemByName(/AI \/ Machine learning/);
3642
expect(firstItem).toBeInTheDocument();
@@ -49,7 +55,7 @@ describe("TreeView", () => {
4955
});
5056

5157
it("can expand all nodes", async () => {
52-
render(TreeView);
58+
render(component);
5359

5460
noExpandedItems();
5561

@@ -60,7 +66,7 @@ describe("TreeView", () => {
6066
});
6167

6268
it("can expand some nodes", async () => {
63-
render(TreeView);
69+
render(component);
6470

6571
noExpandedItems();
6672

0 commit comments

Comments
 (0)