@nocobase/flow-engine 2.0.16 → 2.0.18

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.
@@ -459,6 +459,12 @@ const _FlowExecutor = class _FlowExecutor {
459
459
  result,
460
460
  ...abortedByExitAll ? { aborted: true } : {}
461
461
  });
462
+ if (result && typeof result === "object") {
463
+ Object.defineProperty(result, "__abortedByExitAll", {
464
+ value: abortedByExitAll,
465
+ configurable: true
466
+ });
467
+ }
462
468
  return result;
463
469
  } catch (error) {
464
470
  try {
package/lib/types.d.ts CHANGED
@@ -177,7 +177,7 @@ export interface ActionDefinition<TModel extends FlowModel = FlowModel, TCtx ext
177
177
  * - 收录内置常用事件,便于智能提示;
178
178
  * - 允许扩展字符串以保持向后兼容。
179
179
  */
180
- export type FlowEventName = 'click' | 'submit' | 'reset' | 'remove' | 'openView' | 'dropdownOpen' | 'popupScroll' | 'search' | 'customRequest' | 'collapseToggle' | (string & {});
180
+ export type FlowEventName = 'click' | 'close' | 'submit' | 'reset' | 'remove' | 'openView' | 'dropdownOpen' | 'popupScroll' | 'search' | 'customRequest' | 'collapseToggle' | (string & {});
181
181
  /**
182
182
  * 事件流的执行时机(phase)。
183
183
  *
@@ -8,11 +8,11 @@
8
8
  */
9
9
  export { BLOCK_GROUP_CONFIGS, BLOCK_TYPES, FLOW_ENGINE_NAMESPACE, MENU_KEYS, type BlockBuilderConfig, } from './constants';
10
10
  export { escapeT, getT, tExpr } from './translation';
11
- export { FlowCancelSaveException, FlowExitException } from './exceptions';
11
+ export { FlowCancelSaveException, FlowExitAllException, FlowExitException } from './exceptions';
12
12
  export { defineAction } from './flow-definitions';
13
13
  export { isInheritedFrom } from './inheritance';
14
14
  export { resolveCreateModelOptions, resolveDefaultParams, resolveExpressions } from './params-resolvers';
15
- export { compileUiSchema, resolveStepUiSchema, resolveStepDisabledInSettings, resolveUiMode, shouldHideStepInSettings, } from './schema-utils';
15
+ export { compileUiSchema, resolveStepUiSchema, resolveStepDisabledInSettings, resolveUiMode, shouldHideEventInSettings, shouldHideStepInSettings, } from './schema-utils';
16
16
  export { setupRuntimeContextSteps } from './setupRuntimeContextSteps';
17
17
  export { createCollectionContextMeta } from './createCollectionContextMeta';
18
18
  export { createAssociationAwareObjectMetaFactory, createAssociationSubpathResolver } from './associationObjectVariable';
@@ -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
  });
