@nocobase/flow-engine 2.1.0-beta.42 → 2.1.0-beta.44

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/components/settings/wrappers/contextual/DefaultSettingsIcon.js +76 -31
  2. package/lib/components/subModel/LazyDropdown.js +17 -9
  3. package/lib/executor/FlowExecutor.js +0 -3
  4. package/lib/flowContext.d.ts +6 -1
  5. package/lib/flowContext.js +35 -6
  6. package/lib/flowEngine.d.ts +4 -3
  7. package/lib/flowEngine.js +69 -37
  8. package/lib/models/flowModel.js +45 -13
  9. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
  10. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
  11. package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
  12. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
  13. package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
  14. package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
  15. package/lib/runjs-context/contexts/base.js +464 -29
  16. package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
  17. package/lib/runjs-context/contexts/elementDoc.js +152 -0
  18. package/lib/utils/loadedPageCache.d.ts +24 -0
  19. package/lib/utils/loadedPageCache.js +139 -0
  20. package/package.json +4 -4
  21. package/src/__tests__/flowContext.test.ts +23 -0
  22. package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
  23. package/src/__tests__/runjsContext.test.ts +18 -0
  24. package/src/__tests__/runjsContextImplementations.test.ts +9 -2
  25. package/src/__tests__/runjsLocales.test.ts +6 -5
  26. package/src/__tests__/viewScopedFlowEngine.test.ts +133 -0
  27. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +79 -37
  28. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +150 -3
  29. package/src/components/subModel/LazyDropdown.tsx +16 -7
  30. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +51 -0
  31. package/src/executor/FlowExecutor.ts +0 -3
  32. package/src/executor/__tests__/flowExecutor.test.ts +2 -4
  33. package/src/flowContext.ts +40 -6
  34. package/src/flowEngine.ts +71 -35
  35. package/src/models/__tests__/flowModel.test.ts +13 -28
  36. package/src/models/flowModel.tsx +62 -29
  37. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
  38. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
  39. package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
  40. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
  41. package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
  42. package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
  43. package/src/runjs-context/contexts/base.ts +467 -31
  44. package/src/runjs-context/contexts/elementDoc.ts +130 -0
  45. package/src/utils/loadedPageCache.ts +147 -0
@@ -18,6 +18,33 @@ import { APIClient as SDKApiClient } from '@nocobase/sdk';
18
18
  import { FlowEngine } from '../flowEngine';
19
19
  import { createViewScopedEngine } from '../ViewScopedFlowEngine';
20
20
  import { FlowModel } from '../models';
21
+ import type { IFlowModelRepository } from '../types';
22
+
23
+ const clone = <T>(value: T): T => (value == null ? value : JSON.parse(JSON.stringify(value)));
24
+
25
+ class DirtyPageRepository implements IFlowModelRepository<FlowModel> {
26
+ public findOneCalls = 0;
27
+ public data: Record<string, any> | null = null;
28
+
29
+ async findOne(): Promise<Record<string, any> | null> {
30
+ this.findOneCalls += 1;
31
+ return clone(this.data);
32
+ }
33
+
34
+ async save(model: FlowModel): Promise<Record<string, any>> {
35
+ return model.serialize();
36
+ }
37
+
38
+ async destroy(): Promise<boolean> {
39
+ return true;
40
+ }
41
+
42
+ async move(): Promise<void> {}
43
+
44
+ async duplicate(): Promise<Record<string, any> | null> {
45
+ return null;
46
+ }
47
+ }
21
48
 
