@nocobase/flow-engine 2.1.0-beta.22 → 2.1.0-beta.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/lib/components/FieldModelRenderer.js +2 -2
  2. package/lib/components/FlowModelRenderer.d.ts +2 -0
  3. package/lib/components/FlowModelRenderer.js +2 -0
  4. package/lib/components/dnd/index.d.ts +19 -1
  5. package/lib/components/dnd/index.js +239 -21
  6. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +20 -1
  7. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +4 -0
  8. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +21 -8
  9. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +2 -0
  10. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +100 -32
  11. package/lib/components/subModel/index.d.ts +1 -0
  12. package/lib/components/subModel/index.js +19 -0
  13. package/lib/components/subModel/utils.d.ts +1 -1
  14. package/lib/data-source/index.d.ts +73 -0
  15. package/lib/data-source/index.js +205 -1
  16. package/lib/flowContext.d.ts +2 -0
  17. package/lib/flowI18n.js +2 -1
  18. package/lib/models/DisplayItemModel.d.ts +1 -1
  19. package/lib/models/EditableItemModel.d.ts +1 -1
  20. package/lib/models/FilterableItemModel.d.ts +1 -1
  21. package/lib/models/flowModel.d.ts +11 -9
  22. package/lib/models/flowModel.js +48 -9
  23. package/lib/provider.js +38 -23
  24. package/package.json +4 -4
  25. package/src/__tests__/provider.test.tsx +24 -2
  26. package/src/components/FieldModelRenderer.tsx +2 -1
  27. package/src/components/FlowModelRenderer.tsx +6 -0
  28. package/src/components/__tests__/dnd.test.ts +44 -0
  29. package/src/components/dnd/index.tsx +286 -26
  30. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +25 -1
  31. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +24 -5
  32. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +94 -3
  33. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +171 -2
  34. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +2 -0
  35. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +112 -32
  36. package/src/components/subModel/index.ts +1 -0
  37. package/src/data-source/__tests__/index.test.ts +34 -1
  38. package/src/data-source/index.ts +252 -2
  39. package/src/flowContext.ts +2 -0
  40. package/src/flowI18n.ts +2 -1
  41. package/src/models/DisplayItemModel.tsx +1 -1
  42. package/src/models/EditableItemModel.tsx +1 -1
  43. package/src/models/FilterableItemModel.tsx +1 -1
  44. package/src/models/flowModel.tsx +85 -23
  45. package/src/provider.tsx +41 -25
@@ -0,0 +1,44 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import { resolveOverlayAnchorTransform } from '../dnd';
12
+
13
+ describe('resolveOverlayAnchorTransform', () => {
14
+ it('should keep the original transform when anchor point is missing', () => {
15
+ const transform = { x: 24, y: 36, scaleX: 1, scaleY: 1 };
16
+
17
+ expect(
18
+ resolveOverlayAnchorTransform({
19
+ activeId: 'menu-item-1',
20
+ active: { id: 'menu-item-1' },
21
+ transform,
22
+ activeNodeRect: { top: 80, left: 120 },
23
+ dragAnchorPoint: null,
24
+ }),
25
+ ).toEqual(transform);
26
+ });
27
+
28
+ it('should align the overlay origin to the pointer position when dragging from toolbar handle', () => {
29
+ expect(
30
+ resolveOverlayAnchorTransform({
31
+ activeId: 'menu-item-1',
32
+ active: { id: 'menu-item-1' },
33
+ transform: { x: 20, y: 30, scaleX: 1, scaleY: 1 },
34
+ activeNodeRect: { top: 200, left: 100 },
35
+ dragAnchorPoint: { x: 180, y: 260 },
36
+ }),
37
+ ).toEqual({
38
+ x: 100,
39
+ y: 90,
40
+ scaleX: 1,
41
+ scaleY: 1,
42
+ });
43
+ });
44
+ });
@@ -8,8 +8,10 @@
8
8
  */
9
9
 
10
10
  import { DragOutlined } from '@ant-design/icons';
