@nocobase/flow-engine 2.1.0-alpha.4 → 2.1.0-alpha.45

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 (209) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/FlowContextProvider.d.ts +5 -1
  4. package/lib/FlowContextProvider.js +9 -2
  5. package/lib/JSRunner.d.ts +10 -1
  6. package/lib/JSRunner.js +50 -5
  7. package/lib/ViewScopedFlowEngine.js +5 -1
  8. package/lib/components/FieldModelRenderer.js +2 -2
  9. package/lib/components/FlowModelRenderer.d.ts +3 -1
  10. package/lib/components/FlowModelRenderer.js +12 -6
  11. package/lib/components/FormItem.d.ts +6 -0
  12. package/lib/components/FormItem.js +11 -3
  13. package/lib/components/MobilePopup.js +6 -5
  14. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  15. package/lib/components/dnd/gridDragPlanner.js +613 -21
  16. package/lib/components/dnd/index.d.ts +31 -2
  17. package/lib/components/dnd/index.js +244 -23
  18. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  19. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  20. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  21. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +76 -11
  22. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  23. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  24. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  25. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  26. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  27. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  28. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  29. package/lib/components/subModel/AddSubModelButton.js +27 -1
  30. package/lib/components/subModel/LazyDropdown.js +293 -52
  31. package/lib/components/subModel/index.d.ts +1 -0
  32. package/lib/components/subModel/index.js +19 -0
  33. package/lib/components/subModel/utils.d.ts +1 -1
  34. package/lib/components/subModel/utils.js +9 -3
  35. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  36. package/lib/components/variables/VariableHybridInput.js +499 -0
  37. package/lib/components/variables/index.d.ts +2 -0
  38. package/lib/components/variables/index.js +3 -0
  39. package/lib/data-source/index.d.ts +84 -0
  40. package/lib/data-source/index.js +259 -5
  41. package/lib/executor/FlowExecutor.js +32 -9
  42. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  43. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  44. package/lib/flow-registry/index.d.ts +1 -0
  45. package/lib/flow-registry/index.js +3 -1
  46. package/lib/flowContext.d.ts +3 -0
  47. package/lib/flowContext.js +46 -1
  48. package/lib/flowEngine.d.ts +151 -1
  49. package/lib/flowEngine.js +392 -18
  50. package/lib/flowI18n.js +2 -1
  51. package/lib/flowSettings.d.ts +14 -6
  52. package/lib/flowSettings.js +34 -6
  53. package/lib/index.d.ts +2 -0
  54. package/lib/index.js +7 -0
  55. package/lib/lazy-helper.d.ts +14 -0
  56. package/lib/lazy-helper.js +71 -0
  57. package/lib/locale/en-US.json +1 -0
  58. package/lib/locale/index.d.ts +2 -0
  59. package/lib/locale/zh-CN.json +1 -0
  60. package/lib/models/DisplayItemModel.d.ts +1 -1
  61. package/lib/models/EditableItemModel.d.ts +1 -1
  62. package/lib/models/FilterableItemModel.d.ts +1 -1
  63. package/lib/models/flowModel.d.ts +13 -10
  64. package/lib/models/flowModel.js +81 -21
  65. package/lib/provider.js +38 -23
  66. package/lib/reactive/observer.js +46 -16
  67. package/lib/runjs-context/registry.d.ts +1 -1
  68. package/lib/runjs-context/setup.js +20 -12
  69. package/lib/runjs-context/snippets/index.js +13 -2
  70. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  71. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  72. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  73. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  74. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  75. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  76. package/lib/types.d.ts +50 -2
  77. package/lib/types.js +1 -0
  78. package/lib/utils/createCollectionContextMeta.js +6 -2
  79. package/lib/utils/index.d.ts +3 -2
  80. package/lib/utils/index.js +7 -0
  81. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  82. package/lib/utils/parsePathnameToViewParams.js +29 -5
  83. package/lib/utils/randomId.d.ts +39 -0
  84. package/lib/utils/randomId.js +45 -0
  85. package/lib/utils/runjsTemplateCompat.js +1 -1
  86. package/lib/utils/runjsValue.js +41 -11
  87. package/lib/utils/schema-utils.d.ts +7 -1
  88. package/lib/utils/schema-utils.js +19 -0
  89. package/lib/views/FlowView.d.ts +7 -1
  90. package/lib/views/FlowView.js +11 -1
  91. package/lib/views/PageComponent.js +8 -6
  92. package/lib/views/ViewNavigation.d.ts +12 -2
  93. package/lib/views/ViewNavigation.js +28 -9
  94. package/lib/views/createViewMeta.js +114 -50
  95. package/lib/views/inheritLayoutContext.d.ts +10 -0
  96. package/lib/views/inheritLayoutContext.js +50 -0
  97. package/lib/views/runViewBeforeClose.d.ts +10 -0
  98. package/lib/views/runViewBeforeClose.js +45 -0
  99. package/lib/views/useDialog.d.ts +2 -1
  100. package/lib/views/useDialog.js +22 -3
  101. package/lib/views/useDrawer.d.ts +2 -1
  102. package/lib/views/useDrawer.js +22 -3
  103. package/lib/views/usePage.d.ts +5 -11
  104. package/lib/views/usePage.js +304 -144
  105. package/package.json +6 -5
  106. package/src/FlowContextProvider.tsx +9 -1
  107. package/src/JSRunner.ts +68 -4
  108. package/src/ViewScopedFlowEngine.ts +4 -0
  109. package/src/__tests__/JSRunner.test.ts +27 -1
  110. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  111. package/src/__tests__/flow-engine.test.ts +166 -0
  112. package/src/__tests__/flowContext.test.ts +82 -1
  113. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  114. package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
  115. package/src/__tests__/flowSettings.test.ts +94 -15
  116. package/src/__tests__/objectVariable.test.ts +24 -0
  117. package/src/__tests__/provider.test.tsx +24 -2
  118. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  119. package/src/__tests__/runjsContext.test.ts +16 -0
  120. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  121. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  122. package/src/__tests__/runjsSnippets.test.ts +21 -0
  123. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  124. package/src/components/FieldModelRenderer.tsx +2 -1
  125. package/src/components/FlowModelRenderer.tsx +18 -6
  126. package/src/components/FormItem.tsx +7 -1
  127. package/src/components/MobilePopup.tsx +4 -2
  128. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  129. package/src/components/__tests__/FormItem.test.tsx +25 -0
  130. package/src/components/__tests__/dnd.test.ts +44 -0
  131. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  132. package/src/components/__tests__/gridDragPlanner.test.ts +558 -3
  133. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  134. package/src/components/dnd/gridDragPlanner.ts +758 -19
  135. package/src/components/dnd/index.tsx +305 -28
  136. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  137. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +99 -11
  138. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  139. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  140. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +194 -5
  141. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  142. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  143. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  144. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  145. package/src/components/subModel/LazyDropdown.tsx +332 -56
  146. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +522 -37
  147. package/src/components/subModel/__tests__/utils.test.ts +24 -0
  148. package/src/components/subModel/index.ts +1 -0
  149. package/src/components/subModel/utils.ts +7 -1
  150. package/src/components/variables/VariableHybridInput.tsx +531 -0
  151. package/src/components/variables/index.ts +2 -0
  152. package/src/data-source/__tests__/collection.test.ts +41 -2
  153. package/src/data-source/__tests__/index.test.ts +68 -1
  154. package/src/data-source/index.ts +322 -6
  155. package/src/executor/FlowExecutor.ts +35 -10
  156. package/src/executor/__tests__/flowExecutor.test.ts +85 -0
  157. package/src/flow-registry/DetachedFlowRegistry.ts +46 -0
  158. package/src/flow-registry/__tests__/detachedFlowRegistry.test.ts +47 -0
  159. package/src/flow-registry/index.ts +1 -0
  160. package/src/flowContext.ts +50 -3
  161. package/src/flowEngine.ts +449 -14
  162. package/src/flowI18n.ts +2 -1
  163. package/src/flowSettings.ts +40 -6
  164. package/src/index.ts +2 -0
  165. package/src/lazy-helper.tsx +57 -0
  166. package/src/locale/en-US.json +1 -0
  167. package/src/locale/zh-CN.json +1 -0
  168. package/src/models/DisplayItemModel.tsx +1 -1
  169. package/src/models/EditableItemModel.tsx +1 -1
  170. package/src/models/FilterableItemModel.tsx +1 -1
  171. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  172. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  173. package/src/models/__tests__/flowModel.test.ts +80 -37
  174. package/src/models/flowModel.tsx +122 -36
  175. package/src/provider.tsx +41 -25
  176. package/src/reactive/__tests__/observer.test.tsx +82 -0
  177. package/src/reactive/observer.tsx +87 -25
  178. package/src/runjs-context/registry.ts +1 -1
  179. package/src/runjs-context/setup.ts +22 -12
  180. package/src/runjs-context/snippets/index.ts +12 -1
  181. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  182. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  183. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  184. package/src/types.ts +62 -0
  185. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  186. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +28 -0
  187. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  188. package/src/utils/__tests__/utils.test.ts +62 -0
  189. package/src/utils/createCollectionContextMeta.ts +6 -2
  190. package/src/utils/index.ts +5 -1
  191. package/src/utils/parsePathnameToViewParams.ts +47 -7
  192. package/src/utils/randomId.ts +48 -0
  193. package/src/utils/runjsTemplateCompat.ts +1 -1
  194. package/src/utils/runjsValue.ts +50 -11
  195. package/src/utils/schema-utils.ts +30 -1
  196. package/src/views/FlowView.tsx +22 -2
  197. package/src/views/PageComponent.tsx +7 -4
  198. package/src/views/ViewNavigation.ts +46 -9
  199. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  200. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  201. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  202. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  203. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  204. package/src/views/createViewMeta.ts +106 -34
  205. package/src/views/inheritLayoutContext.ts +26 -0
  206. package/src/views/runViewBeforeClose.ts +19 -0
  207. package/src/views/useDialog.tsx +27 -3
  208. package/src/views/useDrawer.tsx +27 -3
  209. package/src/views/usePage.tsx +367 -179
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
- import { JSRunner } from '../JSRunner';
11
+ import { JSRunner, shouldPreprocessRunJSTemplates } from '../JSRunner';
12
12
  import { createSafeWindow } from '../utils';