22
49
  describe('ViewScopedFlowEngine', () => {
23
50
  it('shares global actions/events and model classes with parent', async () => {
@@ -307,4 +334,110 @@ describe('ViewScopedFlowEngine', () => {
307
334
  expect(result).not.toBeNull();
308
335
  expect(result?.uid).toBe('child-normal');
309
336
  });
337
+
338
+ it('reloads a dirty loaded page from repository and replaces stale parent reference', async () => {
339
+ const root = new FlowEngine();
340
+ const repository = new DirtyPageRepository();
341
+ root.setModelRepository(repository);
342
+
343
+ class ParentModel extends FlowModel {}
344
+ class PageModel extends FlowModel {}
345
+ class BlockModel extends FlowModel {}
346
+ root.registerModels({ ParentModel, PageModel, BlockModel });
347
+
348
+ const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'popup-action' });
349
+ const oldScoped = createViewScopedEngine(root);
350
+ const stalePage = oldScoped.createModel<PageModel>({
351
+ use: 'PageModel',
352
+ uid: 'popup-page',
353
+ parentId: parent.uid,
354
+ subKey: 'page',
355
+ subType: 'object',
356
+ subModels: {
357
+ items: [{ use: 'BlockModel', uid: 'stale-block' }],
358
+ },
359
+ });
360
+ const staleBlock = stalePage.findSubModel('items' as any, (item) => item.uid === 'stale-block') as FlowModel;
361
+ parent.setSubModel('page', stalePage);
362
+ oldScoped.unlinkFromStack();
363
+
364
+ repository.data = {
365
+ use: 'PageModel',
366
+ uid: 'popup-page',
367
+ parentId: parent.uid,
368
+ subKey: 'page',
369
+ subType: 'object',
370
+ subModels: {
371
+ items: [{ use: 'BlockModel', uid: 'fresh-block' }],
372
+ },
373
+ };
374
+
375
+ root.flowSettings.enable();
376
+ await staleBlock.saveStepParams();
377
+ root.flowSettings.disable();
378
+ repository.findOneCalls = 0;
379
+
380
+ const runtimeScoped = createViewScopedEngine(root);
381
+ const loaded = await runtimeScoped.loadOrCreateModel<PageModel>({
382
+ async: true,
383
+ parentId: parent.uid,
384
+ subKey: 'page',
385
+ subType: 'object',
386
+ use: 'PageModel',
387
+ });
388
+
389
+ expect(repository.findOneCalls).toBe(1);
390
+ expect(loaded).not.toBe(stalePage);
391
+ expect((parent.subModels as any).page).toBe(loaded);
392
+ expect(loaded?.mapSubModels('items' as any, (item) => item.uid)).toEqual(['fresh-block']);
393
+
394
+ repository.findOneCalls = 0;
395
+ const nextRuntimeScoped = createViewScopedEngine(root);
396
+ const loadedAgain = await nextRuntimeScoped.loadOrCreateModel<PageModel>({
397
+ async: true,
398
+ parentId: parent.uid,
399
+ subKey: 'page',
400
+ subType: 'object',
401
+ use: 'PageModel',
402
+ });
403
+
404
+ expect(repository.findOneCalls).toBe(0);
405
+ expect(loadedAgain?.uid).toBe('popup-page');
406
+ });
407
+
408
+ it('does not bypass loaded page cache after a non-config save', async () => {
409
+ const root = new FlowEngine();
410
+ const repository = new DirtyPageRepository();
411
+ root.setModelRepository(repository);
412
+
413
+ class ParentModel extends FlowModel {}
414
+ class PageModel extends FlowModel {}
415
+ root.registerModels({ ParentModel, PageModel });
416
+
417
+ const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'normal-parent' });
418
+ const stalePage = root.createModel<PageModel>({
419
+ use: 'PageModel',
420
+ uid: 'normal-page',
421
+ parentId: parent.uid,
422
+ subKey: 'page',
423
+ subType: 'object',
424
+ });
425
+ parent.setSubModel('page', stalePage);
426
+
427
+ root.flowSettings.disable();
428
+ await root.saveModel(stalePage);
429
+ repository.findOneCalls = 0;
430
+
431
+ const runtimeScoped = createViewScopedEngine(root);
432
+ const loaded = await runtimeScoped.loadOrCreateModel<PageModel>({
433
+ async: true,
434
+ parentId: parent.uid,
435
+ subKey: 'page',
436
+ subType: 'object',
437
+ use: 'PageModel',
438
+ });
439
+
440
+ expect(repository.findOneCalls).toBe(0);
441
+ expect(loaded?.uid).toBe('normal-page');
442
+ });
310
443
  });
@@ -263,8 +263,18 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
263
263
  // 当模型发生子模型替换/增删等变化时,强制刷新菜单数据
264
264
  const [refreshTick, setRefreshTick] = useState(0);
265
265
  const [extraMenuItems, setExtraMenuItems] = useState<FlowModelExtraMenuItem[]>([]);
266
+ const [extraMenuItemsLoaded, setExtraMenuItemsLoaded] = useState(false);
266
267
  const [configurableFlowsAndSteps, setConfigurableFlowsAndSteps] = useState<FlowInfo[]>([]);
