@nocobase/flow-engine 2.1.0-beta.1 → 2.1.0-beta.11

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 (63) 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/dnd/gridDragPlanner.js +6 -2
  7. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  8. package/lib/components/subModel/AddSubModelButton.js +15 -0
  9. package/lib/executor/FlowExecutor.js +31 -8
  10. package/lib/flowContext.js +4 -1
  11. package/lib/flowEngine.d.ts +19 -0
  12. package/lib/flowEngine.js +29 -1
  13. package/lib/runjs-context/registry.d.ts +1 -1
  14. package/lib/runjs-context/setup.js +19 -12
  15. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  16. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  17. package/lib/types.d.ts +1 -1
  18. package/lib/utils/index.d.ts +2 -2
  19. package/lib/utils/index.js +4 -0
  20. package/lib/utils/parsePathnameToViewParams.js +1 -1
  21. package/lib/utils/schema-utils.d.ts +7 -1
  22. package/lib/utils/schema-utils.js +19 -0
  23. package/lib/views/FlowView.d.ts +7 -1
  24. package/lib/views/runViewBeforeClose.d.ts +10 -0
  25. package/lib/views/runViewBeforeClose.js +45 -0
  26. package/lib/views/useDialog.d.ts +2 -1
  27. package/lib/views/useDialog.js +20 -3
  28. package/lib/views/useDrawer.d.ts +2 -1
  29. package/lib/views/useDrawer.js +20 -3
  30. package/lib/views/usePage.d.ts +2 -1
  31. package/lib/views/usePage.js +10 -3
  32. package/package.json +5 -5
  33. package/src/JSRunner.ts +68 -4
  34. package/src/ViewScopedFlowEngine.ts +4 -0
  35. package/src/__tests__/JSRunner.test.ts +27 -1
  36. package/src/__tests__/runjsContext.test.ts +13 -0
  37. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  38. package/src/components/__tests__/gridDragPlanner.test.ts +88 -0
  39. package/src/components/dnd/gridDragPlanner.ts +8 -2
  40. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  41. package/src/components/subModel/AddSubModelButton.tsx +16 -0
  42. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +50 -0
  43. package/src/executor/FlowExecutor.ts +34 -9
  44. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  45. package/src/flowContext.ts +5 -3
  46. package/src/flowEngine.ts +33 -1
  47. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  48. package/src/runjs-context/registry.ts +1 -1
  49. package/src/runjs-context/setup.ts +21 -12
  50. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  51. package/src/types.ts +1 -0
  52. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
  53. package/src/utils/__tests__/utils.test.ts +62 -0
  54. package/src/utils/index.ts +2 -1
  55. package/src/utils/parsePathnameToViewParams.ts +2 -2
  56. package/src/utils/schema-utils.ts +30 -1
  57. package/src/views/FlowView.tsx +11 -1
  58. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  59. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  60. package/src/views/runViewBeforeClose.ts +19 -0
  61. package/src/views/useDialog.tsx +25 -3
  62. package/src/views/useDrawer.tsx +25 -3
  63. package/src/views/usePage.tsx +12 -3
@@ -30,6 +30,7 @@ __export(utils_exports, {
30
30
  BLOCK_TYPES: () => import_constants.BLOCK_TYPES,
31
31
  FLOW_ENGINE_NAMESPACE: () => import_constants.FLOW_ENGINE_NAMESPACE,
32
32
  FlowCancelSaveException: () => import_exceptions.FlowCancelSaveException,
33
+ FlowExitAllException: () => import_exceptions.FlowExitAllException,
33
34
  FlowExitException: () => import_exceptions.FlowExitException,
34
35
  MENU_KEYS: () => import_constants.MENU_KEYS,
35
36
  buildRecordMeta: () => import_variablesParams.buildRecordMeta,
@@ -86,6 +87,7 @@ __export(utils_exports, {
86
87
  serializeCtxDateValue: () => import_dateVariable.serializeCtxDateValue,
87
88
  setAutoFlowError: () => import_autoFlowError.setAutoFlowError,
88
89
  setupRuntimeContextSteps: () => import_setupRuntimeContextSteps.setupRuntimeContextSteps,
90
+ shouldHideEventInSettings: () => import_schema_utils.shouldHideEventInSettings,
89
91
  shouldHideStepInSettings: () => import_schema_utils.shouldHideStepInSettings,
90
92
  tExpr: () => import_translation.tExpr
91
93
  });
@@ -119,6 +121,7 @@ var import_resolveModuleUrl = require("./resolveModuleUrl");
119
121
  BLOCK_TYPES,
120
122
  FLOW_ENGINE_NAMESPACE,
121
123
  FlowCancelSaveException,
124
+ FlowExitAllException,
122
125
  FlowExitException,
123
126
  MENU_KEYS,
124
127
  buildRecordMeta,
@@ -175,6 +178,7 @@ var import_resolveModuleUrl = require("./resolveModuleUrl");
175
178
  serializeCtxDateValue,
176
179
  setAutoFlowError,
177
180
  setupRuntimeContextSteps,
181
+ shouldHideEventInSettings,
178
182
  shouldHideStepInSettings,
179
183
  tExpr
180
184
  });
