@nocobase/flow-engine 2.1.0-alpha.40 → 2.1.0-alpha.46

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 (77) hide show
  1. package/lib/FlowContextProvider.d.ts +5 -1
  2. package/lib/FlowContextProvider.js +9 -2
  3. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +84 -32
  4. package/lib/components/subModel/LazyDropdown.js +208 -16
  5. package/lib/components/subModel/utils.d.ts +1 -0
  6. package/lib/components/subModel/utils.js +6 -2
  7. package/lib/data-source/index.d.ts +9 -0
  8. package/lib/data-source/index.js +12 -0
  9. package/lib/executor/FlowExecutor.js +0 -3
  10. package/lib/flowContext.d.ts +6 -1
  11. package/lib/flowContext.js +38 -6
  12. package/lib/flowEngine.d.ts +4 -3
  13. package/lib/flowEngine.js +72 -40
  14. package/lib/models/flowModel.js +48 -16
  15. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
  16. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
  17. package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
  18. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
  19. package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
  20. package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
  21. package/lib/runjs-context/contexts/base.js +464 -29
  22. package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
  23. package/lib/runjs-context/contexts/elementDoc.js +152 -0
  24. package/lib/utils/loadedPageCache.d.ts +24 -0
  25. package/lib/utils/loadedPageCache.js +139 -0
  26. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  27. package/lib/utils/parsePathnameToViewParams.js +28 -4
  28. package/lib/views/ViewNavigation.d.ts +12 -2
  29. package/lib/views/ViewNavigation.js +22 -7
  30. package/lib/views/createViewMeta.js +114 -50
  31. package/lib/views/inheritLayoutContext.d.ts +10 -0
  32. package/lib/views/inheritLayoutContext.js +50 -0
  33. package/lib/views/useDialog.js +2 -0
  34. package/lib/views/useDrawer.js +2 -0
  35. package/lib/views/usePage.js +2 -0
  36. package/package.json +4 -4
  37. package/src/FlowContextProvider.tsx +9 -1
  38. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  39. package/src/__tests__/flowContext.test.ts +23 -0
  40. package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
  41. package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
  42. package/src/__tests__/runjsContext.test.ts +18 -0
  43. package/src/__tests__/runjsContextImplementations.test.ts +9 -2
  44. package/src/__tests__/runjsLocales.test.ts +6 -5
  45. package/src/__tests__/viewScopedFlowEngine.test.ts +133 -0
  46. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +90 -38
  47. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +155 -5
  48. package/src/components/subModel/LazyDropdown.tsx +237 -16
  49. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +254 -1
  50. package/src/components/subModel/utils.ts +6 -1
  51. package/src/data-source/index.ts +18 -0
  52. package/src/executor/FlowExecutor.ts +0 -3
  53. package/src/executor/__tests__/flowExecutor.test.ts +26 -0
  54. package/src/flowContext.ts +43 -6
  55. package/src/flowEngine.ts +75 -38
  56. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  57. package/src/models/__tests__/flowModel.test.ts +46 -62
  58. package/src/models/flowModel.tsx +65 -32
  59. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
  60. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
  61. package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
  62. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
  63. package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
  64. package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
  65. package/src/runjs-context/contexts/base.ts +467 -31
  66. package/src/runjs-context/contexts/elementDoc.ts +130 -0
  67. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
  68. package/src/utils/loadedPageCache.ts +147 -0
  69. package/src/utils/parsePathnameToViewParams.ts +45 -5
  70. package/src/views/ViewNavigation.ts +40 -7
  71. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  72. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  73. package/src/views/createViewMeta.ts +106 -34
  74. package/src/views/inheritLayoutContext.ts +26 -0
  75. package/src/views/useDialog.tsx +2 -0
  76. package/src/views/useDrawer.tsx +2 -0
  77. package/src/views/usePage.tsx +2 -0
@@ -8,9 +8,30 @@
8
8
  */
9
9
 
10
10
  import { reaction } from '@nocobase/flow-engine';
11
- import { beforeEach, describe, expect, it } from 'vitest';
11
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
12
12
  import { FlowEngine } from '../flowEngine';
13
13
  import { FlowModel } from '../models';
