@nocobase/flow-engine 2.1.0-alpha.3 → 2.1.0-alpha.30

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 (160) 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/FieldModelRenderer.js +2 -2
  7. package/lib/components/FlowModelRenderer.d.ts +3 -1
  8. package/lib/components/FlowModelRenderer.js +12 -6
  9. package/lib/components/MobilePopup.js +6 -5
  10. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  11. package/lib/components/dnd/gridDragPlanner.js +601 -21
  12. package/lib/components/dnd/index.d.ts +19 -1
  13. package/lib/components/dnd/index.js +243 -23
  14. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  15. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  16. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  17. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +68 -10
  18. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  19. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  20. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  21. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  22. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  23. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  24. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  25. package/lib/components/subModel/AddSubModelButton.js +27 -1
  26. package/lib/components/subModel/index.d.ts +1 -0
  27. package/lib/components/subModel/index.js +19 -0
  28. package/lib/components/subModel/utils.d.ts +1 -1
  29. package/lib/components/subModel/utils.js +2 -2
  30. package/lib/data-source/index.d.ts +73 -0
  31. package/lib/data-source/index.js +211 -1
  32. package/lib/executor/FlowExecutor.js +31 -8
  33. package/lib/flowContext.d.ts +2 -0
  34. package/lib/flowContext.js +31 -1
  35. package/lib/flowEngine.d.ts +151 -1
  36. package/lib/flowEngine.js +389 -15
  37. package/lib/flowI18n.js +2 -1
  38. package/lib/flowSettings.d.ts +14 -6
  39. package/lib/flowSettings.js +34 -6
  40. package/lib/lazy-helper.d.ts +14 -0
  41. package/lib/lazy-helper.js +71 -0
  42. package/lib/locale/en-US.json +1 -0
  43. package/lib/locale/index.d.ts +2 -0
  44. package/lib/locale/zh-CN.json +1 -0
  45. package/lib/models/DisplayItemModel.d.ts +1 -1
  46. package/lib/models/EditableItemModel.d.ts +1 -1
  47. package/lib/models/FilterableItemModel.d.ts +1 -1
  48. package/lib/models/flowModel.d.ts +13 -10
  49. package/lib/models/flowModel.js +78 -18
  50. package/lib/provider.js +38 -23
  51. package/lib/reactive/observer.js +46 -16
  52. package/lib/runjs-context/registry.d.ts +1 -1
  53. package/lib/runjs-context/setup.js +20 -12
  54. package/lib/runjs-context/snippets/index.js +13 -2
  55. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  56. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  57. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  58. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  59. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  60. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  61. package/lib/types.d.ts +47 -1
  62. package/lib/utils/createCollectionContextMeta.js +6 -2
  63. package/lib/utils/index.d.ts +2 -2
  64. package/lib/utils/index.js +4 -0
  65. package/lib/utils/parsePathnameToViewParams.js +1 -1
  66. package/lib/utils/runjsTemplateCompat.js +1 -1
  67. package/lib/utils/runjsValue.js +41 -11
  68. package/lib/utils/schema-utils.d.ts +7 -1
  69. package/lib/utils/schema-utils.js +19 -0
  70. package/lib/views/FlowView.d.ts +7 -1
  71. package/lib/views/runViewBeforeClose.d.ts +10 -0
  72. package/lib/views/runViewBeforeClose.js +45 -0
  73. package/lib/views/useDialog.d.ts +2 -1
  74. package/lib/views/useDialog.js +20 -3
  75. package/lib/views/useDrawer.d.ts +2 -1
  76. package/lib/views/useDrawer.js +20 -3
  77. package/lib/views/usePage.d.ts +2 -1
  78. package/lib/views/usePage.js +10 -3
  79. package/package.json +6 -5
  80. package/src/JSRunner.ts +68 -4
  81. package/src/ViewScopedFlowEngine.ts +4 -0
  82. package/src/__tests__/JSRunner.test.ts +27 -1
  83. package/src/__tests__/flow-engine.test.ts +166 -0
  84. package/src/__tests__/flowContext.test.ts +65 -1
  85. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  86. package/src/__tests__/flowSettings.test.ts +94 -15
  87. package/src/__tests__/objectVariable.test.ts +24 -0
  88. package/src/__tests__/provider.test.tsx +24 -2
  89. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  90. package/src/__tests__/runjsContext.test.ts +16 -0
  91. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  92. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  93. package/src/__tests__/runjsSnippets.test.ts +21 -0
  94. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  95. package/src/components/FieldModelRenderer.tsx +2 -1
  96. package/src/components/FlowModelRenderer.tsx +18 -6
  97. package/src/components/MobilePopup.tsx +4 -2
  98. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  99. package/src/components/__tests__/dnd.test.ts +44 -0
  100. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  101. package/src/components/__tests__/gridDragPlanner.test.ts +512 -3
  102. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  103. package/src/components/dnd/gridDragPlanner.ts +743 -19
  104. package/src/components/dnd/index.tsx +291 -27
  105. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  106. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +88 -10
  107. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  108. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  109. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +189 -3
  110. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  111. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  112. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  113. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  114. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +142 -32
  115. package/src/components/subModel/index.ts +1 -0
  116. package/src/components/subModel/utils.ts +1 -1
  117. package/src/data-source/__tests__/index.test.ts +34 -1
  118. package/src/data-source/index.ts +258 -2
  119. package/src/executor/FlowExecutor.ts +34 -9
  120. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  121. package/src/flowContext.ts +37 -3
  122. package/src/flowEngine.ts +445 -11
  123. package/src/flowI18n.ts +2 -1
  124. package/src/flowSettings.ts +40 -6
  125. package/src/lazy-helper.tsx +57 -0
  126. package/src/locale/en-US.json +1 -0
  127. package/src/locale/zh-CN.json +1 -0
  128. package/src/models/DisplayItemModel.tsx +1 -1
  129. package/src/models/EditableItemModel.tsx +1 -1
  130. package/src/models/FilterableItemModel.tsx +1 -1
  131. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  132. package/src/models/__tests__/flowModel.test.ts +19 -3
  133. package/src/models/flowModel.tsx +119 -33
  134. package/src/provider.tsx +41 -25
  135. package/src/reactive/__tests__/observer.test.tsx +82 -0
  136. package/src/reactive/observer.tsx +87 -25
  137. package/src/runjs-context/registry.ts +1 -1
  138. package/src/runjs-context/setup.ts +22 -12
  139. package/src/runjs-context/snippets/index.ts +12 -1
  140. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  141. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  142. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  143. package/src/types.ts +60 -0
  144. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  145. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
  146. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  147. package/src/utils/__tests__/utils.test.ts +62 -0
  148. package/src/utils/createCollectionContextMeta.ts +6 -2
  149. package/src/utils/index.ts +2 -1
  150. package/src/utils/parsePathnameToViewParams.ts +2 -2
  151. package/src/utils/runjsTemplateCompat.ts +1 -1
  152. package/src/utils/runjsValue.ts +50 -11
  153. package/src/utils/schema-utils.ts +30 -1
  154. package/src/views/FlowView.tsx +11 -1
  155. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  156. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  157. package/src/views/runViewBeforeClose.ts +19 -0
  158. package/src/views/useDialog.tsx +25 -3
  159. package/src/views/useDrawer.tsx +25 -3
  160. package/src/views/usePage.tsx +12 -3
