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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/JSRunner.d.ts +10 -1
  4. package/lib/JSRunner.js +50 -5
  5. package/lib/ViewScopedFlowEngine.js +5 -1
  6. package/lib/components/FlowModelRenderer.d.ts +1 -1
  7. package/lib/components/FlowModelRenderer.js +10 -6
  8. package/lib/components/MobilePopup.js +6 -5
  9. package/lib/components/dnd/gridDragPlanner.js +6 -2
  10. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  11. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +48 -9
  12. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +19 -43
  13. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +339 -295
  14. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  15. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  16. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +272 -0
  17. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  18. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +247 -0
  19. package/lib/components/subModel/AddSubModelButton.js +27 -1
  20. package/lib/components/subModel/utils.js +2 -2
  21. package/lib/data-source/index.js +6 -0
  22. package/lib/executor/FlowExecutor.js +31 -8
  23. package/lib/flowContext.js +31 -1
  24. package/lib/flowEngine.d.ts +151 -1
  25. package/lib/flowEngine.js +389 -15
  26. package/lib/flowSettings.d.ts +14 -6
  27. package/lib/flowSettings.js +34 -6
  28. package/lib/lazy-helper.d.ts +14 -0
  29. package/lib/lazy-helper.js +71 -0
  30. package/lib/locale/en-US.json +1 -0
  31. package/lib/locale/index.d.ts +2 -0
  32. package/lib/locale/zh-CN.json +1 -0
  33. package/lib/models/flowModel.d.ts +2 -1
  34. package/lib/models/flowModel.js +28 -9
  35. package/lib/reactive/observer.js +46 -16
  36. package/lib/runjs-context/registry.d.ts +1 -1
  37. package/lib/runjs-context/setup.js +20 -12
  38. package/lib/runjs-context/snippets/index.js +13 -2
  39. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  40. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  41. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  42. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  43. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  44. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  45. package/lib/types.d.ts +47 -1
  46. package/lib/utils/index.d.ts +2 -2
  47. package/lib/utils/index.js +4 -0
  48. package/lib/utils/parsePathnameToViewParams.js +1 -1
  49. package/lib/utils/runjsTemplateCompat.js +1 -1
  50. package/lib/utils/runjsValue.js +41 -11
  51. package/lib/utils/schema-utils.d.ts +7 -1
  52. package/lib/utils/schema-utils.js +19 -0
  53. package/lib/views/FlowView.d.ts +7 -1
  54. package/lib/views/runViewBeforeClose.d.ts +10 -0
  55. package/lib/views/runViewBeforeClose.js +45 -0
  56. package/lib/views/useDialog.d.ts +2 -1
  57. package/lib/views/useDialog.js +20 -3
  58. package/lib/views/useDrawer.d.ts +2 -1
  59. package/lib/views/useDrawer.js +20 -3
  60. package/lib/views/usePage.d.ts +2 -1
  61. package/lib/views/usePage.js +10 -3
  62. package/package.json +6 -5
  63. package/src/JSRunner.ts +68 -4
  64. package/src/ViewScopedFlowEngine.ts +4 -0
  65. package/src/__tests__/JSRunner.test.ts +27 -1
  66. package/src/__tests__/flow-engine.test.ts +166 -0
  67. package/src/__tests__/flowContext.test.ts +65 -1
  68. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  69. package/src/__tests__/flowSettings.test.ts +94 -15
  70. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  71. package/src/__tests__/runjsContext.test.ts +16 -0
  72. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  73. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  74. package/src/__tests__/runjsSnippets.test.ts +21 -0
  75. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  76. package/src/components/FlowModelRenderer.tsx +12 -6
  77. package/src/components/MobilePopup.tsx +4 -2
  78. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  79. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  80. package/src/components/__tests__/gridDragPlanner.test.ts +88 -0
  81. package/src/components/dnd/gridDragPlanner.ts +8 -2
  82. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +63 -9
  83. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +468 -440
  84. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  85. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +95 -0
  86. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +609 -0
  87. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +358 -0
  88. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +281 -0
  89. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  90. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +142 -32
  91. package/src/components/subModel/utils.ts +1 -1
  92. package/src/data-source/index.ts +6 -0
  93. package/src/executor/FlowExecutor.ts +34 -9
  94. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  95. package/src/flowContext.ts +35 -3
  96. package/src/flowEngine.ts +445 -11
  97. package/src/flowSettings.ts +40 -6
  98. package/src/lazy-helper.tsx +57 -0
  99. package/src/locale/en-US.json +1 -0
  100. package/src/locale/zh-CN.json +1 -0
  101. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  102. package/src/models/flowModel.tsx +31 -10
  103. package/src/reactive/__tests__/observer.test.tsx +82 -0
  104. package/src/reactive/observer.tsx +87 -25
  105. package/src/runjs-context/registry.ts +1 -1
  106. package/src/runjs-context/setup.ts +22 -12
  107. package/src/runjs-context/snippets/index.ts +12 -1
  108. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  109. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  110. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  111. package/src/types.ts +60 -0
  112. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
  113. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  114. package/src/utils/__tests__/utils.test.ts +62 -0
  115. package/src/utils/index.ts +2 -1
  116. package/src/utils/parsePathnameToViewParams.ts +2 -2
  117. package/src/utils/runjsTemplateCompat.ts +1 -1
  118. package/src/utils/runjsValue.ts +50 -11
  119. package/src/utils/schema-utils.ts +30 -1
  120. package/src/views/FlowView.tsx +11 -1
  121. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  122. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  123. package/src/views/runViewBeforeClose.ts +19 -0
  124. package/src/views/useDialog.tsx +25 -3
  125. package/src/views/useDrawer.tsx +25 -3
  126. package/src/views/usePage.tsx +12 -3
