@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
|
@@ -30,65 +30,74 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
|
|
|
30
30
|
|
|
31
31
|
const { data: displayedValue } = useRequest(
|
|
32
32
|
async () => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
36
|
-
|
|
37
|
-
: ctx.t(metaTreeNode.title) || '';
|
|
38
|
-
}
|
|
33
|
+
const resolveLabelFromPath = async (rawPath?: (string | number)[]): Promise<string | null> => {
|
|
34
|
+
if (!rawPath) return null;
|
|
35
|
+
if (!Array.isArray(rawPath)) return null;
|
|
36
|
+
if (!Array.isArray(resolvedMetaTree)) return null;
|
|
39
37
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
// 兼容 metaTree 为子树:顶层不含首段时,裁剪首段
|
|
39
|
+
const topNames = new Set((resolvedMetaTree || []).map((n: any) => String(n?.name)));
|
|
40
|
+
const path = !topNames.has(String(rawPath[0])) ? rawPath.slice(1) : rawPath;
|
|
41
|
+
if (!path.length) return '';
|
|
42
|
+
|
|
43
|
+
let nodes: MetaTreeNode[] | undefined = resolvedMetaTree as MetaTreeNode[];
|
|
44
|
+
const titleChain: string[] = [];
|
|
45
|
+
let matchedCount = 0;
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < path.length; i++) {
|
|
48
|
+
if (!nodes) break;
|
|
49
|
+
const seg = String(path[i]);
|
|
50
|
+
const node = nodes.find((n) => String(n?.name) === seg) as MetaTreeNode | undefined;
|
|
51
|
+
if (!node) break; // 停在第一个无效段之前
|
|
52
|
+
|
|
53
|
+
titleChain.push(String(node.title ?? node.name ?? seg));
|
|
54
|
+
matchedCount = i + 1;
|
|
47
55
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (!node) break; // 停在第一个无效段之前
|
|
61
|
-
deepest = node;
|
|
62
|
-
matchedCount = i + 1;
|
|
63
|
-
if (i < path.length - 1) {
|
|
64
|
-
if (Array.isArray(node.children)) {
|
|
65
|
-
nodes = node.children as any;
|
|
66
|
-
} else if (typeof node.children === 'function') {
|
|
67
|
-
try {
|
|
68
|
-
const childNodes = await (node.children as any)();
|
|
69
|
-
(node as any).children = childNodes;
|
|
70
|
-
nodes = childNodes as any;
|
|
71
|
-
} catch {
|
|
56
|
+
if (i < path.length - 1) {
|
|
57
|
+
if (Array.isArray(node.children)) {
|
|
58
|
+
nodes = node.children as any;
|
|
59
|
+
} else if (typeof node.children === 'function') {
|
|
60
|
+
try {
|
|
61
|
+
const childNodes = await (node.children as any)();
|
|
62
|
+
(node as any).children = childNodes;
|
|
63
|
+
nodes = childNodes as any;
|
|
64
|
+
} catch {
|
|
65
|
+
nodes = undefined;
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
72
68
|
nodes = undefined;
|
|
73
69
|
}
|
|
74
|
-
} else {
|
|
75
|
-
nodes = undefined;
|
|
76
70
|
}
|
|
77
71
|
}
|
|
78
|
-
}
|
|
79
72
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
let label =
|
|
73
|
+
if (matchedCount === 0) return null;
|
|
74
|
+
|
|
75
|
+
let label = titleChain.map(ctx.t).join('/');
|
|
83
76
|
if (matchedCount < path.length) {
|
|
84
77
|
const tail = path.slice(matchedCount).join('/');
|
|
85
78
|
label = tail ? `${label}/${tail}` : label;
|
|
86
79
|
}
|
|
87
80
|
return label;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// 1) 优先使用已解析到的节点(包含完整父标题链)
|
|
84
|
+
if (metaTreeNode?.parentTitles) {
|
|
85
|
+
return [...metaTreeNode.parentTitles, metaTreeNode.title].map(ctx.t).join('/');
|
|
88
86
|
}
|
|
89
87
|
|
|
90
|
-
//
|
|
91
|
-
|
|
88
|
+
// 2) metaTreeNode 存在但缺少 parentTitles:尝试根据 value/metaTreeNode.paths 从 metaTree 还原完整路径
|
|
89
|
+
if (metaTreeNode) {
|
|
90
|
+
const rawPath = parseValueToPath(value) || metaTreeNode.paths;
|
|
91
|
+
const label = await resolveLabelFromPath(rawPath as any);
|
|
92
|
+
return label ?? ctx.t(metaTreeNode.title) ?? '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3) 无 metaTreeNode:从 value 还原路径并拼接标题链;若找不到任何前缀则回退原始路径字符串
|
|
96
|
+
if (!value) return String(value);
|
|
97
|
+
const rawPath = parseValueToPath(value);
|
|
98
|
+
const label = await resolveLabelFromPath(rawPath as any);
|
|
99
|
+
if (label != null) return label;
|
|
100
|
+
return Array.isArray(rawPath) ? rawPath.join('/') : String(value);
|
|
92
101
|
},
|
|
93
102
|
{ refreshDeps: [resolvedMetaTree, value, metaTreeNode] },
|
|
94
103
|
);
|
|
@@ -272,6 +272,56 @@ describe('VariableTag', () => {
|
|
|
272
272
|
);
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
+
it('should display full path when metaTreeNode has no parentTitles but metaTree is provided', async () => {
|
|
276
|
+
const metaTree = [
|
|
277
|
+
{
|
|
278
|
+
name: 'item',
|
|
279
|
+
title: 'Current item',
|
|
280
|
+
type: 'object',
|
|
281
|
+
paths: ['item'],
|
|
282
|
+
children: [
|
|
283
|
+
{
|
|
284
|
+
name: 'value',
|
|
285
|
+
title: 'Attributes',
|
|
286
|
+
type: 'object',
|
|
287
|
+
paths: ['item', 'value'],
|
|
288
|
+
children: [
|
|
289
|
+
{
|
|
290
|
+
name: 'nickname',
|
|
291
|
+
title: 'Nickname',
|
|
292
|
+
type: 'string',
|
|
293
|
+
paths: ['item', 'value', 'nickname'],
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
const mockMetaTreeNode = {
|
|
302
|
+
name: 'nickname',
|
|
303
|
+
title: 'Nickname',
|
|
304
|
+
type: 'string',
|
|
305
|
+
paths: ['item', 'value', 'nickname'],
|
|
306
|
+
// 没有 parentTitles 属性
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
renderWithCtx(
|
|
310
|
+
<VariableTag
|
|
311
|
+
value="{{ ctx.item.value.nickname }}"
|
|
312
|
+
metaTreeNode={mockMetaTreeNode as any}
|
|
313
|
+
metaTree={metaTree as any}
|
|
314
|
+
/>,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
await waitFor(
|
|
318
|
+
() => {
|
|
319
|
+
expect(screen.getByText('Current item/Attributes/Nickname')).toBeInTheDocument();
|
|
320
|
+
},
|
|
321
|
+
{ timeout: 3000 },
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
275
325
|
it('should handle function type metaTree gracefully', async () => {
|
|
276
326
|
const mockMetaTreeFunction = () => [
|
|
277
327
|
{
|
package/src/data-source/index.ts
CHANGED
|
@@ -99,7 +99,7 @@ export class DataSource {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
get displayName() {
|
|
102
|
-
return this.
|
|
102
|
+
return this.flowEngine.translate(this.options.displayName, { ns: 'lm-collections' }) || this.key;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
get key() {
|
|
@@ -496,7 +496,7 @@ export class Collection {
|
|
|
496
496
|
return this.options.storage || 'local';
|
|
497
497
|
}
|
|
498
498
|
get title() {
|
|
499
|
-
return this.
|
|
499
|
+
return this.flowEngine.translate(this.options.title, { ns: 'lm-collections' }) || this.name;
|
|
500
500
|
}
|
|
501
501
|
|
|
502
502
|
get titleCollectionField() {
|
|
@@ -769,8 +769,8 @@ export class CollectionField {
|
|
|
769
769
|
}
|
|
770
770
|
|
|
771
771
|
get title() {
|
|
772
|
-
const titleValue = this.options?.uiSchema?.title || this.options?.title
|
|
773
|
-
return this.flowEngine.translate(titleValue);
|
|
772
|
+
const titleValue = this.options?.uiSchema?.title || this.options?.title;
|
|
773
|
+
return this.flowEngine.translate(titleValue, { ns: 'lm-collections' }) || this.options.name;
|
|
774
774
|
}
|
|
775
775
|
|
|
776
776
|
set title(value: string) {
|
|
@@ -789,11 +789,17 @@ export class CollectionField {
|
|
|
789
789
|
}
|
|
790
790
|
return {
|
|
791
791
|
...v,
|
|
792
|
+
label: v.label ? this.flowEngine.translate(v.label, { ns: 'lm-collections' }) : v.label,
|
|
792
793
|
value: Number(v.value),
|
|
793
794
|
};
|
|
794
795
|
});
|
|
795
796
|
}
|
|
796
|
-
return options
|
|
797
|
+
return options.map((v) => {
|
|
798
|
+
return {
|
|
799
|
+
...v,
|
|
800
|
+
label: this.flowEngine.translate(v.label, { ns: 'lm-collections' }),
|
|
801
|
+
};
|
|
802
|
+
});
|
|
797
803
|
}
|
|
798
804
|
|
|
799
805
|
get defaultValue() {
|
package/src/flowContext.ts
CHANGED
|
@@ -205,7 +205,7 @@ export interface PropertyOptions {
|
|
|
205
205
|
// - function: 根据子路径决定是否交给服务端(子路径示例:'record.roles[0].name'、'id'、'')
|
|
206
206
|
resolveOnServer?: boolean | ((subPath: string) => boolean);
|
|
207
207
|
// 优化:当需要服务端解析但本属性在 buildVariablesParams 返回空时,是否跳过调用服务端。
|
|
208
|
-
// - 典型场景:formValues /
|
|
208
|
+
// - 典型场景:formValues / item 仅在“已选关联值”存在时才需要服务端;否则没有必要请求。
|
|
209
209
|
// - 默认 false:保持兼容,其他变量即使没有 contextParams 也可选择调用服务端。
|
|
210
210
|
serverOnlyWhenContextParams?: boolean;
|
|
211
211
|
}
|
|
@@ -1144,7 +1144,8 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
1144
1144
|
value: this.engine,
|
|
1145
1145
|
});
|
|
1146
1146
|
this.defineProperty('sql', {
|
|
1147
|
-
get: () => new FlowSQLRepository(
|
|
1147
|
+
get: (ctx) => new FlowSQLRepository(ctx),
|
|
1148
|
+
cache: false,
|
|
1148
1149
|
});
|
|
1149
1150
|
this.defineProperty('dataSourceManager', {
|
|
1150
1151
|
value: dataSourceManager,
|
|
@@ -1471,6 +1472,9 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
1471
1472
|
const modelClass = getModelClassName(this);
|
|
1472
1473
|
const Ctor: new (delegate: any) => any = RunJSContextRegistry.resolve(version, modelClass) || FlowRunJSContext;
|
|
1473
1474
|
const runCtx = new Ctor(this);
|
|
1475
|
+
runCtx.defineMethod('t', (key: string, options?: any) => {
|
|
1476
|
+
return this.t(key, { ns: 'runjs', ...options });
|
|
1477
|
+
});
|
|
1474
1478
|
const globals: Record<string, any> = { ctx: runCtx, ...(options?.globals || {}) };
|
|
1475
1479
|
const { timeoutMs } = options || {};
|
|
1476
1480
|
return new JSRunner({ globals, timeoutMs });
|
package/src/flowI18n.ts
CHANGED
|
@@ -33,15 +33,17 @@ export class FlowI18n {
|
|
|
33
33
|
return keyOrTemplate;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
if (options?.compareWith && keyOrTemplate === options.compareWith) {
|
|
37
|
+
return keyOrTemplate;
|
|
38
|
+
}
|
|
38
39
|
|
|
39
40
|
// 检查翻译结果是否包含模板语法,如果有则进行模板编译
|
|
40
|
-
if (this.isTemplate(
|
|
41
|
-
|
|
41
|
+
if (this.isTemplate(keyOrTemplate)) {
|
|
42
|
+
return this.compileTemplate(keyOrTemplate);
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
// 先尝试一次翻译
|
|
46
|
+
return this.translateKey(keyOrTemplate, options);
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
/**
|
package/src/locale/en-US.json
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"This is likely a NocoBase internals bug. Please open an issue at": "This is likely a NocoBase internals bug. Please open an issue at",
|
|
63
63
|
"This step has no configurable parameters": "This step has no configurable parameters",
|
|
64
64
|
"This variable is not available": "This variable is not available",
|
|
65
|
+
"Use return to output value": "Use return to output value",
|
|
65
66
|
"Try again": "Try again",
|
|
66
67
|
"Template created": "Template created",
|
|
67
68
|
"Template description": "Template description",
|
|
@@ -70,4 +71,4 @@
|
|
|
70
71
|
"UID copied to clipboard": "UID copied to clipboard",
|
|
71
72
|
"createModelOptions must specify use property": "createModelOptions must specify \"use\" property",
|
|
72
73
|
"here": "here"
|
|
73
|
-
}
|
|
74
|
+
}
|
package/src/locale/zh-CN.json
CHANGED
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"This is likely a NocoBase internals bug. Please open an issue at": "这可能是 NocoBase 内部错误。请在以下地址提交问题",
|
|
67
67
|
"This step has no configurable parameters": "此步骤没有可配置的参数",
|
|
68
68
|
"This variable is not available": "此变量不可用",
|
|
69
|
+
"Use return to output value": "使用 return 返回最终值",
|
|
69
70
|
"Try again": "重试",
|
|
70
71
|
"UID copied to clipboard": "UID 已复制到剪贴板",
|
|
71
72
|
"createModelOptions must specify use property": "createModelOptions 必须指定 \"use\" 属性",
|
|
@@ -27,10 +27,10 @@ type SQLSaveOptions = {
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
export class FlowSQLRepository {
|
|
30
|
-
protected ctx:
|
|
30
|
+
protected ctx: FlowContext;
|
|
31
31
|
|
|
32
|
-
constructor(ctx:
|
|
33
|
-
this.ctx = new FlowContext()
|
|
32
|
+
constructor(ctx: FlowContext) {
|
|
33
|
+
this.ctx = new FlowContext();
|
|
34
34
|
this.ctx.addDelegate(ctx);
|
|
35
35
|
this.ctx.defineProperty('offset', {
|
|
36
36
|
get: () => 0,
|
package/src/types.ts
CHANGED
|
@@ -172,6 +172,18 @@ export interface ActionDefinition<TModel extends FlowModel = FlowModel, TCtx ext
|
|
|
172
172
|
* - StepDefinition.hideInSettings can override the ActionDefinition value.
|
|
173
173
|
*/
|
|
174
174
|
hideInSettings?: boolean | ((ctx: TCtx) => boolean | Promise<boolean>);
|
|
175
|
+
/**
|
|
176
|
+
* Whether to disable this step/action in settings menus.
|
|
177
|
+
* - Supports static boolean and dynamic decision based on runtime context.
|
|
178
|
+
* - StepDefinition.disabledInSettings can override the ActionDefinition value.
|
|
179
|
+
*/
|
|
180
|
+
disabledInSettings?: boolean | ((ctx: TCtx) => boolean | Promise<boolean>);
|
|
181
|
+
/**
|
|
182
|
+
* Optional reason shown when this step/action is disabled in settings menus.
|
|
183
|
+
* - Supports static string and dynamic resolver based on runtime context.
|
|
184
|
+
* - StepDefinition.disabledReasonInSettings can override the ActionDefinition value.
|
|
185
|
+
*/
|
|
186
|
+
disabledReasonInSettings?: string | ((ctx: TCtx) => string | Promise<string>);
|
|
175
187
|
/**
|
|
176
188
|
* 在执行 Action 前为 ctx 定义临时属性。
|
|
177
189
|
* - 仅支持 PropertyOptions 形态(例如:{ foo: { value: 5 } });
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import { extractUsedVariablePathsFromRunJS, isRunJSValue, normalizeRunJSValue } from '../runjsValue';
|
|
12
|
+
|
|
13
|
+
describe('runjsValue utils', () => {
|
|
14
|
+
it('isRunJSValue: strict shape detection', () => {
|
|
15
|
+
expect(isRunJSValue({ code: 'return 1' })).toBe(true);
|
|
16
|
+
expect(isRunJSValue({ code: 'return 1', version: 'v1' })).toBe(true);
|
|
17
|
+
|
|
18
|
+
expect(isRunJSValue(null)).toBe(false);
|
|
19
|
+
expect(isRunJSValue('return 1')).toBe(false);
|
|
20
|
+
expect(isRunJSValue({})).toBe(false);
|
|
21
|
+
expect(isRunJSValue({ version: 'v1' })).toBe(false);
|
|
22
|
+
expect(isRunJSValue({ code: 1 })).toBe(false);
|
|
23
|
+
expect(isRunJSValue({ code: 'return 1', foo: 1 })).toBe(false);
|
|
24
|
+
expect(isRunJSValue([])).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('normalizeRunJSValue: defaults version to v1', () => {
|
|
28
|
+
expect(normalizeRunJSValue({ code: 'return 1' })).toEqual({ code: 'return 1', version: 'v1' });
|
|
29
|
+
expect(normalizeRunJSValue({ code: 'return 1', version: 'v2' })).toEqual({ code: 'return 1', version: 'v2' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('extractUsedVariablePathsFromRunJS: extracts ctx usage (dot + bracket root)', () => {
|
|
33
|
+
const code = `
|
|
34
|
+
// comment: ctx.ignore.me
|
|
35
|
+
const x = "ctx.ignore.too";
|
|
36
|
+
return ctx.formValues.a + ctx.record.id + ctx.someVar + ctx['user'].name;
|
|
37
|
+
`;
|
|
38
|
+
const out = extractUsedVariablePathsFromRunJS(code);
|
|
39
|
+
expect(out.formValues).toContain('a');
|
|
40
|
+
expect(out.record).toContain('id');
|
|
41
|
+
expect(out.someVar).toContain('');
|
|
42
|
+
expect(out.user).toContain('name');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -28,6 +28,14 @@ describe('safeGlobals', () => {
|
|
|
28
28
|
expect(win.console).toBeDefined();
|
|
29
29
|
expect(win.foo).toBe(123);
|
|
30
30
|
expect(new win.FormData()).toBeInstanceOf(window.FormData);
|
|
31
|
+
if (typeof window.Blob !== 'undefined') {
|
|
32
|
+
expect(typeof win.Blob).toBe('function');
|
|
33
|
+
expect(new win.Blob(['x'])).toBeInstanceOf(window.Blob);
|
|
34
|
+
}
|
|
35
|
+
if (typeof window.URL !== 'undefined') {
|
|
36
|
+
expect(win.URL).toBe(window.URL);
|
|
37
|
+
expect(typeof win.URL.createObjectURL).toBe('function');
|
|
38
|
+
}
|
|
31
39
|
// access to location proxy is allowed, but sensitive props throw
|
|
32
40
|
expect(() => win.location.href).toThrow(/not allowed/);
|
|
33
41
|
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
isInheritedFrom,
|
|
14
14
|
resolveDefaultParams,
|
|
15
15
|
resolveStepUiSchema,
|
|
16
|
+
resolveStepDisabledInSettings,
|
|
16
17
|
shouldHideStepInSettings,
|
|
17
18
|
FlowExitException,
|
|
18
19
|
defineAction,
|
|
@@ -1117,4 +1118,98 @@ describe('Utils', () => {
|
|
|
1117
1118
|
consoleSpy.mockRestore();
|
|
1118
1119
|
});
|
|
1119
1120
|
});
|
|
1121
|
+
|
|
1122
|
+
// ==================== resolveStepDisabledInSettings() FUNCTION ====================
|
|
1123
|
+
describe('resolveStepDisabledInSettings()', () => {
|
|
1124
|
+
let mockFlow: any;
|
|
1125
|
+
let mockStep: StepDefinition;
|
|
1126
|
+
|
|
1127
|
+
beforeEach(() => {
|
|
1128
|
+
mockFlow = {
|
|
1129
|
+
key: 'testFlow',
|
|
1130
|
+
title: 'Test Flow',
|
|
1131
|
+
steps: {},
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
mockStep = {
|
|
1135
|
+
key: 'testStep',
|
|
1136
|
+
handler: vi.fn(),
|
|
1137
|
+
};
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
test('returns disabled=false when step is falsy', async () => {
|
|
1141
|
+
const result = await resolveStepDisabledInSettings(mockModel, mockFlow, null as any);
|
|
1142
|
+
expect(result).toEqual({ disabled: false });
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
test('respects static step.disabledInSettings and disabledReasonInSettings', async () => {
|
|
1146
|
+
mockStep.disabledInSettings = true;
|
|
1147
|
+
mockStep.disabledReasonInSettings = 'legacy reason';
|
|
1148
|
+
|
|
1149
|
+
const result = await resolveStepDisabledInSettings(mockModel, mockFlow, mockStep);
|
|
1150
|
+
expect(result).toEqual({ disabled: true, reason: 'legacy reason' });
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
test('falls back to action disabled settings when step value is undefined', async () => {
|
|
1154
|
+
const action: ActionDefinition = {
|
|
1155
|
+
name: 'testAction',
|
|
1156
|
+
handler: vi.fn(),
|
|
1157
|
+
disabledInSettings: true,
|
|
1158
|
+
disabledReasonInSettings: 'from action',
|
|
1159
|
+
} as any;
|
|
1160
|
+
|
|
1161
|
+
mockStep.use = 'testAction';
|
|
1162
|
+
mockModel.flowEngine.getAction = vi.fn().mockReturnValue(action);
|
|
1163
|
+
|
|
1164
|
+
const result = await resolveStepDisabledInSettings(mockModel, mockFlow, mockStep);
|
|
1165
|
+
expect(result).toEqual({ disabled: true, reason: 'from action' });
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
test('prefers step disabled settings over action values', async () => {
|
|
1169
|
+
const action: ActionDefinition = {
|
|
1170
|
+
name: 'testAction',
|
|
1171
|
+
handler: vi.fn(),
|
|
1172
|
+
disabledInSettings: false,
|
|
1173
|
+
disabledReasonInSettings: 'from action',
|
|
1174
|
+
} as any;
|
|
1175
|
+
|
|
1176
|
+
mockStep.use = 'testAction';
|
|
1177
|
+
mockStep.disabledInSettings = true;
|
|
1178
|
+
mockStep.disabledReasonInSettings = 'from step';
|
|
1179
|
+
mockModel.flowEngine.getAction = vi.fn().mockReturnValue(action);
|
|
1180
|
+
|
|
1181
|
+
const result = await resolveStepDisabledInSettings(mockModel, mockFlow, mockStep);
|
|
1182
|
+
expect(result).toEqual({ disabled: true, reason: 'from step' });
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test('evaluates function disabled settings with FlowRuntimeContext', async () => {
|
|
1186
|
+
const disabledFn = vi.fn().mockResolvedValue(true);
|
|
1187
|
+
const reasonFn = vi.fn().mockResolvedValue('computed reason');
|
|
1188
|
+
mockStep.disabledInSettings = disabledFn as any;
|
|
1189
|
+
mockStep.disabledReasonInSettings = reasonFn as any;
|
|
1190
|
+
|
|
1191
|
+
const result = await resolveStepDisabledInSettings(mockModel, mockFlow, mockStep);
|
|
1192
|
+
|
|
1193
|
+
expect(disabledFn).toHaveBeenCalledTimes(1);
|
|
1194
|
+
expect(reasonFn).toHaveBeenCalledTimes(1);
|
|
1195
|
+
const disabledCtx = disabledFn.mock.calls[0][0] as FlowRuntimeContext;
|
|
1196
|
+
const reasonCtx = reasonFn.mock.calls[0][0] as FlowRuntimeContext;
|
|
1197
|
+
expect(disabledCtx).toBeInstanceOf(FlowRuntimeContext);
|
|
1198
|
+
expect(reasonCtx).toBeInstanceOf(FlowRuntimeContext);
|
|
1199
|
+
expect((disabledCtx as any).currentStep).toBeInstanceOf(ContextPathProxy);
|
|
1200
|
+
expect(String((disabledCtx as any).currentStep)).toBe('{{ctx.currentStep}}');
|
|
1201
|
+
expect(result).toEqual({ disabled: true, reason: 'computed reason' });
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
test('returns disabled=false when function disabledInSettings throws', async () => {
|
|
1205
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
1206
|
+
mockStep.disabledInSettings = vi.fn().mockRejectedValue(new Error('boom')) as any;
|
|
1207
|
+
|
|
1208
|
+
const result = await resolveStepDisabledInSettings(mockModel, mockFlow, mockStep);
|
|
1209
|
+
|
|
1210
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
1211
|
+
expect(result).toEqual({ disabled: false });
|
|
1212
|
+
consoleSpy.mockRestore();
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1120
1215
|
});
|
|
@@ -71,7 +71,7 @@ function toFilterByTk(value: unknown, primaryKey: string | string[]) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
* 创建一个用于“对象类变量”(如 formValues /
|
|
74
|
+
* 创建一个用于“对象类变量”(如 formValues / item)的 `resolveOnServer` 判定函数。
|
|
75
75
|
* 仅当访问路径以“关联字段名”开头(且继续访问其子属性)时,返回 true 交由服务端解析;
|
|
76
76
|
* 否则在前端解析即可。
|
|
77
77
|
*
|
|
@@ -114,7 +114,7 @@ export function createAssociationSubpathResolver(
|
|
|
114
114
|
*
|
|
115
115
|
* @param collectionAccessor 获取集合对象,用于字段/元信息来源
|
|
116
116
|
* @param title 变量组标题(用于 UI 展示)
|
|
117
|
-
* @param valueAccessor 获取当前对象值(如 ctx.form.getFieldsValue() / ctx.
|
|
117
|
+
* @param valueAccessor 获取当前对象值(如 ctx.form.getFieldsValue() / ctx.item)
|
|
118
118
|
* @returns PropertyMetaFactory
|
|
119
119
|
*/
|
|
120
120
|
export function createAssociationAwareObjectMetaFactory(
|
package/src/utils/index.ts
CHANGED
|
@@ -34,7 +34,13 @@ export { isInheritedFrom } from './inheritance';
|
|
|
34
34
|
export { resolveCreateModelOptions, resolveDefaultParams, resolveExpressions } from './params-resolvers';
|
|
35
35
|
|
|
36
36
|
// Schema 工具
|
|
37
|
-
export {
|
|
37
|
+
export {
|
|
38
|
+
compileUiSchema,
|
|
39
|
+
resolveStepUiSchema,
|
|
40
|
+
resolveStepDisabledInSettings,
|
|
41
|
+
resolveUiMode,
|
|
42
|
+
shouldHideStepInSettings,
|
|
43
|
+
} from './schema-utils';
|
|
38
44
|
|
|
39
45
|
// Runtime Context Steps 设置
|
|
40
46
|
export { setupRuntimeContextSteps } from './setupRuntimeContextSteps';
|
|
@@ -61,7 +67,19 @@ export { clearAutoFlowError, getAutoFlowError, setAutoFlowError, type AutoFlowEr
|
|
|
61
67
|
export { parsePathnameToViewParams, type ViewParam } from './parsePathnameToViewParams';
|
|
62
68
|
|
|
63
69
|
// 安全全局对象(window/document)
|
|
64
|
-
export {
|
|
70
|
+
export {
|
|
71
|
+
createSafeDocument,
|
|
72
|
+
createSafeWindow,
|
|
73
|
+
createSafeNavigator,
|
|
74
|
+
createSafeRunJSGlobals,
|
|
75
|
+
runjsWithSafeGlobals,
|
|
76
|
+
} from './safeGlobals';
|
|
77
|
+
|
|
78
|
+
// RunJS value helpers
|
|
79
|
+
export { isRunJSValue, normalizeRunJSValue, extractUsedVariablePathsFromRunJS, type RunJSValue } from './runjsValue';
|
|
80
|
+
|
|
81
|
+
// RunJS helpers
|
|
82
|
+
export { resolveRunJSObjectValues } from './resolveRunJSObjectValues';
|
|
65
83
|
|
|
66
84
|
// RunJS 代码兼容预处理({{ }})与 JSX 编译
|
|
67
85
|
export { prepareRunJsCode, preprocessRunJsTemplates } from './runjsTemplateCompat';
|
|
@@ -0,0 +1,46 @@
|
|
|
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 { isRunJSValue, normalizeRunJSValue } from './runjsValue';
|
|
11
|
+
import { runjsWithSafeGlobals } from './safeGlobals';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve an object's values, executing any RunJSValue entries via ctx.runjs.
|
|
15
|
+
*
|
|
16
|
+
* - Skips `undefined` values
|
|
17
|
+
* - Skips empty RunJS code (treated as not configured)
|
|
18
|
+
* - Throws when a RunJS execution fails
|
|
19
|
+
*/
|
|
20
|
+
export async function resolveRunJSObjectValues(ctx: unknown, raw: unknown): Promise<Record<string, any>> {
|
|
21
|
+
const out: Record<string, any> = {};
|
|
22
|
+
|
|
23
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
24
|
+
if (Array.isArray(raw)) return out;
|
|
25
|
+
|
|
26
|
+
for (const [key, value] of Object.entries(raw as Record<string, any>)) {
|
|
27
|
+
if (typeof value === 'undefined') continue;
|
|
28
|
+
|
|
29
|
+
if (isRunJSValue(value)) {
|
|
30
|
+
const { code, version } = normalizeRunJSValue(value);
|
|
31
|
+
if (!code.trim()) continue;
|
|
32
|
+
const ret = await runjsWithSafeGlobals(ctx, code, { version });
|
|
33
|
+
if (!ret?.success) {
|
|
34
|
+
throw new Error(`RunJS execution failed for "${key}"`);
|
|
35
|
+
}
|
|
36
|
+
if (typeof ret.value !== 'undefined') {
|
|
37
|
+
out[key] = ret.value;
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
out[key] = value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return out;
|
|
46
|
+
}
|