diff --git a/packages/pluggableWidgets/tree-node-web/CHANGELOG.md b/packages/pluggableWidgets/tree-node-web/CHANGELOG.md index e0035def4f..ff4eef976f 100644 --- a/packages/pluggableWidgets/tree-node-web/CHANGELOG.md +++ b/packages/pluggableWidgets/tree-node-web/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Changed + +- We changed `hasChildren` configuration from boolean to expression. + +### Fixed + +- We fixed an issue where Tree Nodes showing loading spinner when children can't be found while `hasChildren` property configured to `true`. + ## [3.6.0] - 2025-10-01 ### Changed diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx b/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx index 9acba12343..50de82ddbd 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.editorPreview.tsx @@ -1,5 +1,5 @@ -import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; import { mapPreviewIconToWebIcon } from "@mendix/widget-plugin-platform/preview/map-icon"; +import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; import { GUID } from "mendix"; import { ReactElement } from "react"; import { TreeNodePreviewProps } from "../typings/TreeNodeProps"; @@ -32,10 +32,10 @@ export function preview(props: TreeNodePreviewProps): ReactElement | null {
- ) + ), + isUserDefinedLeafNode: !props.hasChildren } ]} - isUserDefinedLeafNode={!props.hasChildren} startExpanded showCustomIcon={Boolean(props.expandedIcon) || Boolean(props.collapsedIcon)} iconPlacement={props.showIcon} diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx index a7c633fc6c..f8564776aa 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.tsx @@ -1,21 +1,21 @@ -import { ReactElement, useEffect, useState } from "react"; import { ObjectItem, ValueStatus } from "mendix"; +import { ReactElement, useEffect, useState } from "react"; import { TreeNodeContainerProps } from "../typings/TreeNodeProps"; -import { TreeNode as TreeNodeComponent, TreeNodeItem } from "./components/TreeNode"; +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) + bodyContent: props.children?.get(item), + isUserDefinedLeafNode: props.hasChildren?.get(item).value === false }; } export function TreeNode(props: TreeNodeContainerProps): ReactElement { const { datasource } = props; - - const [treeNodeItems, setTreeNodeItems] = useState([]); + const [treeNodeItems, setTreeNodeItems] = useState([]); useEffect(() => { // only get the items when datasource is actually available @@ -24,7 +24,9 @@ export function TreeNode(props: TreeNodeContainerProps): ReactElement { if (datasource.items && datasource.items.length) { setTreeNodeItems(datasource.items.map(item => mapDataSourceItemToTreeNodeItem(item, props))); } else { - setTreeNodeItems([]); + setTreeNodeItems({ + Message: "No data available" + }); } } }, [datasource.status, datasource.items]); @@ -36,7 +38,6 @@ export function TreeNode(props: TreeNodeContainerProps): ReactElement { class={props.class} style={props.style} items={treeNodeItems} - isUserDefinedLeafNode={!props.hasChildren} startExpanded={props.startExpanded} showCustomIcon={Boolean(props.expandedIcon) || Boolean(props.collapsedIcon)} iconPlacement={props.showIcon} diff --git a/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml b/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml index baeb7d19df..73552bc94d 100644 --- a/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml +++ b/packages/pluggableWidgets/tree-node-web/src/TreeNode.xml @@ -40,9 +40,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/TreeNode.tsx b/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx index f8e0f2c3df..1ba1796da8 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/TreeNode.tsx @@ -13,13 +13,17 @@ import { TreeNodeBranchContext, useInformParentContextOfChildNodes } from "./Tre export interface TreeNodeItem extends ObjectItem { headerContent: ReactNode; bodyContent: ReactNode; + isUserDefinedLeafNode: boolean; +} + +export interface InfoTreeNodeItem { + Message: string; } export interface TreeNodeProps extends Pick { class: string; style?: CSSProperties; - items: TreeNodeItem[] | null; - isUserDefinedLeafNode: TreeNodeBranchProps["isUserDefinedLeafNode"]; + items: TreeNodeItem[] | InfoTreeNodeItem | null; startExpanded: TreeNodeBranchProps["startExpanded"]; showCustomIcon: boolean; iconPlacement: TreeNodeBranchProps["iconPlacement"]; @@ -34,7 +38,6 @@ export function TreeNode({ class: className, items, style, - isUserDefinedLeafNode, showCustomIcon, startExpanded, iconPlacement, @@ -63,11 +66,11 @@ export function TreeNode({ return treeNodeElement?.parentElement?.className.includes(treeNodeBranchUtils.bodyClassName) ?? false; }, [treeNodeElement]); - useInformParentContextOfChildNodes(items?.length ?? 0, isInsideAnotherTreeNode); + useInformParentContextOfChildNodes(Array.isArray(items) ? items.length : 0, isInsideAnotherTreeNode); const changeTreeNodeBranchHeaderFocus = useTreeNodeFocusChangeHandler(); - if (items === null || items.length === 0) { + if (items === null || (Array.isArray(items) && items.length === 0)) { return null; } @@ -79,22 +82,26 @@ export function TreeNode({ data-focusindex={tabIndex || 0} role={level === 0 ? "tree" : "group"} > - {items.map(({ id, headerContent, bodyContent }) => ( - - {bodyContent} - - ))} + {Array.isArray(items) && + items.map(item => { + const { id, headerContent, bodyContent, isUserDefinedLeafNode } = item; + return ( + + {bodyContent} + + ); + })} ); } diff --git a/packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNode.spec.tsx b/packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNode.spec.tsx index 6480e75b8b..e5662241d4 100644 --- a/packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNode.spec.tsx +++ b/packages/pluggableWidgets/tree-node-web/src/components/__tests__/TreeNode.spec.tsx @@ -1,30 +1,45 @@ -import { GUID } from "mendix"; -import { isValidElement, ReactElement, ReactNode } from "react"; +import "@testing-library/jest-dom"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { UserEvent } from "@testing-library/user-event/setup/setup"; -import "@testing-library/jest-dom"; -import { TreeNode, TreeNodeProps, TreeNodeState } from "../TreeNode"; +import { GUID } from "mendix"; +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"); interface TreeNodeItem { id: GUID; headerContent: string; + isUserDefinedLeafNode: boolean; bodyContent: ReactElement; } const items: TreeNodeItem[] = [ - { id: "11" as GUID, headerContent: "First header", bodyContent:
First content
}, - { id: "22" as GUID, headerContent: "Second header", bodyContent:
Second content
}, - { id: "33" as GUID, headerContent: "Third header", bodyContent:
Third content
} + { + id: "11" as GUID, + headerContent: "First header", + isUserDefinedLeafNode: false, + bodyContent:
First content
+ }, + { + id: "22" as GUID, + headerContent: "Second header", + isUserDefinedLeafNode: false, + bodyContent:
Second content
+ }, + { + id: "33" as GUID, + headerContent: "Third header", + isUserDefinedLeafNode: false, + bodyContent:
Third content
+ } ]; const defaultProps: TreeNodeProps = { class: "", items: [], - isUserDefinedLeafNode: false, startExpanded: false, showCustomIcon: false, iconPlacement: "right", @@ -59,7 +74,7 @@ describe("TreeNode", () => { }); it("shows all headers and contents when starting expanded", () => { - renderTreeNode({ items, isUserDefinedLeafNode: false, startExpanded: true }); + renderTreeNode({ items, startExpanded: true }); items.forEach(item => { expect(screen.getByText(item.headerContent as string)).toBeInTheDocument(); const texts = getTextFromElement(item.bodyContent); @@ -68,7 +83,7 @@ describe("TreeNode", () => { }); it("does not show item content when not starting expanded", () => { - renderTreeNode({ items, isUserDefinedLeafNode: false, startExpanded: false }); + renderTreeNode({ items, startExpanded: false }); items.forEach(item => { expect(screen.getByText(item.headerContent as string)).toBeInTheDocument(); const texts = getTextFromElement(item.bodyContent); @@ -82,16 +97,17 @@ describe("TreeNode", () => { { id: "44" as GUID, headerContent:
This is the 44 header
, + isUserDefinedLeafNode: false, bodyContent:
Fourth content
} ]; - renderTreeNode({ items: newItems, isUserDefinedLeafNode: false, startExpanded: true }); + renderTreeNode({ items: newItems, startExpanded: true }); expect(screen.getByText("This is the 44 header")).toBeInTheDocument(); expect(screen.getByText("Fourth content")).toBeInTheDocument(); }); it("shows the tree node headers in the correct order as a treeitem when not defined as a leaf node", () => { - renderTreeNode({ items, isUserDefinedLeafNode: false, startExpanded: false }); + renderTreeNode({ items, startExpanded: false }); const treeNodeHeaders = screen.getAllByRole("treeitem"); expect(treeNodeHeaders).toHaveLength(items.length); items.forEach(item => { @@ -100,7 +116,7 @@ describe("TreeNode", () => { }); it("correctly collapses and expands the tree node branch content when clicking on the header", async () => { - renderTreeNode({ items, isUserDefinedLeafNode: false, startExpanded: false }); + renderTreeNode({ items, startExpanded: false }); const treeNodeItems = screen.getAllByRole("treeitem"); @@ -122,7 +138,6 @@ describe("TreeNode", () => { expandedIcon: { type: "glyph", iconClass: "expanded-icon" }, collapsedIcon: { type: "image", iconUrl: "image.png" }, items, - isUserDefinedLeafNode: false, startExpanded: false }); expect(screen.getByText("First header")).toBeInTheDocument(); @@ -134,7 +149,6 @@ describe("TreeNode", () => { expandedIcon: { type: "glyph", iconClass: "expanded-icon" }, collapsedIcon: { type: "image", iconUrl: "image.png" }, items, - isUserDefinedLeafNode: false, startExpanded: true }); expect(screen.getByText("First header")).toBeInTheDocument(); @@ -143,9 +157,8 @@ describe("TreeNode", () => { { id: "11" as GUID, headerContent: "Parent treeview with a nested treeview that is empty", - bodyContent: ( - - ) + isUserDefinedLeafNode: false, + bodyContent: } ]; renderTreeNode({ @@ -153,7 +166,6 @@ describe("TreeNode", () => { expandedIcon: { type: "glyph", iconClass: "expanded-icon" }, collapsedIcon: { type: "image", iconUrl: "image.png" }, items: nestedItems, - isUserDefinedLeafNode: false, startExpanded: true }); expect(screen.getByText("Parent treeview with a nested treeview that is empty")).toBeInTheDocument(); @@ -164,9 +176,8 @@ describe("TreeNode", () => { { id: "11" as GUID, headerContent: "Parent treeview with a nested treeview that is empty", - bodyContent: ( - - ) + isUserDefinedLeafNode: false, + bodyContent: } ]; renderTreeNode({ @@ -174,7 +185,6 @@ describe("TreeNode", () => { expandedIcon: { type: "glyph", iconClass: "expanded-icon" }, collapsedIcon: { type: "image", iconUrl: "image.png" }, items: nestedItems, - isUserDefinedLeafNode: false, startExpanded: true }); expect(screen.getByText("Parent treeview with a nested treeview that is empty")).toBeInTheDocument(); @@ -187,9 +197,10 @@ describe("TreeNode", () => { id: "11" as GUID, headerContent: "Parent treeview with a nested treeview that is empty and wrapped with a random other widget", + isUserDefinedLeafNode: false, bodyContent: ( - + ) } @@ -199,7 +210,6 @@ describe("TreeNode", () => { expandedIcon: { type: "glyph", iconClass: "expanded-icon" }, collapsedIcon: { type: "image", iconUrl: "image.png" }, items: nestedItems, - isUserDefinedLeafNode: false, startExpanded: true }); expect( @@ -214,8 +224,14 @@ describe("TreeNode", () => { showCustomIcon: true, expandedIcon: { type: "glyph", iconClass: "expanded-icon" }, collapsedIcon: { type: "image", iconUrl: "image.png" }, - items: [{ id: "11" as GUID, headerContent: "First header", bodyContent: undefined }], - isUserDefinedLeafNode: false, + items: [ + { + id: "11" as GUID, + headerContent: "First header", + isUserDefinedLeafNode: false, + bodyContent: undefined + } + ], startExpanded: true }); expect(screen.getByText("First header")).toBeInTheDocument(); @@ -224,7 +240,6 @@ describe("TreeNode", () => { it("adds a CSS class for the header when the icon animates on toggle", () => { renderTreeNode({ items, - isUserDefinedLeafNode: false, startExpanded: true, animateIcon: true }); @@ -241,8 +256,18 @@ describe("TreeNode", () => { return ; }; const testItems: TreeNodeProps["items"] = [ - { id: "1" as GUID, headerContent: "Header 1", bodyContent:
Content 1
}, - { id: "2" as GUID, headerContent: "Header 2", bodyContent:
Content 2
} + { + id: "1" as GUID, + headerContent: "Header 1", + isUserDefinedLeafNode: false, + bodyContent:
Content 1
+ }, + { + id: "2" as GUID, + headerContent: "Header 2", + isUserDefinedLeafNode: false, + bodyContent:
Content 2
+ } ]; render(); expect(renderSpy).toHaveBeenCalledTimes(1); @@ -260,10 +285,16 @@ describe("TreeNode", () => { describe("when interacting through the keyboard", () => { beforeEach(() => { const treeNodeItems = [ - { id: "1" as GUID, headerContent: "First header", bodyContent:
First content
}, + { + id: "1" as GUID, + headerContent: "First header", + isUserDefinedLeafNode: false, + bodyContent:
First content
+ }, { id: "2" as GUID, headerContent: "Second header", + isUserDefinedLeafNode: false, bodyContent: ( { { id: "21" as GUID, headerContent: "Second First header", + isUserDefinedLeafNode: false, bodyContent:
Second First content
}, { id: "22" as GUID, headerContent: "Second Second header", + isUserDefinedLeafNode: false, bodyContent:
Second Second content
}, { id: "23" as GUID, headerContent: "Second Third header", + isUserDefinedLeafNode: false, bodyContent:
Second Third content
} ]} - isUserDefinedLeafNode={false} startExpanded={false} /> ) @@ -293,19 +326,13 @@ describe("TreeNode", () => { { id: "3" as GUID, headerContent: "Third header", - bodyContent: ( - - ) + isUserDefinedLeafNode: false, + bodyContent: }, { id: "4" as GUID, headerContent: "Fourth header", + isUserDefinedLeafNode: false, bodyContent:
Fourth content
} ]; @@ -313,7 +340,6 @@ describe("TreeNode", () => { renderTreeNode({ class: "", items: treeNodeItems, - isUserDefinedLeafNode: false, startExpanded: 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..0d47f596a2 100644 --- a/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts +++ b/packages/pluggableWidgets/tree-node-web/typings/TreeNodeProps.d.ts @@ -23,7 +23,7 @@ export interface TreeNodeContainerProps { openNodeOn: OpenNodeOnEnum; headerContent?: ListWidgetValue; headerCaption?: ListExpressionValue; - hasChildren: boolean; + hasChildren: ListExpressionValue; startExpanded: boolean; children?: ListWidgetValue; animate: boolean; @@ -50,7 +50,7 @@ export interface TreeNodePreviewProps { openNodeOn: OpenNodeOnEnum; headerContent: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; headerCaption: string; - hasChildren: boolean; + hasChildren: string; startExpanded: boolean; children: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; animate: boolean;