@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
@@ -17,6 +17,7 @@ import { FlowEngineProvider } from '../../../../../provider';
17
17
  import { FieldModelRenderer } from '../../../../FieldModelRenderer';
18
18
  import { FlowModelRenderer } from '../../../../FlowModelRenderer';
19
19
  import { FlowsFloatContextMenu } from '../FlowsFloatContextMenu';
20
+ import { TOOLBAR_DRAG_ACTIVITY_EVENT } from '../../../../dnd';
20
21
 
21
22
  const mockColorTextTertiary = '#8c8c8c';
22
23
 
@@ -151,17 +152,36 @@ const setupDrawerPopup = () => {
151
152
  return { drawerWrapper, drawerContent };
152
153
  };
153
154
 
155
+ const setupOverflowPopup = () => {
156
+ const appContainer = createAppContainer();
157
+ const popupRoot = document.createElement('div');
158
+ popupRoot.className = 'ant-menu-submenu-popup';
159
+ popupRoot.style.zIndex = '1000';
160
+ appContainer.appendChild(popupRoot);
161
+ mockRect(appContainer, { top: 40, left: 60, width: 1200, height: 800 });
162
+ mockRect(popupRoot, { top: 96, left: 420, width: 260, height: 240 });
163
+ return { appContainer, popupRoot };
164
+ };
165
+
154
166
  const getHost = (element: HTMLElement) => element.closest('[data-has-float-menu="true"]') as HTMLDivElement;
155
167
  const queryOverlay = (container: HTMLElement, uid: string) =>
156
168
  container.querySelector(`[data-model-uid="${uid}"]`) as HTMLDivElement | null;
157
169
 
