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 <bramhavers@gmail.com>
This commit is contained in:
Eric Liu 2024-12-07 13:54:06 -08:00
commit 5f1e8de1e1
29 changed files with 414 additions and 273 deletions

View file

@ -4699,21 +4699,20 @@ export interface TreeNode {
### Props ### Props
| Prop name | Required | Kind | Reactive | Type | Default value | Description | | Prop name | Required | Kind | Reactive | Type | Default value | Description |
| :------------ | :------- | :-------------------- | :------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | :------------ | :------- | :-------------------- | :------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| expandedIds | No | <code>let</code> | Yes | <code>ReadonlyArray<TreeNodeId></code> | <code>[]</code> | Set the node ids to be expanded | | expandedIds | No | <code>let</code> | Yes | <code>ReadonlyArray<TreeNodeId></code> | <code>[]</code> | Set the node ids to be expanded |
| selectedIds | No | <code>let</code> | Yes | <code>ReadonlyArray<TreeNodeId></code> | <code>[]</code> | Set the node ids to be selected | | selectedIds | No | <code>let</code> | Yes | <code>ReadonlyArray<TreeNodeId></code> | <code>[]</code> | Set the node ids to be selected |
| activeId | No | <code>let</code> | Yes | <code>TreeNodeId</code> | <code>""</code> | Set the current active node id<br />Only one node can be active | | activeId | No | <code>let</code> | Yes | <code>TreeNodeId</code> | <code>""</code> | Set the current active node id<br />Only one node can be active |
| nodes | No | <code>let</code> | No | <code>Array<TreeNode></code> | <code>[]</code> | Provide a nested array of nodes to render | | nodes | No | <code>let</code> | No | <code>Array<TreeNode></code> | <code>[]</code> | Provide an array of nodes to render |
| size | No | <code>let</code> | No | <code>"default" &#124; "compact"</code> | <code>"default"</code> | Specify the TreeView size | | size | No | <code>let</code> | No | <code>"default" &#124; "compact"</code> | <code>"default"</code> | Specify the TreeView size |
| labelText | No | <code>let</code> | No | <code>string</code> | <code>""</code> | Specify the label text | | labelText | No | <code>let</code> | No | <code>string</code> | <code>""</code> | Specify the label text |
| hideLabel | No | <code>let</code> | No | <code>boolean</code> | <code>false</code> | Set to `true` to visually hide the label text | | hideLabel | No | <code>let</code> | No | <code>boolean</code> | <code>false</code> | Set to `true` to visually hide the label text |
| expandAll | No | <code>function</code> | No | <code>() => void</code> | <code>() => { expandedIds = [...nodeIds]; }</code> | Programmatically expand all nodes | | expandAll | No | <code>function</code> | No | <code>() => void</code> | <code>() => { expandedIds = [...nodeIds]; }</code> | Programmatically expand all nodes |
| collapseAll | No | <code>function</code> | No | <code>() => void</code> | <code>() => { expandedIds = []; }</code> | Programmatically collapse all nodes | | collapseAll | No | <code>function</code> | No | <code>() => void</code> | <code>() => { expandedIds = []; }</code> | Programmatically collapse all nodes |
| toHierarchy | No | <code>function</code> | No | <code>(flatArray: TreeNode[] & { pid?: any }[]) => TreeNode[]</code> | <code>() => { return th(flatArray); }</code> | Create a nested array from a flat array | | expandNodes | No | <code>function</code> | No | <code>(filterId?: (node: TreeNode) => boolean) => void</code> | <code>() => { expandedIds = flattenedNodes .filter( (node) => filterNode(node) &#124;&#124; node.nodes?.some((child) => filterNode(child) && child.nodes), ) .map((node) => node.id); }</code> | Programmatically expand a subset of nodes.<br />Expands all nodes if no argument is provided |
| expandNodes | No | <code>function</code> | No | <code>(filterId?: (node: TreeNode) => boolean) => void</code> | <code>() => { expandedIds = flattenedNodes .filter( (node) => filterNode(node) &#124;&#124; node.nodes?.some((child) => filterNode(child) && child.nodes), ) .map((node) => node.id); }</code> | Programmatically expand a subset of nodes.<br />Expands all nodes if no argument is provided | | collapseNodes | No | <code>function</code> | No | <code>(filterId?: (node: TreeNode) => boolean) => void</code> | <code>() => { expandedIds = flattenedNodes .filter((node) => expandedIds.includes(node.id) && !filterNode(node)) .map((node) => node.id); }</code> | Programmatically collapse a subset of nodes.<br />Collapses all nodes if no argument is provided |
| collapseNodes | No | <code>function</code> | No | <code>(filterId?: (node: TreeNode) => boolean) => void</code> | <code>() => { expandedIds = flattenedNodes .filter((node) => expandedIds.includes(node.id) && !filterNode(node)) .map((node) => node.id); }</code> | Programmatically collapse a subset of nodes.<br />Collapses all nodes if no argument is provided | | showNode | No | <code>function</code> | No | <code>(id: TreeNodeId) => void</code> | <code>() => { 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; } } }</code> | Programmatically show a node by `id`.<br />The matching node will be expanded, selected, and focused |
| showNode | No | <code>function</code> | No | <code>(id: TreeNodeId) => void</code> | <code>() => { 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; } } }</code> | Programmatically show a node by `id`.<br />The matching node will be expanded, selected, and focused |
### Slots ### Slots

View file

@ -24,7 +24,7 @@
} }
}, },
"..": { "..": {
"version": "0.86.2", "version": "0.86.1",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",

View file

@ -17770,7 +17770,7 @@
{ {
"name": "nodes", "name": "nodes",
"kind": "let", "kind": "let",
"description": "Provide a nested array of nodes to render", "description": "Provide an array of nodes to render",
"type": "Array<TreeNode>", "type": "Array<TreeNode>",
"value": "[]", "value": "[]",
"isFunction": false, "isFunction": false,
@ -17875,18 +17875,6 @@
"constant": false, "constant": false,
"reactive": 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", "name": "expandNodes",
"kind": "function", "kind": "function",

View file

@ -38,3 +38,10 @@ Set `type` to `"ordered"` to use the ordered list variant.
Set `type` to `"ordered-native"` to use the native styles for an ordered list. Set `type` to `"ordered-native"` to use the native styles for an ordered list.
<FileSource src="/framed/RecursiveList/RecursiveListOrderedNative" /> <FileSource src="/framed/RecursiveList/RecursiveListOrderedNative" />
## 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.
<FileSource src="/framed/RecursiveList/RecursiveListFlatArray" />

View file

@ -64,7 +64,6 @@ Expanded nodes can be set using `expandedIds`.
<FileSource src="/framed/TreeView/TreeViewExpanded" /> <FileSource src="/framed/TreeView/TreeViewExpanded" />
## Initial multiple selected nodes ## Initial multiple selected nodes
Initial multiple selected nodes can be set using `selectedIds`. 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 ## 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.
<FileSource src="/framed/TreeView/TreeViewFlatArray" /> <FileSource src="/framed/TreeView/TreeViewFlatArray" />

View file

@ -0,0 +1,20 @@
<script>
import { RecursiveList, toHierarchy } from "carbon-components-svelte";
const nodesFlat = [
{ id: 1, text: "Item 1" },
{ id: 2, text: "Item 1a", pid: 1 },
{ id: 3, html: "<h5>HTML content</h5>", pid: 2 },
{ id: 4, text: "Item 2" },
{ id: 5, href: "https://svelte.dev/", pid: 4 },
{
id: 6,
href: "https://svelte.dev/",
text: "Link with custom text",
pid: 4,
},
{ id: 7, text: "Item 3" },
];
</script>
<RecursiveList nodes={toHierarchy(nodesFlat, (node) => node.pid)} />

View file

@ -1,69 +1,28 @@
<script> <script>
import { TreeView, toHierarchy } from "carbon-components-svelte"; import { TreeView, toHierarchy } from "carbon-components-svelte";
import WatsonMachineLearning from "carbon-icons-svelte/lib/WatsonMachineLearning.svelte";
import Analytics from "carbon-icons-svelte/lib/Analytics.svelte"; import Analytics from "carbon-icons-svelte/lib/Analytics.svelte";
import Blockchain from "carbon-icons-svelte/lib/Blockchain.svelte";
import DataBase from "carbon-icons-svelte/lib/DataBase.svelte";
import SignalStrength from "carbon-icons-svelte/lib/SignalStrength.svelte";
let activeId = "";
let selectedIds = [];
let nodesFlat = [ let nodesFlat = [
{ id: 0, text: "AI / Machine learning", icon: WatsonMachineLearning }, { id: 0, text: "AI / Machine learning", icon: Analytics },
{ id: 1, text: "Analytics", icon: Analytics }, { id: 1, text: "Analytics" },
{ id: 2, text: "IBM Analytics Engine", pid: 1, icon: Analytics }, { id: 2, text: "IBM Analytics Engine", pid: 1 },
{ id: 3, text: "Apache Spark", pid: 2, icon: Analytics }, { id: 3, text: "Apache Spark", pid: 2 },
{ id: 4, text: "Hadoop", icon: Analytics, pid: 2 }, { id: 4, text: "Hadoop", pid: 2 },
{ id: 5, text: "IBM Cloud SQL Query", icon: Analytics, pid: 1 }, { id: 5, text: "IBM Cloud SQL Query", pid: 1 },
{ id: 6, text: "IBM Db2 Warehouse on Cloud", icon: Analytics, pid: 1 }, { id: 6, text: "IBM Db2 Warehouse on Cloud", pid: 1 },
{ id: 7, text: "Blockchain", icon: Blockchain }, { id: 7, text: "Blockchain" },
{ id: 8, text: "IBM Blockchain Platform", icon: Blockchain, pid: 7 }, { id: 8, text: "IBM Blockchain Platform", pid: 7 },
{ id: 9, text: "Databases", icon: DataBase }, { id: 9, text: "Databases" },
{ { id: 10, text: "IBM Cloud Databases for Elasticsearch", pid: 9 },
id: 10, { id: 11, text: "IBM Cloud Databases for Enterprise DB", pid: 9 },
text: "IBM Cloud Databases for Elasticsearch", { id: 12, text: "IBM Cloud Databases for MongoDB", pid: 9 },
icon: DataBase, { id: 13, text: "IBM Cloud Databases for PostgreSQL", pid: 9 },
pid: 9, { id: 14, text: "Integration", disabled: true },
}, { id: 15, text: "IBM API Connect", disabled: true, pid: 14 },
{
id: 11,
text: "IBM Cloud Databases for Enterprise DB",
icon: DataBase,
pid: 9,
},
{ id: 12, text: "IBM Cloud Databases for MongoDB", icon: DataBase, pid: 9 },
{
id: 13,
text: "IBM Cloud Databases for PostgreSQL",
icon: DataBase,
pid: 9,
},
{ id: 14, text: "Integration", icon: SignalStrength, disabled: true },
{
id: 15,
text: "IBM API Connect",
icon: SignalStrength,
disabled: true,
pid: 14,
},
]; ];
</script> </script>
<TreeView <TreeView
labelText="Cloud Products" labelText="Cloud Products"
nodes={toHierarchy(nodesFlat)} nodes={toHierarchy(nodesFlat, (node) => node.pid)}
bind:activeId
bind:selectedIds
on:select={({ detail }) => console.log("select", detail)}
on:toggle={({ detail }) => console.log("toggle", detail)}
on:focus={({ detail }) => console.log("focus", detail)}
/> />
<div>Active node id: {activeId}</div>
<div>Selected ids: {JSON.stringify(selectedIds)}</div>
<style>
div {
margin-top: var(--cds-spacing-05);
}
</style>

View file

@ -37,7 +37,7 @@
*/ */
/** /**
* Provide a nested array of nodes to render * Provide an array of nodes to render
* @type {Array<TreeNode>} * @type {Array<TreeNode>}
*/ */
export let nodes = []; export let nodes = [];
@ -89,14 +89,6 @@
expandedIds = []; 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. * Programmatically expand a subset of nodes.
* Expands all nodes if no argument is provided * Expands all nodes if no argument is provided
@ -155,7 +147,6 @@
import { createEventDispatcher, setContext, onMount, tick } from "svelte"; import { createEventDispatcher, setContext, onMount, tick } from "svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import TreeViewNodeList from "./TreeViewNodeList.svelte"; import TreeViewNodeList from "./TreeViewNodeList.svelte";
import { toHierarchy as th } from "./treeview";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const labelId = `label-${Math.random().toString(36)}`; const labelId = `label-${Math.random().toString(36)}`;

View file

@ -1,2 +1 @@
export { default as TreeView } from "./TreeView.svelte"; export { default as TreeView } from "./TreeView.svelte";
export { toHierarchy } from "./treeview";

View file

@ -1,2 +1 @@
export { default as TreeView } from "./TreeView.svelte"; export { default as TreeView } from "./TreeView.svelte";
export { toHierarchy } from "./treeview";

View file

@ -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;

View file

@ -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;

View file

@ -127,7 +127,6 @@ export { Tooltip, TooltipFooter } from "./Tooltip";
export { TooltipDefinition } from "./TooltipDefinition"; export { TooltipDefinition } from "./TooltipDefinition";
export { TooltipIcon } from "./TooltipIcon"; export { TooltipIcon } from "./TooltipIcon";
export { TreeView } from "./TreeView"; export { TreeView } from "./TreeView";
export { toHierarchy } from "./TreeView/treeview";
export { Truncate } from "./Truncate"; export { Truncate } from "./Truncate";
export { default as truncate } from "./Truncate/truncate"; export { default as truncate } from "./Truncate/truncate";
export { export {
@ -153,3 +152,4 @@ export {
HeaderSearch, HeaderSearch,
} from "./UIShell"; } from "./UIShell";
export { UnorderedList } from "./UnorderedList"; export { UnorderedList } from "./UnorderedList";
export { toHierarchy } from "./utils/toHierarchy";

21
src/utils/toHierarchy.d.ts vendored Normal file
View file

@ -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<T, "id" | "nodes">,
>(
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;

49
src/utils/toHierarchy.js Normal file
View file

@ -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<string, any>} [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;

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { TreeView as TreeViewNav } from "carbon-components-svelte"; import { TreeView as TreeViewNav } from "carbon-components-svelte";
import TreeView from "./TreeView/TreeView.test.svelte"; import TreeView from "./TreeView/TreeView.test.svelte";
import TreeViewHierarchy from "./TreeView/TreeView.hierarchy.test.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
const routes = [ const routes = [
@ -9,6 +10,11 @@
name: "TreeView", name: "TreeView",
component: TreeView, component: TreeView,
}, },
{
path: "/treeview-hierarchy",
name: "TreeViewHierarchy",
component: TreeViewHierarchy,
},
] as const; ] as const;
let currentPath = window.location.pathname; let currentPath = window.location.pathname;

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { RecursiveList } from "carbon-components-svelte";
import toHierarchy from "../../src/utils/toHierarchy";
let nodes = toHierarchy(
[
{ id: 1, text: "Item 1" },
{ id: 2, text: "Item 1a", pid: 1 },
{ id: 3, html: "<h5>HTML content</h5>", pid: 2 },
{ id: 4, text: "Item 2" },
{ id: 5, href: "https://svelte.dev/", pid: 4 },
{
id: 6,
href: "https://svelte.dev/",
text: "Link with custom text",
pid: 4,
},
{ id: 7, text: "Item 3" },
],
(node) => node.pid,
);
</script>
<RecursiveList type="ordered" {nodes} />

View file

@ -14,18 +14,14 @@
{ {
text: "Item 2", text: "Item 2",
nodes: [ nodes: [
{ { href: "https://svelte.dev/" },
href: "https://svelte.dev/",
},
{ {
href: "https://svelte.dev/", href: "https://svelte.dev/",
text: "Link with custom text", text: "Link with custom text",
}, },
], ],
}, },
{ { text: "Item 3" },
text: "Item 3",
},
]; ];
</script> </script>

View file

@ -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/");
});
});

