@nocobase/flow-engine 2.1.0-alpha.3 → 2.1.0-alpha.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -661
- package/README.md +79 -10
- package/lib/JSRunner.d.ts +10 -1
- package/lib/JSRunner.js +50 -5
- package/lib/ViewScopedFlowEngine.js +5 -1
- package/lib/components/FieldModelRenderer.js +2 -2
- package/lib/components/FlowModelRenderer.d.ts +3 -1
- package/lib/components/FlowModelRenderer.js +12 -6
- package/lib/components/MobilePopup.js +6 -5
- package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
- package/lib/components/dnd/gridDragPlanner.js +601 -21
- package/lib/components/dnd/index.d.ts +19 -1
- package/lib/components/dnd/index.js +243 -23
- package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
- package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +68 -10
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
- package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
- package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
- package/lib/components/subModel/AddSubModelButton.js +27 -1
- package/lib/components/subModel/index.d.ts +1 -0
- package/lib/components/subModel/index.js +19 -0
- package/lib/components/subModel/utils.d.ts +1 -1
- package/lib/components/subModel/utils.js +2 -2
- package/lib/data-source/index.d.ts +73 -0
- package/lib/data-source/index.js +211 -1
- package/lib/executor/FlowExecutor.js +31 -8
- package/lib/flowContext.d.ts +2 -0
- package/lib/flowContext.js +31 -1
- package/lib/flowEngine.d.ts +151 -1
- package/lib/flowEngine.js +389 -15
- package/lib/flowI18n.js +2 -1
- package/lib/flowSettings.d.ts +14 -6
- package/lib/flowSettings.js +34 -6
- package/lib/lazy-helper.d.ts +14 -0
- package/lib/lazy-helper.js +71 -0
- package/lib/locale/en-US.json +1 -0
- package/lib/locale/index.d.ts +2 -0
- package/lib/locale/zh-CN.json +1 -0
- package/lib/models/DisplayItemModel.d.ts +1 -1
- package/lib/models/EditableItemModel.d.ts +1 -1
- package/lib/models/FilterableItemModel.d.ts +1 -1
- package/lib/models/flowModel.d.ts +13 -10
- package/lib/models/flowModel.js +78 -18
- package/lib/provider.js +38 -23
- package/lib/reactive/observer.js +46 -16
- package/lib/runjs-context/registry.d.ts +1 -1
- package/lib/runjs-context/setup.js +20 -12
- package/lib/runjs-context/snippets/index.js +13 -2
- package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
- package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
- package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
- package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
- package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
- package/lib/scheduler/ModelOperationScheduler.js +3 -2
- package/lib/types.d.ts +47 -1
- package/lib/utils/createCollectionContextMeta.js +6 -2
- package/lib/utils/index.d.ts +2 -2
- package/lib/utils/index.js +4 -0
- package/lib/utils/parsePathnameToViewParams.js +1 -1
- package/lib/utils/runjsTemplateCompat.js +1 -1
- package/lib/utils/runjsValue.js +41 -11
- package/lib/utils/schema-utils.d.ts +7 -1
- package/lib/utils/schema-utils.js +19 -0
- package/lib/views/FlowView.d.ts +7 -1
- package/lib/views/runViewBeforeClose.d.ts +10 -0
- package/lib/views/runViewBeforeClose.js +45 -0
- package/lib/views/useDialog.d.ts +2 -1
- package/lib/views/useDialog.js +20 -3
- package/lib/views/useDrawer.d.ts +2 -1
- package/lib/views/useDrawer.js +20 -3
- package/lib/views/usePage.d.ts +2 -1
- package/lib/views/usePage.js +10 -3
- package/package.json +6 -5
- package/src/JSRunner.ts +68 -4
- package/src/ViewScopedFlowEngine.ts +4 -0
- package/src/__tests__/JSRunner.test.ts +27 -1
- package/src/__tests__/flow-engine.test.ts +166 -0
- package/src/__tests__/flowContext.test.ts +65 -1
- package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
- package/src/__tests__/flowSettings.test.ts +94 -15
- package/src/__tests__/objectVariable.test.ts +24 -0
- package/src/__tests__/provider.test.tsx +24 -2
- package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
- package/src/__tests__/runjsContext.test.ts +16 -0
- package/src/__tests__/runjsContextRuntime.test.ts +2 -0
- package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
- package/src/__tests__/runjsSnippets.test.ts +21 -0
- package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
- package/src/components/FieldModelRenderer.tsx +2 -1
- package/src/components/FlowModelRenderer.tsx +18 -6
- package/src/components/MobilePopup.tsx +4 -2
- package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
- package/src/components/__tests__/dnd.test.ts +44 -0
- package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
- package/src/components/__tests__/gridDragPlanner.test.ts +512 -3
- package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
- package/src/components/dnd/gridDragPlanner.ts +743 -19
- package/src/components/dnd/index.tsx +291 -27
- package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +88 -10
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
- package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +189 -3
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
- package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
- package/src/components/subModel/AddSubModelButton.tsx +32 -2
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +142 -32
- package/src/components/subModel/index.ts +1 -0
- package/src/components/subModel/utils.ts +1 -1
- package/src/data-source/__tests__/index.test.ts +34 -1
- package/src/data-source/index.ts +258 -2
- package/src/executor/FlowExecutor.ts +34 -9
- package/src/executor/__tests__/flowExecutor.test.ts +57 -0
- package/src/flowContext.ts +37 -3
- package/src/flowEngine.ts +445 -11
- package/src/flowI18n.ts +2 -1
- package/src/flowSettings.ts +40 -6
- package/src/lazy-helper.tsx +57 -0
- package/src/locale/en-US.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/models/DisplayItemModel.tsx +1 -1
- package/src/models/EditableItemModel.tsx +1 -1
- package/src/models/FilterableItemModel.tsx +1 -1
- package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
- package/src/models/__tests__/flowModel.test.ts +19 -3
- package/src/models/flowModel.tsx +119 -33
- package/src/provider.tsx +41 -25
- package/src/reactive/__tests__/observer.test.tsx +82 -0
- package/src/reactive/observer.tsx +87 -25
- package/src/runjs-context/registry.ts +1 -1
- package/src/runjs-context/setup.ts +22 -12
- package/src/runjs-context/snippets/index.ts +12 -1
- package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
- package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
- package/src/scheduler/ModelOperationScheduler.ts +14 -3
- package/src/types.ts +60 -0
- package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
- package/src/utils/__tests__/runjsValue.test.ts +11 -0
- package/src/utils/__tests__/utils.test.ts +62 -0
- package/src/utils/createCollectionContextMeta.ts +6 -2
- package/src/utils/index.ts +2 -1
- package/src/utils/parsePathnameToViewParams.ts +2 -2
- package/src/utils/runjsTemplateCompat.ts +1 -1
- package/src/utils/runjsValue.ts +50 -11
- package/src/utils/schema-utils.ts +30 -1
- package/src/views/FlowView.tsx +11 -1
- package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
- package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
- package/src/views/runViewBeforeClose.ts +19 -0
- package/src/views/useDialog.tsx +25 -3
- package/src/views/useDrawer.tsx +25 -3
- package/src/views/usePage.tsx +12 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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>>;
|
|
@@ -26,6 +26,7 @@ describe('createCollectionContextMeta', () => {
|
|
|
26
26
|
{ name: 'id', type: 'integer', interface: 'number', filterable: true },
|
|
27
27
|
{ name: 'email', type: 'string', interface: 'text', filterable: true },
|
|
28
28
|
{ name: 'nickname', type: 'string', interface: 'text' }, // 未声明 filterable
|
|
29
|
+
{ name: 'rawUserPayload', type: 'json', filterable: true },
|
|
29
30
|
],
|
|
30
31
|
});
|
|
31
32
|
|
|
@@ -34,6 +35,7 @@ describe('createCollectionContextMeta', () => {
|
|
|
34
35
|
fields: [
|
|
35
36
|
{ name: 'title', type: 'string', interface: 'text', filterable: true },
|
|
36
37
|
{ name: 'author', type: 'belongsTo', target: 'users', interface: 'm2o', filterable: true },
|
|
38
|
+
{ name: 'rawPostPayload', type: 'json', filterable: true },
|
|
37
39
|
],
|
|
38
40
|
});
|
|
39
41
|
|
|
@@ -44,8 +46,54 @@ describe('createCollectionContextMeta', () => {
|
|
|
44
46
|
const authorMeta: any = props?.author;
|
|
45
47
|
const authorFields = await authorMeta?.properties?.();
|
|
46
48
|
|
|
49
|
+
expect(props).toHaveProperty('title');
|
|
50
|
+
expect(props).toHaveProperty('author');
|
|
51
|
+
expect(props).not.toHaveProperty('rawPostPayload');
|
|
47
52
|
expect(authorFields).toBeTruthy();
|
|
48
53
|
expect(authorFields).toHaveProperty('email');
|
|
49
54
|
expect(authorFields).not.toHaveProperty('nickname');
|
|
55
|
+
expect(authorFields).not.toHaveProperty('rawUserPayload');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps interfaced non-filterable fields but hides fields without interface when includeNonFilterable is true', async () => {
|
|
59
|
+
const engine = new FlowEngine();
|
|
60
|
+
const dm = engine.dataSourceManager as any;
|
|
61
|
+
dm.collectionFieldInterfaceManager = new CollectionFieldInterfaceManager([], {}, dm);
|
|
62
|
+
engine.context.defineProperty('app', { value: { dataSourceManager: dm } });
|
|
63
|
+
const ds = dm.getDataSource('main')!;
|
|
64
|
+
|
|
65
|
+
ds.addCollection({
|
|
66
|
+
name: 'users',
|
|
67
|
+
fields: [
|
|
68
|
+
{ name: 'id', type: 'integer', interface: 'number', filterable: true },
|
|
69
|
+
{ name: 'email', type: 'string', interface: 'text', filterable: true },
|
|
70
|
+
{ name: 'nickname', type: 'string', interface: 'text' },
|
|
71
|
+
{ name: 'rawUserPayload', type: 'json', filterable: true },
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
ds.addCollection({
|
|
76
|
+
name: 'posts',
|
|
77
|
+
fields: [
|
|
78
|
+
{ name: 'title', type: 'string', interface: 'text', filterable: true },
|
|
79
|
+
{ name: 'internalName', type: 'string', interface: 'text' },
|
|
80
|
+
{ name: 'rawPostPayload', type: 'json', filterable: true },
|
|
81
|
+
{ name: 'author', type: 'belongsTo', target: 'users', interface: 'm2o', filterable: true },
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const posts = ds.getCollection('posts')!;
|
|
86
|
+
const metaFactory = createCollectionContextMeta(posts, 'Posts', true);
|
|
87
|
+
const meta = await metaFactory();
|
|
88
|
+
const props = await (meta?.properties as any)?.();
|
|
89
|
+
const authorFields = await props?.author?.properties?.();
|
|
90
|
+
|
|
91
|
+
expect(props).toHaveProperty('title');
|
|
92
|
+
expect(props).toHaveProperty('internalName');
|
|
93
|
+
expect(props).toHaveProperty('author');
|
|
94
|
+
expect(props).not.toHaveProperty('rawPostPayload');
|
|
95
|
+
expect(authorFields).toHaveProperty('email');
|
|
96
|
+
expect(authorFields).toHaveProperty('nickname');
|
|
97
|
+
expect(authorFields).not.toHaveProperty('rawUserPayload');
|
|
50
98
|
});
|
|
51
99
|
});
|
|
@@ -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;
|
|
@@ -14,6 +14,10 @@ import type { PropertyMetaFactory } from '../flowContext';
|
|
|
14
14
|
const RELATION_FIELD_TYPES = ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany', 'belongsToArray'] as const;
|
|
15
15
|
const NUMERIC_FIELD_TYPES = ['integer', 'float', 'double', 'decimal'] as const;
|
|
16
16
|
|
|
17
|
+
function shouldShowFieldInMeta(field: CollectionField, includeNonFilterable?: boolean) {
|
|
18
|
+
return Boolean(field.interface && (includeNonFilterable || field.filterable));
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
/**
|
|
18
22
|
* 创建字段的完整元数据(统一处理关联和非关联字段)
|
|
19
23
|
*/
|
|
@@ -36,7 +40,7 @@ function createFieldMetadata(field: CollectionField, includeNonFilterable?: bool
|
|
|
36
40
|
properties: async () => {
|
|
37
41
|
const subProperties: Record<string, any> = {};
|
|
38
42
|
targetCollection.fields.forEach((subField) => {
|
|
39
|
-
if (includeNonFilterable
|
|
43
|
+
if (shouldShowFieldInMeta(subField, includeNonFilterable)) {
|
|
40
44
|
subProperties[subField.name] = createFieldMetadata(subField, includeNonFilterable);
|
|
41
45
|
}
|
|
42
46
|
});
|
|
@@ -114,7 +118,7 @@ export function createCollectionContextMeta(
|
|
|
114
118
|
|
|
115
119
|
// 添加所有字段
|
|
116
120
|
collection.fields.forEach((field) => {
|
|
117
|
-
if (includeNonFilterable
|
|
121
|
+
if (shouldShowFieldInMeta(field, includeNonFilterable)) {
|
|
118
122
|
properties[field.name] = createFieldMetadata(field, includeNonFilterable);
|
|
119
123
|
}
|
|
120
124
|
});
|
package/src/utils/index.ts
CHANGED
|
@@ -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 &&
|
|
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}`;
|
package/src/utils/runjsValue.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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 优先)。
|
package/src/views/FlowView.tsx
CHANGED
|
@@ -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
|
+
});
|