@nocobase/flow-engine 2.1.10 → 2.1.12
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/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/flowContext.d.ts +1 -1
- package/lib/flowContext.js +32 -8
- 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/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 -0
- package/lib/utils/index.js +11 -0
- 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/views/ViewNavigation.js +5 -0
- package/package.json +4 -4
- package/src/__tests__/flowContext.test.ts +131 -0
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
- package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
- 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/flowContext.ts +45 -8
- 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/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 -0
- package/src/utils/openViewRouteState.ts +107 -0
- package/src/utils/parsePathnameToViewParams.ts +23 -1
- package/src/views/ViewNavigation.ts +6 -1
- package/src/views/__tests__/ViewNavigation.test.ts +15 -0
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
|
|
package/src/flowContext.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { ISchema } from '@formily/json-schema';
|
|
11
11
|
import { observable } from '@formily/reactive';
|
|
12
|
-
import { APIClient, RequestOptions } from '@nocobase/sdk';
|
|
12
|
+
import type { APIClient, RequestOptions } from '@nocobase/sdk';
|
|
13
13
|
import type { Router } from '@remix-run/router';
|
|
14
14
|
import axios from 'axios';
|
|
15
15
|
import { MessageInstance } from 'antd/es/message/interface';
|
|
@@ -39,9 +39,11 @@ import {
|
|
|
39
39
|
extractUsedVariablePaths,
|
|
40
40
|
FlowExitException,
|
|
41
41
|
FLOW_ENGINE_NAMESPACE,
|
|
42
|
+
createOpenViewRouteState,
|
|
42
43
|
isCtxDatePathPrefix,
|
|
43
44
|
isCssFile,
|
|
44
45
|
prepareRunJsCode,
|
|
46
|
+
RUNJS_OPEN_VIEW_ROUTE_STATE,
|
|
45
47
|
resolveCtxDatePath,
|
|
46
48
|
resolveDefaultParams,
|
|
47
49
|
resolveExpressions,
|
|
@@ -51,6 +53,7 @@ import { FlowExitAllException } from './utils/exceptions';
|
|
|
51
53
|
import { enqueueVariablesResolve, JSONValue } from './utils/params-resolvers';
|
|
52
54
|
import type { RecordRef } from './utils/serverContextParams';
|
|
53
55
|
import { buildServerContextParams as _buildServerContextParams } from './utils/serverContextParams';
|
|
56
|
+
import { getDirtyAwareApiClient } from './utils/dirtyAwareApiClient';
|
|
54
57
|
import { inferRecordRef } from './utils/variablesParams';
|
|
55
58
|
import { FlowView, FlowViewer } from './views/FlowView';
|
|
56
59
|
import { RunJSContextRegistry, getModelClassName, type RunJSVersion } from './runjs-context/registry';
|
|
@@ -2909,19 +2912,20 @@ export class FlowContext {
|
|
|
2909
2912
|
|
|
2910
2913
|
// 静态值
|
|
2911
2914
|
if ('value' in options) {
|
|
2912
|
-
return options.value;
|
|
2915
|
+
return key === 'api' ? getDirtyAwareApiClient(options.value, currentContext) : options.value;
|
|
2913
2916
|
}
|
|
2914
2917
|
|
|
2915
2918
|
// get 方法
|
|
2916
2919
|
if (options.get) {
|
|
2917
2920
|
if (options.cache === false) {
|
|
2918
|
-
|
|
2921
|
+
const value = options.get(currentContext);
|
|
2922
|
+
return key === 'api' ? getDirtyAwareApiClient(value, currentContext) : value;
|
|
2919
2923
|
}
|
|
2920
2924
|
|
|
2921
2925
|
const cacheKey = options.observable ? '_observableCache' : '_cache';
|
|
2922
2926
|
|
|
2923
2927
|
if (key in this[cacheKey]) {
|
|
2924
|
-
return this[cacheKey][key];
|
|
2928
|
+
return key === 'api' ? getDirtyAwareApiClient(this[cacheKey][key], currentContext) : this[cacheKey][key];
|
|
2925
2929
|
}
|
|
2926
2930
|
|
|
2927
2931
|
if (this._pending[key]) return this._pending[key];
|
|
@@ -2939,7 +2943,7 @@ export class FlowContext {
|
|
|
2939
2943
|
(v) => {
|
|
2940
2944
|
this[cacheKey][key] = v;
|
|
2941
2945
|
delete this._pending[key];
|
|
2942
|
-
return v;
|
|
2946
|
+
return key === 'api' ? getDirtyAwareApiClient(v, currentContext) : v;
|
|
2943
2947
|
},
|
|
2944
2948
|
(err) => {
|
|
2945
2949
|
delete this._pending[key];
|
|
@@ -2951,7 +2955,7 @@ export class FlowContext {
|
|
|
2951
2955
|
|
|
2952
2956
|
// sync 直接缓存
|
|
2953
2957
|
this[cacheKey][key] = result;
|
|
2954
|
-
return result;
|
|
2958
|
+
return key === 'api' ? getDirtyAwareApiClient(result, currentContext) : result;
|
|
2955
2959
|
}
|
|
2956
2960
|
|
|
2957
2961
|
return undefined;
|
|
@@ -3074,7 +3078,7 @@ class BaseFlowEngineContext extends FlowContext {
|
|
|
3074
3078
|
this.defineMethod('getModel', (modelName: string, searchInPreviousEngines?: boolean) => {
|
|
3075
3079
|
return this.engine.getModel(modelName, searchInPreviousEngines);
|
|
3076
3080
|
});
|
|
3077
|
-
this.defineMethod('request', (options: RequestOptions)
|
|
3081
|
+
this.defineMethod('request', function (this: FlowContext, options: RequestOptions) {
|
|
3078
3082
|
const app = this.app as { getApiUrl?: (pathname?: string) => string } | undefined;
|
|
3079
3083
|
if (typeof options?.url === 'string' && shouldBypassApiClient(options.url, app)) {
|
|
3080
3084
|
return axios.request(options);
|
|
@@ -3429,7 +3433,19 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3429
3433
|
}),
|
|
3430
3434
|
});
|
|
3431
3435
|
this.defineProperty('role', {
|
|
3432
|
-
get: () =>
|
|
3436
|
+
get: () => {
|
|
3437
|
+
const currentRole = this.api?.auth?.role;
|
|
3438
|
+
if (currentRole !== '__union__') {
|
|
3439
|
+
return currentRole;
|
|
3440
|
+
}
|
|
3441
|
+
const roles = this.user?.roles;
|
|
3442
|
+
if (!Array.isArray(roles)) {
|
|
3443
|
+
return [];
|
|
3444
|
+
}
|
|
3445
|
+
return roles
|
|
3446
|
+
.map((role: { name?: string }) => role?.name)
|
|
3447
|
+
.filter((name: string | undefined): name is string => !!name);
|
|
3448
|
+
},
|
|
3433
3449
|
cache: false,
|
|
3434
3450
|
// 注意:使用惰性 meta 工厂,避免在 i18n 尚未注入时提前求值导致无法翻译
|
|
3435
3451
|
meta: Object.assign(() => ({ type: 'string', title: this.t('Current role'), sort: 990 }), {
|
|
@@ -4578,6 +4594,27 @@ export class FlowRunJSContext extends FlowContext {
|
|
|
4578
4594
|
this.defineProperty('ReactDOM', { value: ReactDOMShim });
|
|
4579
4595
|
|
|
4580
4596
|
setupRunJSLibs(this);
|
|
4597
|
+
this.defineMethod('openView', async function (uid: string, options?: Record<PropertyKey, unknown>) {
|
|
4598
|
+
const delegateOpenView = (
|
|
4599
|
+
delegate as FlowContext & {
|
|
4600
|
+
openView?: (uid: string, options?: Record<PropertyKey, unknown>) => Promise<unknown>;
|
|
4601
|
+
}
|
|
4602
|
+
).openView;
|
|
4603
|
+
|
|
4604
|
+
if (typeof delegateOpenView !== 'function') {
|
|
4605
|
+
throw new Error('ctx.openView is not available in current context.');
|
|
4606
|
+
}
|
|
4607
|
+
|
|
4608
|
+
const routeState = createOpenViewRouteState(options);
|
|
4609
|
+
if (!routeState) {
|
|
4610
|
+
return delegateOpenView(uid, options);
|
|
4611
|
+
}
|
|
4612
|
+
|
|
4613
|
+
return delegateOpenView(uid, {
|
|
4614
|
+
...(options || {}),
|
|
4615
|
+
[RUNJS_OPEN_VIEW_ROUTE_STATE]: routeState,
|
|
4616
|
+
});
|
|
4617
|
+
});
|
|
4581
4618
|
|
|
4582
4619
|
// Convenience: ctx.render(<App />[, container])
|
|
4583
4620
|
// - container defaults to ctx.element if available
|
package/src/locale/en-US.json
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"Replace current block with template?": "Replace current block with template?",
|
|
57
57
|
"Replaced with template block": "Replaced with template block",
|
|
58
58
|
"Render failed": "Render failed",
|
|
59
|
+
"Response record": "Response record",
|
|
59
60
|
"Step configuration": "Step configuration",
|
|
60
61
|
"Step parameter configuration": "Step parameter configuration",
|
|
61
62
|
"Step with key {{stepKey}} not found": "Step with key {{stepKey}} not found",
|
package/src/locale/zh-CN.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { APIClient } from '@nocobase/sdk';
|
|
11
11
|
import { FlowContext } from '../flowContext';
|
|
12
|
+
import { getDirtyAwareApiClient } from '../utils/dirtyAwareApiClient';
|
|
12
13
|
import { FlowResource, ResourceError } from './flowResource';
|
|
13
14
|
|
|
14
15
|
export class APIResource<TData = any> extends FlowResource<TData> {
|
|
@@ -33,7 +34,7 @@ export class APIResource<TData = any> extends FlowResource<TData> {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
setAPIClient(api: APIClient) {
|
|
36
|
-
this.api = api;
|
|
37
|
+
this.api = getDirtyAwareApiClient(api, this.context) as APIClient;
|
|
37
38
|
return this;
|
|
38
39
|
}
|
|
39
40
|
|