@nocobase/flow-engine 2.0.0-beta.2 → 2.0.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.
- package/lib/BlockScopedFlowEngine.js +0 -1
- package/lib/JSRunner.d.ts +6 -0
- package/lib/JSRunner.js +2 -1
- package/lib/ViewScopedFlowEngine.js +3 -0
- package/lib/acl/Acl.js +13 -3
- package/lib/components/dnd/gridDragPlanner.d.ts +1 -0
- package/lib/components/dnd/gridDragPlanner.js +53 -1
- package/lib/components/settings/wrappers/component/SwitchWithTitle.js +2 -1
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +11 -3
- package/lib/components/variables/VariableInput.js +8 -2
- package/lib/data-source/index.js +6 -0
- package/lib/executor/FlowExecutor.d.ts +2 -1
- package/lib/executor/FlowExecutor.js +156 -22
- package/lib/flowContext.d.ts +4 -1
- package/lib/flowContext.js +176 -107
- package/lib/flowEngine.d.ts +21 -0
- package/lib/flowEngine.js +38 -0
- package/lib/flowSettings.js +12 -10
- package/lib/index.d.ts +3 -0
- package/lib/index.js +16 -0
- package/lib/models/CollectionFieldModel.d.ts +1 -0
- package/lib/models/CollectionFieldModel.js +3 -2
- package/lib/models/flowModel.d.ts +7 -0
- package/lib/models/flowModel.js +66 -1
- package/lib/provider.js +7 -6
- package/lib/resources/baseRecordResource.d.ts +5 -0
- package/lib/resources/baseRecordResource.js +24 -0
- package/lib/resources/multiRecordResource.d.ts +1 -0
- package/lib/resources/multiRecordResource.js +11 -4
- package/lib/resources/singleRecordResource.js +2 -0
- package/lib/resources/sqlResource.d.ts +1 -0
- package/lib/resources/sqlResource.js +8 -3
- package/lib/runjs-context/contexts/base.js +10 -4
- package/lib/runjsLibs.d.ts +28 -0
- package/lib/runjsLibs.js +532 -0
- package/lib/scheduler/ModelOperationScheduler.d.ts +2 -0
- package/lib/scheduler/ModelOperationScheduler.js +21 -21
- package/lib/types.d.ts +15 -0
- package/lib/utils/createCollectionContextMeta.js +1 -0
- package/lib/utils/index.d.ts +2 -0
- package/lib/utils/index.js +10 -0
- package/lib/utils/params-resolvers.js +16 -9
- package/lib/utils/resolveModuleUrl.d.ts +58 -0
- package/lib/utils/resolveModuleUrl.js +65 -0
- package/lib/utils/runjsModuleLoader.d.ts +58 -0
- package/lib/utils/runjsModuleLoader.js +422 -0
- package/lib/utils/runjsTemplateCompat.d.ts +35 -0
- package/lib/utils/runjsTemplateCompat.js +743 -0
- package/lib/utils/safeGlobals.d.ts +5 -9
- package/lib/utils/safeGlobals.js +129 -17
- package/lib/views/createViewMeta.d.ts +0 -7
- package/lib/views/createViewMeta.js +19 -70
- package/lib/views/index.d.ts +1 -2
- package/lib/views/index.js +4 -3
- package/lib/views/useDialog.js +8 -3
- package/lib/views/useDrawer.js +7 -2
- package/lib/views/usePage.d.ts +4 -0
- package/lib/views/usePage.js +43 -6
- package/lib/views/usePopover.js +4 -1
- package/lib/views/viewEvents.d.ts +17 -0
- package/lib/views/viewEvents.js +90 -0
- package/package.json +4 -4
- package/src/BlockScopedFlowEngine.ts +2 -5
- package/src/JSRunner.ts +8 -1
- package/src/ViewScopedFlowEngine.ts +4 -0
- package/src/__tests__/createViewMeta.popup.test.ts +62 -1
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +63 -0
- package/src/__tests__/flowSettings.open.test.tsx +69 -15
- package/src/__tests__/provider.test.tsx +0 -5
- package/src/__tests__/runjsExternalLibs.test.ts +242 -0
- package/src/__tests__/runjsLibsLazyLoading.test.ts +44 -0
- package/src/__tests__/runjsPreprocessDefault.test.ts +49 -0
- package/src/acl/Acl.tsx +3 -3
- package/src/components/__tests__/gridDragPlanner.test.ts +141 -1
- package/src/components/dnd/gridDragPlanner.ts +60 -0
- package/src/components/settings/wrappers/component/SwitchWithTitle.tsx +2 -1
- package/src/components/settings/wrappers/component/__tests__/InlineControls.test.tsx +74 -0
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +11 -3
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +63 -4
- package/src/components/variables/VariableInput.tsx +8 -2
- package/src/data-source/index.ts +6 -0
- package/src/executor/FlowExecutor.ts +193 -23
- package/src/executor/__tests__/flowExecutor.test.ts +66 -0
- package/src/flowContext.ts +234 -118
- package/src/flowEngine.ts +41 -0
- package/src/flowSettings.ts +12 -11
- package/src/index.ts +10 -0
- package/src/models/CollectionFieldModel.tsx +3 -1
- package/src/models/__tests__/dispatchEvent.when.test.ts +356 -0
- package/src/models/__tests__/flowModel.clone.test.ts +416 -0
- package/src/models/__tests__/flowModel.test.ts +16 -0
- package/src/models/flowModel.tsx +94 -1
- package/src/provider.tsx +9 -7
- package/src/resources/__tests__/multiRecordResource.test.ts +44 -0
- package/src/resources/__tests__/sqlResource.test.ts +60 -0
- package/src/resources/baseRecordResource.ts +31 -0
- package/src/resources/multiRecordResource.ts +11 -4
- package/src/resources/singleRecordResource.ts +3 -0
- package/src/resources/sqlResource.ts +8 -3
- package/src/runjs-context/contexts/base.ts +9 -2
- package/src/runjsLibs.ts +622 -0
- package/src/scheduler/ModelOperationScheduler.ts +23 -21
- package/src/types.ts +26 -1
- package/src/utils/__tests__/params-resolvers.test.ts +40 -0
- package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +38 -0
- package/src/utils/__tests__/runjsTemplateCompat.test.ts +159 -0
- package/src/utils/__tests__/safeGlobals.test.ts +49 -2
- package/src/utils/createCollectionContextMeta.ts +1 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/params-resolvers.ts +23 -9
- package/src/utils/resolveModuleUrl.ts +91 -0
- package/src/utils/runjsModuleLoader.ts +553 -0
- package/src/utils/runjsTemplateCompat.ts +828 -0
- package/src/utils/safeGlobals.ts +133 -16
- package/src/views/__tests__/FlowView.usePage.test.tsx +54 -1
- package/src/views/__tests__/useDialog.closeDestroy.test.tsx +35 -8
- package/src/views/__tests__/viewEvents.resolveOpenerEngine.test.ts +28 -0
- package/src/views/createViewMeta.ts +22 -75
- package/src/views/index.tsx +1 -2
- package/src/views/useDialog.tsx +9 -2
- package/src/views/useDrawer.tsx +8 -1
- package/src/views/usePage.tsx +51 -5
- package/src/views/usePopover.tsx +4 -1
- package/src/views/viewEvents.ts +55 -0
|
@@ -0,0 +1,74 @@
|
|
|
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 React from 'react';
|
|
11
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
12
|
+
import { render, screen, fireEvent } from '@nocobase/test/client';
|
|
13
|
+
import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
|
|
14
|
+
|
|
15
|
+
import { SwitchWithTitle } from '../SwitchWithTitle';
|
|
16
|
+
import { SelectWithTitle } from '../SelectWithTitle';
|
|
17
|
+
|
|
18
|
+
vi.mock('antd', async (importOriginal) => {
|
|
19
|
+
const actual = (await importOriginal()) as any;
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
Select: ({
|
|
23
|
+
popupMatchSelectWidth,
|
|
24
|
+
bordered,
|
|
25
|
+
popupClassName,
|
|
26
|
+
fieldNames,
|
|
27
|
+
labelRender,
|
|
28
|
+
optionRender,
|
|
29
|
+
dropdownRender,
|
|
30
|
+
options,
|
|
31
|
+
...props
|
|
32
|
+
}: any) => React.createElement('select', props),
|
|
33
|
+
Switch: ({ checkedChildren, unCheckedChildren, size, ...props }: any) =>
|
|
34
|
+
React.createElement('input', { ...props, type: 'checkbox', readOnly: true }),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('Inline controls - stopPropagation', () => {
|
|
39
|
+
it('SwitchWithTitle click does not bubble to parent', async () => {
|
|
40
|
+
const engine = new FlowEngine();
|
|
41
|
+
const parentClick = vi.fn();
|
|
42
|
+
const onChange = vi.fn();
|
|
43
|
+
|
|
44
|
+
render(
|
|
45
|
+
<FlowEngineProvider engine={engine}>
|
|
46
|
+
<div onClick={parentClick}>
|
|
47
|
+
<SwitchWithTitle title="Enabled" itemKey="enabled" onChange={onChange} />
|
|
48
|
+
</div>
|
|
49
|
+
</FlowEngineProvider>,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
fireEvent.click(screen.getByText('Enabled'));
|
|
53
|
+
|
|
54
|
+
expect(parentClick).not.toHaveBeenCalled();
|
|
55
|
+
expect(onChange).toHaveBeenCalledWith({ enabled: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('SelectWithTitle click does not bubble to parent', async () => {
|
|
59
|
+
const engine = new FlowEngine();
|
|
60
|
+
const parentClick = vi.fn();
|
|
61
|
+
|
|
62
|
+
render(
|
|
63
|
+
<FlowEngineProvider engine={engine}>
|
|
64
|
+
<div onClick={parentClick}>
|
|
65
|
+
<SelectWithTitle title="Mode" itemKey="mode" options={[{ label: 'A', value: 'a' }]} />
|
|
66
|
+
</div>
|
|
67
|
+
</FlowEngineProvider>,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
fireEvent.click(screen.getByText('Mode'));
|
|
71
|
+
|
|
72
|
+
expect(parentClick).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -184,6 +184,9 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
184
184
|
// 当模型发生子模型替换/增删等变化时,强制刷新菜单数据
|
|
185
185
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
186
186
|
const [extraMenuItems, setExtraMenuItems] = useState<FlowModelExtraMenuItem[]>([]);
|
|
187
|
+
const closeDropdown = useCallback(() => {
|
|
188
|
+
setVisible(false);
|
|
189
|
+
}, []);
|
|
187
190
|
const handleOpenChange: DropdownProps['onOpenChange'] = useCallback((nextOpen: boolean, info) => {
|
|
188
191
|
if (info.source === 'trigger' || nextOpen) {
|
|
189
192
|
// 当鼠标快速滑过时,终止菜单的渲染,防止卡顿
|
|
@@ -292,6 +295,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
292
295
|
);
|
|
293
296
|
|
|
294
297
|
const handleDelete = useCallback(() => {
|
|
298
|
+
closeDropdown();
|
|
295
299
|
Modal.confirm({
|
|
296
300
|
title: t('Confirm delete'),
|
|
297
301
|
icon: <ExclamationCircleOutlined />,
|
|
@@ -312,7 +316,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
312
316
|
}
|
|
313
317
|
},
|
|
314
318
|
});
|
|
315
|
-
}, [model]);
|
|
319
|
+
}, [closeDropdown, model, t]);
|
|
316
320
|
|
|
317
321
|
const handleStepConfiguration = useCallback(
|
|
318
322
|
(key: string) => {
|
|
@@ -345,6 +349,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
345
349
|
}
|
|
346
350
|
|
|
347
351
|
try {
|
|
352
|
+
closeDropdown();
|
|
348
353
|
targetModel.openFlowSettings({
|
|
349
354
|
flowKey,
|
|
350
355
|
stepKey,
|
|
@@ -353,7 +358,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
353
358
|
console.log(t('Configuration popup cancelled or error'), ':', error);
|
|
354
359
|
}
|
|
355
360
|
},
|
|
356
|
-
[model],
|
|
361
|
+
[closeDropdown, model, t],
|
|
357
362
|
);
|
|
358
363
|
|
|
359
364
|
const handleMenuClick = useCallback(
|
|
@@ -363,18 +368,21 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
363
368
|
const cleanKey = key.includes('-') && /^(.+)-\d+$/.test(key) ? key.replace(/-\d+$/, '') : key;
|
|
364
369
|
|
|
365
370
|
if (cleanKey.startsWith('copy-pop-uid:')) {
|
|
371
|
+
closeDropdown();
|
|
366
372
|
handleCopyPopupUid(cleanKey);
|
|
367
373
|
return;
|
|
368
374
|
}
|
|
369
375
|
|
|
370
376
|
const extra = extraMenuItems.find((it) => it?.key === originalKey || it?.key === cleanKey);
|
|
371
377
|
if (extra?.onClick) {
|
|
378
|
+
closeDropdown();
|
|
372
379
|
extra.onClick();
|
|
373
380
|
return;
|
|
374
381
|
}
|
|
375
382
|
|
|
376
383
|
switch (cleanKey) {
|
|
377
384
|
case 'copy-uid':
|
|
385
|
+
closeDropdown();
|
|
378
386
|
handleCopyUid();
|
|
379
387
|
break;
|
|
380
388
|
case 'delete':
|
|
@@ -385,7 +393,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
385
393
|
break;
|
|
386
394
|
}
|
|
387
395
|
},
|
|
388
|
-
[handleCopyUid, handleDelete, handleStepConfiguration, handleCopyPopupUid, extraMenuItems],
|
|
396
|
+
[closeDropdown, handleCopyUid, handleDelete, handleStepConfiguration, handleCopyPopupUid, extraMenuItems],
|
|
389
397
|
);
|
|
390
398
|
|
|
391
399
|
// 获取单个模型的可配置flows和steps
|
|
@@ -35,6 +35,7 @@ vi.mock('antd', async (importOriginal) => {
|
|
|
35
35
|
const Dropdown = (props: any) => {
|
|
36
36
|
(globalThis as any).__lastDropdownMenu = props.menu;
|
|
37
37
|
(globalThis as any).__lastDropdownOnOpenChange = props.onOpenChange;
|
|
38
|
+
(globalThis as any).__lastDropdownOpen = props.open;
|
|
38
39
|
dropdownMenus.push(props.menu);
|
|
39
40
|
return React.createElement('span', { 'data-testid': 'dropdown' }, props.children);
|
|
40
41
|
};
|
|
@@ -98,6 +99,7 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
|
|
|
98
99
|
dropdownMenus.length = 0;
|
|
99
100
|
(globalThis as any).__lastDropdownMenu = undefined;
|
|
100
101
|
(globalThis as any).__lastDropdownOnOpenChange = undefined;
|
|
102
|
+
(globalThis as any).__lastDropdownOpen = undefined;
|
|
101
103
|
});
|
|
102
104
|
|
|
103
105
|
afterEach(() => {
|
|
@@ -265,10 +267,60 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
|
|
|
265
267
|
expect((globalThis as any).__lastDropdownMenu).toBeTruthy();
|
|
266
268
|
});
|
|
267
269
|
const menu = (globalThis as any).__lastDropdownMenu;
|
|
268
|
-
|
|
270
|
+
await act(async () => {
|
|
271
|
+
menu.onClick?.({ key: 'flowC:general' });
|
|
272
|
+
});
|
|
269
273
|
expect(openSpy).toHaveBeenCalledWith({ flowKey: 'flowC', stepKey: 'general' });
|
|
270
274
|
});
|
|
271
275
|
|
|
276
|
+
it('closes dropdown when opening flow settings modal', async () => {
|
|
277
|
+
class TestFlowModel extends FlowModel {}
|
|
278
|
+
const engine = new FlowEngine();
|
|
279
|
+
const model = new TestFlowModel({ uid: 'm-close', flowEngine: engine });
|
|
280
|
+
vi.spyOn(model, 'openFlowSettings').mockResolvedValue(undefined as any);
|
|
281
|
+
|
|
282
|
+
TestFlowModel.registerFlow({
|
|
283
|
+
key: 'flowClose',
|
|
284
|
+
title: 'Flow Close',
|
|
285
|
+
steps: {
|
|
286
|
+
general: { title: 'General', uiSchema: { f: { type: 'string', 'x-component': 'Input' } } },
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
render(
|
|
291
|
+
React.createElement(
|
|
292
|
+
ConfigProvider as any,
|
|
293
|
+
null,
|
|
294
|
+
React.createElement(App as any, null, React.createElement(DefaultSettingsIcon as any, { model })),
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
await waitFor(() => {
|
|
299
|
+
expect((globalThis as any).__lastDropdownMenu).toBeTruthy();
|
|
300
|
+
expect((globalThis as any).__lastDropdownOnOpenChange).toBeTruthy();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// open dropdown
|
|
304
|
+
await act(async () => {
|
|
305
|
+
(globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await waitFor(() => {
|
|
309
|
+
expect((globalThis as any).__lastDropdownOpen).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
313
|
+
|
|
314
|
+
// click config item to open modal
|
|
315
|
+
await act(async () => {
|
|
316
|
+
menu.onClick?.({ key: 'flowClose:general' });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
await waitFor(() => {
|
|
320
|
+
expect((globalThis as any).__lastDropdownOpen).toBe(false);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
272
324
|
it('copy UID action writes model uid to clipboard', async () => {
|
|
273
325
|
class TestFlowModel extends FlowModel {}
|
|
274
326
|
const engine = new FlowEngine();
|
|
@@ -298,7 +350,9 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
|
|
|
298
350
|
expect((globalThis as any).__lastDropdownMenu).toBeTruthy();
|
|
299
351
|
});
|
|
300
352
|
const menu = (globalThis as any).__lastDropdownMenu;
|
|
301
|
-
|
|
353
|
+
await act(async () => {
|
|
354
|
+
menu.onClick?.({ key: 'copy-uid' });
|
|
355
|
+
});
|
|
302
356
|
expect((navigator as any).clipboard.writeText).toHaveBeenCalledWith('m-copy');
|
|
303
357
|
});
|
|
304
358
|
|
|
@@ -326,7 +380,9 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
|
|
|
326
380
|
expect((globalThis as any).__lastDropdownMenu).toBeTruthy();
|
|
327
381
|
});
|
|
328
382
|
const menu = (globalThis as any).__lastDropdownMenu;
|
|
329
|
-
|
|
383
|
+
await act(async () => {
|
|
384
|
+
menu.onClick?.({ key: 'delete' });
|
|
385
|
+
});
|
|
330
386
|
expect(destroySpy).toHaveBeenCalled();
|
|
331
387
|
});
|
|
332
388
|
|
|
@@ -556,8 +612,11 @@ describe('DefaultSettingsIcon - extra menu items', () => {
|
|
|
556
612
|
});
|
|
557
613
|
|
|
558
614
|
const menu = (globalThis as any).__lastDropdownMenu;
|
|
559
|
-
|
|
615
|
+
await act(async () => {
|
|
616
|
+
menu.onClick?.({ key: 'extra-action' });
|
|
617
|
+
});
|
|
560
618
|
expect(onClick).toHaveBeenCalled();
|
|
619
|
+
expect((globalThis as any).__lastDropdownOpen).toBe(false);
|
|
561
620
|
} finally {
|
|
562
621
|
dispose?.();
|
|
563
622
|
}
|
|
@@ -241,12 +241,18 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
241
241
|
|
|
242
242
|
const handleVariableSelect = useCallback(
|
|
243
243
|
(variableValue: string, metaTreeNode?: MetaTreeNode) => {
|
|
244
|
+
if (!metaTreeNode && variableValue === '') {
|
|
245
|
+
const cleared = clearValue !== undefined ? clearValue : null;
|
|
246
|
+
setInnerValue(cleared);
|
|
247
|
+
emitChange(cleared as any);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
244
250
|
setCurrentMetaTreeNode(metaTreeNode);
|
|
245
251
|
const finalValue = resolveValueFromPath?.(metaTreeNode) || variableValue;
|
|
246
252
|
setInnerValue(finalValue);
|
|
247
253
|
emitChange(finalValue, metaTreeNode);
|
|
248
254
|
},
|
|
249
|
-
[emitChange, resolveValueFromPath],
|
|
255
|
+
[emitChange, resolveValueFromPath, clearValue],
|
|
250
256
|
);
|
|
251
257
|
|
|
252
258
|
const { disabled } = restProps;
|
|
@@ -286,7 +292,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
286
292
|
|
|
287
293
|
const inputProps = useMemo(() => {
|
|
288
294
|
const baseProps = {
|
|
289
|
-
value: innerValue ?? '',
|
|
295
|
+
value: ValueComponent === Input ? innerValue ?? '' : innerValue,
|
|
290
296
|
onChange: handleInputChange,
|
|
291
297
|
disabled,
|
|
292
298
|
};
|
package/src/data-source/index.ts
CHANGED
|
@@ -486,6 +486,9 @@ export class Collection {
|
|
|
486
486
|
if (typeof this.filterTargetKey === 'string') {
|
|
487
487
|
return record[this.filterTargetKey];
|
|
488
488
|
}
|
|
489
|
+
if (Array.isArray(this.filterTargetKey) && this.filterTargetKey.length === 1) {
|
|
490
|
+
return record[this.filterTargetKey[0]];
|
|
491
|
+
}
|
|
489
492
|
return _.pick(record, this.filterTargetKey);
|
|
490
493
|
}
|
|
491
494
|
|
|
@@ -815,6 +818,9 @@ export class CollectionField {
|
|
|
815
818
|
if (typeof v !== 'object') {
|
|
816
819
|
return v;
|
|
817
820
|
}
|
|
821
|
+
if (v.value === null || v.value === undefined) {
|
|
822
|
+
return v;
|
|
823
|
+
}
|
|
818
824
|
return {
|
|
819
825
|
...v,
|
|
820
826
|
value: Number(v.value),
|
|
@@ -17,10 +17,20 @@ import { FlowExitException, resolveDefaultParams } from '../utils';
|
|
|
17
17
|
import { FlowExitAllException } from '../utils/exceptions';
|
|
18
18
|
import { setupRuntimeContextSteps } from '../utils/setupRuntimeContextSteps';
|
|
19
19
|
import { createEphemeralContext } from '../utils/createEphemeralContext';
|
|
20
|
+
import type { ScheduledCancel } from '../scheduler/ModelOperationScheduler';
|
|
20
21
|
|
|
21
22
|
export class FlowExecutor {
|
|
22
23
|
constructor(private readonly engine: FlowEngine) {}
|
|
23
24
|
|
|
25
|
+
private async emitModelEventIf(
|
|
26
|
+
eventName: string | undefined,
|
|
27
|
+
topic: string,
|
|
28
|
+
payload: Record<string, any>,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
if (!eventName) return;
|
|
31
|
+
await this.engine.emitter.emitAsync(`model:event:${eventName}:${topic}`, payload);
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
/** Cache wrapper for applyFlow cache lifecycle */
|
|
25
35
|
private async withApplyFlowCache<T>(cacheKey: string | null, executor: () => Promise<T>): Promise<T> {
|
|
26
36
|
if (!cacheKey || !this.engine) return await executor();
|
|
@@ -57,7 +67,13 @@ export class FlowExecutor {
|
|
|
57
67
|
/**
|
|
58
68
|
* Execute a single flow on model.
|
|
59
69
|
*/
|
|
60
|
-
async runFlow(
|
|
70
|
+
async runFlow(
|
|
71
|
+
model: FlowModel,
|
|
72
|
+
flowKey: string,
|
|
73
|
+
inputArgs?: Record<string, any>,
|
|
74
|
+
runId?: string,
|
|
75
|
+
eventName?: string,
|
|
76
|
+
): Promise<any> {
|
|
61
77
|
const flow = model.getFlow(flowKey);
|
|
62
78
|
|
|
63
79
|
if (!flow) {
|
|
@@ -99,6 +115,16 @@ export class FlowExecutor {
|
|
|
99
115
|
setupRuntimeContextSteps(flowContext, stepDefs, model, flowKey);
|
|
100
116
|
const stepsRuntime = flowContext.steps as Record<string, { params: any; uiSchema?: any; result?: any }>;
|
|
101
117
|
|
|
118
|
+
const flowEventBasePayload = {
|
|
119
|
+
uid: model.uid,
|
|
120
|
+
model,
|
|
121
|
+
runId: flowContext.runId,
|
|
122
|
+
inputArgs,
|
|
123
|
+
flowKey,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
await this.emitModelEventIf(eventName, `flow:${flowKey}:start`, flowEventBasePayload);
|
|
127
|
+
|
|
102
128
|
for (const [stepKey, step] of Object.entries(stepDefs) as [string, StepDefinition][]) {
|
|
103
129
|
// Resolve handler and params
|
|
104
130
|
let handler: ActionDefinition<FlowModel, FlowRuntimeContext>['handler'] | undefined;
|
|
@@ -156,6 +182,11 @@ export class FlowExecutor {
|
|
|
156
182
|
);
|
|
157
183
|
continue;
|
|
158
184
|
}
|
|
185
|
+
|
|
186
|
+
await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:start`, {
|
|
187
|
+
...flowEventBasePayload,
|
|
188
|
+
stepKey,
|
|
189
|
+
});
|
|
159
190
|
const currentStepResult = handler(runtimeCtx, combinedParams);
|
|
160
191
|
const isAwait = step.isAwait !== false;
|
|
161
192
|
lastResult = isAwait ? await currentStepResult : currentStepResult;
|
|
@@ -163,15 +194,47 @@ export class FlowExecutor {
|
|
|
163
194
|
// Store step result and update context
|
|
164
195
|
stepResults[stepKey] = lastResult;
|
|
165
196
|
stepsRuntime[stepKey].result = stepResults[stepKey];
|
|
197
|
+
await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:end`, {
|
|
198
|
+
...flowEventBasePayload,
|
|
199
|
+
result: lastResult,
|
|
200
|
+
stepKey,
|
|
201
|
+
});
|
|
166
202
|
} catch (error) {
|
|
203
|
+
if (!(error instanceof FlowExitException) && !(error instanceof FlowExitAllException)) {
|
|
204
|
+
await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:error`, {
|
|
205
|
+
...flowEventBasePayload,
|
|
206
|
+
error,
|
|
207
|
+
stepKey,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
167
210
|
if (error instanceof FlowExitException) {
|
|
168
211
|
flowContext.logger.info(`[FlowEngine] ${error.message}`);
|
|
212
|
+
await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:end`, {
|
|
213
|
+
...flowEventBasePayload,
|
|
214
|
+
stepKey,
|
|
215
|
+
});
|
|
216
|
+
await this.emitModelEventIf(eventName, `flow:${flowKey}:end`, {
|
|
217
|
+
...flowEventBasePayload,
|
|
218
|
+
result: stepResults,
|
|
219
|
+
});
|
|
169
220
|
return Promise.resolve(stepResults);
|
|
170
221
|
}
|
|
171
222
|
if (error instanceof FlowExitAllException) {
|
|
172
223
|
flowContext.logger.info(`[FlowEngine] ${error.message}`);
|
|
224
|
+
await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:end`, {
|
|
225
|
+
...flowEventBasePayload,
|
|
226
|
+
stepKey,
|
|
227
|
+
});
|
|
228
|
+
await this.emitModelEventIf(eventName, `flow:${flowKey}:end`, {
|
|
229
|
+
...flowEventBasePayload,
|
|
230
|
+
result: error,
|
|
231
|
+
});
|
|
173
232
|
return Promise.resolve(error);
|
|
174
233
|
}
|
|
234
|
+
await this.emitModelEventIf(eventName, `flow:${flowKey}:error`, {
|
|
235
|
+
...flowEventBasePayload,
|
|
236
|
+
error,
|
|
237
|
+
});
|
|
175
238
|
flowContext.logger.error(
|
|
176
239
|
{ err: error },
|
|
177
240
|
`BaseModel.applyFlow: Error executing step '${stepKey}' in flow '${flowKey}':`,
|
|
@@ -179,11 +242,13 @@ export class FlowExecutor {
|
|
|
179
242
|
return Promise.reject(error);
|
|
180
243
|
}
|
|
181
244
|
}
|
|
245
|
+
await this.emitModelEventIf(eventName, `flow:${flowKey}:end`, {
|
|
246
|
+
...flowEventBasePayload,
|
|
247
|
+
result: stepResults,
|
|
248
|
+
});
|
|
182
249
|
return Promise.resolve(stepResults);
|
|
183
250
|
}
|
|
184
251
|
|
|
185
|
-
// runAutoFlows 已移除:统一通过 dispatchEvent('beforeRender') + useCache 控制
|
|
186
|
-
|
|
187
252
|
/**
|
|
188
253
|
* Dispatch an event to flows bound via flow.on and execute them.
|
|
189
254
|
*/
|
|
@@ -202,14 +267,15 @@ export class FlowExecutor {
|
|
|
202
267
|
|
|
203
268
|
const runId = `${model.uid}-${eventName}-${Date.now()}`;
|
|
204
269
|
const logger = model.context.logger;
|
|
270
|
+
const eventBasePayload = {
|
|
271
|
+
uid: model.uid,
|
|
272
|
+
model,
|
|
273
|
+
runId,
|
|
274
|
+
inputArgs,
|
|
275
|
+
};
|
|
205
276
|
|
|
206
277
|
try {
|
|
207
|
-
await this.
|
|
208
|
-
uid: model.uid,
|
|
209
|
-
model,
|
|
210
|
-
runId,
|
|
211
|
-
inputArgs,
|
|
212
|
-
});
|
|
278
|
+
await this.emitModelEventIf(eventName, 'start', eventBasePayload);
|
|
213
279
|
await model.onDispatchEventStart?.(eventName, options, inputArgs);
|
|
214
280
|
} catch (err) {
|
|
215
281
|
if (isBeforeRender && err instanceof FlowExitException) {
|
|
@@ -237,11 +303,25 @@ export class FlowExecutor {
|
|
|
237
303
|
return false;
|
|
238
304
|
});
|
|
239
305
|
|
|
306
|
+
// 路由系统的“重放打开视图”会再次 dispatchEvent('click'),但这不应重复触发用户配置的动态事件流。
|
|
307
|
+
// 约定:由路由重放触发时,会在 inputArgs 中携带 triggerByRouter: true
|
|
308
|
+
const isRouterReplayClick = eventName === 'click' && inputArgs?.triggerByRouter === true;
|
|
309
|
+
const flowsToRun = isRouterReplayClick
|
|
310
|
+
? flows.filter((flow) => {
|
|
311
|
+
const reg = flow['flowRegistry'] as any;
|
|
312
|
+
const type = reg?.constructor?._type as 'instance' | 'global' | undefined;
|
|
313
|
+
return type !== 'instance';
|
|
314
|
+
})
|
|
315
|
+
: flows;
|
|
316
|
+
|
|
317
|
+
// 记录本次 dispatchEvent 内注册的调度任务,用于在结束/错误后兜底清理未触发的任务
|
|
318
|
+
const scheduledCancels: ScheduledCancel[] = [];
|
|
319
|
+
|
|
240
320
|
// 组装执行函数(返回值用于缓存;beforeRender 返回 results:any[],其它返回 true)
|
|
241
321
|
const execute = async () => {
|
|
242
322
|
if (sequential) {
|
|
243
323
|
// 顺序执行:动态流(实例级)优先,其次静态流;各自组内再按 sort 升序,最后保持原始顺序稳定
|
|
244
|
-
const flowsWithIndex =
|
|
324
|
+
const flowsWithIndex = flowsToRun.map((f, i) => ({ f, i }));
|
|
245
325
|
const ordered = flowsWithIndex
|
|
246
326
|
.slice()
|
|
247
327
|
.sort((a, b) => {
|
|
@@ -259,12 +339,103 @@ export class FlowExecutor {
|
|
|
259
339
|
})
|
|
260
340
|
.map((x) => x.f);
|
|
261
341
|
const results: any[] = [];
|
|
342
|
+
|
|
343
|
+
// 预处理:当事件流配置了 on.phase 时,将其执行移动到指定节点,并从“立即执行列表”中移除
|
|
344
|
+
const staticFlowsByKey = new Map(
|
|
345
|
+
ordered
|
|
346
|
+
.filter((f) => {
|
|
347
|
+
const reg = f['flowRegistry'] as any;
|
|
348
|
+
const type = reg?.constructor?._type as 'instance' | 'global' | undefined;
|
|
349
|
+
return type !== 'instance';
|
|
350
|
+
})
|
|
351
|
+
.map((f) => [f.key, f] as const),
|
|
352
|
+
);
|
|
353
|
+
const scheduled = new Set<string>();
|
|
354
|
+
const scheduleGroups = new Map<string, Array<{ flow: any; order: number }>>();
|
|
355
|
+
ordered.forEach((flow, indexInOrdered) => {
|
|
356
|
+
const on = flow.on;
|
|
357
|
+
const onObj = typeof on === 'object' ? (on as any) : undefined;
|
|
358
|
+
if (!onObj) return;
|
|
359
|
+
|
|
360
|
+
const phase: any = onObj.phase;
|
|
361
|
+
const flowKey: any = onObj.flowKey;
|
|
362
|
+
const stepKey: any = onObj.stepKey;
|
|
363
|
+
|
|
364
|
+
// 默认:beforeAllFlows(保持现有行为)
|
|
365
|
+
if (!phase || phase === 'beforeAllFlows') return;
|
|
366
|
+
|
|
367
|
+
let whenKey: string | null = null;
|
|
368
|
+
if (phase === 'afterAllFlows') {
|
|
369
|
+
whenKey = `event:${eventName}:end`;
|
|
370
|
+
} else if (phase === 'beforeFlow' || phase === 'afterFlow') {
|
|
371
|
+
if (!flowKey) {
|
|
372
|
+
// 配置不完整:降级到“全部静态流之后”
|
|
373
|
+
whenKey = `event:${eventName}:end`;
|
|
374
|
+
} else {
|
|
375
|
+
const anchorFlow = staticFlowsByKey.get(String(flowKey));
|
|
376
|
+
if (anchorFlow) {
|
|
377
|
+
const anchorPhase = phase === 'beforeFlow' ? 'start' : 'end';
|
|
378
|
+
whenKey = `event:${eventName}:flow:${String(flowKey)}:${anchorPhase}`;
|
|
379
|
+
} else {
|
|
380
|
+
// 锚点不存在(flow 被删除或覆盖等):降级到“全部静态流之后”
|
|
381
|
+
whenKey = `event:${eventName}:end`;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} else if (phase === 'beforeStep' || phase === 'afterStep') {
|
|
385
|
+
if (!flowKey || !stepKey) {
|
|
386
|
+
// 配置不完整:降级到“全部静态流之后”
|
|
387
|
+
whenKey = `event:${eventName}:end`;
|
|
388
|
+
} else {
|
|
389
|
+
const anchorFlow = staticFlowsByKey.get(String(flowKey));
|
|
390
|
+
const anchorStepExists = !!anchorFlow?.hasStep?.(String(stepKey));
|
|
391
|
+
if (anchorFlow && anchorStepExists) {
|
|
392
|
+
const anchorPhase = phase === 'beforeStep' ? 'start' : 'end';
|
|
393
|
+
whenKey = `event:${eventName}:flow:${String(flowKey)}:step:${String(stepKey)}:${anchorPhase}`;
|
|
394
|
+
} else {
|
|
395
|
+
// 锚点不存在(flow/step 被删除或覆盖等):降级到“全部静态流之后”
|
|
396
|
+
whenKey = `event:${eventName}:end`;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
// 未知 phase:忽略
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!whenKey) return;
|
|
405
|
+
scheduled.add(flow.key);
|
|
406
|
+
const list = scheduleGroups.get(whenKey) || [];
|
|
407
|
+
list.push({ flow, order: indexInOrdered });
|
|
408
|
+
scheduleGroups.set(whenKey, list);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// 注册调度(同锚点按 flow.sort 升序;sort 相同保持稳定顺序)
|
|
412
|
+
for (const [whenKey, list] of scheduleGroups.entries()) {
|
|
413
|
+
const sorted = list.slice().sort((a, b) => {
|
|
414
|
+
const sa = a.flow.sort ?? 0;
|
|
415
|
+
const sb = b.flow.sort ?? 0;
|
|
416
|
+
if (sa !== sb) return sa - sb;
|
|
417
|
+
return a.order - b.order;
|
|
418
|
+
});
|
|
419
|
+
for (const it of sorted) {
|
|
420
|
+
const cancel = model.scheduleModelOperation(
|
|
421
|
+
model.uid,
|
|
422
|
+
async (m) => {
|
|
423
|
+
const res = await this.runFlow(m, it.flow.key, inputArgs, runId, eventName);
|
|
424
|
+
results.push(res);
|
|
425
|
+
},
|
|
426
|
+
{ when: whenKey as any },
|
|
427
|
+
);
|
|
428
|
+
scheduledCancels.push(cancel);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
262
432
|
for (const flow of ordered) {
|
|
433
|
+
if (scheduled.has(flow.key)) continue;
|
|
263
434
|
try {
|
|
264
435
|
logger.debug(
|
|
265
436
|
`BaseModel '${model.uid}' dispatching event '${eventName}' to flow '${flow.key}' (sequential).`,
|
|
266
437
|
);
|
|
267
|
-
const result = await this.runFlow(model, flow.key, inputArgs, runId);
|
|
438
|
+
const result = await this.runFlow(model, flow.key, inputArgs, runId, eventName);
|
|
268
439
|
if (result instanceof FlowExitAllException) {
|
|
269
440
|
logger.debug(`[FlowEngine.dispatchEvent] ${result.message}`);
|
|
270
441
|
break; // 终止后续
|
|
@@ -283,10 +454,10 @@ export class FlowExecutor {
|
|
|
283
454
|
|
|
284
455
|
// 并行
|
|
285
456
|
const results = await Promise.all(
|
|
286
|
-
|
|
457
|
+
flowsToRun.map(async (flow) => {
|
|
287
458
|
logger.debug(`BaseModel '${model.uid}' dispatching event '${eventName}' to flow '${flow.key}'.`);
|
|
288
459
|
try {
|
|
289
|
-
return await this.runFlow(model, flow.key, inputArgs, runId);
|
|
460
|
+
return await this.runFlow(model, flow.key, inputArgs, runId, eventName);
|
|
290
461
|
} catch (error) {
|
|
291
462
|
logger.error(
|
|
292
463
|
{ err: error },
|
|
@@ -318,11 +489,8 @@ export class FlowExecutor {
|
|
|
318
489
|
} catch (hookErr) {
|
|
319
490
|
logger.error({ err: hookErr }, `BaseModel.dispatchEvent: End hook error for event '${eventName}'`);
|
|
320
491
|
}
|
|
321
|
-
await this.
|
|
322
|
-
|
|
323
|
-
model,
|
|
324
|
-
runId,
|
|
325
|
-
inputArgs,
|
|
492
|
+
await this.emitModelEventIf(eventName, 'end', {
|
|
493
|
+
...eventBasePayload,
|
|
326
494
|
result,
|
|
327
495
|
});
|
|
328
496
|
return result;
|
|
@@ -337,14 +505,16 @@ export class FlowExecutor {
|
|
|
337
505
|
{ err: error },
|
|
338
506
|
`BaseModel.dispatchEvent: Error executing event '${eventName}' for model '${model.uid}':`,
|
|
339
507
|
);
|
|
340
|
-
await this.
|
|
341
|
-
|
|
342
|
-
model,
|
|
343
|
-
runId,
|
|
344
|
-
inputArgs,
|
|
508
|
+
await this.emitModelEventIf(eventName, 'error', {
|
|
509
|
+
...eventBasePayload,
|
|
345
510
|
error,
|
|
346
511
|
});
|
|
347
512
|
if (throwOnError) throw error;
|
|
513
|
+
} finally {
|
|
514
|
+
// 清理未触发的调度任务,避免跨事件/跨 runId 残留导致意外执行
|
|
515
|
+
for (const cancel of scheduledCancels) {
|
|
516
|
+
cancel();
|
|
517
|
+
}
|
|
348
518
|
}
|
|
349
519
|
}
|
|
350
520
|
}
|