@@ -41,17 +41,27 @@ export async function setupRunJSContexts() {
41
41
  import('./contexts/JSCollectionActionRunJSContext'),
42
42
  ]);
43
43
 
44
- const v1 = 'v1';
45
- RunJSContextRegistry.register(v1, '*', FlowRunJSContext);
46
- RunJSContextRegistry.register(v1, 'JSBlockModel', JSBlockRunJSContext, { scenes: ['block'] });
47
- RunJSContextRegistry.register(v1, 'JSFieldModel', JSFieldRunJSContext, { scenes: ['detail'] });
48
- RunJSContextRegistry.register(v1, 'JSEditableFieldModel', JSEditableFieldRunJSContext, { scenes: ['form'] });
49
- RunJSContextRegistry.register(v1, 'JSItemModel', JSItemRunJSContext, { scenes: ['form'] });
50
- RunJSContextRegistry.register(v1, 'JSColumnModel', JSColumnRunJSContext, { scenes: ['table'] });
51
- RunJSContextRegistry.register(v1, 'FormJSFieldItemModel', FormJSFieldItemRunJSContext, { scenes: ['form'] });
52
- RunJSContextRegistry.register(v1, 'JSRecordActionModel', JSRecordActionRunJSContext, { scenes: ['table'] });
53
- RunJSContextRegistry.register(v1, 'JSCollectionActionModel', JSCollectionActionRunJSContext, { scenes: ['table'] });
54
- await applyRunJSContextContributions(v1);
44
+ const registerBuiltins = (version: 'v1' | 'v2') => {
45
+ RunJSContextRegistry.register(version, '*', FlowRunJSContext);
46
+ RunJSContextRegistry.register(version, 'JSBlockModel', JSBlockRunJSContext, { scenes: ['block'] });
47
+ RunJSContextRegistry.register(version, 'JSFieldModel', JSFieldRunJSContext, { scenes: ['detail'] });
48
+ RunJSContextRegistry.register(version, 'JSEditableFieldModel', JSEditableFieldRunJSContext, { scenes: ['form'] });
49
+ RunJSContextRegistry.register(version, 'JSItemModel', JSItemRunJSContext, { scenes: ['form'] });
50
+ RunJSContextRegistry.register(version, 'JSItemActionModel', JSItemRunJSContext, { scenes: ['table'] });
51
+ RunJSContextRegistry.register(version, 'JSColumnModel', JSColumnRunJSContext, { scenes: ['table'] });
52
+ RunJSContextRegistry.register(version, 'FormJSFieldItemModel', FormJSFieldItemRunJSContext, { scenes: ['form'] });
53
+ RunJSContextRegistry.register(version, 'JSRecordActionModel', JSRecordActionRunJSContext, { scenes: ['table'] });
54
+ RunJSContextRegistry.register(version, 'JSCollectionActionModel', JSCollectionActionRunJSContext, {
55
+ scenes: ['table'],
56
+ });
57
+ };
58
+
59
+ const versions: Array<'v1' | 'v2'> = ['v1', 'v2'];
60
+ for (const version of versions) {
61
+ registerBuiltins(version);
62
+ await applyRunJSContextContributions(version);
63
+ markRunJSContextsSetupDone(version);
64
+ }
65
+
55
66
  done = true;