@@ -92,7 +92,7 @@ const parsePathnameToViewParams = /* @__PURE__ */ __name((pathname) => {
92
92
  } catch (_) {
93
93
  parsed = decoded;
94
94
  }
95
- } else if (decoded && decoded.includes("=") && decoded.includes("&")) {
95
+ } else if (decoded && /^[^=&]+=[^=&]*(?:&[^=&]+=[^=&]*)*$/.test(decoded)) {
96
96
  parsed = parseKeyValuePairs(decoded);
97
97
  }
98
98
  currentView.filterByTk = parsed;
@@ -9,7 +9,7 @@
9
9
  import type { ISchema } from '@formily/json-schema';
10
10
  import type { FlowModel } from '../models';
11
11
  import { FlowRuntimeContext } from '../flowContext';
12
- import type { StepDefinition, StepUIMode } from '../types';
12
+ import type { EventDefinition, StepDefinition, StepUIMode } from '../types';
13
13
  /**
14
14
  * 解析 uiMode,支持静态值和函数形式
15
15
  * 函数可以接收 FlowRuntimeContext
@@ -38,6 +38,12 @@ export declare function compileUiSchema(scope: Record<string, any>, uiSchema: an
38
38
  * @returns 合并后的uiSchema对象,如果为空则返回null
39
39
  */
40
40
  export declare function resolveStepUiSchema<TModel extends FlowModel = FlowModel>(model: TModel, flow: any, step: StepDefinition): Promise<Record<string, ISchema> | null>;