14
+ import type { IFlowModelRepository } from '../types';
15
+
16
+ class MoveRepository implements IFlowModelRepository {
17
+ move = vi.fn(async (_sourceId: string, _targetId: string, _position: 'before' | 'after'): Promise<void> => {});
18
+
19
+ async findOne(): Promise<Record<string, unknown> | null> {
20
+ return null;
21
+ }
22
+
23
+ async save(): Promise<Record<string, unknown>> {
24
+ return {};
25
+ }
26
+
27
+ async destroy(): Promise<boolean> {
28
+ return true;
29
+ }
30
+
31
+ async duplicate(): Promise<Record<string, unknown> | null> {
32
+ return null;
33
+ }
34
+ }
14
35
 
15
36
  describe('FlowEngine moveModel', () => {
16
37
  let engine: FlowEngine;
@@ -20,6 +41,14 @@ describe('FlowEngine moveModel', () => {
20
41
  engine.registerModels({ FlowModel });
21
42
  });
22
43
 
44
+ const createParentWithChildren = () => {
45
+ const parent = engine.createModel({ uid: 'parent', use: 'FlowModel' });
46
+ parent.addSubModel('items', { uid: 'child-a', use: 'FlowModel' });
47
+ parent.addSubModel('items', { uid: 'child-b', use: 'FlowModel' });
48
+ parent.addSubModel('items', { uid: 'child-c', use: 'FlowModel' });
49
+ return parent;
50
+ };
51
+
23
52
  it('keeps subModels array reactive after move so later additions trigger reactions', async () => {
24
53
  const parent = engine.createModel({ uid: 'parent', use: 'FlowModel' });
25
54
  parent.addSubModel('items', { uid: 'child-a', use: 'FlowModel' });
@@ -40,4 +69,55 @@ describe('FlowEngine moveModel', () => {
40
69
  dispose();
41
70
  expect(seen).toEqual([3]);
42
71
  });
72
+
73
+ it('persists an after move when dragging forward', async () => {
74
+ const repository = new MoveRepository();
75
+ engine.setModelRepository(repository);
76
+ const parent = createParentWithChildren();
77
+
78
+ await engine.moveModel('child-a', 'child-c');
79
+
80
+ expect(repository.move).toHaveBeenCalledWith('child-a', 'child-c', 'after');
81
+ expect((parent.subModels.items as FlowModel[]).map((item) => item.uid)).toEqual(['child-b', 'child-c', 'child-a']);
82
+ });
83
+
84
+ it('persists a before move when dragging backward', async () => {
85
+ const repository = new MoveRepository();
86
+ engine.setModelRepository(repository);
87
+ const parent = createParentWithChildren();
88
+
89
+ await engine.moveModel('child-c', 'child-a');
90
+
91
+ expect(repository.move).toHaveBeenCalledWith('child-c', 'child-a', 'before');
92
+ expect((parent.subModels.items as FlowModel[]).map((item) => item.uid)).toEqual(['child-c', 'child-a', 'child-b']);
93
+ });
94
+
95
+ it('does not persist self-drop', async () => {
96
+ const repository = new MoveRepository();
97
+ engine.setModelRepository(repository);
98
+ const parent = createParentWithChildren();
99
+
100
+ await engine.moveModel('child-a', 'child-a');
101
+
102
+ expect(repository.move).not.toHaveBeenCalled();
103
+ expect((parent.subModels.items as FlowModel[]).map((item) => item.uid)).toEqual(['child-a', 'child-b', 'child-c']);
104
+ });
105
+
106
+ it('keeps null sortIndex subModels in stable order', () => {
107
+ const parent = engine.createModel({
108
+ uid: 'parent',
109
+ use: 'FlowModel',
110
+ subModels: {
111
+ items: [
112
+ { uid: 'child-a', use: 'FlowModel', sortIndex: null as unknown as number },
113
+ { uid: 'child-b', use: 'FlowModel', sortIndex: null as unknown as number },
114
+ ],
115
+ },
116
+ });
117
+
118
+ parent.addSubModel('items', { uid: 'child-c', use: 'FlowModel' });
119
+
120
+ expect((parent.subModels.items as FlowModel[]).map((item) => item.uid)).toEqual(['child-a', 'child-b', 'child-c']);
121
+ expect((parent.subModels.items as FlowModel[]).map((item) => item.sortIndex)).toEqual([1, 2, 3]);
122
+ });
43
123
  });
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { beforeEach, describe, expect, it } from 'vitest';
10
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
11
11
  import { FlowEngine } from '../flowEngine';
12
12
  import { FlowModel } from '../models';
13
13
 
@@ -20,6 +20,7 @@ describe('FlowEngine removeModel', () => {
20
20
  });
21
21
 
22
22
  it('removeModel should remove model but keep sub-models in cache (current behavior)', () => {
23
+ const loggerSpy = vi.spyOn(engine.logger, 'debug').mockImplementation(() => {});
23
24
  const parent = engine.createModel({ uid: 'parent', use: 'FlowModel' });
24
25
  const child = engine.createModel({
25
26
  uid: 'child',
@@ -32,14 +33,53 @@ describe('FlowEngine removeModel', () => {
32
33
  expect(engine.getModel('parent')).toBe(parent);
33
34
  expect(engine.getModel('child')).toBe(child);
34
35
 
35
- engine.removeModel('parent');
36
+ try {
37
+ engine.removeModel('parent');
38
+ } finally {
39
+ loggerSpy.mockRestore();
40
+ }
36
41
 
37
42
  expect(engine.getModel('parent')).toBeUndefined();
38
43
  // Current behavior: child is still in cache
39
44
  expect(engine.getModel('child')).toBeDefined();
40
45
  });
41
46
 
47
+ it('removeModel should log missing models at debug level', () => {
48
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
49
+ const loggerSpy = vi.spyOn(engine.logger, 'debug').mockImplementation(() => {});
50
+
51
+ try {
52
+ expect(engine.removeModel('missing')).toBe(false);
53
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
54
+ expect(loggerSpy).toHaveBeenCalledWith("FlowEngine: Model with UID 'missing' does not exist.");
55
+ } finally {
56
+ consoleWarnSpy.mockRestore();
57
+ loggerSpy.mockRestore();
58
+ }
59
+ });
60
+
61
+ it('should reduce default logger verbosity in production', () => {
62
+ const originalNodeEnv = process.env.NODE_ENV;
63
+
64
+ try {
65
+ process.env.NODE_ENV = 'production';
66
+
67
+ const productionEngine = new FlowEngine();
68
+
69
+ expect(productionEngine.logger.level).toBe('warn');
70
+ expect(productionEngine.logger.isLevelEnabled('debug')).toBe(false);
71
+ expect(productionEngine.logger.isLevelEnabled('warn')).toBe(true);
72
+ } finally {
73
+ if (originalNodeEnv === undefined) {
74
+ delete process.env.NODE_ENV;
75
+ } else {
76
+ process.env.NODE_ENV = originalNodeEnv;
77
+ }
78
+ }
79
+ });
80
+
42
81
  it('removeModelWithSubModels should remove model and all sub-models from cache', () => {
82
+ const loggerSpy = vi.spyOn(engine.logger, 'debug').mockImplementation(() => {});
43
83
  const parent = engine.createModel({ uid: 'parent', use: 'FlowModel' });
44
84
  const child = engine.createModel({
45
85
  uid: 'child',
@@ -63,7 +103,11 @@ describe('FlowEngine removeModel', () => {
63
103
  expect(engine.getModel('child')).toBe(child);
64
104
  expect(engine.getModel('grandChild')).toBe(grandChild);
65
105
 
66
- engine.removeModelWithSubModels('parent');
106
+ try {
107
+ engine.removeModelWithSubModels('parent');
108
+ } finally {
109
+ loggerSpy.mockRestore();
110
+ }
67
111
 
68
112
  expect(engine.getModel('parent')).toBeUndefined();
69
113
  expect(engine.getModel('child')).toBeUndefined();
@@ -88,6 +88,19 @@ describe('flowRunJSContext registry and doc', () => {
88
88
  const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
89
89
  expect(doc).toBeTruthy();
90
90
  expect(doc?.label).toMatch(/RunJS base/);
91
+ expect(doc?.properties?.element).toBeUndefined();
92
+ });
93
+
94
+ it('should mark element-dependent base completions with element requirement', () => {
95
+ const ctx: any = { model: { constructor: { name: 'UnknownModel' } } };
96
+ const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
97
+
98
+ expect((doc?.methods?.render as any)?.completion?.requires).toContain('element');
99
+ expect((doc?.properties?.viewer as any)?.properties?.popover?.completion?.requires).toContain('element');
100
+ expect((doc?.properties?.viewer as any)?.properties?.embed?.completion?.requires).toContain('element');
101
+ expect(
102
+ (doc?.properties?.libs as any)?.properties?.ReactDOM?.properties?.createRoot?.completion?.requires,
103
+ ).toContain('element');
91
104
  });
92
105
 
93
106
  it('should support locale-specific doc', () => {
@@ -99,6 +112,11 @@ describe('flowRunJSContext registry and doc', () => {
99
112
  const messageText =
100
113
  typeof message === 'string' ? message : (message as any)?.description ?? (message as any)?.detail ?? '';
101
114
  expect(String(messageText)).toMatch(/Ant Design 全局消息/);
115
+ expect((doc?.methods?.render as any)?.completion?.requires).toContain('element');
116
+ expect((doc?.properties?.viewer as any)?.properties?.popover?.completion?.requires).toContain('element');
117
+ expect(
118
+ (doc?.properties?.libs as any)?.properties?.ReactDOM?.properties?.createRoot?.completion?.requires,
119
+ ).toContain('element');
102
120
  });
103
121
 
104
122
  it('should fallback to English when locale is not found', () => {
@@ -27,7 +27,10 @@ describe('Specific RunJSContext implementations', () => {
27
27
  const ctx: any = { model: { constructor: { name: 'JSColumnModel' } } };
28
28
  const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
29
29
  expect(doc?.properties?.element).toBeTruthy();
30
- expect(doc?.properties?.element).toContain('ElementProxy');
30
+ const elementDoc: any = doc?.properties?.element;
31
+ expect(elementDoc?.detail).toContain('ElementProxy');
32
+ expect(elementDoc?.properties?.setAttribute).toBeTruthy();
33
+ expect(elementDoc?.properties?.querySelector).toBeTruthy();
31
34
  });
32
35
 
33
36
  it('should have record property in doc', () => {
@@ -68,7 +71,9 @@ describe('Specific RunJSContext implementations', () => {
68
71
  (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } });
69
72
  const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
70
73
  expect(doc?.label).toMatch(/JS 列/);
71
- expect(doc?.properties?.element).toContain('表格单元格');
74
+ const elementDoc: any = doc?.properties?.element;
75
+ expect(elementDoc?.description).toContain('表格单元格');
76
+ expect(elementDoc?.properties?.addEventListener).toBeTruthy();
72
77
  });
73
78
 
74
79
  it('should create instance successfully', () => {
@@ -162,6 +167,7 @@ describe('Specific RunJSContext implementations', () => {
162
167
  const ctx: any = { model: { constructor: { name: 'JSRecordActionModel' } } };
163
168
  const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
164
169
  expect(doc?.properties?.record).toBeTruthy();
170
+ expect(doc?.properties?.element).toBeUndefined();
165
171
  });
166
172
 
167
173
  it('should have filterByTk property', () => {
@@ -184,6 +190,7 @@ describe('Specific RunJSContext implementations', () => {
184
190
  const ctx: any = { model: { constructor: { name: 'JSCollectionActionModel' } } };
185
191
  const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
186
192
  expect(doc?.properties?.resource).toBeTruthy();
193
+ expect(doc?.properties?.element).toBeUndefined();
187
194
  });
188
195
 
189
196
  it('should support zh-CN locale', () => {
@@ -12,6 +12,10 @@ import { getRunJSDocFor } from '..';
12
12
  import { setupRunJSContexts } from '../runjs-context/setup';
13
13
  import { FlowContext } from '../flowContext';
14
14
 
15
+ function getRunJSDocText(doc: unknown) {
16
+ return typeof doc === 'string' ? doc : (doc as any)?.description ?? (doc as any)?.detail ?? '';
17
+ }
18
+
15
19
  describe('RunJS locales patch (engine doc)', () => {
16
20
  beforeAll(async () => {
17
21
  await setupRunJSContexts();
@@ -30,10 +34,7 @@ describe('RunJS locales patch (engine doc)', () => {
30
34
  (ctx as any).defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } });
31
35
  (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } });
32
36
  const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
33
- const message = doc?.properties?.message;
34
- const messageText =
35
- typeof message === 'string' ? message : (message as any)?.description ?? (message as any)?.detail ?? '';
36
- expect(String(messageText)).toMatch(/Ant Design 全局消息 API/);
37
- expect(String(doc?.methods?.t || '')).toMatch(/国际化函数/);
37
+ expect(String(getRunJSDocText(doc?.properties?.message))).toMatch(/Ant Design 全局消息 API/);
38
+ expect(String(getRunJSDocText(doc?.methods?.t))).toMatch(/国际化函数/);
38
39
  });
39
40
  });
@@ -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
  });
@@ -237,6 +237,15 @@ const getToolbarPopupContainer = (triggerNode?: HTMLElement | null) => {
237
237
  );
238
238
  };
239
239
 
240
+ const removeExtraMenuItemClickHandlers = (item: FlowModelExtraMenuItem): FlowModelExtraMenuItem => {
241
+ const { onClick: _onClick, children, ...rest } = item;
242
+
243
+ return {
244
+ ...rest,
245
+ children: children?.length ? children.map(removeExtraMenuItemClickHandlers) : undefined,
246
+ };
247
+ };
248
+
240
249
  export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
241
250
  model,
242
251
  showDeleteButton = true,
@@ -254,8 +263,18 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
254
263
  // 当模型发生子模型替换/增删等变化时,强制刷新菜单数据
255
264
  const [refreshTick, setRefreshTick] = useState(0);
256
265
  const [extraMenuItems, setExtraMenuItems] = useState<FlowModelExtraMenuItem[]>([]);
266
+ const [extraMenuItemsLoaded, setExtraMenuItemsLoaded] = useState(false);
257
267
  const [configurableFlowsAndSteps, setConfigurableFlowsAndSteps] = useState<FlowInfo[]>([]);
258
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);
259
278
  const closeDropdown = useCallback(() => {
260
279
  setVisible(false);
261
280
  onDropdownVisibleChange?.(false);
@@ -294,26 +313,30 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
294
313
  useEffect(() => {
295
314
  let mounted = true;
296
315
  const loadExtras = async () => {
297
- const allExtras: FlowModelExtraMenuItem[] = [];
298
- const modelsToProcess: Array<{ model: FlowModel; modelKey?: string }> = [];
299
- walkSubModels(model, { maxDepth: menuLevels, arrayLimit: 50, mode: 'stack' }, (targetModel, { modelKey }) => {
300
- modelsToProcess.push({ model: targetModel, modelKey });
301
- });
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
+ });
302
323
 
303
- for (const { model: targetModel, modelKey } of modelsToProcess) {
304
- const Cls = targetModel.constructor as typeof FlowModel;
305
- const extras = await Cls.getExtraMenuItems?.(targetModel, t);
306
- if (extras?.length) {
307
- allExtras.push(
308
- ...extras.map((item) => ({
309
- ...item,
310
- key: modelKey ? `${modelKey}:${item.key}` : item.key,
311
- })),
312
- );
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
+ }
313
335
  }
314
- }
315
336
 
316
- if (mounted) {
337
+ if (!mounted) {
338
+ return;
339
+ }
317
340
  const seen = new Set<string>();
318
341
  const dedupedExtras = allExtras.filter((item) => {
319
342
  if (seen.has(`${item.key}`)) {
@@ -323,16 +346,22 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
323
346
  return true;
324
347
  });
325
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
+ }
326
358
  }
327
359
  };
328
- // 避免 effect 触发 setState 导致循环:仅在 visible 打开时加载一次,关闭后仍保留结果
329
- if (visible) {
330
- loadExtras();
331
- }
360
+ loadExtras();
332
361
  return () => {
333
362
  mounted = false;
334
363
  };
335
- }, [model, menuLevels, t, refreshTick, visible]);
364
+ }, [model, menuLevels, t, refreshTick]);
336
365
 
337
366
  // 统一的复制 UID 方法
338
367
  const copyUidToClipboard = useCallback(
@@ -623,7 +652,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
623
652
  return [];
624
653
  }
625
654
  },
626
- [],
655
+ [t],
627
656
  );
628
657
 
629
658
  // 获取可配置的flows和steps
@@ -666,21 +695,50 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
666
695
  }, [model, menuLevels, refreshTick]);
667
696
 
668
697
  useEffect(() => {
698
+ let mounted = true;
669
699
  const loadConfigurableFlowsAndSteps = async () => {
670
700
  setIsLoading(true);
701
+ if (shouldDeferConfigLoading) {
702
+ setConfigurableFlowsAndSteps([]);
703
+ }
671
704
  try {
672
705
  const flows = await getConfigurableFlowsAndSteps();
673
- setConfigurableFlowsAndSteps(flows);
706
+ if (mounted) {
707
+ setConfigurableFlowsAndSteps(flows);
708
+ }
674
709
  } catch (error) {
675
710
  console.error('Failed to load configurable flows and steps:', error);
676
- setConfigurableFlowsAndSteps([]);
711
+ if (mounted) {
712
+ setConfigurableFlowsAndSteps([]);
713
+ }
677
714
  } finally {
678
- setIsLoading(false);
715
+ if (mounted) {
716
+ setIsLoading(false);
717
+ }
679
718
  }
680
719
  };
681
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
+
682
737
  loadConfigurableFlowsAndSteps();
683
- }, [getConfigurableFlowsAndSteps, refreshTick]);
738
+ return () => {
739
+ mounted = false;
740
+ };
741
+ }, [getConfigurableFlowsAndSteps, refreshTick, shouldDeferConfigLoading, shouldWaitForCommonActionProbe, visible]);
684
742
 
685
743
  // 构建菜单项,包含错误处理和记忆化
686
744
  const menuItems = useMemo((): NonNullable<MenuProps['items']> => {
@@ -847,16 +905,12 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
847
905
  }
848
906
 
849
907
  return items;
850
- }, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, t]);
908
+ }, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, message, model, t]);
851
909
 
852
910
  // 向菜单项添加额外按钮
853
911
  const finalMenuItems = useMemo((): NonNullable<MenuProps['items']> => {
854
912
  const items = [...menuItems];
855
913
 
856
- const commonExtras = extraMenuItems
857
- .filter((it) => it.group === 'common-actions')
858
- .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
859
-
860
914
  if (showCopyUidButton || showDeleteButton || commonExtras.length > 0) {
861
915
  items.push({
862
916
  type: 'divider',
@@ -870,7 +924,8 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
870
924
  // });
871
925
 
872
926
  if (commonExtras.length > 0) {
873
- items.push(...(commonExtras as MenuProps['items']));
927
+ // Antd Menu 会同时触发 item.onClick menu.onClick,这里统一交给 handleMenuClick 执行。
928
+ items.push(...(commonExtras.map(removeExtraMenuItemClickHandlers) as MenuProps['items']));
874
929
  }
875
930
 
876
931
  // 添加复制uid按钮
@@ -891,12 +946,9 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
891
946
  }
892
947
 
893
948
  return items;
894
- }, [menuItems, showCopyUidButton, showDeleteButton, model.uid, model.destroy, t, extraMenuItems]);
895
-
896
- // 如果正在加载或没有可配置的flows且不显示删除按钮和复制UID按钮,不显示菜单
897
- const hasExtras = extraMenuItems.some((it) => it.group === 'common-actions');
949
+ }, [menuItems, showCopyUidButton, showDeleteButton, commonExtras, model.uid, model.destroy, t]);
898
950
 
899
- if (isLoading || (configurableFlowsAndSteps.length === 0 && !showDeleteButton && !showCopyUidButton && !hasExtras)) {
951
+ if (!canRenderIcon) {
900
952
  return null;
901
953
  }
902
954