@nocobase/flow-engine 2.0.0-beta.21 → 2.0.0-beta.23
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/FlowDefinition.d.ts +2 -0
- package/lib/JSRunner.js +23 -1
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +66 -13
- package/lib/components/settings/wrappers/contextual/FlowsContextMenu.js +24 -4
- package/lib/components/variables/VariableInput.js +1 -2
- package/lib/components/variables/VariableTag.js +46 -39
- package/lib/data-source/index.js +11 -5
- package/lib/flowContext.js +5 -1
- package/lib/flowI18n.js +6 -4
- package/lib/locale/en-US.json +2 -1
- package/lib/locale/index.d.ts +2 -0
- package/lib/locale/zh-CN.json +1 -0
- package/lib/resources/sqlResource.d.ts +3 -3
- package/lib/types.d.ts +12 -0
- package/lib/utils/associationObjectVariable.d.ts +2 -2
- package/lib/utils/index.d.ts +4 -2
- package/lib/utils/index.js +16 -0
- package/lib/utils/resolveRunJSObjectValues.d.ts +16 -0
- package/lib/utils/resolveRunJSObjectValues.js +61 -0
- package/lib/utils/runjsValue.d.ts +29 -0
- package/lib/utils/runjsValue.js +275 -0
- package/lib/utils/safeGlobals.d.ts +14 -0
- package/lib/utils/safeGlobals.js +37 -2
- package/lib/utils/schema-utils.d.ts +10 -0
- package/lib/utils/schema-utils.js +61 -0
- package/package.json +4 -4
- package/src/JSRunner.ts +29 -1
- package/src/__tests__/JSRunner.test.ts +64 -0
- package/src/__tests__/flowContext.test.ts +78 -0
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +99 -14
- package/src/components/settings/wrappers/contextual/FlowsContextMenu.tsx +41 -7
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +94 -1
- package/src/components/variables/VariableInput.tsx +4 -2
- package/src/components/variables/VariableTag.tsx +54 -45
- package/src/components/variables/__tests__/VariableTag.test.tsx +50 -0
- package/src/data-source/index.ts +11 -5
- package/src/flowContext.ts +6 -2
- package/src/flowI18n.ts +7 -5
- package/src/locale/en-US.json +2 -1
- package/src/locale/zh-CN.json +1 -0
- package/src/resources/sqlResource.ts +3 -3
- package/src/types.ts +12 -0
- package/src/utils/__tests__/runjsValue.test.ts +44 -0
- package/src/utils/__tests__/safeGlobals.test.ts +8 -0
- package/src/utils/__tests__/utils.test.ts +95 -0
- package/src/utils/associationObjectVariable.ts +2 -2
- package/src/utils/index.ts +20 -2
- package/src/utils/resolveRunJSObjectValues.ts +46 -0
- package/src/utils/runjsValue.ts +287 -0
- package/src/utils/safeGlobals.ts +55 -1
- package/src/utils/schema-utils.ts +79 -0
package/src/JSRunner.ts
CHANGED
|
@@ -34,13 +34,41 @@ export class JSRunner {
|
|
|
34
34
|
return typeof fn === 'function' ? fn.bind(globalThis) : fn;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
const providedGlobals = options.globals || {};
|
|
38
|
+
const liftedGlobals: Record<string, any> = {};
|
|
39
|
+
|
|
40
|
+
// Auto-lift selected globals from safe window into top-level sandbox globals
|
|
41
|
+
// so user code can access them directly (e.g. `new Blob(...)`).
|
|
42
|
+
if (!Object.prototype.hasOwnProperty.call(providedGlobals, 'Blob')) {
|
|
43
|
+
try {
|
|
44
|
+
const blobCtor = (providedGlobals as any).window?.Blob;
|
|
45
|
+
if (typeof blobCtor !== 'undefined') {
|
|
46
|
+
liftedGlobals.Blob = blobCtor;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// ignore when window proxy blocks property access
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!Object.prototype.hasOwnProperty.call(providedGlobals, 'URL')) {
|
|
54
|
+
try {
|
|
55
|
+
const urlCtor = (providedGlobals as any).window?.URL;
|
|
56
|
+
if (typeof urlCtor !== 'undefined') {
|
|
57
|
+
liftedGlobals.URL = urlCtor;
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// ignore when window proxy blocks property access
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
37
64
|
this.globals = {
|
|
38
65
|
console,
|
|
39
66
|
setTimeout: bindWindowFn('setTimeout'),
|
|
40
67
|
clearTimeout: bindWindowFn('clearTimeout'),
|
|
41
68
|
setInterval: bindWindowFn('setInterval'),
|
|
42
69
|
clearInterval: bindWindowFn('clearInterval'),
|
|
43
|
-
...
|
|
70
|
+
...liftedGlobals,
|
|
71
|
+
...providedGlobals,
|
|
44
72
|
};
|
|
45
73
|
this.timeoutMs = options.timeoutMs ?? 5000; // 默认 5 秒超时
|
|
46
74
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
11
11
|
import { JSRunner } from '../JSRunner';
|
|
12
|
+
import { createSafeWindow } from '../utils';
|
|
12
13
|
|
|
13
14
|
describe('JSRunner', () => {
|
|
14
15
|
let originalSearch: string;
|
|
@@ -48,6 +49,69 @@ describe('JSRunner', () => {
|
|
|
48
49
|
expect(res2.value).toBe('baz');
|
|
49
50
|
});
|
|
50
51
|
|
|
52
|
+
it('auto-lifts Blob from injected window to top-level globals', async () => {
|
|
53
|
+
if (typeof Blob === 'undefined') {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const runner = new JSRunner({
|
|
58
|
+
globals: {
|
|
59
|
+
window: createSafeWindow(),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = await runner.run('return new Blob(["x"]).size');
|
|
64
|
+
expect(result.success).toBe(true);
|
|
65
|
+
expect(result.value).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps explicit globals.Blob higher priority than auto-lifted Blob', async () => {
|
|
69
|
+
const explicitBlob = function ExplicitBlob(this: any, chunks: any[]) {
|
|
70
|
+
this.size = Array.isArray(chunks) ? chunks.length : 0;
|
|
71
|
+
} as any;
|
|
72
|
+
|
|
73
|
+
const runner = new JSRunner({
|
|
74
|
+
globals: {
|
|
75
|
+
window: createSafeWindow(),
|
|
76
|
+
Blob: explicitBlob,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const result = await runner.run('const b = new Blob([1,2,3]); return b.size;');
|
|
81
|
+
expect(result.success).toBe(true);
|
|
82
|
+
expect(result.value).toBe(3);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('auto-lifts URL from injected window to top-level globals', async () => {
|
|
86
|
+
const runner = new JSRunner({
|
|
87
|
+
globals: {
|
|
88
|
+
window: createSafeWindow(),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const result = await runner.run('return typeof URL.createObjectURL === "function"');
|
|
93
|
+
expect(result.success).toBe(true);
|
|
94
|
+
expect(result.value).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('keeps explicit globals.URL higher priority than auto-lifted URL', async () => {
|
|
98
|
+
const explicitURL = {
|
|
99
|
+
createObjectURL: () => 'explicit://url',
|
|
100
|
+
revokeObjectURL: (_url: string) => undefined,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const runner = new JSRunner({
|
|
104
|
+
globals: {
|
|
105
|
+
window: createSafeWindow(),
|
|
106
|
+
URL: explicitURL,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = await runner.run('return URL.createObjectURL(new Blob(["x"]))');
|
|
111
|
+
expect(result.success).toBe(true);
|
|
112
|
+
expect(result.value).toBe('explicit://url');
|
|
113
|
+
});
|
|
114
|
+
|
|
51
115
|
it('exposes console in sandbox by default', async () => {
|
|
52
116
|
const runner = new JSRunner();
|
|
53
117
|
const result = await runner.run('return typeof console !== "undefined"');
|
|
@@ -759,6 +759,84 @@ describe('FlowEngine context', () => {
|
|
|
759
759
|
expect(engine.context.appName).toBe('NocoBase');
|
|
760
760
|
});
|
|
761
761
|
|
|
762
|
+
it('ctx.sql should resolve template variables from caller context in delegate chain', async () => {
|
|
763
|
+
const engine = new FlowEngine();
|
|
764
|
+
const request = vi.fn(async () => ({ data: { data: [] } }));
|
|
765
|
+
engine.context.defineProperty('api', {
|
|
766
|
+
value: { request },
|
|
767
|
+
});
|
|
768
|
+
engine.context.defineProperty('minId', {
|
|
769
|
+
get: () => 999,
|
|
770
|
+
cache: false,
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
const callerCtx = new FlowContext();
|
|
774
|
+
callerCtx.addDelegate(engine.context);
|
|
775
|
+
callerCtx.defineProperty('minId', {
|
|
776
|
+
get: () => 1,
|
|
777
|
+
cache: false,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
await callerCtx.sql.run('SELECT * FROM users WHERE id > {{ctx.minId}}', { type: 'selectRows' });
|
|
781
|
+
|
|
782
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
783
|
+
const config = request.mock.calls[0]?.[0];
|
|
784
|
+
expect(config?.url).toBe('flowSql:run');
|
|
785
|
+
expect(config?.data.bind.__var1).toBe(1);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('ctx.sql should not share repository instance between different caller contexts', async () => {
|
|
789
|
+
const engine = new FlowEngine();
|
|
790
|
+
const request = vi.fn(async () => ({ data: { data: [] } }));
|
|
791
|
+
engine.context.defineProperty('api', {
|
|
792
|
+
value: { request },
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const caller1 = new FlowContext();
|
|
796
|
+
caller1.addDelegate(engine.context);
|
|
797
|
+
caller1.defineProperty('minId', {
|
|
798
|
+
get: () => 1,
|
|
799
|
+
cache: false,
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const caller2 = new FlowContext();
|
|
803
|
+
caller2.addDelegate(engine.context);
|
|
804
|
+
caller2.defineProperty('minId', {
|
|
805
|
+
get: () => 2,
|
|
806
|
+
cache: false,
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
expect(caller1.sql).not.toBe(caller2.sql);
|
|
810
|
+
|
|
811
|
+
await caller1.sql.run('SELECT * FROM users WHERE id > {{ctx.minId}}', { type: 'selectRows' });
|
|
812
|
+
await caller2.sql.run('SELECT * FROM users WHERE id > {{ctx.minId}}', { type: 'selectRows' });
|
|
813
|
+
|
|
814
|
+
expect(request).toHaveBeenCalledTimes(2);
|
|
815
|
+
const config1 = request.mock.calls[0]?.[0];
|
|
816
|
+
const config2 = request.mock.calls[1]?.[0];
|
|
817
|
+
expect(config1?.data.bind.__var1).toBe(1);
|
|
818
|
+
expect(config2?.data.bind.__var1).toBe(2);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it('engine.context.sql should keep working when accessed directly', async () => {
|
|
822
|
+
const engine = new FlowEngine();
|
|
823
|
+
const request = vi.fn(async () => ({ data: { data: [] } }));
|
|
824
|
+
engine.context.defineProperty('api', {
|
|
825
|
+
value: { request },
|
|
826
|
+
});
|
|
827
|
+
engine.context.defineProperty('minId', {
|
|
828
|
+
get: () => 3,
|
|
829
|
+
cache: false,
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
await engine.context.sql.run('SELECT * FROM users WHERE id > {{ctx.minId}}', { type: 'selectRows' });
|
|
833
|
+
|
|
834
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
835
|
+
const config = request.mock.calls[0]?.[0];
|
|
836
|
+
expect(config?.url).toBe('flowSql:run');
|
|
837
|
+
expect(config?.data.bind.__var1).toBe(3);
|
|
838
|
+
});
|
|
839
|
+
|
|
762
840
|
it('engine.context.getVar should resolve variable by path', async () => {
|
|
763
841
|
const engine = new FlowEngine();
|
|
764
842
|
engine.context.defineProperty('foo', { value: { bar: 1 } });
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { ExclamationCircleOutlined, MenuOutlined } from '@ant-design/icons';
|
|
10
|
+
import { ExclamationCircleOutlined, MenuOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
|
11
11
|
import type { DropdownProps, MenuProps } from 'antd';
|
|
12
|
-
import { App, Dropdown, Modal } from 'antd';
|
|
12
|
+
import { App, Dropdown, Modal, Tooltip, theme } from 'antd';
|
|
13
13
|
import React, { startTransition, useCallback, useEffect, useMemo, useState, FC } from 'react';
|
|
14
14
|
import { FlowModel } from '../../../../models';
|
|
15
15
|
import type { FlowModelExtraMenuItem } from '../../../../models';
|
|
@@ -17,6 +17,7 @@ import type { StepDefinition, StepUIMode } from '../../../../types';
|
|
|
17
17
|
import {
|
|
18
18
|
getT,
|
|
19
19
|
resolveStepUiSchema,
|
|
20
|
+
resolveStepDisabledInSettings,
|
|
20
21
|
shouldHideStepInSettings,
|
|
21
22
|
resolveDefaultParams,
|
|
22
23
|
resolveUiMode,
|
|
@@ -33,6 +34,8 @@ interface StepInfo {
|
|
|
33
34
|
title: string;
|
|
34
35
|
modelKey?: string;
|
|
35
36
|
uiMode?: StepUIMode;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
disabledReason?: string;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
interface FlowInfo {
|
|
@@ -150,12 +153,29 @@ const componentMap = {
|
|
|
150
153
|
const MenuLabelItem = ({ title, uiMode, itemProps }) => {
|
|
151
154
|
const type = uiMode?.type || uiMode;
|
|
152
155
|
const Component = type ? componentMap[type] : null;
|
|
156
|
+
const disabled = !!itemProps?.disabled;
|
|
157
|
+
const disabledReason = itemProps?.disabledReason;
|
|
158
|
+
const disabledIconColor = itemProps?.disabledIconColor;
|
|
153
159
|
|
|
154
|
-
|
|
155
|
-
|
|
160
|
+
const content = (() => {
|
|
161
|
+
if (!Component) {
|
|
162
|
+
return <>{title}</>;
|
|
163
|
+
}
|
|
164
|
+
return <Component title={title} {...itemProps} />;
|
|
165
|
+
})();
|
|
166
|
+
|
|
167
|
+
if (!disabled) {
|
|
168
|
+
return content;
|
|
156
169
|
}
|
|
157
170
|
|
|
158
|
-
return
|
|
171
|
+
return (
|
|
172
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
173
|
+
{content}
|
|
174
|
+
<Tooltip title={disabledReason} placement="right" destroyTooltipOnHide>
|
|
175
|
+
<QuestionCircleOutlined style={{ color: disabledIconColor }} />
|
|
176
|
+
</Tooltip>
|
|
177
|
+
</span>
|
|
178
|
+
);
|
|
159
179
|
};
|
|
160
180
|
|
|
161
181
|
/**
|
|
@@ -180,10 +200,14 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
180
200
|
}) => {
|
|
181
201
|
const { message } = App.useApp();
|
|
182
202
|
const t = useMemo(() => getT(model), [model]);
|
|
203
|
+
const { token } = theme.useToken();
|
|
204
|
+
const disabledIconColor = token?.colorTextTertiary || token?.colorTextDescription || token?.colorTextSecondary;
|
|
183
205
|
const [visible, setVisible] = useState(false);
|
|
184
206
|
// 当模型发生子模型替换/增删等变化时,强制刷新菜单数据
|
|
185
207
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
186
208
|
const [extraMenuItems, setExtraMenuItems] = useState<FlowModelExtraMenuItem[]>([]);
|
|
209
|
+
const [configurableFlowsAndSteps, setConfigurableFlowsAndSteps] = useState<FlowInfo[]>([]);
|
|
210
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
187
211
|
const closeDropdown = useCallback(() => {
|
|
188
212
|
setVisible(false);
|
|
189
213
|
}, []);
|
|
@@ -237,7 +261,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
237
261
|
return () => {
|
|
238
262
|
mounted = false;
|
|
239
263
|
};
|
|
240
|
-
}, [model, menuLevels, t, refreshTick, visible
|
|
264
|
+
}, [model, menuLevels, t, refreshTick, visible]);
|
|
241
265
|
|
|
242
266
|
// 统一的复制 UID 方法
|
|
243
267
|
const copyUidToClipboard = useCallback(
|
|
@@ -361,6 +385,31 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
361
385
|
[closeDropdown, model, t],
|
|
362
386
|
);
|
|
363
387
|
|
|
388
|
+
const isStepMenuItemDisabled = useCallback(
|
|
389
|
+
(key: string) => {
|
|
390
|
+
const cleanKey = key.includes('-') && /^(.+)-\d+$/.test(key) ? key.replace(/-\d+$/, '') : key;
|
|
391
|
+
const keys = cleanKey.split(':');
|
|
392
|
+
let modelKey: string | undefined;
|
|
393
|
+
let flowKey: string | undefined;
|
|
394
|
+
let stepKey: string | undefined;
|
|
395
|
+
|
|
396
|
+
if (keys.length === 3) {
|
|
397
|
+
[modelKey, flowKey, stepKey] = keys;
|
|
398
|
+
} else if (keys.length === 2) {
|
|
399
|
+
[flowKey, stepKey] = keys;
|
|
400
|
+
} else {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return configurableFlowsAndSteps.some(({ flow, steps, modelKey: flowModelKey }: FlowInfo) => {
|
|
405
|
+
const sameModel = (flowModelKey || undefined) === modelKey;
|
|
406
|
+
if (!sameModel || flow.key !== flowKey) return false;
|
|
407
|
+
return steps.some((stepInfo: StepInfo) => stepInfo.stepKey === stepKey && !!stepInfo.disabled);
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
[configurableFlowsAndSteps],
|
|
411
|
+
);
|
|
412
|
+
|
|
364
413
|
const handleMenuClick = useCallback(
|
|
365
414
|
({ key }: { key: string }) => {
|
|
366
415
|
const originalKey = key;
|
|
@@ -380,6 +429,10 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
380
429
|
return;
|
|
381
430
|
}
|
|
382
431
|
|
|
432
|
+
if (isStepMenuItemDisabled(cleanKey)) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
383
436
|
switch (cleanKey) {
|
|
384
437
|
case 'copy-uid':
|
|
385
438
|
closeDropdown();
|
|
@@ -393,7 +446,15 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
393
446
|
break;
|
|
394
447
|
}
|
|
395
448
|
},
|
|
396
|
-
[
|
|
449
|
+
[
|
|
450
|
+
closeDropdown,
|
|
451
|
+
handleCopyUid,
|
|
452
|
+
handleDelete,
|
|
453
|
+
handleStepConfiguration,
|
|
454
|
+
handleCopyPopupUid,
|
|
455
|
+
extraMenuItems,
|
|
456
|
+
isStepMenuItemDisabled,
|
|
457
|
+
],
|
|
397
458
|
);
|
|
398
459
|
|
|
399
460
|
// 获取单个模型的可配置flows和steps
|
|
@@ -417,6 +478,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
417
478
|
if (await shouldHideStepInSettings(targetModel, flow, actionStep)) {
|
|
418
479
|
return null;
|
|
419
480
|
}
|
|
481
|
+
const disabledState = await resolveStepDisabledInSettings(targetModel, flow, actionStep as any);
|
|
420
482
|
let uiMode: any = await resolveUiMode(actionStep.uiMode, (targetModel as any).context);
|
|
421
483
|
// 检查是否有uiSchema(静态或动态)
|
|
422
484
|
const hasStepUiSchema = actionStep.uiSchema != null;
|
|
@@ -466,6 +528,8 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
466
528
|
title: t(stepTitle) || stepKey,
|
|
467
529
|
modelKey, // 添加模型标识
|
|
468
530
|
uiMode,
|
|
531
|
+
disabled: disabledState.disabled,
|
|
532
|
+
disabledReason: disabledState.reason,
|
|
469
533
|
};
|
|
470
534
|
}),
|
|
471
535
|
).then((steps) => steps.filter(Boolean));
|
|
@@ -502,9 +566,6 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
502
566
|
return result;
|
|
503
567
|
}, [model, menuLevels, getModelConfigurableFlowsAndSteps]);
|
|
504
568
|
|
|
505
|
-
const [configurableFlowsAndSteps, setConfigurableFlowsAndSteps] = useState<FlowInfo[]>([]);
|
|
506
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
507
|
-
|
|
508
569
|
useEffect(() => {
|
|
509
570
|
const triggerRebuild = () => {
|
|
510
571
|
setRefreshTick((v) => v + 1);
|
|
@@ -611,10 +672,14 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
611
672
|
},
|
|
612
673
|
...((uiMode as any)?.props || {}),
|
|
613
674
|
itemKey: (uiMode as any)?.key,
|
|
675
|
+
disabled: !!stepInfo.disabled,
|
|
676
|
+
disabledReason: stepInfo.disabledReason,
|
|
677
|
+
disabledIconColor,
|
|
614
678
|
};
|
|
615
679
|
items.push({
|
|
616
680
|
key: uniqueKey,
|
|
617
|
-
label: <MenuLabelItem title={
|
|
681
|
+
label: <MenuLabelItem title={stepInfo.title} uiMode={uiMode} itemProps={itemProps} />,
|
|
682
|
+
disabled: !!stepInfo.disabled,
|
|
618
683
|
});
|
|
619
684
|
});
|
|
620
685
|
if (flow.options.divider === 'bottom') {
|
|
@@ -656,7 +721,17 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
656
721
|
|
|
657
722
|
items.push({
|
|
658
723
|
key: uniqueKey,
|
|
659
|
-
label:
|
|
724
|
+
label: stepInfo.disabled ? (
|
|
725
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
726
|
+
{stepInfo.title}
|
|
727
|
+
<Tooltip title={stepInfo.disabledReason} placement="right" destroyTooltipOnHide>
|
|
728
|
+
<QuestionCircleOutlined style={{ color: disabledIconColor }} />
|
|
729
|
+
</Tooltip>
|
|
730
|
+
</span>
|
|
731
|
+
) : (
|
|
732
|
+
stepInfo.title
|
|
733
|
+
),
|
|
734
|
+
disabled: !!stepInfo.disabled,
|
|
660
735
|
});
|
|
661
736
|
});
|
|
662
737
|
});
|
|
@@ -671,7 +746,17 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
671
746
|
|
|
672
747
|
subMenuChildren.push({
|
|
673
748
|
key: uniqueKey,
|
|
674
|
-
label:
|
|
749
|
+
label: stepInfo.disabled ? (
|
|
750
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
751
|
+
{stepInfo.title}
|
|
752
|
+
<Tooltip title={stepInfo.disabledReason} placement="right" destroyTooltipOnHide>
|
|
753
|
+
<QuestionCircleOutlined style={{ color: disabledIconColor }} />
|
|
754
|
+
</Tooltip>
|
|
755
|
+
</span>
|
|
756
|
+
) : (
|
|
757
|
+
stepInfo.title
|
|
758
|
+
),
|
|
759
|
+
disabled: !!stepInfo.disabled,
|
|
675
760
|
});
|
|
676
761
|
});
|
|
677
762
|
});
|
|
@@ -687,7 +772,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
687
772
|
}
|
|
688
773
|
|
|
689
774
|
return items;
|
|
690
|
-
}, [configurableFlowsAndSteps, flattenSubMenus, t]);
|
|
775
|
+
}, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, t]);
|
|
691
776
|
|
|
692
777
|
// 向菜单项添加额外按钮
|
|
693
778
|
const finalMenuItems = useMemo((): NonNullable<MenuProps['items']> => {
|
|
@@ -7,14 +7,19 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { DeleteOutlined, ExclamationCircleOutlined, SettingOutlined } from '@ant-design/icons';
|
|
10
|
+
import { DeleteOutlined, ExclamationCircleOutlined, QuestionCircleOutlined, SettingOutlined } from '@ant-design/icons';
|
|
11
11
|
import type { MenuProps } from 'antd';
|
|
12
|
-
import { Alert, Dropdown, Modal } from 'antd';
|
|
12
|
+
import { Alert, Dropdown, Modal, Tooltip, theme } from 'antd';
|
|
13
13
|
import React, { useCallback, useEffect, useState } from 'react';
|
|
14
14
|
import { FlowRuntimeContext } from '../../../../flowContext';
|
|
15
15
|
import { useFlowModelById } from '../../../../hooks';
|
|
16
16
|
import { FlowModel } from '../../../../models';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
getT,
|
|
19
|
+
resolveStepDisabledInSettings,
|
|
20
|
+
setupRuntimeContextSteps,
|
|
21
|
+
shouldHideStepInSettings,
|
|
22
|
+
} from '../../../../utils';
|
|
18
23
|
import { openStepSettingsDialog } from './StepSettingsDialog';
|
|
19
24
|
import { ActionDefinition } from '../../../../types';
|
|
20
25
|
import { observer } from '../../../../reactive';
|
|
@@ -74,6 +79,21 @@ const FlowsContextMenu: React.FC<FlowsContextMenuProps> = (props) => {
|
|
|
74
79
|
const FlowsContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
75
80
|
({ model, children, enabled = true, position = 'right', showDeleteButton = true }) => {
|
|
76
81
|
const t = getT(model);
|
|
82
|
+
const { token } = theme.useToken();
|
|
83
|
+
const disabledIconColor = token?.colorTextTertiary || token?.colorTextDescription || token?.colorTextSecondary;
|
|
84
|
+
const [configurableFlowsAndSteps, setConfigurableFlowsAndSteps] = useState<any[]>([]);
|
|
85
|
+
const isStepMenuItemDisabled = useCallback(
|
|
86
|
+
(key: string) => {
|
|
87
|
+
const [flowKey, stepKey] = key.split(':');
|
|
88
|
+
if (!flowKey || !stepKey) return false;
|
|
89
|
+
return configurableFlowsAndSteps.some(({ flow, steps }) => {
|
|
90
|
+
if (flow.key !== flowKey) return false;
|
|
91
|
+
return steps.some((stepInfo) => stepInfo.stepKey === stepKey && !!stepInfo.disabled);
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
[configurableFlowsAndSteps],
|
|
95
|
+
);
|
|
96
|
+
|
|
77
97
|
const handleMenuClick = useCallback(
|
|
78
98
|
({ key }: { key: string }) => {
|
|
79
99
|
if (key === 'delete') {
|
|
@@ -99,6 +119,9 @@ const FlowsContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
99
119
|
},
|
|
100
120
|
});
|
|
101
121
|
} else {
|
|
122
|
+
if (isStepMenuItemDisabled(key)) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
102
125
|
// 处理step配置,key格式为 "flowKey:stepKey"
|
|
103
126
|
const [flowKey, stepKey] = key.split(':');
|
|
104
127
|
try {
|
|
@@ -127,7 +150,7 @@ const FlowsContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
127
150
|
}
|
|
128
151
|
}
|
|
129
152
|
},
|
|
130
|
-
[model],
|
|
153
|
+
[isStepMenuItemDisabled, model],
|
|
131
154
|
);
|
|
132
155
|
|
|
133
156
|
if (!model) {
|
|
@@ -156,6 +179,7 @@ const FlowsContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
156
179
|
if (await shouldHideStepInSettings(model as FlowModel, flow, actionStep)) {
|
|
157
180
|
return null;
|
|
158
181
|
}
|
|
182
|
+
const disabledState = await resolveStepDisabledInSettings(model as FlowModel, flow, actionStep);
|
|
159
183
|
|
|
160
184
|
// 从step获取uiSchema(如果存在)
|
|
161
185
|
const stepUiSchema: ActionDefinition['uiSchema'] = actionStep.uiSchema || {};
|
|
@@ -191,6 +215,8 @@ const FlowsContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
191
215
|
step: actionStep,
|
|
192
216
|
uiSchema: mergedUiSchema,
|
|
193
217
|
title: actionStep.title || stepKey,
|
|
218
|
+
disabled: disabledState.disabled,
|
|
219
|
+
disabledReason: disabledState.reason,
|
|
194
220
|
};
|
|
195
221
|
}),
|
|
196
222
|
).then((steps) => steps.filter(Boolean));
|
|
@@ -206,8 +232,6 @@ const FlowsContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
206
232
|
}
|
|
207
233
|
}, [model]);
|
|
208
234
|
|
|
209
|
-
const [configurableFlowsAndSteps, setConfigurableFlowsAndSteps] = useState<any[]>([]);
|
|
210
|
-
|
|
211
235
|
useEffect(() => {
|
|
212
236
|
let mounted = true;
|
|
213
237
|
(async () => {
|
|
@@ -243,7 +267,17 @@ const FlowsContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
243
267
|
menuItems.push({
|
|
244
268
|
key: `${flow.key}:${stepInfo.stepKey}`,
|
|
245
269
|
icon: <SettingOutlined />,
|
|
246
|
-
label: stepInfo.
|
|
270
|
+
label: stepInfo.disabled ? (
|
|
271
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
272
|
+
{stepInfo.title}
|
|
273
|
+
<Tooltip title={stepInfo.disabledReason} placement="right" destroyTooltipOnHide>
|
|
274
|
+
<QuestionCircleOutlined style={{ color: disabledIconColor }} />
|
|
275
|
+
</Tooltip>
|
|
276
|
+
</span>
|
|
277
|
+
) : (
|
|
278
|
+
stepInfo.title
|
|
279
|
+
),
|
|
280
|
+
disabled: !!stepInfo.disabled,
|
|
247
281
|
});
|
|
248
282
|
});
|
|
249
283
|
});
|
|
@@ -18,6 +18,7 @@ import { DefaultSettingsIcon } from '../DefaultSettingsIcon';
|
|
|
18
18
|
|
|
19
19
|
// ---- Mock antd to capture Dropdown menu props ----
|
|
20
20
|
const dropdownMenus: any[] = [];
|
|
21
|
+
const mockColorTextTertiary = '#8c8c8c';
|
|
21
22
|
vi.mock('antd', async (importOriginal) => {
|
|
22
23
|
const messageApi = {
|
|
23
24
|
success: vi.fn(),
|
|
@@ -72,6 +73,7 @@ vi.mock('antd', async (importOriginal) => {
|
|
|
72
73
|
const Alert = (props: any) => React.createElement('div', { role: 'alert' }, props.message ?? 'Alert');
|
|
73
74
|
const Button = (props: any) => React.createElement('button', props, props.children ?? 'Button');
|
|
74
75
|
const Result = (props: any) => React.createElement('div', null, props.children ?? 'Result');
|
|
76
|
+
const Tooltip = ({ children }: any) => React.createElement('span', null, children);
|
|
75
77
|
|
|
76
78
|
// Keep other components from original mock/default
|
|
77
79
|
return {
|
|
@@ -90,10 +92,40 @@ vi.mock('antd', async (importOriginal) => {
|
|
|
90
92
|
Alert,
|
|
91
93
|
Button,
|
|
92
94
|
Result,
|
|
93
|
-
|
|
95
|
+
Tooltip,
|
|
96
|
+
theme: { useToken: () => ({ token: { colorTextTertiary: mockColorTextTertiary } }) },
|
|
94
97
|
};
|
|
95
98
|
});
|
|
96
99
|
|
|
100
|
+
const findElement = (node: any, predicate: (element: React.ReactElement) => boolean): React.ReactElement | null => {
|
|
101
|
+
if (!node) return null;
|
|
102
|
+
|
|
103
|
+
if (React.isValidElement(node)) {
|
|
104
|
+
if (predicate(node)) {
|
|
105
|
+
return node;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const children = React.Children.toArray(node.props?.children);
|
|
109
|
+
for (const child of children) {
|
|
110
|
+
const matched = findElement(child, predicate);
|
|
111
|
+
if (matched) {
|
|
112
|
+
return matched;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (Array.isArray(node)) {
|
|
118
|
+
for (const child of node) {
|
|
119
|
+
const matched = findElement(child, predicate);
|
|
120
|
+
if (matched) {
|
|
121
|
+
return matched;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return null;
|
|
127
|
+
};
|
|
128
|
+
|
|
97
129
|
describe('DefaultSettingsIcon - only static flows are shown', () => {
|
|
98
130
|
beforeEach(() => {
|
|
99
131
|
dropdownMenus.length = 0;
|
|
@@ -241,6 +273,67 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
|
|
|
241
273
|
});
|
|
242
274
|
});
|
|
243
275
|
|
|
276
|
+
it('keeps disabled legacy step visible with tooltip and blocks click', async () => {
|
|
277
|
+
class TestFlowModel extends FlowModel {}
|
|
278
|
+
const engine = new FlowEngine();
|
|
279
|
+
const model = new TestFlowModel({ uid: 'm-disabled', flowEngine: engine });
|
|
280
|
+
const openSpy = vi.spyOn(model, 'openFlowSettings').mockResolvedValue(undefined as any);
|
|
281
|
+
const disabledReason = 'This setting has been moved to: Form block settings > Field values';
|
|
282
|
+
|
|
283
|
+
TestFlowModel.registerFlow({
|
|
284
|
+
key: 'flowDisabled',
|
|
285
|
+
title: 'Flow Disabled',
|
|
286
|
+
steps: {
|
|
287
|
+
legacyDefault: {
|
|
288
|
+
title: 'Default value',
|
|
289
|
+
disabledInSettings: true,
|
|
290
|
+
disabledReasonInSettings: disabledReason,
|
|
291
|
+
uiSchema: { f: { type: 'string', 'x-component': 'Input' } },
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
render(
|
|
297
|
+
React.createElement(
|
|
298
|
+
ConfigProvider as any,
|
|
299
|
+
null,
|
|
300
|
+
React.createElement(App as any, null, React.createElement(DefaultSettingsIcon as any, { model })),
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
let disabledItem: any;
|
|
305
|
+
await waitFor(() => {
|
|
306
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
307
|
+
const items = (menu?.items || []) as any[];
|
|
308
|
+
disabledItem = items.find((it) => String(it.key || '') === 'flowDisabled:legacyDefault');
|
|
309
|
+
expect(disabledItem).toBeTruthy();
|
|
310
|
+
expect(disabledItem.disabled).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const resolvedLabel =
|
|
314
|
+
React.isValidElement(disabledItem.label) && typeof disabledItem.label.type === 'function'
|
|
315
|
+
? (disabledItem.label.type as any)(disabledItem.label.props)
|
|
316
|
+
: disabledItem.label;
|
|
317
|
+
|
|
318
|
+
const tooltipElement = findElement(
|
|
319
|
+
resolvedLabel,
|
|
320
|
+
(element) =>
|
|
321
|
+
Object.prototype.hasOwnProperty.call(element.props || {}, 'title') && element.props.title === disabledReason,
|
|
322
|
+
);
|
|
323
|
+
expect(tooltipElement).toBeTruthy();
|
|
324
|
+
|
|
325
|
+
const iconElement = React.isValidElement(tooltipElement) ? tooltipElement.props.children : null;
|
|
326
|
+
expect(React.isValidElement(iconElement)).toBe(true);
|
|
327
|
+
expect((iconElement as any).props?.style?.color).toBe(mockColorTextTertiary);
|
|
328
|
+
|
|
329
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
330
|
+
await act(async () => {
|
|
331
|
+
menu.onClick?.({ key: 'flowDisabled:legacyDefault' });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(openSpy).not.toHaveBeenCalled();
|
|
335
|
+
});
|
|
336
|
+
|
|
244
337
|
it('clicking a step item opens flow settings with correct args', async () => {
|
|
245
338
|
class TestFlowModel extends FlowModel {}
|
|
246
339
|
const engine = new FlowEngine();
|
|
@@ -204,8 +204,10 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
204
204
|
useEffect(() => {
|
|
205
205
|
if (!resolvedMetaTreeNode) return;
|
|
206
206
|
if (!Array.isArray(resolvedMetaTree) || innerValue == null) return;
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
// During initial restoration, `innerValue` already represents the persisted value.
|
|
208
|
+
// Do NOT override it with `resolveValueFromPath`, otherwise truthy defaults (e.g. RunJSValue objects)
|
|
209
|
+
// may accidentally wipe persisted content when reopening.
|
|
210
|
+
emitChange(innerValue, resolvedMetaTreeNode);
|
|
209
211
|
setCurrentMetaTreeNode(resolvedMetaTreeNode);
|
|
210
212
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
211
213
|
}, [resolvedMetaTreeNode]);
|