Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/tree-node-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- 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.

## [3.8.0] - 2026-01-16

### Changed
Expand Down
121 changes: 121 additions & 0 deletions packages/pluggableWidgets/tree-node-web/e2e/TreeNode.spec.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

Expand All @@ -6,6 +6,13 @@
await page.evaluate(() => window.mx.session.logout());
});

async function navigateToV2Page(page) {
await page.goto("/");
await page.waitForLoadState("networkidle");
await page.getByRole("menuitem", { name: "Tree Node V2" }).click();
await page.waitForLoadState("networkidle");
}

function getTreeNodeHeaders(page) {
return page.locator(".mx-name-treeNode1 .widget-tree-node-branch-header-value");
}
Expand Down Expand Up @@ -56,6 +63,120 @@
});
});

test.describe("v2: lazy loading (parentAssociation)", () => {
test.beforeEach(async ({ page }) => {
await navigateToV2Page(page);
});

test("renders only root nodes initially", async ({ page }) => {
const widget = page.locator(".mx-name-treeNodeV2_1");
await expect(widget.getByRole("treeitem", { name: "Electronics" })).toBeVisible();
await expect(widget.getByRole("treeitem", { name: "Clothing" })).toBeVisible();
await expect(widget.getByRole("treeitem", { name: "Books" })).toBeVisible();
// root nodes start collapsed — verify via aria-expanded (children are CSS-clipped, not removed from DOM)
await expect(widget.getByRole("treeitem", { name: "Electronics" })).toHaveAttribute("aria-expanded", "false");
await expect(widget.getByRole("treeitem", { name: "Clothing" })).toHaveAttribute("aria-expanded", "false");
});

test("shows expand icon on nodes that have children", async ({ page }) => {
const widget = page.locator(".mx-name-treeNodeV2_1");
const electronicsHeader = widget
.getByRole("treeitem", { name: "Electronics" })
.locator(".widget-tree-node-branch-header-icon-container");
await expect(electronicsHeader).toBeVisible();
// Books has no children — no icon
const booksIcon = widget
.getByRole("treeitem", { name: "Books" })
.locator(".widget-tree-node-branch-header-icon-container");
await expect(booksIcon).not.toBeVisible();
});

test("lazy loads children when a node is expanded", async ({ page }) => {
const widget = page.locator(".mx-name-treeNodeV2_1");
await widget
.getByRole("treeitem", { name: "Electronics" })
.locator(".widget-tree-node-branch-header")
.first()
.click();
await expect(widget.getByRole("treeitem", { name: "Phones" })).toBeVisible();
await expect(widget.getByRole("treeitem", { name: "Laptops" })).toBeVisible();
await expect(widget.getByRole("treeitem", { name: "Tablets" })).toBeVisible();
});

test("lazy loads grandchildren when a nested node is expanded", async ({ page }) => {
const widget = page.locator(".mx-name-treeNodeV2_1");
await widget
.getByRole("treeitem", { name: "Electronics" })
.locator(".widget-tree-node-branch-header")
.first()
.click();
const phonesItem = widget.getByRole("treeitem", { name: "Phones" });
await expect(phonesItem).toBeVisible();
await phonesItem.locator(".widget-tree-node-branch-header").first().click();
// grandchildren load via network; assert Phones expanded (aria-expanded changes immediately)
// then verify Android/iOS appear as treeitems in the DOM
await expect(phonesItem).toHaveAttribute("aria-expanded", "true", { timeout: 8000 });
await expect(widget.getByRole("treeitem", { name: "Android" })).toBeAttached({ timeout: 8000 });
await expect(widget.getByRole("treeitem", { name: "iOS" })).toBeAttached({ timeout: 8000 });
});

test("collapses children when an expanded node is clicked again", async ({ page }) => {
const widget = page.locator(".mx-name-treeNodeV2_1");
const electronicsItem = widget.getByRole("treeitem", { name: "Electronics" });
await electronicsItem.locator(".widget-tree-node-branch-header").first().click();
await expect(widget.getByRole("treeitem", { name: "Phones" })).toBeVisible();
await electronicsItem.locator(".widget-tree-node-branch-header").first().click();
// Collapse is CSS-only (grid 0fr); assert via aria-expanded rather than child visibility
await expect(electronicsItem).toHaveAttribute("aria-expanded", "false");
});

test("multiple root nodes can be expanded independently", async ({ page }) => {
const widget = page.locator(".mx-name-treeNodeV2_1");
await widget
.getByRole("treeitem", { name: "Electronics" })
.locator(".widget-tree-node-branch-header")
.first()
.click();
await widget
.getByRole("treeitem", { name: "Clothing" })
.locator(".widget-tree-node-branch-header")
.first()
.click();
await expect(widget.getByRole("treeitem", { name: "Phones" })).toBeVisible();
await expect(widget.getByRole("treeitem", { name: "Men", exact: true })).toBeVisible();
await expect(widget.getByRole("treeitem", { name: "Women" })).toBeVisible();
});
});

