diff --git a/packages/pluggableWidgets/tree-node-web/CHANGELOG.md b/packages/pluggableWidgets/tree-node-web/CHANGELOG.md
index e0035def4f..ff4eef976f 100644
--- a/packages/pluggableWidgets/tree-node-web/CHANGELOG.md
+++ b/packages/pluggableWidgets/tree-node-web/CHANGELOG.md
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]
+### Changed
+
+- We changed `hasChildren` configuration from boolean to expression.
+
+### Fixed
+
+- We fixed an issue where Tree Nodes showing loading spinner when children can't be found while `hasChildren` property configured to `true`.
+
## [3.6.0] - 2025-10-01
### Changed
diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx b/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx
index 9acba12343..50de82ddbd 100644
--- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx
+++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx
@@ -1,5 +1,5 @@
-import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style";
import { mapPreviewIconToWebIcon } from "@mendix/widget-plugin-platform/preview/map-icon";
+import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style";
import { GUID } from "mendix";
import { ReactElement } from "react";
import { TreeNodePreviewProps } from "../typings/TreeNodeProps";
@@ -32,10 +32,10 @@ export function preview(props: TreeNodePreviewProps): ReactElement | null {
- )
+ ),
+ isUserDefinedLeafNode: !props.hasChildren
}
]}
- isUserDefinedLeafNode={!props.hasChildren}
startExpanded
showCustomIcon={Boolean(props.expandedIcon) || Boolean(props.collapsedIcon)}
iconPlacement={props.showIcon}
diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx
index a7c633fc6c..f8564776aa 100644
--- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx
+++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx
@@ -1,21 +1,21 @@
-import { ReactElement, useEffect, useState } from "react";
import { ObjectItem, ValueStatus } from "mendix";
+import { ReactElement, useEffect, useState } from "react";
import { TreeNodeContainerProps } from "../typings/TreeNodeProps";
-import { TreeNode as TreeNodeComponent, TreeNodeItem } from "./components/TreeNode";
+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)
+ bodyContent: props.children?.get(item),
+ isUserDefinedLeafNode: props.hasChildren?.get(item).value === false
};
}
export function TreeNode(props: TreeNodeContainerProps): ReactElement {
const { datasource } = props;
-
- const [treeNodeItems, setTreeNodeItems] = useState([]);
+ const [treeNodeItems, setTreeNodeItems] = useState([]);
useEffect(() => {
// only get the items when datasource is actually available
@@ -24,7 +24,9 @@ export function TreeNode(props: TreeNodeContainerProps): ReactElement {
if (datasource.items && datasource.items.length) {
setTreeNodeItems(datasource.items.map(item => mapDataSourceItemToTreeNodeItem(item, props)));
} else {
- setTreeNodeItems([]);
+ setTreeNodeItems({
+ Message: "No data available"
+ });
}
}
}, [datasource.status, datasource.items]);
@@ -36,7 +38,6 @@ export function TreeNode(props: TreeNodeContainerProps): ReactElement {
class={props.class}
style={props.style}
items={treeNodeItems}
- isUserDefinedLeafNode={!props.hasChildren}
startExpanded={props.startExpanded}
showCustomIcon={Boolean(props.expandedIcon) || Boolean(props.collapsedIcon)}
iconPlacement={props.showIcon}
diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml b/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml
index baeb7d19df..73552bc94d 100644
--- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml
+++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml
@@ -40,9 +40,10 @@
Header caption
-
+
Has children
Indicate whether the node has children or is an end node. When set to yes, a composable region becomes available to define the child nodes.
+
Start expanded
diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx
index f8e0f2c3df..1ba1796da8 100644
--- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx
+++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx
@@ -13,13 +13,17 @@ import { TreeNodeBranchContext, useInformParentContextOfChildNodes } from "./Tre
export interface TreeNodeItem extends ObjectItem {
headerContent: ReactNode;
bodyContent: ReactNode;
+ isUserDefinedLeafNode: boolean;
+}
+
+export interface InfoTreeNodeItem {
+ Message: string;
}
export interface TreeNodeProps extends Pick {
class: string;
style?: CSSProperties;
- items: TreeNodeItem[] | null;
- isUserDefinedLeafNode: TreeNodeBranchProps["isUserDefinedLeafNode"];
+ items: TreeNodeItem[] | InfoTreeNodeItem | null;
startExpanded: TreeNodeBranchProps["startExpanded"];
showCustomIcon: boolean;
iconPlacement: TreeNodeBranchProps["iconPlacement"];
@@ -34,7 +38,6 @@ export function TreeNode({
class: className,
items,
style,
- isUserDefinedLeafNode,
showCustomIcon,
startExpanded,
iconPlacement,
@@ -63,11 +66,11 @@ export function TreeNode({
return treeNodeElement?.parentElement?.className.includes(treeNodeBranchUtils.bodyClassName) ?? false;
}, [treeNodeElement]);
- useInformParentContextOfChildNodes(items?.length ?? 0, isInsideAnotherTreeNode);
+ useInformParentContextOfChildNodes(Array.isArray(items) ? items.length : 0, isInsideAnotherTreeNode);
const changeTreeNodeBranchHeaderFocus = useTreeNodeFocusChangeHandler();
- if (items === null || items.length === 0) {
+ if (items === null || (Array.isArray(items) && items.length === 0)) {
return null;
}
@@ -79,22 +82,26 @@ export function TreeNode({
data-focusindex={tabIndex || 0}
role={level === 0 ? "tree" : "group"}
>
- {items.map(({ id, headerContent, bodyContent }) => (
-
- {bodyContent}
-
- ))}
+ {Array.isArray(items) &&
+ items.map(item => {
+ const { id, headerContent, bodyContent, isUserDefinedLeafNode } = item;
+ return (
+
+ {bodyContent}
+
+ );
+ })}
);
}
diff --git a/packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNode.spec.tsx b/packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNode.spec.tsx
index 6480e75b8b..e5662241d4 100644
--- a/packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNode.spec.tsx
+++ b/packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNode.spec.tsx
@@ -1,30 +1,45 @@
-import { GUID } from "mendix";
-import { isValidElement, ReactElement, ReactNode } from "react";
+import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserEvent } from "@testing-library/user-event/setup/setup";
-import "@testing-library/jest-dom";
-import { TreeNode, TreeNodeProps, TreeNodeState } from "../TreeNode";
+import { GUID } from "mendix";
+import { isValidElement, ReactElement, ReactNode } from "react";
import { renderTreeNodeHeaderIcon } from "../HeaderIcon";
+import { TreeNode, TreeNodeProps, TreeNodeState } from "../TreeNode";
jest.mock("../../assets/loading-circle.svg", () => "loading-logo.svg");
interface TreeNodeItem {
id: GUID;
headerContent: string;
+ isUserDefinedLeafNode: boolean;
bodyContent: ReactElement;
}
const items: TreeNodeItem[] = [
- { id: "11" as GUID, headerContent: "First header", bodyContent: First content
},
- { id: "22" as GUID, headerContent: "Second header", bodyContent: Second content
},
- { id: "33" as GUID, headerContent: "Third header", bodyContent: Third content
}
+ {
+ id: "11" as GUID,
+ headerContent: "First header",
+ isUserDefinedLeafNode: false,
+ bodyContent: First content
+ },
+ {
+ id: "22" as GUID,
+ headerContent: "Second header",
+ isUserDefinedLeafNode: false,
+ bodyContent: Second content
+ },
+ {
+ id: "33" as GUID,
+ headerContent: "Third header",
+ isUserDefinedLeafNode: false,
+ bodyContent: Third content
+ }
];
const defaultProps: TreeNodeProps = {
class: "",
items: [],
- isUserDefinedLeafNode: false,
startExpanded: false,
showCustomIcon: false,
iconPlacement: "right",
@@ -59,7 +74,7 @@ describe("TreeNode", () => {
});
it("shows all headers and contents when starting expanded", () => {
- renderTreeNode({ items, isUserDefinedLeafNode: false, startExpanded: true });
+ renderTreeNode({ items, startExpanded: true });
items.forEach(item => {
expect(screen.getByText(item.headerContent as string)).toBeInTheDocument();
const texts = getTextFromElement(item.bodyContent);
@@ -68,7 +83,7 @@ describe("TreeNode", () => {
});
it("does not show item content when not starting expanded", () => {
- renderTreeNode({ items, isUserDefinedLeafNode: false, startExpanded: false });
+ renderTreeNode({ items, startExpanded: false });
items.forEach(item => {
expect(screen.getByText(item.headerContent as string)).toBeInTheDocument();
const texts = getTextFromElement(item.bodyContent);
@@ -82,16 +97,17 @@ describe("TreeNode", () => {
{
id: "44" as GUID,
headerContent: This is the 44 header
,
+ isUserDefinedLeafNode: false,
bodyContent: Fourth content
}
];
- renderTreeNode({ items: newItems, isUserDefinedLeafNode: false, startExpanded: true });
+ renderTreeNode({ items: newItems, startExpanded: true });
expect(screen.getByText("This is the 44 header")).toBeInTheDocument();
expect(screen.getByText("Fourth content")).toBeInTheDocument();
});
it("shows the tree node headers in the correct order as a treeitem when not defined as a leaf node", () => {
- renderTreeNode({ items, isUserDefinedLeafNode: false, startExpanded: false });
+ renderTreeNode({ items, startExpanded: false });
const treeNodeHeaders = screen.getAllByRole("treeitem");
expect(treeNodeHeaders).toHaveLength(items.length);
items.forEach(item => {
@@ -100,7 +116,7 @@ describe("TreeNode", () => {
});
it("correctly collapses and expands the tree node branch content when clicking on the header", async () => {
- renderTreeNode({ items, isUserDefinedLeafNode: false, startExpanded: false });
+ renderTreeNode({ items, startExpanded: false });
const treeNodeItems = screen.getAllByRole("treeitem");
@@ -122,7 +138,6 @@ describe("TreeNode", () => {
expandedIcon: { type: "glyph", iconClass: "expanded-icon" },
collapsedIcon: { type: "image", iconUrl: "image.png" },
items,
- isUserDefinedLeafNode: false,
startExpanded: false
});
expect(screen.getByText("First header")).toBeInTheDocument();
@@ -134,7 +149,6 @@ describe("TreeNode", () => {
expandedIcon: { type: "glyph", iconClass: "expanded-icon" },
collapsedIcon: { type: "image", iconUrl: "image.png" },
items,
- isUserDefinedLeafNode: false,
startExpanded: true
});
expect(screen.getByText("First header")).toBeInTheDocument();
@@ -143,9 +157,8 @@ describe("TreeNode", () => {
{
id: "11" as GUID,
headerContent: "Parent treeview with a nested treeview that is empty",
- bodyContent: (
-
- )
+ isUserDefinedLeafNode: false,
+ bodyContent:
}
];
renderTreeNode({
@@ -153,7 +166,6 @@ describe("TreeNode", () => {
expandedIcon: { type: "glyph", iconClass: "expanded-icon" },
collapsedIcon: { type: "image", iconUrl: "image.png" },
items: nestedItems,
- isUserDefinedLeafNode: false,
startExpanded: true
});
expect(screen.getByText("Parent treeview with a nested treeview that is empty")).toBeInTheDocument();
@@ -164,9 +176,8 @@ describe("TreeNode", () => {
{
id: "11" as GUID,
headerContent: "Parent treeview with a nested treeview that is empty",
- bodyContent: (
-
- )
+ isUserDefinedLeafNode: false,
+ bodyContent:
}
];
renderTreeNode({
@@ -174,7 +185,6 @@ describe("TreeNode", () => {
expandedIcon: { type: "glyph", iconClass: "expanded-icon" },
collapsedIcon: { type: "image", iconUrl: "image.png" },
items: nestedItems,
- isUserDefinedLeafNode: false,
startExpanded: true
});
expect(screen.getByText("Parent treeview with a nested treeview that is empty")).toBeInTheDocument();
@@ -187,9 +197,10 @@ describe("TreeNode", () => {
id: "11" as GUID,
headerContent:
"Parent treeview with a nested treeview that is empty and wrapped with a random other widget",
+ isUserDefinedLeafNode: false,
bodyContent: (
-
+
)
}
@@ -199,7 +210,6 @@ describe("TreeNode", () => {
expandedIcon: { type: "glyph", iconClass: "expanded-icon" },
collapsedIcon: { type: "image", iconUrl: "image.png" },
items: nestedItems,
- isUserDefinedLeafNode: false,
startExpanded: true
});
expect(
@@ -214,8 +224,14 @@ describe("TreeNode", () => {
showCustomIcon: true,
expandedIcon: { type: "glyph", iconClass: "expanded-icon" },
collapsedIcon: { type: "image", iconUrl: "image.png" },
- items: [{ id: "11" as GUID, headerContent: "First header", bodyContent: undefined }],
- isUserDefinedLeafNode: false,
+ items: [
+ {
+ id: "11" as GUID,
+ headerContent: "First header",
+ isUserDefinedLeafNode: false,
+ bodyContent: undefined
+ }
+ ],
startExpanded: true
});
expect(screen.getByText("First header")).toBeInTheDocument();
@@ -224,7 +240,6 @@ describe("TreeNode", () => {
it("adds a CSS class for the header when the icon animates on toggle", () => {
renderTreeNode({
items,
- isUserDefinedLeafNode: false,
startExpanded: true,
animateIcon: true
});
@@ -241,8 +256,18 @@ describe("TreeNode", () => {
return ;
};
const testItems: TreeNodeProps["items"] = [
- { id: "1" as GUID, headerContent: "Header 1", bodyContent: Content 1
},
- { id: "2" as GUID, headerContent: "Header 2", bodyContent: Content 2
}
+ {
+ id: "1" as GUID,
+ headerContent: "Header 1",
+ isUserDefinedLeafNode: false,
+ bodyContent: Content 1
+ },
+ {
+ id: "2" as GUID,
+ headerContent: "Header 2",
+ isUserDefinedLeafNode: false,
+ bodyContent: Content 2
+ }
];
render();
expect(renderSpy).toHaveBeenCalledTimes(1);
@@ -260,10 +285,16 @@ describe("TreeNode", () => {
describe("when interacting through the keyboard", () => {
beforeEach(() => {
const treeNodeItems = [
- { id: "1" as GUID, headerContent: "First header", bodyContent: First content
},
+ {
+ id: "1" as GUID,
+ headerContent: "First header",
+ isUserDefinedLeafNode: false,
+ bodyContent: First content
+ },
{
id: "2" as GUID,
headerContent: "Second header",
+ isUserDefinedLeafNode: false,
bodyContent: (
{
{
id: "21" as GUID,
headerContent: "Second First header",
+ isUserDefinedLeafNode: false,
bodyContent: Second First content
},
{
id: "22" as GUID,
headerContent: "Second Second header",
+ isUserDefinedLeafNode: false,
bodyContent: Second Second content
},
{
id: "23" as GUID,
headerContent: "Second Third header",
+ isUserDefinedLeafNode: false,
bodyContent: Second Third content
}
]}
- isUserDefinedLeafNode={false}
startExpanded={false}
/>
)
@@ -293,19 +326,13 @@ describe("TreeNode", () => {
{
id: "3" as GUID,
headerContent: "Third header",
- bodyContent: (
-
- )
+ isUserDefinedLeafNode: false,
+ bodyContent:
},
{
id: "4" as GUID,
headerContent: "Fourth header",
+ isUserDefinedLeafNode: false,
bodyContent: Fourth content
}
];
@@ -313,7 +340,6 @@ describe("TreeNode", () => {
renderTreeNode({
class: "",
items: treeNodeItems,
- isUserDefinedLeafNode: false,
startExpanded: false
});
});
diff --git a/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts b/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts
index 004c12f44a..0d47f596a2 100644
--- a/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts
+++ b/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts
@@ -23,7 +23,7 @@ export interface TreeNodeContainerProps {
openNodeOn: OpenNodeOnEnum;
headerContent?: ListWidgetValue;
headerCaption?: ListExpressionValue;
- hasChildren: boolean;
+ hasChildren: ListExpressionValue;
startExpanded: boolean;
children?: ListWidgetValue;
animate: boolean;
@@ -50,7 +50,7 @@ export interface TreeNodePreviewProps {
openNodeOn: OpenNodeOnEnum;
headerContent: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> };
headerCaption: string;
- hasChildren: boolean;
+ hasChildren: string;
startExpanded: boolean;
children: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> };
animate: boolean;