From 282b0ebad623f46a653b1e9ec7fc0c6cdf79d4dd Mon Sep 17 00:00:00 2001 From: gjulivan Date: Thu, 13 Nov 2025 14:09:00 +0100 Subject: [PATCH 1/3] chore: state change of tree node to accomodate graph --- .../src/TreeNode.editorPreview.tsx | 83 +++++++------- .../tree-node-web/src/TreeNode.tsx | 57 +++++----- .../tree-node-web/src/TreeNode.xml | 7 ++ .../src/components/HeaderIcon.tsx | 7 +- .../src/components/TreeNodeBranch.tsx | 15 +-- .../src/components/TreeNodeBranchContext.ts | 3 + .../{TreeNode.tsx => TreeNodeComponent.tsx} | 102 ++++++++++-------- .../hooks/TreeNodeAccessibility.tsx | 2 +- .../tree-node-web/typings/TreeNodeProps.d.ts | 4 +- 9 files changed, 159 insertions(+), 121 deletions(-) rename packages/pluggableWidgets/tree-node-web/src/components/{TreeNode.tsx => TreeNodeComponent.tsx} (52%) diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx b/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx index 9acba12343..7591b92dd1 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx @@ -1,49 +1,48 @@ -import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; -import { mapPreviewIconToWebIcon } from "@mendix/widget-plugin-platform/preview/map-icon"; -import { GUID } from "mendix"; import { ReactElement } from "react"; import { TreeNodePreviewProps } from "../typings/TreeNodeProps"; -import { TreeNode } from "./components/TreeNode"; -function renderTextTemplateWithFallback(textTemplateValue: string, placeholder: string): string { - if (textTemplateValue.trim().length === 0) { - return placeholder; - } - return textTemplateValue; -} +// function renderTextTemplateWithFallback(textTemplateValue: string, placeholder: string): string { +// if (textTemplateValue.trim().length === 0) { +// return placeholder; +// } +// return textTemplateValue; +// } -export function preview(props: TreeNodePreviewProps): ReactElement | null { +export function preview(_props: TreeNodePreviewProps): ReactElement | null { return ( - -
- - ), - bodyContent: ( - -
- - ) - } - ]} - isUserDefinedLeafNode={!props.hasChildren} - startExpanded - showCustomIcon={Boolean(props.expandedIcon) || Boolean(props.collapsedIcon)} - iconPlacement={props.showIcon} - collapsedIcon={mapPreviewIconToWebIcon(props.collapsedIcon)} - expandedIcon={mapPreviewIconToWebIcon(props.expandedIcon)} - animateIcon={false} - animateTreeNodeContent={false} - openNodeOn={"headerClick"} - /> +
test
+ // + // //
+ // // + // // ), + // // bodyContent: ( + // // + // //
+ // // + // // ) + // } + // ]} + // // isUserDefinedLeafNode={!props.hasChildren} + // startExpanded + // showCustomIcon={Boolean(props.expandedIcon) || Boolean(props.collapsedIcon)} + // // iconPlacement={props.showIcon} + // collapsedIcon={mapPreviewIconToWebIcon(props.collapsedIcon)} + // expandedIcon={mapPreviewIconToWebIcon(props.expandedIcon)} + // animateIcon={false} + // // animateTreeNodeContent={false} + // openNodeOn={"headerClick"} + // /> ); } diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx index a7c633fc6c..d6570f1f9d 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx @@ -1,31 +1,47 @@ -import { ReactElement, useEffect, useState } from "react"; -import { ObjectItem, ValueStatus } from "mendix"; +import { GUID, ObjectItem, Option, ValueStatus } from "mendix"; +import { association, equals, literal } from "mendix/filters/builders"; +import { ReactElement, useCallback, useContext, useEffect, useId, useState } from "react"; import { TreeNodeContainerProps } from "../typings/TreeNodeProps"; -import { TreeNode as TreeNodeComponent, TreeNodeItem } from "./components/TreeNode"; +import { TreeNodeComponent } from "./components/TreeNodeComponent"; +import { TreeNodeBranchContext } from "./components/TreeNodeBranchContext"; -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) - }; -} +type treeNodeGraph = { + parentObject: ObjectItem; + items: ObjectItem[]; +}; export function TreeNode(props: TreeNodeContainerProps): ReactElement { const { datasource } = props; + const rootId = useId(); + + const [treeNodeItems, setTreeNodeItems] = useState(new Map | string, treeNodeGraph>()); + const { level, parent } = useContext(TreeNodeBranchContext); + + const filterContent = useCallback( + (item: Option) => { + if (props.parentAssociation) { + return equals(association(props.parentAssociation?.id), literal(item)); + } + }, + [props.parentAssociation, treeNodeItems, parent, rootId] + ); - const [treeNodeItems, setTreeNodeItems] = useState([]); + useEffect(() => { + // Initial Load of Top Level Items + datasource.setFilter(filterContent(parent?.id ? treeNodeItems.get(parent!.id)?.parentObject : undefined)); + }, []); 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) { + const updatedItems = new Map(treeNodeItems); if (datasource.items && datasource.items.length) { - setTreeNodeItems(datasource.items.map(item => mapDataSourceItemToTreeNodeItem(item, props))); + updatedItems.set(parent?.id || rootId, { items: datasource.items, parentObject: parent! }); } else { - setTreeNodeItems([]); + updatedItems.set(parent?.id || rootId, { items: [], parentObject: parent! }); } + setTreeNodeItems(updatedItems); } }, [datasource.status, datasource.items]); const expandedIcon = props.expandedIcon?.status === ValueStatus.Available ? props.expandedIcon.value : undefined; @@ -33,19 +49,12 @@ export function TreeNode(props: TreeNodeContainerProps): ReactElement { return ( ); } diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml b/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml index baeb7d19df..23f9e99da7 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml @@ -16,6 +16,13 @@ Data source + + Parent association + Select the self-referencing association that connects each item to its parent, enabling infinite depth hierarchies. + + + + Header type diff --git a/packages/pluggableWidgets/tree-node-web/src/components/HeaderIcon.tsx b/packages/pluggableWidgets/tree-node-web/src/components/HeaderIcon.tsx index b5e5272aa3..2d58d02a1a 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/HeaderIcon.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/HeaderIcon.tsx @@ -5,9 +5,12 @@ import { ShowIconEnum } from "../../typings/TreeNodeProps"; import loadingCircleSvg from "../assets/loading-circle.svg"; import { ChevronIcon, CustomHeaderIcon } from "./Icons"; -import { TreeNodeProps, TreeNodeState } from "./TreeNode"; +import { TreeNodeComponentProps, TreeNodeState } from "./TreeNodeComponent"; -export type IconOptions = Pick; +export type IconOptions = Pick< + TreeNodeComponentProps, + "animateIcon" | "collapsedIcon" | "expandedIcon" | "showCustomIcon" +>; export type TreeNodeHeaderIcon = ( treeNodeState: TreeNodeState, diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx index c2883833af..7133fa15d8 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx @@ -14,13 +14,13 @@ import { } from "react"; import { OpenNodeOnEnum, ShowIconEnum } from "../../typings/TreeNodeProps"; - +import { GUID, ObjectItem, Option } from "mendix"; import { useTreeNodeLazyLoading } from "./hooks/lazyLoading"; import { useAnimatedTreeNodeContentHeight } from "./hooks/useAnimatedHeight"; import { TreeNodeFocusChangeHandler, useTreeNodeBranchKeyboardHandler } from "./hooks/TreeNodeAccessibility"; import { TreeNodeHeaderIcon } from "./HeaderIcon"; -import { TreeNodeItem, TreeNodeState } from "./TreeNode"; +import { TreeNodeState } from "./TreeNodeComponent"; import { TreeNodeBranchContext, TreeNodeBranchContextProps } from "./TreeNodeBranchContext"; export interface TreeNodeBranchProps { @@ -28,18 +28,19 @@ export interface TreeNodeBranchProps { children: ReactNode; headerContent: ReactNode; iconPlacement: ShowIconEnum; - id: TreeNodeItem["id"]; + id: Option; isUserDefinedLeafNode: boolean; openNodeOn: OpenNodeOnEnum; startExpanded: boolean; changeFocus: TreeNodeFocusChangeHandler; renderHeaderIcon: TreeNodeHeaderIcon; + item: ObjectItem; } export const treeNodeBranchUtils = { bodyClassName: "widget-tree-node-body", - getHeaderId: (id: TreeNodeItem["id"]) => `${id}TreeNodeBranchHeader`, - getBodyId: (id: TreeNodeItem["id"]) => `${id}TreeNodeBranchBody` + getHeaderId: (id: Option) => `${id}TreeNodeBranchHeader`, + getBodyId: (id: Option) => `${id}TreeNodeBranchBody` }; export function TreeNodeBranch({ @@ -52,7 +53,8 @@ export function TreeNodeBranch({ isUserDefinedLeafNode, openNodeOn, renderHeaderIcon, - startExpanded + startExpanded, + item }: TreeNodeBranchProps): ReactElement { const { level: currentContextLevel } = useContext(TreeNodeBranchContext); @@ -184,6 +186,7 @@ export function TreeNodeBranch({ {((!isActualLeafNode && treeNodeState !== TreeNodeState.COLLAPSED_WITH_JS) || isAnimating) && ( void; } export const TreeNodeBranchContext = createContext({ + parent: undefined, level: 0, informParentOfChildNodes: () => null }); diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx similarity index 52% rename from packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx rename to packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx index f8e0f2c3df..d3674db92e 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx @@ -1,62 +1,73 @@ import classNames from "classnames"; import { ObjectItem, WebIcon } from "mendix"; -import { CSSProperties, ReactElement, ReactNode, useCallback, useContext } from "react"; +import { ReactElement, useCallback } from "react"; -import { OpenNodeOnEnum, TreeNodeContainerProps } from "../../typings/TreeNodeProps"; +import { TreeNodeContainerProps } from "../../typings/TreeNodeProps"; +import { renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "./HeaderIcon"; 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"; - -export interface TreeNodeItem extends ObjectItem { - headerContent: ReactNode; - bodyContent: ReactNode; -} +import { TreeNodeBranch, treeNodeBranchUtils } from "./TreeNodeBranch"; +import { useInformParentContextOfChildNodes } from "./TreeNodeBranchContext"; -export interface TreeNodeProps extends Pick { - class: string; - style?: CSSProperties; - items: TreeNodeItem[] | null; - isUserDefinedLeafNode: TreeNodeBranchProps["isUserDefinedLeafNode"]; - startExpanded: TreeNodeBranchProps["startExpanded"]; +export interface TreeNodeComponentProps + extends Pick< + TreeNodeContainerProps, + | "tabIndex" + | "class" + | "style" + | "hasChildren" + | "startExpanded" + | "showIcon" + | "animate" + | "animateIcon" + | "openNodeOn" + | "headerType" + | "headerCaption" + | "headerContent" + | "children" + > { + items: ObjectItem[] | null; showCustomIcon: boolean; - iconPlacement: TreeNodeBranchProps["iconPlacement"]; expandedIcon?: WebIcon; collapsedIcon?: WebIcon; - animateIcon: boolean; - animateTreeNodeContent: TreeNodeBranchProps["animateTreeNodeContent"]; - openNodeOn: OpenNodeOnEnum; + level?: number; } -export function TreeNode({ - class: className, - items, - style, - isUserDefinedLeafNode, - showCustomIcon, - startExpanded, - iconPlacement, - expandedIcon, - collapsedIcon, - tabIndex, - animateIcon, - animateTreeNodeContent, - openNodeOn -}: TreeNodeProps): ReactElement | null { - const { level } = useContext(TreeNodeBranchContext); +export function TreeNodeComponent(props: TreeNodeComponentProps): ReactElement | null { + const { + class: className, + tabIndex, + style, + items, + hasChildren, + startExpanded, + showIcon, + animate, + animateIcon, + openNodeOn, + headerType, + headerCaption, + headerContent, + children, + showCustomIcon, + expandedIcon, + collapsedIcon, + level + } = props; const [treeNodeElement, updateTreeNodeElement] = useTreeNodeRef(); + const isUserDefinedLeafNode = hasChildren === false; + const showIconAnimation = animate && animateIcon; const renderHeaderIconCallback = useCallback( (treeNodeState, iconPlacement) => renderTreeNodeHeaderIcon(treeNodeState, iconPlacement, { - animateIcon, + animateIcon: showIconAnimation, collapsedIcon, expandedIcon, showCustomIcon }), - [collapsedIcon, expandedIcon, showCustomIcon, animateIcon] + [collapsedIcon, expandedIcon, showCustomIcon, showIconAnimation] ); const isInsideAnotherTreeNode = useCallback(() => { @@ -79,20 +90,21 @@ export function TreeNode({ data-focusindex={tabIndex || 0} role={level === 0 ? "tree" : "group"} > - {items.map(({ id, headerContent, bodyContent }) => ( + {items.map((item, _idx) => ( - {bodyContent} + {children?.get(item)} ))} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/hooks/TreeNodeAccessibility.tsx b/packages/pluggableWidgets/tree-node-web/src/components/hooks/TreeNodeAccessibility.tsx index d5ebb86c12..5f658381ab 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/hooks/TreeNodeAccessibility.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/hooks/TreeNodeAccessibility.tsx @@ -1,5 +1,5 @@ import { EventHandler, SyntheticEvent, useCallback, useMemo } from "react"; -import { TreeNodeState } from "../TreeNode"; +import { TreeNodeState } from "../TreeNodeComponent"; import { KeyboardHandlerHook, useKeyboardHandler } from "./useKeyboardHandler"; export const enum FocusTargetChange { diff --git a/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts b/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts index 004c12f44a..40a5f12e2a 100644 --- a/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts +++ b/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts @@ -4,7 +4,7 @@ * @author Mendix Widgets Framework Team */ import { ComponentType, CSSProperties, ReactNode } from "react"; -import { DynamicValue, ListValue, ListExpressionValue, ListWidgetValue, WebIcon } from "mendix"; +import { DynamicValue, ListValue, ListExpressionValue, ListReferenceValue, ListWidgetValue, WebIcon } from "mendix"; export type HeaderTypeEnum = "text" | "custom"; @@ -19,6 +19,7 @@ export interface TreeNodeContainerProps { tabIndex?: number; advancedMode: boolean; datasource: ListValue; + parentAssociation?: ListReferenceValue; headerType: HeaderTypeEnum; openNodeOn: OpenNodeOnEnum; headerContent?: ListWidgetValue; @@ -46,6 +47,7 @@ export interface TreeNodePreviewProps { translate: (text: string) => string; advancedMode: boolean; datasource: {} | { caption: string } | { type: string } | null; + parentAssociation: string; headerType: HeaderTypeEnum; openNodeOn: OpenNodeOnEnum; headerContent: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; From af93c941e38684a4535f605dc80a163fe20c99a8 Mon Sep 17 00:00:00 2001 From: gjulivan Date: Tue, 18 Nov 2025 12:08:03 +0100 Subject: [PATCH 2/3] chore: allow recursive rendering --- .../tree-node-web/src/TreeNode.tsx | 53 ++++++++++++------- .../src/components/TreeNodeBranch.tsx | 16 +++--- .../src/components/TreeNodeComponent.tsx | 9 +++- .../src/components/TreeNodeRoot.tsx | 15 ++++++ .../src/components/TreeNodeRootContext.ts | 19 +++++++ 5 files changed, 86 insertions(+), 26 deletions(-) create mode 100644 packages/pluggableWidgets/tree-node-web/src/components/TreeNodeRoot.tsx create mode 100644 packages/pluggableWidgets/tree-node-web/src/components/TreeNodeRootContext.ts diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx index d6570f1f9d..4af1e76bf1 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx @@ -1,21 +1,20 @@ import { GUID, ObjectItem, Option, ValueStatus } from "mendix"; import { association, equals, literal } from "mendix/filters/builders"; -import { ReactElement, useCallback, useContext, useEffect, useId, useState } from "react"; +import { ReactElement, useCallback, useEffect, useId, useRef, useState } from "react"; import { TreeNodeContainerProps } from "../typings/TreeNodeProps"; -import { TreeNodeComponent } from "./components/TreeNodeComponent"; -import { TreeNodeBranchContext } from "./components/TreeNodeBranchContext"; +import { TreeNodeRoot } from "./components/TreeNodeRoot"; +import { TreeNodeRootContext } from "./components/TreeNodeRootContext"; type treeNodeGraph = { - parentObject: ObjectItem; + parentObject: ObjectItem | null; items: ObjectItem[]; }; export function TreeNode(props: TreeNodeContainerProps): ReactElement { const { datasource } = props; const rootId = useId(); - + const parent = useRef(null); const [treeNodeItems, setTreeNodeItems] = useState(new Map | string, treeNodeGraph>()); - const { level, parent } = useContext(TreeNodeBranchContext); const filterContent = useCallback( (item: Option) => { @@ -23,12 +22,24 @@ export function TreeNode(props: TreeNodeContainerProps): ReactElement { return equals(association(props.parentAssociation?.id), literal(item)); } }, - [props.parentAssociation, treeNodeItems, parent, rootId] + [props.parentAssociation] + ); + + const fetchChildren = useCallback( + (item?: Option) => { + parent.current = item || null; + if (props.parentAssociation) { + datasource.setFilter(filterContent(item)); + } + }, + [filterContent, datasource, props.parentAssociation] ); useEffect(() => { // Initial Load of Top Level Items - datasource.setFilter(filterContent(parent?.id ? treeNodeItems.get(parent!.id)?.parentObject : undefined)); + if (props.parentAssociation) { + fetchChildren(undefined); + } }, []); useEffect(() => { @@ -37,9 +48,12 @@ export function TreeNode(props: TreeNodeContainerProps): ReactElement { if (datasource.status === ValueStatus.Available) { const updatedItems = new Map(treeNodeItems); if (datasource.items && datasource.items.length) { - updatedItems.set(parent?.id || rootId, { items: datasource.items, parentObject: parent! }); + updatedItems.set(parent.current?.id || rootId, { + items: datasource.items, + parentObject: parent.current ?? null + }); } else { - updatedItems.set(parent?.id || rootId, { items: [], parentObject: parent! }); + updatedItems.set(parent.current?.id || rootId, { items: [], parentObject: parent.current ?? null }); } setTreeNodeItems(updatedItems); } @@ -48,13 +62,16 @@ export function TreeNode(props: TreeNodeContainerProps): ReactElement { const collapsedIcon = props.collapsedIcon?.status === ValueStatus.Available ? props.collapsedIcon.value : undefined; return ( - + + + ); } diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx index 7133fa15d8..bb7978e646 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx @@ -13,15 +13,16 @@ import { useState } from "react"; -import { OpenNodeOnEnum, ShowIconEnum } from "../../typings/TreeNodeProps"; import { GUID, ObjectItem, Option } from "mendix"; +import { OpenNodeOnEnum, ShowIconEnum } from "../../typings/TreeNodeProps"; import { useTreeNodeLazyLoading } from "./hooks/lazyLoading"; -import { useAnimatedTreeNodeContentHeight } from "./hooks/useAnimatedHeight"; import { TreeNodeFocusChangeHandler, useTreeNodeBranchKeyboardHandler } from "./hooks/TreeNodeAccessibility"; +import { useAnimatedTreeNodeContentHeight } from "./hooks/useAnimatedHeight"; import { TreeNodeHeaderIcon } from "./HeaderIcon"; -import { TreeNodeState } from "./TreeNodeComponent"; import { TreeNodeBranchContext, TreeNodeBranchContextProps } from "./TreeNodeBranchContext"; +import { TreeNodeState } from "./TreeNodeComponent"; +import { TreeNodeRootContext } from "./TreeNodeRootContext"; export interface TreeNodeBranchProps { animateTreeNodeContent: boolean; @@ -35,6 +36,7 @@ export interface TreeNodeBranchProps { changeFocus: TreeNodeFocusChangeHandler; renderHeaderIcon: TreeNodeHeaderIcon; item: ObjectItem; + level: number; } export const treeNodeBranchUtils = { @@ -54,9 +56,10 @@ export function TreeNodeBranch({ openNodeOn, renderHeaderIcon, startExpanded, - item + item, + level }: TreeNodeBranchProps): ReactElement { - const { level: currentContextLevel } = useContext(TreeNodeBranchContext); + const { fetchChildren } = useContext(TreeNodeRootContext); const treeNodeBranchRef = useRef(null); const treeNodeBranchBody = useRef(null); @@ -100,6 +103,7 @@ export function TreeNodeBranch({ return; } + fetchChildren(item); if (!isActualLeafNode) { captureElementHeight(); setTreeNodeState(treeNodeState => { @@ -187,7 +191,7 @@ export function TreeNodeBranch({ diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx index d3674db92e..debc38595e 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx @@ -9,6 +9,7 @@ import { useTreeNodeFocusChangeHandler } from "./hooks/TreeNodeAccessibility"; import { useTreeNodeRef } from "./hooks/useTreeNodeRef"; import { TreeNodeBranch, treeNodeBranchUtils } from "./TreeNodeBranch"; import { useInformParentContextOfChildNodes } from "./TreeNodeBranchContext"; +import { TreeNodeRoot } from "./TreeNodeRoot"; export interface TreeNodeComponentProps extends Pick< @@ -31,7 +32,8 @@ export interface TreeNodeComponentProps showCustomIcon: boolean; expandedIcon?: WebIcon; collapsedIcon?: WebIcon; - level?: number; + level: number; + isInfiniteMode: boolean; } export function TreeNodeComponent(props: TreeNodeComponentProps): ReactElement | null { @@ -53,7 +55,8 @@ export function TreeNodeComponent(props: TreeNodeComponentProps): ReactElement | showCustomIcon, expandedIcon, collapsedIcon, - level + level, + isInfiniteMode } = props; const [treeNodeElement, updateTreeNodeElement] = useTreeNodeRef(); const isUserDefinedLeafNode = hasChildren === false; @@ -103,8 +106,10 @@ export function TreeNodeComponent(props: TreeNodeComponentProps): ReactElement | animateTreeNodeContent={animate} openNodeOn={openNodeOn} item={item} + level={level} > {children?.get(item)} + {isInfiniteMode && } ))} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeRoot.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeRoot.tsx new file mode 100644 index 0000000000..3141cdcc7a --- /dev/null +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeRoot.tsx @@ -0,0 +1,15 @@ +import { ReactElement, useContext } from "react"; +import { TreeNodeBranchContext } from "./TreeNodeBranchContext"; +import { TreeNodeComponent, TreeNodeComponentProps } from "./TreeNodeComponent"; +import { TreeNodeRootContext } from "./TreeNodeRootContext"; + +export function TreeNodeRoot(props: Omit): ReactElement { + const { level, parent } = useContext(TreeNodeBranchContext); + const { treeNodeItems, rootId } = useContext(TreeNodeRootContext); + const parentId = props.isInfiniteMode ? parent?.id || rootId : rootId; + const items = treeNodeItems?.get(parentId)?.items || []; + if (items.length === 0) { + return
; + } + return ; +} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeRootContext.ts b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeRootContext.ts new file mode 100644 index 0000000000..9743b33fb6 --- /dev/null +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeRootContext.ts @@ -0,0 +1,19 @@ +import { ObjectItem, Option } from "mendix"; +import { createContext } from "react"; + +export type TreeNodeGraph = { + parentObject: ObjectItem | null; + items: ObjectItem[]; +}; + +export interface TreeNodeRootContextProps { + rootId: string; + fetchChildren: (item?: Option) => void; + treeNodeItems?: Map | string, TreeNodeGraph>; +} + +export const TreeNodeRootContext = createContext({ + rootId: Math.random().toString(36).substring(2, 15), + treeNodeItems: new Map | string, TreeNodeGraph>(), + fetchChildren: (_item?: Option) => null +}); From 360aeb660eb537255db43a88efe5b49310518f7e Mon Sep 17 00:00:00 2001 From: gjulivan Date: Tue, 18 Nov 2025 14:32:54 +0100 Subject: [PATCH 3/3] chore: allow expression on has children variables --- packages/pluggableWidgets/tree-node-web/src/TreeNode.xml | 3 ++- .../tree-node-web/src/components/TreeNodeBranch.tsx | 2 +- .../tree-node-web/src/components/TreeNodeComponent.tsx | 3 +-- .../tree-node-web/src/components/hooks/lazyLoading.ts | 2 +- .../pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml b/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml index 23f9e99da7..a4bd51361c 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml @@ -47,9 +47,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/TreeNodeBranch.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx index bb7978e646..85d5702fe3 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx @@ -121,7 +121,7 @@ export function TreeNodeBranch({ }); } }, - [captureElementHeight, eventTargetIsNotCurrentBranch, isActualLeafNode] + [captureElementHeight, eventTargetIsNotCurrentBranch, isActualLeafNode, fetchChildren, item] ); const onHeaderKeyDown = useTreeNodeBranchKeyboardHandler( diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx index debc38595e..b8c0be49f3 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx @@ -59,7 +59,6 @@ export function TreeNodeComponent(props: TreeNodeComponentProps): ReactElement | isInfiniteMode } = props; const [treeNodeElement, updateTreeNodeElement] = useTreeNodeRef(); - const isUserDefinedLeafNode = hasChildren === false; const showIconAnimation = animate && animateIcon; const renderHeaderIconCallback = useCallback( @@ -98,7 +97,7 @@ export function TreeNodeComponent(props: TreeNodeComponentProps): ReactElement | key={item.id} id={item.id} headerContent={headerType === "text" ? headerCaption?.get(item).value : headerContent?.get(item)} - isUserDefinedLeafNode={isUserDefinedLeafNode} + isUserDefinedLeafNode={hasChildren?.get(item).value === false} startExpanded={startExpanded} iconPlacement={showIcon} renderHeaderIcon={renderHeaderIconCallback} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/hooks/lazyLoading.ts b/packages/pluggableWidgets/tree-node-web/src/components/hooks/lazyLoading.ts index 3dd46cb5a5..90d6a6ccaa 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/hooks/lazyLoading.ts +++ b/packages/pluggableWidgets/tree-node-web/src/components/hooks/lazyLoading.ts @@ -10,7 +10,7 @@ export const useTreeNodeLazyLoading = ( hasNestedTreeNode: () => boolean; } => { const hasNestedTreeNode = useCallback( - () => treeNodeBranchBody.current?.lastElementChild?.className.includes("widget-tree-node") ?? true, + () => treeNodeBranchBody.current?.lastElementChild?.className.includes("widget-tree-node") ?? false, [] ); diff --git a/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts b/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts index 40a5f12e2a..83f2048398 100644 --- a/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts +++ b/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts @@ -24,7 +24,7 @@ export interface TreeNodeContainerProps { openNodeOn: OpenNodeOnEnum; headerContent?: ListWidgetValue; headerCaption?: ListExpressionValue; - hasChildren: boolean; + hasChildren: ListExpressionValue; startExpanded: boolean; children?: ListWidgetValue; animate: boolean; @@ -52,7 +52,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;