@nocobase/flow-engine 2.2.0-beta.15 → 2.2.0-beta.17

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 (45) 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 +7 -1
  4. package/lib/components/MobilePopup.js +14 -3
  5. package/lib/components/subModel/LazyDropdown.js +41 -26
  6. package/lib/flowContext.d.ts +5 -1
  7. package/lib/flowContext.js +18 -6
  8. package/lib/locale/en-US.json +2 -0
  9. package/lib/locale/index.d.ts +4 -0
  10. package/lib/locale/zh-CN.json +2 -0
  11. package/lib/utils/associationObjectVariable.d.ts +10 -0
  12. package/lib/utils/associationObjectVariable.js +10 -7
  13. package/lib/utils/dateVariable.d.ts +22 -0
  14. package/lib/utils/dateVariable.js +123 -16
  15. package/lib/utils/index.d.ts +3 -3
  16. package/lib/utils/index.js +8 -0
  17. package/lib/utils/params-resolvers.d.ts +3 -0
  18. package/lib/utils/params-resolvers.js +10 -0
  19. package/lib/utils/variablesParams.js +5 -0
  20. package/lib/views/createViewMeta.d.ts +1 -0
  21. package/lib/views/createViewMeta.js +53 -22
  22. package/package.json +4 -4
  23. package/src/__tests__/createViewMeta.popup.test.ts +84 -1
  24. package/src/__tests__/flowContext.test.ts +8 -0
  25. package/src/__tests__/objectVariable.test.ts +6 -1
  26. package/src/acl/Acl.tsx +36 -1
  27. package/src/acl/__tests__/Acl.test.tsx +70 -0
  28. package/src/components/FlowContextSelector.tsx +7 -1
  29. package/src/components/MobilePopup.tsx +16 -4
  30. package/src/components/__tests__/MobilePopup.test.tsx +42 -1
  31. package/src/components/subModel/LazyDropdown.tsx +44 -26
  32. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
  33. package/src/components/variables/__tests__/FlowContextSelector.test.tsx +35 -0
  34. package/src/flowContext.ts +31 -5
  35. package/src/locale/__tests__/index.test.ts +21 -0
  36. package/src/locale/en-US.json +2 -0
  37. package/src/locale/zh-CN.json +2 -0
  38. package/src/utils/__tests__/dateVariable.test.ts +57 -4
  39. package/src/utils/__tests__/variablesParams.test.ts +28 -1
  40. package/src/utils/associationObjectVariable.ts +9 -6
  41. package/src/utils/dateVariable.ts +145 -18
  42. package/src/utils/index.ts +17 -2
  43. package/src/utils/params-resolvers.ts +12 -0
  44. package/src/utils/variablesParams.ts +10 -0
  45. package/src/views/createViewMeta.ts +52 -18
@@ -16,6 +16,7 @@ import { RunJSContextRegistry } from '../runjs-context/registry';
16
16
  import { setupRunJSContexts } from '../runjs-context/setup';
17
17
  import { createViewScopedEngine } from '../ViewScopedFlowEngine';
18
18
  import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
19
+ import { serializeCtxDateExpressionConfig } from '../utils/dateVariable';
19
20
 
