@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.
Files changed (83) hide show
  1. package/lib/acl/Acl.d.ts +2 -1
  2. package/lib/acl/Acl.js +28 -0
  3. package/lib/components/FlowContextSelector.js +62 -13
  4. package/lib/components/FormItem.js +11 -7
  5. package/lib/components/MobilePopup.js +39 -10
  6. package/lib/components/MobilePopup.style.js +11 -1
  7. package/lib/components/subModel/LazyDropdown.js +62 -33
  8. package/lib/components/variables/VariableHybridInput.d.ts +9 -0
  9. package/lib/components/variables/VariableHybridInput.js +146 -17
  10. package/lib/components/variables/VariableInput.js +19 -7
  11. package/lib/components/variables/VariableTag.js +48 -36
  12. package/lib/components/variables/types.d.ts +21 -0
  13. package/lib/flowContext.d.ts +12 -1
  14. package/lib/flowContext.js +47 -6
  15. package/lib/flowEngine.js +6 -0
  16. package/lib/flowI18n.js +3 -3
  17. package/lib/locale/en-US.json +2 -0
  18. package/lib/locale/index.d.ts +4 -0
  19. package/lib/locale/zh-CN.json +2 -0
  20. package/lib/resources/flowResource.js +1 -0
  21. package/lib/types.d.ts +3 -1
  22. package/lib/types.js +1 -0
  23. package/lib/utils/associationObjectVariable.d.ts +10 -0
  24. package/lib/utils/associationObjectVariable.js +10 -7
  25. package/lib/utils/dateVariable.d.ts +22 -0
  26. package/lib/utils/dateVariable.js +123 -16
  27. package/lib/utils/dirtyAwareApiClient.d.ts +1 -0
  28. package/lib/utils/dirtyAwareApiClient.js +280 -13
  29. package/lib/utils/index.d.ts +3 -3
  30. package/lib/utils/index.js +8 -0
  31. package/lib/utils/loadedPageCache.d.ts +1 -0
  32. package/lib/utils/loadedPageCache.js +6 -0
  33. package/lib/utils/params-resolvers.d.ts +3 -0
  34. package/lib/utils/params-resolvers.js +10 -0
  35. package/lib/utils/variablesParams.js +5 -0
  36. package/lib/views/createViewMeta.d.ts +1 -0
  37. package/lib/views/createViewMeta.js +53 -22
  38. package/package.json +4 -4
  39. package/src/__tests__/createViewMeta.popup.test.ts +84 -1
  40. package/src/__tests__/flowContext.test.ts +8 -0
  41. package/src/__tests__/flowI18n.test.ts +11 -0
  42. package/src/__tests__/objectVariable.test.ts +6 -1
  43. package/src/__tests__/runjsFormSubmit.test.ts +138 -0
  44. package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
  45. package/src/acl/Acl.tsx +36 -1
  46. package/src/acl/__tests__/Acl.test.tsx +70 -0
  47. package/src/components/FlowContextSelector.tsx +73 -12
  48. package/src/components/FormItem.tsx +12 -7
  49. package/src/components/MobilePopup.style.ts +12 -1
  50. package/src/components/MobilePopup.tsx +42 -10
  51. package/src/components/__tests__/FormItem.test.tsx +17 -2
  52. package/src/components/__tests__/MobilePopup.test.tsx +150 -0
  53. package/src/components/subModel/LazyDropdown.tsx +71 -38
  54. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
  55. package/src/components/subModel/__tests__/LazyDropdown.test.tsx +202 -0
  56. package/src/components/variables/VariableHybridInput.tsx +185 -14
  57. package/src/components/variables/VariableInput.tsx +32 -7
  58. package/src/components/variables/VariableTag.tsx +51 -37
  59. package/src/components/variables/__tests__/FlowContextSelector.test.tsx +95 -3
  60. package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
  61. package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
  62. package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
  63. package/src/components/variables/types.ts +21 -0
  64. package/src/flowContext.ts +79 -6
  65. package/src/flowEngine.ts +6 -0
  66. package/src/flowI18n.ts +8 -3
  67. package/src/locale/__tests__/index.test.ts +21 -0
  68. package/src/locale/en-US.json +2 -0
  69. package/src/locale/zh-CN.json +2 -0
  70. package/src/resources/__tests__/flowResource.test.ts +3 -0
  71. package/src/resources/flowResource.ts +1 -0
  72. package/src/types.ts +2 -0
  73. package/src/utils/__tests__/dateVariable.test.ts +57 -4
  74. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +321 -0
  75. package/src/utils/__tests__/variablesParams.test.ts +28 -1
  76. package/src/utils/associationObjectVariable.ts +9 -6
  77. package/src/utils/dateVariable.ts +145 -18
  78. package/src/utils/dirtyAwareApiClient.ts +348 -13
  79. package/src/utils/index.ts +17 -2
  80. package/src/utils/loadedPageCache.ts +7 -0
  81. package/src/utils/params-resolvers.ts +12 -0
  82. package/src/utils/variablesParams.ts +10 -0
  83. 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 any)?.isComposing || searchHandlers.isComposing(searchKey)) {
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
- if (value) {
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 SUBMENU_CLOSE_DELAY = 0.05;
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 dropdownMaxHeight = useNiceDropdownMaxHeight();
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
- setActiveSearchKey(null);
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
- setActiveSearchKey(key);
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
- setActiveSearchKey((prev) => (prev === key ? null : prev));
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
- setActiveSearchKey(null);
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
- [activeSearchKey, clearSearchValue],
659
+ [clearSearchValue],
632
660
  );
633
661
 
634
662
  const handleMenuOpenChange = useCallback(
635
663
  (nextOpenKeys: string[]) => {
636
664
  let normalized = normalizeOpenKeys(nextOpenKeys);
637
- if (activeSearchKey && openKeys.has(activeSearchKey) && !normalized.includes(activeSearchKey)) {
638
- if (normalized.length || skipPreserveActiveSearchRef.current) {
665
+ const activeSearchKey = activeSearchKeyRef.current;
666
+ if (activeSearchKey && !normalized.includes(activeSearchKey)) {
667
+ if (skipPreserveActiveSearchRef.current) {
639
668
  clearSearchValue(activeSearchKey);
640
- setActiveSearchKey(null);
669
+ activeSearchKeyRef.current = null;
641
670
  } else {
642
- normalized = [activeSearchKey];
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
- [activeSearchKey, clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
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 isOutside = !target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
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 ?? SUBMENU_CLOSE_DELAY,
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 && activeSearchKey && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
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('closes searchable submenu after focus without input', async () => {
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 waitFor(() => expect(screen.queryByText('Field 1')).not.toBeInTheDocument());
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
+ });