267
268
  const [isLoading, setIsLoading] = useState(true);
269
+ const commonExtras = useMemo(
270
+ () => extraMenuItems.filter((it) => it.group === 'common-actions').sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)),
271
+ [extraMenuItems],
272
+ );
273
+ const hasCommonActions = showCopyUidButton || showDeleteButton || commonExtras.length > 0;
274
+ const shouldDeferConfigLoading = flattenSubMenus && menuLevels > 1 && hasCommonActions;
275
+ const shouldWaitForCommonActionProbe =
276
+ flattenSubMenus && menuLevels > 1 && !showCopyUidButton && !showDeleteButton && !extraMenuItemsLoaded;
277
+ const canRenderIcon = hasCommonActions || (!isLoading && configurableFlowsAndSteps.length > 0);
268
278
  const closeDropdown = useCallback(() => {
269
279
  setVisible(false);
270
280
  onDropdownVisibleChange?.(false);
@@ -303,26 +313,30 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
303
313
  useEffect(() => {
304
314
  let mounted = true;
305
315
  const loadExtras = async () => {
306
- const allExtras: FlowModelExtraMenuItem[] = [];
307
- const modelsToProcess: Array<{ model: FlowModel; modelKey?: string }> = [];
308
- walkSubModels(model, { maxDepth: menuLevels, arrayLimit: 50, mode: 'stack' }, (targetModel, { modelKey }) => {
309
- modelsToProcess.push({ model: targetModel, modelKey });
310
- });
316
+ setExtraMenuItemsLoaded(false);
317
+ try {
318
+ const allExtras: FlowModelExtraMenuItem[] = [];
319
+ const modelsToProcess: Array<{ model: FlowModel; modelKey?: string }> = [];
320
+ walkSubModels(model, { maxDepth: menuLevels, arrayLimit: 50, mode: 'stack' }, (targetModel, { modelKey }) => {
321
+ modelsToProcess.push({ model: targetModel, modelKey });
322
+ });
311
323
 
312
- for (const { model: targetModel, modelKey } of modelsToProcess) {
313
- const Cls = targetModel.constructor as typeof FlowModel;
314
- const extras = await Cls.getExtraMenuItems?.(targetModel, t);
315
- if (extras?.length) {
316
- allExtras.push(
317
- ...extras.map((item) => ({
318
- ...item,
319
- key: modelKey ? `${modelKey}:${item.key}` : item.key,
320
- })),
321
- );
324
+ for (const { model: targetModel, modelKey } of modelsToProcess) {
325
+ const Cls = targetModel.constructor as typeof FlowModel;
326
+ const extras = await Cls.getExtraMenuItems?.(targetModel, t);
327
+ if (extras?.length) {
328
+ allExtras.push(
329
+ ...extras.map((item) => ({
330
+ ...item,
331
+ key: modelKey ? `${modelKey}:${item.key}` : item.key,
332
+ })),
333
+ );
334
+ }
322
335
  }
323
- }
324
336
 
325
- if (mounted) {
337
+ if (!mounted) {
338
+ return;
339
+ }
326
340
  const seen = new Set<string>();
327
341
  const dedupedExtras = allExtras.filter((item) => {
328
342
  if (seen.has(`${item.key}`)) {
@@ -332,16 +346,22 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
332
346
  return true;
333
347
  });
334
348
  setExtraMenuItems(dedupedExtras);
349
+ } catch (error) {
350
+ console.error('Failed to load extra menu items:', error);
351
+ if (mounted) {
352
+ setExtraMenuItems([]);
353
+ }
354
+ } finally {
355
+ if (mounted) {
356
+ setExtraMenuItemsLoaded(true);
357
+ }
335
358
  }
336
359
  };
337
- // 避免 effect 触发 setState 导致循环:仅在 visible 打开时加载一次,关闭后仍保留结果
338
- if (visible) {
339
- loadExtras();
340
- }
360
+ loadExtras();
341
361
  return () => {
342
362
  mounted = false;
343
363
  };
344
- }, [model, menuLevels, t, refreshTick, visible]);
364
+ }, [model, menuLevels, t, refreshTick]);
345
365
 
346
366
  // 统一的复制 UID 方法
347
367
  const copyUidToClipboard = useCallback(
@@ -632,7 +652,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
632
652
  return [];
633
653
  }
634
654
  },
635
- [],
655
+ [t],
636
656
  );
637
657
 
638
658
  // 获取可配置的flows和steps
@@ -675,21 +695,50 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
675
695
  }, [model, menuLevels, refreshTick]);
