@nocobase/flow-engine 2.2.0-beta.6 → 2.2.0-beta.8
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/MobilePopup.style.js +16 -5
- 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/MobilePopup.style.ts +22 -6
- package/src/components/__tests__/MobilePopup.style.test.tsx +103 -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/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
|
@@ -11,6 +11,7 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
|
|
11
11
|
import { createPortal } from 'react-dom';
|
|
12
12
|
import { Alert, Space } from 'antd';
|
|
13
13
|
import { css } from '@emotion/css';
|
|
14
|
+
import { useMemoizedFn } from 'ahooks';
|
|
14
15
|
import { FlowModel } from '../../../../models';
|
|
15
16
|
import { ToolbarItemConfig } from '../../../../types';
|
|
16
17
|
import { useFlowModelById } from '../../../../hooks';
|
|
@@ -394,6 +395,49 @@ const isModelByIdProps = (props: FlowsFloatContextMenuProps): props is ModelById
|
|
|
394
395
|
return 'uid' in props && 'modelClassName' in props && Boolean(props.uid) && Boolean(props.modelClassName);
|
|
395
396
|
};
|
|
396
397
|
|
|
398
|
+
const stopResizeInteractionEvent = (event?: MouseEvent | React.MouseEvent) => {
|
|
399
|
+
if (!event) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
event.preventDefault();
|
|
404
|
+
event.stopPropagation();
|
|
405
|
+
|
|
406
|
+
const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event;
|
|
407
|
+
nativeEvent.stopImmediatePropagation?.();
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const pendingResizeClickSuppressions = new WeakMap<
|
|
411
|
+
Document,
|
|
412
|
+
{ listener: (event: MouseEvent) => void; timer: number }
|
|
413
|
+
>();
|
|
414
|
+
|
|
415
|
+
const clearPendingResizeClickSuppression = (ownerDocument: Document) => {
|
|
416
|
+
const pending = pendingResizeClickSuppressions.get(ownerDocument);
|
|
417
|
+
if (!pending) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
ownerDocument.removeEventListener('click', pending.listener, true);
|
|
422
|
+
(ownerDocument.defaultView || window).clearTimeout(pending.timer);
|
|
423
|
+
pendingResizeClickSuppressions.delete(ownerDocument);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const suppressNextResizeClick = (ownerDocument: Document) => {
|
|
427
|
+
clearPendingResizeClickSuppression(ownerDocument);
|
|
428
|
+
|
|
429
|
+
const listener = (event: MouseEvent) => {
|
|
430
|
+
stopResizeInteractionEvent(event);
|
|
431
|
+
clearPendingResizeClickSuppression(ownerDocument);
|
|
432
|
+
};
|
|
433
|
+
const timer = (ownerDocument.defaultView || window).setTimeout(() => {
|
|
434
|
+
clearPendingResizeClickSuppression(ownerDocument);
|
|
435
|
+
}, 0);
|
|
436
|
+
|
|
437
|
+
pendingResizeClickSuppressions.set(ownerDocument, { listener, timer });
|
|
438
|
+
ownerDocument.addEventListener('click', listener, true);
|
|
439
|
+
};
|
|
440
|
+
|
|
397
441
|
/**
|
|
398
442
|
* FlowsFloatContextMenu组件 - 悬浮配置工具栏组件
|
|
399
443
|
*
|
|
@@ -442,55 +486,80 @@ const ResizeHandles: React.FC<{
|
|
|
442
486
|
const isDraggingRef = useRef<boolean>(false);
|
|
443
487
|
const dragTypeRef = useRef<'left' | 'right' | null>(null);
|
|
444
488
|
const dragStartPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
489
|
+
const dragOwnerDocumentRef = useRef<Document | null>(null);
|
|
445
490
|
const { onDragStart, onDragEnd } = props;
|
|
446
491
|
|
|
447
492
|
// 把拖拽位移转成上层已约定的 resize 事件。
|
|
448
|
-
const handleDragMove =
|
|
449
|
-
(
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
)
|
|
493
|
+
const handleDragMove = useMemoizedFn((e: MouseEvent) => {
|
|
494
|
+
if (!isDraggingRef.current || !dragTypeRef.current) return;
|
|
495
|
+
|
|
496
|
+
stopResizeInteractionEvent(e);
|
|
497
|
+
const deltaX = e.clientX - dragStartPosRef.current.x;
|
|
498
|
+
|
|
499
|
+
switch (dragTypeRef.current) {
|
|
500
|
+
case 'left':
|
|
501
|
+
props.model.parent.emitter.emit('onResizeLeft', { resizeDistance: -deltaX, model: props.model });
|
|
502
|
+
break;
|
|
503
|
+
case 'right':
|
|
504
|
+
props.model.parent.emitter.emit('onResizeRight', { resizeDistance: deltaX, model: props.model });
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const handleDragEnd = useMemoizedFn((e?: MouseEvent) => {
|
|
510
|
+
if (!isDraggingRef.current) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
stopResizeInteractionEvent(e);
|
|
515
|
+
const ownerDocument = dragOwnerDocumentRef.current || document;
|
|
516
|
+
|
|
517
|
+
ownerDocument.removeEventListener('mousemove', handleDragMove, true);
|
|
518
|
+
ownerDocument.removeEventListener('mouseup', handleDragEnd, true);
|
|
519
|
+
suppressNextResizeClick(ownerDocument);
|
|
465
520
|
|
|
466
|
-
const handleDragEnd = useCallback(() => {
|
|
467
521
|
isDraggingRef.current = false;
|
|
468
522
|
dragTypeRef.current = null;
|
|
469
523
|
dragStartPosRef.current = { x: 0, y: 0 };
|
|
470
|
-
|
|
471
|
-
document.removeEventListener('mousemove', handleDragMove);
|
|
472
|
-
document.removeEventListener('mouseup', handleDragEnd);
|
|
524
|
+
dragOwnerDocumentRef.current = null;
|
|
473
525
|
|
|
474
526
|
props.model.parent.emitter.emit('onResizeEnd');
|
|
475
527
|
onDragEnd?.();
|
|
476
|
-
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
useEffect(() => {
|
|
531
|
+
return () => {
|
|
532
|
+
const dragOwnerDocument = dragOwnerDocumentRef.current;
|
|
533
|
+
dragOwnerDocument?.removeEventListener('mousemove', handleDragMove, true);
|
|
534
|
+
dragOwnerDocument?.removeEventListener('mouseup', handleDragEnd, true);
|
|
535
|
+
|
|
536
|
+
if (isDraggingRef.current) {
|
|
537
|
+
suppressNextResizeClick(dragOwnerDocument || document);
|
|
538
|
+
props.model.parent.emitter.emit('onResizeEnd');
|
|
539
|
+
onDragEnd?.();
|
|
540
|
+
}
|
|
477
541
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
542
|
+
isDraggingRef.current = false;
|
|
543
|
+
dragTypeRef.current = null;
|
|
544
|
+
dragStartPosRef.current = { x: 0, y: 0 };
|
|
545
|
+
dragOwnerDocumentRef.current = null;
|
|
546
|
+
};
|
|
547
|
+
}, [handleDragMove, handleDragEnd, onDragEnd, props.model]);
|
|
482
548
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
549
|
+
const handleDragStart = useMemoizedFn((e: React.MouseEvent, type: 'left' | 'right') => {
|
|
550
|
+
stopResizeInteractionEvent(e);
|
|
551
|
+
const ownerDocument = e.currentTarget.ownerDocument;
|
|
486
552
|
|
|
487
|
-
|
|
488
|
-
|
|
553
|
+
isDraggingRef.current = true;
|
|
554
|
+
dragTypeRef.current = type;
|
|
555
|
+
dragStartPosRef.current = { x: e.clientX, y: e.clientY };
|
|
556
|
+
dragOwnerDocumentRef.current = ownerDocument;
|
|
489
557
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
558
|
+
ownerDocument.addEventListener('mousemove', handleDragMove, true);
|
|
559
|
+
ownerDocument.addEventListener('mouseup', handleDragEnd, true);
|
|
560
|
+
|
|
561
|
+
onDragStart?.();
|
|
562
|
+
});
|
|
494
563
|
|
|
495
564
|
return (
|
|
496
565
|
<>
|
|
@@ -601,6 +670,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
601
670
|
getPopupContainer,
|
|
602
671
|
handleSettingsMenuOpenChange,
|
|
603
672
|
model,
|
|
673
|
+
modelUid,
|
|
604
674
|
settingsMenuLevel,
|
|
605
675
|
showCopyUidButton,
|
|
606
676
|
showDeleteButton,
|
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
|
|