test.describe("v2: startExpanded", () => {
test.beforeEach(async ({ page }) => {
await navigateToV2Page(page);
});

test("renders all nodes expanded when startExpanded is true", async ({ page }) => {
const widget = page.locator(".mx-name-treeNodeV2_2");
// All levels should be present with aria-expanded="true"
await expect(widget.getByRole("treeitem", { name: "Electronics" }).first()).toBeVisible();
await expect(widget.getByRole("treeitem", { name: "Phones" }).first()).toBeVisible();
// Deep nodes load via network — allow extra time
await expect(widget.getByRole("treeitem", { name: "Android" })).toHaveAttribute("aria-expanded", "true", {
timeout: 8000
});
await expect(widget.getByRole("treeitem", { name: "iOS" })).toHaveAttribute("aria-expanded", "true", {
timeout: 8000
});
await expect(widget.getByRole("treeitem", { name: "Men", exact: true })).toBeVisible();
});

test("can still collapse nodes when startExpanded is true", async ({ page }) => {
const widget = page.locator(".mx-name-treeNodeV2_2");
const electronicsItem = widget.getByRole("treeitem", { name: "Electronics" }).first();
await electronicsItem.locator(".widget-tree-node-branch-header").first().click();
// Collapse is CSS-only (grid 0fr); assert via aria-expanded rather than child visibility
await expect(electronicsItem).toHaveAttribute("aria-expanded", "false");
});
});

