@nocobase/flow-engine 2.2.0-alpha.1 → 2.2.0-alpha.2
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/JSRunner.d.ts +1 -0
- package/lib/JSRunner.js +110 -20
- package/lib/components/FieldModelRenderer.js +44 -31
- package/lib/components/FlowContextSelector.js +24 -3
- package/lib/components/dnd/index.js +9 -2
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
- package/lib/components/variables/VariableHybridInput.d.ts +8 -0
- package/lib/components/variables/VariableHybridInput.js +128 -12
- package/lib/components/variables/types.d.ts +8 -0
- package/lib/flowContext.d.ts +1 -1
- package/lib/flowContext.js +87 -36
- package/lib/flowI18n.js +3 -3
- package/lib/flowSettings.d.ts +5 -1
- package/lib/flowSettings.js +70 -0
- package/lib/locale/en-US.json +1 -0
- package/lib/locale/index.d.ts +2 -0
- package/lib/locale/zh-CN.json +1 -0
- package/lib/resources/apiResource.js +2 -1
- package/lib/resources/baseRecordResource.js +6 -17
- package/lib/resources/multiRecordResource.js +13 -3
- package/lib/resources/singleRecordResource.js +7 -2
- package/lib/runjs-context/helpers.js +12 -5
- package/lib/types.d.ts +12 -0
- package/lib/utils/dataSourceDirty.d.ts +20 -0
- package/lib/utils/dataSourceDirty.js +139 -0
- package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
- package/lib/utils/dirtyAwareApiClient.js +378 -0
- package/lib/utils/index.d.ts +1 -1
- package/lib/utils/index.js +11 -11
- package/lib/utils/openViewRouteState.d.ts +28 -0
- package/lib/utils/openViewRouteState.js +125 -0
- package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
- package/lib/utils/parsePathnameToViewParams.js +18 -1
- package/lib/utils/resolveRunJSObjectValues.js +3 -2
- package/lib/utils/runjsModuleLoader.js +0 -30
- package/lib/views/ViewNavigation.js +5 -0
- package/package.json +4 -4
- package/src/JSRunner.ts +112 -25
- package/src/__tests__/JSRunner.test.ts +4 -5
- package/src/__tests__/flowContext.test.ts +131 -0
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
- package/src/__tests__/flowSettings.test.ts +72 -0
- package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
- package/src/components/FieldModelRenderer.tsx +50 -36
- package/src/components/FlowContextSelector.tsx +33 -2
- package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
- package/src/components/dnd/index.tsx +11 -2
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
- package/src/components/variables/VariableHybridInput.tsx +166 -9
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +178 -0
- package/src/components/variables/types.ts +8 -0
- package/src/flowContext.ts +100 -33
- package/src/flowI18n.ts +8 -3
- package/src/flowSettings.ts +85 -1
- package/src/locale/en-US.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/resources/apiResource.ts +2 -1
- package/src/resources/baseRecordResource.ts +6 -23
- package/src/resources/multiRecordResource.ts +13 -3
- package/src/resources/singleRecordResource.ts +6 -1
- package/src/runjs-context/helpers.ts +12 -6
- package/src/types.ts +14 -0
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
- package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
- package/src/utils/dataSourceDirty.ts +126 -0
- package/src/utils/dirtyAwareApiClient.ts +430 -0
- package/src/utils/index.ts +10 -9
- package/src/utils/openViewRouteState.ts +107 -0
- package/src/utils/parsePathnameToViewParams.ts +23 -1
- package/src/utils/resolveRunJSObjectValues.ts +5 -2
- package/src/utils/runjsModuleLoader.ts +0 -32
- package/src/views/ViewNavigation.ts +6 -1
- package/src/views/__tests__/ViewNavigation.test.ts +15 -0
- package/lib/utils/safeGlobals.d.ts +0 -28
- package/lib/utils/safeGlobals.js +0 -367
- package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
- package/src/utils/__tests__/safeGlobals.test.ts +0 -106
- package/src/utils/safeGlobals.ts +0 -406
package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import React from 'react';
|
|
11
|
-
import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react';
|
|
11
|
+
import { act, cleanup, fireEvent, render, waitFor, within } from '@testing-library/react';
|
|
12
12
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
13
|
import { App, ConfigProvider } from 'antd';
|
|
14
14
|
import { FlowEngine } from '../../../../../flowEngine';
|
|
@@ -17,7 +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
|
+
import { DndProvider, DragHandler, TOOLBAR_DRAG_ACTIVITY_EVENT } from '../../../../dnd';
|
|
21
21
|
|
|
22
22
|
const mockColorTextTertiary = '#8c8c8c';
|
|
23
23
|
|
|
@@ -182,12 +182,24 @@ const ToolbarDragItem = ({ model }: { model: FlowModel }) => {
|
|
|
182
182
|
);
|
|
183
183
|
};
|
|
184
184
|
|
|
185
|
+
const ForkedToolbarDragItem = ({ model }: { model: FlowModel }) => {
|
|
186
|
+
return (
|
|
187
|
+
<DragHandler model={model}>
|
|
188
|
+
<button type="button" aria-label="forked-toolbar-drag">
|
|
189
|
+
drag
|
|
190
|
+
</button>
|
|
191
|
+
</DragHandler>
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
185
195
|
describe('FlowsFloatContextMenu', () => {
|
|
186
196
|
const originalResizeObserver = globalThis.ResizeObserver;
|
|
187
197
|
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
|
188
198
|
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
|
199
|
+
const originalPointerEvent = globalThis.PointerEvent;
|
|
189
200
|
|
|
190
201
|
beforeEach(() => {
|
|
202
|
+
globalThis.PointerEvent = MouseEvent as typeof PointerEvent;
|
|
191
203
|
globalThis.ResizeObserver = class {
|
|
192
204
|
observe = vi.fn();
|
|
193
205
|
disconnect = vi.fn();
|
|
@@ -207,6 +219,7 @@ describe('FlowsFloatContextMenu', () => {
|
|
|
207
219
|
globalThis.ResizeObserver = originalResizeObserver;
|
|
208
220
|
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
|
209
221
|
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
|
222
|
+
globalThis.PointerEvent = originalPointerEvent;
|
|
210
223
|
document.body.innerHTML = '';
|
|
211
224
|
});
|
|
212
225
|
|
|
@@ -545,28 +558,163 @@ describe('FlowsFloatContextMenu', () => {
|
|
|
545
558
|
fireEvent.mouseEnter(icons, { relatedTarget: host });
|
|
546
559
|
|
|
547
560
|
const dragButton = within(overlay).getByLabelText('toolbar-drag');
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
561
|
+
act(() => {
|
|
562
|
+
dragButton.ownerDocument.dispatchEvent(
|
|
563
|
+
new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
|
|
564
|
+
detail: { active: true, modelUid: model.uid },
|
|
565
|
+
}),
|
|
566
|
+
);
|
|
567
|
+
});
|
|
553
568
|
fireEvent.mouseLeave(icons, { relatedTarget: document.createElement('div') });
|
|
554
569
|
|
|
555
570
|
await waitFor(() => {
|
|
556
571
|
expect(queryOverlay(appContainer, 'drag-toolbar-model')?.className).toContain('nb-toolbar-visible');
|
|
557
572
|
});
|
|
558
573
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
574
|
+
act(() => {
|
|
575
|
+
dragButton.ownerDocument.dispatchEvent(
|
|
576
|
+
new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
|
|
577
|
+
detail: { active: false, modelUid: model.uid },
|
|
578
|
+
}),
|
|
579
|
+
);
|
|
580
|
+
});
|
|
564
581
|
|
|
565
582
|
await waitFor(() => {
|
|
566
583
|
expect(queryOverlay(appContainer, 'drag-toolbar-model')).toBeNull();
|
|
567
584
|
});
|
|
568
585
|
});
|
|
569
586
|
|
|
587
|
+
it('does not let resize handle drag release close the surrounding modal wrap', async () => {
|
|
588
|
+
const engine = new FlowEngine();
|
|
589
|
+
await engine.flowSettings.forceEnable();
|
|
590
|
+
const parentModel = createModel(engine, 'resize-parent-model');
|
|
591
|
+
const model = createModel(engine, 'resize-child-model');
|
|
592
|
+
model.setParent(parentModel);
|
|
593
|
+
const appContainer = createAppContainer();
|
|
594
|
+
const modalWrap = createPopupRoot('ant-modal-wrap');
|
|
595
|
+
const onModalWrapClick = vi.fn();
|
|
596
|
+
modalWrap.addEventListener('click', onModalWrapClick);
|
|
597
|
+
appContainer.appendChild(modalWrap);
|
|
598
|
+
mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
|
|
599
|
+
mockRect(modalWrap, { top: 100, left: 200, width: 640, height: 520 });
|
|
600
|
+
|
|
601
|
+
const { getByTestId } = renderWithProviders(
|
|
602
|
+
engine,
|
|
603
|
+
<FlowsFloatContextMenu model={model} showDragHandle>
|
|
604
|
+
<div data-testid="resize-content">content</div>
|
|
605
|
+
</FlowsFloatContextMenu>,
|
|
606
|
+
{ container: modalWrap },
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
const host = getHost(getByTestId('resize-content'));
|
|
610
|
+
mockRect(host, { top: 140, left: 280, width: 220, height: 48 });
|
|
611
|
+
|
|
612
|
+
fireEvent.mouseEnter(host);
|
|
613
|
+
|
|
614
|
+
const overlay = await waitFor(() => {
|
|
615
|
+
const nextOverlay = modalWrap.querySelector('[data-model-uid="resize-child-model"]') as HTMLDivElement | null;
|
|
616
|
+
expect(nextOverlay).toBeTruthy();
|
|
617
|
+
return nextOverlay as HTMLDivElement;
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const resizeHandle = overlay.querySelector('.resize-handle-right') as HTMLDivElement;
|
|
621
|
+
expect(resizeHandle).toBeTruthy();
|
|
622
|
+
|
|
623
|
+
fireEvent.mouseDown(resizeHandle, { clientX: 500, clientY: 164 });
|
|
624
|
+
fireEvent.mouseMove(document, { clientX: 460, clientY: 164 });
|
|
625
|
+
fireEvent.mouseUp(modalWrap, { clientX: 460, clientY: 164 });
|
|
626
|
+
fireEvent.click(modalWrap, { clientX: 460, clientY: 164 });
|
|
627
|
+
|
|
628
|
+
expect(onModalWrapClick).not.toHaveBeenCalled();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('emits resize end when resize handles unmount during a drag', async () => {
|
|
632
|
+
const engine = new FlowEngine();
|
|
633
|
+
await engine.flowSettings.forceEnable();
|
|
634
|
+
const parentModel = createModel(engine, 'resize-unmount-parent-model');
|
|
635
|
+
const model = createModel(engine, 'resize-unmount-child-model');
|
|
636
|
+
model.setParent(parentModel);
|
|
637
|
+
const appContainer = createAppContainer();
|
|
638
|
+
const onResizeEnd = vi.fn();
|
|
639
|
+
parentModel.emitter.on('onResizeEnd', onResizeEnd);
|
|
640
|
+
mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
|
|
641
|
+
|
|
642
|
+
const { getByTestId, unmount } = renderWithProviders(
|
|
643
|
+
engine,
|
|
644
|
+
<FlowsFloatContextMenu model={model} showDragHandle>
|
|
645
|
+
<div data-testid="resize-unmount-content">content</div>
|
|
646
|
+
</FlowsFloatContextMenu>,
|
|
647
|
+
{ container: appContainer },
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
const host = getHost(getByTestId('resize-unmount-content'));
|
|
651
|
+
mockRect(host, { top: 140, left: 280, width: 220, height: 48 });
|
|
652
|
+
|
|
653
|
+
fireEvent.mouseEnter(host);
|
|
654
|
+
|
|
655
|
+
const overlay = await waitFor(() => {
|
|
656
|
+
const nextOverlay = appContainer.querySelector(
|
|
657
|
+
'[data-model-uid="resize-unmount-child-model"]',
|
|
658
|
+
) as HTMLDivElement | null;
|
|
659
|
+
expect(nextOverlay).toBeTruthy();
|
|
660
|
+
return nextOverlay as HTMLDivElement;
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
const resizeHandle = overlay.querySelector('.resize-handle-right') as HTMLDivElement;
|
|
664
|
+
expect(resizeHandle).toBeTruthy();
|
|
665
|
+
|
|
666
|
+
fireEvent.mouseDown(resizeHandle, { clientX: 500, clientY: 164 });
|
|
667
|
+
unmount();
|
|
668
|
+
|
|
669
|
+
expect(onResizeEnd).toHaveBeenCalledTimes(1);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('keeps the resize release click suppressed after handles unmount', async () => {
|
|
673
|
+
const engine = new FlowEngine();
|
|
674
|
+
await engine.flowSettings.forceEnable();
|
|
675
|
+
const parentModel = createModel(engine, 'resize-release-unmount-parent-model');
|
|
676
|
+
const model = createModel(engine, 'resize-release-unmount-child-model');
|
|
677
|
+
model.setParent(parentModel);
|
|
678
|
+
const appContainer = createAppContainer();
|
|
679
|
+
const modalWrap = createPopupRoot('ant-modal-wrap');
|
|
680
|
+
const onModalWrapClick = vi.fn();
|
|
681
|
+
modalWrap.addEventListener('click', onModalWrapClick);
|
|
682
|
+
appContainer.appendChild(modalWrap);
|
|
683
|
+
mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
|
|
684
|
+
mockRect(modalWrap, { top: 100, left: 200, width: 640, height: 520 });
|
|
685
|
+
|
|
686
|
+
const { getByTestId, unmount } = renderWithProviders(
|
|
687
|
+
engine,
|
|
688
|
+
<FlowsFloatContextMenu model={model} showDragHandle>
|
|
689
|
+
<div data-testid="resize-release-unmount-content">content</div>
|
|
690
|
+
</FlowsFloatContextMenu>,
|
|
691
|
+
{ container: modalWrap },
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
const host = getHost(getByTestId('resize-release-unmount-content'));
|
|
695
|
+
mockRect(host, { top: 140, left: 280, width: 220, height: 48 });
|
|
696
|
+
|
|
697
|
+
fireEvent.mouseEnter(host);
|
|
698
|
+
|
|
699
|
+
const overlay = await waitFor(() => {
|
|
700
|
+
const nextOverlay = modalWrap.querySelector(
|
|
701
|
+
'[data-model-uid="resize-release-unmount-child-model"]',
|
|
702
|
+
) as HTMLDivElement | null;
|
|
703
|
+
expect(nextOverlay).toBeTruthy();
|
|
704
|
+
return nextOverlay as HTMLDivElement;
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const resizeHandle = overlay.querySelector('.resize-handle-right') as HTMLDivElement;
|
|
708
|
+
expect(resizeHandle).toBeTruthy();
|
|
709
|
+
|
|
710
|
+
fireEvent.mouseDown(resizeHandle, { clientX: 500, clientY: 164 });
|
|
711
|
+
fireEvent.mouseUp(modalWrap, { clientX: 460, clientY: 164 });
|
|
712
|
+
unmount();
|
|
713
|
+
fireEvent.click(modalWrap, { clientX: 460, clientY: 164 });
|
|
714
|
+
|
|
715
|
+
expect(onModalWrapClick).not.toHaveBeenCalled();
|
|
716
|
+
});
|
|
717
|
+
|
|
570
718
|
it('hides parent toolbar when hovering a nested child host', async () => {
|
|
571
719
|
const engine = new FlowEngine();
|
|
572
720
|
await engine.flowSettings.forceEnable();
|
|
@@ -714,6 +862,122 @@ describe('FlowsFloatContextMenu', () => {
|
|
|
714
862
|
});
|
|
715
863
|
});
|
|
716
864
|
|
|
865
|
+
it('restores parent toolbar when stale child activity remains but pointer is on the parent block', async () => {
|
|
866
|
+
const engine = new FlowEngine();
|
|
867
|
+
await engine.flowSettings.forceEnable();
|
|
868
|
+
const parentModel = createModel(engine, 'parent-stale-child-model');
|
|
869
|
+
const childModel = createModel(engine, 'child-stale-model');
|
|
870
|
+
const appContainer = createAppContainer();
|
|
871
|
+
mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
|
|
872
|
+
|
|
873
|
+
const { getByTestId } = renderWithProviders(
|
|
874
|
+
engine,
|
|
875
|
+
<FlowsFloatContextMenu model={parentModel}>
|
|
876
|
+
<div data-testid="parent-content">
|
|
877
|
+
<div data-testid="parent-gap">gap</div>
|
|
878
|
+
<FlowsFloatContextMenu model={childModel}>
|
|
879
|
+
<div data-testid="child-content">child</div>
|
|
880
|
+
</FlowsFloatContextMenu>
|
|
881
|
+
</div>
|
|
882
|
+
</FlowsFloatContextMenu>,
|
|
883
|
+
{ container: appContainer },
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
const parentHost = getHost(getByTestId('parent-content'));
|
|
887
|
+
const childHost = getHost(getByTestId('child-content'));
|
|
888
|
+
const parentGap = getByTestId('parent-gap');
|
|
889
|
+
mockRect(parentHost, { top: 10, left: 10, width: 320, height: 160 });
|
|
890
|
+
mockRect(childHost, { top: 28, left: 36, width: 120, height: 48 });
|
|
891
|
+
|
|
892
|
+
fireEvent.mouseEnter(parentHost);
|
|
893
|
+
|
|
894
|
+
const parentOverlay = await waitFor(() => {
|
|
895
|
+
const nextOverlay = queryOverlay(appContainer, 'parent-stale-child-model');
|
|
896
|
+
expect(nextOverlay).toBeTruthy();
|
|
897
|
+
return nextOverlay as HTMLDivElement;
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
await waitFor(() => {
|
|
901
|
+
expect(within(parentOverlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
act(() => {
|
|
905
|
+
childHost.dispatchEvent(
|
|
906
|
+
new CustomEvent('nb-float-menu-child-activity', {
|
|
907
|
+
bubbles: true,
|
|
908
|
+
detail: { active: true, modelUid: childModel.uid },
|
|
909
|
+
}),
|
|
910
|
+
);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
await waitFor(() => {
|
|
914
|
+
expect(queryOverlay(appContainer, 'parent-stale-child-model')).toBeNull();
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
fireEvent.mouseMove(parentGap);
|
|
918
|
+
|
|
919
|
+
await waitFor(() => {
|
|
920
|
+
const parentOverlayAfterRestore = queryOverlay(appContainer, 'parent-stale-child-model');
|
|
921
|
+
expect(parentOverlayAfterRestore).toBeTruthy();
|
|
922
|
+
expect(parentOverlayAfterRestore?.className).toContain('nb-toolbar-visible');
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('keeps parent toolbar hidden when moving over the parent block while child toolbar is active', async () => {
|
|
927
|
+
const engine = new FlowEngine();
|
|
928
|
+
await engine.flowSettings.forceEnable();
|
|
929
|
+
const parentModel = createModel(engine, 'parent-active-child-model');
|
|
930
|
+
const childModel = createModel(engine, 'child-active-model');
|
|
931
|
+
const appContainer = createAppContainer();
|
|
932
|
+
mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
|
|
933
|
+
|
|
934
|
+
const { getByTestId } = renderWithProviders(
|
|
935
|
+
engine,
|
|
936
|
+
<FlowsFloatContextMenu model={parentModel}>
|
|
937
|
+
<div data-testid="parent-content">
|
|
938
|
+
<div data-testid="parent-gap">gap</div>
|
|
939
|
+
<FlowsFloatContextMenu model={childModel}>
|
|
940
|
+
<div data-testid="child-content">child</div>
|
|
941
|
+
</FlowsFloatContextMenu>
|
|
942
|
+
</div>
|
|
943
|
+
</FlowsFloatContextMenu>,
|
|
944
|
+
{ container: appContainer },
|
|
945
|
+
);
|
|
946
|
+
|
|
947
|
+
const parentHost = getHost(getByTestId('parent-content'));
|
|
948
|
+
const childHost = getHost(getByTestId('child-content'));
|
|
949
|
+
const parentGap = getByTestId('parent-gap');
|
|
950
|
+
mockRect(parentHost, { top: 10, left: 10, width: 320, height: 160 });
|
|
951
|
+
mockRect(childHost, { top: 28, left: 36, width: 120, height: 48 });
|
|
952
|
+
|
|
953
|
+
fireEvent.mouseEnter(parentHost);
|
|
954
|
+
|
|
955
|
+
await waitFor(() => {
|
|
956
|
+
expect(queryOverlay(appContainer, 'parent-active-child-model')).toBeTruthy();
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
fireEvent.mouseEnter(childHost);
|
|
960
|
+
fireEvent.mouseMove(childHost);
|
|
961
|
+
|
|
962
|
+
const childOverlay = await waitFor(() => {
|
|
963
|
+
const nextOverlay = queryOverlay(appContainer, 'child-active-model');
|
|
964
|
+
expect(nextOverlay).toBeTruthy();
|
|
965
|
+
return nextOverlay as HTMLDivElement;
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
await waitFor(() => {
|
|
969
|
+
expect(childOverlay.className).toContain('nb-toolbar-visible');
|
|
970
|
+
expect(queryOverlay(appContainer, 'parent-active-child-model')).toBeNull();
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
fireEvent.mouseMove(parentGap);
|
|
974
|
+
|
|
975
|
+
await waitFor(() => {
|
|
976
|
+
expect(queryOverlay(appContainer, 'child-active-model')?.className).toContain('nb-toolbar-visible');
|
|
977
|
+
expect(queryOverlay(appContainer, 'parent-active-child-model')).toBeNull();
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
|
|
717
981
|
it('treats forked models as distinct float menu instances even when they share the same uid', async () => {
|
|
718
982
|
const engine = new FlowEngine();
|
|
719
983
|
await engine.flowSettings.forceEnable();
|
|
@@ -775,4 +1039,109 @@ describe('FlowsFloatContextMenu', () => {
|
|
|
775
1039
|
expect(firstOverlay.getAttribute('data-model-uid')).toBe(firstInstanceId);
|
|
776
1040
|
expect(secondOverlay.getAttribute('data-model-uid')).toBe(secondInstanceId);
|
|
777
1041
|
});
|
|
1042
|
+
|
|
1043
|
+
it('keeps a forked child toolbar active while dragging one of its toolbar items', async () => {
|
|
1044
|
+
const engine = new FlowEngine();
|
|
1045
|
+
await engine.flowSettings.forceEnable();
|
|
1046
|
+
const parentModel = createModel(engine, 'parent-forked-drag-model');
|
|
1047
|
+
const childMasterModel = new FlowModel({ uid: 'forked-drag-child-model', flowEngine: engine });
|
|
1048
|
+
childMasterModel.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
|
|
1049
|
+
const childFork = childMasterModel.createFork({}, 'drag-card') as FlowModel & { forkId?: string };
|
|
1050
|
+
const childInstanceId = `forked-drag-child-model::${String(childFork.forkId)}`;
|
|
1051
|
+
const appContainer = createAppContainer();
|
|
1052
|
+
mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
|
|
1053
|
+
|
|
1054
|
+
const { getByTestId } = renderWithProviders(
|
|
1055
|
+
engine,
|
|
1056
|
+
<DndProvider>
|
|
1057
|
+
<FlowsFloatContextMenu model={parentModel}>
|
|
1058
|
+
<div data-testid="parent-content">
|
|
1059
|
+
<div data-testid="parent-gap">gap</div>
|
|
1060
|
+
<FlowsFloatContextMenu
|
|
1061
|
+
model={childFork}
|
|
1062
|
+
extraToolbarItems={[
|
|
1063
|
+
{
|
|
1064
|
+
key: 'forked-toolbar-drag',
|
|
1065
|
+
component: ForkedToolbarDragItem,
|
|
1066
|
+
sort: 100,
|
|
1067
|
+
},
|
|
1068
|
+
]}
|
|
1069
|
+
>
|
|
1070
|
+
<div data-testid="forked-child-content">child</div>
|
|
1071
|
+
</FlowsFloatContextMenu>
|
|
1072
|
+
</div>
|
|
1073
|
+
</FlowsFloatContextMenu>
|
|
1074
|
+
</DndProvider>,
|
|
1075
|
+
{ container: appContainer },
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
const parentHost = getHost(getByTestId('parent-content'));
|
|
1079
|
+
const childHost = getHost(getByTestId('forked-child-content'));
|
|
1080
|
+
const parentGap = getByTestId('parent-gap');
|
|
1081
|
+
mockRect(parentHost, { top: 10, left: 10, width: 320, height: 160 });
|
|
1082
|
+
mockRect(childHost, { top: 28, left: 36, width: 120, height: 48 });
|
|
1083
|
+
|
|
1084
|
+
fireEvent.mouseEnter(parentHost);
|
|
1085
|
+
|
|
1086
|
+
await waitFor(() => {
|
|
1087
|
+
expect(queryOverlay(appContainer, 'parent-forked-drag-model')).toBeTruthy();
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
fireEvent.mouseEnter(childHost);
|
|
1091
|
+
fireEvent.mouseMove(childHost);
|
|
1092
|
+
|
|
1093
|
+
const childOverlay = await waitFor(() => {
|
|
1094
|
+
const nextOverlay = queryOverlay(appContainer, childInstanceId);
|
|
1095
|
+
expect(nextOverlay).toBeTruthy();
|
|
1096
|
+
return nextOverlay as HTMLDivElement;
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
await waitFor(() => {
|
|
1100
|
+
expect(queryOverlay(appContainer, 'parent-forked-drag-model')).toBeNull();
|
|
1101
|
+
expect(within(childOverlay).getByLabelText('forked-toolbar-drag')).toBeTruthy();
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
const childIcons = childOverlay.querySelector('.nb-toolbar-container-icons') as HTMLDivElement;
|
|
1105
|
+
fireEvent.mouseLeave(parentHost, { relatedTarget: childIcons });
|
|
1106
|
+
fireEvent.mouseLeave(childHost, { relatedTarget: childIcons });
|
|
1107
|
+
fireEvent.mouseEnter(childIcons, { relatedTarget: childHost });
|
|
1108
|
+
|
|
1109
|
+
const dragButton = within(childOverlay).getByLabelText('forked-toolbar-drag');
|
|
1110
|
+
const dragActivityListener = vi.fn();
|
|
1111
|
+
dragButton.ownerDocument.addEventListener(TOOLBAR_DRAG_ACTIVITY_EVENT, dragActivityListener);
|
|
1112
|
+
fireEvent.pointerDown(dragButton.parentElement as HTMLElement, { button: 0, clientX: 48, clientY: 36 });
|
|
1113
|
+
|
|
1114
|
+
expect(dragActivityListener).toHaveBeenCalledWith(
|
|
1115
|
+
expect.objectContaining({
|
|
1116
|
+
detail: expect.objectContaining({ active: true, modelUid: childInstanceId }),
|
|
1117
|
+
}),
|
|
1118
|
+
);
|
|
1119
|
+
dragButton.ownerDocument.removeEventListener(TOOLBAR_DRAG_ACTIVITY_EVENT, dragActivityListener);
|
|
1120
|
+
|
|
1121
|
+
vi.useFakeTimers();
|
|
1122
|
+
const querySelectorAllSpy = vi.spyOn(parentGap.ownerDocument, 'querySelectorAll');
|
|
1123
|
+
|
|
1124
|
+
try {
|
|
1125
|
+
fireEvent.mouseLeave(childIcons, { relatedTarget: parentGap });
|
|
1126
|
+
fireEvent.mouseEnter(parentHost, { relatedTarget: childIcons });
|
|
1127
|
+
fireEvent.mouseEnter(parentGap, { relatedTarget: childIcons });
|
|
1128
|
+
fireEvent.mouseMove(parentGap);
|
|
1129
|
+
|
|
1130
|
+
expect(querySelectorAllSpy).not.toHaveBeenCalled();
|
|
1131
|
+
|
|
1132
|
+
await act(async () => {
|
|
1133
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
1134
|
+
});
|
|
1135
|
+
} finally {
|
|
1136
|
+
querySelectorAllSpy.mockRestore();
|
|
1137
|
+
vi.useRealTimers();
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
await waitFor(() => {
|
|
1141
|
+
const childOverlayAfterDrag = queryOverlay(appContainer, childInstanceId);
|
|
1142
|
+
expect(childOverlayAfterDrag).toBeTruthy();
|
|
1143
|
+
expect(childOverlayAfterDrag?.className).toContain('nb-toolbar-visible');
|
|
1144
|
+
expect(queryOverlay(appContainer, 'parent-forked-drag-model')).toBeNull();
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
778
1147
|
});
|
|
@@ -47,6 +47,25 @@ const getToolbarModelUidFromTarget = (target: EventTarget | null): string | null
|
|
|
47
47
|
return target.closest('.nb-toolbar-container[data-model-uid]')?.getAttribute('data-model-uid') || null;
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
+
const escapeCssAttributeValue = (value: string): string => {
|
|
51
|
+
return value
|
|
52
|
+
.replace(/\\/g, '\\\\')
|
|
53
|
+
.replace(/"/g, '\\"')
|
|
54
|
+
.replace(/\n/g, '\\a ')
|
|
55
|
+
.replace(/\r/g, '\\d ')
|
|
56
|
+
.replace(/\f/g, '\\c ');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const isToolbarModelUidRendered = (ownerDocument: Document | null, modelUid: string): boolean => {
|
|
60
|
+
if (!ownerDocument) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return !!ownerDocument.querySelector<HTMLElement>(
|
|
65
|
+
`.nb-toolbar-container[data-model-uid="${escapeCssAttributeValue(modelUid)}"]`,
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
50
69
|
const isNodeWithinDescendantFloatToolbar = (
|
|
51
70
|
target: EventTarget | null,
|
|
52
71
|
container: HTMLElement | null,
|
|
@@ -264,6 +283,15 @@ export const useFloatToolbarVisibility = ({
|
|
|
264
283
|
|
|
265
284
|
if (isCurrentHostTarget) {
|
|
266
285
|
clearHideToolbarTimer();
|
|
286
|
+
setActiveChildToolbarIds((prevIds) => {
|
|
287
|
+
if (!prevIds.length) {
|
|
288
|
+
return prevIds;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const ownerDocument = containerRef.current?.ownerDocument ?? null;
|
|
292
|
+
const nextIds = prevIds.filter((id) => isToolbarModelUidRendered(ownerDocument, id));
|
|
293
|
+
return nextIds.length === prevIds.length ? prevIds : nextIds;
|
|
294
|
+
});
|
|
267
295
|
setHostHovered(true);
|
|
268
296
|
}
|
|
269
297
|
|