@nocobase/flow-engine 2.1.0-alpha.21 → 2.1.0-alpha.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__/FlowModelRenderer.test.tsx +22 -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
|
@@ -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
|
});
|
package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx
CHANGED
|
@@ -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 &&
|
|
87
|
-
|
|
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 (
|
|
139
|
+
if (isDraggingToolbarRef.current || isDraggingToolbarItemRef.current || isToolbarPinnedRef.current) {
|
|
106
140
|
return;
|
|
107
141
|
}
|
|
108
|
-
|
|
109
|
-
|
|
142
|
+
setHostHovered(false);
|
|
143
|
+
setToolbarHovered(false);
|
|
110
144
|
}, TOOLBAR_HIDE_DELAY);
|
|
111
|
-
}, [clearHideToolbarTimer,
|
|
145
|
+
}, [clearHideToolbarTimer, setHostHovered, setToolbarHovered]);
|
|
112
146
|
|
|
113
|
-
const handleSettingsMenuOpenChange = useCallback(
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
}, [clearHideToolbarTimer, updatePortalRect]);
|
|
279
|
+
setHostHovered(true);
|
|
280
|
+
}, [clearHideToolbarTimer, setHostHovered, updatePortalRect]);
|
|
210
281
|
|
|
211
282
|
const handleHostMouseLeave = useCallback(
|
|
212
283
|
(e: ReactMouseEvent<HTMLDivElement>) => {
|
|
213
|
-
if (
|
|
214
|
-
|
|
284
|
+
if (isToolbarPinnedRef.current) {
|
|
285
|
+
setHostHovered(false);
|
|
215
286
|
return;
|
|
216
287
|
}
|
|
217
288
|
if (isNodeWithin(e.relatedTarget, toolbarContainerRef.current)) {
|
|
218
289
|
clearHideToolbarTimer();
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
297
|
+
setHostHovered(true);
|
|
227
298
|
return;
|
|
228
299
|
}
|
|
229
300
|
scheduleHideToolbar();
|
|
230
301
|
},
|
|
231
|
-
[
|
|
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
|
-
|
|
238
|
-
|
|
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 (
|
|
244
|
-
|
|
322
|
+
if (isToolbarPinnedRef.current || isDraggingToolbarItemRef.current) {
|
|
323
|
+
clearHideToolbarTimer();
|
|
324
|
+
setToolbarHovered(false);
|
|
245
325
|
return;
|
|
246
326
|
}
|
|
247
|
-
|
|
327
|
+
setToolbarHovered(false);
|
|
248
328
|
if (isNodeWithin(e.relatedTarget, containerRef.current)) {
|
|
249
329
|
clearHideToolbarTimer();
|
|
250
|
-
|
|
330
|
+
setHostHovered(true);
|
|
251
331
|
return;
|
|
252
332
|
}
|
|
253
333
|
scheduleHideToolbar();
|
|
254
334
|
},
|
|
255
|
-
[clearHideToolbarTimer, containerRef,
|
|
335
|
+
[clearHideToolbarTimer, containerRef, scheduleHideToolbar, setHostHovered, setToolbarHovered],
|
|
256
336
|
);
|
|
257
337
|
|
|
258
338
|
const handleResizeDragStart = useCallback(() => {
|
|
259
339
|
updatePortalRect();
|
|
260
|
-
|
|
340
|
+
setDraggingToolbar(true);
|
|
261
341
|
schedulePortalRectUpdate();
|
|
262
|
-
}, [schedulePortalRectUpdate, updatePortalRect]);
|
|
342
|
+
}, [schedulePortalRectUpdate, setDraggingToolbar, updatePortalRect]);
|
|
263
343
|
|
|
264
344
|
const handleResizeDragEnd = useCallback(() => {
|
|
265
|
-
|
|
345
|
+
setDraggingToolbar(false);
|
|
266
346
|
schedulePortalRectUpdate();
|
|
267
|
-
}, [schedulePortalRectUpdate]);
|
|
347
|
+
}, [schedulePortalRectUpdate, setDraggingToolbar]);
|
|
268
348
|
|
|
269
349
|
return {
|
|
270
350
|
isToolbarVisible,
|
|
@@ -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
|
});
|