@@ -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);
@@ -108,6 +109,7 @@ function useDialog() {
108
109
  type: "dialog",
109
110
  inputArgs: config.inputArgs || {},
110
111
  preventClose: !!config.preventClose,
112
+ beforeClose: void 0,
111
113
  destroy: /* @__PURE__ */ __name((result) => {
112
114
  var _a2, _b2, _c2, _d;
113
115
  if (destroyed) return;
@@ -125,16 +127,21 @@ function useDialog() {
125
127
  var _a2;
126
128
  return (_a2 = dialogRef.current) == null ? void 0 : _a2.update(newConfig);
127
129
  }, "update"),
128
- close: /* @__PURE__ */ __name((result, force) => {
130
+ close: /* @__PURE__ */ __name(async (result, force) => {
129
131
  var _a2, _b2;
130
132
  if (config.preventClose && !force) {
131
- return;
133
+ return false;
134
+ }
135
+ const shouldClose = await (0, import_runViewBeforeClose.runViewBeforeClose)(currentDialog, { result, force });
136
+ if (!shouldClose) {
137
+ return false;
132
138
  }
133
139
  if (config.triggerByRouter && ((_b2 = (_a2 = config.inputArgs) == null ? void 0 : _a2.navigation) == null ? void 0 : _b2.back)) {
134
140
  config.inputArgs.navigation.back();
135
- return;
141
+ return true;
136
142
  }
137
143
  currentDialog.destroy(result);
144
+ return true;
138
145
  }, "close"),
139
146
  Footer: FooterComponent,
140
147
  Header: HeaderComponent,
@@ -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: [] }), []);
@@ -127,6 +128,7 @@ function useDrawer() {
127
128
  type: "drawer",
128
129
  inputArgs: config.inputArgs || {},
129
130
  preventClose: !!config.preventClose,
131
+ beforeClose: void 0,
130
132
  destroy: /* @__PURE__ */ __name((result) => {
131
133
  var _a2, _b2, _c, _d;
132
134
  if (destroyed) return;
@@ -144,16 +146,21 @@ function useDrawer() {
144
146
  var _a2;
145
147
  return (_a2 = drawerRef.current) == null ? void 0 : _a2.update(newConfig);
146
148
  }, "update"),
147
- close: /* @__PURE__ */ __name((result, force) => {
149
+ close: /* @__PURE__ */ __name(async (result, force) => {
148
150
  var _a2, _b2;
149
151
  if (config.preventClose && !force) {
150
- return;
152
+ return false;
153
+ }
154
+ const shouldClose = await (0, import_runViewBeforeClose.runViewBeforeClose)(currentDrawer, { result, force });
155
+ if (!shouldClose) {
156
+ return false;
151
157
  }
152
158
  if (config.triggerByRouter && ((_b2 = (_a2 = config.inputArgs) == null ? void 0 : _a2.navigation) == null ? void 0 : _b2.back)) {
153
159
  config.inputArgs.navigation.back();
154
- return;
160
+ return true;
155
161
  }
156
162
  currentDrawer.destroy(result);
163
+ return true;
157
164
  }, "close"),
158
165
  Footer: FooterComponent,
159
166
  Header: HeaderComponent,
@@ -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.0.16",
3
+ "version": "2.0.18",
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.0.16",
12
- "@nocobase/shared": "2.0.16",
11
+ "@nocobase/sdk": "2.0.18",
12
+ "@nocobase/shared": "2.0.18",
13
13
  "ahooks": "^3.7.2",
14
14
  "dayjs": "^1.11.9",
15
15
  "dompurify": "^3.0.2",
@@ -36,5 +36,5 @@
36
36
  ],
37
37
  "author": "NocoBase Team",
38
38
  "license": "Apache-2.0",
39
- "gitHead": "cd1b339b28eadbefde73535f945ef7cc3655d727"
39
+ "gitHead": "f93082fd2c15618fe85e697a9cdd90614371732e"
40
40
  }
@@ -519,6 +519,12 @@ export class FlowExecutor {
519
519
  result,
520
520
  ...(abortedByExitAll ? { aborted: true } : {}),
521
521
  });
522
+ if (result && typeof result === 'object') {
523
+ Object.defineProperty(result, '__abortedByExitAll', {
524
+ value: abortedByExitAll,
525
+ configurable: true,
526
+ });
527
+ }
522
528
  return result;
523
529
  } catch (error) {
524
530
  // 进入错误钩子并记录
@@ -232,6 +232,37 @@ describe('FlowExecutor', () => {
232
232
  expect(calls.sort()).toEqual(['a', 'b']);
233
233
  });
234
234
 
235
+ it('dispatchEvent sequential exposes abortedByExitAll metadata on result array', async () => {
236
+ const flows = {
237
+ stopClose: {
238
+ on: { eventName: 'close' },
239
+ steps: {
240
+ only: {
241
+ handler: vi.fn().mockImplementation((ctx) => {
242
+ ctx.exit();
243
+ }),
244
+ },
245
+ },
246
+ },
247
+ afterClose: {
248
+ on: { eventName: 'close', phase: 'afterAllFlows' },
249
+ steps: {
250
+ only: {
251
+ handler: vi.fn(),
252
+ },
253
+ },
254
+ },
255
+ } satisfies Record<string, Omit<FlowDefinitionOptions, 'key'>>;
256
+
257
+ const model = createModelWithFlows('m-close-meta', flows);
258
+
259
+ const result = await engine.executor.dispatchEvent(model, 'close', {}, { sequential: true });
260
+
261
+ expect(Array.isArray(result)).toBe(true);
262
+ expect((result as any).__abortedByExitAll).toBe(true);
263
+ expect(flows.afterClose.steps.only.handler).not.toHaveBeenCalled();
264
+ });
265
+
235
266
  it('dispatchEvent sequential respects sort order and stops on errors', async () => {
236
267
  const calls: string[] = [];
237
268
  const mkFlow = (key: string, sort: number, opts?: { throw?: boolean }) => ({
package/src/types.ts CHANGED
@@ -213,6 +213,7 @@ export interface ActionDefinition<TModel extends FlowModel = FlowModel, TCtx ext
213
213
  */
214
214
  export type FlowEventName =
215
215
  | 'click'
216
+ | 'close'
216
217
  | 'submit'
217
218
  | 'reset'
218
219
  | 'remove'
@@ -12,6 +12,7 @@ import {
12
12
  getT,
13
13
  isInheritedFrom,
14
14
  resolveDefaultParams,
15
+ shouldHideEventInSettings,
15
16
  resolveStepUiSchema,
16
17
  resolveStepDisabledInSettings,
17
18
  shouldHideStepInSettings,
@@ -27,6 +28,7 @@ import type {
27
28
  FlowDefinitionOptions,
28
29
  ActionDefinition,
29
30
  DeepPartial,
31
+ EventDefinition,
30
32
  ModelConstructor,
31
33
  StepParams,
32
34
  StepDefinition,
@@ -1002,6 +1004,66 @@ describe('Utils', () => {
1002
1004
  });
1003
1005
  });
1004
1006
 
1007
+ // ==================== shouldHideEventInSettings() FUNCTION ====================
1008
+ describe('shouldHideEventInSettings()', () => {
1009
+ let mockFlow: any;
1010
+ let mockEvent: EventDefinition;
1011
+
1012
+ beforeEach(() => {
1013
+ mockFlow = {
1014
+ key: 'testFlow',
1015
+ title: 'Test Flow',
1016
+ steps: {},
1017
+ };
1018
+
1019
+ mockEvent = {
1020
+ name: 'close',
1021
+ title: 'Close',
1022
+ handler: vi.fn(),
1023
+ };
1024
+ });
1025
+
1026
+ test('returns true for static hideInSettings=true', async () => {
1027
+ mockEvent.hideInSettings = true;
1028
+
1029
+ const result = await shouldHideEventInSettings(mockModel, mockFlow, mockEvent);
1030
+
1031
+ expect(result).toBe(true);
1032
+ });
1033
+
1034
+ test('returns false for static hideInSettings=false', async () => {
1035
+ mockEvent.hideInSettings = false;
1036
+
1037
+ const result = await shouldHideEventInSettings(mockModel, mockFlow, mockEvent);
1038
+
1039
+ expect(result).toBe(false);
1040
+ });
1041
+
1042
+ test('evaluates function hideInSettings with FlowRuntimeContext and can read ctx.view.preventClose', async () => {
1043
+ mockModel.context.defineProperty('view', { value: { preventClose: true } });
1044
+ const hideFn = vi.fn().mockImplementation((ctx) => !!ctx.view?.preventClose);
1045
+ mockEvent.hideInSettings = hideFn as any;
1046
+
1047
+ const result = await shouldHideEventInSettings(mockModel, mockFlow, mockEvent);
1048
+
1049
+ expect(hideFn).toHaveBeenCalledTimes(1);
1050
+ const ctx = hideFn.mock.calls[0][0] as FlowRuntimeContext;
1051
+ expect(ctx).toBeInstanceOf(FlowRuntimeContext);
1052
+ expect(result).toBe(true);
1053
+ });
1054
+
1055
+ test('returns false and logs warning when event hideInSettings throws', async () => {
1056
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1057
+ mockEvent.hideInSettings = vi.fn().mockRejectedValue(new Error('boom')) as any;
1058
+
1059
+ const result = await shouldHideEventInSettings(mockModel, mockFlow, mockEvent);
1060
+
1061
+ expect(consoleSpy).toHaveBeenCalled();
1062
+ expect(result).toBe(false);
1063
+ consoleSpy.mockRestore();
1064
+ });
1065
+ });
1066
+
1005
1067
  // ==================== shouldHideStepInSettings() FUNCTION ====================
1006
1068
  describe('shouldHideStepInSettings()', () => {
1007
1069
  let mockFlow: any;
@@ -22,7 +22,7 @@ export {
22
22
  export { escapeT, getT, tExpr } from './translation';
23
23
 
24
24
  // 异常类
25
- export { FlowCancelSaveException, FlowExitException } from './exceptions';
25
+ export { FlowCancelSaveException, FlowExitAllException, FlowExitException } from './exceptions';
26
26
 
27
27
  // 流程定义相关
28
28
  export { defineAction } from './flow-definitions';
@@ -39,6 +39,7 @@ export {
39
39
  resolveStepUiSchema,
40
40
  resolveStepDisabledInSettings,
41
41
  resolveUiMode,
42
+ shouldHideEventInSettings,
42
43
  shouldHideStepInSettings,
43
44
  } from './schema-utils';
44
45
 
@@ -11,7 +11,7 @@ import type { ISchema } from '@formily/json-schema';
11
11
  import { Schema } from '@formily/json-schema';
12
12
  import type { FlowModel } from '../models';
13
13
  import { FlowRuntimeContext } from '../flowContext';
14
- import type { StepDefinition, StepUIMode } from '../types';
14
+ import type { EventDefinition, StepDefinition, StepUIMode } from '../types';
15
15
  import { setupRuntimeContextSteps } from './setupRuntimeContextSteps';
16
16
 
17
17
  /**
@@ -242,6 +242,35 @@ export async function resolveStepUiSchema<TModel extends FlowModel = FlowModel>(
242
242
  return resolvedStepUiSchema;
243
243
  }
244
244
 
245
+ /**
246
+ * 判断事件在设置菜单中是否应被隐藏。
247
+ * - 支持 EventDefinition.hideInSettings。
248
+ * - hideInSettings 可为布尔值或函数(接收 FlowRuntimeContext)。
249
+ */
250
+ export async function shouldHideEventInSettings<TModel extends FlowModel = FlowModel>(
251
+ model: TModel,
252
+ flow: any,
253
+ event: EventDefinition<TModel> | undefined,
254
+ ): Promise<boolean> {
255
+ if (!event) return true;
256
+
257
+ const { hideInSettings } = event;
258
+
259
+ if (typeof hideInSettings === 'function') {
260
+ try {
261
+ const ctx = new FlowRuntimeContext(model, flow.key, 'settings');
262
+ setupRuntimeContextSteps(ctx, flow.steps || {}, model, flow.key);
263
+ const result = await hideInSettings(ctx as any);
264
+ return !!result;
265
+ } catch (error) {
266
+ console.warn(`Error evaluating hideInSettings for event '${event.name || ''}' in flow '${flow.key}':`, error);
267
+ return false;
268
+ }
269
+ }
270
+
271
+ return !!hideInSettings;
272
+ }
273
+
245
274
  /**
246
275
  * 判断步骤在设置菜单中是否应被隐藏。
247
276
  * - 支持 StepDefinition.hideInSettings 与 ActionDefinition.hideInSettings(step 优先)。
@@ -11,13 +11,23 @@ import { PopoverProps as AntdPopoverProps } from 'antd';
11
11
  import { FlowContext } from '../flowContext';
12
12
  import { ViewNavigation } from './ViewNavigation';
13
13
 
14
+ export type FlowViewBeforeClosePayload = {
15
+ result?: any;
16
+ force?: boolean;
17
+ };
18
+
19
+ export type FlowViewBeforeCloseHandler = (
20
+ payload: FlowViewBeforeClosePayload,
21
+ ) => Promise<boolean | void> | boolean | void;
22
+
14
23
  export type FlowView = {
15
24
  type: 'drawer' | 'popover' | 'dialog' | 'embed';
16
25
  inputArgs: any;
17
26
  Header: React.FC<{ title?: React.ReactNode; extra?: React.ReactNode }> | null;
18
27
  Footer: React.FC<{ children?: React.ReactNode }> | null;
19
- close: (result?: any, force?: boolean) => void;
28
+ close: (result?: any, force?: boolean) => Promise<boolean | void> | boolean | void;
20
29
  update: (newConfig: any) => void;
30
+ beforeClose?: FlowViewBeforeCloseHandler;
21
31
  navigation?: ViewNavigation;
22
32
  /** 页面的销毁方法 */
23
33
  destroy?: () => void;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import { runViewBeforeClose } from '../runViewBeforeClose';
12
+
13
+ describe('runViewBeforeClose', () => {
14
+ it('returns true when no beforeClose handler is configured', async () => {
15
+ await expect(runViewBeforeClose({} as any, { force: false })).resolves.toBe(true);
16
+ });
17
+
18
+ it('skips beforeClose handler for force close', async () => {
19
+ const beforeClose = vi.fn();
20
+
21
+ await expect(runViewBeforeClose({ beforeClose } as any, { force: true })).resolves.toBe(true);
22
+ expect(beforeClose).not.toHaveBeenCalled();
23
+ });
24
+
25
+ it('returns false when beforeClose handler blocks the close', async () => {
26
+ const beforeClose = vi.fn().mockResolvedValue(false);
27
+
28
+ await expect(runViewBeforeClose({ beforeClose } as any, { force: false })).resolves.toBe(false);
29
+ });
30
+ });
@@ -76,40 +76,40 @@ describe('useDialog - close/destroy logic', () => {
76
76
  return api;
77
77
  };
78
78
 
79
- it('should call destroy (and thus closeFunc) when close is called without preventClose', () => {
79
+ it('should call destroy (and thus closeFunc) when close is called without preventClose', async () => {
80
80
  const api = renderUseDialog();
81
81
  const flowContext = createMockFlowContext();
82
82
 
83
83
  const dialog = api.open({}, flowContext);
84
84
 
85
- dialog.close();
85
+ await dialog.close();
86
86
 
87
87
  expect(mockCloseFunc).toHaveBeenCalled();
88
88
  });
89
89
 
90
- it('should not call destroy (and thus closeFunc) when close is called with preventClose=true', () => {
90
+ it('should not call destroy (and thus closeFunc) when close is called with preventClose=true', async () => {
91
91
  const api = renderUseDialog();
92
92
  const flowContext = createMockFlowContext();
93
93
 
94
94
  const dialog = api.open({ preventClose: true }, flowContext);
95
95
 
96
- dialog.close();
96
+ await dialog.close();
97
97
 
98
98
  expect(mockCloseFunc).not.toHaveBeenCalled();
99
99
  });
100
100
 
101
- it('should call destroy (and thus closeFunc) when close is called with preventClose=true but force=true', () => {
101
+ it('should call destroy (and thus closeFunc) when close is called with preventClose=true but force=true', async () => {
102
102
  const api = renderUseDialog();
103
103
  const flowContext = createMockFlowContext();
104
104
 
105
105
  const dialog = api.open({ preventClose: true }, flowContext);
106
106
 
107
- dialog.close(undefined, true);
107
+ await dialog.close(undefined, true);
108
108
 
109
109
  expect(mockCloseFunc).toHaveBeenCalled();
110
110
  });
111
111
 
112
- it('should delegate to navigation.back when triggerByRouter is true', () => {
112
+ it('should delegate to navigation.back when triggerByRouter is true', async () => {
113
113
  const api = renderUseDialog();
114
114
  const flowContext = createMockFlowContext();
115
115
  const backMock = vi.fn();
@@ -126,25 +126,25 @@ describe('useDialog - close/destroy logic', () => {
126
126
  flowContext,
127
127
  );
128
128
 
129
- dialog.close();
129
+ await dialog.close();
130
130
 
131
131
  expect(backMock).toHaveBeenCalled();
132
132
  // Should not call destroy directly, let router handle it
133
133
  expect(mockCloseFunc).not.toHaveBeenCalled();
134
134
  });
135
135
 
136
- it('should emit view activated event on opener engine', () => {
136
+ it('should emit view activated event on opener engine', async () => {
137
137
  const api = renderUseDialog();
138
138
  const flowContext = createMockFlowContext();
139
139
  const emitSpy = flowContext.engine.emitter.emit;
140
140
 
141
141
  const dialog = api.open({ inputArgs: { viewUid: 'child-view' } }, flowContext);
142
142
 
143
- dialog.close();
143
+ await dialog.close();
144
144
  expect(emitSpy).toHaveBeenCalledWith('view:activated', expect.anything());
145
145
  });
146
146
 
147
- it('should emit view events on immediate opener engine (previousEngine) when present', () => {
147
+ it('should emit view events on immediate opener engine (previousEngine) when present', async () => {
148
148
  const api = renderUseDialog();
149
149
  const flowContext = createMockFlowContext();
150
150
  const rootEmitSpy = flowContext.engine.emitter.emit;
@@ -153,7 +153,7 @@ describe('useDialog - close/destroy logic', () => {
153
153
 
154
154
  const dialog = api.open({ inputArgs: { viewUid: 'child-view' } }, flowContext);
155
155
 
156
- dialog.close();
156
+ await dialog.close();
157
157
  expect(openerEmitSpy).toHaveBeenCalledWith('view:activated', expect.anything());
158
158
  expect(rootEmitSpy).not.toHaveBeenCalledWith('view:activated', expect.anything());
159
159
  });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import type { FlowView, FlowViewBeforeClosePayload } from './FlowView';
11
+
12
+ export async function runViewBeforeClose(view: FlowView, payload: FlowViewBeforeClosePayload): Promise<boolean> {
13
+ if (payload.force) {
14
+ return true;
15
+ }
16
+
17
+ const result = await view.beforeClose?.(payload);
18
+ return result !== false;
19
+ }
@@ -19,6 +19,7 @@ import { VIEW_ACTIVATED_EVENT, bumpViewActivatedVersion, resolveOpenerEngine } f
19
19
  import { FlowEngineProvider } from '../provider';
20
20
  import { createViewScopedEngine } from '../ViewScopedFlowEngine';
21
21
  import { createViewRecordResolveOnServer, getViewRecordFromParent } from '../utils/variablesParams';
22
+ import { runViewBeforeClose } from './runViewBeforeClose';
22
23
 
23
24
  let uuid = 0;
24
25
 
@@ -97,6 +98,7 @@ export function useDialog() {
97
98
  type: 'dialog' as const,
98
99
  inputArgs: config.inputArgs || {},
99
100
  preventClose: !!config.preventClose,
101
+ beforeClose: undefined,
100
102
  destroy: (result?: any) => {
101
103
  if (destroyed) return;
102
104
  destroyed = true;
@@ -112,18 +114,24 @@ export function useDialog() {
112
114
  scopedEngine.unlinkFromStack();
113
115
  },
114
116
  update: (newConfig) => dialogRef.current?.update(newConfig),
115
- close: (result?: any, force?: boolean) => {
117
+ close: async (result?: any, force?: boolean) => {
116
118
  if (config.preventClose && !force) {
117
- return;
119
+ return false;
120
+ }
121
+
122
+ const shouldClose = await runViewBeforeClose(currentDialog, { result, force });
123
+ if (!shouldClose) {
124
+ return false;
118
125
  }
119
126
 
120
127
  if (config.triggerByRouter && config.inputArgs?.navigation?.back) {
121
128
  // 交由路由系统来销毁当前视图
122
129
  config.inputArgs.navigation.back();
123
- return;
130
+ return true;
124
131
  }
125
132
 
126
133
  currentDialog.destroy(result);
134
+ return true;
127
135
  },
128
136
  Footer: FooterComponent,
129
137
  Header: HeaderComponent,
@@ -19,6 +19,7 @@ import { VIEW_ACTIVATED_EVENT, bumpViewActivatedVersion, resolveOpenerEngine } f
19
19
  import { FlowEngineProvider } from '../provider';
20
20
  import { createViewScopedEngine } from '../ViewScopedFlowEngine';
21
21
  import { createViewRecordResolveOnServer, getViewRecordFromParent } from '../utils/variablesParams';
22
+ import { runViewBeforeClose } from './runViewBeforeClose';
22
23
 
23
24
  export function useDrawer() {
24
25
  const holderRef = React.useRef(null);
@@ -126,6 +127,7 @@ export function useDrawer() {
126
127
  type: 'drawer' as const,
127
128
  inputArgs: config.inputArgs || {},
128
129
  preventClose: !!config.preventClose,
130
+ beforeClose: undefined,
129
131
  destroy: (result?: any) => {
130
132
  if (destroyed) return;
131
133
  destroyed = true;
@@ -141,18 +143,24 @@ export function useDrawer() {
141
143
  scopedEngine.unlinkFromStack();
142
144
  },
143
145
  update: (newConfig) => drawerRef.current?.update(newConfig),
144
- close: (result?: any, force?: boolean) => {
146
+ close: async (result?: any, force?: boolean) => {
145
147
  if (config.preventClose && !force) {
146
- return;
148
+ return false;
149
+ }
150
+
151
+ const shouldClose = await runViewBeforeClose(currentDrawer, { result, force });
152
+ if (!shouldClose) {
153
+ return false;
147
154
  }
148
155
 
149
156
  if (config.triggerByRouter && config.inputArgs?.navigation?.back) {
150
157
  // 交由路由系统来销毁当前视图
151
158
  config.inputArgs.navigation.back();
152
- return;
159
+ return true;
153
160
  }
154
161
 
155
162
  currentDrawer.destroy(result);
163
+ return true;
156
164
  },
157
165
  Footer: FooterComponent,
158
166
  Header: HeaderComponent,
@@ -19,6 +19,7 @@ import { VIEW_ACTIVATED_EVENT, bumpViewActivatedVersion, resolveOpenerEngine } f
19
19
  import { FlowEngineProvider } from '../provider';
20
20
  import { createViewScopedEngine } from '../ViewScopedFlowEngine';
21
21
  import { createViewRecordResolveOnServer, getViewRecordFromParent } from '../utils/variablesParams';
22
+ import { runViewBeforeClose } from './runViewBeforeClose';
22
23
 
23
24
  let uuid = 0;
24
25
 
@@ -122,6 +123,7 @@ export function usePage() {
122
123
  type: 'embed' as const,
123
124
  inputArgs: viewInputArgs,
124
125
  preventClose: !!config.preventClose,
126
+ beforeClose: undefined,
125
127
  destroy: (result?: any) => {
126
128
  config.onClose?.();
127
129
  resolvePromise?.(result);
@@ -145,18 +147,24 @@ export function usePage() {
145
147
  scopedEngine.unlinkFromStack();
146
148
  },
147
149
  update: (newConfig) => pageRef.current?.update(newConfig),
148
- close: (result?: any, force?: boolean) => {
150
+ close: async (result?: any, force?: boolean) => {
149
151
  if (preventClose && !force) {
150
- return;
152
+ return false;
153
+ }
154
+
155
+ const shouldClose = await runViewBeforeClose(currentPage, { result, force });
156
+ if (!shouldClose) {
157
+ return false;
151
158
  }
152
159
 
153
160
  if (config.triggerByRouter && config.inputArgs?.navigation?.back) {
154
161
  // 交由路由系统来销毁当前视图
155
162
  config.inputArgs.navigation.back();
156
- return;
163
+ return true;
157
164
  }
158
165
 
159
166
  currentPage.destroy(result);
167
+ return true;
160
168
  },
161
169
  Header: HeaderComponent,
162
170
  Footer: FooterComponent,