@nocobase/flow-engine 2.2.0-beta.2 → 2.2.0-beta.5
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/components/FieldModelRenderer.js +44 -31
- package/lib/flowSettings.d.ts +5 -1
- package/lib/flowSettings.js +70 -0
- package/lib/types.d.ts +12 -0
- package/package.json +4 -4
- package/src/__tests__/flowSettings.test.ts +72 -0
- package/src/components/FieldModelRenderer.tsx +50 -36
- package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
- package/src/flowSettings.ts +85 -1
- package/src/types.ts +14 -0
|
@@ -58,41 +58,54 @@ const flowModelRendererPropKeys = [
|
|
|
58
58
|
"extraToolbarItems"
|
|
59
59
|
];
|
|
60
60
|
function FieldModelRenderer(props) {
|
|
61
|
-
const { model, ...rest } = props;
|
|
61
|
+
const { model, onChange, ...rest } = props;
|
|
62
62
|
const composingRef = (0, import_react.useRef)(false);
|
|
63
|
-
const handleChange =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
63
|
+
const handleChange = (0, import_react.useCallback)(
|
|
64
|
+
(e) => {
|
|
65
|
+
var _a;
|
|
66
|
+
let val;
|
|
67
|
+
if (e && e.target && typeof e.target.value !== "undefined") {
|
|
68
|
+
val = e.target.value;
|
|
69
|
+
} else if (typeof e === "string" || typeof e === "number" || typeof e === "boolean" || typeof e === "object" && !(e instanceof Event)) {
|
|
70
|
+
val = e;
|
|
71
|
+
} else {
|
|
72
|
+
val = null;
|
|
73
|
+
}
|
|
74
|
+
const isComposing = composingRef.current || ((_a = e == null ? void 0 : e.nativeEvent) == null ? void 0 : _a.isComposing);
|
|
75
|
+
if (isComposing) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
model.setProps({ value: val });
|
|
79
|
+
if (!composingRef.current) {
|
|
80
|
+
onChange == null ? void 0 : onChange(val);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
[model, onChange]
|
|
84
|
+
);
|
|
85
|
+
const handleCompositionStart = (0, import_react.useCallback)(() => {
|
|
79
86
|
composingRef.current = true;
|
|
80
|
-
},
|
|
81
|
-
const handleCompositionEnd =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
}, []);
|
|
88
|
+
const handleCompositionEnd = (0, import_react.useCallback)(
|
|
89
|
+
(e, flag = true) => {
|
|
90
|
+
composingRef.current = false;
|
|
91
|
+
if (flag) {
|
|
92
|
+
handleChange(e);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
[handleChange]
|
|
96
|
+
);
|
|
97
|
+
const modelProps = (0, import_react.useMemo)(
|
|
98
|
+
() => ({
|
|
99
|
+
onCompositionStart: handleCompositionStart,
|
|
100
|
+
onCompositionEnd: handleCompositionEnd,
|
|
101
|
+
...import_lodash.default.omit(rest, flowModelRendererPropKeys),
|
|
102
|
+
onChange: handleChange
|
|
103
|
+
}),
|
|
104
|
+
[handleChange, handleCompositionEnd, handleCompositionStart, rest]
|
|
105
|
+
);
|
|
93
106
|
(0, import_react.useEffect)(() => {
|
|
94
107
|
model && model.setProps(modelProps);
|
|
95
|
-
}, [modelProps]);
|
|
108
|
+
}, [model, modelProps]);
|
|
96
109
|
return /* @__PURE__ */ import_react.default.createElement(import_FlowModelRenderer.FlowModelRenderer, { model, ...rest });
|
|
97
110
|
}
|
|
98
111
|
__name(FieldModelRenderer, "FieldModelRenderer");
|
package/lib/flowSettings.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import { FlowEngine } from '.';
|
|
11
11
|
import type { FlowModel } from './models';
|
|
12
|
-
import { StepSettingsDialogProps, ToolbarItemConfig } from './types';
|
|
12
|
+
import { DynamicFlowSource, DynamicFlowSourceProvider, StepSettingsDialogProps, ToolbarItemConfig } from './types';
|
|
13
13
|
/**
|
|
14
14
|
* 打开流程设置的参数接口
|
|
15
15
|
*/
|
|
@@ -93,6 +93,7 @@ export declare class FlowSettings {
|
|
|
93
93
|
enabled: boolean;
|
|
94
94
|
private engine;
|
|
95
95
|
toolbarItems: ToolbarItemConfig[];
|
|
96
|
+
private dynamicFlowSourceProviders;
|
|
96
97
|
constructor(engine: FlowEngine);
|
|
97
98
|
on(event: 'beforeOpen', callback: (...args: any[]) => void): void;
|
|
98
99
|
off(event: 'beforeOpen', callback: (...args: any[]) => void): void;
|
|
@@ -206,6 +207,9 @@ export declare class FlowSettings {
|
|
|
206
207
|
* @returns {ToolbarItemConfig[]} 所有项目配置
|
|
207
208
|
*/
|
|
208
209
|
getToolbarItems(): ToolbarItemConfig[];
|
|
210
|
+
registerDynamicFlowSourceProvider(provider: DynamicFlowSourceProvider): () => void;
|
|
211
|
+
hasDynamicFlowSourceProvider(model: FlowModel): boolean;
|
|
212
|
+
getDynamicFlowSources(model: FlowModel): Promise<DynamicFlowSource[]>;
|
|
209
213
|
/**
|
|
210
214
|
* 清空所有工具栏项目
|
|
211
215
|
* @example
|
package/lib/flowSettings.js
CHANGED
|
@@ -78,6 +78,7 @@ const _FlowSettings = class _FlowSettings {
|
|
|
78
78
|
__privateAdd(this, _forceEnabled, false);
|
|
79
79
|
// 强制启用状态,主要用于设计模式下的强制启用
|
|
80
80
|
__publicField(this, "toolbarItems", []);
|
|
81
|
+
__publicField(this, "dynamicFlowSourceProviders", []);
|
|
81
82
|
__privateAdd(this, _emitter, new import_emitter.Emitter());
|
|
82
83
|
this.engine = engine;
|
|
83
84
|
this.enabled = false;
|
|
@@ -369,6 +370,75 @@ const _FlowSettings = class _FlowSettings {
|
|
|
369
370
|
getToolbarItems() {
|
|
370
371
|
return [...this.toolbarItems];
|
|
371
372
|
}
|
|
373
|
+
registerDynamicFlowSourceProvider(provider) {
|
|
374
|
+
const existingIndex = this.dynamicFlowSourceProviders.findIndex((item) => item.key === provider.key);
|
|
375
|
+
if (existingIndex !== -1) {
|
|
376
|
+
console.warn(
|
|
377
|
+
`FlowSettings: Dynamic flow source provider with key '${provider.key}' already exists and will be replaced.`
|
|
378
|
+
);
|
|
379
|
+
this.dynamicFlowSourceProviders[existingIndex] = provider;
|
|
380
|
+
} else {
|
|
381
|
+
this.dynamicFlowSourceProviders.push(provider);
|
|
382
|
+
}
|
|
383
|
+
this.dynamicFlowSourceProviders.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
|
384
|
+
return () => {
|
|
385
|
+
const index = this.dynamicFlowSourceProviders.indexOf(provider);
|
|
386
|
+
if (index !== -1) {
|
|
387
|
+
this.dynamicFlowSourceProviders.splice(index, 1);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
hasDynamicFlowSourceProvider(model) {
|
|
392
|
+
return this.dynamicFlowSourceProviders.some((provider) => {
|
|
393
|
+
try {
|
|
394
|
+
return provider.visible ? provider.visible(model) : true;
|
|
395
|
+
} catch (error) {
|
|
396
|
+
console.warn(`FlowSettings: Dynamic flow source provider '${provider.key}' visibility check failed.`, error);
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
async getDynamicFlowSources(model) {
|
|
402
|
+
const t = (0, import_utils.getT)(model);
|
|
403
|
+
const selfSource = {
|
|
404
|
+
key: "self",
|
|
405
|
+
label: t("Current block"),
|
|
406
|
+
model,
|
|
407
|
+
sort: -1e3
|
|
408
|
+
};
|
|
409
|
+
const sources = [];
|
|
410
|
+
const seenKeys = /* @__PURE__ */ new Set(["self"]);
|
|
411
|
+
const seenModelUids = /* @__PURE__ */ new Set([model.uid]);
|
|
412
|
+
for (const provider of this.dynamicFlowSourceProviders) {
|
|
413
|
+
try {
|
|
414
|
+
if (provider.visible && !provider.visible(model)) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const providerSources = await provider.getSources(model);
|
|
418
|
+
for (const source of providerSources || []) {
|
|
419
|
+
if (!(source == null ? void 0 : source.key) || !source.model) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const key = String(source.key);
|
|
423
|
+
const uid = source.model.uid;
|
|
424
|
+
if (seenKeys.has(key) || seenModelUids.has(uid)) {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
seenKeys.add(key);
|
|
428
|
+
seenModelUids.add(uid);
|
|
429
|
+
sources.push({
|
|
430
|
+
...source,
|
|
431
|
+
key,
|
|
432
|
+
label: source.label || key,
|
|
433
|
+
sort: source.sort || 0
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
} catch (error) {
|
|
437
|
+
console.warn(`FlowSettings: Dynamic flow source provider '${provider.key}' failed.`, error);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return [selfSource, ...sources.sort((a, b) => (a.sort || 0) - (b.sort || 0))];
|
|
441
|
+
}
|
|
372
442
|
/**
|
|
373
443
|
* 清空所有工具栏项目
|
|
374
444
|
* @example
|
package/lib/types.d.ts
CHANGED
|
@@ -500,6 +500,18 @@ export interface ToolbarItemConfig {
|
|
|
500
500
|
/** 排序权重,数字越小越靠右(先添加的在右边) */
|
|
501
501
|
sort?: number;
|
|
502
502
|
}
|
|
503
|
+
export interface DynamicFlowSource {
|
|
504
|
+
key: string;
|
|
505
|
+
label: React.ReactNode;
|
|
506
|
+
model: FlowModel;
|
|
507
|
+
sort?: number;
|
|
508
|
+
}
|
|
509
|
+
export interface DynamicFlowSourceProvider {
|
|
510
|
+
key: string;
|
|
511
|
+
sort?: number;
|
|
512
|
+
visible?: (model: FlowModel) => boolean;
|
|
513
|
+
getSources: (model: FlowModel) => DynamicFlowSource[] | Promise<DynamicFlowSource[]>;
|
|
514
|
+
}
|
|
503
515
|
export interface ApplyFlowCacheEntry {
|
|
504
516
|
status: 'pending' | 'resolved' | 'rejected';
|
|
505
517
|
promise: Promise<any>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/flow-engine",
|
|
3
|
-
"version": "2.2.0-beta.
|
|
3
|
+
"version": "2.2.0-beta.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@formily/antd-v5": "1.x",
|
|
10
10
|
"@formily/reactive": "2.x",
|
|
11
|
-
"@nocobase/sdk": "2.2.0-beta.
|
|
12
|
-
"@nocobase/shared": "2.2.0-beta.
|
|
11
|
+
"@nocobase/sdk": "2.2.0-beta.5",
|
|
12
|
+
"@nocobase/shared": "2.2.0-beta.5",
|
|
13
13
|
"ahooks": "^3.7.2",
|
|
14
14
|
"axios": "^1.7.0",
|
|
15
15
|
"dayjs": "^1.11.9",
|
|
@@ -37,5 +37,5 @@
|
|
|
37
37
|
],
|
|
38
38
|
"author": "NocoBase Team",
|
|
39
39
|
"license": "Apache-2.0",
|
|
40
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "81eab2cfa9d3d989e99ba0c914807b38db55f023"
|
|
41
41
|
}
|
|
@@ -557,6 +557,78 @@ describe('FlowSettings', () => {
|
|
|
557
557
|
});
|
|
558
558
|
});
|
|
559
559
|
|
|
560
|
+
describe('Dynamic Flow Source Providers', () => {
|
|
561
|
+
test('should return current model as the default dynamic flow source', async () => {
|
|
562
|
+
const model = new FlowModel({ uid: 'source-model', flowEngine: engine });
|
|
563
|
+
|
|
564
|
+
const sources = await flowSettings.getDynamicFlowSources(model);
|
|
565
|
+
|
|
566
|
+
expect(sources).toHaveLength(1);
|
|
567
|
+
expect(sources[0].key).toBe('self');
|
|
568
|
+
expect(sources[0].label).toBe('Current block');
|
|
569
|
+
expect(sources[0].model).toBe(model);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('should register, resolve, and dispose dynamic flow source providers', async () => {
|
|
573
|
+
const model = new FlowModel({ uid: 'source-model', flowEngine: engine });
|
|
574
|
+
const target = new FlowModel({ uid: 'target-model', flowEngine: engine });
|
|
575
|
+
|
|
576
|
+
const dispose = flowSettings.registerDynamicFlowSourceProvider({
|
|
577
|
+
key: 'test-provider',
|
|
578
|
+
visible: (m) => m.uid === model.uid,
|
|
579
|
+
getSources: () => [{ key: 'target', label: 'Target model', model: target, sort: 10 }],
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
expect(flowSettings.hasDynamicFlowSourceProvider(model)).toBe(true);
|
|
583
|
+
expect(flowSettings.hasDynamicFlowSourceProvider(target)).toBe(false);
|
|
584
|
+
|
|
585
|
+
const sources = await flowSettings.getDynamicFlowSources(model);
|
|
586
|
+
expect(sources.map((source) => source.key)).toEqual(['self', 'target']);
|
|
587
|
+
|
|
588
|
+
dispose();
|
|
589
|
+
|
|
590
|
+
expect(flowSettings.hasDynamicFlowSourceProvider(model)).toBe(false);
|
|
591
|
+
await expect(flowSettings.getDynamicFlowSources(model)).resolves.toHaveLength(1);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test('should skip duplicate dynamic flow source keys and model uids', async () => {
|
|
595
|
+
const model = new FlowModel({ uid: 'source-model', flowEngine: engine });
|
|
596
|
+
const target = new FlowModel({ uid: 'target-model', flowEngine: engine });
|
|
597
|
+
|
|
598
|
+
flowSettings.registerDynamicFlowSourceProvider({
|
|
599
|
+
key: 'test-provider',
|
|
600
|
+
getSources: () => [
|
|
601
|
+
{ key: 'target', label: 'Target model', model: target },
|
|
602
|
+
{ key: 'target', label: 'Duplicate key', model: new FlowModel({ uid: 'other-model', flowEngine: engine }) },
|
|
603
|
+
{ key: 'duplicate-uid', label: 'Duplicate uid', model: target },
|
|
604
|
+
],
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
const sources = await flowSettings.getDynamicFlowSources(model);
|
|
608
|
+
|
|
609
|
+
expect(sources.map((source) => source.key)).toEqual(['self', 'target']);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('should ignore failing dynamic flow source providers', async () => {
|
|
613
|
+
const model = new FlowModel({ uid: 'source-model', flowEngine: engine });
|
|
614
|
+
|
|
615
|
+
flowSettings.registerDynamicFlowSourceProvider({
|
|
616
|
+
key: 'failing-provider',
|
|
617
|
+
getSources: () => {
|
|
618
|
+
throw new Error('provider failed');
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const sources = await flowSettings.getDynamicFlowSources(model);
|
|
623
|
+
|
|
624
|
+
expect(sources.map((source) => source.key)).toEqual(['self']);
|
|
625
|
+
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
|
626
|
+
"FlowSettings: Dynamic flow source provider 'failing-provider' failed.",
|
|
627
|
+
expect.any(Error),
|
|
628
|
+
);
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
560
632
|
describe('Step Settings Dialog', () => {
|
|
561
633
|
test('should call openStepSettingsDialog with correct parameters', async () => {
|
|
562
634
|
const { openStepSettingsDialog } = await import('../components/settings/wrappers/contextual/StepSettingsDialog');
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { FlowModelRendererProps } from './FlowModelRenderer';
|
|
11
11
|
import { FlowModelRenderer } from './FlowModelRenderer';
|
|
12
12
|
import _ from 'lodash';
|
|
13
|
-
import React, { useEffect, useMemo, useRef } from 'react';
|
|
13
|
+
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
14
14
|
|
|
15
15
|
const flowModelRendererPropKeys: (keyof FlowModelRendererProps)[] = [
|
|
16
16
|
'model',
|
|
@@ -28,49 +28,63 @@ const flowModelRendererPropKeys: (keyof FlowModelRendererProps)[] = [
|
|
|
28
28
|
];
|
|
29
29
|
|
|
30
30
|
export function FieldModelRenderer(props: any) {
|
|
31
|
-
const { model, ...rest } = props;
|
|
31
|
+
const { model, onChange, ...rest } = props;
|
|
32
32
|
const composingRef = useRef(false);
|
|
33
33
|
|
|
34
|
-
const handleChange = (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
34
|
+
const handleChange = useCallback(
|
|
35
|
+
(e: any) => {
|
|
36
|
+
let val;
|
|
37
|
+
if (e && e.target && typeof e.target.value !== 'undefined') {
|
|
38
|
+
val = e.target.value;
|
|
39
|
+
} else if (
|
|
40
|
+
typeof e === 'string' ||
|
|
41
|
+
typeof e === 'number' ||
|
|
42
|
+
typeof e === 'boolean' ||
|
|
43
|
+
(typeof e === 'object' && !(e instanceof Event))
|
|
44
|
+
) {
|
|
45
|
+
val = e;
|
|
46
|
+
} else {
|
|
47
|
+
val = null;
|
|
48
|
+
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
const isComposing = composingRef.current || e?.nativeEvent?.isComposing;
|
|
51
|
+
if (isComposing) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
model.setProps({ value: val });
|
|
56
|
+
if (!composingRef.current) {
|
|
57
|
+
onChange?.(val);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
[model, onChange],
|
|
61
|
+
);
|
|
62
|
+
const handleCompositionStart = useCallback(() => {
|
|
55
63
|
composingRef.current = true;
|
|
56
|
-
};
|
|
64
|
+
}, []);
|
|
57
65
|
|
|
58
|
-
const handleCompositionEnd = (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
const handleCompositionEnd = useCallback(
|
|
67
|
+
(e: React.CompositionEvent<HTMLInputElement>, flag = true) => {
|
|
68
|
+
composingRef.current = false;
|
|
69
|
+
if (flag) {
|
|
70
|
+
handleChange(e);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
[handleChange],
|
|
74
|
+
);
|
|
64
75
|
|
|
65
|
-
const modelProps =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
const modelProps = useMemo(
|
|
77
|
+
() => ({
|
|
78
|
+
onCompositionStart: handleCompositionStart,
|
|
79
|
+
onCompositionEnd: handleCompositionEnd,
|
|
80
|
+
..._.omit(rest, flowModelRendererPropKeys),
|
|
81
|
+
onChange: handleChange,
|
|
82
|
+
}),
|
|
83
|
+
[handleChange, handleCompositionEnd, handleCompositionStart, rest],
|
|
84
|
+
);
|
|
71
85
|
useEffect(() => {
|
|
72
86
|
model && model.setProps(modelProps);
|
|
73
|
-
}, [modelProps]);
|
|
87
|
+
}, [model, modelProps]);
|
|
74
88
|
|
|
75
89
|
return <FlowModelRenderer model={model} {...rest} />;
|
|
76
90
|
}
|
|
@@ -0,0 +1,165 @@
|
|
|
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 { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { vi } from 'vitest';
|
|
13
|
+
import { FlowEngine } from '../../flowEngine';
|
|
14
|
+
import { FlowModel } from '../../models/flowModel';
|
|
15
|
+
import { FlowEngineProvider } from '../../provider';
|
|
16
|
+
import { FieldModelRenderer } from '../FieldModelRenderer';
|
|
17
|
+
|
|
18
|
+
class InputModel extends FlowModel {
|
|
19
|
+
render(): React.ReactNode {
|
|
20
|
+
return (
|
|
21
|
+
<input
|
|
22
|
+
data-testid="field-input"
|
|
23
|
+
value={this.props.value ?? ''}
|
|
24
|
+
onChange={this.props.onChange}
|
|
25
|
+
onCompositionStart={this.props.onCompositionStart}
|
|
26
|
+
onCompositionEnd={this.props.onCompositionEnd}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('FieldModelRenderer', () => {
|
|
33
|
+
it('updates model and form value for normal input changes', async () => {
|
|
34
|
+
const flowEngine = new FlowEngine();
|
|
35
|
+
const model = new InputModel({
|
|
36
|
+
uid: 'normal-input-field-model',
|
|
37
|
+
flowEngine,
|
|
38
|
+
props: {
|
|
39
|
+
value: '',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
43
|
+
const onChange = vi.fn();
|
|
44
|
+
|
|
45
|
+
render(
|
|
46
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
47
|
+
<FieldModelRenderer model={model} onChange={onChange} />
|
|
48
|
+
</FlowEngineProvider>,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const input = await screen.findByTestId('field-input');
|
|
52
|
+
await waitFor(() => {
|
|
53
|
+
expect(model.props.onChange).toBeTypeOf('function');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
fireEvent.change(input, { target: { value: 'abc' } });
|
|
57
|
+
|
|
58
|
+
expect(model.props.value).toBe('abc');
|
|
59
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(onChange).toHaveBeenCalledWith('abc');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('updates model and form value when field components pass values directly', async () => {
|
|
64
|
+
const flowEngine = new FlowEngine();
|
|
65
|
+
const model = new InputModel({
|
|
66
|
+
uid: 'direct-value-field-model',
|
|
67
|
+
flowEngine,
|
|
68
|
+
props: {
|
|
69
|
+
value: '',
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
73
|
+
const onChange = vi.fn();
|
|
74
|
+
|
|
75
|
+
render(
|
|
76
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
77
|
+
<FieldModelRenderer model={model} onChange={onChange} />
|
|
78
|
+
</FlowEngineProvider>,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await screen.findByTestId('field-input');
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(model.props.onChange).toBeTypeOf('function');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
act(() => {
|
|
87
|
+
model.props.onChange(123);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(model.props.value).toBe(123);
|
|
91
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(onChange).toHaveBeenCalledWith(123);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('defers model and form value updates while IME composition is active', async () => {
|
|
96
|
+
const flowEngine = new FlowEngine();
|
|
97
|
+
const model = new InputModel({
|
|
98
|
+
uid: 'input-field-model',
|
|
99
|
+
flowEngine,
|
|
100
|
+
props: {
|
|
101
|
+
value: '',
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
105
|
+
const onChange = vi.fn();
|
|
106
|
+
|
|
107
|
+
render(
|
|
108
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
109
|
+
<FieldModelRenderer model={model} onChange={onChange} />
|
|
110
|
+
</FlowEngineProvider>,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const input = await screen.findByTestId('field-input');
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(model.props.onChange).toBeTypeOf('function');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
fireEvent.compositionStart(input);
|
|
119
|
+
fireEvent.change(input, { target: { value: 't' }, nativeEvent: { isComposing: true } });
|
|
120
|
+
|
|
121
|
+
expect(model.props.value).toBe('');
|
|
122
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
123
|
+
|
|
124
|
+
fireEvent.compositionEnd(input, { target: { value: 'te' } });
|
|
125
|
+
|
|
126
|
+
expect(model.props.value).toBe('te');
|
|
127
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(onChange).toHaveBeenCalledWith('te');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not commit composition value when composition end opts out', async () => {
|
|
132
|
+
const flowEngine = new FlowEngine();
|
|
133
|
+
const model = new InputModel({
|
|
134
|
+
uid: 'composition-opt-out-field-model',
|
|
135
|
+
flowEngine,
|
|
136
|
+
props: {
|
|
137
|
+
value: '',
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
141
|
+
const onChange = vi.fn();
|
|
142
|
+
|
|
143
|
+
render(
|
|
144
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
145
|
+
<FieldModelRenderer model={model} onChange={onChange} />
|
|
146
|
+
</FlowEngineProvider>,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
await screen.findByTestId('field-input');
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(model.props.onChange).toBeTypeOf('function');
|
|
152
|
+
expect(model.props.onCompositionStart).toBeTypeOf('function');
|
|
153
|
+
expect(model.props.onCompositionEnd).toBeTypeOf('function');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
act(() => {
|
|
157
|
+
model.props.onCompositionStart();
|
|
158
|
+
model.props.onChange({ target: { value: 'search' }, nativeEvent: { isComposing: true } });
|
|
159
|
+
model.props.onCompositionEnd({ target: { value: 'search' } }, false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(model.props.value).toBe('');
|
|
163
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
});
|
package/src/flowSettings.ts
CHANGED
|
@@ -20,7 +20,13 @@ import { FlowRuntimeContext } from './flowContext';
|
|
|
20
20
|
import { FlowEngine, untracked } from '.';
|
|
21
21
|
import { FlowSettingsContextProvider, useFlowSettingsContext } from './hooks/useFlowSettingsContext';
|
|
22
22
|
import type { FlowModel } from './models';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
DynamicFlowSource,
|
|
25
|
+
DynamicFlowSourceProvider,
|
|
26
|
+
ParamObject,
|
|
27
|
+
StepSettingsDialogProps,
|
|
28
|
+
ToolbarItemConfig,
|
|
29
|
+
} from './types';
|
|
24
30
|
import {
|
|
25
31
|
compileUiSchema,
|
|
26
32
|
FlowCancelSaveException,
|
|
@@ -128,6 +134,7 @@ export class FlowSettings {
|
|
|
128
134
|
private engine: FlowEngine;
|
|
129
135
|
#forceEnabled = false; // 强制启用状态,主要用于设计模式下的强制启用
|
|
130
136
|
public toolbarItems: ToolbarItemConfig[] = [];
|
|
137
|
+
private dynamicFlowSourceProviders: DynamicFlowSourceProvider[] = [];
|
|
131
138
|
#emitter: Emitter = new Emitter();
|
|
132
139
|
|
|
133
140
|
constructor(engine: FlowEngine) {
|
|
@@ -463,6 +470,83 @@ export class FlowSettings {
|
|
|
463
470
|
return [...this.toolbarItems];
|
|
464
471
|
}
|
|
465
472
|
|
|
473
|
+
public registerDynamicFlowSourceProvider(provider: DynamicFlowSourceProvider): () => void {
|
|
474
|
+
const existingIndex = this.dynamicFlowSourceProviders.findIndex((item) => item.key === provider.key);
|
|
475
|
+
if (existingIndex !== -1) {
|
|
476
|
+
console.warn(
|
|
477
|
+
`FlowSettings: Dynamic flow source provider with key '${provider.key}' already exists and will be replaced.`,
|
|
478
|
+
);
|
|
479
|
+
this.dynamicFlowSourceProviders[existingIndex] = provider;
|
|
480
|
+
} else {
|
|
481
|
+
this.dynamicFlowSourceProviders.push(provider);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
this.dynamicFlowSourceProviders.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
|
485
|
+
|
|
486
|
+
return () => {
|
|
487
|
+
const index = this.dynamicFlowSourceProviders.indexOf(provider);
|
|
488
|
+
if (index !== -1) {
|
|
489
|
+
this.dynamicFlowSourceProviders.splice(index, 1);
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
public hasDynamicFlowSourceProvider(model: FlowModel): boolean {
|
|
495
|
+
return this.dynamicFlowSourceProviders.some((provider) => {
|
|
496
|
+
try {
|
|
497
|
+
return provider.visible ? provider.visible(model) : true;
|
|
498
|
+
} catch (error) {
|
|
499
|
+
console.warn(`FlowSettings: Dynamic flow source provider '${provider.key}' visibility check failed.`, error);
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
public async getDynamicFlowSources(model: FlowModel): Promise<DynamicFlowSource[]> {
|
|
506
|
+
const t = getT(model);
|
|
507
|
+
const selfSource: DynamicFlowSource = {
|
|
508
|
+
key: 'self',
|
|
509
|
+
label: t('Current block'),
|
|
510
|
+
model,
|
|
511
|
+
sort: -1000,
|
|
512
|
+
};
|
|
513
|
+
const sources: DynamicFlowSource[] = [];
|
|
514
|
+
const seenKeys = new Set<string>(['self']);
|
|
515
|
+
const seenModelUids = new Set<string>([model.uid]);
|
|
516
|
+
|
|
517
|
+
for (const provider of this.dynamicFlowSourceProviders) {
|
|
518
|
+
try {
|
|
519
|
+
if (provider.visible && !provider.visible(model)) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const providerSources = await provider.getSources(model);
|
|
524
|
+
for (const source of providerSources || []) {
|
|
525
|
+
if (!source?.key || !source.model) {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const key = String(source.key);
|
|
529
|
+
const uid = source.model.uid;
|
|
530
|
+
if (seenKeys.has(key) || seenModelUids.has(uid)) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
seenKeys.add(key);
|
|
534
|
+
seenModelUids.add(uid);
|
|
535
|
+
sources.push({
|
|
536
|
+
...source,
|
|
537
|
+
key,
|
|
538
|
+
label: source.label || key,
|
|
539
|
+
sort: source.sort || 0,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
} catch (error) {
|
|
543
|
+
console.warn(`FlowSettings: Dynamic flow source provider '${provider.key}' failed.`, error);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return [selfSource, ...sources.sort((a, b) => (a.sort || 0) - (b.sort || 0))];
|
|
548
|
+
}
|
|
549
|
+
|
|
466
550
|
/**
|
|
467
551
|
* 清空所有工具栏项目
|
|
468
552
|
* @example
|
package/src/types.ts
CHANGED
|
@@ -612,6 +612,20 @@ export interface ToolbarItemConfig {
|
|
|
612
612
|
sort?: number;
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
+
export interface DynamicFlowSource {
|
|
616
|
+
key: string;
|
|
617
|
+
label: React.ReactNode;
|
|
618
|
+
model: FlowModel;
|
|
619
|
+
sort?: number;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export interface DynamicFlowSourceProvider {
|
|
623
|
+
key: string;
|
|
624
|
+
sort?: number;
|
|
625
|
+
visible?: (model: FlowModel) => boolean;
|
|
626
|
+
getSources: (model: FlowModel) => DynamicFlowSource[] | Promise<DynamicFlowSource[]>;
|
|
627
|
+
}
|
|
628
|
+
|
|
615
629
|
export interface ApplyFlowCacheEntry {
|
|
616
630
|
status: 'pending' | 'resolved' | 'rejected';
|
|
617
631
|
promise: Promise<any>;
|