13
13
 
14
14
  describe('JSRunner', () => {
@@ -30,6 +30,18 @@ describe('JSRunner', () => {
30
30
  vi.restoreAllMocks();
31
31
  });
32
32
 
33
+ it('shouldPreprocessRunJSTemplates: explicit option has highest priority', () => {
34
+ expect(shouldPreprocessRunJSTemplates({ version: 'v2', preprocessTemplates: true })).toBe(true);
35
+ expect(shouldPreprocessRunJSTemplates({ version: 'v1', preprocessTemplates: false })).toBe(false);
36
+ });
37
+
38
+ it('shouldPreprocessRunJSTemplates: falls back to version policy', () => {
39
+ expect(shouldPreprocessRunJSTemplates({ version: 'v1' })).toBe(true);
40
+ expect(shouldPreprocessRunJSTemplates({ version: 'v2' })).toBe(false);
41
+ expect(shouldPreprocessRunJSTemplates({})).toBe(true);
42
+ expect(shouldPreprocessRunJSTemplates()).toBe(true);
43
+ });
44
+
33
45
  it('executes simple code and returns value', async () => {
34
46
  const runner = new JSRunner();
35
47
  const result = await runner.run('return 1 + 2 + 3');
@@ -152,6 +164,20 @@ describe('JSRunner', () => {
152
164
  expect((result.error as Error).message).toBe('Execution timed out');
153
165
  });
154
166
 
167
+ it('returns friendly hint when bare {{ctx.xxx}} appears in syntax error', async () => {
168
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
169
+ const runner = new JSRunner();
170
+ const result = await runner.run('const z = {{ctx.user.id}}');
171
+ expect(result.success).toBe(false);
172
+ expect(result.error).toBeInstanceOf(SyntaxError);
173
+ const msg = String((result.error as any)?.message || '');
174
+ expect(msg).toContain('"{{ctx.user.id}}" has been deprecated');
175
+ expect(msg).toContain('await ctx.getVar("ctx.user.id")');
176
+ expect(msg).not.toContain('(at ');
177
+ expect((result.error as any)?.__runjsHideLocation).toBe(true);
178
+ expect(spy).toHaveBeenCalled();
179
+ });
180
+
155
181
  it('skips execution when URL contains skipRunJs=true', async () => {
156
182
  // 模拟预览模式下通过 URL 参数跳过代码执行
157
183
  if (typeof window !== 'undefined' && typeof window.history?.pushState === 'function') {
@@ -11,7 +11,7 @@ import { describe, it, expect, vi } from 'vitest';
11
11
  import { FlowContext } from '../flowContext';
12
12
  import { FlowEngine } from '../flowEngine';
13
13
  import type { FlowView } from '../views/FlowView';
14
- import { createPopupMeta } from '../views/createViewMeta';
14
+ import { buildPopupRuntime, createPopupMeta } from '../views/createViewMeta';
15
15
 
16
16
  describe('createPopupMeta - popup variables', () => {
17
17
  function makeCtx() {
@@ -23,6 +23,62 @@ describe('createPopupMeta - popup variables', () => {
23
23
  return { engine, ctx };
24
24
  }
25
25
 
26
+ function makeNestedPopupView(viewUid: string, filterByTk: number): FlowView {
27
+ return {
28
+ type: 'drawer',
29
+ inputArgs: {
30
+ viewUid,
31
+ filterByTk,
32
+ sourceId: 13,
33
+ },
34
+ Header: null,
35
+ Footer: null,
36
+ close: () => void 0,
37
+ update: () => void 0,
38
+ navigation: {
39
+ viewStack: [
40
+ { viewUid: 'base-page-uid' },
41
+ { viewUid: 'parent-popup-uid', filterByTk: 13, sourceId: 13 },
42
+ { viewUid: 'child-popup-uid', filterByTk: 24, sourceId: 13 },
43
+ ],
44
+ } as any,
45
+ } as any;
46
+ }
47
+
48
+ function mockNestedPopupModels(engine: FlowEngine) {
49
+ vi.spyOn(engine as any, 'getModel').mockImplementation((uid: string) => {
50
+ if (uid === 'parent-popup-uid') {
51
+ return {
52
+ getStepParams: vi.fn((_fk: string, sk: string) =>
53
+ sk === 'openView'
54
+ ? {
55
+ collectionName: 'users',
56
+ dataSourceKey: 'main',
57
+ associationName: 'users.orgs',
58
+ }
59
+ : undefined,
60
+ ),
61
+ };
62
+ }
63
+ if (uid === 'child-popup-uid') {
64
+ return {
65
+ getStepParams: vi.fn((_fk: string, sk: string) =>
66
+ sk === 'openView'
67
+ ? {
68
+ collectionName: 'orgs',
69
+ dataSourceKey: 'main',
70
+ associationName: 'users.orgs',
71
+ }
72
+ : undefined,
73
+ ),
74
+ };
75
+ }
76
+ return {
77
+ getStepParams: vi.fn(() => undefined),
78
+ };
79
+ });
80
+ }
81
+
26
82
  it('buildVariablesParams(record) uses anchor view instead of ctx.view', async () => {
27
83
  const { engine, ctx } = makeCtx();
28
84
 
@@ -108,6 +164,64 @@ describe('createPopupMeta - popup variables', () => {
108
164
  expect(vars.record.collection).not.toBe('comments');
109
165
  });
110
166
 
167
+ it('buildPopupRuntime anchors current popup even when the navigation stack already has a child popup', async () => {
168
+ const { engine, ctx } = makeCtx();
169
+ const parentView = makeNestedPopupView('parent-popup-uid', 13);
170
+ mockNestedPopupModels(engine);
171
+
172
+ const popup = await buildPopupRuntime(ctx, parentView);
173
+
174
+ expect(popup?.uid).toBe('parent-popup-uid');
175
+ expect(popup?.resource).toEqual({
176
+ dataSourceKey: 'main',
177
+ collectionName: 'users',
178
+ associationName: 'users.orgs',
179
+ filterByTk: 13,
180
+ sourceId: 13,
181
+ });
182
+ });
183
+
184
+ it('buildVariablesParams(record) keeps the parent view record when a child popup is open', async () => {
185
+ const { engine, ctx } = makeCtx();
186
+ const parentView = makeNestedPopupView('parent-popup-uid', 13);
187
+ mockNestedPopupModels(engine);
188
+
189
+ const meta = (await createPopupMeta(ctx, parentView)())!;
190
+ const vars = (await meta.buildVariablesParams!(ctx)) as any;
191
+
192
+ expect(vars.record).toEqual({
193
+ collection: 'users',
194
+ dataSourceKey: 'main',
195
+ filterByTk: 13,
196
+ associationName: 'users.orgs',
197
+ sourceId: 13,
198
+ });
199
+ });
200
+
201
+ it('buildVariablesParams(parent.record) is still relative to the child popup view', async () => {
202
+ const { engine, ctx } = makeCtx();
203
+ const childView = makeNestedPopupView('child-popup-uid', 24);
204
+ mockNestedPopupModels(engine);
205
+
206
+ const meta = (await createPopupMeta(ctx, childView)())!;
207
+ const vars = (await meta.buildVariablesParams!(ctx)) as any;
208
+
209
+ expect(vars.record).toEqual({
210
+ collection: 'orgs',
211
+ dataSourceKey: 'main',
212
+ filterByTk: 24,
213
+ associationName: 'users.orgs',
214
+ sourceId: 13,
215
+ });
216
+ expect(vars.parent.record).toEqual({
217
+ collection: 'users',
218
+ dataSourceKey: 'main',
219
+ filterByTk: 13,
220
+ sourceId: 13,
221
+ associationName: 'users.orgs',
222
+ });
223
+ });
224
+
111
225
  it('properties() provides a record factory node (lazy) with title', async () => {
112
226
  const { engine, ctx } = makeCtx();
113
227
  // 只要能通过 anchorView 推断到集合名和主键即可;集合详情在懒加载时再取
@@ -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';
@@ -159,6 +160,23 @@ describe('FlowContext properties and methods', () => {
159
160
  expect(ctx.shared).toBe('from delegate');
160
161
  });
161
162
 
163
+ it('should expose current language as a top-level variable', async () => {
164
+ const engine = new FlowEngine();
165
+ const ctx = engine.context;
166
+ ctx.defineProperty('api', { value: { auth: { locale: 'zh-CN' } } });
167
+ ctx.defineProperty('i18n', { value: { language: 'en-US' } });
168
+
169
+ expect(ctx.locale).toBe('zh-CN');
170
+ await expect(ctx.resolveJsonTemplate('{{ ctx.locale }}')).resolves.toBe('zh-CN');
171
+
172
+ const localeNode = ctx.getPropertyMetaTree().find((node) => node.name === 'locale');
173
+ expect(localeNode).toMatchObject({
174
+ name: 'locale',
175
+ title: '{{t("Current language")}}',
176
+ paths: ['locale'],
177
+ });
178
+ });
179
+
162
180
  it('should throw sync error in get', () => {
163
181
  const ctx = new FlowContext();
164
182
  ctx.defineProperty('error', {
@@ -1630,6 +1648,69 @@ describe('runAction delegation from runtime context', () => {
1630
1648
  });
1631
1649
  });
1632
1650
 
1651
+ describe('FlowContext request defaults', () => {
1652
+ class RequestModel extends FlowModel {}
1653
+
1654
+ afterEach(() => {
1655
+ vi.restoreAllMocks();
1656
+ });
1657
+
1658
+ const createRequestContext = () => {
1659
+ const engine = new FlowEngine();
1660
+ engine.registerModels({ RequestModel });
1661
+
1662
+ const apiRequest = vi.fn(async (options) => options);
1663
+ const app = {
1664
+ getApiUrl(pathname = '') {
1665
+ return 'https://app.example.com/api/'.replace(/\/$/g, '') + '/' + pathname.replace(/^\//g, '');
1666
+ },
1667
+ };
1668
+
1669
+ engine.context.defineProperty('api', { value: { request: apiRequest } as any });
1670
+ engine.context.defineProperty('app', { value: app });
1671
+
1672
+ const model = engine.createModel({ use: 'RequestModel' });
1673
+ const ctx = new FlowRuntimeContext(model, 'flow');
1674
+ const directAxiosRequest = vi.spyOn(axios, 'request').mockResolvedValue({ data: {} } as any);
1675
+
1676
+ return { ctx, apiRequest, directAxiosRequest };
1677
+ };
1678
+
1679
+ it.each([
1680
+ ['apiClient', 'users:list', 'api'],
1681
+ ['apiClient', '/api/users:list', 'api'],
1682
+ ['apiClient', 'https://app.example.com/api/users:list', 'api'],
1683
+ ['direct axios', 'https://app.example.com/custom-api/users', 'axios'],
1684
+ ])('should use %s for %s', async (_target, url, expected) => {
1685
+ const { ctx, apiRequest, directAxiosRequest } = createRequestContext();
1686
+
1687
+ await ctx.request({ url, method: 'get' });
1688
+
1689
+ if (expected === 'api') {
1690
+ expect(apiRequest).toHaveBeenCalledTimes(1);
1691
+ expect(directAxiosRequest).not.toHaveBeenCalled();
1692
+ return;
1693
+ }
1694
+
1695
+ expect(directAxiosRequest).toHaveBeenCalledTimes(1);
1696
+ expect(apiRequest).not.toHaveBeenCalled();
1697
+ });
1698
+
1699
+ it('should use direct axios for cross-origin absolute urls', async () => {
1700
+ const { ctx, apiRequest, directAxiosRequest } = createRequestContext();
1701
+
1702
+ await ctx.request({ url: 'https://api.example.com/users', method: 'get', skipAuth: false });
1703
+
1704
+ expect(directAxiosRequest).toHaveBeenCalledTimes(1);
1705
+ expect(apiRequest).not.toHaveBeenCalled();
1706
+ expect(directAxiosRequest.mock.calls[0][0]).toMatchObject({
1707
+ url: 'https://api.example.com/users',
1708
+ method: 'get',
1709
+ skipAuth: false,
1710
+ });
1711
+ });
1712
+ });
1713
+
1633
1714
  describe('FlowContext delayed meta loading', () => {
1634
1715
  // 测试场景:属性定义时 meta 为异步函数,首次访问时延迟加载
1635
1716
  // 输入:属性带有异步 meta 函数