Skip to content

Commit 68a2f6f

Browse files
authored
[WC-3163] Treenode v2 (#2166)
2 parents e75cf0e + 0468a7a commit 68a2f6f

34 files changed

Lines changed: 1415 additions & 89 deletions

packages/pluggableWidgets/tree-node-web/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- We added `Parent association` config to allow creating infinite level treenodes by using self reference association to itself. Fill this configuration with the association and Treenode will render as an infinite treenode.
12+
913
## [3.8.0] - 2026-01-16
1014

1115
### Changed

packages/pluggableWidgets/tree-node-web/e2e/TreeNode.spec.js

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import { AxeBuilder } from "@axe-core/playwright";
12
import { test, expect } from "@mendix/run-e2e/fixtures";
23
import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers";
3-
import AxeBuilder from "@axe-core/playwright";
4+
5+
async function navigateToV2Page(page) {
6+
await page.goto("/");
7+
await waitForMendixApp(page);
8+
await page.getByRole("menuitem", { name: "Tree Node V2" }).click();
9+
await waitForMendixApp(page);
10+
}
411

512
function getTreeNodeHeaders(page) {
613
return page.locator(".mx-name-treeNode1 .widget-tree-node-branch-header-value");
@@ -52,6 +59,125 @@ test.describe("capabilities: collapse", () => {
5259
});
5360
});
5461