676
696
 
677
697
  useEffect(() => {
698
+ let mounted = true;
678
699
  const loadConfigurableFlowsAndSteps = async () => {
679
700
  setIsLoading(true);
701
+ if (shouldDeferConfigLoading) {
702
+ setConfigurableFlowsAndSteps([]);
703
+ }
680
704
  try {
681
705
  const flows = await getConfigurableFlowsAndSteps();
682
- setConfigurableFlowsAndSteps(flows);
706
+ if (mounted) {
707
+ setConfigurableFlowsAndSteps(flows);
708
+ }
683
709
  } catch (error) {
684
710
  console.error('Failed to load configurable flows and steps:', error);
685
- setConfigurableFlowsAndSteps([]);
711
+ if (mounted) {
712
+ setConfigurableFlowsAndSteps([]);
713
+ }
686
714
  } finally {
687
- setIsLoading(false);
715
+ if (mounted) {
716
+ setIsLoading(false);
717
+ }
688
718
  }
689
719
  };
690
720
 
721
+ if (shouldWaitForCommonActionProbe) {
722
+ setConfigurableFlowsAndSteps([]);
723
+ setIsLoading(false);
724
+ return () => {
725
+ mounted = false;
726
+ };
727
+ }
728
+
729
+ if (!visible && shouldDeferConfigLoading) {
730
+ setConfigurableFlowsAndSteps([]);
731
+ setIsLoading(false);
732
+ return () => {
733
+ mounted = false;
734
+ };
735
+ }
736
+
691
737
  loadConfigurableFlowsAndSteps();
692
- }, [getConfigurableFlowsAndSteps, refreshTick]);
738
+ return () => {
739
+ mounted = false;
740
+ };
741
+ }, [getConfigurableFlowsAndSteps, refreshTick, shouldDeferConfigLoading, shouldWaitForCommonActionProbe, visible]);
693
742
 
694
743
  // 构建菜单项,包含错误处理和记忆化
695
744
  const menuItems = useMemo((): NonNullable<MenuProps['items']> => {
@@ -856,16 +905,12 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
856
905
  }
857
906
 
858
907
  return items;
859
- }, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, t]);
908
+ }, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, message, model, t]);
860
909
 
861
910
  // 向菜单项添加额外按钮
862
911
  const finalMenuItems = useMemo((): NonNullable<MenuProps['items']> => {
863
912
  const items = [...menuItems];
864
913
 
865
- const commonExtras = extraMenuItems
866
- .filter((it) => it.group === 'common-actions')
867
- .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
868
-
869
914
  if (showCopyUidButton || showDeleteButton || commonExtras.length > 0) {
870
915
  items.push({
871
916
  type: 'divider',
@@ -901,12 +946,9 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
901
946
  }
902
947
 
903
948
  return items;
904
- }, [menuItems, showCopyUidButton, showDeleteButton, model.uid, model.destroy, t, extraMenuItems]);
905
-
906
- // 如果正在加载或没有可配置的flows且不显示删除按钮和复制UID按钮,不显示菜单
907
- const hasExtras = extraMenuItems.some((it) => it.group === 'common-actions');
949
+ }, [menuItems, showCopyUidButton, showDeleteButton, commonExtras, model.uid, model.destroy, t]);
908
950
 
909
- if (isLoading || (configurableFlowsAndSteps.length === 0 && !showDeleteButton && !showCopyUidButton && !hasExtras)) {
951
+ if (!canRenderIcon) {
910
952
  return null;
911
953
  }
912
954
 
@@ -7,10 +7,10 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import React from 'react';
11
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12
- import { render, cleanup, waitFor, act } from '@testing-library/react';
10
+ import { act, cleanup, render, waitFor } from '@testing-library/react';
13
11
  import { App, ConfigProvider } from 'antd';
12
+ import React from 'react';
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14
14
 
15
15
  import { FlowEngine } from '../../../../../flowEngine';
16
16
  import { FlowModel } from '../../../../../models/flowModel';
@@ -141,6 +141,86 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
141
141
  vi.clearAllMocks();
142
142
  });
143
143
 