11
+ import type { Modifier } from '@dnd-kit/core';
11
12
  import { DndContext, DndContextProps, DragOverlay, useDraggable, useDroppable } from '@dnd-kit/core';
12
- import React, { FC, useState } from 'react';
13
+ import type { Transform } from '@dnd-kit/utilities';
14
+ import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
13
15
  import { createPortal } from 'react-dom';
14
16
  import { FlowModel } from '../../models';
15
17
  import { useFlowEngine } from '../../provider';
@@ -19,18 +21,229 @@ export * from './findModelUidPosition';
19
21
  export * from './gridDragPlanner';
20
22
 
21
23
  export const EMPTY_COLUMN_UID = 'EMPTY_COLUMN';
24
+ export const TOOLBAR_DRAG_ACTIVITY_EVENT = 'nb-toolbar-drag-activity';
25
+ const TOOLBAR_DRAG_ANCHOR_EVENT = 'nb-toolbar-drag-anchor';
26
+ const MENU_SUBMENU_POPUP_SELECTOR = '.ant-menu-submenu-popup';
27
+
28
+ type ToolbarDragAnchorPoint = {
29
+ x: number;
30
+ y: number;
31
+ };
32
+
33
+ type ToolbarDragAnchorDetail = {
34
+ modelUid: string;
35
+ point: ToolbarDragAnchorPoint | null;
36
+ };
37
+
38
+ export const resolveOverlayAnchorTransform = ({
39
+ activeId,
40
+ active,
41
+ transform,
42
+ activeNodeRect,
43
+ dragAnchorPoint,
44
+ }: {
45
+ activeId: string | null;
46
+ active: { id: string | number } | null | undefined;
47
+ transform: Transform;
48
+ activeNodeRect: { top: number; left: number } | null;
49
+ dragAnchorPoint: ToolbarDragAnchorPoint | null;
50
+ }): Transform => {
51
+ if (!activeId || active?.id !== activeId || !dragAnchorPoint || !activeNodeRect) {
52
+ return transform;
53
+ }
54
+
55
+ return {
56
+ ...transform,
57
+ x: transform.x + dragAnchorPoint.x - activeNodeRect.left,
58
+ y: transform.y + dragAnchorPoint.y - activeNodeRect.top,
59
+ };
60
+ };
61
+
62
+ const resolveDraggableHostNode = (activatorNode: HTMLElement | null) => {
63
+ const ownerDocument = activatorNode?.ownerDocument;
64
+ const floatToolbarContainer = activatorNode?.closest<HTMLElement>('.nb-toolbar-container[data-model-uid]');
65
+ const toolbarModelUid = floatToolbarContainer?.getAttribute('data-model-uid');
66
+
67
+ if (!ownerDocument || !toolbarModelUid) {
68
+ return activatorNode;
69
+ }
70
+
71
+ const matchedHosts = Array.from(
72
+ ownerDocument.querySelectorAll<HTMLElement>(
73
+ `[data-has-float-menu="true"][data-float-menu-model-uid="${toolbarModelUid}"]`,
74
+ ),
75
+ );
76
+ const popupRoot = floatToolbarContainer.closest<HTMLElement>(MENU_SUBMENU_POPUP_SELECTOR);
77
+
78
+ if (popupRoot) {
79
+ return (
80
+ matchedHosts.find((hostNode) => hostNode.closest(MENU_SUBMENU_POPUP_SELECTOR) === popupRoot) || activatorNode
81
+ );
82
+ }
83
+
84
+ return (
85
+ matchedHosts.find((hostNode) => !hostNode.closest(MENU_SUBMENU_POPUP_SELECTOR)) || matchedHosts[0] || activatorNode
86
+ );
87
+ };
22
88
 
23
89
  // 可拖拽图标组件