View file

@ -0,0 +1,61 @@
<script lang="ts">
import { Button, TreeView } from "carbon-components-svelte";
import { toHierarchy } from "../../src/utils/toHierarchy";
import type { TreeNodeId } from "carbon-components-svelte/TreeView/TreeView.svelte";
import Analytics from "carbon-icons-svelte/lib/Analytics.svelte";
let treeview: TreeView;
let activeId: TreeNodeId = "";
let selectedIds: TreeNodeId[] = [];
let expandedIds: TreeNodeId[] = [];
let nodes = toHierarchy(
[
{ id: 0, text: "AI / Machine learning", icon: Analytics },
{ id: 1, text: "Analytics" },
{ id: 2, text: "IBM Analytics Engine", pid: 1 },
{ id: 3, text: "Apache Spark", pid: 2 },
{ id: 4, text: "Hadoop", pid: 2 },
{ id: 5, text: "IBM Cloud SQL Query", pid: 1 },
{ id: 6, text: "IBM Db2 Warehouse on Cloud", pid: 1 },
{ id: 7, text: "Blockchain" },
{ id: 8, text: "IBM Blockchain Platform", pid: 7 },
{ id: 9, text: "Databases" },
{ id: 10, text: "IBM Cloud Databases for Elasticsearch", pid: 9 },
{ id: 11, text: "IBM Cloud Databases for Enterprise DB", pid: 9 },
{ id: 12, text: "IBM Cloud Databases for MongoDB", pid: 9 },
{ id: 13, text: "IBM Cloud Databases for PostgreSQL", pid: 9 },
{ id: 14, text: "Integration", disabled: true },
{ id: 15, text: "IBM API Connect", disabled: true, pid: 14 },
],
(node) => node.pid,
);
$: console.log("selectedIds", selectedIds);
</script>
<TreeView
bind:this={treeview}
size="compact"
labelText="Cloud Products"
{nodes}
bind:activeId
bind:selectedIds
bind:expandedIds
on:select={({ detail }) => console.log("select", detail)}
on:toggle={({ detail }) => console.log("toggle", detail)}
on:focus={({ detail }) => console.log("focus", detail)}
let:node
>
{node.text}
</TreeView>
<Button on:click={treeview.expandAll}>Expand all</Button>
<Button
on:click={() => {
treeview.expandNodes((node) => {
return /^IBM/.test(node.text);
});
}}
>
Expand some nodes
</Button>

