@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.
- package/lib/components/FieldModelRenderer.js +2 -2
- package/lib/components/FlowModelRenderer.d.ts +2 -0
- package/lib/components/FlowModelRenderer.js +2 -0
- package/lib/components/dnd/index.d.ts +19 -1
- package/lib/components/dnd/index.js +239 -21
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +20 -1
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +4 -0
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +21 -8
- package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +2 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +100 -32
- package/lib/components/subModel/index.d.ts +1 -0
- package/lib/components/subModel/index.js +19 -0
- package/lib/components/subModel/utils.d.ts +1 -1
- package/lib/data-source/index.d.ts +73 -0
- package/lib/data-source/index.js +205 -1
- package/lib/flowContext.d.ts +2 -0
- package/lib/flowI18n.js +2 -1
- package/lib/models/DisplayItemModel.d.ts +1 -1
- package/lib/models/EditableItemModel.d.ts +1 -1
- package/lib/models/FilterableItemModel.d.ts +1 -1
- package/lib/models/flowModel.d.ts +11 -9
- package/lib/models/flowModel.js +48 -9
- package/lib/provider.js +38 -23
- package/package.json +4 -4
- package/src/__tests__/provider.test.tsx +24 -2
- package/src/components/FieldModelRenderer.tsx +2 -1
- package/src/components/FlowModelRenderer.tsx +6 -0
- package/src/components/__tests__/dnd.test.ts +44 -0
- package/src/components/dnd/index.tsx +286 -26
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +25 -1
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +24 -5
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +94 -3
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +171 -2
- package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +2 -0
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +112 -32
- package/src/components/subModel/index.ts +1 -0
- package/src/data-source/__tests__/index.test.ts +34 -1
- package/src/data-source/index.ts +252 -2
- package/src/flowContext.ts +2 -0
- package/src/flowI18n.ts +2 -1
- package/src/models/DisplayItemModel.tsx +1 -1
- package/src/models/EditableItemModel.tsx +1 -1
- package/src/models/FilterableItemModel.tsx +1 -1
- package/src/models/flowModel.tsx +85 -23
- 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
|
|
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
|
|
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={
|
|
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
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 =
|
|
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: ${
|
|
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)
|
|
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
|
|
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
|
});
|