Skip to content

Commit 5b56842

Browse files
committed
feat: allow treenode to have self reference association
1 parent b303f3d commit 5b56842

8 files changed

Lines changed: 268 additions & 86 deletions

File tree

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 the new property "Parent association". This property allows tree node to have infinite levels of children by setting up the property to a self reference association.
12+
913
## [3.8.0] - 2026-01-16
1014

1115
### Changed

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

Lines changed: 5 additions & 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, TreeNodeItem } from "./components/TreeNode";
77

88
function renderTextTemplateWithFallback(textTemplateValue: string, placeholder: string): string {
99
if (textTemplateValue.trim().length === 0) {
@@ -44,6 +44,10 @@ export function preview(props: TreeNodePreviewProps): ReactElement | null {
4444
animateIcon={false}
4545
animateTreeNodeContent={false}
4646
openNodeOn={"headerClick"}
47+
fetchChildren={() => {
48+
return new Promise<TreeNodeItem[]>(resolve => resolve([]));
49+
}}
50+
isInfiniteTreeNodesEnabled={false}
4751
/>
4852
);
4953
}
Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,22 @@
1-
import { ObjectItem, ValueStatus } from "mendix";
2-
import { ReactElement, useEffect, useState } from "react";
1+
import { ValueStatus } from "mendix";
2+
import { ReactElement, useMemo } from "react";
33
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-
}
4+
import { TreeNode as TreeNodeComponent } from "./components/TreeNode";
5+
import { useInfiniteTreeNodes } from "./components/hooks/useInfiniteTreeNodes";
156

