From bb2ed6ad401689b962ee37ced60d831011d6c2a6 Mon Sep 17 00:00:00 2001 From: gjulivan Date: Fri, 3 Apr 2026 14:15:02 +0200 Subject: [PATCH 1/3] chore: grouped current version into treenode v1 --- .../src/TreeNode.editorPreview.tsx | 2 +- .../tree-node-web/src/TreeNode.tsx | 56 +++---------------- .../tree-node-web/src/TreeNode.xml | 7 +++ .../src/components/{ => v1}/HeaderIcon.tsx | 4 +- .../src/components/{ => v1}/Icons.tsx | 0 .../tree-node-web/src/components/v1/Root.tsx | 53 ++++++++++++++++++ .../src/components/{ => v1}/TreeNode.tsx | 2 +- .../components/{ => v1}/TreeNodeBranch.tsx | 2 +- .../{ => v1}/TreeNodeBranchContext.ts | 0 .../{ => v1}/__tests__/TreeNode.spec.tsx | 2 +- .../__tests__/TreeNodeBranch.spec.tsx | 0 .../__snapshots__/TreeNode.spec.tsx.snap | 0 .../{ => v1}/hooks/TreeNodeAccessibility.tsx | 0 .../components/{ => v1}/hooks/lazyLoading.ts | 0 .../{ => v1}/hooks/useAnimatedHeight.tsx | 0 .../{ => v1}/hooks/useKeyboardHandler.tsx | 0 .../{ => v1}/hooks/useTreeNodeRef.ts | 0 .../src/components/v2/TreeNode.tsx | 6 ++ .../tree-node-web/typings/TreeNodeProps.d.ts | 4 +- 19 files changed, 83 insertions(+), 55 deletions(-) rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/HeaderIcon.tsx (92%) rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/Icons.tsx (100%) create mode 100644 packages/pluggableWidgets/tree-node-web/src/components/v1/Root.tsx rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/TreeNode.tsx (97%) rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/TreeNodeBranch.tsx (99%) rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/TreeNodeBranchContext.ts (100%) rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/__tests__/TreeNode.spec.tsx (99%) rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/__tests__/TreeNodeBranch.spec.tsx (100%) rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/__tests__/__snapshots__/TreeNode.spec.tsx.snap (100%) rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/hooks/TreeNodeAccessibility.tsx (100%) rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/hooks/lazyLoading.ts (100%) rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/hooks/useAnimatedHeight.tsx (100%) rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/hooks/useKeyboardHandler.tsx (100%) rename packages/pluggableWidgets/tree-node-web/src/components/{ => v1}/hooks/useTreeNodeRef.ts (100%) create mode 100644 packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx b/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx index 50de82ddbd..f10587f5c5 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx @@ -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 } from "./components/v1/TreeNode"; function renderTextTemplateWithFallback(textTemplateValue: string, placeholder: string): string { if (textTemplateValue.trim().length === 0) { diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx index f8564776aa..ae773199d0 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx @@ -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([]); - - 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 ( - - ); + if (props.parentAssociation) { + return ; + } else { + return ; + } } diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml b/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml index 73552bc94d..a4bd51361c 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/v1/HeaderIcon.tsx similarity index 92% rename from packages/pluggableWidgets/tree-node-web/src/components/HeaderIcon.tsx rename to packages/pluggableWidgets/tree-node-web/src/components/v1/HeaderIcon.tsx index b5e5272aa3..5b53b3fca0 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/HeaderIcon.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/v1/HeaderIcon.tsx @@ -1,8 +1,8 @@ import classNames from "classnames"; 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"; diff --git a/packages/pluggableWidgets/tree-node-web/src/components/Icons.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/Icons.tsx similarity index 100% rename from packages/pluggableWidgets/tree-node-web/src/components/Icons.tsx rename to packages/pluggableWidgets/tree-node-web/src/components/v1/Icons.tsx diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v1/Root.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/Root.tsx new file mode 100644 index 0000000000..1728f7765e --- /dev/null +++ b/packages/pluggableWidgets/tree-node-web/src/components/v1/Root.tsx @@ -0,0 +1,53 @@ +import { ObjectItem, ValueStatus } from "mendix"; +import { ReactElement, useEffect, useState } from "react"; + +import { InfoTreeNodeItem, TreeNode, TreeNodeItem } from "./TreeNode"; +import { TreeNodeContainerProps } from "../../../typings/TreeNodeProps"; + +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 + }; +} + +export function TreeNodeV1(props: TreeNodeContainerProps): ReactElement { + const { datasource } = props; + const [treeNodeItems, setTreeNodeItems] = useState([]); + + useEffect(() => { + // Only process datasource items when they are available to avoid rendering resets while 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 ( + + ); +} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNode.tsx similarity index 97% rename from packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx rename to packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNode.tsx index 1ba1796da8..4e56fb5105 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNode.tsx @@ -2,7 +2,7 @@ import classNames from "classnames"; import { ObjectItem, WebIcon } from "mendix"; import { CSSProperties, ReactElement, ReactNode, useCallback, useContext } from "react"; -import { OpenNodeOnEnum, TreeNodeContainerProps } from "../../typings/TreeNodeProps"; +import { OpenNodeOnEnum, TreeNodeContainerProps } from "../../../typings/TreeNodeProps"; import { useTreeNodeFocusChangeHandler } from "./hooks/TreeNodeAccessibility"; import { useTreeNodeRef } from "./hooks/useTreeNodeRef"; diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNodeBranch.tsx similarity index 99% rename from packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx rename to packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNodeBranch.tsx index c2883833af..0d67ae34f2 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNodeBranch.tsx @@ -13,7 +13,7 @@ import { useState } from "react"; -import { OpenNodeOnEnum, ShowIconEnum } from "../../typings/TreeNodeProps"; +import { OpenNodeOnEnum, ShowIconEnum } from "../../../typings/TreeNodeProps"; import { useTreeNodeLazyLoading } from "./hooks/lazyLoading"; import { useAnimatedTreeNodeContentHeight } from "./hooks/useAnimatedHeight"; diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranchContext.ts b/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNodeBranchContext.ts similarity index 100% rename from packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranchContext.ts rename to packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNodeBranchContext.ts diff --git a/packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNode.spec.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/__tests__/TreeNode.spec.tsx similarity index 99% rename from packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNode.spec.tsx rename to packages/pluggableWidgets/tree-node-web/src/components/v1/__tests__/TreeNode.spec.tsx index e5662241d4..6260b47653 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNode.spec.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/v1/__tests__/TreeNode.spec.tsx @@ -7,7 +7,7 @@ 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"); +jest.mock("../../../assets/loading-circle.svg", () => "loading-logo.svg"); interface TreeNodeItem { id: GUID; diff --git a/packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNodeBranch.spec.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/__tests__/TreeNodeBranch.spec.tsx similarity index 100% rename from packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNodeBranch.spec.tsx rename to packages/pluggableWidgets/tree-node-web/src/components/v1/__tests__/TreeNodeBranch.spec.tsx diff --git a/packages/pluggableWidgets/tree-node-web/src/components/__tests__/__snapshots__/TreeNode.spec.tsx.snap b/packages/pluggableWidgets/tree-node-web/src/components/v1/__tests__/__snapshots__/TreeNode.spec.tsx.snap similarity index 100% rename from packages/pluggableWidgets/tree-node-web/src/components/__tests__/__snapshots__/TreeNode.spec.tsx.snap rename to packages/pluggableWidgets/tree-node-web/src/components/v1/__tests__/__snapshots__/TreeNode.spec.tsx.snap diff --git a/packages/pluggableWidgets/tree-node-web/src/components/hooks/TreeNodeAccessibility.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/hooks/TreeNodeAccessibility.tsx similarity index 100% rename from packages/pluggableWidgets/tree-node-web/src/components/hooks/TreeNodeAccessibility.tsx rename to packages/pluggableWidgets/tree-node-web/src/components/v1/hooks/TreeNodeAccessibility.tsx diff --git a/packages/pluggableWidgets/tree-node-web/src/components/hooks/lazyLoading.ts b/packages/pluggableWidgets/tree-node-web/src/components/v1/hooks/lazyLoading.ts similarity index 100% rename from packages/pluggableWidgets/tree-node-web/src/components/hooks/lazyLoading.ts rename to packages/pluggableWidgets/tree-node-web/src/components/v1/hooks/lazyLoading.ts diff --git a/packages/pluggableWidgets/tree-node-web/src/components/hooks/useAnimatedHeight.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/hooks/useAnimatedHeight.tsx similarity index 100% rename from packages/pluggableWidgets/tree-node-web/src/components/hooks/useAnimatedHeight.tsx rename to packages/pluggableWidgets/tree-node-web/src/components/v1/hooks/useAnimatedHeight.tsx diff --git a/packages/pluggableWidgets/tree-node-web/src/components/hooks/useKeyboardHandler.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/hooks/useKeyboardHandler.tsx similarity index 100% rename from packages/pluggableWidgets/tree-node-web/src/components/hooks/useKeyboardHandler.tsx rename to packages/pluggableWidgets/tree-node-web/src/components/v1/hooks/useKeyboardHandler.tsx diff --git a/packages/pluggableWidgets/tree-node-web/src/components/hooks/useTreeNodeRef.ts b/packages/pluggableWidgets/tree-node-web/src/components/v1/hooks/useTreeNodeRef.ts similarity index 100% rename from packages/pluggableWidgets/tree-node-web/src/components/hooks/useTreeNodeRef.ts rename to packages/pluggableWidgets/tree-node-web/src/components/v1/hooks/useTreeNodeRef.ts diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx new file mode 100644 index 0000000000..466e50958c --- /dev/null +++ b/packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx @@ -0,0 +1,6 @@ +import { ReactElement } from "react"; +import { TreeNodeContainerProps } from "../../../typings/TreeNodeProps"; + +export function TreeNodeV2(_props: TreeNodeContainerProps): ReactElement { + return
; +} diff --git a/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts b/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts index 0d47f596a2..83f2048398 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 4725ce939cc3ddf76b9359729a376c42e954675a Mon Sep 17 00:00:00 2001 From: gjulivan Date: Wed, 8 Apr 2026 10:59:55 +0200 Subject: [PATCH 2/3] chore: add child preload and animations --- .../src/components/common/TreeNodeState.ts | 6 + .../src/components/v1/TreeNode.tsx | 10 +- .../src/components/v2/TreeNode.tsx | 115 ++++++++++++- .../v2/hooks/useIncrementalTreeData.ts | 162 ++++++++++++++++++ .../v2/hooks/useInfiniteTreeNode.ts | 81 +++++++++ .../src/components/v2/treeBuilder.ts | 72 ++++++++ .../src/components/v2/ui/TreeNodeV2.scss | 23 +++ 7 files changed, 459 insertions(+), 10 deletions(-) create mode 100644 packages/pluggableWidgets/tree-node-web/src/components/common/TreeNodeState.ts create mode 100644 packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useIncrementalTreeData.ts create mode 100644 packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useInfiniteTreeNode.ts create mode 100644 packages/pluggableWidgets/tree-node-web/src/components/v2/treeBuilder.ts create mode 100644 packages/pluggableWidgets/tree-node-web/src/components/v2/ui/TreeNodeV2.scss diff --git a/packages/pluggableWidgets/tree-node-web/src/components/common/TreeNodeState.ts b/packages/pluggableWidgets/tree-node-web/src/components/common/TreeNodeState.ts new file mode 100644 index 0000000000..e5ea12f453 --- /dev/null +++ b/packages/pluggableWidgets/tree-node-web/src/components/common/TreeNodeState.ts @@ -0,0 +1,6 @@ +export const enum TreeNodeState { + COLLAPSED_WITH_JS = "COLLAPSED_WITH_JS", + COLLAPSED_WITH_CSS = "COLLAPSED_WITH_CSS", + EXPANDED = "EXPANDED", + LOADING = "LOADING" +} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNode.tsx index 4e56fb5105..7539a88fac 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNode.tsx @@ -3,6 +3,7 @@ import { ObjectItem, WebIcon } from "mendix"; import { CSSProperties, ReactElement, ReactNode, useCallback, useContext } from "react"; import { OpenNodeOnEnum, TreeNodeContainerProps } from "../../../typings/TreeNodeProps"; +import { TreeNodeState } from "../common/TreeNodeState"; import { useTreeNodeFocusChangeHandler } from "./hooks/TreeNodeAccessibility"; import { useTreeNodeRef } from "./hooks/useTreeNodeRef"; @@ -10,6 +11,8 @@ import { renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "./HeaderIcon"; import { TreeNodeBranch, TreeNodeBranchProps, treeNodeBranchUtils } from "./TreeNodeBranch"; import { TreeNodeBranchContext, useInformParentContextOfChildNodes } from "./TreeNodeBranchContext"; +export { TreeNodeState }; + export interface TreeNodeItem extends ObjectItem { headerContent: ReactNode; bodyContent: ReactNode; @@ -105,10 +108,3 @@ export function TreeNode({ ); } - -export const enum TreeNodeState { - COLLAPSED_WITH_JS = "COLLAPSED_WITH_JS", - COLLAPSED_WITH_CSS = "COLLAPSED_WITH_CSS", - EXPANDED = "EXPANDED", - LOADING = "LOADING" -} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx index 466e50958c..94ada6951e 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx @@ -1,6 +1,115 @@ -import { ReactElement } from "react"; +import classNames from "classnames"; +import { ValueStatus } from "mendix"; +import { ReactElement, useCallback, useMemo, useState } from "react"; +import "./ui/TreeNodeV2.scss"; +import { TreeNodeState } from "../common/TreeNodeState"; +import { renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "../v1/HeaderIcon"; +import { useIncrementalTreeData } from "./hooks/useIncrementalTreeData"; +import { useInfiniteTreeNodes } from "./hooks/useInfiniteTreeNode"; +import { TreeNodeV2DataItem } from "./treeBuilder"; import { TreeNodeContainerProps } from "../../../typings/TreeNodeProps"; -export function TreeNodeV2(_props: TreeNodeContainerProps): ReactElement { - return
; +function renderRecursiveNode( + node: TreeNodeV2DataItem, + renderHeaderIcon: TreeNodeHeaderIcon, + iconPlacement: TreeNodeContainerProps["showIcon"], + onNodeClick: (node: TreeNodeV2DataItem) => void +): ReactElement { + const hasChildren = node.children.length > 0; + const isExpanded = node.treeNodeState === TreeNodeState.EXPANDED; + + return ( +
  • + onNodeClick(node)} + > + {node.title} + {hasChildren && iconPlacement !== "no" ? ( + + {renderHeaderIcon(node.treeNodeState, iconPlacement)} + + ) : null} + + {hasChildren ? ( +
    +
      + {node.children.map(child => + renderRecursiveNode(child, renderHeaderIcon, iconPlacement, onNodeClick) + )} +
    +
    + ) : null} +
  • + ); +} + +export function TreeNodeV2(props: TreeNodeContainerProps): ReactElement { + const { items, appendItems } = useInfiniteTreeNodes(props); + const [, forceRender] = useState(0); + + const expandedIcon = props.expandedIcon?.status === ValueStatus.Available ? props.expandedIcon.value : undefined; + const collapsedIcon = props.collapsedIcon?.status === ValueStatus.Available ? props.collapsedIcon.value : undefined; + const showCustomIcon = Boolean(props.expandedIcon) || Boolean(props.collapsedIcon); + const iconPlacement = props.showIcon; + const animateIcon = props.animate && props.animateIcon; + + const renderHeaderIcon = useMemo( + () => (treeNodeState, placement) => + renderTreeNodeHeaderIcon(treeNodeState, placement, { + animateIcon, + collapsedIcon, + expandedIcon, + showCustomIcon + }), + [animateIcon, collapsedIcon, expandedIcon, showCustomIcon] + ); + + const treeConfig = useMemo( + () => ({ + headerCaption: props.headerCaption, + headerContent: props.headerContent, + headerType: props.headerType, + parentAssociation: props.parentAssociation + }), + [props.parentAssociation, props.headerType, props.headerCaption, props.headerContent] + ); + + const treeData = useIncrementalTreeData(items, treeConfig); + const onNodeClick = useCallback( + (node: TreeNodeV2DataItem) => { + if (node.treeNodeState === TreeNodeState.EXPANDED) { + node.treeNodeState = TreeNodeState.COLLAPSED_WITH_CSS; + forceRender(version => version + 1); + return; + } + + node.treeNodeState = TreeNodeState.EXPANDED; + appendItems( + node.item, + node.children.map(child => child.item) + ); + forceRender(version => version + 1); + }, + [appendItems] + ); + + return ( +
      + {treeData.map(node => renderRecursiveNode(node, renderHeaderIcon, iconPlacement, onNodeClick))} +
    + ); } diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useIncrementalTreeData.ts b/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useIncrementalTreeData.ts new file mode 100644 index 0000000000..36c5dde08d --- /dev/null +++ b/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useIncrementalTreeData.ts @@ -0,0 +1,162 @@ +import { ObjectItem } from "mendix"; +import { ReactNode, useEffect, useRef, useState } from "react"; +import { TreeNodeContainerProps } from "../../../../typings/TreeNodeProps"; +import { TreeNodeState } from "../../common/TreeNodeState"; +import { TreeNodeV2DataItem } from "../treeBuilder"; + +type NodePlacement = string | null; + +interface TreeConfigRef { + headerCaption: TreeNodeContainerProps["headerCaption"]; + headerContent: TreeNodeContainerProps["headerContent"]; + headerType: TreeNodeContainerProps["headerType"]; + parentAssociation: TreeNodeContainerProps["parentAssociation"]; +} + +function getItemId(item: ObjectItem): string { + return String(item.id); +} + +function getParentId( + item: ObjectItem, + parentAssociation: TreeNodeContainerProps["parentAssociation"] +): string | undefined { + const parentObject = parentAssociation?.get(item).value; + return parentObject?.id ? String(parentObject.id) : undefined; +} + +function getItemTitle(item: ObjectItem, config: TreeConfigRef): ReactNode { + if (config.headerType === "text") { + return config.headerCaption?.get(item).value ?? String(item.id); + } + return config.headerContent?.get(item) ?? String(item.id); +} + +function isConfigChanged(previous: TreeConfigRef | null, next: TreeConfigRef): boolean { + if (!previous) { + return true; + } + + return ( + previous.headerType !== next.headerType || + previous.headerCaption !== next.headerCaption || + previous.headerContent !== next.headerContent || + previous.parentAssociation !== next.parentAssociation + ); +} + +export function useIncrementalTreeData(items: ObjectItem[] | undefined, config: TreeConfigRef): TreeNodeV2DataItem[] { + const [treeData, setTreeData] = useState([]); + + const rootsRef = useRef([]); + const nodesByIdRef = useRef>(new Map()); + const placementByIdRef = useRef>(new Map()); + const previousIdsRef = useRef>(new Set()); + const previousConfigRef = useRef(null); + + useEffect(() => { + const sourceItems = items ?? []; + const incomingIds = new Set(sourceItems.map(getItemId)); + + const removedIdsDetected = + incomingIds.size < previousIdsRef.current.size || + [...previousIdsRef.current].some(id => !incomingIds.has(id)); + + const configChanged = isConfigChanged(previousConfigRef.current, config); + previousConfigRef.current = config; + + if (configChanged || removedIdsDetected) { + rootsRef.current = []; + nodesByIdRef.current.clear(); + placementByIdRef.current.clear(); + } + + const removeFromCurrentPlacement = (nodeId: string): void => { + const placement = placementByIdRef.current.get(nodeId); + + if (placement === undefined) { + return; + } + + if (placement === null) { + rootsRef.current = rootsRef.current.filter(node => node.id !== nodeId); + return; + } + + const parent = nodesByIdRef.current.get(placement); + if (parent) { + parent.children = parent.children.filter(child => child.id !== nodeId); + } + }; + + const placeNode = (node: TreeNodeV2DataItem): void => { + const parentId = node.parentId && node.parentId !== node.id ? node.parentId : undefined; + + if (parentId) { + const parent = nodesByIdRef.current.get(parentId); + + if (parent) { + if (placementByIdRef.current.get(node.id) === parentId) { + return; + } + + removeFromCurrentPlacement(node.id); + parent.children.push(node); + placementByIdRef.current.set(node.id, parentId); + return; + } + } + + if (placementByIdRef.current.get(node.id) === null) { + return; + } + + removeFromCurrentPlacement(node.id); + rootsRef.current.push(node); + placementByIdRef.current.set(node.id, null); + }; + + for (const item of sourceItems) { + const nodeId = getItemId(item); + const nextParentId = getParentId(item, config.parentAssociation); + const nextTitle = getItemTitle(item, config); + + const existingNode = nodesByIdRef.current.get(nodeId); + if (existingNode) { + const parentChanged = existingNode.parentId !== nextParentId; + existingNode.item = item; + existingNode.parentId = nextParentId; + existingNode.title = nextTitle; + + if (parentChanged) { + placeNode(existingNode); + } + continue; + } + + const newNode: TreeNodeV2DataItem = { + children: [], + id: nodeId, + item, + parentId: nextParentId, + treeNodeState: TreeNodeState.COLLAPSED_WITH_JS, + title: nextTitle + }; + + nodesByIdRef.current.set(nodeId, newNode); + placeNode(newNode); + + // Re-place potential children that were temporarily roots while parent wasn't loaded yet. + for (const candidate of nodesByIdRef.current.values()) { + if (candidate.parentId === nodeId && candidate.id !== nodeId) { + placeNode(candidate); + } + } + } + + previousIdsRef.current = incomingIds; + setTreeData([...rootsRef.current]); + }, [items, config]); + + return treeData; +} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useInfiniteTreeNode.ts b/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useInfiniteTreeNode.ts new file mode 100644 index 0000000000..ebde32792c --- /dev/null +++ b/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useInfiniteTreeNode.ts @@ -0,0 +1,81 @@ +import { ObjectItem, Option } from "mendix"; +import { association, equals, literal, or } from "mendix/filters/builders"; +import { useCallback, useEffect, useRef } from "react"; +import { TreeNodeContainerProps } from "../../../../typings/TreeNodeProps"; + +export type ItemType = Array>; + +export function useInfiniteTreeNodes(props: TreeNodeContainerProps) { + const { datasource, parentAssociation } = props; + const loadedParentsByIdRef = useRef>(new Map()); + const loadedChildsByIdRef = useRef>(new Map()); + const initializedRef = useRef(false); + + const getDatasourceFilter = useCallback( + (items?: ItemType) => { + if (items && items.length > 1) { + // retrieve new datasource for array of items + return or(...items.map(item => equals(association(parentAssociation!.id), literal(item)))); + } else { + return equals(association(parentAssociation!.id), literal(items?.[0])); + } + }, + [parentAssociation] + ); + + const getExpandedFilterItems = useCallback( + (): ItemType => [undefined, ...loadedParentsByIdRef.current.values(), ...loadedChildsByIdRef.current.values()], + [] + ); + + const appendItems = useCallback( + (newItem: ObjectItem, children?: ObjectItem[]) => { + const parentId = String(newItem.id); + + if (loadedParentsByIdRef.current.has(parentId)) { + if (children && children.length > 0) { + children.forEach(child => { + const childId = String(child.id); + loadedChildsByIdRef.current.set(childId, child); + }); + + if (loadedChildsByIdRef.current.has(parentId)) { + loadedParentsByIdRef.current.set(parentId, loadedChildsByIdRef.current.get(parentId)!); + loadedChildsByIdRef.current.delete(parentId); + } else { + loadedParentsByIdRef.current.set(parentId, newItem); + } + } + } else { + loadedParentsByIdRef.current.set(parentId, newItem); + } + + datasource.setFilter(getDatasourceFilter(getExpandedFilterItems())); + }, + [datasource, getDatasourceFilter, getExpandedFilterItems] + ); + + useEffect(() => { + if (initializedRef.current) { + if (loadedParentsByIdRef.current.size === 0) { + datasource.items?.forEach(item => { + const parentId = String(item.id); + loadedParentsByIdRef.current.set(parentId, item); + }); + } + + datasource.setFilter(getDatasourceFilter(getExpandedFilterItems())); + + return; + } + + initializedRef.current = true; + loadedParentsByIdRef.current.clear(); + datasource.setFilter(getDatasourceFilter([undefined])); + }, [datasource, getDatasourceFilter, getExpandedFilterItems]); + + return { + items: datasource.items, + appendItems + }; +} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v2/treeBuilder.ts b/packages/pluggableWidgets/tree-node-web/src/components/v2/treeBuilder.ts new file mode 100644 index 0000000000..d7d77a25de --- /dev/null +++ b/packages/pluggableWidgets/tree-node-web/src/components/v2/treeBuilder.ts @@ -0,0 +1,72 @@ +import { ObjectItem } from "mendix"; +import { ReactNode } from "react"; +import { TreeNodeContainerProps } from "../../../typings/TreeNodeProps"; +import { TreeNodeState } from "../common/TreeNodeState"; + +export interface TreeNodeV2DataItem { + children: TreeNodeV2DataItem[]; + id: string; + item: ObjectItem; + parentId?: string; + treeNodeState: TreeNodeState; + title: ReactNode; +} + +export interface TreeNodeBuildConfig { + headerCaption: TreeNodeContainerProps["headerCaption"]; + headerContent: TreeNodeContainerProps["headerContent"]; + headerType: TreeNodeContainerProps["headerType"]; + parentAssociation: TreeNodeContainerProps["parentAssociation"]; +} + +function getItemId(item: ObjectItem): string { + return String(item.id); +} + +function getParentId( + item: ObjectItem, + parentAssociation: TreeNodeBuildConfig["parentAssociation"] +): string | undefined { + const parentObject = parentAssociation?.get(item).value; + return parentObject?.id ? String(parentObject.id) : undefined; +} + +function getItemTitle(item: ObjectItem, config: TreeNodeBuildConfig): ReactNode { + if (config.headerType === "text") { + return config.headerCaption?.get(item).value ?? String(item.id); + } + return config.headerContent?.get(item) ?? String(item.id); +} + +export function buildTree(items: ObjectItem[], config: TreeNodeBuildConfig): TreeNodeV2DataItem[] { + const nodes = items.map(item => ({ + children: [], + id: getItemId(item), + item, + parentId: getParentId(item, config.parentAssociation), + treeNodeState: TreeNodeState.COLLAPSED_WITH_JS, + title: getItemTitle(item, config) + })); + + const nodeMap = new Map(); + nodes.forEach(node => { + nodeMap.set(node.id, node); + }); + + const roots: TreeNodeV2DataItem[] = []; + nodes.forEach(node => { + if (!node.parentId || node.parentId === node.id) { + roots.push(node); + return; + } + + const parentNode = nodeMap.get(node.parentId); + if (parentNode) { + parentNode.children.push(node); + } else { + roots.push(node); + } + }); + + return roots; +} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v2/ui/TreeNodeV2.scss b/packages/pluggableWidgets/tree-node-web/src/components/v2/ui/TreeNodeV2.scss new file mode 100644 index 0000000000..10a550b0fb --- /dev/null +++ b/packages/pluggableWidgets/tree-node-web/src/components/v2/ui/TreeNodeV2.scss @@ -0,0 +1,23 @@ +.widget-tree-node { + &.widget-tree-node-v2 { + .widget-tree-node-v2-body { + display: grid; + grid-template-rows: 1fr; + opacity: 1; + transition: + grid-template-rows 220ms ease, + opacity 180ms ease; + will-change: grid-template-rows, opacity; + + > ul { + min-height: 0; + overflow: hidden; + } + } + + .widget-tree-node-v2-body.widget-tree-node-v2-body-collapsed { + grid-template-rows: 0fr; + opacity: 0; + } + } +} From fc01f8bc420b22bb3d9ff18626aa297d2977fb5a Mon Sep 17 00:00:00 2001 From: gjulivan Date: Thu, 9 Apr 2026 11:14:22 +0200 Subject: [PATCH 3/3] chore: update animation, allow render children --- .../src/TreeNode.editorConfig.ts | 29 ++++---- .../src/components/common/HeaderIcon.tsx | 46 ++++++++++++ .../src/components/v1/HeaderIcon.tsx | 41 +---------- .../src/components/v1/TreeNode.tsx | 2 +- .../src/components/v1/TreeNodeBranch.tsx | 4 +- .../src/components/v2/TreeNode.tsx | 23 +++--- .../v2/hooks/useIncrementalTreeData.ts | 13 +++- .../v2/hooks/useInfiniteTreeNode.ts | 17 +++-- .../src/components/v2/treeBuilder.ts | 72 ------------------- .../src/components/v2/ui/TreeNodeV2.scss | 7 +- 10 files changed, 107 insertions(+), 147 deletions(-) create mode 100644 packages/pluggableWidgets/tree-node-web/src/components/common/HeaderIcon.tsx delete mode 100644 packages/pluggableWidgets/tree-node-web/src/components/v2/treeBuilder.ts diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorConfig.ts b/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorConfig.ts index 0353c131fb..a84598fa71 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorConfig.ts +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorConfig.ts @@ -1,3 +1,10 @@ +import { + hidePropertiesIn, + hidePropertyIn, + Problem, + Properties, + transformGroupsIntoTabs +} from "@mendix/pluggable-widgets-tools"; import { ContainerProps, DropZoneProps, @@ -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"; @@ -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") { @@ -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 !== null; const titleHeader: RowLayoutProps = { type: "RowLayout", @@ -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" @@ -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)] : []) ] @@ -124,7 +129,7 @@ export function getPreview(values: TreeNodePreviewProps, isDarkMode: boolean): S }; const getTreeNodeContent: () => StructurePreviewProps[] = () => - values.hasChildren + showChildren ? [ { type: "RowLayout", diff --git a/packages/pluggableWidgets/tree-node-web/src/components/common/HeaderIcon.tsx b/packages/pluggableWidgets/tree-node-web/src/components/common/HeaderIcon.tsx new file mode 100644 index 0000000000..19c1129b1a --- /dev/null +++ b/packages/pluggableWidgets/tree-node-web/src/components/common/HeaderIcon.tsx @@ -0,0 +1,46 @@ +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 { ChevronIcon, CustomHeaderIcon } from "../v1/Icons"; +import { TreeNodeState } from "./TreeNodeState"; + +export interface IconOptions { + animateIcon: boolean; + collapsedIcon?: WebIcon; + expandedIcon?: WebIcon; + showCustomIcon: boolean; +} + +export type TreeNodeHeaderIcon = ( + treeNodeState: TreeNodeState, + iconPlacement: Exclude +) => ReactNode; + +export function renderTreeNodeHeaderIcon( + treeNodeState: TreeNodeState, + iconPlacement: Exclude, + iconOptions: IconOptions +): ReactNode { + if (treeNodeState === TreeNodeState.LOADING) { + return ; + } + + const { animateIcon, collapsedIcon, expandedIcon, showCustomIcon } = iconOptions; + const treeNodeIsExpanded = treeNodeState === TreeNodeState.EXPANDED; + + return showCustomIcon ? ( + + ) : ( + + ); +} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v1/HeaderIcon.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/HeaderIcon.tsx index 5b53b3fca0..42d38a3932 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/v1/HeaderIcon.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/v1/HeaderIcon.tsx @@ -1,40 +1 @@ -import classNames from "classnames"; -import { ReactNode } from "react"; - -import { ShowIconEnum } from "../../../typings/TreeNodeProps"; -import loadingCircleSvg from "../../assets/loading-circle.svg"; - -import { ChevronIcon, CustomHeaderIcon } from "./Icons"; -import { TreeNodeProps, TreeNodeState } from "./TreeNode"; - -export type IconOptions = Pick; - -export type TreeNodeHeaderIcon = ( - treeNodeState: TreeNodeState, - iconPlacement: Exclude -) => ReactNode; - -export function renderTreeNodeHeaderIcon( - treeNodeState: TreeNodeState, - iconPlacement: Exclude, - iconOptions: IconOptions -): ReactNode { - if (treeNodeState === TreeNodeState.LOADING) { - return ; - } - - const { animateIcon, collapsedIcon, expandedIcon, showCustomIcon } = iconOptions; - const treeNodeIsExpanded = treeNodeState === TreeNodeState.EXPANDED; - - return showCustomIcon ? ( - - ) : ( - - ); -} +export { IconOptions, renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "../common/HeaderIcon"; diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNode.tsx index 7539a88fac..b4cffe9d21 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNode.tsx @@ -3,11 +3,11 @@ import { ObjectItem, WebIcon } from "mendix"; import { CSSProperties, ReactElement, ReactNode, useCallback, useContext } from "react"; import { OpenNodeOnEnum, TreeNodeContainerProps } from "../../../typings/TreeNodeProps"; +import { renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "../common/HeaderIcon"; import { TreeNodeState } from "../common/TreeNodeState"; 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"; diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNodeBranch.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNodeBranch.tsx index 0d67ae34f2..4cc23ee069 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNodeBranch.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNodeBranch.tsx @@ -14,12 +14,12 @@ import { } from "react"; import { OpenNodeOnEnum, ShowIconEnum } from "../../../typings/TreeNodeProps"; +import { TreeNodeHeaderIcon } from "../common/HeaderIcon"; 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 { TreeNodeItem, TreeNodeState } from "./TreeNode"; import { TreeNodeBranchContext, TreeNodeBranchContextProps } from "./TreeNodeBranchContext"; diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx index 94ada6951e..03966dc405 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx @@ -2,18 +2,18 @@ import classNames from "classnames"; import { ValueStatus } from "mendix"; import { ReactElement, useCallback, useMemo, useState } from "react"; import "./ui/TreeNodeV2.scss"; +import { renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "../common/HeaderIcon"; import { TreeNodeState } from "../common/TreeNodeState"; -import { renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "../v1/HeaderIcon"; -import { useIncrementalTreeData } from "./hooks/useIncrementalTreeData"; +import { useIncrementalTreeData, TreeNodeV2DataItem } from "./hooks/useIncrementalTreeData"; import { useInfiniteTreeNodes } from "./hooks/useInfiniteTreeNode"; -import { TreeNodeV2DataItem } from "./treeBuilder"; import { TreeNodeContainerProps } from "../../../typings/TreeNodeProps"; function renderRecursiveNode( node: TreeNodeV2DataItem, renderHeaderIcon: TreeNodeHeaderIcon, iconPlacement: TreeNodeContainerProps["showIcon"], - onNodeClick: (node: TreeNodeV2DataItem) => void + onNodeClick: (node: TreeNodeV2DataItem) => void, + children?: TreeNodeContainerProps["children"] ): ReactElement { const hasChildren = node.children.length > 0; const isExpanded = node.treeNodeState === TreeNodeState.EXPANDED; @@ -22,7 +22,8 @@ function renderRecursiveNode(
  • onNodeClick(node)} @@ -41,9 +42,10 @@ function renderRecursiveNode( })} id={`${node.id}TreeNodeBranchBody`} > +
    {children?.get(node.item)}
      {node.children.map(child => - renderRecursiveNode(child, renderHeaderIcon, iconPlacement, onNodeClick) + renderRecursiveNode(child, renderHeaderIcon, iconPlacement, onNodeClick, children) )}
  • @@ -78,9 +80,10 @@ export function TreeNodeV2(props: TreeNodeContainerProps): ReactElement { headerCaption: props.headerCaption, headerContent: props.headerContent, headerType: props.headerType, - parentAssociation: props.parentAssociation + parentAssociation: props.parentAssociation, + startExpanded: props.startExpanded }), - [props.parentAssociation, props.headerType, props.headerCaption, props.headerContent] + [props.headerCaption, props.headerContent, props.headerType, props.parentAssociation, props.startExpanded] ); const treeData = useIncrementalTreeData(items, treeConfig); @@ -109,7 +112,9 @@ export function TreeNodeV2(props: TreeNodeContainerProps): ReactElement { data-focusindex={props.tabIndex || 0} role="tree" > - {treeData.map(node => renderRecursiveNode(node, renderHeaderIcon, iconPlacement, onNodeClick))} + {treeData.map(node => + renderRecursiveNode(node, renderHeaderIcon, iconPlacement, onNodeClick, props.children) + )} ); } diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useIncrementalTreeData.ts b/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useIncrementalTreeData.ts index 36c5dde08d..0594d45b1e 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useIncrementalTreeData.ts +++ b/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useIncrementalTreeData.ts @@ -2,7 +2,6 @@ import { ObjectItem } from "mendix"; import { ReactNode, useEffect, useRef, useState } from "react"; import { TreeNodeContainerProps } from "../../../../typings/TreeNodeProps"; import { TreeNodeState } from "../../common/TreeNodeState"; -import { TreeNodeV2DataItem } from "../treeBuilder"; type NodePlacement = string | null; @@ -11,6 +10,16 @@ interface TreeConfigRef { headerContent: TreeNodeContainerProps["headerContent"]; headerType: TreeNodeContainerProps["headerType"]; parentAssociation: TreeNodeContainerProps["parentAssociation"]; + startExpanded: TreeNodeContainerProps["startExpanded"]; +} + +export interface TreeNodeV2DataItem { + children: TreeNodeV2DataItem[]; + id: string; + item: ObjectItem; + parentId?: string; + treeNodeState: TreeNodeState; + title: ReactNode; } function getItemId(item: ObjectItem): string { @@ -139,7 +148,7 @@ export function useIncrementalTreeData(items: ObjectItem[] | undefined, config: id: nodeId, item, parentId: nextParentId, - treeNodeState: TreeNodeState.COLLAPSED_WITH_JS, + treeNodeState: config.startExpanded ? TreeNodeState.EXPANDED : TreeNodeState.COLLAPSED_WITH_JS, title: nextTitle }; diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useInfiniteTreeNode.ts b/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useInfiniteTreeNode.ts index ebde32792c..8dba7510f9 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useInfiniteTreeNode.ts +++ b/packages/pluggableWidgets/tree-node-web/src/components/v2/hooks/useInfiniteTreeNode.ts @@ -5,8 +5,11 @@ import { TreeNodeContainerProps } from "../../../../typings/TreeNodeProps"; export type ItemType = Array>; -export function useInfiniteTreeNodes(props: TreeNodeContainerProps) { - const { datasource, parentAssociation } = props; +export function useInfiniteTreeNodes(props: TreeNodeContainerProps): { + items: ObjectItem[] | undefined; + appendItems: (newItem: ObjectItem, children?: ObjectItem[]) => void; +} { + const { datasource, parentAssociation, startExpanded } = props; const loadedParentsByIdRef = useRef>(new Map()); const loadedChildsByIdRef = useRef>(new Map()); const initializedRef = useRef(false); @@ -62,17 +65,19 @@ export function useInfiniteTreeNodes(props: TreeNodeContainerProps) { const parentId = String(item.id); loadedParentsByIdRef.current.set(parentId, item); }); - } - datasource.setFilter(getDatasourceFilter(getExpandedFilterItems())); + datasource.setFilter(getDatasourceFilter(getExpandedFilterItems())); + } return; } initializedRef.current = true; loadedParentsByIdRef.current.clear(); - datasource.setFilter(getDatasourceFilter([undefined])); - }, [datasource, getDatasourceFilter, getExpandedFilterItems]); + if (!startExpanded) { + datasource.setFilter(getDatasourceFilter([undefined])); + } + }, [datasource, getDatasourceFilter, getExpandedFilterItems, startExpanded]); return { items: datasource.items, diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v2/treeBuilder.ts b/packages/pluggableWidgets/tree-node-web/src/components/v2/treeBuilder.ts deleted file mode 100644 index d7d77a25de..0000000000 --- a/packages/pluggableWidgets/tree-node-web/src/components/v2/treeBuilder.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { ObjectItem } from "mendix"; -import { ReactNode } from "react"; -import { TreeNodeContainerProps } from "../../../typings/TreeNodeProps"; -import { TreeNodeState } from "../common/TreeNodeState"; - -export interface TreeNodeV2DataItem { - children: TreeNodeV2DataItem[]; - id: string; - item: ObjectItem; - parentId?: string; - treeNodeState: TreeNodeState; - title: ReactNode; -} - -export interface TreeNodeBuildConfig { - headerCaption: TreeNodeContainerProps["headerCaption"]; - headerContent: TreeNodeContainerProps["headerContent"]; - headerType: TreeNodeContainerProps["headerType"]; - parentAssociation: TreeNodeContainerProps["parentAssociation"]; -} - -function getItemId(item: ObjectItem): string { - return String(item.id); -} - -function getParentId( - item: ObjectItem, - parentAssociation: TreeNodeBuildConfig["parentAssociation"] -): string | undefined { - const parentObject = parentAssociation?.get(item).value; - return parentObject?.id ? String(parentObject.id) : undefined; -} - -function getItemTitle(item: ObjectItem, config: TreeNodeBuildConfig): ReactNode { - if (config.headerType === "text") { - return config.headerCaption?.get(item).value ?? String(item.id); - } - return config.headerContent?.get(item) ?? String(item.id); -} - -export function buildTree(items: ObjectItem[], config: TreeNodeBuildConfig): TreeNodeV2DataItem[] { - const nodes = items.map(item => ({ - children: [], - id: getItemId(item), - item, - parentId: getParentId(item, config.parentAssociation), - treeNodeState: TreeNodeState.COLLAPSED_WITH_JS, - title: getItemTitle(item, config) - })); - - const nodeMap = new Map(); - nodes.forEach(node => { - nodeMap.set(node.id, node); - }); - - const roots: TreeNodeV2DataItem[] = []; - nodes.forEach(node => { - if (!node.parentId || node.parentId === node.id) { - roots.push(node); - return; - } - - const parentNode = nodeMap.get(node.parentId); - if (parentNode) { - parentNode.children.push(node); - } else { - roots.push(node); - } - }); - - return roots; -} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/v2/ui/TreeNodeV2.scss b/packages/pluggableWidgets/tree-node-web/src/components/v2/ui/TreeNodeV2.scss index 10a550b0fb..ae763a83c6 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/v2/ui/TreeNodeV2.scss +++ b/packages/pluggableWidgets/tree-node-web/src/components/v2/ui/TreeNodeV2.scss @@ -2,21 +2,22 @@ &.widget-tree-node-v2 { .widget-tree-node-v2-body { display: grid; - grid-template-rows: 1fr; + grid-template-rows: auto 1fr; opacity: 1; transition: grid-template-rows 220ms ease, opacity 180ms ease; will-change: grid-template-rows, opacity; - > ul { + > ul, + > div { min-height: 0; overflow: hidden; } } .widget-tree-node-v2-body.widget-tree-node-v2-body-collapsed { - grid-template-rows: 0fr; + grid-template-rows: 0fr 0fr; opacity: 0; } }