@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.
@@ -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 = /* @__PURE__ */ __name((e) => {
64
- var _a;
65
- let val;
66
- if (e && e.target && typeof e.target.value !== "undefined") {
67
- val = e.target.value;
68
- } else if (typeof e === "string" || typeof e === "number" || typeof e === "boolean" || typeof e === "object" && !(e instanceof Event)) {
69
- val = e;
70
- } else {
71
- val = null;
72
- }
73
- model.setProps({ value: val });
74
- if (!composingRef.current) {
75
- (_a = props.onChange) == null ? void 0 : _a.call(props, val);
76
- }
77
- }, "handleChange");
78
- const handleCompositionStart = /* @__PURE__ */ __name(() => {
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
- }, "handleCompositionStart");
81
- const handleCompositionEnd = /* @__PURE__ */ __name((e, flag = true) => {
82
- composingRef.current = false;
83
- if (flag) {
84
- props.onChange(e);
85
- }
86
- }, "handleCompositionEnd");
87
- const modelProps = {
88
- onCompositionStart: handleCompositionStart,
89
- onCompositionEnd: handleCompositionEnd,
90
- ...import_lodash.default.omit(rest, flowModelRendererPropKeys),
91
- onChange: handleChange
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");
@@ -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
@@ -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.2",
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.2",
12
- "@nocobase/shared": "2.2.0-beta.2",
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": "2fe3e6a86fc1d0cd039349a68b2e4d1d6945ce45"
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 = (e: any) => {
35
- let val;
36
- if (e && e.target && typeof e.target.value !== 'undefined') {
37
- val = e.target.value;
38
- } else if (
39
- typeof e === 'string' ||
40
- typeof e === 'number' ||
41
- typeof e === 'boolean' ||
42
- (typeof e === 'object' && !(e instanceof Event))
43
- ) {
44
- val = e;
45
- } else {
46
- val = null;
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
- model.setProps({ value: val });
50
- if (!composingRef.current) {
51
- props.onChange?.(val);
52
- }
53
- };
54
- const handleCompositionStart = () => {
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 = (e: React.CompositionEvent<HTMLInputElement>, flag = true) => {
59
- composingRef.current = false;
60
- if (flag) {
61
- props.onChange(e);
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
- onCompositionStart: handleCompositionStart,
67
- onCompositionEnd: handleCompositionEnd,
68
- ..._.omit(rest, flowModelRendererPropKeys),
69
- onChange: handleChange,
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
+ });
@@ -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 { ParamObject, StepSettingsDialogProps, ToolbarItemConfig } from './types';
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>;