167
export function TreeNode(props: TreeNodeContainerProps): ReactElement {
17-
const { datasource } = props;
18-
const [treeNodeItems, setTreeNodeItems] = useState<TreeNodeItem[] | InfoTreeNodeItem | null>([]);
8+
const expandedIcon = useMemo(
9+
() => (props.expandedIcon?.status === ValueStatus.Available ? props.expandedIcon.value : undefined),
10+
// eslint-disable-next-line react-hooks/exhaustive-deps
11+
[props.expandedIcon?.status]
12+
);
13+
const collapsedIcon = useMemo(
14+
() => (props.collapsedIcon?.status === ValueStatus.Available ? props.collapsedIcon.value : undefined),
15+
// eslint-disable-next-line react-hooks/exhaustive-deps
16+
[props.collapsedIcon?.status]
17+
);
1918

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;
19+
const { treeNodeItems, fetchChildren, isInfiniteTreeNodesEnabled } = useInfiniteTreeNodes(props);
3520

3621
return (
3722
<TreeNodeComponent
@@ -47,6 +32,8 @@ export function TreeNode(props: TreeNodeContainerProps): ReactElement {
4732
animateIcon={props.animate && props.animateIcon}
4833
animateTreeNodeContent={props.animate}
4934
openNodeOn={props.openNodeOn}
35+
fetchChildren={fetchChildren}
36+
isInfiniteTreeNodesEnabled={isInfiniteTreeNodesEnabled}
5037
/>
5138
);
5239
}

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

Lines changed: 7 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 />

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

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import classNames from "classnames";
2-
import { ObjectItem, WebIcon } from "mendix";
2+
import { ObjectItem, Option, WebIcon } from "mendix";
33
import { CSSProperties, ReactElement, ReactNode, useCallback, useContext } from "react";
4-
54
import { OpenNodeOnEnum, TreeNodeContainerProps } from "../../typings/TreeNodeProps";
65

7-
import { useTreeNodeFocusChangeHandler } from "./hooks/TreeNodeAccessibility";
8-
import { useTreeNodeRef } from "./hooks/useTreeNodeRef";
96
import { renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "./HeaderIcon";
107
import { TreeNodeBranch, TreeNodeBranchProps, treeNodeBranchUtils } from "./TreeNodeBranch";
118
import { TreeNodeBranchContext, useInformParentContextOfChildNodes } from "./TreeNodeBranchContext";
9+
import { useTreeNodeFocusChangeHandler } from "./hooks/TreeNodeAccessibility";
10+
import { useLocalizedTreeNode } from "./hooks/useInfiniteTreeNodes";
11+
import { useTreeNodeRef } from "./hooks/useTreeNodeRef";
1212

1313
export interface TreeNodeItem extends ObjectItem {
1414
headerContent: ReactNode;
1515
bodyContent: ReactNode;
1616
isUserDefinedLeafNode: boolean;
17+
children?: TreeNodeItem[];
1718
}
1819

1920
export interface InfoTreeNodeItem {
@@ -32,23 +33,31 @@ export interface TreeNodeProps extends Pick<TreeNodeContainerProps, "tabIndex">
3233
animateIcon: boolean;
3334
animateTreeNodeContent: TreeNodeBranchProps["animateTreeNodeContent"];
3435
openNodeOn: OpenNodeOnEnum;
36+
fetchChildren: (item?: Option<ObjectItem>) => Promise<TreeNodeItem[]>;
37+
isInfiniteTreeNodesEnabled: boolean;
3538
}
3639

37-
export function TreeNode({
38-
class: className,
39-
items,
40-
style,
41-
showCustomIcon,
42-
startExpanded,
43-
iconPlacement,
44-
expandedIcon,
45-
collapsedIcon,
46-
tabIndex,
47-
animateIcon,
48-
animateTreeNodeContent,
49-
openNodeOn
50-
}: TreeNodeProps): ReactElement | null {
40+
export function TreeNode(props: TreeNodeProps): ReactElement | null {
41+
const {
42+
class: className,
43+
items,
44+
style,
45+
showCustomIcon,
46+
startExpanded,
47+
iconPlacement,
48+
expandedIcon,
49+
collapsedIcon,
50+
tabIndex,
51+
animateIcon,
52+
animateTreeNodeContent,
53+
openNodeOn,
54+
fetchChildren,
55+
isInfiniteTreeNodesEnabled
56+
} = props;
5157
const { level } = useContext(TreeNodeBranchContext);
58+
// localized items if infinite tree nodes is enabled,
59+
// this is to allow each nodes updates their own items when children are fetched
60+
const { localizedItems: localItems, appendChildren } = useLocalizedTreeNode(items, isInfiniteTreeNodesEnabled);
5261
const [treeNodeElement, updateTreeNodeElement] = useTreeNodeRef();
5362

5463
const renderHeaderIconCallback = useCallback<TreeNodeHeaderIcon>(
@@ -66,11 +75,11 @@ export function TreeNode({
6675
return treeNodeElement?.parentElement?.className.includes(treeNodeBranchUtils.bodyClassName) ?? false;
6776
}, [treeNodeElement]);
6877

69-
useInformParentContextOfChildNodes(Array.isArray(items) ? items.length : 0, isInsideAnotherTreeNode);
78+
useInformParentContextOfChildNodes(Array.isArray(localItems) ? localItems.length : 0, isInsideAnotherTreeNode);
7079

7180
const changeTreeNodeBranchHeaderFocus = useTreeNodeFocusChangeHandler();
7281

73-
if (items === null || (Array.isArray(items) && items.length === 0)) {
82+
if (localItems === null || (Array.isArray(localItems) && localItems.length === 0)) {
7483
return null;
7584
}
7685

@@ -82,23 +91,24 @@ export function TreeNode({
8291
data-focusindex={tabIndex || 0}
8392
role={level === 0 ? "tree" : "group"}
8493
>
85-
{Array.isArray(items) &&
86-
items.map(item => {
87-
const { id, headerContent, bodyContent, isUserDefinedLeafNode } = item;
94+
{Array.isArray(localItems) &&
95+
localItems.map(item => {
8896
return (
8997
<TreeNodeBranch
90-
key={id}
91-
id={id}
92-
headerContent={headerContent}
93-
isUserDefinedLeafNode={isUserDefinedLeafNode}
98+
key={item.id}
99+
item={item}
94100
startExpanded={startExpanded}
95101
iconPlacement={iconPlacement}
96102
renderHeaderIcon={renderHeaderIconCallback}
97103
changeFocus={changeTreeNodeBranchHeaderFocus}
98104
animateTreeNodeContent={animateTreeNodeContent}
99105
openNodeOn={openNodeOn}
106+
fetchChildren={fetchChildren}
107+
isInfiniteTreeNodesEnabled={isInfiniteTreeNodesEnabled}
108+
appendChildren={appendChildren}
109+
treeNodeProps={props}
100110
>
101-
{bodyContent}
111+
{item.bodyContent}
102112
</TreeNodeBranch>
103113
);
104114
})}

packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
useRef,
1313
useState
1414
} from "react";
15+
import { ObjectItem, Option } from "mendix";
1516

1617
import { OpenNodeOnEnum, ShowIconEnum } from "../../typings/TreeNodeProps";
1718

@@ -20,20 +21,22 @@ import { useAnimatedTreeNodeContentHeight } from "./hooks/useAnimatedHeight";
2021
import { TreeNodeFocusChangeHandler, useTreeNodeBranchKeyboardHandler } from "./hooks/TreeNodeAccessibility";
2122

2223
import { TreeNodeHeaderIcon } from "./HeaderIcon";
23-
import { TreeNodeItem, TreeNodeState } from "./TreeNode";
24+
import { TreeNode as TreeNodeComponent, TreeNodeItem, TreeNodeProps, TreeNodeState } from "./TreeNode";
2425
import { TreeNodeBranchContext, TreeNodeBranchContextProps } from "./TreeNodeBranchContext";
2526

2627
export interface TreeNodeBranchProps {
28+
item: TreeNodeItem;
2729
animateTreeNodeContent: boolean;
2830
children: ReactNode;
29-
headerContent: ReactNode;
3031
iconPlacement: ShowIconEnum;
31-
id: TreeNodeItem["id"];
32-
isUserDefinedLeafNode: boolean;
3332
openNodeOn: OpenNodeOnEnum;
3433
startExpanded: boolean;
3534
changeFocus: TreeNodeFocusChangeHandler;
3635
renderHeaderIcon: TreeNodeHeaderIcon;
36+
fetchChildren: (item?: Option<ObjectItem>) => Promise<TreeNodeItem[]>;
37+
appendChildren: (items: TreeNodeItem[], parent: TreeNodeItem) => void;
38+
treeNodeProps: TreeNodeProps;
39+
isInfiniteTreeNodesEnabled: boolean;
3740
}
3841

3942
export const treeNodeBranchUtils = {
@@ -43,23 +46,28 @@ export const treeNodeBranchUtils = {
4346
};
4447

4548
export function TreeNodeBranch({
49+
item,
4650
animateTreeNodeContent: animateTreeNodeContentProp,
4751
changeFocus,
4852
children,
49-
headerContent,
5053
iconPlacement,
51-
id,
52-
isUserDefinedLeafNode,
5354
openNodeOn,
5455
renderHeaderIcon,
55-
startExpanded
56+
startExpanded,
57+
fetchChildren,
58+
appendChildren,
59+
isInfiniteTreeNodesEnabled,
60+
treeNodeProps
5661
}: TreeNodeBranchProps): ReactElement {
5762
const { level: currentContextLevel } = useContext(TreeNodeBranchContext);
63+
const { id, headerContent, isUserDefinedLeafNode } = item;
5864

5965
const treeNodeBranchRef = useRef<HTMLLIElement>(null);
6066
const treeNodeBranchBody = useRef<HTMLDivElement>(null);
6167

62-
const [isActualLeafNode, setIsActualLeafNode] = useState<boolean>(isUserDefinedLeafNode || !children);
68+
const [isActualLeafNode, setIsActualLeafNode] = useState<boolean>(
69+
isUserDefinedLeafNode || (!children && !isInfiniteTreeNodesEnabled)
70+
);
6371
const [treeNodeState, setTreeNodeState] = useState<TreeNodeState>(
6472
startExpanded ? TreeNodeState.EXPANDED : TreeNodeState.COLLAPSED_WITH_JS
6573
);
@@ -92,30 +100,55 @@ export function TreeNodeBranch({
92100
);
93101
}, []);
94102

103+
const updateTreeNodeState = useCallback(() => {
104+
setTreeNodeState(treeNodeState => {
105+
if (treeNodeState === TreeNodeState.LOADING) {
106+
// TODO:
107+
return treeNodeState;
108+
}
109+
if (treeNodeState === TreeNodeState.COLLAPSED_WITH_JS) {
110+
return TreeNodeState.LOADING;
111+
}
112+
if (treeNodeState === TreeNodeState.COLLAPSED_WITH_CSS) {
113+
return TreeNodeState.EXPANDED;
114+
}
115+
return TreeNodeState.COLLAPSED_WITH_CSS;
116+
});
117+
}, []);
118+
95119
const toggleTreeNodeContent = useCallback<ReactEventHandler<HTMLElement>>(
96120
event => {
97121
if (eventTargetIsNotCurrentBranch(event)) {
98122
return;
99123
}
100124

101-
if (!isActualLeafNode) {
102-
captureElementHeight();
103-
setTreeNodeState(treeNodeState => {
104-
if (treeNodeState === TreeNodeState.LOADING) {
105-
// TODO:
106-
return treeNodeState;
107-
}
108-
if (treeNodeState === TreeNodeState.COLLAPSED_WITH_JS) {
109-
return TreeNodeState.LOADING;
125+
// load children for infinite tree nodes
126+
if (isInfiniteTreeNodesEnabled) {
127+
fetchChildren(item).then(result => {
128+
if (Array.isArray(result) && result.length > 0) {
129+
// append children to the localized item
130+
appendChildren(result, item);
131+
} else {
132+
setIsActualLeafNode(true);
110133
}
111-
if (treeNodeState === TreeNodeState.COLLAPSED_WITH_CSS) {
112-
return TreeNodeState.EXPANDED;
113-
}
114-
return TreeNodeState.COLLAPSED_WITH_CSS;
115134
});
116135
}
136+
137+
if (!isActualLeafNode) {
138+
captureElementHeight();
139+
updateTreeNodeState();
140+
}
117141
},
118-
[captureElementHeight, eventTargetIsNotCurrentBranch, isActualLeafNode]
142+
[
143+
captureElementHeight,
144+
eventTargetIsNotCurrentBranch,
145+
isActualLeafNode,
146+
updateTreeNodeState,
147+
fetchChildren,
148+
item,
149+
isInfiniteTreeNodesEnabled,
150+
appendChildren
151+
]
119152
);
120153

121154
const onHeaderKeyDown = useTreeNodeBranchKeyboardHandler(
@@ -143,8 +176,8 @@ export function TreeNodeBranch({
143176
}, [animateTreeNodeContent, animateTreeNodeContentProp, treeNodeState]);
144177

145178
useEffect(() => {
146-
setIsActualLeafNode(isUserDefinedLeafNode || !children);
147-
}, [children, isUserDefinedLeafNode]);
179+
setIsActualLeafNode(isUserDefinedLeafNode || (!children && !isInfiniteTreeNodesEnabled));
180+
}, [children, isUserDefinedLeafNode, isInfiniteTreeNodesEnabled]);
148181

149182
useEffect(() => {
150183
if (treeNodeState === TreeNodeState.LOADING) {
@@ -199,7 +232,11 @@ export function TreeNodeBranch({
199232
ref={treeNodeBranchBody}
200233
onTransitionEnd={cleanupAnimation}
201234
>
202-
{children}
235+
{isInfiniteTreeNodesEnabled && item.children && item.children.length > 0 ? (
236+
<TreeNodeComponent {...treeNodeProps} items={item.children || []} />
237+
) : (
238+
children
239+
)}
203240
</div>
204241
</TreeNodeBranchContext.Provider>
205242
)}

0 commit comments

Comments
 (0)