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..4af1e76bf1 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx @@ -1,51 +1,77 @@ -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, useEffect, useId, useRef, useState } from "react"; import { TreeNodeContainerProps } from "../typings/TreeNodeProps"; -import { TreeNode as TreeNodeComponent, TreeNodeItem } from "./components/TreeNode"; +import { TreeNodeRoot } from "./components/TreeNodeRoot"; +import { TreeNodeRootContext } from "./components/TreeNodeRootContext"; -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 | 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 [treeNodeItems, setTreeNodeItems] = useState([]); + const filterContent = useCallback( + (item: Option) => { + if (props.parentAssociation) { + return equals(association(props.parentAssociation?.id), literal(item)); + } + }, + [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 + if (props.parentAssociation) { + fetchChildren(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.current?.id || rootId, { + items: datasource.items, + parentObject: parent.current ?? null + }); } else { - setTreeNodeItems([]); + updatedItems.set(parent.current?.id || rootId, { items: [], parentObject: parent.current ?? null }); } + setTreeNodeItems(updatedItems); } }, [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/TreeNode.xml b/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml index baeb7d19df..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 @@ -40,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/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/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx deleted file mode 100644 index f8e0f2c3df..0000000000 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import classNames from "classnames"; -import { ObjectItem, 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"; - -export interface TreeNodeItem extends ObjectItem { - headerContent: ReactNode; - bodyContent: ReactNode; -} - -export interface TreeNodeProps extends Pick { - class: string; - style?: CSSProperties; - items: TreeNodeItem[] | null; - isUserDefinedLeafNode: TreeNodeBranchProps["isUserDefinedLeafNode"]; - startExpanded: TreeNodeBranchProps["startExpanded"]; - showCustomIcon: boolean; - iconPlacement: TreeNodeBranchProps["iconPlacement"]; - expandedIcon?: WebIcon; - collapsedIcon?: WebIcon; - animateIcon: boolean; - animateTreeNodeContent: TreeNodeBranchProps["animateTreeNodeContent"]; - openNodeOn: OpenNodeOnEnum; -} - -export function TreeNode({ - class: className, - items, - style, - isUserDefinedLeafNode, - showCustomIcon, - startExpanded, - iconPlacement, - expandedIcon, - collapsedIcon, - tabIndex, - animateIcon, - animateTreeNodeContent, - openNodeOn -}: TreeNodeProps): ReactElement | null { - const { level } = useContext(TreeNodeBranchContext); - const [treeNodeElement, updateTreeNodeElement] = useTreeNodeRef(); - - const renderHeaderIconCallback = useCallback( - (treeNodeState, iconPlacement) => - renderTreeNodeHeaderIcon(treeNodeState, iconPlacement, { - animateIcon, - collapsedIcon, - expandedIcon, - showCustomIcon - }), - [collapsedIcon, expandedIcon, showCustomIcon, animateIcon] - ); - - const isInsideAnotherTreeNode = useCallback(() => { - return treeNodeElement?.parentElement?.className.includes(treeNodeBranchUtils.bodyClassName) ?? false; - }, [treeNodeElement]); - - useInformParentContextOfChildNodes(items?.length ?? 0, isInsideAnotherTreeNode); - - const changeTreeNodeBranchHeaderFocus = useTreeNodeFocusChangeHandler(); - - if (items === null || items.length === 0) { - return null; - } - - return ( -
    - {items.map(({ id, headerContent, bodyContent }) => ( - - {bodyContent} - - ))} -
- ); -} - -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/TreeNodeBranch.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx index c2883833af..85d5702fe3 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranch.tsx @@ -13,33 +13,36 @@ import { useState } from "react"; +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 { TreeNodeItem, TreeNodeState } from "./TreeNode"; import { TreeNodeBranchContext, TreeNodeBranchContextProps } from "./TreeNodeBranchContext"; +import { TreeNodeState } from "./TreeNodeComponent"; +import { TreeNodeRootContext } from "./TreeNodeRootContext"; export interface TreeNodeBranchProps { animateTreeNodeContent: boolean; children: ReactNode; headerContent: ReactNode; iconPlacement: ShowIconEnum; - id: TreeNodeItem["id"]; + id: Option; isUserDefinedLeafNode: boolean; openNodeOn: OpenNodeOnEnum; startExpanded: boolean; changeFocus: TreeNodeFocusChangeHandler; renderHeaderIcon: TreeNodeHeaderIcon; + item: ObjectItem; + level: number; } 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,9 +55,11 @@ export function TreeNodeBranch({ isUserDefinedLeafNode, openNodeOn, renderHeaderIcon, - startExpanded + startExpanded, + item, + level }: TreeNodeBranchProps): ReactElement { - const { level: currentContextLevel } = useContext(TreeNodeBranchContext); + const { fetchChildren } = useContext(TreeNodeRootContext); const treeNodeBranchRef = useRef(null); const treeNodeBranchBody = useRef(null); @@ -98,6 +103,7 @@ export function TreeNodeBranch({ return; } + fetchChildren(item); if (!isActualLeafNode) { captureElementHeight(); setTreeNodeState(treeNodeState => { @@ -115,7 +121,7 @@ export function TreeNodeBranch({ }); } }, - [captureElementHeight, eventTargetIsNotCurrentBranch, isActualLeafNode] + [captureElementHeight, eventTargetIsNotCurrentBranch, isActualLeafNode, fetchChildren, item] ); const onHeaderKeyDown = useTreeNodeBranchKeyboardHandler( @@ -184,7 +190,8 @@ export function TreeNodeBranch({ {((!isActualLeafNode && treeNodeState !== TreeNodeState.COLLAPSED_WITH_JS) || isAnimating) && ( diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranchContext.ts b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranchContext.ts index 32b2afba30..dee9d2db16 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranchContext.ts +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeBranchContext.ts @@ -1,11 +1,14 @@ +import { ObjectItem } from "mendix"; import { createContext, useContext, useEffect } from "react"; export interface TreeNodeBranchContextProps { + parent?: ObjectItem; level: number; informParentOfChildNodes: (numberOfNodes: number | undefined) => void; } export const TreeNodeBranchContext = createContext({ + parent: undefined, level: 0, informParentOfChildNodes: () => null }); diff --git a/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx new file mode 100644 index 0000000000..b8c0be49f3 --- /dev/null +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNodeComponent.tsx @@ -0,0 +1,123 @@ +import classNames from "classnames"; +import { ObjectItem, WebIcon } from "mendix"; +import { ReactElement, useCallback } from "react"; + +import { TreeNodeContainerProps } from "../../typings/TreeNodeProps"; + +import { renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "./HeaderIcon"; +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< + TreeNodeContainerProps, + | "tabIndex" + | "class" + | "style" + | "hasChildren" + | "startExpanded" + | "showIcon" + | "animate" + | "animateIcon" + | "openNodeOn" + | "headerType" + | "headerCaption" + | "headerContent" + | "children" + > { + items: ObjectItem[] | null; + showCustomIcon: boolean; + expandedIcon?: WebIcon; + collapsedIcon?: WebIcon; + level: number; + isInfiniteMode: boolean; +} + +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, + isInfiniteMode + } = props; + const [treeNodeElement, updateTreeNodeElement] = useTreeNodeRef(); + const showIconAnimation = animate && animateIcon; + + const renderHeaderIconCallback = useCallback( + (treeNodeState, iconPlacement) => + renderTreeNodeHeaderIcon(treeNodeState, iconPlacement, { + animateIcon: showIconAnimation, + collapsedIcon, + expandedIcon, + showCustomIcon + }), + [collapsedIcon, expandedIcon, showCustomIcon, showIconAnimation] + ); + + const isInsideAnotherTreeNode = useCallback(() => { + return treeNodeElement?.parentElement?.className.includes(treeNodeBranchUtils.bodyClassName) ?? false; + }, [treeNodeElement]); + + useInformParentContextOfChildNodes(items?.length ?? 0, isInsideAnotherTreeNode); + + const changeTreeNodeBranchHeaderFocus = useTreeNodeFocusChangeHandler(); + + if (items === null || items.length === 0) { + return null; + } + + return ( +
    + {items.map((item, _idx) => ( + + {children?.get(item)} + {isInfiniteMode && } + + ))} +
+ ); +} + +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/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 +}); 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/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 004c12f44a..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,11 +19,12 @@ export interface TreeNodeContainerProps { tabIndex?: number; advancedMode: boolean; datasource: ListValue; + parentAssociation?: ListReferenceValue; headerType: HeaderTypeEnum; openNodeOn: OpenNodeOnEnum; headerContent?: ListWidgetValue; headerCaption?: ListExpressionValue; - hasChildren: boolean; + hasChildren: ListExpressionValue; startExpanded: boolean; children?: ListWidgetValue; animate: boolean; @@ -46,11 +47,12 @@ 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 }> }; headerCaption: string; - hasChildren: boolean; + hasChildren: string; startExpanded: boolean; children: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; animate: boolean;