@nocobase/flow-engine 2.1.0-alpha.2 → 2.1.0-alpha.20

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 (123) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/JSRunner.d.ts +10 -1
  4. package/lib/JSRunner.js +50 -5
  5. package/lib/ViewScopedFlowEngine.js +5 -1
  6. package/lib/components/FlowModelRenderer.d.ts +1 -1
  7. package/lib/components/MobilePopup.js +6 -5
  8. package/lib/components/dnd/gridDragPlanner.js +6 -2
  9. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  10. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +48 -9
  11. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +19 -43
  12. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +332 -296
  13. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  14. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  15. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +272 -0
  16. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  17. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +247 -0
  18. package/lib/components/subModel/AddSubModelButton.js +27 -1
  19. package/lib/components/subModel/utils.js +2 -2
  20. package/lib/data-source/index.js +6 -0
  21. package/lib/executor/FlowExecutor.js +31 -8
  22. package/lib/flowContext.js +31 -1
  23. package/lib/flowEngine.d.ts +151 -1
  24. package/lib/flowEngine.js +389 -15
  25. package/lib/flowSettings.d.ts +14 -6
  26. package/lib/flowSettings.js +34 -6
  27. package/lib/lazy-helper.d.ts +14 -0
  28. package/lib/lazy-helper.js +71 -0
  29. package/lib/locale/en-US.json +1 -0
  30. package/lib/locale/index.d.ts +2 -0
  31. package/lib/locale/zh-CN.json +1 -0
  32. package/lib/models/flowModel.js +17 -7
  33. package/lib/reactive/observer.js +46 -16
  34. package/lib/runjs-context/registry.d.ts +1 -1
  35. package/lib/runjs-context/setup.js +20 -12
  36. package/lib/runjs-context/snippets/index.js +13 -2
  37. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  38. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  39. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  40. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  41. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  42. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  43. package/lib/types.d.ts +47 -1
  44. package/lib/utils/index.d.ts +2 -2
  45. package/lib/utils/index.js +4 -0
  46. package/lib/utils/parsePathnameToViewParams.js +1 -1
  47. package/lib/utils/runjsTemplateCompat.js +1 -1
  48. package/lib/utils/runjsValue.js +41 -11
  49. package/lib/utils/schema-utils.d.ts +7 -1
  50. package/lib/utils/schema-utils.js +19 -0
  51. package/lib/views/FlowView.d.ts +7 -1
  52. package/lib/views/runViewBeforeClose.d.ts +10 -0
  53. package/lib/views/runViewBeforeClose.js +45 -0
  54. package/lib/views/useDialog.d.ts +2 -1
  55. package/lib/views/useDialog.js +20 -3
  56. package/lib/views/useDrawer.d.ts +2 -1
  57. package/lib/views/useDrawer.js +20 -3
  58. package/lib/views/usePage.d.ts +2 -1
  59. package/lib/views/usePage.js +10 -3
  60. package/package.json +6 -5
  61. package/src/JSRunner.ts +68 -4
  62. package/src/ViewScopedFlowEngine.ts +4 -0
  63. package/src/__tests__/JSRunner.test.ts +27 -1
  64. package/src/__tests__/flow-engine.test.ts +166 -0
  65. package/src/__tests__/flowContext.test.ts +65 -1
  66. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  67. package/src/__tests__/flowSettings.test.ts +94 -15
  68. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  69. package/src/__tests__/runjsContext.test.ts +16 -0
  70. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  71. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  72. package/src/__tests__/runjsSnippets.test.ts +21 -0
  73. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  74. package/src/components/FlowModelRenderer.tsx +3 -1
  75. package/src/components/MobilePopup.tsx +4 -2
  76. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  77. package/src/components/__tests__/gridDragPlanner.test.ts +88 -0
  78. package/src/components/dnd/gridDragPlanner.ts +8 -2
  79. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +63 -9
  80. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +457 -440
  81. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  82. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +95 -0
  83. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +547 -0
  84. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +358 -0
  85. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +281 -0
  86. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  87. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +142 -32
  88. package/src/components/subModel/utils.ts +1 -1
  89. package/src/data-source/index.ts +6 -0
  90. package/src/executor/FlowExecutor.ts +34 -9
  91. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  92. package/src/flowContext.ts +35 -3
  93. package/src/flowEngine.ts +445 -11
  94. package/src/flowSettings.ts +40 -6
  95. package/src/lazy-helper.tsx +57 -0
  96. package/src/locale/en-US.json +1 -0
  97. package/src/locale/zh-CN.json +1 -0
  98. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  99. package/src/models/flowModel.tsx +18 -6
  100. package/src/reactive/__tests__/observer.test.tsx +82 -0
  101. package/src/reactive/observer.tsx +87 -25
  102. package/src/runjs-context/registry.ts +1 -1
  103. package/src/runjs-context/setup.ts +22 -12
  104. package/src/runjs-context/snippets/index.ts +12 -1
  105. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  106. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  107. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  108. package/src/types.ts +60 -0
  109. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
  110. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  111. package/src/utils/__tests__/utils.test.ts +62 -0
  112. package/src/utils/index.ts +2 -1
  113. package/src/utils/parsePathnameToViewParams.ts +2 -2
  114. package/src/utils/runjsTemplateCompat.ts +1 -1
  115. package/src/utils/runjsValue.ts +50 -11
  116. package/src/utils/schema-utils.ts +30 -1
  117. package/src/views/FlowView.tsx +11 -1
  118. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  119. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  120. package/src/views/runViewBeforeClose.ts +19 -0
  121. package/src/views/useDialog.tsx +25 -3
  122. package/src/views/useDrawer.tsx +25 -3
  123. package/src/views/usePage.tsx +12 -3
