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

## [3.8.0] - 2026-01-16

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style";
import { GUID } from "mendix";
import { ReactElement } from "react";
import { TreeNodePreviewProps } from "../typings/TreeNodeProps";
import { TreeNode } from "./components/TreeNode";
import { TreeNode, TreeNodeItem } from "./components/TreeNode";

function renderTextTemplateWithFallback(textTemplateValue: string, placeholder: string): string {
if (textTemplateValue.trim().length === 0) {
Expand Down Expand Up @@ -44,6 +44,10 @@ export function preview(props: TreeNodePreviewProps): ReactElement | null {
animateIcon={false}
animateTreeNodeContent={false}
openNodeOn={"headerClick"}
fetchChildren={() => {
return new Promise<TreeNodeItem[]>(resolve => resolve([]));
}}
isInfiniteTreeNodesEnabled={false}
/>
);
}
47 changes: 17 additions & 30 deletions packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,22 @@
import { ObjectItem, ValueStatus } from "mendix";
import { ReactElement, useEffect, useState } from "react";
import { ValueStatus } from "mendix";
import { ReactElement, useMemo } 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 { TreeNode as TreeNodeComponent } from "./components/TreeNode";
import { useInfiniteTreeNodes } from "./components/hooks/useInfiniteTreeNodes";

export function TreeNode(props: TreeNodeContainerProps): ReactElement {
const { datasource } = props;
const [treeNodeItems, setTreeNodeItems] = useState<TreeNodeItem[] | InfoTreeNodeItem | null>([]);
const expandedIcon = useMemo(
() => (props.expandedIcon?.status === ValueStatus.Available ? props.expandedIcon.value : undefined),
// eslint-disable-next-line react-hooks/exhaustive-deps
[props.expandedIcon?.status]
);
const collapsedIcon = useMemo(
() => (props.collapsedIcon?.status === ValueStatus.Available ? props.collapsedIcon.value : undefined),
// eslint-disable-next-line react-hooks/exhaustive-deps
[props.collapsedIcon?.status]
);

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

return (
<TreeNodeComponent
Expand All @@ -47,6 +32,8 @@ export function TreeNode(props: TreeNodeContainerProps): ReactElement {
animateIcon={props.animate && props.animateIcon}
animateTreeNodeContent={props.animate}
openNodeOn={props.openNodeOn}
fetchChildren={fetchChildren}
isInfiniteTreeNodesEnabled={isInfiniteTreeNodesEnabled}
/>
);
}
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,19 +1,20 @@
import classNames from "classnames";
import { ObjectItem, WebIcon } from "mendix";
import { ObjectItem, Option, WebIcon } from "mendix";
import { CSSProperties, ReactElement, ReactNode, useCallback, useContext } from "react";

import { OpenNodeOnEnum, TreeNodeContainerProps } from "../../typings/TreeNodeProps";

import { useTreeNodeFocusChangeHandler } from "./hooks/TreeNodeAccessibility";
import { useTreeNodeRef } from "./hooks/useTreeNodeRef";
import { renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "./HeaderIcon";
import { TreeNodeBranch, TreeNodeBranchProps, treeNodeBranchUtils } from "./TreeNodeBranch";
import { TreeNodeBranchContext, useInformParentContextOfChildNodes } from "./TreeNodeBranchContext";
import { useTreeNodeFocusChangeHandler } from "./hooks/TreeNodeAccessibility";
import { useLocalizedTreeNode } from "./hooks/useInfiniteTreeNodes";
import { useTreeNodeRef } from "./hooks/useTreeNodeRef";

export interface TreeNodeItem extends ObjectItem {
headerContent: ReactNode;
bodyContent: ReactNode;
isUserDefinedLeafNode: boolean;
children?: TreeNodeItem[];
}

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

export function TreeNode({
class: className,
items,
style,
showCustomIcon,
startExpanded,
iconPlacement,
expandedIcon,
collapsedIcon,
tabIndex,
animateIcon,
animateTreeNodeContent,
openNodeOn
}: TreeNodeProps): ReactElement | null {
export function TreeNode(props: TreeNodeProps): ReactElement | null {
const {
class: className,
items,
style,
showCustomIcon,
startExpanded,
iconPlacement,
expandedIcon,
collapsedIcon,
tabIndex,
animateIcon,
animateTreeNodeContent,
openNodeOn,
fetchChildren,
isInfiniteTreeNodesEnabled
} = props;
const { level } = useContext(TreeNodeBranchContext);
// localized items if infinite tree nodes is enabled,
// this is to allow each nodes updates their own items when children are fetched
const { localizedItems: localItems, appendChildren } = useLocalizedTreeNode(items, isInfiniteTreeNodesEnabled);
const [treeNodeElement, updateTreeNodeElement] = useTreeNodeRef();

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

useInformParentContextOfChildNodes(Array.isArray(items) ? items.length : 0, isInsideAnotherTreeNode);
useInformParentContextOfChildNodes(Array.isArray(localItems) ? localItems.length : 0, isInsideAnotherTreeNode);

const changeTreeNodeBranchHeaderFocus = useTreeNodeFocusChangeHandler();

if (items === null || (Array.isArray(items) && items.length === 0)) {
if (localItems === null || (Array.isArray(localItems) && localItems.length === 0)) {
return null;
}

Expand All @@ -82,23 +91,24 @@ export function TreeNode({
data-focusindex={tabIndex || 0}
role={level === 0 ? "tree" : "group"}
>
{Array.isArray(items) &&
items.map(item => {
const { id, headerContent, bodyContent, isUserDefinedLeafNode } = item;
{Array.isArray(localItems) &&
localItems.map(item => {
return (
<TreeNodeBranch
key={id}
id={id}
headerContent={headerContent}
isUserDefinedLeafNode={isUserDefinedLeafNode}
key={item.id}
item={item}
startExpanded={startExpanded}
iconPlacement={iconPlacement}
renderHeaderIcon={renderHeaderIconCallback}
changeFocus={changeTreeNodeBranchHeaderFocus}
animateTreeNodeContent={animateTreeNodeContent}
openNodeOn={openNodeOn}
fetchChildren={fetchChildren}
isInfiniteTreeNodesEnabled={isInfiniteTreeNodesEnabled}
appendChildren={appendChildren}
treeNodeProps={props}
>
{bodyContent}
{item.bodyContent}
</TreeNodeBranch>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
useRef,
useState
} from "react";
import { ObjectItem, Option } from "mendix";

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

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

import { TreeNodeHeaderIcon } from "./HeaderIcon";
import { TreeNodeItem, TreeNodeState } from "./TreeNode";
import { TreeNode as TreeNodeComponent, TreeNodeItem, TreeNodeProps, TreeNodeState } from "./TreeNode";
import { TreeNodeBranchContext, TreeNodeBranchContextProps } from "./TreeNodeBranchContext";

export interface TreeNodeBranchProps {
item: TreeNodeItem;
animateTreeNodeContent: boolean;
children: ReactNode;
headerContent: ReactNode;
iconPlacement: ShowIconEnum;
id: TreeNodeItem["id"];
isUserDefinedLeafNode: boolean;
openNodeOn: OpenNodeOnEnum;
startExpanded: boolean;
changeFocus: TreeNodeFocusChangeHandler;
renderHeaderIcon: TreeNodeHeaderIcon;
fetchChildren: (item?: Option<ObjectItem>) => Promise<TreeNodeItem[]>;
appendChildren: (items: TreeNodeItem[], parent: TreeNodeItem) => void;
treeNodeProps: TreeNodeProps;
isInfiniteTreeNodesEnabled: boolean;
}

export const treeNodeBranchUtils = {
Expand All @@ -43,23 +46,28 @@ export const treeNodeBranchUtils = {
};

export function TreeNodeBranch({
item,
animateTreeNodeContent: animateTreeNodeContentProp,
changeFocus,
children,
headerContent,
iconPlacement,
id,
isUserDefinedLeafNode,
openNodeOn,
renderHeaderIcon,
startExpanded
startExpanded,
fetchChildren,
appendChildren,
isInfiniteTreeNodesEnabled,
treeNodeProps
}: TreeNodeBranchProps): ReactElement {
const { level: currentContextLevel } = useContext(TreeNodeBranchContext);
const { id, headerContent, isUserDefinedLeafNode } = item;

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

const [isActualLeafNode, setIsActualLeafNode] = useState<boolean>(isUserDefinedLeafNode || !children);
const [isActualLeafNode, setIsActualLeafNode] = useState<boolean>(
isUserDefinedLeafNode || (!children && !isInfiniteTreeNodesEnabled)
);
const [treeNodeState, setTreeNodeState] = useState<TreeNodeState>(
startExpanded ? TreeNodeState.EXPANDED : TreeNodeState.COLLAPSED_WITH_JS
);
Expand Down Expand Up @@ -92,30 +100,55 @@ export function TreeNodeBranch({
);
}, []);

const updateTreeNodeState = useCallback(() => {
setTreeNodeState(treeNodeState => {
if (treeNodeState === TreeNodeState.LOADING) {
// TODO:
return treeNodeState;
}
if (treeNodeState === TreeNodeState.COLLAPSED_WITH_JS) {
return TreeNodeState.LOADING;
}
if (treeNodeState === TreeNodeState.COLLAPSED_WITH_CSS) {
return TreeNodeState.EXPANDED;
}
return TreeNodeState.COLLAPSED_WITH_CSS;
});
}, []);

const toggleTreeNodeContent = useCallback<ReactEventHandler<HTMLElement>>(
event => {
if (eventTargetIsNotCurrentBranch(event)) {
return;
}

if (!isActualLeafNode) {
captureElementHeight();
setTreeNodeState(treeNodeState => {
if (treeNodeState === TreeNodeState.LOADING) {
// TODO:
return treeNodeState;
}
if (treeNodeState === TreeNodeState.COLLAPSED_WITH_JS) {
return TreeNodeState.LOADING;
// load children for infinite tree nodes
if (isInfiniteTreeNodesEnabled) {
fetchChildren(item).then(result => {
if (Array.isArray(result) && result.length > 0) {
// append children to the localized item
appendChildren(result, item);
} else {
setIsActualLeafNode(true);
}
if (treeNodeState === TreeNodeState.COLLAPSED_WITH_CSS) {
return TreeNodeState.EXPANDED;
}
return TreeNodeState.COLLAPSED_WITH_CSS;
});
}

if (!isActualLeafNode) {
captureElementHeight();
updateTreeNodeState();
}
},
[captureElementHeight, eventTargetIsNotCurrentBranch, isActualLeafNode]
[
captureElementHeight,
eventTargetIsNotCurrentBranch,
isActualLeafNode,
updateTreeNodeState,
fetchChildren,
item,
isInfiniteTreeNodesEnabled,
appendChildren
]
);

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

useEffect(() => {
setIsActualLeafNode(isUserDefinedLeafNode || !children);
}, [children, isUserDefinedLeafNode]);
setIsActualLeafNode(isUserDefinedLeafNode || (!children && !isInfiniteTreeNodesEnabled));
}, [children, isUserDefinedLeafNode, isInfiniteTreeNodesEnabled]);

useEffect(() => {
if (treeNodeState === TreeNodeState.LOADING) {
Expand Down Expand Up @@ -199,7 +232,11 @@ export function TreeNodeBranch({
ref={treeNodeBranchBody}
onTransitionEnd={cleanupAnimation}
>
{children}
{isInfiniteTreeNodesEnabled && item.children && item.children.length > 0 ? (
<TreeNodeComponent {...treeNodeProps} items={item.children || []} />
) : (
children
)}
</div>
</TreeNodeBranchContext.Provider>
)}
Expand Down
Loading
Loading