test.describe("a11y testing:", () => {
test("checks accessibility violations", async ({ page }) => {
await page.goto("/");
Expand Down
6 changes: 3 additions & 3 deletions packages/pluggableWidgets/tree-node-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@
},
"testProject": {
"githubUrl": "https://github.com/mendix/testProjects",
"branchName": "tree-node-web/main"
"branchName": "tree-node-web/tree-node-v2"
},
"scripts": {
"build": "pluggable-widgets-tools build:web",
"create-translation": "rui-create-translation",
"dev": "pluggable-widgets-tools start:web",
"e2e": "run-e2e ci",
"e2e": "MENDIX_VERSION=11.9.1 run-e2e ci",
"e2e-update-project": "pnpm --filter @mendix/data-widgets run build:include-deps",
"e2edev": "run-e2e dev --with-preps",
"e2edev": "MENDIX_VERSION=11.9.1 run-e2e dev --with-preps",
"format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .",
"lint": "eslint src/ package.json",
"release": "pluggable-widgets-tools release:web",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import {
hidePropertiesIn,
hidePropertyIn,
Problem,
Properties,
transformGroupsIntoTabs
} from "@mendix/pluggable-widgets-tools";
import {
ContainerProps,
DropZoneProps,
Expand All @@ -6,13 +13,6 @@ import {
StructurePreviewProps,
TextProps
} from "@mendix/widget-plugin-platform/preview/structure-preview-api";
import {
hidePropertiesIn,
hidePropertyIn,
Problem,
Properties,
transformGroupsIntoTabs
} from "@mendix/pluggable-widgets-tools";

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

Expand All @@ -35,8 +35,12 @@ export function getProperties(
hidePropertyIn(defaultProperties, values, "headerCaption");
}

if (!values.hasChildren) {
hidePropertiesIn(defaultProperties, values, ["startExpanded", "children"]);
if (values.parentAssociation) {
hidePropertyIn(defaultProperties, values, "hasChildren");
} else {
if (!values.hasChildren) {
hidePropertiesIn(defaultProperties, values, ["startExpanded", "children"]);
}
}

if (platform === "web") {
Expand All @@ -59,6 +63,7 @@ export function getProperties(

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

const titleHeader: RowLayoutProps = {
type: "RowLayout",
Expand Down Expand Up @@ -91,7 +96,7 @@ export function getPreview(values: TreeNodePreviewProps, isDarkMode: boolean): S
columnSize: "grow",
padding: 4,
children: [
...(values.showIcon === "left" && values.hasChildren
...(values.showIcon === "left" && showChildren
? [getChevronIconPreview(values.headerType, isDarkMode)]
: []),
values.headerType === "text"
Expand All @@ -115,7 +120,7 @@ export function getPreview(values: TreeNodePreviewProps, isDarkMode: boolean): S
]
} as RowLayoutProps),

...(values.showIcon === "right" && values.hasChildren
...(values.showIcon === "right" && showChildren
? [getChevronIconPreview(values.headerType, isDarkMode)]
: [])
]
Expand All @@ -124,7 +129,7 @@ export function getPreview(values: TreeNodePreviewProps, isDarkMode: boolean): S
};

const getTreeNodeContent: () => StructurePreviewProps[] = () =>
values.hasChildren
showChildren
? [
{
type: "RowLayout",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { mapPreviewIconToWebIcon } from "@mendix/widget-plugin-platform/preview/map-icon";
import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style";
import { GUID } from "mendix";

Check warning on line 3 in packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`mendix` import should occur before import of `@mendix/widget-plugin-platform/preview/map-icon`
import { ReactElement } from "react";

Check warning on line 4 in packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`react` import should occur before import of `@mendix/widget-plugin-platform/preview/map-icon`
import { TreeNodePreviewProps } from "../typings/TreeNodeProps";
import { TreeNode } from "./components/TreeNode";
import { TreeNode } from "./components/v1/TreeNode";

function renderTextTemplateWithFallback(textTemplateValue: string, placeholder: string): string {
if (textTemplateValue.trim().length === 0) {
Expand Down
56 changes: 8 additions & 48 deletions packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,12 @@
import { ObjectItem, ValueStatus } from "mendix";
import { ReactElement, useEffect, useState } from "react";
import { ReactElement } from "react";
import { TreeNodeContainerProps } from "../typings/TreeNodeProps";
import { InfoTreeNodeItem, TreeNode as TreeNodeComponent, TreeNodeItem } from "./components/TreeNode";

function mapDataSourceItemToTreeNodeItem(item: ObjectItem, props: TreeNodeContainerProps): TreeNodeItem {
return {
id: item.id,
headerContent:
props.headerType === "text" ? props.headerCaption?.get(item).value : props.headerContent?.get(item),
bodyContent: props.children?.get(item),
isUserDefinedLeafNode: props.hasChildren?.get(item).value === false
};
}
import { TreeNodeV1 } from "./components/v1/Root";
import { TreeNodeV2 } from "./components/v2/TreeNode";

export function TreeNode(props: TreeNodeContainerProps): ReactElement {
const { datasource } = props;
const [treeNodeItems, setTreeNodeItems] = useState<TreeNodeItem[] | InfoTreeNodeItem | null>([]);

useEffect(() => {
// only get the items when datasource is actually available
// this is to prevent treenode resetting it's render while datasource is loading.
if (datasource.status === ValueStatus.Available) {
if (datasource.items && datasource.items.length) {
setTreeNodeItems(datasource.items.map(item => mapDataSourceItemToTreeNodeItem(item, props)));
} else {
setTreeNodeItems({
Message: "No data available"
});
}
}
}, [datasource.status, datasource.items]);
const expandedIcon = props.expandedIcon?.status === ValueStatus.Available ? props.expandedIcon.value : undefined;
const collapsedIcon = props.collapsedIcon?.status === ValueStatus.Available ? props.collapsedIcon.value : undefined;

return (
<TreeNodeComponent
class={props.class}
style={props.style}
items={treeNodeItems}
startExpanded={props.startExpanded}
showCustomIcon={Boolean(props.expandedIcon) || Boolean(props.collapsedIcon)}
iconPlacement={props.showIcon}
expandedIcon={expandedIcon}
collapsedIcon={collapsedIcon}
tabIndex={props.tabIndex}
animateIcon={props.animate && props.animateIcon}
animateTreeNodeContent={props.animate}
openNodeOn={props.openNodeOn}
/>
);
if (props.parentAssociation) {
return <TreeNodeV2 {...props} />;
} else {
return <TreeNodeV1 {...props} />;
}
}
7 changes: 7 additions & 0 deletions packages/pluggableWidgets/tree-node-web/src/TreeNode.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
<caption>Data source</caption>
<description />
</property>
<property key="parentAssociation" type="association" dataSource="datasource" selectableObjects="datasource" required="false">
<caption>Parent association</caption>
<description>Select the self-referencing association that connects each item to its parent, enabling infinite depth hierarchies.</description>
<associationTypes>
<associationType name="Reference" />
</associationTypes>
</property>
<property key="headerType" type="enumeration" defaultValue="text">
<caption>Header type</caption>
<description />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import classNames from "classnames";
import { WebIcon } from "mendix";
import { ReactNode } from "react";

import { ShowIconEnum } from "../../typings/TreeNodeProps";
import loadingCircleSvg from "../assets/loading-circle.svg";
import { ShowIconEnum } from "../../../typings/TreeNodeProps";
import loadingCircleSvg from "../../assets/loading-circle.svg";

import { ChevronIcon, CustomHeaderIcon } from "./Icons";
import { TreeNodeProps, TreeNodeState } from "./TreeNode";
import { ChevronIcon, CustomHeaderIcon } from "../v1/Icons";
import { TreeNodeState } from "./TreeNodeState";

Check warning on line 9 in packages/pluggableWidgets/tree-node-web/src/components/common/HeaderIcon.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`./TreeNodeState` import should occur before import of `../../../typings/TreeNodeProps`

export type IconOptions = Pick<TreeNodeProps, "animateIcon" | "collapsedIcon" | "expandedIcon" | "showCustomIcon">;
export interface IconOptions {
animateIcon: boolean;
collapsedIcon?: WebIcon;
expandedIcon?: WebIcon;
showCustomIcon: boolean;
}

export type TreeNodeHeaderIcon = (
treeNodeState: TreeNodeState,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const enum TreeNodeState {
COLLAPSED_WITH_JS = "COLLAPSED_WITH_JS",
COLLAPSED_WITH_CSS = "COLLAPSED_WITH_CSS",
EXPANDED = "EXPANDED",
LOADING = "LOADING"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { IconOptions, renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "../common/HeaderIcon";
File renamed without changes.
Loading
Loading