@@ -189,4 +189,170 @@ describe('FlowEngine', () => {
189
189
  expect(mounted?.uid).toBe('c3');
190
190
  });
191
191
  });
192
+
193
+ describe('getSubclassesOfAsync', () => {
194
+ it('should return async-loaded subclasses matching extends declaration', async () => {
195
+ class AsyncSubModelD extends BaseModel {}
196
+ class AsyncSubModelE extends BaseModel {}
197
+
198
+ engine.registerModelLoaders({
199
+ AsyncSubModelD: {
200
+ extends: 'BaseModel',
201
+ loader: async () => ({ AsyncSubModelD }),
202
+ },
203
+ AsyncSubModelE: {
204
+ extends: 'BaseModel',
205
+ loader: async () => ({ AsyncSubModelE }),
206
+ },
207
+ });
208
+
209
+ const result = await engine.getSubclassesOfAsync(BaseModel);
210
+
211
+ // Sync-registered subclasses
212
+ expect(result.has('SubModelA')).toBe(true);
213
+ expect(result.has('SubModelB')).toBe(true);
214
+ expect(result.has('SubModelC')).toBe(true);
215
+ // Async-loaded subclasses
216
+ expect(result.has('AsyncSubModelD')).toBe(true);
217
+ expect(result.has('AsyncSubModelE')).toBe(true);
218
+ // Base class excluded
219
+ expect(result.has('BaseModel')).toBe(false);
220
+ });
221
+
222
+ it('should merge sync-registered and async-loaded subclasses', async () => {
223
+ class AsyncSubModel extends BaseModel {}
224
+
225
+ engine.registerModelLoaders({
226
+ AsyncSubModel: {
227
+ extends: 'BaseModel',
228
+ loader: async () => ({ AsyncSubModel }),
229
+ },
230
+ });
231
+
232
+ const result = await engine.getSubclassesOfAsync('BaseModel');
233
+
234
+ // Sync: SubModelA, SubModelB, SubModelC
235
+ expect(result.has('SubModelA')).toBe(true);
236
+ expect(result.has('SubModelB')).toBe(true);
237
+ expect(result.has('SubModelC')).toBe(true);
238
+ // Async
239
+ expect(result.has('AsyncSubModel')).toBe(true);
240
+ expect(result.size).toBe(4);
241
+ });
242
+
243
+ it('should support extends as string array (multiple parents)', async () => {
244
+ class AnotherBase extends FlowModel {}
245
+ class MultiParentModel extends BaseModel {}
246
+
247
+ engine.registerModels({ AnotherBase });
248
+ engine.registerModelLoaders({
249
+ MultiParentModel: {
250
+ extends: ['BaseModel', 'AnotherBase'],
251
+ loader: async () => ({ MultiParentModel }),
252
+ },
253
+ });
254
+
255
+ const resultBase = await engine.getSubclassesOfAsync(BaseModel);
256
+ expect(resultBase.has('MultiParentModel')).toBe(true);
257
+
258
+ // Also found by AnotherBase (even though actual inheritance is from BaseModel, not AnotherBase)
259
+ // The extends declaration triggers loading, but isInheritedFrom validation will exclude it from AnotherBase results
260
+ const resultAnother = await engine.getSubclassesOfAsync(AnotherBase);
261
+ expect(resultAnother.has('MultiParentModel')).toBe(false);
262
+ });
263
+
264
+ it('should support extends as ModelConstructor', async () => {
265
+ class AsyncCtorSubModel extends BaseModel {}
266
+
267
+ engine.registerModelLoaders({
268
+ AsyncCtorSubModel: {
269
+ extends: BaseModel,
270
+ loader: async () => ({ AsyncCtorSubModel }),
271
+ },
272
+ });
273
+
274
+ const result = await engine.getSubclassesOfAsync(BaseModel);
275
+ expect(result.has('AsyncCtorSubModel')).toBe(true);
276
+ });
277
+
278
+ it('should validate actual inheritance and warn on mismatch', async () => {
279
+ class UnrelatedModel extends FlowModel {}
280
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
281
+
282
+ engine.registerModelLoaders({
283
+ UnrelatedModel: {
284
+ extends: 'BaseModel',
285
+ loader: async () => ({ UnrelatedModel }),
286
+ },
287
+ });
288
+
289
+ const result = await engine.getSubclassesOfAsync(BaseModel);
290
+ expect(result.has('UnrelatedModel')).toBe(false);
291
+ expect(warnSpy).toHaveBeenCalledWith(
292
+ expect.stringContaining("declares extends 'BaseModel' but does not actually inherit from it"),
293
+ );
294
+
295
+ warnSpy.mockRestore();
296
+ });
297
+
298
+ it('should resolve base class from loaders if not in _modelClasses', async () => {
299
+ const freshEngine = new FlowEngine();
300
+
301
+ class LazyBase extends FlowModel {}
302
+ class LazySub extends LazyBase {}
303
+
304
+ freshEngine.registerModelLoaders({
305
+ LazyBase: {
306
+ loader: async () => ({ LazyBase }),
307
+ },
308
+ LazySub: {
309
+ extends: 'LazyBase',
310
+ loader: async () => ({ LazySub }),
311
+ },
312
+ });
313
+
314
+ const result = await freshEngine.getSubclassesOfAsync('LazyBase');
315
+ expect(result.has('LazySub')).toBe(true);
316
+ expect(result.size).toBe(1);
317
+ });
318
+
319
+ it('should return empty Map when base class cannot be found', async () => {
320
+ const result = await engine.getSubclassesOfAsync('NonExistentModel');
321
+ expect(result.size).toBe(0);
322
+ });
323
+
324
+ it('should support filter parameter on both sync and async sources', async () => {
325
+ class FilteredAsyncModel extends BaseModel {}
326
+
327
+ engine.registerModelLoaders({
328
+ FilteredAsyncModel: {
329
+ extends: 'BaseModel',
330
+ loader: async () => ({ FilteredAsyncModel }),
331
+ },
332
+ });
333
+
334
+ const result = await engine.getSubclassesOfAsync(BaseModel, (_ModelClass, name) => name.startsWith('SubModelA'));
335
+
336
+ // Only SubModelA passes the filter (SubModelB, SubModelC, FilteredAsyncModel excluded)
337
+ expect(result.has('SubModelA')).toBe(true);
338
+ expect(result.has('SubModelB')).toBe(false);
339
+ expect(result.has('SubModelC')).toBe(false);
340
+ expect(result.has('FilteredAsyncModel')).toBe(false);
341
+ });
342
+
343
+ it('should not include loaders without extends declaration', async () => {
344
+ class NoExtendsModel extends BaseModel {}
345
+
346
+ engine.registerModelLoaders({
347
+ NoExtendsModel: {
348
+ loader: async () => ({ NoExtendsModel }),
349
+ },
350
+ });
351
+
352
+ const result = await engine.getSubclassesOfAsync(BaseModel);
353
+ // Only sync-registered subclasses; NoExtendsModel has no extends, so not discovered
354
+ expect(result.has('NoExtendsModel')).toBe(false);
355
+ expect(result.has('SubModelA')).toBe(true);
356
+ });
357
+ });
192
358
  });