24
- export const DragHandler: FC<{ model: FlowModel; children: React.ReactNode }> = ({
90
+ export const DragHandler: FC<{ model: FlowModel; children?: React.ReactNode }> = ({
25
91
  model,
26
92
  children = <DragOutlined />,
27
93
  }) => {
28
- const { attributes, listeners, setNodeRef } = useDraggable({ id: model.uid });
94
+ const { attributes, isDragging, listeners, setActivatorNodeRef, setNodeRef } = useDraggable({ id: model.uid });
95
+ const dragHandlerRef = useRef<HTMLSpanElement | null>(null);
96
+ const draggableNodeRef = useRef<HTMLElement | null>(null);
97
+ const pointerPressCleanupRef = useRef<(() => void) | null>(null);
98
+ const isDraggingRef = useRef(isDragging);
99
+ const isPointerPressActiveRef = useRef(false);
100
+ const isToolbarDragActiveRef = useRef(false);
101
+ const syncDraggableNodeRef = useCallback(
102
+ (activatorNode: HTMLElement | null) => {
103
+ const nextNode = resolveDraggableHostNode(activatorNode);
104
+
105
+ if (draggableNodeRef.current === nextNode) {
106
+ return;
107
+ }
108
+
109
+ draggableNodeRef.current = nextNode;
110
+ setNodeRef(nextNode);
111
+ },
112
+ [setNodeRef],
113
+ );
114
+ const setDragHandlerNodeRef = useCallback(
115
+ (node: HTMLSpanElement | null) => {
116
+ dragHandlerRef.current = node;
117
+ setActivatorNodeRef(node);
118
+ syncDraggableNodeRef(node);
119
+ },
120
+ [setActivatorNodeRef, syncDraggableNodeRef],
121
+ );
122
+ const dispatchToolbarDragActivity = useCallback(
123
+ (active: boolean) => {
124
+ const ownerDocument = dragHandlerRef.current?.ownerDocument;
125
+ if (!ownerDocument) {
126
+ return;
127
+ }
128
+
129
+ ownerDocument.dispatchEvent(
130
+ new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
131
+ detail: { active, modelUid: model.uid },
132
+ }),
133
+ );
134
+ },
135
+ [model.uid],
136
+ );
137
+
138
+ const dispatchToolbarDragAnchor = useCallback(
139
+ (detail: Pick<ToolbarDragAnchorDetail, 'point'>) => {
140
+ const ownerDocument = dragHandlerRef.current?.ownerDocument;
141
+ if (!ownerDocument) {
142
+ return;
143
+ }
144
+
145
+ ownerDocument.dispatchEvent(
146
+ new CustomEvent<ToolbarDragAnchorDetail>(TOOLBAR_DRAG_ANCHOR_EVENT, {
147
+ detail: { modelUid: model.uid, point: detail.point },
148
+ }),
149
+ );
150
+ },
151
+ [model.uid],
152
+ );
153
+
154
+ const clearPointerPressListeners = useCallback(() => {
155
+ pointerPressCleanupRef.current?.();
156
+ pointerPressCleanupRef.current = null;
157
+ }, []);
158
+
159
+ const syncToolbarDragActivity = useCallback(() => {
160
+ const nextActive = isPointerPressActiveRef.current || isDraggingRef.current;
161
+ if (nextActive === isToolbarDragActiveRef.current) {
162
+ return;
163
+ }
164
+
165
+ isToolbarDragActiveRef.current = nextActive;
166
+ dispatchToolbarDragActivity(nextActive);
167
+ }, [dispatchToolbarDragActivity]);
168
+
169
+ const handlePointerPressEnd = useCallback(() => {
170
+ isPointerPressActiveRef.current = false;
171
+ clearPointerPressListeners();
172
+ syncToolbarDragActivity();
173
+ }, [clearPointerPressListeners, syncToolbarDragActivity]);
174
+
175
+ const registerPointerPressListeners = useCallback(() => {
176
+ const ownerDocument = dragHandlerRef.current?.ownerDocument;
177
+ const ownerWindow = ownerDocument?.defaultView;
178
+ if (!ownerDocument) {
179
+ return;
180
+ }
181
+
182
+ clearPointerPressListeners();
183
+
184
+ const handlePointerEnd = () => {
185
+ handlePointerPressEnd();
186
+ };
187
+ const handleKeyDown = (event: KeyboardEvent) => {
188
+ if (event.key === 'Escape') {
189
+ handlePointerPressEnd();
190
+ }
191
+ };
192
+
193
+ ownerDocument.addEventListener('pointerup', handlePointerEnd, true);
194
+ ownerDocument.addEventListener('pointercancel', handlePointerEnd, true);
195
+ ownerDocument.addEventListener('keydown', handleKeyDown, true);
196
+ ownerWindow?.addEventListener('blur', handlePointerEnd);
197
+
198
+ pointerPressCleanupRef.current = () => {
199
+ ownerDocument.removeEventListener('pointerup', handlePointerEnd, true);
200
+ ownerDocument.removeEventListener('pointercancel', handlePointerEnd, true);
201
+ ownerDocument.removeEventListener('keydown', handleKeyDown, true);
202
+ ownerWindow?.removeEventListener('blur', handlePointerEnd);
203
+ };
204
+ }, [clearPointerPressListeners, handlePointerPressEnd]);
205
+
206
+ useEffect(() => {
207
+ syncDraggableNodeRef(dragHandlerRef.current);
208
+ }, [syncDraggableNodeRef]);
209
+
210
+ useEffect(() => {
211
+ isDraggingRef.current = isDragging;
212
+ syncToolbarDragActivity();
213
+ }, [isDragging, syncToolbarDragActivity]);
214
+
215
+ useEffect(() => {
216
+ return () => {
217
+ if (isToolbarDragActiveRef.current) {
218
+ dispatchToolbarDragActivity(false);
219
+ }
220
+ isPointerPressActiveRef.current = false;
221
+ isDraggingRef.current = false;
222
+ isToolbarDragActiveRef.current = false;
223
+ clearPointerPressListeners();
224
+ };
225
+ }, [clearPointerPressListeners, dispatchToolbarDragActivity]);
226
+
29
227
  return (
30
228
  <span
31
- ref={setNodeRef}
229
+ ref={setDragHandlerNodeRef}
32
230
  {...listeners}
33
231
  {...attributes}
232
+ onPointerDownCapture={(event) => {
233
+ if (event.button !== 0) {
234
+ return;
235
+ }
236
+
237
+ dispatchToolbarDragAnchor({
238
+ point: {
239
+ x: event.clientX,
240
+ y: event.clientY,
241
+ },
242
+ });
243
+ isPointerPressActiveRef.current = true;
244
+ syncToolbarDragActivity();
245
+ registerPointerPressListeners();
246
+ }}
34
247
  style={{
35
248
  cursor: 'grab',
36
249
  }}
@@ -78,7 +291,40 @@ export const DndProvider: FC<DndContextProps & PersistOptions> = ({
78
291
  ...restProps
79
292
  }) => {
80
293
  const [activeId, setActiveId] = useState<string | null>(null);
294
+ const [dragAnchorPoint, setDragAnchorPoint] = useState<ToolbarDragAnchorDetail['point']>(null);
81
295
  const flowEngine = useFlowEngine();
296
+
297
+ useEffect(() => {
298
+ if (typeof document === 'undefined') {
299
+ return;
300
+ }
301
+
302
+ const handleToolbarDragAnchor = (event: Event) => {
303
+ const customEvent = event as CustomEvent<ToolbarDragAnchorDetail>;
304
+ setDragAnchorPoint(customEvent.detail?.point || null);
305
+ };
306
+
307
+ document.addEventListener(TOOLBAR_DRAG_ANCHOR_EVENT, handleToolbarDragAnchor as EventListener);
308
+ return () => {
309
+ document.removeEventListener(TOOLBAR_DRAG_ANCHOR_EVENT, handleToolbarDragAnchor as EventListener);
310
+ };
311
+ }, []);
312
+
313
+ const overlayAnchorModifier = useCallback<Modifier>(
314
+ ({ active, activeNodeRect, transform }) => {
315
+ const nextTransform: Transform = resolveOverlayAnchorTransform({
316
+ activeId,
317
+ active,
318
+ transform,
319
+ activeNodeRect,
320
+ dragAnchorPoint,
321
+ });
322
+
323
+ return nextTransform;
324
+ },
325
+ [activeId, dragAnchorPoint],
326
+ );
327
+
82
328
  return (
83
329
  <DndContext
84
330
  onDragStart={(event) => {
@@ -87,6 +333,7 @@ export const DndProvider: FC<DndContextProps & PersistOptions> = ({
87
333
  }}
88
334
  onDragEnd={(event) => {
89
335
  setActiveId(null);
336
+ setDragAnchorPoint(null);
90
337
  // 如果没有 onDragEnd 回调,则默认调用 flowEngine 的 moveModel 方法
91
338
  if (!onDragEnd) {
92
339
  if (event.over) {
@@ -97,32 +344,45 @@ export const DndProvider: FC<DndContextProps & PersistOptions> = ({
97
344
  onDragEnd(event);
98
345
  }
99
346
  }}
347
+ onDragCancel={(event) => {
348
+ setActiveId(null);
349
+ setDragAnchorPoint(null);
350
+ restProps.onDragCancel?.(event);
351
+ }}
100
352
  {...restProps}
101
353
  >
102
354
  {children}
103
- {createPortal(
104
- <DragOverlay dropAnimation={null} zIndex={2000}>
105
- {activeId && (
106
- <span
107
- style={{
108
- display: 'inline-flex',
109
- alignItems: 'center',
110
- whiteSpace: 'nowrap',
111
- background: '#fff',
112
- border: '1px solid #1890ff',
113
- borderRadius: 4,
114
- padding: '4px 12px',
115
- color: '#1890ff',
116
- // fontSize: 18,
117
- boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
118
- }}
355
+ {typeof document !== 'undefined'
356
+ ? createPortal(
357
+ <DragOverlay
358
+ dropAnimation={null}
359
+ modifiers={[overlayAnchorModifier]}
360
+ zIndex={2000}
361
+ style={{ pointerEvents: 'none' }}
119
362
  >
120
- {flowEngine.translate('Dragging')}
121
- </span>
122
- )}
123
- </DragOverlay>,
124
- document.body,
125
- )}
363
+ {activeId && (
364
+ <span
365
+ style={{
366
+ display: 'inline-flex',
367
+ alignItems: 'center',
368
+ whiteSpace: 'nowrap',
369
+ background: '#fff',
370
+ border: '1px solid #1890ff',
371
+ borderRadius: 4,
372
+ padding: '4px 12px',
373
+ color: '#1890ff',
374
+ pointerEvents: 'none',
375
+ // fontSize: 18,
376
+ boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
377
+ }}
378
+ >
379
+ {flowEngine.translate('Dragging')}
380
+ </span>
381
+ )}
382
+ </DragOverlay>,
383
+ document.body,
384
+ )
385
+ : null}
126
386
  </DndContext>
127
387
  );
128
388
  };
@@ -27,6 +27,26 @@ import { useNiceDropdownMaxHeight } from '../../../../hooks';
27
27
  import { SwitchWithTitle } from '../component/SwitchWithTitle';
28
28
  import { SelectWithTitle } from '../component/SelectWithTitle';
29
29
  import type { FlowSettingsContext } from '../../../../flowContext';
30
+
31
+ const findExtraMenuItemByKey = (
32
+ items: FlowModelExtraMenuItem[],
33
+ targetKey: string,
34
+ ): FlowModelExtraMenuItem | undefined => {
35
+ for (const item of items) {
36
+ const itemKey = String(item?.key ?? '');
37
+ if (itemKey === targetKey) {
38
+ return item;
39
+ }
40
+ if (item.children?.length) {
41
+ const matched = findExtraMenuItemByKey(item.children, targetKey);
42
+ if (matched) {
43
+ return matched;
44
+ }
45
+ }
46
+ }
47
+ return undefined;
48
+ };
49
+
30
50
  // Type definitions for better type safety
31
51
  interface StepInfo {
32
52
  stepKey: string;
@@ -473,7 +493,11 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
473
493
  return;
474
494
  }
475
495
 
476
- const extra = extraMenuItems.find((it) => it?.key === originalKey || it?.key === cleanKey);
496
+ const extra =
497
+ findExtraMenuItemByKey(extraMenuItems, originalKey) || findExtraMenuItemByKey(extraMenuItems, cleanKey);
498
+ if (extra?.disabled) {
499
+ return;
500
+ }
477
501
  if (extra?.onClick) {
478
502
  closeDropdown();
479
503
  extra.onClick();
@@ -27,9 +27,9 @@ import {
27
27
  } from './useFloatToolbarPortal';
28
28
  import { useFloatToolbarVisibility } from './useFloatToolbarVisibility';
29
29
 
30
- const TOOLBAR_Z_INDEX = 999;
31
-
32
30
  type ToolbarPosition = 'inside' | 'above' | 'below';
31
+ const TOOLBAR_ITEM_WIDTH = 19;
32
+ const DEFAULT_POPUP_BASE_Z_INDEX = 1000;
33
33
 
34
34
  interface BaseFloatContextMenuProps {
35
35
  children?: React.ReactNode;
@@ -64,6 +64,10 @@ interface BaseFloatContextMenuProps {
64
64
  * Extra toolbar items to add to this context menu instance
65
65
  */
66
66
  extraToolbarItems?: ToolbarItemConfig[];
67
+ /**
68
+ * @default true
69
+ */
70
+ showDynamicFlowsEditor?: boolean;
67
71
  /**
68
72
  * @default 'inside'
69
73
  */
@@ -97,12 +101,14 @@ const toolbarContainerStyles = ({
97
101
  showBackground,
98
102
  showBorder,
99
103
  ctx,
104
+ toolbarZIndex,
100
105
  }: {
101
106
  showBackground: boolean;
102
107
  showBorder: boolean;
103
108
  ctx: any;
109
+ toolbarZIndex: number;
104
110
  }) => css`
105
- z-index: ${TOOLBAR_Z_INDEX};
111
+ z-index: ${toolbarZIndex};
106
112
  opacity: 0;
107
113
  pointer-events: none;
108
114
  overflow: visible;
@@ -294,6 +300,7 @@ const renderToolbarItems = (
294
300
  flowEngine: FlowEngine,
295
301
  settingsMenuLevel?: number,
296
302
  extraToolbarItems?: ToolbarItemConfig[],
303
+ showDynamicFlowsEditor = true,
297
304
  onSettingsMenuOpenChange?: (open: boolean) => void,
298
305
  getPopupContainer?: (triggerNode?: HTMLElement) => HTMLElement,
299
306
  ) => {
@@ -304,6 +311,9 @@ const renderToolbarItems = (
304
311
 
305
312
  return allToolbarItems
306
313
  .filter((itemConfig: ToolbarItemConfig) => {
314
+ if (itemConfig.key === 'dynamic-flows-editor' && showDynamicFlowsEditor === false) {
315
+ return false;
316
+ }
307
317
  return itemConfig.visible ? itemConfig.visible(model) : true;
308
318
  })
309
319
  .map((itemConfig: ToolbarItemConfig) => {
@@ -332,6 +342,7 @@ const buildToolbarContainerClassName = ({
332
342
  showBackground,
333
343
  showBorder,
334
344
  ctx,
345
+ toolbarZIndex,
335
346
  portalRenderSnapshot,
336
347
  isToolbarVisible,
337
348
  className,
@@ -339,12 +350,13 @@ const buildToolbarContainerClassName = ({
339
350
  showBackground: boolean;
340
351
  showBorder: boolean;
341
352
  ctx: any;
353
+ toolbarZIndex: number;
342
354
  portalRenderSnapshot: ToolbarPortalRenderSnapshot | null;
343
355
  isToolbarVisible: boolean;
344
356
  className?: string;
345
357
  }) =>
346
358
  [
347
- toolbarContainerStyles({ showBackground, showBorder, ctx }),
359
+ toolbarContainerStyles({ showBackground, showBorder, ctx, toolbarZIndex }),
348
360
  'nb-toolbar-portal',
349
361
  portalRenderSnapshot?.positioningMode === 'absolute' ? 'nb-toolbar-portal-absolute' : 'nb-toolbar-portal-fixed',
350
362
  isToolbarVisible ? 'nb-toolbar-visible' : '',
@@ -356,11 +368,13 @@ const buildToolbarContainerClassName = ({
356
368
  const buildToolbarContainerStyle = (
357
369
  portalRect: ToolbarPortalRect,
358
370
  toolbarStyle?: React.CSSProperties,
371
+ toolbarItemCount = 0,
359
372
  ): React.CSSProperties => ({
360
373
  top: `${portalRect.top}px`,
361
374
  left: `${portalRect.left}px`,
362
375
  width: `${portalRect.width}px`,
363
376
  height: `${portalRect.height}px`,
377
+ minWidth: toolbarItemCount ? `${TOOLBAR_ITEM_WIDTH * toolbarItemCount}px` : undefined,
364
378
  ...omitToolbarPortalInsetStyle(toolbarStyle),
365
379
  });
366
380
 
@@ -514,6 +528,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
514
528
  showDragHandle = false,
515
529
  settingsMenuLevel,
516
530
  extraToolbarItems,
531
+ showDynamicFlowsEditor = true,
517
532
  toolbarStyle,
518
533
  toolbarPosition = 'inside',
519
534
  }: ModelProvidedProps) => {
@@ -575,6 +590,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
575
590
  flowEngine,
576
591
  settingsMenuLevel,
577
592
  extraToolbarItems,
593
+ showDynamicFlowsEditor,
578
594
  handleSettingsMenuOpenChange,
579
595
  getPopupContainer,
580
596
  )
@@ -588,6 +604,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
588
604
  settingsMenuLevel,
589
605
  showCopyUidButton,
590
606
  showDeleteButton,
607
+ showDynamicFlowsEditor,
591
608
  ],
592
609
  );
593
610
 
@@ -625,15 +642,17 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
625
642
  return <>{children}</>;
626
643
  }
627
644
 
645
+ const toolbarZIndex = (model.context.themeToken?.zIndexPopupBase || DEFAULT_POPUP_BASE_Z_INDEX) + 1;
628
646
  const toolbarContainerClassName = buildToolbarContainerClassName({
629
647
  showBackground,
630
648
  showBorder,
631
649
  ctx: model.context,
650
+ toolbarZIndex,
632
651
  portalRenderSnapshot,
633
652
  isToolbarVisible,
634
653
  className,
635
654
  });
636
- const toolbarContainerStyle = buildToolbarContainerStyle(portalRect, toolbarStyle);
655
+ const toolbarContainerStyle = buildToolbarContainerStyle(portalRect, toolbarStyle, toolbarItems.length);
637
656
 
638
657
  const toolbarNode = shouldRenderToolbar ? (
639
658
  <div
@@ -106,7 +106,7 @@ const findElement = (node: any, predicate: (element: React.ReactElement) => bool
106
106
  return node;
107
107
  }
108
108
 
109
- const children = React.Children.toArray(node.props?.children);
109
+ const children = React.Children.toArray((node as React.ReactElement<any>).props?.children);
110
110
  for (const child of children) {
111
111
  const matched = findElement(child, predicate);
112
112
  if (matched) {
@@ -324,7 +324,9 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
324
324
  );
325
325
  expect(tooltipElement).toBeTruthy();
326
326
 
327
- const iconElement = React.isValidElement(tooltipElement) ? tooltipElement.props.children : null;
327
+ const iconElement = React.isValidElement(tooltipElement)
328
+ ? (tooltipElement as React.ReactElement<any>).props.children
329
+ : null;
328
330
  expect(React.isValidElement(iconElement)).toBe(true);
329
331
  expect((iconElement as any).props?.style?.color).toBe(mockColorTextTertiary);
330
332
 
@@ -612,7 +614,9 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
612
614
  const items = (menu?.items || []) as any[];
613
615
  const subMenu = items.find((it) => Array.isArray(it?.children));
614
616
  expect(subMenu).toBeTruthy();
615
- expect(subMenu!.children.some((it: any) => String(it.key).startsWith('items[0]:childFlow:cstep'))).toBe(true);
617
+ expect((subMenu?.children || []).some((it: any) => String(it.key).startsWith('items[0]:childFlow:cstep'))).toBe(
618
+ true,
619
+ );
616
620
  });
617
621
  });
618
622
 
@@ -809,4 +813,91 @@ describe('DefaultSettingsIcon - extra menu items', () => {
809
813
  dispose?.();
810
814
  }
811
815
  });
816
+
817
+ it('supports nested extra menu items with sorting and disabled states', async () => {
818
+ const onInsertBefore = vi.fn();
819
+ const onInsertAfter = vi.fn();
820
+
821
+ class TestFlowModel extends FlowModel {}
822
+ const dispose = TestFlowModel.registerExtraMenuItems({
823
+ group: 'common-actions',
824
+ sort: 10,
825
+ items: [
826
+ {
827
+ key: 'insert-actions',
828
+ label: 'Insert actions',
829
+ children: [
830
+ { key: 'insert-after', label: 'Insert after', sort: 20, onClick: onInsertAfter },
831
+ { key: 'insert-before', label: 'Insert before', sort: 10, onClick: onInsertBefore },
832
+ { key: 'insert-inner', label: 'Insert inner', sort: 30, disabled: true, onClick: vi.fn() },
833
+ ],
834
+ },
835
+ ],
836
+ });
837
+
838
+ const engine = new FlowEngine();
839
+ const model = new TestFlowModel({ uid: 'm-extra-nested', flowEngine: engine });
840
+
841
+ TestFlowModel.registerFlow({
842
+ key: 'flow',
843
+ title: 'Flow',
844
+ steps: { s: { title: 'S', uiSchema: { f: { type: 'string', 'x-component': 'Input' } } } },
845
+ });
846
+
847
+ try {
848
+ render(
849
+ React.createElement(
850
+ ConfigProvider as any,
851
+ null,
852
+ React.createElement(
853
+ App as any,
854
+ null,
855
+ React.createElement(DefaultSettingsIcon as any, {
856
+ model,
857
+ showCopyUidButton: false,
858
+ showDeleteButton: false,
859
+ }),
860
+ ),
861
+ ),
862
+ );
863
+
864
+ await waitFor(() => {
865
+ expect((globalThis as any).__lastDropdownMenu).toBeTruthy();
866
+ expect((globalThis as any).__lastDropdownOnOpenChange).toBeTruthy();
867
+ });
868
+
869
+ await act(async () => {
870
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
871
+ });
872
+
873
+ await waitFor(() => {
874
+ const menu = (globalThis as any).__lastDropdownMenu;
875
+ const items = (menu?.items || []) as any[];
876
+ const nested = items.find((it) => String(it.key || '') === 'insert-actions');
877
+ expect(nested).toBeTruthy();
878
+ expect((nested.children || []).map((it) => String(it.key || ''))).toEqual([
879
+ 'insert-before',
880
+ 'insert-after',
881
+ 'insert-inner',
882
+ ]);
883
+ expect((nested.children || []).find((it) => String(it.key || '') === 'insert-inner')?.disabled).toBe(true);
884
+ });
885
+
886
+ const menu = (globalThis as any).__lastDropdownMenu;
887
+ await act(async () => {
888
+ menu.onClick?.({ key: 'insert-inner' });
889
+ });
890
+ expect(onInsertBefore).not.toHaveBeenCalled();
891
+ expect(onInsertAfter).not.toHaveBeenCalled();
892
+ expect((globalThis as any).__lastDropdownOpen).toBe(true);
893
+
894
+ await act(async () => {
895
+ menu.onClick?.({ key: 'insert-before' });
896
+ });
897
+ expect(onInsertBefore).toHaveBeenCalledTimes(1);
898
+ expect((globalThis as any).__lastDropdownOpen).toBe(false);
899
+ } finally {
900
+ dispose?.();
901
+ }
902
+ });
812
903
  });