56
- markRunJSContextsSetupDone(v1);
57
67
  }
@@ -49,6 +49,7 @@ const snippets: Record<string, RunJSSnippetLoader | undefined> = {
49
49
  'scene/detail/status-tag': () => import('./scene/detail/status-tag.snippet'),
50
50
  'scene/detail/relative-time': () => import('./scene/detail/relative-time.snippet'),
51
51
  'scene/detail/percentage-bar': () => import('./scene/detail/percentage-bar.snippet'),
52
+ 'scene/detail/set-field-style': () => import('./scene/detail/set-field-style.snippet'),
52
53
  // scene/form
53
54
  'scene/form/render-basic': () => import('./scene/form/render-basic.snippet'),
54
55
  'scene/form/set-field-value': () => import('./scene/form/set-field-value.snippet'),
@@ -67,6 +68,7 @@ const snippets: Record<string, RunJSSnippetLoader | undefined> = {
67
68
  'scene/table/iterate-selected-rows': () => import('./scene/table/iterate-selected-rows.snippet'),
68
69
  'scene/table/destroy-selected': () => import('./scene/table/destroy-selected.snippet'),
69
70
  'scene/table/export-selected-json': () => import('./scene/table/export-selected-json.snippet'),
71
+ 'scene/table/set-cell-style': () => import('./scene/table/set-cell-style.snippet'),
70
72
  };
71
73
 
72
74
  export default snippets;
@@ -125,10 +127,19 @@ function normalizeScenes(def: any, key: string): string[] {
125
127
  return [];
126
128
  }
127
129
 
130
+ function normalizeSceneGroup(scene: string): string {
131
+ const mapping: Record<string, string> = {
132
+ detailFieldEvent: 'detail',
133
+ tableFieldEvent: 'table',
134
+ formFieldEvent: 'form',
135
+ };
136
+ return mapping[scene] || scene;
137
+ }
138
+
128
139
  function computeGroups(def: any, key: string): string[] {
129
140
  const scenes = normalizeScenes(def, key);
130
141
  if (scenes.length) {
131
- return scenes.map((scene) => `scene/${scene}`);
142
+ return Array.from(new Set(scenes.map((scene) => `scene/${normalizeSceneGroup(scene)}`)));
132
143
  }
133
144
  const parts = key.split('/');
134
145
  if (!parts.length) return [];
@@ -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 type { SnippetModule } from '../../types';
11
+ const snippet: SnippetModule = {
12
+ contexts: ['*'],
13
+ scenes: ['detailFieldEvent', 'formFieldEvent'],
14
+ prefix: 'sn-item-style',
15
+ label: 'Set form item/details item style',
16
+ description: 'Customize form item and details item container styles',
17
+ locales: {
18
+ 'zh-CN': {
19
+ label: '设置表单项/详情项样式',
20
+ description: '自定义表单项和详情项容器样式',
21
+ },
22
+ },
23
+ content: `
24
+ ctx.model.props.style = {
25
+ background: 'red',
26
+ };
27
+ `,
28
+ };
29
+
30
+ export default snippet;
@@ -0,0 +1,34 @@
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 { SnippetModule } from '../../types';
11
+ const snippet: SnippetModule = {
12
+ contexts: ['*'],
13
+ scenes: ['tableFieldEvent'],
14
+ prefix: 'sn-table-cell-style',
15
+ label: 'Set table cell style',
16
+ description: 'Customize table field cell styles with onCell',
17
+ locales: {
18
+ 'zh-CN': {
19
+ label: '表格字段样式设置',
20
+ description: '通过 onCell 自定义表格字段单元格样式',
21
+ },
22
+ },
23
+ content: `
24
+ ctx.model.props.onCell = (record, rowIndex) => {
25
+ return {
26
+ style: {
27
+ background: 'red',
28
+ },
29
+ };
30
+ };
31
+ `,
32
+ };
33
+
34
+ export default snippet;
@@ -21,7 +21,11 @@ type LifecycleType =
21
21
  | `event:${string}:end`
22
22
  | `event:${string}:error`;
23
23
 
24
- export type ScheduleWhen = LifecycleType | ((e: LifecycleEvent) => boolean);
24
+ type EventPredicateWhen = ((e: LifecycleEvent) => boolean) & {
25
+ __eventType?: string;
26
+ };
27
+
28
+ export type ScheduleWhen = LifecycleType | EventPredicateWhen;
25
29
 
26
30
  export interface ScheduleOptions {
27
31
  when?: ScheduleWhen;
@@ -37,6 +41,7 @@ export interface LifecycleEvent {
37
41
  error?: any;
38
42
  inputArgs?: Record<string, any>;
39
43
  result?: any;
44
+ aborted?: boolean;
40
45
  flowKey?: string;
41
46
  stepKey?: string;
42
47
  }
@@ -216,8 +221,14 @@ export class ModelOperationScheduler {
216
221
  }
217
222
 
218
223
  private ensureEventSubscriptionIfNeeded(when?: ScheduleWhen) {
219
- if (!when || typeof when !== 'string') return;
220
- const parsed = this.parseEventWhen(when);
224
+ const eventType =
225
+ typeof when === 'string'
226
+ ? when
227
+ : typeof when === 'function'
228
+ ? (when as EventPredicateWhen).__eventType
229
+ : undefined;
230
+ if (!eventType) return;
231
+ const parsed = this.parseEventWhen(eventType as ScheduleWhen);
221
232
  if (!parsed) return;
222
233
  const { name } = parsed;
223
234
  if (this.subscribedEventNames.has(name)) return;
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'
@@ -387,6 +388,65 @@ export interface CreateModelOptions {
387
388
  delegateToParent?: boolean;
388
389
  [key: string]: any; // 允许额外的自定义选项
389
390
  }
391
+
392
+ /**
393
+ * FlowModel loader result.
394
+ * Supports returning the model constructor directly, a default export, or a module object containing the named export.
395
+ */
396
+ export type FlowModelLoaderResult =
397
+ | ModelConstructor
398
+ | {
399
+ default?: ModelConstructor;
400
+ [key: string]: unknown;
401
+ }
402
+ | Record<string, unknown>;
403
+
404
+ /**
405
+ * FlowModel loader function.
406
+ */
407
+ export type FlowModelLoader = () => Promise<FlowModelLoaderResult>;
408
+
409
+ /**
410
+ * FlowModel loader entry (normalized internal form).
411
+ */
412
+ export interface FlowModelLoaderEntry {
413
+ loader: FlowModelLoader;
414
+ extends?: string[];
415
+ // meta?: Partial<FlowModelMeta>;
416
+ // scenes?: string[];
417
+ }
418
+
419
+ /**
420
+ * FlowModel loader input (user-facing form for registerModelLoaders).
421
+ * The `extends` field accepts flexible formats that will be normalized to `string[]` at registration time.
422
+ */
423
+ export interface FlowModelLoaderInput {
424
+ loader: FlowModelLoader;
425
+ extends?: string | ModelConstructor | (string | ModelConstructor)[];
426
+ }
427
+
428
+ /**
429
+ * FlowModel loader entry map (normalized internal form).
430
+ */
431
+ export type FlowModelLoaderMap = Record<string, FlowModelLoaderEntry>;
432
+
433
+ /**
434
+ * FlowModel loader input map (user-facing form for registerModelLoaders).
435
+ */
436
+ export type FlowModelLoaderInputMap = Record<string, FlowModelLoaderInput>;
437
+
438
+ /**
439
+ * Batch ensure result.
440
+ */
441
+ export interface EnsureBatchResult {
442
+ requested: string[];
443
+ loaded: string[];
444
+ failed: Array<{
445
+ name: string;
446
+ error?: unknown;
447
+ }>;
448
+ }
449
+
390
450
  export interface IFlowModelRepository<T extends FlowModel = FlowModel> {
391
451
  findOne(query: Record<string, any>): Promise<Record<string, any> | null>;
392
452
  save(model: T, options?: { onlyStepParams?: boolean }): Promise<Record<string, any>>;
@@ -109,6 +109,13 @@ describe('parsePathnameToViewParams', () => {
109
109
  expect(result).toEqual([{ viewUid: 'xxx', filterByTk: { id: '1', tenant: 'ac' } }]);
110
110
  });
111
111
 
112
+ test('should parse filterByTk from single key-value encoded segment into object', () => {
113
+ const kv = encodeURIComponent('id=1');
114
+ const path = `/admin/xxx/filterbytk/${kv}`;
115
+ const result = parsePathnameToViewParams(path);
116
+ expect(result).toEqual([{ viewUid: 'xxx', filterByTk: { id: '1' } }]);
117
+ });
118
+
112
119
  test('should parse filterByTk from JSON object segment', () => {
113
120
  const json = encodeURIComponent('{"id":"1","tenant":"ac"}');
114
121
  const path = `/admin/xxx/filterbytk/${json}`;
@@ -41,4 +41,15 @@ describe('runjsValue utils', () => {
41
41
  expect(out.someVar).toContain('');
42
42
  expect(out.user).toContain('name');
43
43
  });
44
+
45
+ it('extractUsedVariablePathsFromRunJS: extracts ctx.getVar string paths', () => {
46
+ const code = `
47
+ const phone = await ctx.getVar('ctx.item.value.phone');
48
+ const assignee = await ctx.getVar("ctx.user.profile.name");
49
+ return [phone, assignee];
50
+ `;
51
+ const out = extractUsedVariablePathsFromRunJS(code);
52
+ expect(out.item).toContain('value.phone');
53
+ expect(out.user).toContain('profile.name');
54
+ });
44
55
  });
@@ -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
 
@@ -116,8 +116,8 @@ export const parsePathnameToViewParams = (pathname: string): ViewParam[] => {
116
116
  // 解析失败,按字符串保留
117
117
  parsed = decoded;
118
118
  }
119
- } else if (decoded && decoded.includes('=') && decoded.includes('&')) {
120
- // 形如 a=b&c=d 的整体段
119
+ } else if (decoded && /^[^=&]+=[^=&]*(?:&[^=&]+=[^=&]*)*$/.test(decoded)) {
120
+ // 形如 a=b 或 a=b&c=d 的整体段
121
121
  parsed = parseKeyValuePairs(decoded);
122
122
  }
123
123
  currentView.filterByTk = parsed;
@@ -553,8 +553,8 @@ function extractUsedCtxLibKeys(code: string): string[] {
553
553
  }
554
554
 
555
555
  function injectEnsureLibsPreamble(code: string): string {
556
- if (!CTX_LIBS_MARKER_RE.test(code)) return code;
557
556
  if (ENSURE_LIBS_MARKER_RE.test(code)) return code;
557
+ if (!CTX_LIBS_MARKER_RE.test(code)) return code;
558
558
  const keys = extractUsedCtxLibKeys(code);
559
559
  if (!keys.length) return code;
560
560
  return `/* __runjs_ensure_libs */\nawait ctx.__ensureLibs(${JSON.stringify(keys)});\n${code}`;
@@ -236,6 +236,37 @@ function normalizeSubPath(raw: string): { subPath: string; wildcard: boolean } {
236
236
  return { subPath: s, wildcard: false };
237
237
  }
238
238
 
239
+ function extractCtxRootUsage(expr: string): { varName: string; subPath: string; wildcard: boolean } | null {
240
+ const raw = String(expr || '').trim();
241
+ if (!raw || raw === 'ctx') return null;
242
+
243
+ const dotMatch = raw.match(/^ctx\.([a-zA-Z_$][a-zA-Z0-9_$]*)([\s\S]*)$/);
244
+ if (dotMatch) {
245
+ const varName = dotMatch[1] || '';
246
+ const rest = dotMatch[2] || '';
247
+ const normalized = normalizeSubPath(rest);
248
+ return {
249
+ varName,
250
+ subPath: normalized.subPath,
251
+ wildcard: normalized.wildcard,
252
+ };
253
+ }
254
+
255
+ const bracketMatch = raw.match(/^ctx\s*\[\s*(['"])([a-zA-Z_$][a-zA-Z0-9_$]*)\1\s*\]([\s\S]*)$/);
256
+ if (bracketMatch) {
257
+ const varName = bracketMatch[2] || '';
258
+ const rest = bracketMatch[3] || '';
259
+ const normalized = normalizeSubPath(rest);
260
+ return {
261
+ varName,
262
+ subPath: normalized.subPath,
263
+ wildcard: normalized.wildcard,
264
+ };
265
+ }
266
+
267
+ return null;
268
+ }
269
+
239
270
  /**
240
271
  * Heuristic extraction of ctx variable usage from RunJS code.
241
272
  *
@@ -256,27 +287,35 @@ export function extractUsedVariablePathsFromRunJS(code: string): Record<string,
256
287
  usage.set(varName, set);
257
288
  };
258
289
 
290
+ const addCtxUsage = (expr: string) => {
291
+ const hit = extractCtxRootUsage(expr);
292
+ if (!hit?.varName) return;
293
+ add(hit.varName, hit.wildcard ? '' : hit.subPath);
294
+ };
295
+
259
296
  // dot form: ctx.foo.bar / ctx.foo[0].bar (excluding ctx.method(...))
260
297
  const dotRe = /ctx\.([a-zA-Z_$][a-zA-Z0-9_$]*(?:(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)|(?:\[[^\]]+\]))*)(?!\s*\()/g;
261
298
  let match: RegExpExecArray | null;
262
299
  while ((match = dotRe.exec(src))) {
263
- const pathAfterCtx = match[1] || '';
264
- const firstKeyMatch = pathAfterCtx.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
265
- if (!firstKeyMatch) continue;
266
- const firstKey = firstKeyMatch[1];
267
- const rest = pathAfterCtx.slice(firstKey.length);
268
- const { subPath, wildcard } = normalizeSubPath(rest);
269
- add(firstKey, wildcard ? '' : subPath);
300
+ addCtxUsage(`ctx.${match[1] || ''}`);
270
301
  }
271
302
 
272
303
  // bracket root: ctx['foo'].bar / ctx["foo"][0] (excluding ctx['method'](...))
273
304
  const bracketRootRe =
274
305
  /ctx\s*\[\s*(['"])([a-zA-Z_$][a-zA-Z0-9_$]*)\1\s*\]((?:(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)|(?:\[[^\]]+\]))*)(?!\s*\()/g;
275
306
  while ((match = bracketRootRe.exec(srcWithStrings))) {
276
- const varName = match[2] || '';
277
- const rest = match[3] || '';
278
- const { subPath, wildcard } = normalizeSubPath(rest);
279
- add(varName, wildcard ? '' : subPath);
307
+ addCtxUsage(`ctx['${match[2] || ''}']${match[3] || ''}`);
308
+ }
309
+
310
+ // async-safe helper form: await ctx.getVar('ctx.foo.bar')
311
+ const getVarRe = /ctx\.getVar\s*\(\s*(['"])((?:\\.|(?!\1)[\s\S])*)\1\s*\)/g;
312
+ while ((match = getVarRe.exec(srcWithStrings))) {
313
+ const expr = String(match[2] || '')
314
+ .replace(/\\'/g, "'")
315
+ .replace(/\\"/g, '"')
316
+ .trim();
317
+ if (!expr.startsWith('ctx')) continue;
318
+ addCtxUsage(expr);
280
319
  }
281
320
 
282
321
  const out: Record<string, string[]> = {};
@@ -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
+ });