View file

@ -50,18 +50,6 @@
]; ];
$: console.log("selectedIds", selectedIds); $: 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);
} */
</script> </script>
<TreeView <TreeView

View file

@ -1,8 +1,14 @@
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import { user } from "../setup-tests"; import { user } from "../setup-tests";
import TreeViewHierarchy from "./TreeView.hierarchy.test.svelte";
import TreeView from "./TreeView.test.svelte"; import TreeView from "./TreeView.test.svelte";
describe("TreeView", () => { const testCases = [
{ name: "TreeView", component: TreeView },
{ name: "TreeView hierarchy", component: TreeViewHierarchy },
];
describe.each(testCases)("$name", ({ component }) => {
const getItemByName = (name: RegExp) => { const getItemByName = (name: RegExp) => {
return screen.getByRole("treeitem", { return screen.getByRole("treeitem", {
name, name,
@ -30,7 +36,7 @@ describe("TreeView", () => {
it("can select a node", async () => { it("can select a node", async () => {
const consoleLog = vi.spyOn(console, "log"); const consoleLog = vi.spyOn(console, "log");
render(TreeView); render(component);
const firstItem = getItemByName(/AI \/ Machine learning/); const firstItem = getItemByName(/AI \/ Machine learning/);
expect(firstItem).toBeInTheDocument(); expect(firstItem).toBeInTheDocument();
@ -49,7 +55,7 @@ describe("TreeView", () => {
}); });
it("can expand all nodes", async () => { it("can expand all nodes", async () => {
render(TreeView); render(component);
noExpandedItems(); noExpandedItems();
@ -60,7 +66,7 @@ describe("TreeView", () => {
}); });
it("can expand some nodes", async () => { it("can expand some nodes", async () => {
render(TreeView); render(component);
noExpandedItems(); noExpandedItems();

View file

@ -1,77 +0,0 @@
<script lang="ts">
import { Button, TreeView } from "carbon-components-svelte";
import type {
TreeNode,
TreeNodeId,
} from "carbon-components-svelte/TreeView/TreeView.svelte";
import Analytics from "carbon-icons-svelte/lib/Analytics.svelte";
import WatsonMachineLearning from "carbon-icons-svelte/lib/WatsonMachineLearning.svelte";
import Blockchain from "carbon-icons-svelte/lib/Blockchain.svelte";
import DataBase from "carbon-icons-svelte/lib/DataBase.svelte";
import SignalStrength from "carbon-icons-svelte/lib/SignalStrength.svelte";
let treeview: TreeView;
let activeId: TreeNodeId = "";
let selectedIds: TreeNodeId[] = [];
let expandedIds: TreeNodeId[] = [1];
let nodesFlat: TreeNode[] & { pid?: any }[] = [
{ id: 0, text: "AI / Machine learning", icon: WatsonMachineLearning },
{ id: 1, text: "Analytics", icon: Analytics },
{ id: 2, text: "IBM Analytics Engine", pid: 1, icon: Analytics },
{ id: 3, text: "Apache Spark", pid: 2, icon: Analytics },
{ id: 4, text: "Hadoop", icon: Analytics, pid: 2 },
{ id: 5, text: "IBM Cloud SQL Query", icon: Analytics, pid: 1 },
{ id: 6, text: "IBM Db2 Warehouse on Cloud", icon: Analytics, pid: 1 },
{ id: 7, text: "Blockchain", icon: Blockchain },
{ id: 8, text: "IBM Blockchain Platform", icon: Blockchain, pid: 7 },
{ id: 9, text: "Databases", icon: DataBase },
{
id: 10,
text: "IBM Cloud Databases for Elasticsearch",
icon: DataBase,
pid: 9,
},
{
id: 11,
text: "IBM Cloud Databases for Enterprise DB",
icon: DataBase,
pid: 9,
},
{ id: 12, text: "IBM Cloud Databases for MongoDB", icon: DataBase, pid: 9 },
{
id: 13,
text: "IBM Cloud Databases for PostgreSQL",
icon: DataBase,
pid: 9,
},
{ id: 14, text: "Integration", icon: SignalStrength, disabled: true },
{
id: 15,
text: "IBM API Connect",
icon: SignalStrength,
disabled: true,
pid: 14,
},
];
$: console.log("selectedIds", selectedIds);
</script>
<TreeView
bind:this={treeview}
size="compact"
labelText="Cloud Products"
nodes={treeview?.toHierarchy(nodesFlat)}
bind:activeId
bind:selectedIds
bind:expandedIds
on:select={({ detail }) => console.log("select", detail)}
on:toggle={({ detail }) => console.log("toggle", detail)}
on:focus={({ detail }) => console.log("focus", detail)}
let:node
>
{node.text}
</TreeView>
<Button on:click={treeview.expandAll}>Expand all</Button>

View file

@ -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");
});
});

View file

@ -15,7 +15,7 @@ type $RestProps = SvelteHTMLElements["ul"];
type $Props = { type $Props = {
/** /**
* Provide a nested array of nodes to render * Provide an array of nodes to render
* @default [] * @default []
*/ */
nodes?: Array<TreeNode>; nodes?: Array<TreeNode>;
@ -94,11 +94,6 @@ export default class TreeView extends SvelteComponentTyped<
*/ */
collapseAll: () => void; collapseAll: () => void;
/**
* Create a nested array from a flat array
*/
toHierarchy: (flatArray: TreeNode[] & { pid?: any }[]) => TreeNode[];
/** /**
* Programmatically expand a subset of nodes. * Programmatically expand a subset of nodes.
* Expands all nodes if no argument is provided * Expands all nodes if no argument is provided

View file

@ -1,2 +1 @@
export { default as TreeView } from "./TreeView.svelte"; export { default as TreeView } from "./TreeView.svelte";
export { toHierarchy } from "./treeview";

View file

@ -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;

2
types/index.d.ts vendored
View file

@ -143,7 +143,6 @@ export { default as TooltipFooter } from "./Tooltip/TooltipFooter.svelte";
export { default as TooltipDefinition } from "./TooltipDefinition/TooltipDefinition.svelte"; export { default as TooltipDefinition } from "./TooltipDefinition/TooltipDefinition.svelte";
export { default as TooltipIcon } from "./TooltipIcon/TooltipIcon.svelte"; export { default as TooltipIcon } from "./TooltipIcon/TooltipIcon.svelte";
export { default as TreeView } from "./TreeView/TreeView.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.svelte";
export { default as truncate } from "./Truncate/truncate"; export { default as truncate } from "./Truncate/truncate";
export { default as Header } from "./UIShell/Header.svelte"; 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 HeaderGlobalAction } from "./UIShell/HeaderGlobalAction.svelte";
export { default as HeaderSearch } from "./UIShell/HeaderSearch.svelte"; export { default as HeaderSearch } from "./UIShell/HeaderSearch.svelte";
export { default as UnorderedList } from "./UnorderedList/UnorderedList.svelte"; export { default as UnorderedList } from "./UnorderedList/UnorderedList.svelte";
export { default as toHierarchy } from "./utils/toHierarchy";

21
types/utils/toHierarchy.d.ts vendored Normal file
View file

@ -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<T, "id" | "nodes">,
>(
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;