41
+ /**
42
+ * 判断事件在设置菜单中是否应被隐藏。
43
+ * - 支持 EventDefinition.hideInSettings。
44
+ * - hideInSettings 可为布尔值或函数(接收 FlowRuntimeContext)。
45
+ */
46
+ export declare function shouldHideEventInSettings<TModel extends FlowModel = FlowModel>(model: TModel, flow: any, event: EventDefinition<TModel> | undefined): Promise<boolean>;
41
47
  /**
42
48
  * 判断步骤在设置菜单中是否应被隐藏。
43
49
  * - 支持 StepDefinition.hideInSettings 与 ActionDefinition.hideInSettings(step 优先)。
@@ -31,6 +31,7 @@ __export(schema_utils_exports, {
31
31
  resolveStepDisabledInSettings: () => resolveStepDisabledInSettings,
32
32
  resolveStepUiSchema: () => resolveStepUiSchema,
33
33
  resolveUiMode: () => resolveUiMode,
34
+ shouldHideEventInSettings: () => shouldHideEventInSettings,
34
35
  shouldHideStepInSettings: () => shouldHideStepInSettings
35
36
  });
36
37
  module.exports = __toCommonJS(schema_utils_exports);
@@ -195,6 +196,23 @@ async function resolveStepUiSchema(model, flow, step) {
195
196
  return resolvedStepUiSchema;
196
197
  }
197
198
  __name(resolveStepUiSchema, "resolveStepUiSchema");
199
+ async function shouldHideEventInSettings(model, flow, event) {
200
+ if (!event) return true;
201
+ const { hideInSettings } = event;
202
+ if (typeof hideInSettings === "function") {
203
+ try {
204
+ const ctx = new import_flowContext.FlowRuntimeContext(model, flow.key, "settings");
205
+ (0, import_setupRuntimeContextSteps.setupRuntimeContextSteps)(ctx, flow.steps || {}, model, flow.key);
206
+ const result = await hideInSettings(ctx);
207
+ return !!result;
208
+ } catch (error) {
209
+ console.warn(`Error evaluating hideInSettings for event '${event.name || ""}' in flow '${flow.key}':`, error);
210
+ return false;
211
+ }
212
+ }
213
+ return !!hideInSettings;
214
+ }
215
+ __name(shouldHideEventInSettings, "shouldHideEventInSettings");
198
216
  async function shouldHideStepInSettings(model, flow, step) {
199
217
  var _a;
200
218
  if (!step) return true;
@@ -283,5 +301,6 @@ __name(resolveStepDisabledInSettings, "resolveStepDisabledInSettings");
283
301
  resolveStepDisabledInSettings,
284
302
  resolveStepUiSchema,
285
303
  resolveUiMode,
304
+ shouldHideEventInSettings,
286
305
  shouldHideStepInSettings
287
306
  });
@@ -10,6 +10,11 @@
10
10
  import { PopoverProps as AntdPopoverProps } from 'antd';
11
11
  import { FlowContext } from '../flowContext';
12
12
  import { ViewNavigation } from './ViewNavigation';
13
+ export type FlowViewBeforeClosePayload = {
14
+ result?: any;
15
+ force?: boolean;
16
+ };
17
+ export type FlowViewBeforeCloseHandler = (payload: FlowViewBeforeClosePayload) => Promise<boolean | void> | boolean | void;
13
18
  export type FlowView = {
14
19
  type: 'drawer' | 'popover' | 'dialog' | 'embed';
15
20
  inputArgs: any;
@@ -20,8 +25,9 @@ export type FlowView = {
20
25
  Footer: React.FC<{
21
26
  children?: React.ReactNode;
22
27
  }> | null;
23
- close: (result?: any, force?: boolean) => void;
28
+ close: (result?: any, force?: boolean) => Promise<boolean | void> | boolean | void;
24
29
  update: (newConfig: any) => void;
30
+ beforeClose?: FlowViewBeforeCloseHandler;
25
31
  navigation?: ViewNavigation;
26
32
  /** 页面的销毁方法 */
27
33
  destroy?: () => void;