144
+ it('defers nested configurable step resolution and clears stale config while closed', async () => {
145
+ class TestFlowModel extends FlowModel {}
146
+
147
+ const engine = new FlowEngine();
148
+ const model = new TestFlowModel({ uid: 'model-lazy-settings', flowEngine: engine });
149
+ const hideInSettings = vi.fn((ctx) => !!ctx.getStepParams('general')?.hidden);
150
+ const uiSchema = vi.fn(() => ({
151
+ field: { type: 'string', 'x-component': 'Input' },
152
+ }));
153
+
154
+ TestFlowModel.registerFlow({
155
+ key: 'lazyFlow',
156
+ title: 'Lazy Flow',
157
+ steps: {
158
+ general: {
159
+ title: 'General',
160
+ hideInSettings,
161
+ uiSchema,
162
+ },
163
+ },
164
+ });
165
+
166
+ const { getByLabelText } = render(
167
+ React.createElement(
168
+ ConfigProvider as any,
169
+ null,
170
+ React.createElement(
171
+ App as any,
172
+ null,
173
+ React.createElement(DefaultSettingsIcon as any, { model, menuLevels: 2 }),
174
+ ),
175
+ ),
176
+ );
177
+
178
+ expect(getByLabelText('flows-settings')).toBeTruthy();
179
+ expect(hideInSettings).not.toHaveBeenCalled();
180
+ expect(uiSchema).not.toHaveBeenCalled();
181
+
182
+ await act(async () => {
183
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
184
+ });
185
+
186
+ await waitFor(() => {
187
+ expect(hideInSettings).toHaveBeenCalledTimes(1);
188
+ expect(uiSchema).toHaveBeenCalledTimes(1);
189
+ const menu = (globalThis as any).__lastDropdownMenu;
190
+ const items = (menu?.items || []) as any[];
191
+ expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(true);
192
+ });
193
+
194
+ await act(async () => {
195
+ (globalThis as any).__lastDropdownOnOpenChange?.(false, { source: 'trigger' });
196
+ });
197
+
198
+ await waitFor(() => {
199
+ const menu = (globalThis as any).__lastDropdownMenu;
200
+ const items = (menu?.items || []) as any[];
201
+ expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(false);
202
+ });
203
+
204
+ await act(async () => {
205
+ model.setStepParams('lazyFlow', 'general', { hidden: true });
206
+ });
207
+
208
+ expect(hideInSettings).toHaveBeenCalledTimes(1);
209
+ expect(uiSchema).toHaveBeenCalledTimes(1);
210
+
211
+ await act(async () => {
212
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
213
+ });
214
+
215
+ await waitFor(() => {
216
+ expect(hideInSettings).toHaveBeenCalledTimes(2);
217
+ const menu = (globalThis as any).__lastDropdownMenu;
218
+ const items = (menu?.items || []) as any[];
219
+ expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(false);
220
+ });
221
+ expect(uiSchema).toHaveBeenCalledTimes(1);
222
+ });
223
+
144
224
  it('excludes instance (dynamic) flows from the settings menu', async () => {
145
225
  class TestFlowModel extends FlowModel {}
146
226
 
@@ -720,6 +800,10 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
720
800
  ),
721
801
  );
722
802
 