@@ -7,7 +7,8 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { describe, expect, it, vi } from 'vitest';
10
+ import axios from 'axios';
11
+ import { describe, expect, it, vi, afterEach } from 'vitest';
11
12
  import { FlowContext, FlowRuntimeContext, FlowRunJSContext, type PropertyMetaFactory } from '../flowContext';
12
13
  import { FlowEngine } from '../flowEngine';
13
14
  import { FlowModel } from '../models/flowModel';
@@ -1630,6 +1631,69 @@ describe('runAction delegation from runtime context', () => {
1630
1631
  });
1631
1632
  });
1632
1633
 
1634
+ describe('FlowContext request defaults', () => {
1635
+ class RequestModel extends FlowModel {}
1636
+
1637
+ afterEach(() => {
1638
+ vi.restoreAllMocks();
1639
+ });
1640
+
1641
+ const createRequestContext = () => {
1642
+ const engine = new FlowEngine();
1643
+ engine.registerModels({ RequestModel });
1644
+
1645
+ const apiRequest = vi.fn(async (options) => options);
1646
+ const app = {
1647
+ getApiUrl(pathname = '') {
1648
+ return 'https://app.example.com/api/'.replace(/\/$/g, '') + '/' + pathname.replace(/^\//g, '');
1649
+ },
1650
+ };
1651
+
1652
+ engine.context.defineProperty('api', { value: { request: apiRequest } as any });
1653
+ engine.context.defineProperty('app', { value: app });
1654
+
1655
+ const model = engine.createModel({ use: 'RequestModel' });
1656
+ const ctx = new FlowRuntimeContext(model, 'flow');
1657
+ const directAxiosRequest = vi.spyOn(axios, 'request').mockResolvedValue({ data: {} } as any);
1658
+
1659
+ return { ctx, apiRequest, directAxiosRequest };
1660
+ };
1661
+
1662
+ it.each([
1663
+ ['apiClient', 'users:list', 'api'],
1664
+ ['apiClient', '/api/users:list', 'api'],
1665
+ ['apiClient', 'https://app.example.com/api/users:list', 'api'],
1666
+ ['direct axios', 'https://app.example.com/custom-api/users', 'axios'],
1667
+ ])('should use %s for %s', async (_target, url, expected) => {
1668
+ const { ctx, apiRequest, directAxiosRequest } = createRequestContext();
1669
+
1670
+ await ctx.request({ url, method: 'get' });
1671
+
1672
+ if (expected === 'api') {
1673
+ expect(apiRequest).toHaveBeenCalledTimes(1);
1674
+ expect(directAxiosRequest).not.toHaveBeenCalled();
1675
+ return;
1676
+ }
1677
+
1678
+ expect(directAxiosRequest).toHaveBeenCalledTimes(1);
1679
+ expect(apiRequest).not.toHaveBeenCalled();
1680
+ });
1681
+
1682
+ it('should use direct axios for cross-origin absolute urls', async () => {
1683
+ const { ctx, apiRequest, directAxiosRequest } = createRequestContext();
1684
+
1685
+ await ctx.request({ url: 'https://api.example.com/users', method: 'get', skipAuth: false });
1686
+
1687
+ expect(directAxiosRequest).toHaveBeenCalledTimes(1);
1688
+ expect(apiRequest).not.toHaveBeenCalled();
1689
+ expect(directAxiosRequest.mock.calls[0][0]).toMatchObject({
1690
+ url: 'https://api.example.com/users',
1691
+ method: 'get',
1692
+ skipAuth: false,
1693
+ });
1694
+ });
1695
+ });
1696
+
1633
1697
  describe('FlowContext delayed meta loading', () => {
1634
1698
  // 测试场景:属性定义时 meta 为异步函数,首次访问时延迟加载
1635
1699
  // 输入:属性带有异步 meta 函数
@@ -0,0 +1,245 @@
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 { beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import { FlowEngine } from '../flowEngine';
12
+ import { ErrorFlowModel, FlowModel } from '../models';
13
+ import type { IFlowModelRepository } from '../types';
14
+
15
+ class MockFlowModelRepository implements IFlowModelRepository {
16
+ findOneResult: any = null;
17
+ save = vi.fn(async (model: FlowModel) => ({ success: true, uid: model.uid }));
18
+
19
+ async findOne() {
20
+ return this.findOneResult ? JSON.parse(JSON.stringify(this.findOneResult)) : null;
21
+ }
22
+
23
+ async destroy() {
24
+ return true;
25
+ }
26
+
27
+ async move() {}
28
+
29
+ async duplicate() {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ describe('FlowEngine model loaders', () => {
35
+ let engine: FlowEngine;
36
+ let repo: MockFlowModelRepository;
37
+
38
+ beforeEach(() => {
39
+ engine = new FlowEngine();
40
+ repo = new MockFlowModelRepository();
41
+ engine.setModelRepository(repo);
42
+ });
43
+
44
+ it('resolves explicit and meta-default model trees before synchronous creation', async () => {
45
+ class ParentModel extends FlowModel {}
46
+ class ChildModel extends FlowModel {}
47
+ class DefaultChildModel extends FlowModel {}
48
+
49
+ ParentModel.define({
50
+ createModelOptions: {
51
+ subModels: {
52
+ defaultChild: {
53
+ use: 'DefaultChildModel',
54
+ },
55
+ },
56
+ },
57
+ });
58
+
59
+ const parentLoader = vi.fn(async () => ({ ParentModel }));
60
+ const childLoader = vi.fn(async () => ({ ChildModel }));
61
+ const defaultChildLoader = vi.fn(async () => ({ DefaultChildModel }));
62
+
63
+ engine.registerModelLoaders({
64
+ ParentModel: { loader: parentLoader },
65
+ ChildModel: { loader: childLoader },
66
+ DefaultChildModel: { loader: defaultChildLoader },
67
+ });
68
+
69
+ const model = await engine.loadOrCreateModel({
70
+ uid: 'parent-model',
71
+ use: 'ParentModel',
72
+ subModels: {
73
+ child: {
74
+ use: 'ChildModel',
75
+ },
76
+ },
77
+ });
78
+
79
+ expect(model).toBeInstanceOf(ParentModel);
80
+ expect(model?.subModels.child).toBeInstanceOf(ChildModel);
81
+ expect(model?.subModels.defaultChild).toBeInstanceOf(DefaultChildModel);
82
+ expect(parentLoader).toHaveBeenCalledTimes(1);
83
+ expect(childLoader).toHaveBeenCalledTimes(1);
84
+ expect(defaultChildLoader).toHaveBeenCalledTimes(1);
85
+ });
86
+
87
+ it('resolves repository-loaded model trees before loadModel creates instances', async () => {
88
+ class RepoRootModel extends FlowModel {}
89
+ class RepoChildModel extends FlowModel {}
90
+
91
+ const rootLoader = vi.fn(async () => ({ RepoRootModel }));
92
+ const childLoader = vi.fn(async () => ({ RepoChildModel }));
93
+
94
+ engine.registerModelLoaders({
95
+ RepoRootModel: { loader: rootLoader },
96
+ RepoChildModel: { loader: childLoader },
97
+ });
98
+
99
+ repo.findOneResult = {
100
+ uid: 'repo-root',
101
+ use: 'RepoRootModel',
102
+ subModels: {
103
+ child: {
104
+ use: 'RepoChildModel',
105
+ },
106
+ },
107
+ };
108
+
109
+ const model = await engine.loadModel({ uid: 'repo-root' });
110
+
111
+ expect(model).toBeInstanceOf(RepoRootModel);
112
+ expect(model?.subModels.child).toBeInstanceOf(RepoChildModel);
113
+ expect(rootLoader).toHaveBeenCalledTimes(1);
114
+ expect(childLoader).toHaveBeenCalledTimes(1);
115
+ });
116
+
117
+ it('supports async model creation and async getters', async () => {
118
+ class AsyncRootModel extends FlowModel {}
119
+ class AsyncChildModel extends FlowModel {}
120
+
121
+ const rootLoader = vi.fn(async () => ({ AsyncRootModel }));
122
+ const childLoader = vi.fn(async () => ({ AsyncChildModel }));
123
+
124
+ engine.registerModelLoaders({
125
+ AsyncRootModel: { loader: rootLoader },
126
+ AsyncChildModel: { loader: childLoader },
127
+ });
128
+
129
+ const rootClass = await engine.getModelClassAsync('AsyncRootModel');
130
+ const classes = await engine.getModelClassesAsync();
131
+ const model = await engine.createModelAsync({
132
+ uid: 'async-root',
133
+ use: 'AsyncRootModel',
134
+ subModels: {
135
+ child: {
136
+ use: 'AsyncChildModel',
137
+ },
138
+ },
139
+ });
140
+
141
+ expect(rootClass).toBe(AsyncRootModel);
142
+ expect(classes.get('AsyncRootModel')).toBe(AsyncRootModel);
143
+ expect(classes.get('AsyncChildModel')).toBe(AsyncChildModel);
144
+ expect(model).toBeInstanceOf(AsyncRootModel);
145
+ expect(model.subModels.child).toBeInstanceOf(AsyncChildModel);
146
+ expect(rootLoader).toHaveBeenCalledTimes(1);
147
+ expect(childLoader).toHaveBeenCalledTimes(1);
148
+ });
149
+
150
+ it('createModelAsync degrades unresolved loader failures to ErrorFlowModel', async () => {
151
+ const invalidLoader = vi.fn(async () => ({ notAModel: {} }));
152
+
153
+ engine.registerModelLoaders({
154
+ BrokenRootModel: { loader: invalidLoader as any },
155
+ });
156
+
157
+ const model = await engine.createModelAsync({
158
+ uid: 'broken-root',
159
+ use: 'BrokenRootModel',
160
+ });
161
+
162
+ expect(model).toBeInstanceOf(ErrorFlowModel);
163
+ expect(invalidLoader).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ it('keeps loader resolution idempotent across resolveModelTree and flow settings preload', async () => {
167
+ class RuntimeResolvedModel extends FlowModel {}
168
+ class DesignResolvedModel extends FlowModel {}
169
+
170
+ const runtimeLoader = vi.fn(async () => ({ RuntimeResolvedModel }));
171
+ const designLoader = vi.fn(async () => ({ DesignResolvedModel }));
172
+
173
+ engine.registerModelLoaders({
174
+ RuntimeResolvedModel: { loader: runtimeLoader },
175
+ DesignResolvedModel: { loader: designLoader },
176
+ });
177
+
178
+ await engine.resolveModelTree({
179
+ use: 'RuntimeResolvedModel',
180
+ });
181
+
182
+ const firstPreload = await engine.preloadModelLoaders();
183
+ const secondPreload = await engine.preloadModelLoaders();
184
+
185
+ expect(runtimeLoader).toHaveBeenCalledTimes(1);
186
+ expect(designLoader).toHaveBeenCalledTimes(1);
187
+ expect(firstPreload.loaded).toContain('DesignResolvedModel');
188
+ expect(firstPreload.loaded).not.toContain('RuntimeResolvedModel');
189
+ expect(secondPreload.loaded).toHaveLength(0);
190
+ expect(secondPreload.failed).toHaveLength(0);
191
+ });
192
+
193
+ it('picks up newly registered loaders after preload has already completed', async () => {
194
+ class FirstModel extends FlowModel {}
195
+ class SecondModel extends FlowModel {}
196
+
197
+ const firstLoader = vi.fn(async () => ({ FirstModel }));
198
+ const secondLoader = vi.fn(async () => ({ SecondModel }));
199
+
200
+ engine.registerModelLoaders({
201
+ FirstModel: { loader: firstLoader },
202
+ });
203
+
204
+ await engine.preloadModelLoaders();
205
+ expect(firstLoader).toHaveBeenCalledTimes(1);
206
+ expect(engine.getModelClass('FirstModel')).toBe(FirstModel);
207
+
208
+ engine.registerModelLoaders({
209
+ SecondModel: { loader: secondLoader },
210
+ });
211
+
212
+ const result = await engine.preloadModelLoaders();
213
+
214
+ expect(secondLoader).toHaveBeenCalledTimes(1);
215
+ expect(result.loaded).toContain('SecondModel');
216
+ expect(engine.getModelClass('SecondModel')).toBe(SecondModel);
217
+ });
218
+
219
+ it('degrades unresolved loader failures to ErrorFlowModel instead of crashing runtime creation', async () => {
220
+ class ParentModel extends FlowModel {}
221
+
222
+ const parentLoader = vi.fn(async () => ({ ParentModel }));
223
+ const invalidChildLoader = vi.fn(async () => ({ notAModel: {} }));
224
+
225
+ engine.registerModelLoaders({
226
+ ParentModel: { loader: parentLoader },
227
+ BrokenChildModel: { loader: invalidChildLoader as any },
228
+ });
229
+
230
+ const model = await engine.loadOrCreateModel({
231
+ uid: 'parent-with-broken-child',
232
+ use: 'ParentModel',
233
+ subModels: {
234
+ child: {
235
+ use: 'BrokenChildModel',
236
+ },
237
+ },
238
+ });
239
+
240
+ expect(model).toBeInstanceOf(ParentModel);
241
+ expect(model?.subModels.child).toBeInstanceOf(ErrorFlowModel);
242
+ expect(parentLoader).toHaveBeenCalledTimes(1);
243
+ expect(invalidChildLoader).toHaveBeenCalledTimes(1);
244
+ });
245
+ });
@@ -8,6 +8,10 @@
8
8
  */
9
9
 
10
10
  import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
11
+ import React from 'react';
12
+ import { createForm } from '@formily/core';
13
+ import { createSchemaField, FormProvider } from '@formily/react';
14
+ import { render, screen } from '@testing-library/react';
11
15
  import { FlowSettings } from '../flowSettings';
12
16
  import { DefaultSettingsIcon } from '../components/settings/wrappers/contextual/DefaultSettingsIcon';
13
17
  import { FlowModel } from '../models';
@@ -142,10 +146,10 @@ describe('FlowSettings', () => {
142
146
  expect(settingsItem?.sort).toBe(0);
143
147
  });
144
148
 
145
- test('should set up observable properties', () => {
149
+ test('should set up observable properties', async () => {
146
150
  // Test that enabled property is reactive
147
151
  const initialEnabled = flowSettings.enabled;
148
- flowSettings.enable();
152
+ await flowSettings.enable();
149
153
  expect(flowSettings.enabled).not.toBe(initialEnabled);
150
154
  expect(flowSettings.enabled).toBe(true);
151
155
  });
@@ -186,6 +190,43 @@ describe('FlowSettings', () => {
186
190
  flowSettings.registerComponents({});
187
191
  expect(Object.keys(flowSettings.components)).toHaveLength(0);
188
192
  });
193
+
194
+ test('should register component loaders and load component on render', async () => {
195
+ const loader = vi.fn(async () => ({
196
+ default: () => React.createElement('div', null, 'Lazy Flow Settings Component'),
197
+ }));
198
+
199
+ flowSettings.registerComponentLoaders({
200
+ DemoFlowSettingsLazyField: loader,
201
+ });
202
+
203
+ expect(loader).not.toHaveBeenCalled();
204
+
205
+ const SchemaField = createSchemaField();
206
+ const form = createForm();
207
+
208
+ render(
209
+ React.createElement(
210
+ FormProvider,
211
+ { form },
212
+ React.createElement(SchemaField, {
213
+ schema: {
214
+ type: 'object',
215
+ properties: {
216
+ demo: {
217
+ type: 'void',
218
+ 'x-component': 'DemoFlowSettingsLazyField',
219
+ },
220
+ },
221
+ },
222
+ components: flowSettings.components,
223
+ }),
224
+ ),
225
+ );
226
+
227
+ expect(await screen.findByText('Lazy Flow Settings Component')).toBeInTheDocument();
228
+ expect(loader).toHaveBeenCalledTimes(1);
229
+ });
189
230
  });
190
231
 
191
232
  describe('Scope Registration', () => {
@@ -228,30 +269,68 @@ describe('FlowSettings', () => {
228
269
  });
229
270
 
230
271
  describe('Enable/Disable Functionality', () => {
231
- test('should enable flow settings', () => {
272
+ test('should enable flow settings', async () => {
232
273
  expect(flowSettings.enabled).toBe(false);
233
274
 
234
- flowSettings.enable();
275
+ await flowSettings.enable();
235
276
 
236
277
  expect(flowSettings.enabled).toBe(true);
237
278
  });
238
279
 
239
- test('should disable flow settings', () => {
240
- flowSettings.enable();
280
+ test('should preload model loaders before enabling flow settings', async () => {
281
+ const preloadSpy = vi.spyOn(engine, 'preloadModelLoaders').mockResolvedValue({
282
+ requested: [],
283
+ loaded: [],
284
+ failed: [],
285
+ });
286
+
287
+ await flowSettings.enable();
288
+
289
+ expect(preloadSpy).toHaveBeenCalledTimes(1);
241
290
  expect(flowSettings.enabled).toBe(true);
291
+ });
242
292
 
243
- flowSettings.disable();
293
+ test('should preload model loaders before force enabling flow settings', async () => {
294
+ const preloadSpy = vi.spyOn(engine, 'preloadModelLoaders').mockResolvedValue({
295
+ requested: [],
296
+ loaded: [],
297
+ failed: [],
298
+ });
299
+
300
+ await flowSettings.forceEnable();
301
+
302
+ expect(preloadSpy).toHaveBeenCalledTimes(1);
303
+ expect(flowSettings.enabled).toBe(true);
304
+ });
305
+
306
+ test('should disable flow settings', async () => {
307
+ await flowSettings.enable();
308
+ expect(flowSettings.enabled).toBe(true);
309
+
310
+ await flowSettings.disable();
244
311
 
245
312
  expect(flowSettings.enabled).toBe(false);
246
313
  });
247
314
 
248
- test('should handle multiple enable/disable calls', () => {
249
- flowSettings.enable();
250
- flowSettings.enable();
315
+ test('should handle multiple enable/disable calls', async () => {
316
+ await flowSettings.enable();
317
+ await flowSettings.enable();
251
318
  expect(flowSettings.enabled).toBe(true);
252
319
 
253
- flowSettings.disable();
254
- flowSettings.disable();
320
+ await flowSettings.disable();
321
+ await flowSettings.disable();
322
+ expect(flowSettings.enabled).toBe(false);
323
+ });
324
+
325
+ test('forceDisable should clear force-enabled state and disable flow settings', async () => {
326
+ await flowSettings.forceEnable();
327
+ expect(flowSettings.enabled).toBe(true);
328
+
329
+ await flowSettings.forceDisable();
330
+
331
+ expect(flowSettings.enabled).toBe(false);
332
+
333
+ await flowSettings.disable();
255
334
  expect(flowSettings.enabled).toBe(false);
256
335
  });
257
336
  });
@@ -512,7 +591,7 @@ describe('FlowSettings', () => {
512
591
  });
513
592
 
514
593
  describe('Complex Integration Scenarios', () => {
515
- test('should maintain state consistency during multiple operations', () => {
594
+ test('should maintain state consistency during multiple operations', async () => {
516
595
  // Initialize with components and scopes
517
596
  const TestComponent = () => 'TestComponent';
518
597
  const testScope = () => 'testScope';
@@ -528,7 +607,7 @@ describe('FlowSettings', () => {
528
607
  });
529
608
 
530
609
  // Enable/disable
531
- flowSettings.enable();
610
+ await flowSettings.enable();
532
611
  expect(flowSettings.enabled).toBe(true);
533
612
 
534
613
  // Verify all state is maintained
@@ -536,7 +615,7 @@ describe('FlowSettings', () => {
536
615
  expect(flowSettings.scopes.testScope).toBe(testScope);
537
616
  expect(flowSettings.getToolbarItems().find((item) => item.key === 'integration-test')).toBeDefined();
538
617
 
539
- flowSettings.disable();
618
+ await flowSettings.disable();
540
619
  expect(flowSettings.enabled).toBe(false);
541
620
 
542
621
  // State should still be maintained after disable