From 4cfd8d9250f30751e97ac7fe46ad7bab8cea0955 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 1 Jan 2026 12:02:28 +0500 Subject: [PATCH 01/17] fix code --- client/packages/lowcoder/package.json | 7 ++- .../navComp/components/DroppableMenuItem.tsx | 2 - .../comps/navComp/components/MenuItemList.tsx | 2 - .../src/comps/comps/navComp/navComp.tsx | 1 - client/yarn.lock | 60 +++++++++++++------ 5 files changed, 46 insertions(+), 26 deletions(-) diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json index 418d72a392..6ed1d1b1ee 100644 --- a/client/packages/lowcoder/package.json +++ b/client/packages/lowcoder/package.json @@ -16,10 +16,10 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-sql": "^6.5.4", "@codemirror/search": "^6.5.5", - "@dnd-kit/core": "^5.0.1", + "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^7.0.0", - "@dnd-kit/sortable": "^6.0.0", - "@dnd-kit/utilities": "^3.1.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/free-brands-svg-icons": "^6.5.1", "@fortawesome/free-regular-svg-icons": "^6.5.1", @@ -48,6 +48,7 @@ "copy-to-clipboard": "^3.3.3", "core-js": "^3.25.2", "dayjs": "^1.11.13", + "dnd-kit-sortable-tree": "^0.1.73", "echarts": "^5.4.3", "echarts-for-react": "^3.0.2", "echarts-wordcloud": "^2.1.0", diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx index c4f22191a4..ffd56d9028 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx @@ -66,8 +66,6 @@ export default function DraggableMenuItem(props: IDraggableMenuItemProps) { data: dropData, }); - // TODO: Remove this later. - // Set ItemKey for previously added sub-menus useEffect(() => { if(!items.length) return; if(!(items[0] instanceof LayoutMenuItemComp)) return; diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index 4c9d0de1ef..983489ebfc 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -60,7 +60,6 @@ function MenuItemList(props: IMenuItemListProps) { sourcePath.length === targetPath.length && _.isEqual(sourcePath.slice(0, -1), targetPath.slice(0, -1)) ) { - // same level move const from = sourcePath[sourcePath.length - 1]; let to = targetPath[targetPath.length - 1]; if (from < to) { @@ -68,7 +67,6 @@ function MenuItemList(props: IMenuItemListProps) { } onMoveItem(targetPath, from, to); } else { - // cross level move let targetIndex = targetPath[targetPath.length - 1]; let targetListPath = targetPath; let size = 0; diff --git a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx index 940e0110d0..bdff4240b4 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx @@ -69,7 +69,6 @@ ${props=>props.$animationStyle} const DEFAULT_SIZE = 378; -// If it is a number, use the px unit by default function transToPxSize(size: string | number) { return isNumeric(size) ? size + "px" : (size as string); } diff --git a/client/yarn.lock b/client/yarn.lock index b3885ff806..c1fde16cd7 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1877,7 +1877,7 @@ __metadata: languageName: node linkType: hard -"@dnd-kit/accessibility@npm:^3.0.0": +"@dnd-kit/accessibility@npm:^3.1.1": version: 3.1.1 resolution: "@dnd-kit/accessibility@npm:3.1.1" dependencies: @@ -1888,17 +1888,17 @@ __metadata: languageName: node linkType: hard -"@dnd-kit/core@npm:^5.0.1": - version: 5.0.3 - resolution: "@dnd-kit/core@npm:5.0.3" +"@dnd-kit/core@npm:^6.3.1": + version: 6.3.1 + resolution: "@dnd-kit/core@npm:6.3.1" dependencies: - "@dnd-kit/accessibility": ^3.0.0 - "@dnd-kit/utilities": ^3.1.0 + "@dnd-kit/accessibility": ^3.1.1 + "@dnd-kit/utilities": ^3.2.2 tslib: ^2.0.0 peerDependencies: react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 4ace7c45057ed0a7257ab16b8b0ebf76b135e8d5675d6dd285138b99a17b0edf7f57e02f251b1b17efb055bad32d7c90b96616b6c77b4e775afbfbaddea401c5 + checksum: abe5ca5c63af2652b50df2636111a8eecb1560338f3b57e27af0d4eac31f89a278347049dbd59897aeec262477ef88d7a906a79254360c40480e490ee910947c languageName: node linkType: hard @@ -1915,20 +1915,20 @@ __metadata: languageName: node linkType: hard -"@dnd-kit/sortable@npm:^6.0.0": - version: 6.0.1 - resolution: "@dnd-kit/sortable@npm:6.0.1" +"@dnd-kit/sortable@npm:^10.0.0": + version: 10.0.0 + resolution: "@dnd-kit/sortable@npm:10.0.0" dependencies: - "@dnd-kit/utilities": ^3.1.0 + "@dnd-kit/utilities": ^3.2.2 tslib: ^2.0.0 peerDependencies: - "@dnd-kit/core": ^5.0.2 + "@dnd-kit/core": ^6.3.0 react: ">=16.8.0" - checksum: beb80a229a50885a654ff15ee98af3b34b02826cadd6bc2f94b79dd103a140f70c35d0a3bf422adf87327573ff15dc3e26e9e5769e0f67b68943d8eaa9560183 + checksum: c853cb65d2ffb3d58d400d9f1c993b00413932acf5cf5b780c76acf3b1057aa88e7866021c6b178c4b33fc17db7fe7640584dba4449772e02edcb72cc797eeb0 languageName: node linkType: hard -"@dnd-kit/utilities@npm:^3.1.0, @dnd-kit/utilities@npm:^3.2.2": +"@dnd-kit/utilities@npm:^3.2.2": version: 3.2.2 resolution: "@dnd-kit/utilities@npm:3.2.2" dependencies: @@ -7340,7 +7340,7 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^1.0.4, clsx@npm:^1.1.1": +"clsx@npm:^1.0.4, clsx@npm:^1.1.1, clsx@npm:^1.2.1": version: 1.2.1 resolution: "clsx@npm:1.2.1" checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12 @@ -8899,6 +8899,22 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"dnd-kit-sortable-tree@npm:^0.1.73": + version: 0.1.73 + resolution: "dnd-kit-sortable-tree@npm:0.1.73" + dependencies: + clsx: ^1.2.1 + react-merge-refs: ^2.0.1 + peerDependencies: + "@dnd-kit/core": ">=6.0.5" + "@dnd-kit/sortable": ">=7.0.1" + "@dnd-kit/utilities": ">=3.2.0" + react: ">=16" + react-dom: ">=16" + checksum: 86bec921ebb4484f03848fccac21654b9a98ef590978815c45b908a297d28faf88094093545e74387315a70a8f661c497d32987f34573e6cd2bd44aed0314cad + languageName: node + linkType: hard + "dns-packet@npm:^5.2.2": version: 5.6.1 resolution: "dns-packet@npm:5.6.1" @@ -14113,10 +14129,10 @@ coolshapes-react@lowcoder-org/coolshapes-react: "@codemirror/lang-json": ^6.0.1 "@codemirror/lang-sql": ^6.5.4 "@codemirror/search": ^6.5.5 - "@dnd-kit/core": ^5.0.1 + "@dnd-kit/core": ^6.3.1 "@dnd-kit/modifiers": ^7.0.0 - "@dnd-kit/sortable": ^6.0.0 - "@dnd-kit/utilities": ^3.1.0 + "@dnd-kit/sortable": ^10.0.0 + "@dnd-kit/utilities": ^3.2.2 "@fortawesome/fontawesome-svg-core": ^6.5.1 "@fortawesome/free-brands-svg-icons": ^6.5.1 "@fortawesome/free-regular-svg-icons": ^6.5.1 @@ -14154,6 +14170,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: copy-to-clipboard: ^3.3.3 core-js: ^3.25.2 dayjs: ^1.11.13 + dnd-kit-sortable-tree: ^0.1.73 dotenv: ^16.0.3 echarts: ^5.4.3 echarts-for-react: ^3.0.2 @@ -17955,6 +17972,13 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"react-merge-refs@npm:^2.0.1": + version: 2.1.1 + resolution: "react-merge-refs@npm:2.1.1" + checksum: 40564bc4c16520ef830d4fe7a2bd298c23a42d644a8fcb2353cdc6cf16aa82eac681c554df4c849397b25af9dbe728086566e1a01f65f95d2301dc8fc8f6809f + languageName: node + linkType: hard + "react-player@npm:^2.11.0": version: 2.16.0 resolution: "react-player@npm:2.16.0" From b491ce0b22ee9fa1ec2f60d5200dcd2ae9a66180 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 1 Jan 2026 12:13:03 +0500 Subject: [PATCH 02/17] add sortable tree for nav --- .../navComp/components/DraggableItem.tsx | 106 -------- .../navComp/components/DroppableMenuItem.tsx | 128 ---------- .../components/DroppablePlaceHolder.tsx | 43 ---- .../comps/navComp/components/MenuItem.tsx | 85 +++---- .../comps/navComp/components/MenuItemList.tsx | 238 +++++++++++------- .../comps/comps/navComp/components/types.ts | 15 +- 6 files changed, 193 insertions(+), 422 deletions(-) delete mode 100644 client/packages/lowcoder/src/comps/comps/navComp/components/DraggableItem.tsx delete mode 100644 client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx delete mode 100644 client/packages/lowcoder/src/comps/comps/navComp/components/DroppablePlaceHolder.tsx diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/DraggableItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/DraggableItem.tsx deleted file mode 100644 index 7a4c6ba1b3..0000000000 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/DraggableItem.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { DragIcon } from "lowcoder-design"; -import React, { Ref } from "react"; -import { HTMLAttributes, ReactNode } from "react"; -import styled from "styled-components"; - -const Wrapper = styled.div<{ $dragging: boolean; $isOver: boolean; $dropInAsSub: boolean }>` - position: relative; - width: 100%; - height: 30px; - border: 1px solid #d7d9e0; - border-radius: 4px; - margin-bottom: 4px; - display: flex; - padding: 0 8px; - background-color: #ffffff; - align-items: center; - opacity: ${(props) => (props.$dragging ? "0.5" : 1)}; - - &::after { - content: ""; - display: ${(props) => (props.$isOver ? "block" : "none")}; - height: 4px; - border-radius: 4px; - position: absolute; - left: ${(props) => (props.$dropInAsSub ? "15px" : "-1px")}; - right: 0; - background-color: #315efb; - bottom: -5px; - } - - .draggable-handle-icon { - &:hover, - &:focus { - cursor: grab; - } - - &, - & > svg { - width: 16px; - height: 16px; - } - } - - .draggable-text { - color: #333; - font-size: 13px; - margin-left: 4px; - height: 100%; - display: flex; - align-items: center; - flex: 1; - overflow: hidden; - cursor: pointer; - - & > div { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - display: inline-block; - height: 28px; - line-height: 28px; - } - } - - .draggable-extra-icon { - cursor: pointer; - - &, - & > svg { - width: 16px; - height: 16px; - } - } -`; - -interface IProps extends HTMLAttributes { - dragContent: ReactNode; - isOver?: boolean; - extra?: ReactNode; - dragging?: boolean; - dropInAsSub?: boolean; - dragListeners?: Record; -} - -function DraggableItem(props: IProps, ref: Ref) { - const { - dragContent: text, - extra, - dragging = false, - dropInAsSub = true, - isOver = false, - dragListeners, - ...divProps - } = props; - return ( - -
- -
-
{text}
-
{extra}
-
- ); -} - -export default React.forwardRef(DraggableItem); diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx deleted file mode 100644 index ffd56d9028..0000000000 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useDraggable, useDroppable } from "@dnd-kit/core"; -import { trans } from "i18n"; -import { Fragment, useEffect } from "react"; -import styled from "styled-components"; -import DroppablePlaceholder from "./DroppablePlaceHolder"; -import MenuItem, { ICommonItemProps } from "./MenuItem"; -import { IDragData, IDropData } from "./types"; -import { LayoutMenuItemComp } from "comps/comps/layout/layoutMenuItemComp"; -import { genRandomKey } from "comps/utils/idGenerator"; - -const DraggableMenuItemWrapper = styled.div` - position: relative; -`; - -interface IDraggableMenuItemProps extends ICommonItemProps { - level: number; - active?: boolean; - disabled?: boolean; - disableDropIn?: boolean; - parentDragging?: boolean; -} - -export default function DraggableMenuItem(props: IDraggableMenuItemProps) { - const { - item, - path, - active, - disabled, - parentDragging, - disableDropIn, - dropInAsSub = true, - onAddSubMenu, - onDelete, - } = props; - - const id = path.join("_"); - const items = item.getView().items; - - const handleAddSubMenu = (path: number[]) => { - onAddSubMenu?.(path, { - label: trans("droppadbleMenuItem.subMenu", { number: items.length + 1 }), - }); - }; - - const dragData: IDragData = { - path, - item, - }; - const { - listeners: dragListeners, - setNodeRef: setDragNodeRef, - isDragging, - } = useDraggable({ - id, - data: dragData, - }); - - const dropData: IDropData = { - targetListSize: items.length, - targetPath: dropInAsSub ? [...path, 0] : [...path.slice(0, -1), path[path.length - 1] + 1], - dropInAsSub, - }; - const { setNodeRef: setDropNodeRef, isOver } = useDroppable({ - id, - disabled: isDragging || disabled || disableDropIn, - data: dropData, - }); - - useEffect(() => { - if(!items.length) return; - if(!(items[0] instanceof LayoutMenuItemComp)) return; - - return items.forEach(item => { - const subItem = item as LayoutMenuItemComp; - const itemKey = subItem.children.itemKey.getView(); - if(itemKey === '') { - subItem.children.itemKey.dispatchChangeValueAction(genRandomKey()) - } - }) - }, [items]) - - return ( - <> - - {active && ( - - )} - { - setDragNodeRef(node); - setDropNodeRef(node); - }} - isOver={isOver} - dropInAsSub={dropInAsSub} - dragging={isDragging || parentDragging} - dragListeners={{ ...dragListeners }} - onAddSubMenu={onAddSubMenu && handleAddSubMenu} - onDelete={onDelete} - /> - - {items.length > 0 && ( -
- {items.map((subItem, i) => ( - - - - ))} -
- )} - - ); -} diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppablePlaceHolder.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/DroppablePlaceHolder.tsx deleted file mode 100644 index 72c15cf854..0000000000 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppablePlaceHolder.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useDroppable } from "@dnd-kit/core"; -import styled from "styled-components"; -import { IDropData } from "./types"; - -interface IDroppablePlaceholderProps { - path: number[]; - disabled?: boolean; - targetListSize: number; -} - -const PlaceHolderWrapper = styled.div<{ $active: boolean }>` - position: absolute; - width: 100%; - top: -4px; - height: 25px; - z-index: 10; - /* background-color: rgba(0, 0, 0, 0.2); */ - .position-line { - height: 4px; - border-radius: 4px; - background-color: ${(props) => (props.$active ? "#315efb" : "transparent")}; - width: 100%; - } -`; - -export default function DroppablePlaceholder(props: IDroppablePlaceholderProps) { - const { path, disabled, targetListSize } = props; - const data: IDropData = { - targetPath: path, - targetListSize, - dropInAsSub: false, - }; - const { setNodeRef: setDropNodeRef, isOver } = useDroppable({ - id: `p_${path.join("_")}`, - disabled, - data, - }); - return ( - -
-
- ); -} diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx index b328c30b01..8eb7401996 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx @@ -1,24 +1,16 @@ import { ActiveTextColor, GreyTextColor } from "constants/style"; import { EditPopover, SimplePopover } from "lowcoder-design"; import { PointIcon } from "lowcoder-design"; -import React, { HTMLAttributes, useState } from "react"; +import React, { useState } from "react"; import styled from "styled-components"; -import DraggableItem from "./DraggableItem"; import { NavCompType } from "comps/comps/navComp/components/types"; import { trans } from "i18n"; -export interface ICommonItemProps { - path: number[]; +export interface IMenuItemProps { item: NavCompType; - dropInAsSub?: boolean; - onDelete?: (path: number[]) => void; - onAddSubMenu?: (path: number[], value?: any) => void; -} - -interface IMenuItemProps extends ICommonItemProps, Omit, "id"> { - isOver?: boolean; - dragging?: boolean; - dragListeners?: Record; + onDelete?: () => void; + onAddSubMenu?: () => void; + showAddSubMenu?: boolean; } const MenuItemWrapper = styled.div` @@ -29,6 +21,13 @@ const MenuItemWrapper = styled.div` const MenuItemContent = styled.div` width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; + flex: 1; + color: #333; + font-size: 13px; `; const StyledPointIcon = styled(PointIcon)` @@ -39,61 +38,45 @@ const StyledPointIcon = styled(PointIcon)` } `; -const MenuItem = React.forwardRef((props: IMenuItemProps, ref: React.Ref) => { +const MenuItem: React.FC = (props) => { const { - path, item, - isOver, - dragging, - dragListeners, - dropInAsSub = true, onDelete, onAddSubMenu, - ...divProps + showAddSubMenu = true, } = props; const [isConfigPopShow, showConfigPop] = useState(false); const handleDel = () => { - onDelete?.(path); + onDelete?.(); }; const handleAddSubMenu = () => { - onAddSubMenu?.(path); + onAddSubMenu?.(); }; const content = {item.getPropertyView()}; return ( - - {item.children.label.getView()} - - } - extra={ - - - - } - /> + <> + + {item.children.label.getView()} + + + + + ); -}); +}; export default MenuItem; diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index 983489ebfc..53c8220499 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -1,14 +1,11 @@ -import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core"; +import { SortableTree, TreeItems, TreeItemComponentProps, SimpleTreeItemWrapper } from "dnd-kit-sortable-tree"; import LinkPlusButton from "components/LinkPlusButton"; import { BluePlusIcon, controlItem } from "lowcoder-design"; import { trans } from "i18n"; -import _ from "lodash"; -import { useState } from "react"; +import React, { useMemo, useCallback, createContext, useContext } from "react"; import styled from "styled-components"; -import DraggableMenuItem from "./DroppableMenuItem"; -import DroppablePlaceholder from "./DroppablePlaceHolder"; +import { NavCompType, NavListCompType, NavTreeItemData } from "./types"; import MenuItem from "./MenuItem"; -import { IDragData, IDropData, NavCompType, NavListCompType } from "./types"; const Wrapper = styled.div` .menu-title { @@ -16,118 +13,155 @@ const Wrapper = styled.div` flex-direction: row; justify-content: space-between; align-items: center; + margin-bottom: 8px; } .menu-list { - margin-top: 8px; position: relative; } +`; - .sub-menu-list { - padding-left: 16px; +const TreeItemContent = styled.div` + display: flex; + align-items: center; + width: 100%; + height: 30px; + background-color: #ffffff; + border: 1px solid #d7d9e0; + border-radius: 4px; + padding: 0 8px; + box-sizing: border-box; + gap: 8px; + + &:hover { + border-color: #315efb; } `; +// Context for passing handlers to tree items +interface MenuItemHandlers { + onDeleteItem: (path: number[]) => void; + onAddSubItem: (path: number[], value?: any) => void; +} + +const MenuItemHandlersContext = createContext(null); + +// Tree item component +const NavTreeItemComponent = React.forwardRef< + HTMLDivElement, + TreeItemComponentProps +>((props, ref) => { + const { item, depth, ...rest } = props; + const { comp, path } = item; + const handlers = useContext(MenuItemHandlersContext); + + const hasChildren = item.children && item.children.length > 0; + + const handleDelete = () => { + handlers?.onDeleteItem(path); + }; + + const handleAddSubMenu = () => { + handlers?.onAddSubItem(path, { + label: `Sub Menu ${(item.children?.length || 0) + 1}`, + }); + }; + + return ( + + + + + + ); +}); + +NavTreeItemComponent.displayName = "NavTreeItemComponent"; + interface IMenuItemListProps { items: NavCompType[]; onAddItem: (path: number[], value?: any) => number; onDeleteItem: (path: number[]) => void; onAddSubItem: (path: number[], value: any, unshift?: boolean) => number; onMoveItem: (path: number[], from: number, to: number) => void; + onReorderItems: (newOrder: TreeItems) => void; } const menuItemLabel = trans("navigation.itemsDesc"); +// Convert NavCompType[] to TreeItems format for dnd-kit-sortable-tree +function convertToTreeItems( + items: NavCompType[], + basePath: number[] = [] +): TreeItems { + return items.map((item, index) => { + const path = [...basePath, index]; + const subItems = item.getView().items || []; + + return { + id: path.join("_"), + comp: item, + path: path, + children: subItems.length > 0 + ? convertToTreeItems(subItems, path) + : [], + }; + }); +} + function MenuItemList(props: IMenuItemListProps) { - const { items, onAddItem, onDeleteItem, onMoveItem, onAddSubItem } = props; + const { items, onAddItem, onDeleteItem, onAddSubItem, onReorderItems } = props; - const [active, setActive] = useState(null); - const isDraggingWithSub = active && active.item.children.items.getView().length > 0; + // Convert items to tree format + const treeItems = useMemo(() => convertToTreeItems(items), [items]); - function handleDragStart(event: DragStartEvent) { - setActive(event.active.data.current as IDragData); - } - - function handleDragEnd(e: DragEndEvent) { - const activeData = e.active.data.current as IDragData; - const overData = e.over?.data.current as IDropData; - - if (overData) { - const sourcePath = activeData.path; - const targetPath = overData.targetPath; - - if ( - sourcePath.length === targetPath.length && - _.isEqual(sourcePath.slice(0, -1), targetPath.slice(0, -1)) - ) { - const from = sourcePath[sourcePath.length - 1]; - let to = targetPath[targetPath.length - 1]; - if (from < to) { - to -= 1; - } - onMoveItem(targetPath, from, to); - } else { - let targetIndex = targetPath[targetPath.length - 1]; - let targetListPath = targetPath; - let size = 0; - - onDeleteItem(sourcePath); - - if (overData.dropInAsSub) { - targetListPath = targetListPath.slice(0, -1); - size = onAddSubItem(targetListPath, activeData.item.toJsonValue()); - } else { - size = onAddItem(targetListPath, activeData.item.toJsonValue()); - } - - if (overData.targetListSize !== -1) { - onMoveItem(targetListPath, size, targetIndex); - } - } - } + // Handle tree changes from drag and drop + const handleItemsChanged = useCallback( + (newItems: TreeItems) => { + onReorderItems(newItems); + }, + [onReorderItems] + ); - setActive(null); - } + // Handlers context value + const handlers = useMemo( + () => ({ + onDeleteItem, + onAddSubItem, + }), + [onDeleteItem, onAddSubItem] + ); return ( - -
-
{menuItemLabel}
- onAddItem([0])} icon={}> - {trans("newItem")} - -
-
- {items.map((i, idx) => { - return ( - - ); - })} -
- {active && } -
-
- - {active && } - -
+
+
{menuItemLabel}
+ onAddItem([0])} icon={}> + {trans("newItem")} + +
+
+ + + +
); } export function menuPropertyView(itemsComp: NavListCompType) { const items = itemsComp.getView(); + const getItemByPath = (path: number[], scope?: NavCompType[]): NavCompType => { if (!scope) { scope = items; @@ -148,6 +182,37 @@ export function menuPropertyView(itemsComp: NavListCompType) { return getItemListByPath(path.slice(1), root.getView()[path[0]].children.items); }; + // Convert flat tree structure back to nested comp structure + const handleReorderItems = (newItems: TreeItems) => { + // Build the new order from tree items + const buildJsonFromTree = (treeItems: TreeItems): any[] => { + return treeItems.map((item) => { + const jsonValue = item.comp.toJsonValue() as Record; + return { + ...jsonValue, + items: item.children && item.children.length > 0 + ? buildJsonFromTree(item.children) + : [], + }; + }); + }; + + const newJson = buildJsonFromTree(newItems); + + // Clear all existing items and re-add in new order + const currentLength = itemsComp.getView().length; + + // Delete all items from end to start + for (let i = currentLength - 1; i >= 0; i--) { + itemsComp.deleteItem(i); + } + + // Add items back in new order + newJson.forEach((itemJson) => { + itemsComp.addItem(itemJson); + }); + }; + return controlItem( { filterText: menuItemLabel }, { getItemListByPath(path).moveItem(from, to); }} + onReorderItems={handleReorderItems} /> ); } diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts b/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts index 09640aac33..ede5270cc4 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts @@ -1,5 +1,6 @@ import { NavItemComp, navListComp } from "../navItemComp"; import { LayoutMenuItemComp, LayoutMenuItemListComp } from "comps/comps/layout/layoutMenuItemComp"; +import { TreeItem } from "dnd-kit-sortable-tree"; export type NavCompType = NavItemComp | LayoutMenuItemComp; @@ -15,13 +16,11 @@ export interface NavCompItemType { onEvent: (name: string) => void; } -export interface IDropData { - targetListSize: number; - targetPath: number[]; - dropInAsSub: boolean; -} - -export interface IDragData { - item: NavCompType; +// Tree item data for dnd-kit-sortable-tree +export interface NavTreeItemData { + comp: NavCompType; path: number[]; } + +// Full tree item type for the sortable tree +export type NavTreeItem = TreeItem; From b21552a72a50625ab80cde7ac7097e2c5278eee9 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 1 Jan 2026 13:50:38 +0500 Subject: [PATCH 03/17] add collapsible --- .../comps/navComp/components/MenuItemList.tsx | 57 +++++++++++++++++-- .../comps/comps/navComp/components/types.ts | 1 + 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index 53c8220499..6bc10de976 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -2,7 +2,7 @@ import { SortableTree, TreeItems, TreeItemComponentProps, SimpleTreeItemWrapper import LinkPlusButton from "components/LinkPlusButton"; import { BluePlusIcon, controlItem } from "lowcoder-design"; import { trans } from "i18n"; -import React, { useMemo, useCallback, createContext, useContext } from "react"; +import React, { useMemo, useCallback, createContext, useContext, useState } from "react"; import styled from "styled-components"; import { NavCompType, NavListCompType, NavTreeItemData } from "./types"; import MenuItem from "./MenuItem"; @@ -42,6 +42,8 @@ const TreeItemContent = styled.div` interface MenuItemHandlers { onDeleteItem: (path: number[]) => void; onAddSubItem: (path: number[], value?: any) => void; + collapsedItems: Set; + onToggleCollapse: (id: string) => void; } const MenuItemHandlersContext = createContext(null); @@ -51,6 +53,7 @@ const NavTreeItemComponent = React.forwardRef< HTMLDivElement, TreeItemComponentProps >((props, ref) => { + console.log("NavTreeItemComponent", props); const { item, depth, ...rest } = props; const { comp, path } = item; const handlers = useContext(MenuItemHandlersContext); @@ -97,18 +100,21 @@ const menuItemLabel = trans("navigation.itemsDesc"); // Convert NavCompType[] to TreeItems format for dnd-kit-sortable-tree function convertToTreeItems( items: NavCompType[], - basePath: number[] = [] + basePath: number[] = [], + collapsedItems: Set = new Set() ): TreeItems { return items.map((item, index) => { const path = [...basePath, index]; + const id = path.join("_"); const subItems = item.getView().items || []; return { - id: path.join("_"), + id, comp: item, path: path, + collapsed: collapsedItems.has(id), children: subItems.length > 0 - ? convertToTreeItems(subItems, path) + ? convertToTreeItems(subItems, path, collapsedItems) : [], }; }); @@ -117,12 +123,48 @@ function convertToTreeItems( function MenuItemList(props: IMenuItemListProps) { const { items, onAddItem, onDeleteItem, onAddSubItem, onReorderItems } = props; + // State for tracking collapsed items + const [collapsedItems, setCollapsedItems] = useState>(new Set()); + + // Toggle collapse state for an item + const handleToggleCollapse = useCallback((id: string) => { + setCollapsedItems(prev => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }, []); + // Convert items to tree format - const treeItems = useMemo(() => convertToTreeItems(items), [items]); + const treeItems = useMemo(() => convertToTreeItems(items, [], collapsedItems), [items, collapsedItems]); // Handle tree changes from drag and drop const handleItemsChanged = useCallback( (newItems: TreeItems) => { + // Update collapsed state from the new items + const updateCollapsedState = (treeItems: TreeItems) => { + treeItems.forEach(item => { + if (item.collapsed !== undefined) { + setCollapsedItems(prev => { + const newSet = new Set(prev); + if (item.collapsed) { + newSet.add(item.id as string); + } else { + newSet.delete(item.id as string); + } + return newSet; + }); + } + if (item.children && item.children.length > 0) { + updateCollapsedState(item.children); + } + }); + }; + updateCollapsedState(newItems); onReorderItems(newItems); }, [onReorderItems] @@ -133,8 +175,10 @@ function MenuItemList(props: IMenuItemListProps) { () => ({ onDeleteItem, onAddSubItem, + collapsedItems, + onToggleCollapse: handleToggleCollapse, }), - [onDeleteItem, onAddSubItem] + [onDeleteItem, onAddSubItem, collapsedItems, handleToggleCollapse] ); return ( @@ -152,6 +196,7 @@ function MenuItemList(props: IMenuItemListProps) { onItemsChanged={handleItemsChanged} TreeItemComponent={NavTreeItemComponent} indentationWidth={20} + /> diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts b/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts index ede5270cc4..86e45194c9 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts @@ -20,6 +20,7 @@ export interface NavCompItemType { export interface NavTreeItemData { comp: NavCompType; path: number[]; + collapsed?: boolean; } // Full tree item type for the sortable tree From 9ee94782307275b3f4066223e87598952764ce7a Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 1 Jan 2026 14:03:37 +0500 Subject: [PATCH 04/17] fix linter errors --- client/packages/lowcoder-design/src/components/option.tsx | 8 ++++---- .../lowcoder/src/comps/comps/formComp/createForm.tsx | 4 ++-- .../lowcoder/src/comps/comps/listViewComp/listView.tsx | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/client/packages/lowcoder-design/src/components/option.tsx b/client/packages/lowcoder-design/src/components/option.tsx index 4e62301f83..d35ee0be11 100644 --- a/client/packages/lowcoder-design/src/components/option.tsx +++ b/client/packages/lowcoder-design/src/components/option.tsx @@ -9,7 +9,7 @@ import { CSS } from "@dnd-kit/utilities"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { ConstructorToComp, MultiCompConstructor } from "lowcoder-core"; import { ReactComponent as WarnIcon } from "icons/v1/icon-warning-white.svg"; -import { DndContext } from "@dnd-kit/core"; +import { DndContext, DragEndEvent } from "@dnd-kit/core"; import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { ActiveTextColor, GreyTextColor } from "constants/style"; import { trans } from "i18n/design"; @@ -225,12 +225,12 @@ function Option>(props: { } return -1; }; - const handleDragEnd = (e: { active: { id: string }; over: { id: string } | null }) => { + const handleDragEnd = (e: DragEndEvent) => { if (!e.over) { return; } - const fromIndex = findIndex(e.active.id); - const toIndex = findIndex(e.over.id); + const fromIndex = findIndex(String(e.active.id)); + const toIndex = findIndex(String(e.over.id)); if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { return; } diff --git a/client/packages/lowcoder/src/comps/comps/formComp/createForm.tsx b/client/packages/lowcoder/src/comps/comps/formComp/createForm.tsx index 6d4f2fc9ad..46ca12d17c 100644 --- a/client/packages/lowcoder/src/comps/comps/formComp/createForm.tsx +++ b/client/packages/lowcoder/src/comps/comps/formComp/createForm.tsx @@ -28,7 +28,7 @@ import log from "loglevel"; import { Datasource } from "@lowcoder-ee/constants/datasourceConstants"; import DataSourceIcon from "components/DataSourceIcon"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; -import { DndContext } from "@dnd-kit/core"; +import { DndContext, DragEndEvent } from "@dnd-kit/core"; import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -599,7 +599,7 @@ const CreateFormBody = (props: { onCreate: CreateHandler }) => { setItems(initItems); }, [dataSourceTypeConfig, tableStructure, form]); - const handleDragEnd = useCallback((e: { active: { id: string }; over: { id: string } | null }) => { + const handleDragEnd = useCallback((e: DragEndEvent) => { if (!e.over) { return; } diff --git a/client/packages/lowcoder/src/comps/comps/listViewComp/listView.tsx b/client/packages/lowcoder/src/comps/comps/listViewComp/listView.tsx index 44ff6753d8..849bb4322f 100644 --- a/client/packages/lowcoder/src/comps/comps/listViewComp/listView.tsx +++ b/client/packages/lowcoder/src/comps/comps/listViewComp/listView.tsx @@ -22,7 +22,7 @@ import { useMergeCompStyles } from "@lowcoder-ee/util/hooks"; import { childrenToProps } from "@lowcoder-ee/comps/generators/multi"; import { AnimationStyleType } from "@lowcoder-ee/comps/controls/styleControlConstants"; import { getBackgroundStyle } from "@lowcoder-ee/util/styleUtils"; -import { DndContext } from "@dnd-kit/core"; +import { DndContext, DragEndEvent } from "@dnd-kit/core"; import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { JSONObject } from "@lowcoder-ee/util/jsonTypes"; @@ -354,7 +354,7 @@ export function ListView(props: Props) { useMergeCompStyles(childrenProps, comp.dispatch); - const handleDragEnd = (e: { active: { id: string }; over: { id: string } | null }) => { + const handleDragEnd = (e: DragEndEvent) => { if (!e.over) { return; } From c85ecb55d5948f5ef32a1842720244f65e4093c3 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 1 Jan 2026 14:15:47 +0500 Subject: [PATCH 05/17] add max depth --- .../src/comps/comps/navComp/components/MenuItemList.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index 6bc10de976..eb583edd71 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -6,7 +6,7 @@ import React, { useMemo, useCallback, createContext, useContext, useState } from import styled from "styled-components"; import { NavCompType, NavListCompType, NavTreeItemData } from "./types"; import MenuItem from "./MenuItem"; - +const MAX_DEPTH = 3; const Wrapper = styled.div` .menu-title { display: flex; @@ -59,6 +59,8 @@ const NavTreeItemComponent = React.forwardRef< const handlers = useContext(MenuItemHandlersContext); const hasChildren = item.children && item.children.length > 0; + // allow adding sub-menu only if we are above the max depth (depth is 0-indexed) + const canAddSubMenu = depth < MAX_DEPTH - 1; const handleDelete = () => { handlers?.onDeleteItem(path); @@ -77,7 +79,7 @@ const NavTreeItemComponent = React.forwardRef< item={comp} onDelete={handleDelete} onAddSubMenu={handleAddSubMenu} - showAddSubMenu={!hasChildren || depth === 0} + showAddSubMenu={(!hasChildren || depth === 0) && canAddSubMenu} /> From e9f6152fee495005c73b0f172838c2623e926fdf Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 1 Jan 2026 16:06:20 +0500 Subject: [PATCH 06/17] add submenu --- .../src/comps/comps/navComp/navComp.tsx | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx index bdff4240b4..60722e814c 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx @@ -512,25 +512,27 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { const disabled = !!view?.disabled; const subItems = isCompItem ? view?.items : []; - const subMenuItems: Array<{ key: string; label: any; icon?: any; disabled?: boolean }> = []; const subMenuSelectedKeys: Array = []; - - if (Array.isArray(subItems)) { - subItems.forEach((subItem: any, originalIndex: number) => { - if (subItem.children.hidden.getView()) { - return; - } - const key = originalIndex + ""; - subItem.children.active.getView() && subMenuSelectedKeys.push(key); - const subIcon = hasIcon(subItem.children.icon?.getView?.()) ? subItem.children.icon.getView() : undefined; - subMenuItems.push({ - key: key, - label: subItem.children.label.getView(), - icon: subIcon, - disabled: !!subItem.children.disabled.getView(), - }); - }); - } + const buildSubMenuItems = (list: any[], prefix = ""): Array => { + if (!Array.isArray(list)) return []; + return list + .map((subItem: any, originalIndex: number) => { + if (subItem.children.hidden.getView()) return null; + const key = prefix ? `${prefix}-${originalIndex}` : `${originalIndex}`; + subItem.children.active.getView() && subMenuSelectedKeys.push(key); + const subIcon = hasIcon(subItem.children.icon?.getView?.()) ? subItem.children.icon.getView() : undefined; + const children = buildSubMenuItems(subItem.getView()?.items, key); + return { + key, + label: subItem.children.label.getView(), + icon: subIcon, + disabled: !!subItem.children.disabled.getView(), + ...(children.length > 0 ? { children } : {}), + }; + }) + .filter(Boolean); + }; + const subMenuItems: Array = buildSubMenuItems(subItems); const item = ( { { if (disabled) return; - const subItem = subItems[Number(e.key)]; - const isSubDisabled = !!subItem?.children?.disabled?.getView?.(); + const parts = String(e.key).split("-").filter(Boolean); + let currentList: any[] = subItems; + let current: any = null; + for (const part of parts) { + current = currentList?.[Number(part)]; + if (!current) return; + currentList = current.getView()?.items || []; + } + const isSubDisabled = !!current?.children?.disabled?.getView?.(); if (isSubDisabled) return; - const onSubEvent = subItem?.getView()?.onEvent; + const onSubEvent = current?.getView?.()?.onEvent; onSubEvent && onSubEvent("click"); }} selectedKeys={subMenuSelectedKeys} From a81d69e329cf4aec4409814249562dec219a8fa8 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 1 Jan 2026 16:42:40 +0500 Subject: [PATCH 07/17] add collapsible --- .../comps/navComp/components/MenuItemList.tsx | 92 +++++++++---------- 1 file changed, 43 insertions(+), 49 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index eb583edd71..8dad5efdd0 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -42,8 +42,6 @@ const TreeItemContent = styled.div` interface MenuItemHandlers { onDeleteItem: (path: number[]) => void; onAddSubItem: (path: number[], value?: any) => void; - collapsedItems: Set; - onToggleCollapse: (id: string) => void; } const MenuItemHandlersContext = createContext(null); @@ -53,9 +51,10 @@ const NavTreeItemComponent = React.forwardRef< HTMLDivElement, TreeItemComponentProps >((props, ref) => { - console.log("NavTreeItemComponent", props); - const { item, depth, ...rest } = props; + const { item, depth, collapsed, ...rest } = props; const { comp, path } = item; + + console.log("NavTreeItemComponent", "collapsed", collapsed); const handlers = useContext(MenuItemHandlersContext); const hasChildren = item.children && item.children.length > 0; @@ -73,7 +72,13 @@ const NavTreeItemComponent = React.forwardRef< }; return ( - + = new Set() + collapsedIds: Set = new Set() ): TreeItems { return items.map((item, index) => { const path = [...basePath, index]; @@ -112,62 +119,51 @@ function convertToTreeItems( return { id, + collapsed: collapsedIds.has(id), comp: item, path: path, - collapsed: collapsedItems.has(id), children: subItems.length > 0 - ? convertToTreeItems(subItems, path, collapsedItems) + ? convertToTreeItems(subItems, path, collapsedIds) : [], }; }); } +function extractCollapsedIds(treeItems: TreeItems): Set { + const ids = new Set(); + const walk = (items: TreeItems) => { + items.forEach((item) => { + if (item.collapsed) { + ids.add(String(item.id)); + } + if (item.children?.length) { + walk(item.children); + } + }); + }; + walk(treeItems); + return ids; +} + function MenuItemList(props: IMenuItemListProps) { const { items, onAddItem, onDeleteItem, onAddSubItem, onReorderItems } = props; - // State for tracking collapsed items - const [collapsedItems, setCollapsedItems] = useState>(new Set()); - - // Toggle collapse state for an item - const handleToggleCollapse = useCallback((id: string) => { - setCollapsedItems(prev => { - const newSet = new Set(prev); - if (newSet.has(id)) { - newSet.delete(id); - } else { - newSet.add(id); - } - return newSet; - }); - }, []); + const [collapsedIds, setCollapsedIds] = useState>(() => new Set()); // Convert items to tree format - const treeItems = useMemo(() => convertToTreeItems(items, [], collapsedItems), [items, collapsedItems]); + const treeItems = useMemo(() => convertToTreeItems(items, [], collapsedIds), [items, collapsedIds]); // Handle tree changes from drag and drop const handleItemsChanged = useCallback( - (newItems: TreeItems) => { - // Update collapsed state from the new items - const updateCollapsedState = (treeItems: TreeItems) => { - treeItems.forEach(item => { - if (item.collapsed !== undefined) { - setCollapsedItems(prev => { - const newSet = new Set(prev); - if (item.collapsed) { - newSet.add(item.id as string); - } else { - newSet.delete(item.id as string); - } - return newSet; - }); - } - if (item.children && item.children.length > 0) { - updateCollapsedState(item.children); - } - }); - }; - updateCollapsedState(newItems); - onReorderItems(newItems); + (newItems: TreeItems, reason: TreeChangeReason) => { + // Persist collapsed/expanded state locally (SortableTree is controlled by `items` prop) + setCollapsedIds(extractCollapsedIds(newItems)); + + // Only rewrite the underlying nav structure when the tree structure actually changed. + // (If we rebuild on collapsed/expanded, it immediately resets the UI and looks like "toggle does nothing".) + if (reason.type === "dropped" || reason.type === "removed") { + onReorderItems(newItems); + } }, [onReorderItems] ); @@ -177,10 +173,8 @@ function MenuItemList(props: IMenuItemListProps) { () => ({ onDeleteItem, onAddSubItem, - collapsedItems, - onToggleCollapse: handleToggleCollapse, }), - [onDeleteItem, onAddSubItem, collapsedItems, handleToggleCollapse] + [onDeleteItem, onAddSubItem] ); return ( From ff87f39b6b65e4a651230e1e39354fe1aa7279f1 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 1 Jan 2026 17:07:35 +0500 Subject: [PATCH 08/17] add keys --- .../comps/navComp/components/MenuItemList.tsx | 5 ++- .../src/comps/comps/navComp/navItemComp.tsx | 43 ++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index 8dad5efdd0..a0bba8c9bc 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -54,7 +54,6 @@ const NavTreeItemComponent = React.forwardRef< const { item, depth, collapsed, ...rest } = props; const { comp, path } = item; - console.log("NavTreeItemComponent", "collapsed", collapsed); const handlers = useContext(MenuItemHandlersContext); const hasChildren = item.children && item.children.length > 0; @@ -114,7 +113,9 @@ function convertToTreeItems( ): TreeItems { return items.map((item, index) => { const path = [...basePath, index]; - const id = path.join("_"); + // Use stable itemKey if available, fallback to path-based ID for backwards compatibility + const itemKey = item.getItemKey?.() || ""; + const id = itemKey || path.join("_"); const subItems = item.getView().items || []; return { diff --git a/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx b/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx index 6b64580941..599a8ebe7b 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx @@ -1,9 +1,11 @@ import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { valueComp } from "comps/generators"; import { list } from "comps/generators/list"; import { parseChildrenFromValueAndChildrenMap, ToViewReturn } from "comps/generators/multi"; -import { withDefault } from "comps/generators/simpleGenerators"; +import { migrateOldData, withDefault } from "comps/generators/simpleGenerators"; import { disabledPropertyView, hiddenPropertyView } from "comps/utils/propertyUtils"; +import { genRandomKey } from "comps/utils/idGenerator"; import { trans } from "i18n"; import _ from "lodash"; import { fromRecord, MultiBaseComp, Node, RecordNode, RecordNodeToValue } from "lowcoder-core"; @@ -18,6 +20,7 @@ const childrenMap = { hidden: BoolCodeControl, disabled: BoolCodeControl, active: BoolCodeControl, + itemKey: valueComp(""), onEvent: withDefault(eventHandlerControl(events), [ { // name: "click", @@ -35,6 +38,7 @@ type ChildrenType = { hidden: InstanceType; disabled: InstanceType; active: InstanceType; + itemKey: InstanceType>>; onEvent: InstanceType>; items: InstanceType>; }; @@ -72,6 +76,10 @@ export class NavItemComp extends MultiBaseComp { this.children.items.addItem(value); } + getItemKey(): string { + return this.children.itemKey.getView(); + } + exposingNode(): RecordNode { return fromRecord({ label: this.children.label.exposingNode(), @@ -93,17 +101,42 @@ type NavItemExposing = { items: Node[]>; }; +// Migrate old nav items to ensure they have a stable itemKey +function migrateNavItemData(oldData: any): any { + if (!oldData) return oldData; + + const migrated = { + ...oldData, + itemKey: oldData.itemKey || genRandomKey(), + }; + + // Also migrate nested items recursively + if (Array.isArray(oldData.items)) { + migrated.items = oldData.items.map((item: any) => migrateNavItemData(item)); + } + + return migrated; +} + +const NavItemCompMigrate = migrateOldData(NavItemComp, migrateNavItemData); + export function navListComp() { - const NavItemListCompBase = list(NavItemComp); + const NavItemListCompBase = list(NavItemCompMigrate); return class NavItemListComp extends NavItemListCompBase { addItem(value?: any) { const data = this.getView(); this.dispatch( this.pushAction( - value || { - label: trans("menuItem") + " " + (data.length + 1), - } + value + ? { + ...value, + itemKey: value.itemKey || genRandomKey(), + } + : { + label: trans("menuItem") + " " + (data.length + 1), + itemKey: genRandomKey(), + } ) ); } From 0caae9a1caa1e9eb0f06c71bb540e58beed89c17 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 1 Jan 2026 17:37:41 +0500 Subject: [PATCH 09/17] fix propagation --- .../lowcoder/src/comps/comps/navComp/components/MenuItem.tsx | 4 +++- .../src/comps/comps/navComp/components/MenuItemList.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx index 8eb7401996..4e687fc428 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx @@ -66,7 +66,9 @@ const MenuItem: React.FC = (props) => { visible={isConfigPopShow} setVisible={showConfigPop} > - {item.children.label.getView()} + e.stopPropagation()}> + {item.children.label.getView()} + - + e.stopPropagation()}> Date: Thu, 1 Jan 2026 18:00:22 +0500 Subject: [PATCH 10/17] add scrollbar wrapper --- .../comps/navComp/components/MenuItemList.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index 0ee4472935..4d98678b21 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -1,6 +1,6 @@ import { SortableTree, TreeItems, TreeItemComponentProps, SimpleTreeItemWrapper } from "dnd-kit-sortable-tree"; import LinkPlusButton from "components/LinkPlusButton"; -import { BluePlusIcon, controlItem } from "lowcoder-design"; +import { BluePlusIcon, controlItem, ScrollBar } from "lowcoder-design"; import { trans } from "i18n"; import React, { useMemo, useCallback, createContext, useContext, useState } from "react"; import styled from "styled-components"; @@ -187,15 +187,16 @@ function MenuItemList(props: IMenuItemListProps) {
- - - + + + + +
); From 284007417eef19f8d12237a18b4a51dcc088c848 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 1 Jan 2026 18:34:50 +0500 Subject: [PATCH 11/17] style wrapper --- .../comps/navComp/components/MenuItemList.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index 4d98678b21..fb64b9a88b 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -21,21 +21,21 @@ const Wrapper = styled.div` } `; +const StyledTreeItem = styled.div` + .dnd-sortable-tree_simple_tree-item { + padding: 5px; + border-radius: 4px; + &:hover { + background-color: #f5f5f6; + } + } +`; + const TreeItemContent = styled.div` display: flex; align-items: center; width: 100%; - height: 30px; - background-color: #ffffff; - border: 1px solid #d7d9e0; - border-radius: 4px; - padding: 0 8px; box-sizing: border-box; - gap: 8px; - - &:hover { - border-color: #315efb; - } `; // Context for passing handlers to tree items @@ -71,6 +71,7 @@ const NavTreeItemComponent = React.forwardRef< }; return ( +
+ ); }); @@ -187,7 +189,7 @@ function MenuItemList(props: IMenuItemListProps) {
- + Date: Thu, 1 Jan 2026 19:59:03 +0500 Subject: [PATCH 12/17] adjust scrollbar height --- .../src/comps/comps/navComp/components/MenuItemList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index fb64b9a88b..41a9ada837 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -189,7 +189,7 @@ function MenuItemList(props: IMenuItemListProps) {
- + Date: Thu, 1 Jan 2026 20:08:55 +0500 Subject: [PATCH 13/17] add dispatch --- .../comps/navComp/components/MenuItemList.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index 41a9ada837..a3b3e25628 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -244,18 +244,9 @@ export function menuPropertyView(itemsComp: NavListCompType) { const newJson = buildJsonFromTree(newItems); - // Clear all existing items and re-add in new order - const currentLength = itemsComp.getView().length; - - // Delete all items from end to start - for (let i = currentLength - 1; i >= 0; i--) { - itemsComp.deleteItem(i); - } - - // Add items back in new order - newJson.forEach((itemJson) => { - itemsComp.addItem(itemJson); - }); + // Use setChildrensAction for atomic update instead of delete-all/add-all + // This is more efficient and prevents UI glitches from multiple re-renders + itemsComp.dispatch(itemsComp.setChildrensAction(newJson)); }; return controlItem( From 63dc2438050a1b1757d425f0bb426dae691b99bb Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 1 Jan 2026 22:54:35 +0500 Subject: [PATCH 14/17] add collapsed property --- .../comps/comps/layout/layoutMenuItemComp.tsx | 16 +++++- .../comps/navComp/components/MenuItemList.tsx | 53 +++++-------------- .../src/comps/comps/navComp/navItemComp.tsx | 16 +++++- 3 files changed, 42 insertions(+), 43 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/layout/layoutMenuItemComp.tsx b/client/packages/lowcoder/src/comps/comps/layout/layoutMenuItemComp.tsx index 0999a40122..dc8bee225e 100644 --- a/client/packages/lowcoder/src/comps/comps/layout/layoutMenuItemComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/layout/layoutMenuItemComp.tsx @@ -1,6 +1,7 @@ import { MultiBaseComp } from "lowcoder-core"; import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; -import { valueComp } from "comps/generators"; +import { BoolControl } from "comps/controls/boolControl"; +import { valueComp, withPropertyViewFn } from "comps/generators"; import { list } from "comps/generators/list"; import { parseChildrenFromValueAndChildrenMap, @@ -16,16 +17,21 @@ import { genRandomKey } from "comps/utils/idGenerator"; import { LayoutActionComp } from "comps/comps/layout/layoutActionComp"; import { migrateOldData } from "comps/generators/simpleGenerators"; +// BoolControl without property view (internal state only) +const CollapsedControl = withPropertyViewFn(BoolControl, () => null); + const childrenMap = { label: StringControl, hidden: BoolCodeControl, action: LayoutActionComp, + collapsed: CollapsedControl, // tree editor collapsed state itemKey: valueComp(""), icon: IconControl, }; type ChildrenType = ToInstanceType & { items: InstanceType; + collapsed: InstanceType; }; /** @@ -73,6 +79,14 @@ export class LayoutMenuItemComp extends MultiBaseComp { getItemKey() { return this.children.itemKey.getView(); } + + getCollapsed(): boolean { + return this.children.collapsed.getView(); + } + + setCollapsed(collapsed: boolean) { + this.children.collapsed.dispatchChangeValueAction(collapsed); + } } const LayoutMenuItemCompMigrate = migrateOldData(LayoutMenuItemComp, (oldData: any) => { diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index a3b3e25628..8915b6c4ec 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -2,7 +2,7 @@ import { SortableTree, TreeItems, TreeItemComponentProps, SimpleTreeItemWrapper import LinkPlusButton from "components/LinkPlusButton"; import { BluePlusIcon, controlItem, ScrollBar } from "lowcoder-design"; import { trans } from "i18n"; -import React, { useMemo, useCallback, createContext, useContext, useState } from "react"; +import React, { useMemo, useCallback, createContext, useContext } from "react"; import styled from "styled-components"; import { NavCompType, NavListCompType, NavTreeItemData } from "./types"; import MenuItem from "./MenuItem"; @@ -105,13 +105,10 @@ interface IMenuItemListProps { const menuItemLabel = trans("navigation.itemsDesc"); -type TreeChangeReason = { type: string }; - // Convert NavCompType[] to TreeItems format for dnd-kit-sortable-tree function convertToTreeItems( items: NavCompType[], - basePath: number[] = [], - collapsedIds: Set = new Set() + basePath: number[] = [] ): TreeItems { return items.map((item, index) => { const path = [...basePath, index]; @@ -119,54 +116,31 @@ function convertToTreeItems( const itemKey = item.getItemKey?.() || ""; const id = itemKey || path.join("_"); const subItems = item.getView().items || []; + // Read collapsed state from the item itself + const collapsed = item.getCollapsed?.() ?? false; return { id, - collapsed: collapsedIds.has(id), + collapsed, comp: item, path: path, children: subItems.length > 0 - ? convertToTreeItems(subItems, path, collapsedIds) + ? convertToTreeItems(subItems, path) : [], }; }); } -function extractCollapsedIds(treeItems: TreeItems): Set { - const ids = new Set(); - const walk = (items: TreeItems) => { - items.forEach((item) => { - if (item.collapsed) { - ids.add(String(item.id)); - } - if (item.children?.length) { - walk(item.children); - } - }); - }; - walk(treeItems); - return ids; -} - function MenuItemList(props: IMenuItemListProps) { const { items, onAddItem, onDeleteItem, onAddSubItem, onReorderItems } = props; - const [collapsedIds, setCollapsedIds] = useState>(() => new Set()); - // Convert items to tree format - const treeItems = useMemo(() => convertToTreeItems(items, [], collapsedIds), [items, collapsedIds]); + const treeItems = useMemo(() => convertToTreeItems(items), [items]); - // Handle tree changes from drag and drop + // Handle all tree changes (drag/drop, collapse/expand) const handleItemsChanged = useCallback( - (newItems: TreeItems, reason: TreeChangeReason) => { - // Persist collapsed/expanded state locally (SortableTree is controlled by `items` prop) - setCollapsedIds(extractCollapsedIds(newItems)); - - // Only rewrite the underlying nav structure when the tree structure actually changed. - // (If we rebuild on collapsed/expanded, it immediately resets the UI and looks like "toggle does nothing".) - if (reason.type === "dropped" || reason.type === "removed") { - onReorderItems(newItems); - } + (newItems: TreeItems) => { + onReorderItems(newItems); }, [onReorderItems] ); @@ -227,14 +201,14 @@ export function menuPropertyView(itemsComp: NavListCompType) { return getItemListByPath(path.slice(1), root.getView()[path[0]].children.items); }; - // Convert flat tree structure back to nested comp structure + // Convert tree structure back to nested comp structure const handleReorderItems = (newItems: TreeItems) => { - // Build the new order from tree items const buildJsonFromTree = (treeItems: TreeItems): any[] => { return treeItems.map((item) => { const jsonValue = item.comp.toJsonValue() as Record; return { ...jsonValue, + collapsed: item.collapsed ?? false, // sync collapsed from tree item items: item.children && item.children.length > 0 ? buildJsonFromTree(item.children) : [], @@ -243,9 +217,6 @@ export function menuPropertyView(itemsComp: NavListCompType) { }; const newJson = buildJsonFromTree(newItems); - - // Use setChildrensAction for atomic update instead of delete-all/add-all - // This is more efficient and prevents UI glitches from multiple re-renders itemsComp.dispatch(itemsComp.setChildrensAction(newJson)); }; diff --git a/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx b/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx index 599a8ebe7b..344b398d0b 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx @@ -1,6 +1,7 @@ import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolControl } from "comps/controls/boolControl"; import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; -import { valueComp } from "comps/generators"; +import { valueComp, withPropertyViewFn } from "comps/generators"; import { list } from "comps/generators/list"; import { parseChildrenFromValueAndChildrenMap, ToViewReturn } from "comps/generators/multi"; import { migrateOldData, withDefault } from "comps/generators/simpleGenerators"; @@ -14,12 +15,16 @@ import { IconControl } from "comps/controls/iconControl"; const events = [clickEvent]; +// BoolControl without property view (internal state only) +const CollapsedControl = withPropertyViewFn(BoolControl, () => null); + const childrenMap = { label: StringControl, icon: IconControl, hidden: BoolCodeControl, disabled: BoolCodeControl, active: BoolCodeControl, + collapsed: CollapsedControl, // tree editor collapsed state itemKey: valueComp(""), onEvent: withDefault(eventHandlerControl(events), [ { @@ -38,6 +43,7 @@ type ChildrenType = { hidden: InstanceType; disabled: InstanceType; active: InstanceType; + collapsed: InstanceType; itemKey: InstanceType>>; onEvent: InstanceType>; items: InstanceType>; @@ -80,6 +86,14 @@ export class NavItemComp extends MultiBaseComp { return this.children.itemKey.getView(); } + getCollapsed(): boolean { + return this.children.collapsed.getView(); + } + + setCollapsed(collapsed: boolean) { + this.children.collapsed.dispatchChangeValueAction(collapsed); + } + exposingNode(): RecordNode { return fromRecord({ label: this.children.label.exposingNode(), From 1b07123b27892df91bb6c59b9c8f39fd11763c44 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 2 Jan 2026 18:46:30 +0500 Subject: [PATCH 15/17] [Feat]: #1109 add menu tree + animation + fallback fixes --- .../src/comps/comps/layout/navLayout.tsx | 6 ++ .../comps/navComp/components/MenuItem.tsx | 8 +- .../comps/navComp/components/MenuItemList.tsx | 25 +++---- .../src/comps/comps/navComp/navComp.tsx | 75 +++++++++---------- .../src/comps/comps/navComp/navItemComp.tsx | 36 +++------ .../comps/controls/styleControlConstants.tsx | 42 ----------- .../packages/lowcoder/src/i18n/locales/en.ts | 2 + 7 files changed, 71 insertions(+), 123 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx b/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx index b94fe89658..494fc53ad9 100644 --- a/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx +++ b/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx @@ -204,6 +204,12 @@ let NavTmpLayout = (function () { { label: trans("menuItem") + " 1", itemKey: genRandomKey(), + items: [ + { + label: trans("subMenuItem") + " 1", + itemKey: genRandomKey(), + }, + ], }, ]), jsonItems: jsonControl(convertTreeData, jsonMenuItems), diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx index 4e687fc428..94e562d3eb 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx @@ -66,8 +66,12 @@ const MenuItem: React.FC = (props) => { visible={isConfigPopShow} setVisible={showConfigPop} > - e.stopPropagation()}> - {item.children.label.getView()} + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + {item.children.label.getView() || trans("untitled")} 0; // allow adding sub-menu only if we are above the max depth (depth is 0-indexed) - const canAddSubMenu = depth < MAX_DEPTH - 1; + const canAddSubMenu = depth < MAX_DEPTH - 1 ; const handleDelete = () => { handlers?.onDeleteItem(path); @@ -78,13 +77,19 @@ const NavTreeItemComponent = React.forwardRef< item={item} depth={depth} collapsed={collapsed} + + > - e.stopPropagation()}> + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > @@ -99,7 +104,6 @@ interface IMenuItemListProps { onAddItem: (path: number[], value?: any) => number; onDeleteItem: (path: number[]) => void; onAddSubItem: (path: number[], value: any, unshift?: boolean) => number; - onMoveItem: (path: number[], from: number, to: number) => void; onReorderItems: (newOrder: TreeItems) => void; } @@ -112,15 +116,12 @@ function convertToTreeItems( ): TreeItems { return items.map((item, index) => { const path = [...basePath, index]; - // Use stable itemKey if available, fallback to path-based ID for backwards compatibility - const itemKey = item.getItemKey?.() || ""; - const id = itemKey || path.join("_"); const subItems = item.getView().items || []; // Read collapsed state from the item itself const collapsed = item.getCollapsed?.() ?? false; return { - id, + id: path.join("_"), collapsed, comp: item, path: path, @@ -170,6 +171,7 @@ function MenuItemList(props: IMenuItemListProps) { onItemsChanged={handleItemsChanged} TreeItemComponent={NavTreeItemComponent} indentationWidth={20} + sortableProps={{ animateLayoutChanges: () => false }} /> @@ -237,9 +239,6 @@ export function menuPropertyView(itemsComp: NavListCompType) { item.addSubItem(value); return item.children.items.getView().length; }} - onMoveItem={(path: number[], from: number, to: number) => { - getItemListByPath(path).moveItem(from, to); - }} onReorderItems={handleReorderItems} /> ); diff --git a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx index 60722e814c..b0bef2af5c 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx @@ -29,9 +29,6 @@ import { NavLayoutItemStyle, NavLayoutItemHoverStyle, NavLayoutItemActiveStyle, - NavSubMenuItemStyle, - NavSubMenuItemHoverStyle, - NavSubMenuItemActiveStyle, } from "comps/controls/styleControlConstants"; import { hiddenPropertyView, showDataLoadingIndicatorsPropertyView } from "comps/utils/propertyUtils"; import { trans } from "i18n"; @@ -390,9 +387,7 @@ function renderAdvancedSection(children: any) { function renderStyleSections( children: any, styleSegment: MenuItemStyleOptionValue, - setStyleSegment: (k: MenuItemStyleOptionValue) => void, - subStyleSegment: MenuItemStyleOptionValue, - setSubStyleSegment: (k: MenuItemStyleOptionValue) => void + setStyleSegment: (k: MenuItemStyleOptionValue) => void ) { const isHamburger = children.displayMode.getView() === 'hamburger'; return ( @@ -415,19 +410,6 @@ function renderStyleSections( {styleSegment === "hover" && children.navItemHoverStyle.getPropertyView()} {styleSegment === "active" && children.navItemActiveStyle.getPropertyView()} -
- {controlItem({}, ( - setSubStyleSegment(k as MenuItemStyleOptionValue)} - /> - ))} - {subStyleSegment === "normal" && children.subNavItemStyle.getPropertyView()} - {subStyleSegment === "hover" && children.subNavItemHoverStyle.getPropertyView()} - {subStyleSegment === "active" && children.subNavItemActiveStyle.getPropertyView()} -
{isHamburger && ( <>
@@ -475,14 +457,30 @@ const childrenMap = { hamburgerButtonStyle: styleControl(HamburgerButtonStyle, 'hamburgerButtonStyle'), drawerContainerStyle: styleControl(DrawerContainerStyle, 'drawerContainerStyle'), animationStyle: styleControl(AnimationStyle, 'animationStyle'), - subNavItemStyle: styleControl(NavSubMenuItemStyle, 'subNavItemStyle'), - subNavItemHoverStyle: styleControl(NavSubMenuItemHoverStyle, 'subNavItemHoverStyle'), - subNavItemActiveStyle: styleControl(NavSubMenuItemActiveStyle, 'subNavItemActiveStyle'), items: withDefault(migrateOldData(createNavItemsControl(), fixOldItemsData), { optionType: "manual", manual: [ { label: trans("menuItem") + " 1", + items: [ + { + label: trans("subMenuItem") + " 1", + items: [ + { + label: trans("subMenuItem") + " 1-1", + }, + { + label: trans("subMenuItem") + " 1-2", + }, + ], + }, + { + label: trans("subMenuItem") + " 2", + }, + { + label: trans("subMenuItem") + " 3", + }, + ], }, ], }), @@ -505,7 +503,7 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { return null; } - const label = view?.label; + const label = view?.label || trans("untitled"); const icon = hasIcon(view?.icon) ? view.icon : undefined; const active = !!view?.active; const onEvent = view?.onEvent; @@ -524,7 +522,7 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { const children = buildSubMenuItems(subItem.getView()?.items, key); return { key, - label: subItem.children.label.getView(), + label: subItem.children.label.getView() || trans("untitled"), icon: subIcon, disabled: !!subItem.children.disabled.getView(), ...(children.length > 0 ? { children } : {}), @@ -566,7 +564,7 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { ); if (subMenuItems.length > 0) { const subMenu = ( - + { if (disabled) return; @@ -588,22 +586,22 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { ...item, icon: item.icon || undefined, }))} - $color={(props.subNavItemStyle && props.subNavItemStyle.text) || props.style.text} - $hoverColor={(props.subNavItemHoverStyle && props.subNavItemHoverStyle.text) || props.style.accent} - $activeColor={(props.subNavItemActiveStyle && props.subNavItemActiveStyle.text) || props.style.accent} - $bg={(props.subNavItemStyle && props.subNavItemStyle.background) || undefined} - $hoverBg={(props.subNavItemHoverStyle && props.subNavItemHoverStyle.background) || undefined} - $activeBg={(props.subNavItemActiveStyle && props.subNavItemActiveStyle.background) || undefined} - $border={(props.subNavItemStyle && props.subNavItemStyle.border) || undefined} - $hoverBorder={(props.subNavItemHoverStyle && props.subNavItemHoverStyle.border) || undefined} - $activeBorder={(props.subNavItemActiveStyle && props.subNavItemActiveStyle.border) || undefined} - $radius={(props.subNavItemStyle && props.subNavItemStyle.radius) || undefined} + $color={props.style.text} + $hoverColor={props.style.accent} + $activeColor={props.style.accent} + $bg={undefined} + $hoverBg={undefined} + $activeBg={undefined} + $border={undefined} + $hoverBorder={undefined} + $activeBorder={undefined} + $radius={undefined} $fontFamily={props.style.fontFamily} $fontStyle={props.style.fontStyle} $textWeight={props.style.textWeight} $textSize={props.style.textSize} - $padding={(props.subNavItemStyle && props.subNavItemStyle.padding) || props.style.padding} - $margin={(props.subNavItemStyle && props.subNavItemStyle.margin) || props.style.margin} + $padding={props.style.padding} + $margin={props.style.margin} $textTransform={props.style.textTransform} $textDecoration={props.style.textDecoration} /> @@ -708,7 +706,6 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { const showLogic = mode === "logic" || mode === "both"; const showLayout = mode === "layout" || mode === "both"; const [styleSegment, setStyleSegment] = useState("normal"); - const [subStyleSegment, setSubStyleSegment] = useState("normal"); return ( <> @@ -716,7 +713,7 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { {showLogic && renderInteractionSection(children)} {showLayout && renderLayoutSection(children)} {showLogic && renderAdvancedSection(children)} - {showLayout && renderStyleSections(children, styleSegment, setStyleSegment, subStyleSegment, setSubStyleSegment)} + {showLayout && renderStyleSections(children, styleSegment, setStyleSegment)} ); }) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx b/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx index 344b398d0b..1bd02bed96 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx @@ -1,12 +1,11 @@ import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; import { BoolControl } from "comps/controls/boolControl"; import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; -import { valueComp, withPropertyViewFn } from "comps/generators"; +import { withPropertyViewFn } from "comps/generators"; import { list } from "comps/generators/list"; import { parseChildrenFromValueAndChildrenMap, ToViewReturn } from "comps/generators/multi"; import { migrateOldData, withDefault } from "comps/generators/simpleGenerators"; import { disabledPropertyView, hiddenPropertyView } from "comps/utils/propertyUtils"; -import { genRandomKey } from "comps/utils/idGenerator"; import { trans } from "i18n"; import _ from "lodash"; import { fromRecord, MultiBaseComp, Node, RecordNode, RecordNodeToValue } from "lowcoder-core"; @@ -25,7 +24,6 @@ const childrenMap = { disabled: BoolCodeControl, active: BoolCodeControl, collapsed: CollapsedControl, // tree editor collapsed state - itemKey: valueComp(""), onEvent: withDefault(eventHandlerControl(events), [ { // name: "click", @@ -44,7 +42,6 @@ type ChildrenType = { disabled: InstanceType; active: InstanceType; collapsed: InstanceType; - itemKey: InstanceType>>; onEvent: InstanceType>; items: InstanceType>; }; @@ -82,10 +79,6 @@ export class NavItemComp extends MultiBaseComp { this.children.items.addItem(value); } - getItemKey(): string { - return this.children.itemKey.getView(); - } - getCollapsed(): boolean { return this.children.collapsed.getView(); } @@ -115,42 +108,31 @@ type NavItemExposing = { items: Node[]>; }; -// Migrate old nav items to ensure they have a stable itemKey +// Migrate old nav items to strip out deprecated itemKey field function migrateNavItemData(oldData: any): any { if (!oldData) return oldData; - const migrated = { - ...oldData, - itemKey: oldData.itemKey || genRandomKey(), - }; + const { itemKey, ...rest } = oldData; // Also migrate nested items recursively - if (Array.isArray(oldData.items)) { - migrated.items = oldData.items.map((item: any) => migrateNavItemData(item)); + if (Array.isArray(rest.items)) { + rest.items = rest.items.map((item: any) => migrateNavItemData(item)); } - return migrated; + return rest; } -const NavItemCompMigrate = migrateOldData(NavItemComp, migrateNavItemData); +const NavItemCompMigrated = migrateOldData(NavItemComp, migrateNavItemData); export function navListComp() { - const NavItemListCompBase = list(NavItemCompMigrate); + const NavItemListCompBase = list(NavItemCompMigrated); return class NavItemListComp extends NavItemListCompBase { addItem(value?: any) { const data = this.getView(); this.dispatch( this.pushAction( - value - ? { - ...value, - itemKey: value.itemKey || genRandomKey(), - } - : { - label: trans("menuItem") + " " + (data.length + 1), - itemKey: genRandomKey(), - } + value || { label: trans("menuItem") + " " + (data.length + 1) } ) ); } diff --git a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx index f2516f881d..9a578fcfdb 100644 --- a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx +++ b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx @@ -2418,45 +2418,6 @@ export const NavLayoutItemActiveStyle = [ }, ] as const; -// Submenu item styles (normal/hover/active), similar to top-level menu items -export const NavSubMenuItemStyle = [ - getBackground("primarySurface"), - getStaticBorder("transparent"), - RADIUS, - { - name: "text", - label: trans("text"), - depName: "background", - depType: DEP_TYPE.CONTRAST_TEXT, - transformer: contrastText, - }, - MARGIN, - PADDING, -] as const; - -export const NavSubMenuItemHoverStyle = [ - getBackground("canvas"), - getStaticBorder("transparent"), - { - name: "text", - label: trans("text"), - depName: "background", - depType: DEP_TYPE.CONTRAST_TEXT, - transformer: contrastText, - }, -] as const; - -export const NavSubMenuItemActiveStyle = [ - getBackground("primary"), - getStaticBorder("transparent"), - { - name: "text", - label: trans("text"), - depName: "background", - depType: DEP_TYPE.CONTRAST_TEXT, - transformer: contrastText, - }, -] as const; export const CarouselStyle = [getBackground("canvas")] as const; @@ -2597,9 +2558,6 @@ export type NavLayoutItemHoverStyleType = StyleConfigType< export type NavLayoutItemActiveStyleType = StyleConfigType< typeof NavLayoutItemActiveStyle >; -export type NavSubMenuItemStyleType = StyleConfigType; -export type NavSubMenuItemHoverStyleType = StyleConfigType; -export type NavSubMenuItemActiveStyleType = StyleConfigType; export function widthCalculator(margin: string) { const marginArr = margin?.trim().replace(/\s+/g, " ").split(" ") || ""; diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 47e93620ff..bbf02e29c4 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -33,6 +33,8 @@ export const en = { "form": "Form", "menu": "Menu", "menuItem": "Menu Item", + "subMenuItem": "Sub Menu", + "untitled": "Untitled", "ok": "OK", "cancel": "Cancel", "finish": "Finish", From 9af73e5c64171d9aef4b8c7506227b895560d398 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 2 Jan 2026 19:02:35 +0500 Subject: [PATCH 16/17] remove depth limit --- .../lowcoder/src/comps/comps/navComp/components/MenuItem.tsx | 4 +--- .../src/comps/comps/navComp/components/MenuItemList.tsx | 5 ----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx index 94e562d3eb..37cdfc1681 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx @@ -10,7 +10,6 @@ export interface IMenuItemProps { item: NavCompType; onDelete?: () => void; onAddSubMenu?: () => void; - showAddSubMenu?: boolean; } const MenuItemWrapper = styled.div` @@ -43,7 +42,6 @@ const MenuItem: React.FC = (props) => { item, onDelete, onAddSubMenu, - showAddSubMenu = true, } = props; const [isConfigPopShow, showConfigPop] = useState(false); @@ -76,7 +74,7 @@ const MenuItem: React.FC = (props) => { diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index f0a28ac900..f5025ed6b2 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -6,7 +6,6 @@ import React, { useMemo, useCallback, createContext, useContext } from "react"; import styled from "styled-components"; import { NavCompType, NavListCompType, NavTreeItemData } from "./types"; import MenuItem from "./MenuItem"; -const MAX_DEPTH = 10; const Wrapper = styled.div` .menu-title { display: flex; @@ -56,9 +55,6 @@ const NavTreeItemComponent = React.forwardRef< const handlers = useContext(MenuItemHandlersContext); - // allow adding sub-menu only if we are above the max depth (depth is 0-indexed) - const canAddSubMenu = depth < MAX_DEPTH - 1 ; - const handleDelete = () => { handlers?.onDeleteItem(path); }; @@ -89,7 +85,6 @@ const NavTreeItemComponent = React.forwardRef< item={comp} onDelete={handleDelete} onAddSubMenu={handleAddSubMenu} - showAddSubMenu={canAddSubMenu} /> From d1664751dced8a50e78aa56ddac943b76e0827dc Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 2 Jan 2026 20:02:31 +0500 Subject: [PATCH 17/17] fix border --- .../src/comps/comps/navComp/navComp.tsx | 23 +++++++++++++------ .../comps/controls/styleControlConstants.tsx | 1 + 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx index 9547f4db30..846cc8c1e1 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx @@ -113,6 +113,7 @@ const Item = styled.div<{ $border?: string; $hoverBorder?: string; $activeBorder?: string; + $borderWidth?: string; $radius?: string; $disabled?: boolean; }>` @@ -120,9 +121,13 @@ const Item = styled.div<{ padding: ${(props) => props.$padding || '0 16px'}; color: ${(props) => props.$disabled ? `${props.$color}80` : (props.$active ? props.$activeColor : props.$color)}; background-color: ${(props) => (props.$active ? (props.$activeBg || 'transparent') : (props.$bg || 'transparent'))}; - border: ${(props) => props.$active - ? (props.$activeBorder ? `1px solid ${props.$activeBorder}` : (props.$border ? `1px solid ${props.$border}` : '1px solid transparent')) - : (props.$border ? `1px solid ${props.$border}` : '1px solid transparent')}; + border: ${(props) => { + const width = props.$borderWidth || '1px'; + if (props.$active) { + return props.$activeBorder ? `${width} solid ${props.$activeBorder}` : (props.$border ? `${width} solid ${props.$border}` : `${width} solid transparent`); + } + return props.$border ? `${width} solid ${props.$border}` : `${width} solid transparent`; + }}; border-radius: ${(props) => props.$radius || '0px'}; font-weight: ${(props) => props.$active ? (props.$activeTextWeight || props.$textWeight || 500) @@ -144,7 +149,13 @@ const Item = styled.div<{ &:hover { color: ${(props) => props.$disabled ? (props.$active ? props.$activeColor : props.$color) : (props.$hoverColor || props.$activeColor)}; background-color: ${(props) => props.$disabled ? (props.$active ? (props.$activeBg || 'transparent') : (props.$bg || 'transparent')) : (props.$hoverBg || props.$activeBg || props.$bg || 'transparent')}; - border: ${(props) => props.$hoverBorder ? `1px solid ${props.$hoverBorder}` : (props.$activeBorder ? `1px solid ${props.$activeBorder}` : (props.$border ? `1px solid ${props.$border}` : '1px solid transparent'))}; + border: ${(props) => { + const width = props.$borderWidth || '1px'; + if (props.$hoverBorder) return `${width} solid ${props.$hoverBorder}`; + if (props.$activeBorder) return `${width} solid ${props.$activeBorder}`; + if (props.$border) return `${width} solid ${props.$border}`; + return `${width} solid transparent`; + }}; cursor: ${(props) => props.$disabled ? 'not-allowed' : 'pointer'}; font-weight: ${(props) => props.$disabled ? undefined : (props.$hoverTextWeight || props.$textWeight || 500)}; font-family: ${(props) => props.$disabled ? undefined : (props.$hoverFontFamily || props.$fontFamily || 'sans-serif')}; @@ -212,7 +223,6 @@ const StyledMenu = styled(Menu) < color: ${(p) => p.$color}; background-color: ${(p) => p.$bg || "transparent"}; border-radius: ${(p) => p.$radius || "0px"}; - border: ${(p) => p.$border ? `1px solid ${p.$border}` : "1px solid transparent"}; font-weight: ${(p) => p.$textWeight || 500}; font-family: ${(p) => p.$fontFamily || "sans-serif"}; font-style: ${(p) => p.$fontStyle || "normal"}; @@ -226,7 +236,6 @@ const StyledMenu = styled(Menu) < .ant-dropdown-menu-item:hover { color: ${(p) => p.$hoverColor || p.$color}; background-color: ${(p) => p.$hoverBg || p.$bg || "transparent"} !important; - border: ${(p) => p.$hoverBorder ? `1px solid ${p.$hoverBorder}` : (p.$border ? `1px solid ${p.$border}` : "1px solid transparent")}; font-weight: ${(p) => p.$hoverTextWeight || p.$textWeight || 500}; font-family: ${(p) => p.$hoverFontFamily || p.$fontFamily || "sans-serif"}; font-style: ${(p) => p.$hoverFontStyle || p.$fontStyle || "normal"}; @@ -239,7 +248,6 @@ const StyledMenu = styled(Menu) < .ant-menu-item-selected { color: ${(p) => p.$activeColor}; background-color: ${(p) => p.$activeBg || p.$bg || "transparent"}; - border: ${(p) => p.$activeBorder ? `1px solid ${p.$activeBorder}` : (p.$border ? `1px solid ${p.$border}` : "1px solid transparent")}; font-weight: ${(p) => p.$activeTextWeight || p.$textWeight || 500}; font-family: ${(p) => p.$activeFontFamily || p.$fontFamily || "sans-serif"}; font-style: ${(p) => p.$activeFontStyle || p.$fontStyle || "normal"}; @@ -608,6 +616,7 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { $hoverBorder={props.navItemHoverStyle?.border} $activeBorder={props.navItemActiveStyle?.border} $radius={props.navItemStyle?.radius} + $borderWidth={props.navItemStyle?.borderWidth} $disabled={disabled} onClick={() => { if (!disabled && onEvent) onEvent("click"); }} > diff --git a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx index fee2060fce..01587643db 100644 --- a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx +++ b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx @@ -2362,6 +2362,7 @@ export const NavLayoutItemStyle = [ getBackground("primarySurface"), getStaticBorder("transparent"), RADIUS, + BORDER_WIDTH, { name: "text", label: trans("text"),