803
+ await act(async () => {
804
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
805
+ });
806
+
723
807
  await waitFor(() => {
724
808
  const menu = (globalThis as any).__lastDropdownMenu;
725
809
  expect(menu).toBeTruthy();
@@ -903,4 +987,67 @@ describe('DefaultSettingsIcon - extra menu items', () => {
903
987
  dispose?.();
904
988
  }
905
989
  });
990
+
991
+ it('uses common extra actions to defer nested configurable step resolution', async () => {
992
+ const onClick = vi.fn();
993
+
994
+ class TestFlowModel extends FlowModel {}
995
+ const dispose = TestFlowModel.registerExtraMenuItems({
996
+ group: 'common-actions',
997
+ sort: 10,
998
+ items: [{ key: 'extra-action', label: 'Extra Action', onClick }],
999
+ });
1000
+
1001
+ const engine = new FlowEngine();
1002
+ const model = new TestFlowModel({ uid: 'm-extra-lazy', flowEngine: engine });
1003
+ const uiSchema = vi.fn(() => ({
1004
+ f: { type: 'string', 'x-component': 'Input' },
1005
+ }));
1006
+
1007
+ TestFlowModel.registerFlow({
1008
+ key: 'flow',
1009
+ title: 'Flow',
1010
+ steps: { s: { title: 'S', uiSchema } },
1011
+ });
1012
+
1013
+ try {
1014
+ const { getByLabelText } = render(
1015
+ React.createElement(
1016
+ ConfigProvider as any,
1017
+ null,
1018
+ React.createElement(
1019
+ App as any,
1020
+ null,
1021
+ React.createElement(DefaultSettingsIcon as any, {
1022
+ model,
1023
+ menuLevels: 2,
1024
+ showCopyUidButton: false,
1025
+ showDeleteButton: false,
1026
+ }),
1027
+ ),
1028
+ ),
1029
+ );
1030
+
1031
+ await waitFor(() => {
1032
+ expect(getByLabelText('flows-settings')).toBeTruthy();
1033
+ const menu = (globalThis as any).__lastDropdownMenu;
1034
+ const items = (menu?.items || []) as any[];
1035
+ expect(items.some((it) => String(it.key || '') === 'extra-action')).toBe(true);
1036
+ });
1037
+ expect(uiSchema).not.toHaveBeenCalled();
1038
+
1039
+ await act(async () => {
1040
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
1041
+ });
1042
+
1043
+ await waitFor(() => {
1044
+ expect(uiSchema).toHaveBeenCalledTimes(1);
1045
+ const menu = (globalThis as any).__lastDropdownMenu;
1046
+ const items = (menu?.items || []) as any[];
1047
+ expect(items.some((it) => String(it.key || '') === 'flow:s')).toBe(true);
1048
+ });
1049
+ } finally {
1050
+ dispose?.();
1051
+ }
1052
+ });
906
1053
  });
@@ -463,6 +463,7 @@ const createSearchItem = (
463
463
  },
464
464
  activateSearchSubmenu: (key: string) => void,
465
465
  deactivateSearchSubmenu: (key: string) => void,