62+
test.describe("v2: lazy loading (parentAssociation)", () => {
63+
test.beforeEach(async ({ page }) => {
64+
await navigateToV2Page(page);
65+
});
66+
67+
test("renders only root nodes initially", async ({ page }) => {
68+
const widget = page.locator(".mx-name-treeNodeV2_1");
69+
await expect(widget.getByRole("treeitem", { name: "Electronics" })).toBeVisible();
70+
await expect(widget.getByRole("treeitem", { name: "Clothing" })).toBeVisible();
71+
await expect(widget.getByRole("treeitem", { name: "Books" })).toBeVisible();
72+
// root nodes start collapsed — verify via aria-expanded (children are CSS-clipped, not removed from DOM)
73+
await expect(widget.getByRole("treeitem", { name: "Electronics" })).toHaveAttribute("aria-expanded", "false");
74+
await expect(widget.getByRole("treeitem", { name: "Clothing" })).toHaveAttribute("aria-expanded", "false");
75+
});
76+
77+
test("shows expand icon on nodes that have children", async ({ page }) => {
78+
const widget = page.locator(".mx-name-treeNodeV2_1");
79+
// Scope to the node's own header (first .widget-tree-node-branch-header) to avoid
80+
// matching icon containers from pre-loaded children nested inside the same treeitem.
81+
const electronicsIcon = widget
82+
.getByRole("treeitem", { name: "Electronics" })
83+
.locator(".widget-tree-node-branch-header")
84+
.first()
85+
.locator(".widget-tree-node-branch-header-icon-container");
86+
await expect(electronicsIcon).toBeVisible();
87+
// Books has no children — icon container is never rendered
88+
const booksIcon = widget
89+
.getByRole("treeitem", { name: "Books" })
90+
.locator(".widget-tree-node-branch-header")
91+
.first()
92+
.locator(".widget-tree-node-branch-header-icon-container");
93+
await expect(booksIcon).not.toBeVisible();
94+
});
95+
96+
test("lazy loads children when a node is expanded", async ({ page }) => {
97+
const widget = page.locator(".mx-name-treeNodeV2_1");
98+
await widget
99+
.getByRole("treeitem", { name: "Electronics" })
100+
.locator(".widget-tree-node-branch-header")
101+
.first()
102+
.click();
103+
await expect(widget.getByRole("treeitem", { name: "Phones" })).toBeVisible();
104+
await expect(widget.getByRole("treeitem", { name: "Laptops" })).toBeVisible();
105+
await expect(widget.getByRole("treeitem", { name: "Tablets" })).toBeVisible();
106+
});
107+
108+
test("lazy loads grandchildren when a nested node is expanded", async ({ page }) => {
109+
const widget = page.locator(".mx-name-treeNodeV2_1");
110+
await widget
111+
.getByRole("treeitem", { name: "Electronics" })
112+
.locator(".widget-tree-node-branch-header")
113+
.first()
114+
.click();
115+
const phonesItem = widget.getByRole("treeitem", { name: "Phones" });
116+
await expect(phonesItem).toBeVisible();
117+
await phonesItem.locator(".widget-tree-node-branch-header").first().click();
118+
// grandchildren load via network; assert Phones expanded (aria-expanded changes immediately)
119+
// then verify Android/iOS appear as treeitems in the DOM
120+
await expect(phonesItem).toHaveAttribute("aria-expanded", "true", { timeout: 8000 });
121+
await expect(widget.getByRole("treeitem", { name: "Android" })).toBeAttached({ timeout: 8000 });
122+
await expect(widget.getByRole("treeitem", { name: "iOS" })).toBeAttached({ timeout: 8000 });
123+
});
124+
125+
test("collapses children when an expanded node is clicked again", async ({ page }) => {
126+
const widget = page.locator(".mx-name-treeNodeV2_1");
127+
const electronicsItem = widget.getByRole("treeitem", { name: "Electronics" });
128+
await electronicsItem.locator(".widget-tree-node-branch-header").first().click();
129+
await expect(widget.getByRole("treeitem", { name: "Phones" })).toBeVisible();
130+
await electronicsItem.locator(".widget-tree-node-branch-header").first().click();
131+
// Collapse is CSS-only (grid 0fr); assert via aria-expanded rather than child visibility
132+
await expect(electronicsItem).toHaveAttribute("aria-expanded", "false");
133+
});
134+
135+
test("multiple root nodes can be expanded independently", async ({ page }) => {
136+
const widget = page.locator(".mx-name-treeNodeV2_1");
137+
await widget
138+
.getByRole("treeitem", { name: "Electronics" })
139+
.locator(".widget-tree-node-branch-header")
140+
.first()
141+
.click();
142+
await widget
143+
.getByRole("treeitem", { name: "Clothing" })
144+
.locator(".widget-tree-node-branch-header")
145+
.first()
146+
.click();
147+
await expect(widget.getByRole("treeitem", { name: "Phones" })).toBeVisible();
148+
await expect(widget.getByRole("treeitem", { name: "Men", exact: true })).toBeVisible();
149+
await expect(widget.getByRole("treeitem", { name: "Women" })).toBeVisible();
150+
});
151+
});
152+
153+
test.describe("v2: startExpanded", () => {
154+
test.beforeEach(async ({ page }) => {
155+
await navigateToV2Page(page);
156+
});
157+
158+
test("renders all nodes expanded when startExpanded is true", async ({ page }) => {
159+
const widget = page.locator(".mx-name-treeNodeV2_2");
160+
// All levels should be present with aria-expanded="true"
161+
await expect(widget.getByRole("treeitem", { name: "Electronics" }).first()).toBeVisible();
162+
// Phones has children — assert it is expanded
163+
await expect(widget.getByRole("treeitem", { name: "Phones" }).first()).toHaveAttribute(
164+
"aria-expanded",
165+
"true",
166+
{
167+
timeout: 8000
168+
}
169+
);
170+
});
171+
172+
test("can still collapse nodes when startExpanded is true", async ({ page }) => {
173+
const widget = page.locator(".mx-name-treeNodeV2_2");
174+
const electronicsItem = widget.getByRole("treeitem", { name: "Electronics" }).first();
175+
await electronicsItem.locator(".widget-tree-node-branch-header").first().click();
176+
// Collapse is CSS-only (grid 0fr); assert via aria-expanded rather than child visibility
177+
await expect(electronicsItem).toHaveAttribute("aria-expanded", "false");
178+
});
179+
});
180+
55181
test.describe("a11y testing:", () => {
56182
test("checks accessibility violations", async ({ page }) => {
57183
await page.goto("/");
-185 Bytes
Loading
-334 Bytes
Loading
-334 Bytes
Loading

packages/pluggableWidgets/tree-node-web/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,15 @@
2323
},
2424
"testProject": {
2525
"githubUrl": "https://github.com/mendix/testProjects",
26-
"branchName": "tree-node-web/main"
26+
"branchName": "tree-node-web/tree-node-v2"
2727
},
2828
"scripts": {
2929
"build": "pluggable-widgets-tools build:web",
3030
"create-translation": "rui-create-translation",
3131
"dev": "pluggable-widgets-tools start:web",
32-
"e2e": "run-e2e ci",
32+
"e2e": "MENDIX_VERSION=11.9.1 run-e2e ci",
3333
"e2e-update-project": "pnpm --filter @mendix/data-widgets run build:include-deps",
34-
"e2edev": "run-e2e dev --with-preps",
34+
"e2edev": "MENDIX_VERSION=11.9.1 run-e2e dev --with-preps",
3535
"format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .",
3636
"lint": "eslint src/ package.json",
3737
"release": "pluggable-widgets-tools release:web",

packages/pluggableWidgets/tree-node-web/src/TreeNode.editorConfig.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
import {
2+
hidePropertiesIn,
3+
hidePropertyIn,
4+
Problem,
5+
Properties,
6+
transformGroupsIntoTabs
7+
} from "@mendix/pluggable-widgets-tools";
18
import {
29
ContainerProps,
310
DropZoneProps,
@@ -6,13 +13,6 @@ import {
613
StructurePreviewProps,
714
TextProps
815
} from "@mendix/widget-plugin-platform/preview/structure-preview-api";
9-
import {
10-
hidePropertiesIn,
11-
hidePropertyIn,
12-
Problem,
13-
Properties,
14-
transformGroupsIntoTabs
15-
} from "@mendix/pluggable-widgets-tools";
1616

1717
import { HeaderTypeEnum, TreeNodePreviewProps } from "../typings/TreeNodeProps";
1818

@@ -35,8 +35,13 @@ export function getProperties(
3535
hidePropertyIn(defaultProperties, values, "headerCaption");
3636
}
3737

38-
if (!values.hasChildren) {
39-
hidePropertiesIn(defaultProperties, values, ["startExpanded", "children"]);
38+
if (values.parentAssociation) {
39+
hidePropertyIn(defaultProperties, values, "hasChildren");
40+
} else {
41+
hidePropertyIn(defaultProperties, values, "noDataMessage");
42+
if (!values.hasChildren) {
43+
hidePropertiesIn(defaultProperties, values, ["startExpanded", "children"]);
44+
}
4045
}
4146

4247
if (platform === "web") {
@@ -59,6 +64,7 @@ export function getProperties(
5964

6065
export function getPreview(values: TreeNodePreviewProps, isDarkMode: boolean): StructurePreviewProps | null {
6166
const palette = structurePreviewPalette[isDarkMode ? "dark" : "light"];
67+
const showChildren = values.hasChildren || !!values.parentAssociation;
6268

6369
const titleHeader: RowLayoutProps = {
6470
type: "RowLayout",
@@ -91,7 +97,7 @@ export function getPreview(values: TreeNodePreviewProps, isDarkMode: boolean): S
9197
columnSize: "grow",
9298
padding: 4,
9399
children: [
94-
...(values.showIcon === "left" && values.hasChildren
100+
...(values.showIcon === "left" && showChildren
95101
? [getChevronIconPreview(values.headerType, isDarkMode)]
96102
: []),
97103
values.headerType === "text"
@@ -115,7 +121,7 @@ export function getPreview(values: TreeNodePreviewProps, isDarkMode: boolean): S
115121
]
116122
} as RowLayoutProps),
117123

118-
...(values.showIcon === "right" && values.hasChildren
124+
...(values.showIcon === "right" && showChildren
119125
? [getChevronIconPreview(values.headerType, isDarkMode)]
120126
: [])
121127
]
@@ -124,7 +130,7 @@ export function getPreview(values: TreeNodePreviewProps, isDarkMode: boolean): S
124130
};
125131

126132
const getTreeNodeContent: () => StructurePreviewProps[] = () =>
127-
values.hasChildren
133+
showChildren
128134
? [
129135
{
130136
type: "RowLayout",

packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style";
33
import { GUID } from "mendix";
44
import { ReactElement } from "react";
55
import { TreeNodePreviewProps } from "../typings/TreeNodeProps";
6-
import { TreeNode } from "./components/TreeNode";
6+
import { TreeNode } from "./components/v1/TreeNode";
77

88
function renderTextTemplateWithFallback(textTemplateValue: string, placeholder: string): string {
99
if (textTemplateValue.trim().length === 0) {
Lines changed: 8 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,12 @@
1-
import { ObjectItem, ValueStatus } from "mendix";
2-
import { ReactElement, useEffect, useState } from "react";
1+
import { ReactElement } from "react";
32
import { TreeNodeContainerProps } from "../typings/TreeNodeProps";
4-
import { InfoTreeNodeItem, TreeNode as TreeNodeComponent, TreeNodeItem } from "./components/TreeNode";
5-
6-
function mapDataSourceItemToTreeNodeItem(item: ObjectItem, props: TreeNodeContainerProps): TreeNodeItem {
7-
return {
8-
id: item.id,
9-
headerContent:
10-
props.headerType === "text" ? props.headerCaption?.get(item).value : props.headerContent?.get(item),
11-
bodyContent: props.children?.get(item),
12-
isUserDefinedLeafNode: props.hasChildren?.get(item).value === false
13-
};
14-
}
3+
import { TreeNodeV1 } from "./components/v1/Root";
4+
import { TreeNodeV2 } from "./components/v2/TreeNode";
155

166
export function TreeNode(props: TreeNodeContainerProps): ReactElement {
17-
const { datasource } = props;
18-
const [treeNodeItems, setTreeNodeItems] = useState<TreeNodeItem[] | InfoTreeNodeItem | null>([]);
19-
20-
useEffect(() => {
21-
// only get the items when datasource is actually available
22-
// this is to prevent treenode resetting it's render while datasource is loading.
23-
if (datasource.status === ValueStatus.Available) {
24-
if (datasource.items && datasource.items.length) {
25-
setTreeNodeItems(datasource.items.map(item => mapDataSourceItemToTreeNodeItem(item, props)));
26-
} else {
27-
setTreeNodeItems({
28-
Message: "No data available"
29-
});
30-
}
31-
}
32-
}, [datasource.status, datasource.items]);
33-
const expandedIcon = props.expandedIcon?.status === ValueStatus.Available ? props.expandedIcon.value : undefined;
34-
const collapsedIcon = props.collapsedIcon?.status === ValueStatus.Available ? props.collapsedIcon.value : undefined;
35-
36-
return (
37-
<TreeNodeComponent
38-
class={props.class}
39-
style={props.style}
40-
items={treeNodeItems}
41-
startExpanded={props.startExpanded}
42-
showCustomIcon={Boolean(props.expandedIcon) || Boolean(props.collapsedIcon)}
43-
iconPlacement={props.showIcon}
44-
expandedIcon={expandedIcon}
45-
collapsedIcon={collapsedIcon}
46-
tabIndex={props.tabIndex}
47-
animateIcon={props.animate && props.animateIcon}
48-
animateTreeNodeContent={props.animate}
49-
openNodeOn={props.openNodeOn}
50-
/>
51-
);
7+
if (props.parentAssociation) {
8+
return <TreeNodeV2 {...props} />;
9+
} else {
10+
return <TreeNodeV1 {...props} />;
11+
}
5212
}

packages/pluggableWidgets/tree-node-web/src/TreeNode.xml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616
<caption>Data source</caption>
1717
<description />
1818
</property>
19+
<property key="parentAssociation" type="association" dataSource="datasource" selectableObjects="datasource" required="false">
20+
<caption>Parent association</caption>
21+
<description>Select the self-referencing association that connects each item to its parent, enabling infinite depth hierarchies.</description>
22+
<associationTypes>
23+
<associationType name="Reference" />
24+
</associationTypes>
25+
</property>
1926
<property key="headerType" type="enumeration" defaultValue="text">
2027
<caption>Header type</caption>
2128
<description />
@@ -83,6 +90,16 @@
8390
<description>Animate the icon when the group is collapsing or expanding.</description>
8491
</property>
8592
</propertyGroup>
93+
<propertyGroup caption="Texts">
94+
<property key="noDataMessage" type="textTemplate" required="false">
95+
<caption>No data message</caption>
96+
<description>Message to show when there are no items to display.</description>
97+
<translations>
98+
<translation lang="en_US">No data available</translation>
99+
<translation lang="nl_NL">Geen gegevens beschikbaar</translation>
100+
</translations>
101+
</property>
102+
</propertyGroup>
86103
</propertyGroup>
87104
</properties>
88105
</widget>

0 commit comments

Comments
 (0)