20
21
  describe('FlowContext properties and methods', () => {
21
22
  it('should return static property value', () => {
@@ -2068,10 +2069,16 @@ describe('getPropertyMetaTree with deep delegate meta', () => {
2068
2069
  describe('FlowContext resolveOnServer selective server resolution', () => {
2069
2070
  it('resolves ctx.date expressions on client context', async () => {
2070
2071
  const engine = new FlowEngine();
2072
+ const formattedToday = serializeCtxDateExpressionConfig({
2073
+ kind: 'preset',
2074
+ preset: 'today',
2075
+ format: 'YYYY/MM/DD',
2076
+ });
2071
2077
  const out = await (engine.context as any).resolveJsonTemplate({
2072
2078
  today: '{{ ctx.date.preset.today }}',
2073
2079
  next12: '{{ ctx.date.relative.next.day.n12 }}',
2074
2080
  now: '{{ ctx.date.preset.now }}',
2081
+ formattedToday,
2075
2082
  });
2076
2083
 
2077
2084
  expect(typeof out.today).toBe('string');
@@ -2080,6 +2087,7 @@ describe('FlowContext resolveOnServer selective server resolution', () => {
2080
2087
  expect(out.next12).toMatch(/^\d{4}-\d{2}-\d{2}$/);
2081
2088
  expect(typeof out.now).toBe('string');
2082
2089
  expect(out.now.length).toBeGreaterThan(0);
2090
+ expect(out.formattedToday).toMatch(/^\d{4}\/\d{2}\/\d{2}$/);
2083
2091
  });
2084
2092
 
2085
2093
  it('does not call server by default (no resolveOnServer set)', async () => {
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, it, vi } from 'vitest';
11
+ import { generateFlowModelRdFromToken } from '@nocobase/utils/client';
11
12
  import { FlowContext } from '../flowContext';
12
13
  import { FlowEngine } from '../flowEngine';
13
14
  import {
@@ -122,7 +123,10 @@ describe('objectVariable utilities', () => {
122
123
 
123
124
  // Provide API stub to intercept variables:resolve
124
125
  const calls: any[] = [];
126
+ const payload = Buffer.from(JSON.stringify({ userId: 1, signInTime: 'contract-owner-test' })).toString('base64url');
127
+ const token = `test.${payload}.sig`;
125
128
  (ctx as any).api = {
129
+ auth: { token },
126
130
  request: vi.fn(async ({ url, data, method }) => {
127
131
  calls.push({ url, data, method });
128
132
  const batch = (data?.values?.batch as any[]) || [];
@@ -146,13 +150,14 @@ describe('objectVariable utilities', () => {
146
150
  });
147
151
 
148
152
  const template = { x: '{{ ctx.obj.author.name }}' } as any;
149
- await (ctx as any).resolveJsonTemplate(template);
153
+ await (ctx as any).resolveJsonTemplate(template, { contractModelUid: 'form-grid' });
150
154
 
151
155
  // Assert variables:resolve was called with proper flattened contextParams
152
156
  expect((ctx as any).api.request).toHaveBeenCalled();
153
157
  const call = calls.find((c) => c.url === 'variables:resolve');
154
158
  expect(call).toBeTruthy();
155
159
  const batch0 = call.data?.values?.batch?.[0];
160
+ expect(batch0?.contractRd).toBe(generateFlowModelRdFromToken('form-grid', token));
156
161
  expect(batch0?.contextParams).toBeTruthy();
157
162
  // Flattened key should be 'obj.author'
158
163
  const cp = batch0.contextParams as Record<string, any>;
package/src/acl/Acl.tsx CHANGED
@@ -16,7 +16,7 @@ interface CheckOptions {
16
16
  actionName: string;
17
17
  fields?: string[];
18
18
  recordPkValue?: string | number;
19
- allowedActions: any[];
19
+ allowedActions?: Record<string, Array<string | number>>;
20
20
  }
21
21
 
22
22
  export class ACL {
@@ -149,6 +149,41 @@ export class ACL {
149
149
  return allowed;
150
150
  }
151
151
 
152
+ can(options: CheckOptions): boolean {
153
+ const { allowAll } = this.data;
154
+ if (allowAll) {
155
+ return true;
156
+ }
157
+
158
+ const { actionName, allowedActions, recordPkValue } = options;
159
+ const hasRecordPkValue = recordPkValue !== undefined && recordPkValue !== null;
160
+ const recordPermission =
161
+ hasRecordPkValue && allowedActions ? this.verifyScope(actionName, recordPkValue, allowedActions) : null;
162
+ if (hasRecordPkValue && allowedActions && recordPermission !== true) {
163
+ return false;
164
+ }
165
+
166
+ const params = this.parseAction(options);
167
+ if (!params) {
168
+ return false;
169
+ }
170
+ if (!_.isEmpty(params.filter) && recordPermission !== true) {
171
+ return false;
172
+ }
173
+ if (!options.fields?.length) {
174
+ return true;
175
+ }
176
+
177
+ const allowedFields: string[] = []
178
+ .concat(params.whitelist || [])
179
+ .concat(params.fields || [])
180
+ .concat(params.appends || []);
181
+ if (!allowedFields.length) {
182
+ return true;
183
+ }
184
+ return options.fields.every((field) => allowedFields.includes(field));
185
+ }
186
+
152
187
  async aclCheck(options: CheckOptions): Promise<boolean> {
153
188
  // await this.load();
154
189
  const { allowAll } = this.data;
@@ -71,6 +71,76 @@ describe('ACL', () => {
71
71
  expect(notOk).toBe(false);
72
72
  });
73
73
 
74
+ it('checks record update scope before field permission', () => {
75
+ const payload = {
76
+ data: {
77
+ allowAll: false,
78
+ actionAlias: {},
79
+ resources: ['posts'],
80
+ actions: { 'posts:update': { whitelist: ['title'] } },
81
+ strategy: { actions: [] },
82
+ },
83
+ };
84
+ const engine = makeEngine(payload);
85
+ const acl = new ACL(engine);
86
+ acl.setData(payload.data);
87
+
88
+ const options = {
89
+ dataSourceKey: 'main',
90
+ resourceName: 'posts',
91
+ actionName: 'update',
92
+ allowedActions: {
93
+ update: [1],
94
+ },
95
+ };
96
+
97
+ expect(acl.can({ ...options, recordPkValue: 1, fields: ['title'] })).toBe(true);
98
+ expect(acl.can({ ...options, recordPkValue: 2, fields: ['title'] })).toBe(false);
99
+ expect(acl.can({ ...options, recordPkValue: 1, fields: ['body'] })).toBe(false);
100
+ expect(acl.can({ ...options, recordPkValue: 0, fields: ['title'] })).toBe(false);
101
+ expect(
102
+ acl.can({
103
+ dataSourceKey: 'main',
104
+ resourceName: 'posts',
105
+ actionName: 'update',
106
+ fields: ['title'],
107
+ }),
108
+ ).toBe(true);
109
+ });
110
+
111
+ it('allows every field when a scoped action has no field restriction', () => {
112
+ const payload = {
113
+ data: {
114
+ allowAll: false,
115
+ actionAlias: {},
116
+ resources: ['posts'],
117
+ actions: {
118
+ 'posts:update': {
119
+ filter: { createdById: '{{ ctx.state.currentUser.id }}' },
120
+ },
121
+ },
122
+ strategy: { actions: [] },
123
+ },
124
+ };
125
+ const engine = makeEngine(payload);
126
+ const acl = new ACL(engine);
127
+ acl.setData(payload.data);
128
+
129
+ const options = {
130
+ dataSourceKey: 'main',
131
+ resourceName: 'posts',
132
+ actionName: 'update',
133
+ allowedActions: {
134
+ update: [1],
135
+ },
136
+ fields: ['title'],
137
+ };
138
+
139
+ expect(acl.can({ ...options, recordPkValue: 1 })).toBe(true);
140
+ expect(acl.can({ ...options, recordPkValue: 2 })).toBe(false);
141
+ expect(acl.can({ ...options, allowedActions: undefined, recordPkValue: undefined })).toBe(false);
142
+ });
143
+
74
144
  it('reloads permissions when auth token changes', async () => {
75
145
  const payload1 = {
76
146
  data: {
@@ -311,6 +311,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
311
311
  const path = selectedValues.map(String);
312
312
  const pathString = path.join('.');
313
313
  const isLeaf = lastOption?.isLeaf;
314
+ const isSelectable = lastOption?.meta?.selectable !== false;
314
315
  const now = Date.now();
315
316
 
316
317
  // 使用自定义格式化函数或默认函数
@@ -325,6 +326,10 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
325
326
  }
326
327
 
327
328
  if (isLeaf) {
329
+ if (!isSelectable) {
330
+ setTempSelectedPath(path);
331
+ return;
332
+ }
328
333
  onChange?.(formattedValue, lastOption?.meta);
329
334
  // 选中叶子节点后,可清空内部临时路径(外部 value 将驱动级联)
330
335
  setTempSelectedPath([]);
@@ -333,7 +338,8 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
333
338
 
334
339
  // 非叶子节点:检查双击
335
340
  const lastSelected = lastSelectedRef.current;
336
- const isDoubleClick = !onlyLeafSelectable && lastSelected?.path === pathString && now - lastSelected.time < 300;
341
+ const isDoubleClick =
342
+ isSelectable && !onlyLeafSelectable && lastSelected?.path === pathString && now - lastSelected.time < 300;
337
343
 
338
344
  if (isDoubleClick) {
339
345
  // 双击:选中非叶子节点
@@ -26,26 +26,38 @@ interface MobilePopupProps {
26
26
  footer?: ReactNode;
27
27
  }
28
28
 
29
+ const getMobilePopupMaxHeight = () => {
30
+ if (typeof CSS !== 'undefined' && CSS.supports?.('height', '100dvh')) {
31
+ return 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))';
32
+ }
33
+
34
+ return 'calc(100vh - var(--nb-mobile-page-header-height, 46px))';
35
+ };
36
+
29
37
  export const MobilePopup: FC<MobilePopupProps> = (props) => {
30
38
  const { title, visible, onClose: closePopup, children, minHeight, className, footer } = props;
31
39
  const { t } = useTranslation();
32
40
  const { componentCls, hashId } = useMobileActionDrawerStyle();
33
41
 
34
42
  const bodyStyles = (props as MobilePopupProps & { styles?: { body?: React.CSSProperties } }).styles?.body;
43
+ const defaultMaxHeight = getMobilePopupMaxHeight();
35
44
  const popupStyle = useMemo(() => {
36
45
  return {
37
46
  minHeight: bodyStyles?.minHeight ?? minHeight,
38
47
  height: bodyStyles?.height,
39
- maxHeight: bodyStyles?.maxHeight,
48
+ maxHeight: bodyStyles?.maxHeight ?? defaultMaxHeight,
40
49
  };
41
- }, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, minHeight]);
50
+ }, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, defaultMaxHeight, minHeight]);
42
51
 
43
- const bodyStyle = useMemo(() => {
52
+ const bodyStyle = useMemo<React.CSSProperties>(() => {
44
53
  return {
45
54
  padding: 0,
55
+ maxHeight: defaultMaxHeight,
56
+ overflowY: 'auto',
57
+ overflowX: 'hidden',
46
58
  ...bodyStyles,
47
59
  };
48
- }, [bodyStyles]);
60
+ }, [bodyStyles, defaultMaxHeight]);
49
61
 
50
62
  const handleCloseKeyDown = useCallback(
51
63
  (event: React.KeyboardEvent<HTMLSpanElement>) => {
@@ -9,7 +9,7 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { fireEvent, render, screen } from '@testing-library/react';
12
- import { describe, expect, it, vi } from 'vitest';
12
+ import { afterEach, describe, expect, it, vi } from 'vitest';
13
13
  import { MobilePopup } from '../MobilePopup';
14
14
 
15
15
  vi.mock('react-i18next', () => ({
@@ -61,6 +61,47 @@ describe('MobilePopup', () => {
61
61
  React.ComponentProps<typeof MobilePopup> & { styles?: { body?: React.CSSProperties } }
62
62
  >;
63
63
 
64
+ afterEach(() => {
65
+ vi.unstubAllGlobals();
66
+ });
67
+
68
+ it('constrains the default popup to the dynamic viewport and keeps the body scrollable', () => {
69
+ vi.stubGlobal('CSS', {
70
+ supports: vi.fn((property: string, value: string) => property === 'height' && value === '100dvh'),
71
+ });
72
+
73
+ render(
74
+ <MobilePopup visible title="Title" onClose={vi.fn()}>
75
+ body
76
+ </MobilePopup>,
77
+ );
78
+
79
+ const maxHeight = 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))';
80
+
81
+ expect(screen.getByTestId('mobile-popup')).toHaveStyle({ maxHeight });
82
+ expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
83
+ maxHeight,
84
+ overflowY: 'auto',
85
+ overflowX: 'hidden',
86
+ });
87
+ });
88
+
89
+ it('falls back to the layout viewport when dynamic viewport units are unavailable', () => {
90
+ vi.stubGlobal('CSS', {
91
+ supports: vi.fn(() => false),
92
+ });
93
+
94
+ render(
95
+ <MobilePopup visible title="Title" onClose={vi.fn()}>
96
+ body
97
+ </MobilePopup>,
98
+ );
99
+
100
+ expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
101
+ maxHeight: 'calc(100vh - var(--nb-mobile-page-header-height, 46px))',
102
+ });
103
+ });
104
+
64
105
  it('applies drawer body styles as max bounds without forcing fixed half-window height', () => {
65
106
  render(
66
107
  <MobilePopupWithDrawerStyles visible title="Title" styles={{ body: { maxHeight: '50vh' } }} onClose={vi.fn()}>
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { css } from '@emotion/css';
11
- import { Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
11
+ import { ConfigProvider, Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
12
12
  import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
13
  import { useFlowEngine } from '../../provider';
14
14
 
@@ -484,13 +484,10 @@ const createSearchItem = (
484
484
  if (shouldActivateSearchSubmenu) {
485
485
  activateSearchSubmenu(searchKey);
486
486
  }
487
- if ((e.nativeEvent as any)?.isComposing || searchHandlers.isComposing(searchKey)) {
487
+ if ((e.nativeEvent as InputEvent).isComposing || searchHandlers.isComposing(searchKey)) {
488
488
  searchHandlers.updateInputValue(searchKey, value);
489
489
  return;
490
490
  }
491
- if (!value && shouldActivateSearchSubmenu) {
492
- deactivateSearchSubmenu(searchKey);
493
- }
494
491
  searchHandlers.updateSearchValue(searchKey, value);
495
492
  }}
496
493
  onCompositionStart={(e) => {
@@ -504,11 +501,7 @@ const createSearchItem = (
504
501
  e.stopPropagation();
505
502
  const value = e.currentTarget.value;
506
503
  if (shouldActivateSearchSubmenu) {
507
- if (value) {
508
- activateSearchSubmenu(searchKey);
509
- } else {
510
- deactivateSearchSubmenu(searchKey);
511
- }
504
+ activateSearchSubmenu(searchKey);
512
505
  }
513
506
  searchHandlers.endComposition(searchKey, value);
514
507
  }}
@@ -516,11 +509,21 @@ const createSearchItem = (
516
509
  e.stopPropagation();
517
510
  }}
518
511
  onKeyDown={(e) => {
512
+ if (e.key === 'Escape' || e.key === 'Tab') {
513
+ deactivateSearchSubmenu(searchKey);
514
+ return;
515
+ }
516
+ if (shouldActivateSearchSubmenu) {
517
+ activateSearchSubmenu(searchKey);
518
+ }
519
519
  e.stopPropagation();
520
520
  }}
521
521
  onMouseDown={(e) => {
522
522
  // 防止菜单聚焦丢失或页面滚动
523
523
  e.stopPropagation();
524
+ if (shouldActivateSearchSubmenu) {
525
+ activateSearchSubmenu(searchKey);
526
+ }
524
527
  }}
525
528
  size="small"
526
529
  style={{
@@ -552,7 +555,7 @@ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
552
555
 
553
556
  // 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
554
557
  const DROPDOWN_PERSIST_TTL_MS = 350;
555
- const SUBMENU_CLOSE_DELAY = 0.05;
558
+ const MENU_CLOSE_DELAY = 0.3;
556
559
  const SUBMENU_MOTION_DISABLED = {
557
560
  motionEnter: false,
558
561
  motionLeave: false,
@@ -561,13 +564,20 @@ const dropdownPersistRegistry: Map<string, number> = new Map();
561
564
 
562
565
  const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
563
566
  const engine = useFlowEngine();
567
+ const { getPrefixCls } = React.useContext(ConfigProvider.ConfigContext);
568
+ const triggerId = React.useId();
564
569
  const [menuVisible, setMenuVisible] = useState(false);
565
570
  const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
566
- const [activeSearchKey, setActiveSearchKey] = useState<string | null>(null);
567
571
  const [rootItems, setRootItems] = useState<Item[]>([]);
568
572
  const [rootLoading, setRootLoading] = useState(false);
573
+ const activeSearchKeyRef = useRef<string | null>(null);
569
574
  const closeByOutsideClickRef = useRef(false);
570
575
  const skipPreserveActiveSearchRef = useRef(false);
576
+ const triggerOpenClassName = `nb-lazy-dropdown-trigger-${triggerId.replace(/[^a-zA-Z0-9_-]/g, '')}`;
577
+ const defaultOpenClassName = `${getPrefixCls('dropdown', props.prefixCls)}-open`;
578
+ const mergedOpenClassName = [props.openClassName ?? defaultOpenClassName, triggerOpenClassName]
579
+ .filter(Boolean)
580
+ .join(' ');
571
581
  const dropdownMaxHeight = useNiceDropdownMaxHeight();
572
582
  const t = engine.translate.bind(engine);
573
583
 
@@ -589,13 +599,13 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
589
599
 
590
600
  const closeMenu = useCallback(() => {
591
601
  setMenuVisible(false);
592
- setActiveSearchKey(null);
602
+ activeSearchKeyRef.current = null;
593
603
  setOpenKeys(new Set());
594
604
  clearAllSearchValues();
595
605
  }, [clearAllSearchValues]);
596
606
 
597
607
  const activateSearchSubmenu = useCallback((key: string) => {
598
- setActiveSearchKey(key);
608
+ activeSearchKeyRef.current = key;
599
609
  setOpenKeys((prev) => {
600
610
  if (prev.has(key)) return prev;
601
611
  const next = new Set(prev);
@@ -605,11 +615,14 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
605
615
  }, []);
606
616
 
607
617
  const deactivateSearchSubmenu = useCallback((key: string) => {
608
- setActiveSearchKey((prev) => (prev === key ? null : prev));
618
+ if (activeSearchKeyRef.current === key) {
619
+ activeSearchKeyRef.current = null;
620
+ }
609
621
  }, []);
610
622
 
611
623
  const closeActiveSearchForPath = useCallback(
612
624
  (keyPath: string) => {
625
+ const activeSearchKey = activeSearchKeyRef.current;
613
626
  if (
614
627
  !activeSearchKey ||
615
628
  keyPath === activeSearchKey ||
@@ -621,25 +634,26 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
621
634
 
622
635
  skipPreserveActiveSearchRef.current = true;
623
636
  clearSearchValue(activeSearchKey);
624
- setActiveSearchKey(null);
637
+ activeSearchKeyRef.current = null;
625
638
  setOpenKeys((prev) => {
626
639
  const next = new Set(prev);
627
640
  next.delete(activeSearchKey);
628
641
  return next;
629
642
  });
630
643
  },
631
- [activeSearchKey, clearSearchValue],
644
+ [clearSearchValue],
632
645
  );
633
646
 
634
647
  const handleMenuOpenChange = useCallback(
635
648
  (nextOpenKeys: string[]) => {
636
649
  let normalized = normalizeOpenKeys(nextOpenKeys);
637
- if (activeSearchKey && openKeys.has(activeSearchKey) && !normalized.includes(activeSearchKey)) {
638
- if (normalized.length || skipPreserveActiveSearchRef.current) {
650
+ const activeSearchKey = activeSearchKeyRef.current;
651
+ if (activeSearchKey && !normalized.includes(activeSearchKey)) {
652
+ if (skipPreserveActiveSearchRef.current) {
639
653
  clearSearchValue(activeSearchKey);
640
- setActiveSearchKey(null);
654
+ activeSearchKeyRef.current = null;
641
655
  } else {
642
- normalized = [activeSearchKey];
656
+ normalized = Array.from(openKeys);
643
657
  }
644
658
  }
645
659
 
@@ -658,7 +672,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
658
672
  dropdownMenuProps.onOpenChange?.(normalized);
659
673
  skipPreserveActiveSearchRef.current = false;
660
674
  },
661
- [activeSearchKey, clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
675
+ [clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
662
676
  );
663
677
 
664
678
  useEffect(() => {
@@ -666,7 +680,9 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
666
680
 
667
681
  const markOutsideClick = (event: MouseEvent | PointerEvent) => {
668
682
  const target = event.target as HTMLElement | null;
669
- const isOutside = !target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
683
+ const isInsidePopup = target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
684
+ const isInsideCurrentTrigger = target?.closest(`.${triggerOpenClassName}`);
685
+ const isOutside = !isInsidePopup && !isInsideCurrentTrigger;
670
686
  closeByOutsideClickRef.current = isOutside;
671
687
  if (isOutside) {
672
688
  closeMenu();
@@ -679,7 +695,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
679
695
  document.removeEventListener('pointerdown', markOutsideClick, true);
680
696
  document.removeEventListener('mousedown', markOutsideClick, true);
681
697
  };
682
- }, [closeMenu, menuVisible]);
698
+ }, [closeMenu, menuVisible, triggerOpenClassName]);
683
699
 
684
700
  // 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
685
701
  useEffect(() => {
@@ -957,13 +973,15 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
957
973
  {...props}
958
974
  open={menuVisible}
959
975
  destroyPopupOnHide
976
+ mouseLeaveDelay={props.mouseLeaveDelay ?? MENU_CLOSE_DELAY}
977
+ openClassName={mergedOpenClassName}
960
978
  overlayClassName={overlayClassName}
961
979
  placement="bottomLeft"
962
980
  menu={{
963
981
  ...dropdownMenuProps,
964
982
  openKeys: Array.from(openKeys),
965
983
  items: items,
966
- subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? SUBMENU_CLOSE_DELAY,
984
+ subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? MENU_CLOSE_DELAY,
967
985
  motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
968
986
  onClick: () => {},
969
987
  onOpenChange: handleMenuOpenChange,
@@ -974,7 +992,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
974
992
  },
975
993
  }}
976
994
  onOpenChange={(visible, info) => {
977
- if (!visible && activeSearchKey && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
995
+ if (!visible && activeSearchKeyRef.current && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
978
996
  return;
979
997
  }
980
998
 
@@ -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();
@@ -864,4 +864,39 @@ describe('FlowContextSelector', () => {
864
864
  // It should only expand the node, not select it
865
865
  expect(onChange).not.toHaveBeenCalled();
866
866
  });
867
+
868
+ it('should expand but never select a node marked selectable=false', async () => {
869
+ const onChange = vi.fn();
870
+ const flowContext = createTestFlowContext();
871
+ const metaTree = [
872
+ {
873
+ name: 'date',
874
+ title: 'Date',
875
+ type: 'date',
876
+ paths: ['date'],
877
+ selectable: false,
878
+ children: [{ name: 'today', title: 'Today', type: 'date', paths: ['date', 'today'] }],
879
+ },
880
+ ];
881
+
882
+ render(
883
+ <TestFlowContextWrapper context={flowContext}>
884
+ <FlowContextSelector metaTree={metaTree} onChange={onChange} />
885
+ </TestFlowContextWrapper>,
886
+ );
887
+
888
+ fireEvent.click(screen.getByRole('button'));
889
+ await waitFor(() => expect(screen.getByText('Date')).toBeInTheDocument());
890
+
891
+ fireEvent.click(screen.getByText('Date'));
892
+ fireEvent.click(screen.getByText('Date'));
893
+ expect(onChange).not.toHaveBeenCalled();
894
+
895
+ await waitFor(() => expect(screen.getByText('Today')).toBeInTheDocument());
896
+ fireEvent.click(screen.getByText('Today'));
897
+ expect(onChange).toHaveBeenCalledWith(
898
+ '{{ ctx.date.today }}',
899
+ expect.objectContaining({ paths: ['date', 'today'] }),
900
+ );
901
+ });
867
902
  });