466
+ shouldActivateSearchSubmenu: boolean,
466
467
  ) => ({
467
468
  key: `${searchKey}-search`,
468
469
  type: 'group' as const,
@@ -480,28 +481,34 @@ const createSearchItem = (
480
481
  onChange={(e) => {
481
482
  e.stopPropagation();
482
483
  const value = e.target.value;
483
- activateSearchSubmenu(searchKey);
484
+ if (shouldActivateSearchSubmenu) {
485
+ activateSearchSubmenu(searchKey);
486
+ }
484
487
  if ((e.nativeEvent as any)?.isComposing || searchHandlers.isComposing(searchKey)) {
485
488
  searchHandlers.updateInputValue(searchKey, value);
486
489
  return;
487
490
  }
488
- if (!value) {
491
+ if (!value && shouldActivateSearchSubmenu) {
489
492
  deactivateSearchSubmenu(searchKey);
490
493
  }
491
494
  searchHandlers.updateSearchValue(searchKey, value);
492
495
  }}
493
496
  onCompositionStart={(e) => {
494
497
  e.stopPropagation();
495
- activateSearchSubmenu(searchKey);
498
+ if (shouldActivateSearchSubmenu) {
499
+ activateSearchSubmenu(searchKey);
500
+ }
496
501
  searchHandlers.startComposition(searchKey);
497
502
  }}
498
503
  onCompositionEnd={(e) => {
499
504
  e.stopPropagation();
500
505
  const value = e.currentTarget.value;
501
- if (value) {
502
- activateSearchSubmenu(searchKey);
503
- } else {
504
- deactivateSearchSubmenu(searchKey);
506
+ if (shouldActivateSearchSubmenu) {
507
+ if (value) {
508
+ activateSearchSubmenu(searchKey);
509
+ } else {
510
+ deactivateSearchSubmenu(searchKey);
511
+ }
505
512
  }
506
513
  searchHandlers.endComposition(searchKey, value);
507
514
  }}
@@ -737,6 +744,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
737
744
  const searchKey = keyPath;
738
745
  const currentSearchValue = searchValues[searchKey] || '';
739
746
  const currentInputValue = inputValues[searchKey] ?? currentSearchValue;
747
+ const shouldActivateSearchSubmenu = !(item.type === 'group' && path.length === 0);
740
748
 
741
749
  // 递归过滤:当 child 为分组时,会继续向下过滤其 children;
742
750
  // 仅保留自身匹配或存在匹配子项的分组。
@@ -770,6 +778,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
770
778
  searchHandlers,
771
779
  activateSearchSubmenu,
772
780
  deactivateSearchSubmenu,
781
+ shouldActivateSearchSubmenu,
773
782
  );
774
783
  const dividerItem = { key: `${keyPath}-search-divider`, type: 'divider' as const };
775
784
 
@@ -556,6 +556,57 @@ describe('transformItems - searchable flags', () => {
556
556
  await waitFor(() => expect(screen.getByText('Field 1')).toBeInTheDocument());
557
557
  expect(screen.getByRole('textbox')).toHaveValue('');
558
558
  });
559
+
560
+ it('keeps root group search value when hovering a sibling submenu', async () => {
561
+ const engine = new FlowEngine();
562
+ await engine.flowSettings.forceEnable();
563
+ class Parent extends FlowModel {}
564
+ engine.registerModels({ Parent });
565
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
566
+
567
+ const items = [
568
+ {
569
+ key: 'fields',
570
+ label: '',
571
+ type: 'group' as const,
572
+ searchable: true,
573
+ searchPlaceholder: 'Search fields',
574
+ children: [
575
+ { key: 'nickname', label: 'Nickname', createModelOptions: { use: 'Parent' } },
576
+ { key: 'email', label: 'Email', createModelOptions: { use: 'Parent' } },
577
+ ],
578
+ },
579
+ {
580
+ key: 'association-fields',
581
+ label: 'Display association fields',
582
+ children: [{ key: 'author', label: 'Author', createModelOptions: { use: 'Parent' } }],
583
+ },
584
+ ];
585
+
586
+ const user = userEvent.setup();
587
+ render(
588
+ <FlowEngineProvider engine={engine}>
589
+ <ConfigProvider>
590
+ <App>
591
+ <AddSubModelButton model={parent} subModelKey="items" items={items as any}>
592
+ Open
593
+ </AddSubModelButton>
594
+ </App>
595
+ </ConfigProvider>
596
+ </FlowEngineProvider>,
597
+ );
598
+
599
+ await user.click(screen.getByText('Open'));
600
+ const searchInput = await screen.findByPlaceholderText('Search fields');
601
+ await user.type(searchInput, 'nick');
602
+ await waitFor(() => expect(screen.queryByText('Email')).not.toBeInTheDocument());
603
+
604
+ await user.hover(screen.getByText('Display association fields'));
605
+
606
+ await waitFor(() => expect(screen.getByText('Author')).toBeInTheDocument());
607
+ expect(searchInput).toHaveValue('nick');
608
+ expect(screen.getByText('Nickname')).toBeInTheDocument();
609
+ });
559
610
  });
560
611
 
561
612
  describe('transformItems - hide', () => {
@@ -158,9 +158,6 @@ export class FlowExecutor {
158
158
  const stepDefaultParams = await resolveDefaultParams(step.defaultParams, runtimeCtx);
159
159
  combinedParams = { ...stepDefaultParams };
160
160
  } else {
161
- flowContext.logger.warn(
162
- `BaseModel.applyFlow: Step '${stepKey}' in flow '${flowKey}' has neither 'use' nor 'handler'. Skipping.`,
163
- );
164
161
  continue;
165
162
  }
166
163
 
@@ -81,7 +81,7 @@ describe('FlowExecutor', () => {
81
81
  expect(result.step2).toBe('step2-ok');
82
82
  });
83
83
 
84
- it('runFlow warns and skips steps without use or handler', async () => {
84
+ it('runFlow silently skips steps without use or handler', async () => {
85
85
  const flows = {
86
86
  referenceSettings: {
87
87
  steps: {
@@ -98,9 +98,7 @@ describe('FlowExecutor', () => {
98
98
  const result = await engine.executor.runFlow(model, 'referenceSettings');
99
99
 
100
100
  expect(result).toEqual({});
101
- expect(loggerWarnSpy).toHaveBeenCalledWith(
102
- "BaseModel.applyFlow: Step 'target' in flow 'referenceSettings' has neither 'use' nor 'handler'. Skipping.",
103
- );
101
+ expect(loggerWarnSpy).not.toHaveBeenCalled();
104
102
  expect(loggerErrorSpy).not.toHaveBeenCalled();
105
103
  } finally {
106
104
  loggerChildSpy.mockRestore();