diff --git a/packages/pluggableWidgets/tree-node-web/CHANGELOG.md b/packages/pluggableWidgets/tree-node-web/CHANGELOG.md index 80864ffb5b..378958f370 100644 --- a/packages/pluggableWidgets/tree-node-web/CHANGELOG.md +++ b/packages/pluggableWidgets/tree-node-web/CHANGELOG.md @@ -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 diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx b/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx index 50de82ddbd..ff7c9f464b 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, TreeNodeItem } from "./components/TreeNode"; function renderTextTemplateWithFallback(textTemplateValue: string, placeholder: string): string { if (textTemplateValue.trim().length === 0) { @@ -44,6 +44,10 @@ export function preview(props: TreeNodePreviewProps): ReactElement | null { animateIcon={false} animateTreeNodeContent={false} openNodeOn={"headerClick"} + fetchChildren={() => { + return new Promise(resolve => resolve([])); + }} + isInfiniteTreeNodesEnabled={false} /> ); } diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx index f8564776aa..f45eacd624 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx @@ -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([]); + 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 ( ); } 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/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx index 1ba1796da8..78c5599a57 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx @@ -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 { @@ -32,23 +33,31 @@ export interface TreeNodeProps extends Pick animateIcon: boolean; animateTreeNodeContent: TreeNodeBranchProps["animateTreeNodeContent"]; openNodeOn: OpenNodeOnEnum; + fetchChildren: (item?: Option) => Promise; + 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( @@ -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; } @@ -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 ( - {bodyContent} + {item.bodyContent} ); })} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx index c2883833af..cc4fd76b1d 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx @@ -12,6 +12,7 @@ import { useRef, useState } from "react"; +import { ObjectItem, Option } from "mendix"; import { OpenNodeOnEnum, ShowIconEnum } from "../../typings/TreeNodeProps"; @@ -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) => Promise; + appendChildren: (items: TreeNodeItem[], parent: TreeNodeItem) => void; + treeNodeProps: TreeNodeProps; + isInfiniteTreeNodesEnabled: boolean; } export const treeNodeBranchUtils = { @@ -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(null); const treeNodeBranchBody = useRef(null); - const [isActualLeafNode, setIsActualLeafNode] = useState(isUserDefinedLeafNode || !children); + const [isActualLeafNode, setIsActualLeafNode] = useState( + isUserDefinedLeafNode || (!children && !isInfiniteTreeNodesEnabled) + ); const [treeNodeState, setTreeNodeState] = useState( startExpanded ? TreeNodeState.EXPANDED : TreeNodeState.COLLAPSED_WITH_JS ); @@ -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>( 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( @@ -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) { @@ -199,7 +232,11 @@ export function TreeNodeBranch({ ref={treeNodeBranchBody} onTransitionEnd={cleanupAnimation} > - {children} + {isInfiniteTreeNodesEnabled && item.children && item.children.length > 0 ? ( + + ) : ( + children + )} )} diff --git a/packages/pluggableWidgets/tree-node-web/src/components/hooks/useInfiniteTreeNodes.ts b/packages/pluggableWidgets/tree-node-web/src/components/hooks/useInfiniteTreeNodes.ts new file mode 100644 index 0000000000..ff5c51b028 --- /dev/null +++ b/packages/pluggableWidgets/tree-node-web/src/components/hooks/useInfiniteTreeNodes.ts @@ -0,0 +1,131 @@ +import { ObjectItem, Option, ValueStatus } from "mendix"; +import { association, equals, literal } from "mendix/filters/builders"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { TreeNodeContainerProps } from "../../../typings/TreeNodeProps"; +import { InfoTreeNodeItem, TreeNodeItem } from "../TreeNode"; + +function mapDataSourceItemToTreeNodeItem(item: ObjectItem, props: TreeNodeContainerProps): TreeNodeItem { + return { + ...item, + 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 + }; +} + +/* + * Hook to manage Infinite Tree Nodes functionality + * it allows fetching children based on parent association with javascript Promise pattern + * and then it will resolved the promise when datasource updates comes from the framework + */ +export function useInfiniteTreeNodes(props: TreeNodeContainerProps): { + treeNodeItems: TreeNodeItem[] | InfoTreeNodeItem | null; + fetchChildren: (item?: Option) => Promise; + isInfiniteTreeNodesEnabled: boolean; +} { + const { datasource } = props; + const isInfiniteTreeNodesEnabled = !!props.parentAssociation; + const fetchingItem = useRef | undefined>(undefined); + const resolvePromise = useRef<(value: TreeNodeItem[]) => void | undefined>(undefined); + const [treeNodeItems, setTreeNodeItems] = useState([]); + + // retrieve new datasource based on parents association + const filterContent = useCallback( + (item: Option) => { + if (props.parentAssociation) { + return equals(association(props.parentAssociation?.id), literal(item)); + } + }, + [props.parentAssociation] + ); + + // trigger fetch children via datasource.setFilter + const fetchChildren = useCallback( + (item?: Option) => { + return new Promise(resolve => { + if (isInfiniteTreeNodesEnabled && fetchingItem.current === undefined) { + fetchingItem.current = item; + resolvePromise.current = resolve; + datasource.setFilter(filterContent(item)); + } + }); + }, + [filterContent, datasource, isInfiniteTreeNodesEnabled] + ); + + // Update treeNodeItems when datasource changes + 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) { + const items = datasource.items.map(item => mapDataSourceItemToTreeNodeItem(item, props)); + if (isInfiniteTreeNodesEnabled && fetchingItem.current && resolvePromise.current) { + resolvePromise.current(items); + } + if (Array.isArray(treeNodeItems) && treeNodeItems.length <= 0) { + setTreeNodeItems(items); + } + } else { + resolvePromise.current?.([]); + setTreeNodeItems({ + Message: "No data available" + }); + } + resolvePromise.current = undefined; + fetchingItem.current = undefined; + } + }, [datasource.status, datasource.items]); + + // Initial Load of Top Level TreeNode Items + useEffect(() => { + fetchChildren(undefined); + }, [fetchChildren, isInfiniteTreeNodesEnabled]); + + return { treeNodeItems, fetchChildren, isInfiniteTreeNodesEnabled }; +} + +/* + * Localized TreeNodeItems for Infinite Tree Nodes + * This allows each TreeNode to manage its own items when children are fetched + * without affecting other TreeNodes + * when infinite tree nodes is enabled + * otherwise it will just use the items passed via props + */ +export function useLocalizedTreeNode( + items: TreeNodeItem[] | InfoTreeNodeItem | null, + isInfiniteTreeNodesEnabled: boolean +): { + localizedItems: TreeNodeItem[] | InfoTreeNodeItem | null; + appendChildren: (items: TreeNodeItem[], parent: TreeNodeItem) => void; +} { + const [localizedItems, setLocalizedItems] = useState(items); + const appendChildren = useCallback( + (items: TreeNodeItem[], parent: TreeNodeItem): void => { + setLocalizedItems( + Array.isArray(localizedItems) && isInfiniteTreeNodesEnabled + ? localizedItems.map(item => { + if (item.id === parent.id) { + return { + ...item, + children: items + }; + } + return item; + }) + : localizedItems + ); + }, + [localizedItems, isInfiniteTreeNodesEnabled] + ); + useEffect(() => { + if (isInfiniteTreeNodesEnabled) { + if (Array.isArray(items)) { + setLocalizedItems(items); + } + } + }, [items, isInfiniteTreeNodesEnabled]); + + return { localizedItems: isInfiniteTreeNodesEnabled ? localizedItems : items, appendChildren }; +} 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 }> };