@nocobase/flow-engine 2.2.0-beta.9 → 2.2.0
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/acl/Acl.d.ts +2 -1
- package/lib/acl/Acl.js +28 -0
- package/lib/components/FlowContextSelector.js +62 -13
- package/lib/components/FormItem.js +11 -7
- package/lib/components/MobilePopup.js +39 -10
- package/lib/components/MobilePopup.style.js +11 -1
- package/lib/components/subModel/LazyDropdown.js +62 -33
- package/lib/components/variables/VariableHybridInput.d.ts +9 -0
- package/lib/components/variables/VariableHybridInput.js +146 -17
- package/lib/components/variables/VariableInput.js +19 -7
- package/lib/components/variables/VariableTag.js +48 -36
- package/lib/components/variables/types.d.ts +21 -0
- package/lib/flowContext.d.ts +12 -1
- package/lib/flowContext.js +47 -6
- package/lib/flowEngine.js +6 -0
- package/lib/flowI18n.js +3 -3
- package/lib/locale/en-US.json +2 -0
- package/lib/locale/index.d.ts +4 -0
- package/lib/locale/zh-CN.json +2 -0
- package/lib/resources/flowResource.js +1 -0
- package/lib/types.d.ts +3 -1
- package/lib/types.js +1 -0
- package/lib/utils/associationObjectVariable.d.ts +10 -0
- package/lib/utils/associationObjectVariable.js +10 -7
- package/lib/utils/dateVariable.d.ts +22 -0
- package/lib/utils/dateVariable.js +123 -16
- package/lib/utils/dirtyAwareApiClient.d.ts +1 -0
- package/lib/utils/dirtyAwareApiClient.js +280 -13
- package/lib/utils/index.d.ts +3 -3
- package/lib/utils/index.js +8 -0
- package/lib/utils/loadedPageCache.d.ts +1 -0
- package/lib/utils/loadedPageCache.js +6 -0
- package/lib/utils/params-resolvers.d.ts +3 -0
- package/lib/utils/params-resolvers.js +10 -0
- package/lib/utils/variablesParams.js +5 -0
- package/lib/views/createViewMeta.d.ts +1 -0
- package/lib/views/createViewMeta.js +53 -22
- package/package.json +4 -4
- package/src/__tests__/createViewMeta.popup.test.ts +84 -1
- package/src/__tests__/flowContext.test.ts +8 -0
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/__tests__/objectVariable.test.ts +6 -1
- package/src/__tests__/runjsFormSubmit.test.ts +138 -0
- package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
- package/src/acl/Acl.tsx +36 -1
- package/src/acl/__tests__/Acl.test.tsx +70 -0
- package/src/components/FlowContextSelector.tsx +73 -12
- package/src/components/FormItem.tsx +12 -7
- package/src/components/MobilePopup.style.ts +12 -1
- package/src/components/MobilePopup.tsx +42 -10
- package/src/components/__tests__/FormItem.test.tsx +17 -2
- package/src/components/__tests__/MobilePopup.test.tsx +150 -0
- package/src/components/subModel/LazyDropdown.tsx +71 -38
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- package/src/components/subModel/__tests__/LazyDropdown.test.tsx +202 -0
- package/src/components/variables/VariableHybridInput.tsx +185 -14
- package/src/components/variables/VariableInput.tsx +32 -7
- package/src/components/variables/VariableTag.tsx +51 -37
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +95 -3
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
- package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
- package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
- package/src/components/variables/types.ts +21 -0
- package/src/flowContext.ts +79 -6
- package/src/flowEngine.ts +6 -0
- package/src/flowI18n.ts +8 -3
- package/src/locale/__tests__/index.test.ts +21 -0
- package/src/locale/en-US.json +2 -0
- package/src/locale/zh-CN.json +2 -0
- package/src/resources/__tests__/flowResource.test.ts +3 -0
- package/src/resources/flowResource.ts +1 -0
- package/src/types.ts +2 -0
- package/src/utils/__tests__/dateVariable.test.ts +57 -4
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +321 -0
- package/src/utils/__tests__/variablesParams.test.ts +28 -1
- package/src/utils/associationObjectVariable.ts +9 -6
- package/src/utils/dateVariable.ts +145 -18
- package/src/utils/dirtyAwareApiClient.ts +348 -13
- package/src/utils/index.ts +17 -2
- package/src/utils/loadedPageCache.ts +7 -0
- package/src/utils/params-resolvers.ts +12 -0
- package/src/utils/variablesParams.ts +10 -0
- package/src/views/createViewMeta.ts +52 -18
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { css } from '@emotion/css';
|
|
11
|
-
import { Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
|
|
12
|
-
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
|
+
import { ConfigProvider, Dropdown, DropdownProps, Empty, Input, InputProps, Spin, theme } from 'antd';
|
|
12
|
+
import React, { FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
13
13
|
import { useFlowEngine } from '../../provider';
|
|
14
14
|
|
|
15
15
|
// ==================== Types ====================
|
|
@@ -69,16 +69,6 @@ interface ExtendedMenuInfo {
|
|
|
69
69
|
|
|
70
70
|
// ==================== Custom Hooks ====================
|
|
71
71
|
|
|
72
|
-
/**
|
|
73
|
-
* 计算合适的下拉菜单最大高度
|
|
74
|
-
*/
|
|
75
|
-
const useNiceDropdownMaxHeight = () => {
|
|
76
|
-
return useMemo(() => {
|
|
77
|
-
const maxHeight = Math.min(window.innerHeight * 0.6, 400);
|
|
78
|
-
return maxHeight;
|
|
79
|
-
}, []);
|
|
80
|
-
};
|
|
81
|
-
|
|
82
72
|
/**
|
|
83
73
|
* 处理异步菜单项加载的逻辑
|
|
84
74
|
*/
|
|
@@ -484,13 +474,10 @@ const createSearchItem = (
|
|
|
484
474
|
if (shouldActivateSearchSubmenu) {
|
|
485
475
|
activateSearchSubmenu(searchKey);
|
|
486
476
|
}
|
|
487
|
-
if ((e.nativeEvent as
|
|
477
|
+
if ((e.nativeEvent as InputEvent).isComposing || searchHandlers.isComposing(searchKey)) {
|
|
488
478
|
searchHandlers.updateInputValue(searchKey, value);
|
|
489
479
|
return;
|
|
490
480
|
}
|
|
491
|
-
if (!value && shouldActivateSearchSubmenu) {
|
|
492
|
-
deactivateSearchSubmenu(searchKey);
|
|
493
|
-
}
|
|
494
481
|
searchHandlers.updateSearchValue(searchKey, value);
|
|
495
482
|
}}
|
|
496
483
|
onCompositionStart={(e) => {
|
|
@@ -504,11 +491,7 @@ const createSearchItem = (
|
|
|
504
491
|
e.stopPropagation();
|
|
505
492
|
const value = e.currentTarget.value;
|
|
506
493
|
if (shouldActivateSearchSubmenu) {
|
|
507
|
-
|
|
508
|
-
activateSearchSubmenu(searchKey);
|
|
509
|
-
} else {
|
|
510
|
-
deactivateSearchSubmenu(searchKey);
|
|
511
|
-
}
|
|
494
|
+
activateSearchSubmenu(searchKey);
|
|
512
495
|
}
|
|
513
496
|
searchHandlers.endComposition(searchKey, value);
|
|
514
497
|
}}
|
|
@@ -516,11 +499,21 @@ const createSearchItem = (
|
|
|
516
499
|
e.stopPropagation();
|
|
517
500
|
}}
|
|
518
501
|
onKeyDown={(e) => {
|
|
502
|
+
if (e.key === 'Escape' || e.key === 'Tab') {
|
|
503
|
+
deactivateSearchSubmenu(searchKey);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (shouldActivateSearchSubmenu) {
|
|
507
|
+
activateSearchSubmenu(searchKey);
|
|
508
|
+
}
|
|
519
509
|
e.stopPropagation();
|
|
520
510
|
}}
|
|
521
511
|
onMouseDown={(e) => {
|
|
522
512
|
// 防止菜单聚焦丢失或页面滚动
|
|
523
513
|
e.stopPropagation();
|
|
514
|
+
if (shouldActivateSearchSubmenu) {
|
|
515
|
+
activateSearchSubmenu(searchKey);
|
|
516
|
+
}
|
|
524
517
|
}}
|
|
525
518
|
size="small"
|
|
526
519
|
style={{
|
|
@@ -552,7 +545,8 @@ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
|
|
|
552
545
|
|
|
553
546
|
// 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
|
|
554
547
|
const DROPDOWN_PERSIST_TTL_MS = 350;
|
|
555
|
-
const
|
|
548
|
+
const DEFAULT_DROPDOWN_MAX_HEIGHT = 400;
|
|
549
|
+
const MENU_CLOSE_DELAY = 0.3;
|
|
556
550
|
const SUBMENU_MOTION_DISABLED = {
|
|
557
551
|
motionEnter: false,
|
|
558
552
|
motionLeave: false,
|
|
@@ -561,14 +555,23 @@ const dropdownPersistRegistry: Map<string, number> = new Map();
|
|
|
561
555
|
|
|
562
556
|
const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
|
|
563
557
|
const engine = useFlowEngine();
|
|
558
|
+
const { getPrefixCls } = React.useContext(ConfigProvider.ConfigContext);
|
|
559
|
+
const { token } = theme.useToken();
|
|
560
|
+
const triggerId = React.useId();
|
|
561
|
+
const showArrow = Boolean(props.arrow);
|
|
564
562
|
const [menuVisible, setMenuVisible] = useState(false);
|
|
563
|
+
const [dropdownMaxHeight, setDropdownMaxHeight] = useState(DEFAULT_DROPDOWN_MAX_HEIGHT);
|
|
565
564
|
const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
|
|
566
|
-
const [activeSearchKey, setActiveSearchKey] = useState<string | null>(null);
|
|
567
565
|
const [rootItems, setRootItems] = useState<Item[]>([]);
|
|
568
566
|
const [rootLoading, setRootLoading] = useState(false);
|
|
567
|
+
const activeSearchKeyRef = useRef<string | null>(null);
|
|
569
568
|
const closeByOutsideClickRef = useRef(false);
|
|
570
569
|
const skipPreserveActiveSearchRef = useRef(false);
|
|
571
|
-
const
|
|
570
|
+
const triggerOpenClassName = `nb-lazy-dropdown-trigger-${triggerId.replace(/[^a-zA-Z0-9_-]/g, '')}`;
|
|
571
|
+
const defaultOpenClassName = `${getPrefixCls('dropdown', props.prefixCls)}-open`;
|
|
572
|
+
const mergedOpenClassName = [props.openClassName ?? defaultOpenClassName, triggerOpenClassName]
|
|
573
|
+
.filter(Boolean)
|
|
574
|
+
.join(' ');
|
|
572
575
|
const t = engine.translate.bind(engine);
|
|
573
576
|
|
|
574
577
|
// 解构 menu,避免在 effect 中直接依赖整个对象,减少不必要的重跑并满足 exhaustive-deps
|
|
@@ -587,15 +590,37 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
587
590
|
const { requestKeepOpen, shouldPreventClose } = useKeepDropdownOpen();
|
|
588
591
|
useSubmenuStyles(menuVisible, dropdownMaxHeight);
|
|
589
592
|
|
|
593
|
+
useLayoutEffect(() => {
|
|
594
|
+
if (!menuVisible) return;
|
|
595
|
+
|
|
596
|
+
const updateDropdownMaxHeight = () => {
|
|
597
|
+
const trigger = document.querySelector<HTMLElement>(`.${triggerOpenClassName}`);
|
|
598
|
+
if (!trigger) return;
|
|
599
|
+
|
|
600
|
+
const triggerRect = trigger.getBoundingClientRect();
|
|
601
|
+
const placementOffset = token.marginXXS + (showArrow ? token.sizePopupArrow / 2 : 0);
|
|
602
|
+
const reservedSpace = placementOffset + token.marginXXS;
|
|
603
|
+
const availableAbove = triggerRect.top - reservedSpace;
|
|
604
|
+
const availableBelow = window.innerHeight - triggerRect.bottom - reservedSpace;
|
|
605
|
+
const nextMaxHeight = Math.min(DEFAULT_DROPDOWN_MAX_HEIGHT, Math.max(0, availableAbove, availableBelow));
|
|
606
|
+
|
|
607
|
+
setDropdownMaxHeight(nextMaxHeight);
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
updateDropdownMaxHeight();
|
|
611
|
+
window.addEventListener('resize', updateDropdownMaxHeight);
|
|
612
|
+
return () => window.removeEventListener('resize', updateDropdownMaxHeight);
|
|
613
|
+
}, [menuVisible, showArrow, token.marginXXS, token.sizePopupArrow, triggerOpenClassName]);
|
|
614
|
+
|
|
590
615
|
const closeMenu = useCallback(() => {
|
|
591
616
|
setMenuVisible(false);
|
|
592
|
-
|
|
617
|
+
activeSearchKeyRef.current = null;
|
|
593
618
|
setOpenKeys(new Set());
|
|
594
619
|
clearAllSearchValues();
|
|
595
620
|
}, [clearAllSearchValues]);
|
|
596
621
|
|
|
597
622
|
const activateSearchSubmenu = useCallback((key: string) => {
|
|
598
|
-
|
|
623
|
+
activeSearchKeyRef.current = key;
|
|
599
624
|
setOpenKeys((prev) => {
|
|
600
625
|
if (prev.has(key)) return prev;
|
|
601
626
|
const next = new Set(prev);
|
|
@@ -605,11 +630,14 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
605
630
|
}, []);
|
|
606
631
|
|
|
607
632
|
const deactivateSearchSubmenu = useCallback((key: string) => {
|
|
608
|
-
|
|
633
|
+
if (activeSearchKeyRef.current === key) {
|
|
634
|
+
activeSearchKeyRef.current = null;
|
|
635
|
+
}
|
|
609
636
|
}, []);
|
|
610
637
|
|
|
611
638
|
const closeActiveSearchForPath = useCallback(
|
|
612
639
|
(keyPath: string) => {
|
|
640
|
+
const activeSearchKey = activeSearchKeyRef.current;
|
|
613
641
|
if (
|
|
614
642
|
!activeSearchKey ||
|
|
615
643
|
keyPath === activeSearchKey ||
|
|
@@ -621,25 +649,26 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
621
649
|
|
|
622
650
|
skipPreserveActiveSearchRef.current = true;
|
|
623
651
|
clearSearchValue(activeSearchKey);
|
|
624
|
-
|
|
652
|
+
activeSearchKeyRef.current = null;
|
|
625
653
|
setOpenKeys((prev) => {
|
|
626
654
|
const next = new Set(prev);
|
|
627
655
|
next.delete(activeSearchKey);
|
|
628
656
|
return next;
|
|
629
657
|
});
|
|
630
658
|
},
|
|
631
|
-
[
|
|
659
|
+
[clearSearchValue],
|
|
632
660
|
);
|
|
633
661
|
|
|
634
662
|
const handleMenuOpenChange = useCallback(
|
|
635
663
|
(nextOpenKeys: string[]) => {
|
|
636
664
|
let normalized = normalizeOpenKeys(nextOpenKeys);
|
|
637
|
-
|
|
638
|
-
|
|
665
|
+
const activeSearchKey = activeSearchKeyRef.current;
|
|
666
|
+
if (activeSearchKey && !normalized.includes(activeSearchKey)) {
|
|
667
|
+
if (skipPreserveActiveSearchRef.current) {
|
|
639
668
|
clearSearchValue(activeSearchKey);
|
|
640
|
-
|
|
669
|
+
activeSearchKeyRef.current = null;
|
|
641
670
|
} else {
|
|
642
|
-
normalized =
|
|
671
|
+
normalized = Array.from(openKeys);
|
|
643
672
|
}
|
|
644
673
|
}
|
|
645
674
|
|
|
@@ -658,7 +687,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
658
687
|
dropdownMenuProps.onOpenChange?.(normalized);
|
|
659
688
|
skipPreserveActiveSearchRef.current = false;
|
|
660
689
|
},
|
|
661
|
-
[
|
|
690
|
+
[clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
|
|
662
691
|
);
|
|
663
692
|
|
|
664
693
|
useEffect(() => {
|
|
@@ -666,7 +695,9 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
666
695
|
|
|
667
696
|
const markOutsideClick = (event: MouseEvent | PointerEvent) => {
|
|
668
697
|
const target = event.target as HTMLElement | null;
|
|
669
|
-
const
|
|
698
|
+
const isInsidePopup = target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
|
|
699
|
+
const isInsideCurrentTrigger = target?.closest(`.${triggerOpenClassName}`);
|
|
700
|
+
const isOutside = !isInsidePopup && !isInsideCurrentTrigger;
|
|
670
701
|
closeByOutsideClickRef.current = isOutside;
|
|
671
702
|
if (isOutside) {
|
|
672
703
|
closeMenu();
|
|
@@ -679,7 +710,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
679
710
|
document.removeEventListener('pointerdown', markOutsideClick, true);
|
|
680
711
|
document.removeEventListener('mousedown', markOutsideClick, true);
|
|
681
712
|
};
|
|
682
|
-
}, [closeMenu, menuVisible]);
|
|
713
|
+
}, [closeMenu, menuVisible, triggerOpenClassName]);
|
|
683
714
|
|
|
684
715
|
// 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
|
|
685
716
|
useEffect(() => {
|
|
@@ -957,13 +988,15 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
957
988
|
{...props}
|
|
958
989
|
open={menuVisible}
|
|
959
990
|
destroyPopupOnHide
|
|
991
|
+
mouseLeaveDelay={props.mouseLeaveDelay ?? MENU_CLOSE_DELAY}
|
|
992
|
+
openClassName={mergedOpenClassName}
|
|
960
993
|
overlayClassName={overlayClassName}
|
|
961
994
|
placement="bottomLeft"
|
|
962
995
|
menu={{
|
|
963
996
|
...dropdownMenuProps,
|
|
964
997
|
openKeys: Array.from(openKeys),
|
|
965
998
|
items: items,
|
|
966
|
-
subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ??
|
|
999
|
+
subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? MENU_CLOSE_DELAY,
|
|
967
1000
|
motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
|
|
968
1001
|
onClick: () => {},
|
|
969
1002
|
onOpenChange: handleMenuOpenChange,
|
|
@@ -974,7 +1007,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
974
1007
|
},
|
|
975
1008
|
}}
|
|
976
1009
|
onOpenChange={(visible, info) => {
|
|
977
|
-
if (!visible &&
|
|
1010
|
+
if (!visible && activeSearchKeyRef.current && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
|
|
978
1011
|
return;
|
|
979
1012
|
}
|
|
980
1013
|
|
|
@@ -411,7 +411,7 @@ describe('transformItems - searchable flags', () => {
|
|
|
411
411
|
await waitFor(() => expect(screen.getAllByText('No data').length).toBeGreaterThan(0));
|
|
412
412
|
});
|
|
413
413
|
|
|
414
|
-
it('
|
|
414
|
+
it('keeps searchable submenu open while interacting with its input', async () => {
|
|
415
415
|
const engine = new FlowEngine();
|
|
416
416
|
await engine.flowSettings.forceEnable();
|
|
417
417
|
class Parent extends FlowModel {}
|
|
@@ -451,7 +451,11 @@ describe('transformItems - searchable flags', () => {
|
|
|
451
451
|
await user.click(screen.getByRole('textbox'));
|
|
452
452
|
fireEvent.mouseLeave(screen.getByText('Fields'));
|
|
453
453
|
|
|
454
|
-
await
|
|
454
|
+
await act(async () => {
|
|
455
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(screen.getByText('Field 1')).toBeInTheDocument();
|
|
455
459
|
});
|
|
456
460
|
|
|
457
461
|
it('closes active searchable submenu after outside click', async () => {
|
|
@@ -499,6 +503,85 @@ describe('transformItems - searchable flags', () => {
|
|
|
499
503
|
await waitFor(() => expect(screen.queryByText('Fields')).not.toBeInTheDocument());
|
|
500
504
|
});
|
|
501
505
|
|
|
506
|
+
it('keeps the dropdown open when clicking its already-hovered trigger', async () => {
|
|
507
|
+
const engine = new FlowEngine();
|
|
508
|
+
await engine.flowSettings.forceEnable();
|
|
509
|
+
class Parent extends FlowModel {}
|
|
510
|
+
engine.registerModels({ Parent });
|
|
511
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
512
|
+
const user = userEvent.setup();
|
|
513
|
+
|
|
514
|
+
render(
|
|
515
|
+
<FlowEngineProvider engine={engine}>
|
|
516
|
+
<ConfigProvider>
|
|
517
|
+
<App>
|
|
518
|
+
<AddSubModelButton
|
|
519
|
+
model={parent}
|
|
520
|
+
subModelKey="items"
|
|
521
|
+
items={[
|
|
522
|
+
{
|
|
523
|
+
key: 'child',
|
|
524
|
+
label: 'Child',
|
|
525
|
+
createModelOptions: { use: 'Parent' },
|
|
526
|
+
},
|
|
527
|
+
]}
|
|
528
|
+
>
|
|
529
|
+
Open
|
|
530
|
+
</AddSubModelButton>
|
|
531
|
+
</App>
|
|
532
|
+
</ConfigProvider>
|
|
533
|
+
</FlowEngineProvider>,
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const trigger = screen.getByText('Open');
|
|
537
|
+
await user.hover(trigger);
|
|
538
|
+
await waitFor(() => expect(screen.getByText('Child')).toBeInTheDocument());
|
|
539
|
+
expect(trigger).toHaveClass('ant-dropdown-open');
|
|
540
|
+
|
|
541
|
+
await user.click(trigger);
|
|
542
|
+
|
|
543
|
+
expect(screen.getByText('Child')).toBeInTheDocument();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('closes the dropdown when clicking another dropdown trigger', async () => {
|
|
547
|
+
const engine = new FlowEngine();
|
|
548
|
+
await engine.flowSettings.forceEnable();
|
|
549
|
+
class Parent extends FlowModel {}
|
|
550
|
+
engine.registerModels({ Parent });
|
|
551
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
552
|
+
const user = userEvent.setup();
|
|
553
|
+
|
|
554
|
+
render(
|
|
555
|
+
<FlowEngineProvider engine={engine}>
|
|
556
|
+
<ConfigProvider>
|
|
557
|
+
<App>
|
|
558
|
+
<AddSubModelButton
|
|
559
|
+
model={parent}
|
|
560
|
+
subModelKey="firstItems"
|
|
561
|
+
items={[{ key: 'first-child', label: 'First child', createModelOptions: { use: 'Parent' } }]}
|
|
562
|
+
>
|
|
563
|
+
Open first
|
|
564
|
+
</AddSubModelButton>
|
|
565
|
+
<AddSubModelButton
|
|
566
|
+
model={parent}
|
|
567
|
+
subModelKey="secondItems"
|
|
568
|
+
items={[{ key: 'second-child', label: 'Second child', createModelOptions: { use: 'Parent' } }]}
|
|
569
|
+
>
|
|
570
|
+
Open second
|
|
571
|
+
</AddSubModelButton>
|
|
572
|
+
</App>
|
|
573
|
+
</ConfigProvider>
|
|
574
|
+
</FlowEngineProvider>,
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
await user.hover(screen.getByText('Open first'));
|
|
578
|
+
await waitFor(() => expect(screen.getByText('First child')).toBeInTheDocument());
|
|
579
|
+
|
|
580
|
+
fireEvent.pointerDown(screen.getByText('Open second'));
|
|
581
|
+
|
|
582
|
+
await waitFor(() => expect(screen.queryByText('First child')).not.toBeInTheDocument());
|
|
583
|
+
});
|
|
584
|
+
|
|
502
585
|
it('switches away from active searchable submenu and resets its input', async () => {
|
|
503
586
|
const engine = new FlowEngine();
|
|
504
587
|
await engine.flowSettings.forceEnable();
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { act, render, screen, userEvent, waitFor } from '@nocobase/test/client';
|
|
11
|
+
import { ConfigProvider } from 'antd';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import { FlowEngineProvider } from '../../../provider';
|
|
15
|
+
import { FlowEngine } from '../../../flowEngine';
|
|
16
|
+
import LazyDropdown from '../LazyDropdown';
|
|
17
|
+
|
|
18
|
+
const setViewportHeight = (height: number) => {
|
|
19
|
+
Object.defineProperty(window, 'innerHeight', {
|
|
20
|
+
configurable: true,
|
|
21
|
+
value: height,
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('LazyDropdown', () => {
|
|
26
|
+
const originalInnerHeight = window.innerHeight;
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
setViewportHeight(originalInnerHeight);
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('uses the current viewport space when opening after the viewport height changes', async () => {
|
|
34
|
+
setViewportHeight(720);
|
|
35
|
+
const engine = new FlowEngine();
|
|
36
|
+
const user = userEvent.setup();
|
|
37
|
+
|
|
38
|
+
render(
|
|
39
|
+
<FlowEngineProvider engine={engine}>
|
|
40
|
+
<ConfigProvider>
|
|
41
|
+
<LazyDropdown
|
|
42
|
+
trigger={['click']}
|
|
43
|
+
menu={{
|
|
44
|
+
items: [{ key: 'field', label: 'Field' }],
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<button type="button">Open fields</button>
|
|
48
|
+
</LazyDropdown>
|
|
49
|
+
</ConfigProvider>
|
|
50
|
+
</FlowEngineProvider>,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const trigger = screen.getByRole('button', { name: 'Open fields' });
|
|
54
|
+
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
|
|
55
|
+
bottom: 196,
|
|
56
|
+
height: 32,
|
|
57
|
+
left: 49,
|
|
58
|
+
right: 141,
|
|
59
|
+
top: 164,
|
|
60
|
+
width: 92,
|
|
61
|
+
x: 49,
|
|
62
|
+
y: 164,
|
|
63
|
+
toJSON: () => ({}),
|
|
64
|
+
});
|
|
65
|
+
setViewportHeight(460);
|
|
66
|
+
|
|
67
|
+
await user.click(trigger);
|
|
68
|
+
|
|
69
|
+
const menu = await screen.findByRole('menu');
|
|
70
|
+
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '256px', overflowY: 'auto' }));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('updates the available height while the dropdown is open', async () => {
|
|
74
|
+
setViewportHeight(720);
|
|
75
|
+
const engine = new FlowEngine();
|
|
76
|
+
const user = userEvent.setup();
|
|
77
|
+
|
|
78
|
+
render(
|
|
79
|
+
<FlowEngineProvider engine={engine}>
|
|
80
|
+
<ConfigProvider>
|
|
81
|
+
<LazyDropdown
|
|
82
|
+
trigger={['click']}
|
|
83
|
+
menu={{
|
|
84
|
+
items: [{ key: 'field', label: 'Field' }],
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<button type="button">Open fields</button>
|
|
88
|
+
</LazyDropdown>
|
|
89
|
+
</ConfigProvider>
|
|
90
|
+
</FlowEngineProvider>,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const trigger = screen.getByRole('button', { name: 'Open fields' });
|
|
94
|
+
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
|
|
95
|
+
bottom: 196,
|
|
96
|
+
height: 32,
|
|
97
|
+
left: 49,
|
|
98
|
+
right: 141,
|
|
99
|
+
top: 164,
|
|
100
|
+
width: 92,
|
|
101
|
+
x: 49,
|
|
102
|
+
y: 164,
|
|
103
|
+
toJSON: () => ({}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await user.click(trigger);
|
|
107
|
+
|
|
108
|
+
const menu = await screen.findByRole('menu');
|
|
109
|
+
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '400px', overflowY: 'auto' }));
|
|
110
|
+
|
|
111
|
+
act(() => {
|
|
112
|
+
setViewportHeight(460);
|
|
113
|
+
window.dispatchEvent(new Event('resize'));
|
|
114
|
+
});
|
|
115
|
+
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '256px', overflowY: 'auto' }));
|
|
116
|
+
|
|
117
|
+
act(() => {
|
|
118
|
+
setViewportHeight(720);
|
|
119
|
+
window.dispatchEvent(new Event('resize'));
|
|
120
|
+
});
|
|
121
|
+
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '400px', overflowY: 'auto' }));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('reserves the placement offset when the dropdown has an arrow', async () => {
|
|
125
|
+
setViewportHeight(460);
|
|
126
|
+
const engine = new FlowEngine();
|
|
127
|
+
const user = userEvent.setup();
|
|
128
|
+
|
|
129
|
+
render(
|
|
130
|
+
<FlowEngineProvider engine={engine}>
|
|
131
|
+
<ConfigProvider>
|
|
132
|
+
<LazyDropdown
|
|
133
|
+
arrow
|
|
134
|
+
trigger={['click']}
|
|
135
|
+
menu={{
|
|
136
|
+
items: [{ key: 'field', label: 'Field' }],
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<button type="button">Open fields</button>
|
|
140
|
+
</LazyDropdown>
|
|
141
|
+
</ConfigProvider>
|
|
142
|
+
</FlowEngineProvider>,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const trigger = screen.getByRole('button', { name: 'Open fields' });
|
|
146
|
+
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
|
|
147
|
+
bottom: 196,
|
|
148
|
+
height: 32,
|
|
149
|
+
left: 49,
|
|
150
|
+
right: 141,
|
|
151
|
+
top: 164,
|
|
152
|
+
width: 92,
|
|
153
|
+
x: 49,
|
|
154
|
+
y: 164,
|
|
155
|
+
toJSON: () => ({}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await user.click(trigger);
|
|
159
|
+
|
|
160
|
+
const menu = await screen.findByRole('menu');
|
|
161
|
+
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '248px', overflowY: 'auto' }));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('uses the space above when it is larger than the space below', async () => {
|
|
165
|
+
setViewportHeight(460);
|
|
166
|
+
const engine = new FlowEngine();
|
|
167
|
+
const user = userEvent.setup();
|
|
168
|
+
|
|
169
|
+
render(
|
|
170
|
+
<FlowEngineProvider engine={engine}>
|
|
171
|
+
<ConfigProvider>
|
|
172
|
+
<LazyDropdown
|
|
173
|
+
trigger={['click']}
|
|
174
|
+
menu={{
|
|
175
|
+
items: [{ key: 'field', label: 'Field' }],
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
<button type="button">Open fields</button>
|
|
179
|
+
</LazyDropdown>
|
|
180
|
+
</ConfigProvider>
|
|
181
|
+
</FlowEngineProvider>,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const trigger = screen.getByRole('button', { name: 'Open fields' });
|
|
185
|
+
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
|
|
186
|
+
bottom: 332,
|
|
187
|
+
height: 32,
|
|
188
|
+
left: 49,
|
|
189
|
+
right: 141,
|
|
190
|
+
top: 300,
|
|
191
|
+
width: 92,
|
|
192
|
+
x: 49,
|
|
193
|
+
y: 300,
|
|
194
|
+
toJSON: () => ({}),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await user.click(trigger);
|
|
198
|
+
|
|
199
|
+
const menu = await screen.findByRole('menu');
|
|
200
|
+
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '292px', overflowY: 'auto' }));
|
|
201
|
+
});
|
|
202
|
+
});
|