@@ -0,0 +1,10 @@
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
+ import type { FlowView, FlowViewBeforeClosePayload } from './FlowView';
10
+ export declare function runViewBeforeClose(view: FlowView, payload: FlowViewBeforeClosePayload): Promise<boolean>;
@@ -0,0 +1,45 @@
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
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
15
+ var __export = (target, all) => {
16
+ for (var name in all)
17
+ __defProp(target, name, { get: all[name], enumerable: true });
18
+ };
19
+ var __copyProps = (to, from, except, desc) => {
20
+ if (from && typeof from === "object" || typeof from === "function") {
21
+ for (let key of __getOwnPropNames(from))
22
+ if (!__hasOwnProp.call(to, key) && key !== except)
23
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
24
+ }
25
+ return to;
26
+ };
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+ var runViewBeforeClose_exports = {};
29
+ __export(runViewBeforeClose_exports, {
30
+ runViewBeforeClose: () => runViewBeforeClose
31
+ });
32
+ module.exports = __toCommonJS(runViewBeforeClose_exports);
33
+ async function runViewBeforeClose(view, payload) {
34
+ var _a;
35
+ if (payload.force) {
36
+ return true;
37
+ }
38
+ const result = await ((_a = view.beforeClose) == null ? void 0 : _a.call(view, payload));
39
+ return result !== false;
40
+ }
41
+ __name(runViewBeforeClose, "runViewBeforeClose");
42
+ // Annotate the CommonJS export names for ESM import in node:
43
+ 0 && (module.exports = {
44
+ runViewBeforeClose
45
+ });
@@ -12,9 +12,10 @@ export declare function useDialog(): (React.JSX.Element | {
12
12
  type: "dialog";
13
13
  inputArgs: any;
14
14
  preventClose: boolean;
15
+ beforeClose: any;
15
16
  destroy: (result?: any) => void;
16
17
  update: (newConfig: any) => void;
17
- close: (result?: any, force?: boolean) => void;
18
+ close: (result?: any, force?: boolean) => Promise<boolean>;
18
19
  Footer: React.FC<{
19
20
  children?: React.ReactNode;
20
21
  }>;
@@ -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
  let uuid = 0;
56
57
  function useDialog() {
57
58
  const holderRef = React.useRef(null);
@@ -103,12 +104,16 @@ function useDialog() {
103
104
  } else {
104
105
  ctx.addDelegate(flowContext.engine.context);
105
106
  }
107
+ let destroyed = false;
106
108
  const currentDialog = {
107
109
  type: "dialog",
108
110
  inputArgs: config.inputArgs || {},
109
111
  preventClose: !!config.preventClose,
112
+ beforeClose: void 0,
110
113
  destroy: /* @__PURE__ */ __name((result) => {
111
114
  var _a2, _b2, _c2, _d;
115
+ if (destroyed) return;
116
+ destroyed = true;
112
117
  (_a2 = config.onClose) == null ? void 0 : _a2.call(config);
113
118
  (_b2 = dialogRef.current) == null ? void 0 : _b2.destroy();
114
119
  closeFunc == null ? void 0 : closeFunc();
@@ -122,16 +127,21 @@ function useDialog() {
122
127
  var _a2;
123
128
  return (_a2 = dialogRef.current) == null ? void 0 : _a2.update(newConfig);
124
129
  }, "update"),
125
- close: /* @__PURE__ */ __name((result, force) => {
130
+ close: /* @__PURE__ */ __name(async (result, force) => {
126
131
  var _a2, _b2;
127
132
  if (config.preventClose && !force) {
128
- return;
133
+ return false;
134
+ }
135
+ const shouldClose = await (0, import_runViewBeforeClose.runViewBeforeClose)(currentDialog, { result, force });
136
+ if (!shouldClose) {
137
+ return false;
129
138
  }
130
139
  if (config.triggerByRouter && ((_b2 = (_a2 = config.inputArgs) == null ? void 0 : _a2.navigation) == null ? void 0 : _b2.back)) {
131
140
  config.inputArgs.navigation.back();
132
- return;
141
+ return true;
133
142
  }
134
143
  currentDialog.destroy(result);
144
+ return true;
135
145
  }, "close"),
136
146
  Footer: FooterComponent,
137
147
  Header: HeaderComponent,
@@ -154,6 +164,13 @@ function useDialog() {
154
164
  get: /* @__PURE__ */ __name(() => currentDialog, "get"),
155
165
  resolveOnServer: (0, import_variablesParams.createViewRecordResolveOnServer)(ctx, () => (0, import_variablesParams.getViewRecordFromParent)(flowContext, ctx))
156
166
  });
167
+ scopedEngine.setDestroyView(() => {
168
+ var _a2, _b2;
169
+ if (config.triggerByRouter && ((_b2 = (_a2 = config.inputArgs) == null ? void 0 : _a2.navigation) == null ? void 0 : _b2.back)) {
170
+ config.inputArgs.navigation.back();
171
+ }
172
+ currentDialog.destroy();
173
+ });
157
174
  (0, import_createViewMeta.registerPopupVariable)(ctx, currentDialog);
158
175
  const DialogWithContext = (0, import__.observer)(
159
176
  () => {
@@ -13,9 +13,10 @@ export declare function useDrawer(): (React.JSX.Element | {
13
13
  type: "drawer";
14
14
  inputArgs: any;
15
15
  preventClose: boolean;
16
+ beforeClose: any;
16
17
  destroy: (result?: any) => void;
17
18
  update: (newConfig: any) => void;
18
- close: (result?: any, force?: boolean) => void;
19
+ close: (result?: any, force?: boolean) => Promise<boolean>;
19
20
  Footer: React.FC<{
20
21
  children?: React.ReactNode;
21
22
  }>;
@@ -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-beta.1",
3
+ "version": "2.1.0-beta.11",
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,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.1.0-beta.1",
12
- "@nocobase/shared": "2.1.0-beta.1",
11
+ "@nocobase/sdk": "2.1.0-beta.11",
12
+ "@nocobase/shared": "2.1.0-beta.11",
13
13
  "ahooks": "^3.7.2",
14
14
  "dayjs": "^1.11.9",
15
15
  "dompurify": "^3.0.2",
@@ -35,6 +35,6 @@
35
35
  "workflow"
36
36
  ],
37
37
  "author": "NocoBase Team",
38
- "license": "AGPL-3.0",
39
- "gitHead": "de3efeb357b6a98b813f1c14831afa832aed1780"
38
+ "license": "Apache-2.0",
39
+ "gitHead": "b02e78b928f476d848b88bc545d3acddca00fe3c"
40
40
  }
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') {
@@ -29,6 +29,10 @@ describe('flowRunJSContext registry and doc', () => {
29
29
  expect(RunJSContextRegistry['resolve']('v1' as any, '*')).toBeTruthy();
30
30
  });
31
31
 
32
+ it('should register v2 mapping', () => {
33
+ expect(RunJSContextRegistry['resolve']('v2' as any, '*')).toBeTruthy();
34
+ });
35
+
32
36
  it('should register all context types', () => {
33
37
  const contextTypes = [
34
38
  'JSBlockModel',
@@ -44,12 +48,20 @@ describe('flowRunJSContext registry and doc', () => {
44
48
  const ctor = RunJSContextRegistry['resolve']('v1' as any, modelClass);
45
49
  expect(ctor).toBeTruthy();
46
50
  });
51
+
52
+ contextTypes.forEach((modelClass) => {
53
+ const ctor = RunJSContextRegistry['resolve']('v2' as any, modelClass);
54
+ expect(ctor).toBeTruthy();
55
+ });
47
56
  });
48
57
 
49
58
  it('should expose scene metadata for contexts', () => {
50
59
  expect(getRunJSScenesForModel('JSBlockModel', 'v1')).toEqual(['block']);
51
60
  expect(getRunJSScenesForModel('JSFieldModel', 'v1')).toEqual(['detail']);
61
+ expect(getRunJSScenesForModel('JSBlockModel', 'v2')).toEqual(['block']);
62
+ expect(getRunJSScenesForModel('JSFieldModel', 'v2')).toEqual(['detail']);
52
63
  expect(getRunJSScenesForModel('UnknownModel', 'v1')).toEqual([]);
64
+ expect(getRunJSScenesForModel('UnknownModel', 'v2')).toEqual([]);
53
65
  });
54
66
 
55
67
  it('should only execute once (idempotent)', async () => {
@@ -175,6 +187,7 @@ describe('flowRunJSContext registry and doc', () => {
175
187
  const ctx = new FlowContext();
176
188
  ctx.defineProperty('model', { value: { constructor: { name: 'JSColumnModel' } } });
177
189
  expect(getRunJSScenesForContext(ctx as any, { version: 'v1' })).toEqual(['table']);
190
+ expect(getRunJSScenesForContext(ctx as any, { version: 'v2' })).toEqual(['table']);
178
191
  });
179
192
 
180
193
  it('JSBlockModel context should have element property in doc', () => {
@@ -36,6 +36,29 @@ describe('ctx.runjs preprocessTemplates default', () => {
36
36
  expect(r.value).toBe('{{ctx.user.id}}');
37
37
  });
38
38
 
39
+ it('disables template preprocess by default for version v2', async () => {
40
+ const engine = new FlowEngine();
41
+ const ctx = engine.context as any;
42
+ ctx.defineProperty('user', { value: { id: 123 } });
43
+
44
+ const r = await ctx.runjs('return "{{ctx.user.id}}";', undefined, { version: 'v2' });
45
+ expect(r.success).toBe(true);
46
+ expect(r.value).toBe('{{ctx.user.id}}');
47
+ });
48
+
49
+ it('keeps explicit preprocessTemplates override higher priority than version', async () => {
50
+ const engine = new FlowEngine();
51
+ const ctx = engine.context as any;
52
+ ctx.defineProperty('user', { value: { id: 123 } });
53
+
54
+ const r = await ctx.runjs('return "{{ctx.user.id}}";', undefined, {
55
+ version: 'v2',
56
+ preprocessTemplates: true,
57
+ });
58
+ expect(r.success).toBe(true);
59
+ expect(r.value).toBe('123');
60
+ });
61
+
39
62
  it('does not double-preprocess already prepared code', async () => {
40
63
  const engine = new FlowEngine();
41
64
  const ctx = engine.context as any;