158
- const createModel = (engine: FlowEngine, uid: string) => {
170
+ const createModel = (engine: FlowEngine, uid: string, themeToken?: Record<string, number | undefined>) => {
159
171
  const model = new FlowModel({ uid, flowEngine: engine });
160
- model.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
172
+ model.context.defineProperty('themeToken', { value: { borderRadiusLG: 8, ...themeToken } });
161
173
  model.render = vi.fn().mockReturnValue(<div data-testid={`${uid}-content`}>{uid}</div>);
162
174
  return model;
163
175
  };
164
176
 
177
+ const ToolbarDragItem = ({ model }: { model: FlowModel }) => {
178
+ return (
179
+ <button type="button" aria-label="toolbar-drag">
180
+ drag
181
+ </button>
182
+ );
183
+ };
184
+
165
185
  describe('FlowsFloatContextMenu', () => {
166
186
  const originalResizeObserver = globalThis.ResizeObserver;
167
187
  const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
@@ -314,6 +334,49 @@ describe('FlowsFloatContextMenu', () => {
314
334
  });
315
335
  });
316
336
 
337
+ it('renders overflow popup toolbar above popup roots while keeping dropdown popup bound to local icons container', async () => {
338
+ const engine = new FlowEngine();
339
+ await engine.flowSettings.forceEnable();
340
+ const model = createModel(engine, 'overflow-popup-model', { zIndexPopupBase: 1000 });
341
+ const { appContainer, popupRoot } = setupOverflowPopup();
342
+
343
+ const { findByTestId } = renderWithProviders(
344
+ engine,
345
+ <FlowModelRenderer model={model} showFlowSettings={{ toolbarPosition: 'above' }} />,
346
+ { container: popupRoot },
347
+ );
348
+
349
+ const content = await findByTestId('overflow-popup-model-content');
350
+ const host = getHost(content);
351
+ mockRect(host, { top: 128, left: 436, width: 180, height: 40 });
352
+
353
+ expect(getComputedStyle(popupRoot).zIndex).toBe('1000');
354
+ expect(appContainer.querySelector('[data-model-uid="overflow-popup-model"]')).toBeNull();
355
+
356
+ fireEvent.mouseEnter(host);
357
+
358
+ const overlay = await waitFor(() => {
359
+ const nextOverlay = appContainer.querySelector(
360
+ '[data-model-uid="overflow-popup-model"]',
361
+ ) as HTMLDivElement | null;
362
+ expect(nextOverlay).toBeTruthy();
363
+ return nextOverlay as HTMLDivElement;
364
+ });
365
+
366
+ await waitFor(() => {
367
+ expect(within(overlay).getByLabelText('flows-settings')).toBeTruthy();
368
+ });
369
+
370
+ await waitFor(() => {
371
+ expect(overlay.className).toContain('nb-toolbar-visible');
372
+ expect(getComputedStyle(overlay).zIndex).toBe('1001');
373
+ expect(overlay.parentElement).toBe(popupRoot);
374
+ });
375
+
376
+ const dropdown = within(overlay).getByTestId('dropdown');
377
+ expect(dropdown.getAttribute('data-popup-container')).toContain('nb-toolbar-container-icons');
378
+ });
379
+
317
380
  it('portals field toolbar to the nearest popup root and treats inset values as rect adjustments', async () => {
318
381
  const engine = new FlowEngine();
319
382
  await engine.flowSettings.forceEnable();
@@ -398,6 +461,112 @@ describe('FlowsFloatContextMenu', () => {
398
461
  });
399
462
  });
400
463
 
464
+ it('falls back to popup base 1000 when themeToken.zIndexPopupBase is missing', async () => {
465
+ const engine = new FlowEngine();
466
+ await engine.flowSettings.forceEnable();
467
+ const model = createModel(engine, 'fallback-zindex-model');
468
+ const appContainer = createAppContainer();
469
+ mockRect(appContainer, { top: 20, left: 40, width: 1200, height: 800 });
470
+
471
+ const { getByTestId } = renderWithProviders(
472
+ engine,
473
+ <FlowsFloatContextMenu model={model}>
474
+ <div data-testid="fallback-content">content</div>
475
+ </FlowsFloatContextMenu>,
476
+ { container: appContainer },
477
+ );
478
+
479
+ const host = getHost(getByTestId('fallback-content'));
480
+ mockRect(host, { top: 56, left: 84, width: 160, height: 48 });
481
+
482
+ fireEvent.mouseEnter(host);
483
+
484
+ const overlay = await waitFor(() => {
485
+ const nextOverlay = appContainer.querySelector(
486
+ '[data-model-uid="fallback-zindex-model"]',
487
+ ) as HTMLDivElement | null;
488
+ expect(nextOverlay).toBeTruthy();
489
+ return nextOverlay as HTMLDivElement;
490
+ });
491
+
492
+ await waitFor(() => {
493
+ expect(within(overlay).getByLabelText('flows-settings')).toBeTruthy();
494
+ });
495
+
496
+ await waitFor(() => {
497
+ expect(overlay.className).toContain('nb-toolbar-visible');
498
+ expect(getComputedStyle(overlay).zIndex).toBe('1001');
499
+ });
500
+ });
501
+
502
+ it('keeps toolbar visible while a toolbar drag item is active', async () => {
503
+ const engine = new FlowEngine();
504
+ await engine.flowSettings.forceEnable();
505
+ const model = createModel(engine, 'drag-toolbar-model');
506
+ const appContainer = createAppContainer();
507
+ mockRect(appContainer, { top: 20, left: 40, width: 1200, height: 800 });
508
+
509
+ const { getByTestId } = renderWithProviders(
510
+ engine,
511
+ <FlowsFloatContextMenu
512
+ model={model}
513
+ extraToolbarItems={[
514
+ {
515
+ key: 'toolbar-drag',
516
+ component: ToolbarDragItem,
517
+ sort: 100,
518
+ },
519
+ ]}
520
+ >
521
+ <div data-testid="drag-toolbar-content">content</div>
522
+ </FlowsFloatContextMenu>,
523
+ { container: appContainer },
524
+ );
525
+
526
+ const host = getHost(getByTestId('drag-toolbar-content'));
527
+ mockRect(host, { top: 56, left: 84, width: 160, height: 48 });
528
+
529
+ fireEvent.mouseEnter(host);
530
+
531
+ const overlay = await waitFor(() => {
532
+ const nextOverlay = appContainer.querySelector('[data-model-uid="drag-toolbar-model"]') as HTMLDivElement | null;
533
+ expect(nextOverlay).toBeTruthy();
534
+ return nextOverlay as HTMLDivElement;
535
+ });
536
+
537
+ const icons = overlay.querySelector('.nb-toolbar-container-icons') as HTMLDivElement;
538
+
539
+ await waitFor(() => {
540
+ expect(within(overlay).getByLabelText('toolbar-drag')).toBeTruthy();
541
+ expect(overlay.className).toContain('nb-toolbar-visible');
542
+ });
543
+
544
+ fireEvent.mouseLeave(host, { relatedTarget: icons });
545
+ fireEvent.mouseEnter(icons, { relatedTarget: host });
546
+
547
+ const dragButton = within(overlay).getByLabelText('toolbar-drag');
548
+ dragButton.ownerDocument.dispatchEvent(
549
+ new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
550
+ detail: { active: true, modelUid: model.uid },
551
+ }),
552
+ );
553
+ fireEvent.mouseLeave(icons, { relatedTarget: document.createElement('div') });
554
+
555
+ await waitFor(() => {
556
+ expect(queryOverlay(appContainer, 'drag-toolbar-model')?.className).toContain('nb-toolbar-visible');
557
+ });
558
+
559
+ dragButton.ownerDocument.dispatchEvent(
560
+ new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
561
+ detail: { active: false, modelUid: model.uid },
562
+ }),
563
+ );
564
+
565
+ await waitFor(() => {
566
+ expect(queryOverlay(appContainer, 'drag-toolbar-model')).toBeNull();
567
+ });
568
+ });
569
+
401
570
  it('hides parent toolbar when hovering a nested child host', async () => {
402
571
  const engine = new FlowEngine();
403
572
  await engine.flowSettings.forceEnable();
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
11
11
  import type { CSSProperties, RefObject } from 'react';
12
12
 
13
13
  const APP_CONTAINER_SELECTOR = '#nocobase-app-container';
14
+ const MENU_SUBMENU_POPUP_SELECTOR = '.ant-menu-submenu-popup';
14
15
  const DRAWER_CONTENT_WRAPPER_SELECTOR = '.ant-drawer-content-wrapper';
15
16
  const DRAWER_CONTENT_SELECTOR = '.ant-drawer-content';
16
17
  const DRAWER_ROOT_SELECTOR = '.ant-drawer-root';
@@ -77,6 +78,7 @@ const createAbsolutePortalHostConfig = (element: HTMLElement): ToolbarPortalHost
77
78
  });