@@ -52,6 +52,7 @@ var import_viewEvents = require("./viewEvents");
52
52
  var import_provider = require("../provider");
53
53
  var import_ViewScopedFlowEngine = require("../ViewScopedFlowEngine");
54
54
  var import_variablesParams = require("../utils/variablesParams");
55
+ var import_runViewBeforeClose = require("./runViewBeforeClose");
55
56
  function useDrawer() {
56
57
  const holderRef = React.useRef(null);
57
58
  const drawerList = React.useMemo(() => import__.observable.shallow({ value: [] }), []);
@@ -122,12 +123,16 @@ function useDrawer() {
122
123
  } else {
123
124
  ctx.addDelegate(flowContext.engine.context);
124
125
  }
126
+ let destroyed = false;
125
127
  const currentDrawer = {
126
128
  type: "drawer",
127
129
  inputArgs: config.inputArgs || {},
128
130
  preventClose: !!config.preventClose,
131
+ beforeClose: void 0,
129
132
  destroy: /* @__PURE__ */ __name((result) => {
130
133
  var _a2, _b2, _c, _d;
134
+ if (destroyed) return;
135
+ destroyed = true;
131
136
  (_a2 = config.onClose) == null ? void 0 : _a2.call(config);
132
137
  (_b2 = drawerRef.current) == null ? void 0 : _b2.destroy();
133
138
  closeFunc == null ? void 0 : closeFunc();
@@ -141,16 +146,21 @@ function useDrawer() {
141
146
  var _a2;
142
147
  return (_a2 = drawerRef.current) == null ? void 0 : _a2.update(newConfig);
143
148
  }, "update"),
144
- close: /* @__PURE__ */ __name((result, force) => {
149
+ close: /* @__PURE__ */ __name(async (result, force) => {
145
150
  var _a2, _b2;
146
151
  if (config.preventClose && !force) {
147
- return;
152
+ return false;
153
+ }
154
+ const shouldClose = await (0, import_runViewBeforeClose.runViewBeforeClose)(currentDrawer, { result, force });
155
+ if (!shouldClose) {
156
+ return false;
148
157
  }
149
158
  if (config.triggerByRouter && ((_b2 = (_a2 = config.inputArgs) == null ? void 0 : _a2.navigation) == null ? void 0 : _b2.back)) {
150
159
  config.inputArgs.navigation.back();
151
- return;
160
+ return true;
152
161
  }
153
162
  currentDrawer.destroy(result);
163
+ return true;
154
164
  }, "close"),
155
165
  Footer: FooterComponent,
156
166
  Header: HeaderComponent,
@@ -173,6 +183,13 @@ function useDrawer() {
173
183
  get: /* @__PURE__ */ __name(() => currentDrawer, "get"),
174
184
  resolveOnServer: (0, import_variablesParams.createViewRecordResolveOnServer)(ctx, () => (0, import_variablesParams.getViewRecordFromParent)(flowContext, ctx))
175
185
  });
186
+ scopedEngine.setDestroyView(() => {
187
+ var _a2, _b2;
188
+ if (config.triggerByRouter && ((_b2 = (_a2 = config.inputArgs) == null ? void 0 : _a2.navigation) == null ? void 0 : _b2.back)) {
189
+ config.inputArgs.navigation.back();
190
+ }
191
+ currentDrawer.destroy();
192
+ });
176
193
  (0, import_createViewMeta.registerPopupVariable)(ctx, currentDrawer);
177
194
  const DrawerWithContext = React.memo(
178
195
  (0, import__.observer)((props) => {
@@ -16,9 +16,10 @@ export declare function usePage(): (React.JSX.Element | {
16
16
  type: "embed";
17
17
  inputArgs: any;
18
18
  preventClose: boolean;
19
+ beforeClose: any;
19
20
  destroy: (result?: any) => void;
20
21
  update: (newConfig: any) => void;
21
- close: (result?: any, force?: boolean) => void;
22
+ close: (result?: any, force?: boolean) => Promise<boolean>;
22
23
  Header: React.FC<{
23
24
  title?: React.ReactNode;
24
25
  extra?: React.ReactNode;
@@ -54,6 +54,7 @@ var import_viewEvents = require("./viewEvents");
54
54
  var import_provider = require("../provider");
55
55
  var import_ViewScopedFlowEngine = require("../ViewScopedFlowEngine");
56
56
  var import_variablesParams = require("../utils/variablesParams");
57
+ var import_runViewBeforeClose = require("./runViewBeforeClose");
57
58
  let uuid = 0;
58
59
  const GLOBAL_EMBED_CONTAINER_ID = "nocobase-embed-container";
59
60
  const EMBED_REPLACING_DATA_KEY = "nocobaseEmbedReplacing";
@@ -131,6 +132,7 @@ function usePage() {
131
132
  type: "embed",
132
133
  inputArgs: viewInputArgs,
133
134
  preventClose: !!config.preventClose,
135
+ beforeClose: void 0,
134
136
  destroy: /* @__PURE__ */ __name((result) => {
135
137
  var _a2, _b2, _c2, _d, _e;
136
138
  (_a2 = config.onClose) == null ? void 0 : _a2.call(config);
@@ -152,16 +154,21 @@ function usePage() {
152
154
  var _a2;
153
155
  return (_a2 = pageRef.current) == null ? void 0 : _a2.update(newConfig);
154
156
  }, "update"),
155
- close: /* @__PURE__ */ __name((result, force) => {
157
+ close: /* @__PURE__ */ __name(async (result, force) => {
156
158
  var _a2, _b2;
157
159
  if (preventClose && !force) {
158
- return;
160
+ return false;
161
+ }
162
+ const shouldClose = await (0, import_runViewBeforeClose.runViewBeforeClose)(currentPage, { result, force });
163
+ if (!shouldClose) {
164
+ return false;
159
165
  }
160
166
  if (config.triggerByRouter && ((_b2 = (_a2 = config.inputArgs) == null ? void 0 : _a2.navigation) == null ? void 0 : _b2.back)) {
161
167
  config.inputArgs.navigation.back();
162
- return;
168
+ return true;
163
169
  }
164
170
  currentPage.destroy(result);
171
+ return true;
165
172
  }, "close"),
166
173
  Header: HeaderComponent,
167
174
  Footer: FooterComponent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.1.0-alpha.3",
3
+ "version": "2.1.0-alpha.30",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,9 +8,10 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.1.0-alpha.3",
12
- "@nocobase/shared": "2.1.0-alpha.3",
11
+ "@nocobase/sdk": "2.1.0-alpha.30",
12
+ "@nocobase/shared": "2.1.0-alpha.30",
13
13
  "ahooks": "^3.7.2",
14
+ "axios": "^1.7.0",
14
15
  "dayjs": "^1.11.9",
15
16
  "dompurify": "^3.0.2",
16
17
  "lodash": "^4.x",
@@ -35,6 +36,6 @@
35
36
  "workflow"
36
37
  ],
37
38
  "author": "NocoBase Team",
38
- "license": "AGPL-3.0",
39
- "gitHead": "b4d7448b938c1c3be8b2299ad32c6cbe012dd4ea"
39
+ "license": "Apache-2.0",
40
+ "gitHead": "292ae0ad87f195ed201b274902d21ecd96f5ddd0"
40
41
  }
package/src/JSRunner.ts CHANGED
@@ -16,12 +16,74 @@ export interface JSRunnerOptions {
16
16
  version?: string;
17
17
  /**
18
18
  * Enable RunJS template compatibility preprocessing for `{{ ... }}`.
19
- * When enabled via `ctx.runjs(code, vars, { preprocessTemplates: true })` (default),
19
+ * When enabled (or falling back to version default),
20
20
  * the code will be rewritten to call `ctx.resolveJsonTemplate(...)` at runtime.
21
21
  */
22
22
  preprocessTemplates?: boolean;
23
23
  }
24
24
 
25
+ /**
26
+ * Decide whether RunJS `{{ ... }}` compatibility preprocessing should run.
27
+ *
28
+ * Priority:
29
+ * 1. Explicit `preprocessTemplates` option always wins.
30
+ * 2. Otherwise, `version === 'v2'` disables preprocessing.
31
+ * 3. Fallback keeps v1-compatible behavior (enabled).
32
+ */
33
+ export function shouldPreprocessRunJSTemplates(
34
+ options?: Pick<JSRunnerOptions, 'preprocessTemplates' | 'version'>,
35
+ ): boolean {
36
+ if (typeof options?.preprocessTemplates === 'boolean') {
37
+ return options.preprocessTemplates;
38
+ }
39
+ return options?.version !== 'v2';
40
+ }
41
+
42
+ // Heuristic: detect likely bare `{{ctx.xxx}}` usage in executable positions (not quoted string literals).
43
+ const BARE_CTX_TEMPLATE_RE = /(^|[=(:,[\s)])(\{\{\s*(ctx(?:\.|\[|\?\.)[^}]*)\s*\}\})/m;
44
+
45
+ function extractDeprecatedCtxTemplateUsage(code: string): { placeholder: string; expression: string } | null {
46
+ const src = String(code || '');
47
+ const m = src.match(BARE_CTX_TEMPLATE_RE);
48
+ if (!m) return null;
49
+ const placeholder = String(m[2] || '').trim();
50
+ const expression = String(m[3] || '').trim();
51
+ if (!placeholder || !expression) return null;
52
+ return { placeholder, expression };
53
+ }
54
+
55
+ function shouldHintCtxTemplateSyntax(err: any, usage: { placeholder: string; expression: string } | null): boolean {
56
+ const isSyntaxError = err instanceof SyntaxError || String((err as any)?.name || '') === 'SyntaxError';
57
+ if (!isSyntaxError) return false;
58
+ if (!usage) return false;
59
+ const msg = String((err as any)?.message || err || '');
60
+ return /unexpected token/i.test(msg);
61
+ }
62
+
63
+ function toCtxTemplateSyntaxHintError(
64
+ err: any,
65
+ usage: {
66
+ placeholder: string;
67
+ expression: string;
68
+ },
69
+ ): Error {
70
+ const hint = `"${usage.placeholder}" has been deprecated and cannot be used as executable RunJS syntax. Use await ctx.getVar("${usage.expression}") instead, or keep "${usage.placeholder}" as a plain string.`;
71
+ const out = new SyntaxError(hint);
72
+ try {
73
+ (out as any).cause = err;
74
+ } catch (_) {
75
+ // ignore
76
+ }
77
+ try {
78
+ // Hint-only error: avoid leaking internal bundle line numbers from stack parsers in preview UI.
79
+ (out as any).__runjsHideLocation = true;
80
+ out.stack = `${out.name}: ${out.message}`;
81
+ } catch (_) {
82
+ // ignore
83
+ }
84
+ return out;
85
+ }
86
+
25
87
  export class JSRunner {
26
88
  private globals: Record<string, any>;
27
89
  private timeoutMs: number;
@@ -118,11 +180,13 @@ export class JSRunner {
118
180
  if (err instanceof FlowExitAllException) {
119
181
  throw err;
120
182
  }
121
- console.error(err);
183
+ const usage = extractDeprecatedCtxTemplateUsage(code);
184
+ const outErr = shouldHintCtxTemplateSyntax(err, usage) && usage ? toCtxTemplateSyntaxHintError(err, usage) : err;
185
+ console.error(outErr);
122
186
  return {
123
187
  success: false,
124
- error: err,
125
- timeout: err.message === 'Execution timed out',
188
+ error: outErr,
189
+ timeout: (outErr as any)?.message === 'Execution timed out',
126
190
  };
127
191
  }
128
192
  }
@@ -62,6 +62,10 @@ export function createViewScopedEngine(parent: FlowEngine): FlowEngine {
62
62
  '_nextEngine',
63
63
  // getModel 需要在本地执行以确保全局查找时正确遍历整个引擎栈
64
64
  'getModel',
65
+ // 视图销毁回调需要在本地存储,每个视图引擎有自己的销毁逻辑
66
+ '_destroyView',
67
+ 'setDestroyView',
68
+ 'destroyView',
65
69
  ]);
66
70
 
67
71
  const handler: ProxyHandler<FlowEngine> = {
@@ -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') {
@@ -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 函数