78
79
 
79
80
  const popupPortalHostResolvers: Array<(hostEl: HTMLElement | null) => HTMLElement | null> = [
81
+ (hostEl) => getClosestElement(hostEl, MENU_SUBMENU_POPUP_SELECTOR),
80
82
  (hostEl) => getClosestElement(hostEl, DRAWER_CONTENT_WRAPPER_SELECTOR),
81
83
  (hostEl) => getClosestElement(hostEl, MODAL_WRAP_SELECTOR),
82
84
  (hostEl) => getClosestElement(hostEl, MODAL_SELECTOR),
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { useCallback, useEffect, useRef, useState } from 'react';
11
11
  import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
12
+ import { TOOLBAR_DRAG_ACTIVITY_EVENT } from '../../../dnd';
12
13
 
13
14
  const TOOLBAR_HIDE_DELAY = 180;
14
15
  const CHILD_FLOAT_MENU_ACTIVITY_EVENT = 'nb-float-menu-child-activity';
@@ -75,18 +76,51 @@ export const useFloatToolbarVisibility = ({
75
76
  const [isHostHovered, setIsHostHovered] = useState(false);
76
77
  const [isToolbarHovered, setIsToolbarHovered] = useState(false);
77
78
  const [isDraggingToolbar, setIsDraggingToolbar] = useState(false);
79
+ const [isDraggingToolbarItem, setIsDraggingToolbarItem] = useState(false);
78
80
  const [isToolbarPinned, setIsToolbarPinned] = useState(false);
79
81
  const [isHidePending, setIsHidePending] = useState(false);
80
82
  const [activeChildToolbarIds, setActiveChildToolbarIds] = useState<string[]>([]);
81
83
  const hideToolbarTimerRef = useRef<number | null>(null);
82
84
  const reportedChildActivityToAncestorsRef = useRef(false);
85
+ const isHostHoveredRef = useRef(false);
86
+ const isToolbarHoveredRef = useRef(false);
87
+ const isDraggingToolbarRef = useRef(false);
88
+ const isDraggingToolbarItemRef = useRef(false);
89
+ const isToolbarPinnedRef = useRef(false);
90
+
91
+ const setHostHovered = useCallback((value: boolean) => {
92
+ isHostHoveredRef.current = value;
93
+ setIsHostHovered(value);
94
+ }, []);
95
+
96
+ const setToolbarHovered = useCallback((value: boolean) => {
97
+ isToolbarHoveredRef.current = value;
98
+ setIsToolbarHovered(value);
99
+ }, []);
100
+
101
+ const setDraggingToolbar = useCallback((value: boolean) => {
102
+ isDraggingToolbarRef.current = value;
103
+ setIsDraggingToolbar(value);
104
+ }, []);
105
+
106
+ const setDraggingToolbarItem = useCallback((value: boolean) => {
107
+ isDraggingToolbarItemRef.current = value;
108
+ setIsDraggingToolbarItem(value);
109
+ }, []);
110
+
111
+ const setToolbarPinned = useCallback((value: boolean) => {
112
+ isToolbarPinnedRef.current = value;
113
+ setIsToolbarPinned(value);
114
+ }, []);
83
115
 
84
116
  const hasActiveChildToolbar = activeChildToolbarIds.length > 0;
85
117
  const isToolbarVisible =
86
- !hideMenu && !hasActiveChildToolbar && (isHostHovered || isToolbarHovered || isDraggingToolbar || isToolbarPinned);
87
- const shouldRenderToolbar = isToolbarVisible || isToolbarPinned || isDraggingToolbar;
118
+ !hideMenu &&
119
+ !hasActiveChildToolbar &&
120
+ (isHostHovered || isToolbarHovered || isDraggingToolbar || isDraggingToolbarItem || isToolbarPinned);
121
+ const shouldRenderToolbar = isToolbarVisible || isToolbarPinned || isDraggingToolbar || isDraggingToolbarItem;
88
122
  const isToolbarInteractionActive =
89
- isHostHovered || isToolbarHovered || isDraggingToolbar || isToolbarPinned || isHidePending;
123
+ isHostHovered || isToolbarHovered || isDraggingToolbar || isDraggingToolbarItem || isToolbarPinned || isHidePending;
90
124
 
91
125
  const clearHideToolbarTimer = useCallback(() => {
92
126
  if (hideToolbarTimerRef.current !== null) {
@@ -102,17 +136,20 @@ export const useFloatToolbarVisibility = ({
102
136
  hideToolbarTimerRef.current = window.setTimeout(() => {
103
137
  hideToolbarTimerRef.current = null;
104
138
  setIsHidePending(false);
105
- if (isDraggingToolbar || isToolbarPinned) {
139
+ if (isDraggingToolbarRef.current || isDraggingToolbarItemRef.current || isToolbarPinnedRef.current) {
106
140
  return;
107
141
  }
108
- setIsHostHovered(false);
109
- setIsToolbarHovered(false);
142
+ setHostHovered(false);
143
+ setToolbarHovered(false);
110
144
  }, TOOLBAR_HIDE_DELAY);
111
- }, [clearHideToolbarTimer, isDraggingToolbar, isToolbarPinned]);
145
+ }, [clearHideToolbarTimer, setHostHovered, setToolbarHovered]);
112
146
 
113
- const handleSettingsMenuOpenChange = useCallback((open: boolean) => {
114
- setIsToolbarPinned(open);
115
- }, []);
147
+ const handleSettingsMenuOpenChange = useCallback(
148
+ (open: boolean) => {
149
+ setToolbarPinned(open);
150
+ },
151
+ [setToolbarPinned],
152
+ );
116
153
 
117
154
  useEffect(() => {
118
155
  const hostElement = containerRef.current;
@@ -146,6 +183,40 @@ export const useFloatToolbarVisibility = ({
146
183
  };
147
184
  }, [containerRef]);
148
185
 
186
+ useEffect(() => {
187
+ const hostElement = containerRef.current;
188
+ const ownerDocument = hostElement?.ownerDocument;
189
+ if (!ownerDocument) {
190
+ return;
191
+ }
192
+
193
+ const handleToolbarDragActivity = (event: Event) => {
194
+ const customEvent = event as CustomEvent<{ active?: boolean; modelUid?: string }>;
195
+ if (customEvent.detail?.modelUid !== modelUid) {
196
+ return;
197
+ }
198
+
199
+ if (customEvent.detail?.active) {
200
+ clearHideToolbarTimer();
201
+ setDraggingToolbarItem(true);
202
+ return;
203
+ }
204
+
205
+ setDraggingToolbarItem(false);
206
+ if (isHostHoveredRef.current || isToolbarHoveredRef.current || isToolbarPinnedRef.current) {
207
+ clearHideToolbarTimer();
208
+ return;
209
+ }
210
+
211
+ scheduleHideToolbar();
212
+ };
213
+
214
+ ownerDocument.addEventListener(TOOLBAR_DRAG_ACTIVITY_EVENT, handleToolbarDragActivity as EventListener);
215
+ return () => {
216
+ ownerDocument.removeEventListener(TOOLBAR_DRAG_ACTIVITY_EVENT, handleToolbarDragActivity as EventListener);
217
+ };
218
+ }, [clearHideToolbarTimer, containerRef, modelUid, scheduleHideToolbar, setDraggingToolbarItem]);
219
+
149
220
  useEffect(() => {
150
221
  const hostElement = containerRef.current;
151
222
  if (!hostElement || reportedChildActivityToAncestorsRef.current === isToolbarInteractionActive) {
@@ -193,78 +264,87 @@ export const useFloatToolbarVisibility = ({
193
264
 
194
265
  if (isCurrentHostTarget) {
195
266
  clearHideToolbarTimer();
196
- setIsHostHovered(true);
267
+ setHostHovered(true);
197
268
  }
198
269
 
199
270
  setHideMenu(!!childWithMenu && childWithMenu !== containerRef.current);
200
271
  },
201
- [clearHideToolbarTimer, containerRef],
272
+ [clearHideToolbarTimer, containerRef, setHostHovered],
202
273
  );
203
274
 
204
275
  const handleHostMouseEnter = useCallback(() => {
205
276
  clearHideToolbarTimer();
206
277
  setHideMenu(false);
207
278
  updatePortalRect();
208
- setIsHostHovered(true);
209
- }, [clearHideToolbarTimer, updatePortalRect]);
279
+ setHostHovered(true);
280
+ }, [clearHideToolbarTimer, setHostHovered, updatePortalRect]);
210
281
 
211
282
  const handleHostMouseLeave = useCallback(
212
283
  (e: ReactMouseEvent<HTMLDivElement>) => {
213
- if (isToolbarPinned) {
214
- setIsHostHovered(false);
284
+ if (isToolbarPinnedRef.current) {
285
+ setHostHovered(false);
215
286
  return;
216
287
  }
217
288
  if (isNodeWithin(e.relatedTarget, toolbarContainerRef.current)) {
218
289
  clearHideToolbarTimer();
219
- setIsHostHovered(false);
220
- setIsToolbarHovered(true);
290
+ setHostHovered(false);
291
+ setToolbarHovered(true);
221
292
  return;
222
293
  }
223
294
  if (isNodeWithinDescendantFloatToolbar(e.relatedTarget, containerRef.current, modelUid)) {
224
295
  clearHideToolbarTimer();
225
296
  setHideMenu(false);
226
- setIsHostHovered(true);
297
+ setHostHovered(true);
227
298
  return;
228
299
  }
229
300
  scheduleHideToolbar();
230
301
  },
231
- [clearHideToolbarTimer, containerRef, isToolbarPinned, modelUid, scheduleHideToolbar, toolbarContainerRef],
302
+ [
303
+ clearHideToolbarTimer,
304
+ containerRef,
305
+ modelUid,
306
+ scheduleHideToolbar,
307
+ setHostHovered,
308
+ setToolbarHovered,
309
+ toolbarContainerRef,
310
+ ],
232
311
  );
233
312
 
234
313
  const handleToolbarMouseEnter = useCallback(() => {
235
314
  clearHideToolbarTimer();
236
315
  updatePortalRect();
237
- setIsHostHovered(false);
238
- setIsToolbarHovered(true);
239
- }, [clearHideToolbarTimer, updatePortalRect]);
316
+ setHostHovered(false);
317
+ setToolbarHovered(true);
318
+ }, [clearHideToolbarTimer, setHostHovered, setToolbarHovered, updatePortalRect]);
240
319
 
241
320
  const handleToolbarMouseLeave = useCallback(
242
321
  (e: ReactMouseEvent<HTMLDivElement>) => {
243
- if (isToolbarPinned) {
244
- setIsToolbarHovered(false);
322
+ if (isToolbarPinnedRef.current || isDraggingToolbarItemRef.current) {
323
+ clearHideToolbarTimer();
324
+ setToolbarHovered(false);
245
325
  return;
246
326
  }
247
- setIsToolbarHovered(false);
327
+ setToolbarHovered(false);
248
328
  if (isNodeWithin(e.relatedTarget, containerRef.current)) {
249
329
  clearHideToolbarTimer();
250
- setIsHostHovered(true);
330
+ setHostHovered(true);
251
331
  return;
252
332
  }
253
333
  scheduleHideToolbar();
254
334
  },
255
- [clearHideToolbarTimer, containerRef, isToolbarPinned, scheduleHideToolbar],
335
+ [clearHideToolbarTimer, containerRef, scheduleHideToolbar, setHostHovered, setToolbarHovered],
256
336
  );
257
337
 
258
338
  const handleResizeDragStart = useCallback(() => {
259
339
  updatePortalRect();
260
- setIsDraggingToolbar(true);
340
+ setDraggingToolbar(true);
261
341
  schedulePortalRectUpdate();
262
- }, [schedulePortalRectUpdate, updatePortalRect]);
342
+ }, [schedulePortalRectUpdate, setDraggingToolbar, updatePortalRect]);
263
343
 
264
344
  const handleResizeDragEnd = useCallback(() => {
265
- setIsDraggingToolbar(false);
345
+ setDraggingToolbar(false);
266
346
  schedulePortalRectUpdate();
267
- }, [schedulePortalRectUpdate]);
347
+ }, [schedulePortalRectUpdate, setDraggingToolbar]);
268
348
 
269
349
  return {
270
350
  isToolbarVisible,
@@ -8,5 +8,6 @@
8
8
  */
9
9
 
10
10
  export * from './AddSubModelButton';
11
+ export { default as LazyDropdown } from './LazyDropdown';
11
12
  export * from './utils';
12
13
  //
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { describe, expect, it } from 'vitest';
10
+ import { describe, expect, it, vi } from 'vitest';
11
11
  import { DataSource, DataSourceManager } from '../index';
12
12
  import { FlowEngine } from '../../flowEngine';
13
13
 
@@ -79,4 +79,37 @@ describe('DataSource & Collection APIs', () => {
79
79
  ]),
80
80
  ).toThrow(/circular/);
81
81
  });
82
+
83
+ it('ensureLoaded, reload and data source events work for main loader', async () => {
84
+ const { m, engine } = makeManager();
85
+ const loadedListener = vi.fn();
86
+ const failedListener = vi.fn();
87
+ engine.context.app = { eventBus: new EventTarget() } as any;
88
+ engine.context.app.eventBus.addEventListener('dataSource:loaded', loadedListener);
89
+ engine.context.app.eventBus.addEventListener('dataSource:loadFailed', failedListener);
90
+
91
+ const loader = vi
92
+ .fn()
93
+ .mockResolvedValueOnce({
94
+ collections: [{ name: 'posts', fields: [{ name: 'title', type: 'string', interface: 'input' }] }],
95
+ })
96
+ .mockResolvedValueOnce({
97
+ collections: [{ name: 'users', fields: [{ name: 'nickname', type: 'string', interface: 'input' }] }],
98
+ });
99
+
100
+ m.registerLoader('main', loader);
101
+
102
+ await m.ensureLoaded();
103
+ expect(loader).toHaveBeenCalledTimes(1);
104
+ expect(m.getDataSource('main')?.status).toBe('loaded');
105
+ expect(m.getCollection('main', 'posts')?.name).toBe('posts');
106
+ expect(loadedListener).toHaveBeenCalledTimes(1);
107
+
108
+ await m.reloadDataSource('main');
109
+ expect(loader).toHaveBeenCalledTimes(2);
110
+ expect(m.getCollection('main', 'posts')).toBeUndefined();
111
+ expect(m.getCollection('main', 'users')?.name).toBe('users');
112
+ expect(m.getDataSource('main')?.reload).toBeTypeOf('function');
113
+ expect(failedListener).not.toHaveBeenCalled();
114
+ });
82
115
  });