@nocobase/flow-engine 2.0.0-alpha.32 → 2.0.0-alpha.34
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/variables/VariableInput.js +3 -3
- package/lib/flowContext.d.ts +1 -0
- package/lib/flowContext.js +10 -0
- package/lib/models/forkFlowModel.d.ts +4 -0
- package/lib/models/forkFlowModel.js +6 -0
- package/lib/resources/multiRecordResource.js +5 -0
- package/lib/utils/associationObjectVariable.d.ts +31 -0
- package/lib/utils/associationObjectVariable.js +137 -0
- package/lib/utils/index.d.ts +2 -0
- package/lib/utils/index.js +8 -0
- package/lib/utils/pruneFilter.d.ts +21 -0
- package/lib/utils/pruneFilter.js +52 -0
- package/package.json +4 -4
- package/src/__tests__/flowContext.test.ts +52 -0
- package/src/__tests__/objectVariable.test.ts +403 -0
- package/src/components/variables/VariableInput.tsx +3 -3
- package/src/flowContext.ts +15 -0
- package/src/models/__tests__/forkFlowModel.test.ts +18 -0
- package/src/models/forkFlowModel.ts +7 -0
- package/src/resources/multiRecordResource.ts +5 -0
- package/src/utils/__tests__/pruneFilter.test.ts +38 -0
- package/src/utils/associationObjectVariable.ts +164 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/pruneFilter.ts +41 -0
|
@@ -144,7 +144,7 @@ const VariableInputComponent = /* @__PURE__ */ __name(({
|
|
|
144
144
|
}, [currentMetaTreeNode, innerValue, resolvedMetaTree, resolvePathFromValue]);
|
|
145
145
|
(0, import_react.useEffect)(() => {
|
|
146
146
|
const restoreFromValue = /* @__PURE__ */ __name(async () => {
|
|
147
|
-
if (!Array.isArray(resolvedMetaTree) ||
|
|
147
|
+
if (!Array.isArray(resolvedMetaTree) || value == null) return;
|
|
148
148
|
if (currentMetaTreeNode) {
|
|
149
149
|
return;
|
|
150
150
|
}
|
|
@@ -197,7 +197,7 @@ const VariableInputComponent = /* @__PURE__ */ __name(({
|
|
|
197
197
|
}, [renderInputComponent, resolvedMetaTreeNode, innerValue]);
|
|
198
198
|
(0, import_react.useEffect)(() => {
|
|
199
199
|
if (!resolvedMetaTreeNode) return;
|
|
200
|
-
if (!Array.isArray(resolvedMetaTree) ||
|
|
200
|
+
if (!Array.isArray(resolvedMetaTree) || innerValue == null) return;
|
|
201
201
|
const finalValue = (resolveValueFromPath == null ? void 0 : resolveValueFromPath(resolvedMetaTreeNode)) || innerValue;
|
|
202
202
|
emitChange(finalValue, resolvedMetaTreeNode);
|
|
203
203
|
setCurrentMetaTreeNode(resolvedMetaTreeNode);
|
|
@@ -265,7 +265,7 @@ const VariableInputComponent = /* @__PURE__ */ __name(({
|
|
|
265
265
|
}, [restProps]);
|
|
266
266
|
const inputProps = (0, import_react.useMemo)(() => {
|
|
267
267
|
const baseProps = {
|
|
268
|
-
value: innerValue
|
|
268
|
+
value: innerValue ?? "",
|
|
269
269
|
onChange: handleInputChange,
|
|
270
270
|
disabled
|
|
271
271
|
};
|
package/lib/flowContext.d.ts
CHANGED
package/lib/flowContext.js
CHANGED
|
@@ -772,6 +772,16 @@ const _FlowEngineContext = class _FlowEngineContext extends BaseFlowEngineContex
|
|
|
772
772
|
const inputFromMeta = await collectFromMeta();
|
|
773
773
|
const autoInput = { ...inputFromMeta };
|
|
774
774
|
const autoContextParams = Object.keys(autoInput).length ? (0, import_serverContextParams.buildServerContextParams)(this, autoInput) : void 0;
|
|
775
|
+
if (!autoContextParams) {
|
|
776
|
+
const keys = Object.keys(serverVarPaths);
|
|
777
|
+
const allOptional = keys.length > 0 && keys.every((k) => {
|
|
778
|
+
var _a2;
|
|
779
|
+
return (_a2 = this.getPropertyOptions(k)) == null ? void 0 : _a2.serverOnlyWhenContextParams;
|
|
780
|
+
});
|
|
781
|
+
if (allOptional) {
|
|
782
|
+
return (0, import_utils.resolveExpressions)(template, this);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
775
785
|
if (this.api) {
|
|
776
786
|
try {
|
|
777
787
|
serverResolved = await (0, import_params_resolvers.enqueueVariablesResolve)(this, {
|
|
@@ -58,6 +58,10 @@ export declare class ForkFlowModel<TMaster extends FlowModel = FlowModel> {
|
|
|
58
58
|
* 修改局部 props,仅影响当前 fork
|
|
59
59
|
*/
|
|
60
60
|
setProps(key: string | IModelComponentProps, value?: any): void;
|
|
61
|
+
/**
|
|
62
|
+
* 清理局部 props,仅影响当前 fork
|
|
63
|
+
*/
|
|
64
|
+
clearProps(): {};
|
|
61
65
|
/**
|
|
62
66
|
* render 依旧使用 master 的方法,但合并后的 props 需要透传
|
|
63
67
|
*/
|
|
@@ -179,6 +179,12 @@ const _ForkFlowModel = class _ForkFlowModel {
|
|
|
179
179
|
this.localProps = { ...this.localProps, ...key };
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* 清理局部 props,仅影响当前 fork
|
|
184
|
+
*/
|
|
185
|
+
clearProps() {
|
|
186
|
+
return this.localProps = {};
|
|
187
|
+
}
|
|
182
188
|
/**
|
|
183
189
|
* render 依旧使用 master 的方法,但合并后的 props 需要透传
|
|
184
190
|
*/
|
|
@@ -178,6 +178,11 @@ const _MultiRecordResource = class _MultiRecordResource extends import_baseRecor
|
|
|
178
178
|
options
|
|
179
179
|
);
|
|
180
180
|
await this.runAction("destroy", config);
|
|
181
|
+
const currentPage = this.getPage();
|
|
182
|
+
const lastPage = Math.ceil((this.getCount() - import_lodash.default.castArray(filterByTk).length) / this.getPageSize());
|
|
183
|
+
if (currentPage > lastPage) {
|
|
184
|
+
this.setPage(lastPage);
|
|
185
|
+
}
|
|
181
186
|
await this.refresh();
|
|
182
187
|
}
|
|
183
188
|
setItem(index, newDataItem) {
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
import type { Collection } from '../data-source';
|
|
10
|
+
import type { FlowContext, PropertyMetaFactory } from '../flowContext';
|
|
11
|
+
/**
|
|
12
|
+
* 创建一个用于“对象类变量”(如 formValues / currentObject)的 `resolveOnServer` 判定函数。
|
|
13
|
+
* 仅当访问路径以“关联字段名”开头(且继续访问其子属性)时,返回 true 交由服务端解析;
|
|
14
|
+
* 否则在前端解析即可。
|
|
15
|
+
*
|
|
16
|
+
* @param collectionAccessor 返回当前对象所在collection
|
|
17
|
+
* @returns `(subPath) => boolean` 判断是否需要服务端解析
|
|
18
|
+
*/
|
|
19
|
+
export declare function createAssociationSubpathResolver(collectionAccessor: () => Collection | null): (subPath: string) => boolean;
|
|
20
|
+
/**
|
|
21
|
+
* 构建“对象类变量”的 PropertyMetaFactory:
|
|
22
|
+
* - 暴露集合字段结构(通过 createCollectionContextMeta)用于变量选择器;
|
|
23
|
+
* - 提供 buildVariablesParams:基于对象当前值,收集所有“已选择的关联字段”
|
|
24
|
+
* 以便服务端在 variables:resolve 时按需补全关联数据。
|
|
25
|
+
*
|
|
26
|
+
* @param collectionAccessor 获取集合对象,用于字段/元信息来源
|
|
27
|
+
* @param title 变量组标题(用于 UI 展示)
|
|
28
|
+
* @param valueAccessor 获取当前对象值(如 ctx.form.getFieldsValue() / ctx.currentObject)
|
|
29
|
+
* @returns PropertyMetaFactory
|
|
30
|
+
*/
|
|
31
|
+
export declare function createAssociationAwareObjectMetaFactory(collectionAccessor: () => Collection | null, title: string, valueAccessor: (ctx: FlowContext) => any): PropertyMetaFactory;
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
var __defProp = Object.defineProperty;
|
|
11
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
12
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
13
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
14
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
15
|
+
var __export = (target, all) => {
|
|
16
|
+
for (var name in all)
|
|
17
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
18
|
+
};
|
|
19
|
+
var __copyProps = (to, from, except, desc) => {
|
|
20
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
21
|
+
for (let key of __getOwnPropNames(from))
|
|
22
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
23
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
24
|
+
}
|
|
25
|
+
return to;
|
|
26
|
+
};
|
|
27
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
28
|
+
var associationObjectVariable_exports = {};
|
|
29
|
+
__export(associationObjectVariable_exports, {
|
|
30
|
+
createAssociationAwareObjectMetaFactory: () => createAssociationAwareObjectMetaFactory,
|
|
31
|
+
createAssociationSubpathResolver: () => createAssociationSubpathResolver
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(associationObjectVariable_exports);
|
|
34
|
+
var import_createCollectionContextMeta = require("./createCollectionContextMeta");
|
|
35
|
+
function baseFieldNameOf(subPath) {
|
|
36
|
+
if (!subPath) return void 0;
|
|
37
|
+
const m = subPath.match(/^([^.[]+)/);
|
|
38
|
+
return m == null ? void 0 : m[1];
|
|
39
|
+
}
|
|
40
|
+
__name(baseFieldNameOf, "baseFieldNameOf");
|
|
41
|
+
function findFieldByName(collection, name) {
|
|
42
|
+
var _a;
|
|
43
|
+
if (!collection || !name) return void 0;
|
|
44
|
+
const direct = collection.getField(name);
|
|
45
|
+
if (direct) return direct;
|
|
46
|
+
const fields = ((_a = collection.getFields) == null ? void 0 : _a.call(collection)) || [];
|
|
47
|
+
return fields.find((f) => f.name === name);
|
|
48
|
+
}
|
|
49
|
+
__name(findFieldByName, "findFieldByName");
|
|
50
|
+
function toFilterByTk(value, primaryKey) {
|
|
51
|
+
if (value == null) return void 0;
|
|
52
|
+
if (Array.isArray(primaryKey)) {
|
|
53
|
+
if (typeof value !== "object" || !value) return void 0;
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const k of primaryKey) {
|
|
56
|
+
const v = value[k];
|
|
57
|
+
if (typeof v === "undefined" || v === null) return void 0;
|
|
58
|
+
out[k] = v;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
if (typeof value === "string" || typeof value === "number") return value;
|
|
63
|
+
if (typeof value === "object") {
|
|
64
|
+
return value[primaryKey];
|
|
65
|
+
}
|
|
66
|
+
return void 0;
|
|
67
|
+
}
|
|
68
|
+
__name(toFilterByTk, "toFilterByTk");
|
|
69
|
+
function createAssociationSubpathResolver(collectionAccessor) {
|
|
70
|
+
return (p) => {
|
|
71
|
+
if (!p || !p.includes(".")) return false;
|
|
72
|
+
const base = baseFieldNameOf(p);
|
|
73
|
+
if (!base) return false;
|
|
74
|
+
const collection = collectionAccessor();
|
|
75
|
+
const field = findFieldByName(collection, base);
|
|
76
|
+
return !!(field == null ? void 0 : field.isAssociationField());
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
__name(createAssociationSubpathResolver, "createAssociationSubpathResolver");
|
|
80
|
+
function createAssociationAwareObjectMetaFactory(collectionAccessor, title, valueAccessor) {
|
|
81
|
+
const baseFactory = (0, import_createCollectionContextMeta.createCollectionContextMeta)(collectionAccessor, title);
|
|
82
|
+
const factory = /* @__PURE__ */ __name(async () => {
|
|
83
|
+
const base = await baseFactory();
|
|
84
|
+
if (!base) return null;
|
|
85
|
+
const meta = {
|
|
86
|
+
...base,
|
|
87
|
+
buildVariablesParams: /* @__PURE__ */ __name((ctx) => {
|
|
88
|
+
var _a;
|
|
89
|
+
const collection = collectionAccessor();
|
|
90
|
+
const obj = valueAccessor(ctx);
|
|
91
|
+
if (!collection || !obj || typeof obj !== "object") return {};
|
|
92
|
+
const params = {};
|
|
93
|
+
const fields = ((_a = collection.getFields) == null ? void 0 : _a.call(collection)) || [];
|
|
94
|
+
for (const field of fields) {
|
|
95
|
+
const name = field.name;
|
|
96
|
+
if (!name) continue;
|
|
97
|
+
if (!field.isAssociationField()) continue;
|
|
98
|
+
const target = field.target;
|
|
99
|
+
const targetCollection = field.targetCollection;
|
|
100
|
+
if (!target || !targetCollection) continue;
|
|
101
|
+
const primaryKey = targetCollection.filterTargetKey;
|
|
102
|
+
const associationValue = obj[name];
|
|
103
|
+
if (associationValue == null) continue;
|
|
104
|
+
if (Array.isArray(associationValue)) {
|
|
105
|
+
const ids = associationValue.map((item) => toFilterByTk(item, primaryKey)).filter((v) => v != null);
|
|
106
|
+
if (ids.length) {
|
|
107
|
+
params[name] = {
|
|
108
|
+
collection: target,
|
|
109
|
+
dataSourceKey: targetCollection.dataSourceKey,
|
|
110
|
+
filterByTk: ids
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
const id = toFilterByTk(associationValue, primaryKey);
|
|
115
|
+
if (id != null) {
|
|
116
|
+
params[name] = {
|
|
117
|
+
collection: target,
|
|
118
|
+
dataSourceKey: targetCollection.dataSourceKey,
|
|
119
|
+
filterByTk: id
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return params;
|
|
125
|
+
}, "buildVariablesParams")
|
|
126
|
+
};
|
|
127
|
+
return meta;
|
|
128
|
+
}, "factory");
|
|
129
|
+
factory.title = title;
|
|
130
|
+
return factory;
|
|
131
|
+
}
|
|
132
|
+
__name(createAssociationAwareObjectMetaFactory, "createAssociationAwareObjectMetaFactory");
|
|
133
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
134
|
+
0 && (module.exports = {
|
|
135
|
+
createAssociationAwareObjectMetaFactory,
|
|
136
|
+
createAssociationSubpathResolver
|
|
137
|
+
});
|
package/lib/utils/index.d.ts
CHANGED
|
@@ -15,9 +15,11 @@ export { resolveCreateModelOptions, resolveDefaultParams, resolveExpressions } f
|
|
|
15
15
|
export { compileUiSchema, resolveStepUiSchema, resolveUiMode } from './schema-utils';
|
|
16
16
|
export { setupRuntimeContextSteps } from './setupRuntimeContextSteps';
|
|
17
17
|
export { createCollectionContextMeta } from './createCollectionContextMeta';
|
|
18
|
+
export { createAssociationAwareObjectMetaFactory, createAssociationSubpathResolver } from './associationObjectVariable';
|
|
18
19
|
export { buildRecordMeta, collectContextParamsForTemplate, createCurrentRecordMetaFactory, createRecordMetaFactory, extractUsedVariableNames, extractUsedVariablePaths, inferRecordRef, type RecordParamsBuilder, } from './variablesParams';
|
|
19
20
|
export { extractPropertyPath, formatPathToVariable, isVariableExpression } from './context';
|
|
20
21
|
export { clearAutoFlowError, getAutoFlowError, setAutoFlowError, type AutoFlowError } from './autoFlowError';
|
|
21
22
|
export { parsePathnameToViewParams, type ViewParam } from './parsePathnameToViewParams';
|
|
22
23
|
export { buildSettingsViewInputArgs } from './buildSettingsViewInputArgs';
|
|
23
24
|
export { createSafeDocument, createSafeWindow, createSafeNavigator } from './safeGlobals';
|
|
25
|
+
export { pruneFilter } from './pruneFilter';
|
package/lib/utils/index.js
CHANGED
|
@@ -36,6 +36,8 @@ __export(utils_exports, {
|
|
|
36
36
|
clearAutoFlowError: () => import_autoFlowError.clearAutoFlowError,
|
|
37
37
|
collectContextParamsForTemplate: () => import_variablesParams.collectContextParamsForTemplate,
|
|
38
38
|
compileUiSchema: () => import_schema_utils.compileUiSchema,
|
|
39
|
+
createAssociationAwareObjectMetaFactory: () => import_associationObjectVariable.createAssociationAwareObjectMetaFactory,
|
|
40
|
+
createAssociationSubpathResolver: () => import_associationObjectVariable.createAssociationSubpathResolver,
|
|
39
41
|
createCollectionContextMeta: () => import_createCollectionContextMeta.createCollectionContextMeta,
|
|
40
42
|
createCurrentRecordMetaFactory: () => import_variablesParams.createCurrentRecordMetaFactory,
|
|
41
43
|
createRecordMetaFactory: () => import_variablesParams.createRecordMetaFactory,
|
|
@@ -54,6 +56,7 @@ __export(utils_exports, {
|
|
|
54
56
|
isInheritedFrom: () => import_inheritance.isInheritedFrom,
|
|
55
57
|
isVariableExpression: () => import_context.isVariableExpression,
|
|
56
58
|
parsePathnameToViewParams: () => import_parsePathnameToViewParams.parsePathnameToViewParams,
|
|
59
|
+
pruneFilter: () => import_pruneFilter.pruneFilter,
|
|
57
60
|
resolveCreateModelOptions: () => import_params_resolvers.resolveCreateModelOptions,
|
|
58
61
|
resolveDefaultParams: () => import_params_resolvers.resolveDefaultParams,
|
|
59
62
|
resolveExpressions: () => import_params_resolvers.resolveExpressions,
|
|
@@ -73,12 +76,14 @@ var import_params_resolvers = require("./params-resolvers");
|
|
|
73
76
|
var import_schema_utils = require("./schema-utils");
|
|
74
77
|
var import_setupRuntimeContextSteps = require("./setupRuntimeContextSteps");
|
|
75
78
|
var import_createCollectionContextMeta = require("./createCollectionContextMeta");
|
|
79
|
+
var import_associationObjectVariable = require("./associationObjectVariable");
|
|
76
80
|
var import_variablesParams = require("./variablesParams");
|
|
77
81
|
var import_context = require("./context");
|
|
78
82
|
var import_autoFlowError = require("./autoFlowError");
|
|
79
83
|
var import_parsePathnameToViewParams = require("./parsePathnameToViewParams");
|
|
80
84
|
var import_buildSettingsViewInputArgs = require("./buildSettingsViewInputArgs");
|
|
81
85
|
var import_safeGlobals = require("./safeGlobals");
|
|
86
|
+
var import_pruneFilter = require("./pruneFilter");
|
|
82
87
|
// Annotate the CommonJS export names for ESM import in node:
|
|
83
88
|
0 && (module.exports = {
|
|
84
89
|
BLOCK_GROUP_CONFIGS,
|
|
@@ -91,6 +96,8 @@ var import_safeGlobals = require("./safeGlobals");
|
|
|
91
96
|
clearAutoFlowError,
|
|
92
97
|
collectContextParamsForTemplate,
|
|
93
98
|
compileUiSchema,
|
|
99
|
+
createAssociationAwareObjectMetaFactory,
|
|
100
|
+
createAssociationSubpathResolver,
|
|
94
101
|
createCollectionContextMeta,
|
|
95
102
|
createCurrentRecordMetaFactory,
|
|
96
103
|
createRecordMetaFactory,
|
|
@@ -109,6 +116,7 @@ var import_safeGlobals = require("./safeGlobals");
|
|
|
109
116
|
isInheritedFrom,
|
|
110
117
|
isVariableExpression,
|
|
111
118
|
parsePathnameToViewParams,
|
|
119
|
+
pruneFilter,
|
|
112
120
|
resolveCreateModelOptions,
|
|
113
121
|
resolveDefaultParams,
|
|
114
122
|
resolveExpressions,
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
* 递归清理筛选对象中的“空值”。
|
|
11
|
+
*
|
|
12
|
+
* 规则:
|
|
13
|
+
* - 移除:`null`、`undefined`、空字符串 `''`、空数组 `[]`、空对象 `{}`;
|
|
14
|
+
* - 保留:`false`、`0` 等有意义的“假值”。
|
|
15
|
+
*
|
|
16
|
+
* 注意:当清理后整体变为空结构时,返回 `undefined`,用于表示无需下发该条件。
|
|
17
|
+
*
|
|
18
|
+
* @param input 任意筛选对象/数组/原始值
|
|
19
|
+
* @returns 清理后的对象;若为空则返回 `undefined`
|
|
20
|
+
*/
|
|
21
|
+
export declare function pruneFilter<T = any>(input: T): T | undefined;
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
var __defProp = Object.defineProperty;
|
|
11
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
12
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
13
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
14
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
15
|
+
var __export = (target, all) => {
|
|
16
|
+
for (var name in all)
|
|
17
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
18
|
+
};
|
|
19
|
+
var __copyProps = (to, from, except, desc) => {
|
|
20
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
21
|
+
for (let key of __getOwnPropNames(from))
|
|
22
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
23
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
24
|
+
}
|
|
25
|
+
return to;
|
|
26
|
+
};
|
|
27
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
28
|
+
var pruneFilter_exports = {};
|
|
29
|
+
__export(pruneFilter_exports, {
|
|
30
|
+
pruneFilter: () => pruneFilter
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(pruneFilter_exports);
|
|
33
|
+
function pruneFilter(input) {
|
|
34
|
+
if (Array.isArray(input)) {
|
|
35
|
+
const arr = input.map((v) => pruneFilter(v)).filter((v) => v !== void 0);
|
|
36
|
+
return arr.length ? arr : void 0;
|
|
37
|
+
}
|
|
38
|
+
if (input && typeof input === "object") {
|
|
39
|
+
const out = {};
|
|
40
|
+
Object.keys(input).forEach((k) => {
|
|
41
|
+
const v = pruneFilter(input[k]);
|
|
42
|
+
if (v !== void 0) out[k] = v;
|
|
43
|
+
});
|
|
44
|
+
return Object.keys(out).length ? out : void 0;
|
|
45
|
+
}
|
|
46
|
+
return input === null || input === void 0 || typeof input === "string" && input === "" ? void 0 : input;
|
|
47
|
+
}
|
|
48
|
+
__name(pruneFilter, "pruneFilter");
|
|
49
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
50
|
+
0 && (module.exports = {
|
|
51
|
+
pruneFilter
|
|
52
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/flow-engine",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.34",
|
|
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.0.0-alpha.
|
|
12
|
-
"@nocobase/shared": "2.0.0-alpha.
|
|
11
|
+
"@nocobase/sdk": "2.0.0-alpha.34",
|
|
12
|
+
"@nocobase/shared": "2.0.0-alpha.34",
|
|
13
13
|
"ahooks": "^3.7.2",
|
|
14
14
|
"dompurify": "^3.0.2",
|
|
15
15
|
"lodash": "^4.x",
|
|
@@ -35,5 +35,5 @@
|
|
|
35
35
|
],
|
|
36
36
|
"author": "NocoBase Team",
|
|
37
37
|
"license": "AGPL-3.0",
|
|
38
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "d7bda14bac775be7ef207cb25986511740b2ed70"
|
|
39
39
|
}
|
|
@@ -1282,6 +1282,58 @@ describe('FlowContext resolveOnServer selective server resolution', () => {
|
|
|
1282
1282
|
// server was called
|
|
1283
1283
|
expect(api.request).toHaveBeenCalledTimes(1);
|
|
1284
1284
|
});
|
|
1285
|
+
|
|
1286
|
+
it('skips server call when all vars require contextParams but none provided', async () => {
|
|
1287
|
+
const engine = new FlowEngine();
|
|
1288
|
+
const api = { request: vi.fn() } as any;
|
|
1289
|
+
engine.context.defineProperty('api', { value: api });
|
|
1290
|
+
|
|
1291
|
+
engine.context.defineProperty('foo', {
|
|
1292
|
+
value: { a: 1 },
|
|
1293
|
+
resolveOnServer: true,
|
|
1294
|
+
serverOnlyWhenContextParams: true,
|
|
1295
|
+
meta: async () => ({
|
|
1296
|
+
type: 'object',
|
|
1297
|
+
title: 'Foo',
|
|
1298
|
+
// no buildVariablesParams -> empty contextParams
|
|
1299
|
+
}),
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
const tpl = { a: '{{ ctx.foo.a }}' } as any;
|
|
1303
|
+
const out = await (engine.context as any).resolveJsonTemplate(tpl);
|
|
1304
|
+
expect(out).toEqual({ a: 1 });
|
|
1305
|
+
// skipped server, because all server vars (only foo) require contextParams but none present
|
|
1306
|
+
expect(api.request).not.toHaveBeenCalled();
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
it('still calls server when at least one var has contextParams (even if others require contextParams but none)', async () => {
|
|
1310
|
+
const engine = new FlowEngine();
|
|
1311
|
+
const api = { request: vi.fn(async () => ({ data: { ok: true } })) } as any;
|
|
1312
|
+
engine.context.defineProperty('api', { value: api });
|
|
1313
|
+
|
|
1314
|
+
// foo: requires contextParams but none will be provided
|
|
1315
|
+
engine.context.defineProperty('foo', {
|
|
1316
|
+
value: { a: 1 },
|
|
1317
|
+
resolveOnServer: true,
|
|
1318
|
+
serverOnlyWhenContextParams: true,
|
|
1319
|
+
meta: async () => ({ type: 'object', title: 'Foo' }),
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
// user: provides contextParams via builder
|
|
1323
|
+
engine.context.defineProperty('user', {
|
|
1324
|
+
value: { id: 9 },
|
|
1325
|
+
resolveOnServer: true,
|
|
1326
|
+
meta: async () => ({
|
|
1327
|
+
type: 'object',
|
|
1328
|
+
title: 'User',
|
|
1329
|
+
buildVariablesParams: () => ({ collection: 'users', filterByTk: 9, dataSourceKey: 'main' }),
|
|
1330
|
+
}),
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
const tpl = { a: '{{ ctx.foo.a }}', u: '{{ ctx.user.id }}' } as any;
|
|
1334
|
+
await (engine.context as any).resolveJsonTemplate(tpl);
|
|
1335
|
+
expect(api.request).toHaveBeenCalledTimes(1);
|
|
1336
|
+
});
|
|
1285
1337
|
});
|
|
1286
1338
|
|
|
1287
1339
|
describe('FlowContext.getPropertyOptions()', () => {
|
|
@@ -0,0 +1,403 @@
|
|
|
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, vi } from 'vitest';
|
|
11
|
+
import { FlowContext } from '../flowContext';
|
|
12
|
+
import { FlowEngine } from '../flowEngine';
|
|
13
|
+
import {
|
|
14
|
+
createAssociationAwareObjectMetaFactory,
|
|
15
|
+
createAssociationSubpathResolver,
|
|
16
|
+
} from '../utils/associationObjectVariable';
|
|
17
|
+
|
|
18
|
+
function setupEngineWithCollections() {
|
|
19
|
+
const engine = new FlowEngine();
|
|
20
|
+
const ds = engine.context.dataSourceManager.getDataSource('main');
|
|
21
|
+
|
|
22
|
+
// 真实集合定义,确保 targetCollection / filterTargetKey 等行为一致
|
|
23
|
+
ds.addCollection({
|
|
24
|
+
name: 'users',
|
|
25
|
+
filterTargetKey: 'id',
|
|
26
|
+
fields: [
|
|
27
|
+
{ name: 'id', type: 'integer', interface: 'number' },
|
|
28
|
+
{ name: 'name', type: 'string', interface: 'text' },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
ds.addCollection({
|
|
32
|
+
name: 'tags',
|
|
33
|
+
filterTargetKey: 'id',
|
|
34
|
+
fields: [
|
|
35
|
+
{ name: 'id', type: 'integer', interface: 'number' },
|
|
36
|
+
{ name: 'name', type: 'string', interface: 'text' },
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
ds.addCollection({
|
|
40
|
+
name: 'posts',
|
|
41
|
+
filterTargetKey: 'id',
|
|
42
|
+
fields: [
|
|
43
|
+
{ name: 'title', type: 'string', interface: 'text' },
|
|
44
|
+
{ name: 'author', type: 'belongsTo', target: 'users', interface: 'm2o' },
|
|
45
|
+
{ name: 'tags', type: 'belongsToMany', target: 'tags', interface: 'm2m' },
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return { engine, ds, collection: ds.getCollection('posts') } as const;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('objectVariable utilities', () => {
|
|
53
|
+
it('createAssociationSubpathResolver should detect association subpaths', () => {
|
|
54
|
+
const { collection } = setupEngineWithCollections();
|
|
55
|
+
const resolver = createAssociationSubpathResolver(() => collection);
|
|
56
|
+
expect(resolver('author.name')).toBe(true);
|
|
57
|
+
expect(resolver('tags[0].name')).toBe(true);
|
|
58
|
+
expect(resolver('title')).toBe(false);
|
|
59
|
+
expect(resolver('title.length')).toBe(false);
|
|
60
|
+
expect(resolver('unknown.name')).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('createAssociationAwareObjectMetaFactory should build params for toOne/toMany', async () => {
|
|
64
|
+
const { collection } = setupEngineWithCollections();
|
|
65
|
+
|
|
66
|
+
const obj1 = { title: 'hello', author: 1 };
|
|
67
|
+
const metaFactory1 = createAssociationAwareObjectMetaFactory(
|
|
68
|
+
() => collection,
|
|
69
|
+
'Current object',
|
|
70
|
+
() => obj1,
|
|
71
|
+
);
|
|
72
|
+
const meta1 = await metaFactory1();
|
|
73
|
+
const ctx1 = new FlowContext();
|
|
74
|
+
const params1 = await (meta1 as any).buildVariablesParams(ctx1);
|
|
75
|
+
expect(params1).toEqual({
|
|
76
|
+
author: { collection: 'users', dataSourceKey: 'main', filterByTk: 1 },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const obj2 = { author: { id: 2 }, tags: [{ id: 11 }, { id: 12 }] };
|
|
80
|
+
const metaFactory2 = createAssociationAwareObjectMetaFactory(
|
|
81
|
+
() => collection,
|
|
82
|
+
'Current object',
|
|
83
|
+
() => obj2,
|
|
84
|
+
);
|
|
85
|
+
const meta2 = await metaFactory2();
|
|
86
|
+
const ctx2 = new FlowContext();
|
|
87
|
+
const params2 = await (meta2 as any).buildVariablesParams(ctx2);
|
|
88
|
+
expect(params2).toEqual({
|
|
89
|
+
author: { collection: 'users', dataSourceKey: 'main', filterByTk: 2 },
|
|
90
|
+
tags: { collection: 'tags', dataSourceKey: 'main', filterByTk: [11, 12] },
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('integrates with FlowContext.resolveJsonTemplate to call variables:resolve with flattened contextParams', async () => {
|
|
95
|
+
const { engine, collection } = setupEngineWithCollections();
|
|
96
|
+
const obj = { author: 1 };
|
|
97
|
+
const ctx = engine.context as any;
|
|
98
|
+
|
|
99
|
+
// Provide API stub to intercept variables:resolve
|
|
100
|
+
const calls: any[] = [];
|
|
101
|
+
(ctx as any).api = {
|
|
102
|
+
request: vi.fn(async ({ url, data, method }) => {
|
|
103
|
+
calls.push({ url, data, method });
|
|
104
|
+
const batch = (data?.values?.batch as any[]) || [];
|
|
105
|
+
const results = batch.map((it) => ({ id: it.id, data: it.template }));
|
|
106
|
+
return { data: { data: { results } } };
|
|
107
|
+
}),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Define object-like variable
|
|
111
|
+
const metaFactory = createAssociationAwareObjectMetaFactory(
|
|
112
|
+
() => collection,
|
|
113
|
+
'Current object',
|
|
114
|
+
() => obj,
|
|
115
|
+
);
|
|
116
|
+
ctx.defineProperty('obj', {
|
|
117
|
+
get: () => obj,
|
|
118
|
+
cache: false,
|
|
119
|
+
meta: metaFactory,
|
|
120
|
+
resolveOnServer: createAssociationSubpathResolver(() => collection),
|
|
121
|
+
serverOnlyWhenContextParams: true,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const template = { x: '{{ ctx.obj.author.name }}' } as any;
|
|
125
|
+
await (ctx as any).resolveJsonTemplate(template);
|
|
126
|
+
|
|
127
|
+
// Assert variables:resolve was called with proper flattened contextParams
|
|
128
|
+
expect((ctx as any).api.request).toHaveBeenCalled();
|
|
129
|
+
const call = calls.find((c) => c.url === 'variables:resolve');
|
|
130
|
+
expect(call).toBeTruthy();
|
|
131
|
+
const batch0 = call.data?.values?.batch?.[0];
|
|
132
|
+
expect(batch0?.contextParams).toBeTruthy();
|
|
133
|
+
// Flattened key should be 'obj.author'
|
|
134
|
+
const cp = batch0.contextParams as Record<string, any>;
|
|
135
|
+
const keys = Object.keys(cp || {});
|
|
136
|
+
expect(keys).toContain('obj.author');
|
|
137
|
+
expect(cp['obj.author']).toMatchObject({ collection: 'users', filterByTk: 1 });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('does not call server when only non-association fields are referenced', async () => {
|
|
141
|
+
const { engine, collection } = setupEngineWithCollections();
|
|
142
|
+
const obj = { title: 'hello', author: 1 };
|
|
143
|
+
const ctx = engine.context as any;
|
|
144
|
+
|
|
145
|
+
(ctx as any).api = { request: vi.fn() };
|
|
146
|
+
|
|
147
|
+
const metaFactory = createAssociationAwareObjectMetaFactory(
|
|
148
|
+
() => collection,
|
|
149
|
+
'Current object',
|
|
150
|
+
() => obj,
|
|
151
|
+
);
|
|
152
|
+
ctx.defineProperty('obj', {
|
|
153
|
+
get: () => obj,
|
|
154
|
+
cache: false,
|
|
155
|
+
meta: metaFactory,
|
|
156
|
+
resolveOnServer: createAssociationSubpathResolver(() => collection),
|
|
157
|
+
serverOnlyWhenContextParams: true,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const template = { x: '{{ ctx.obj.title }}' } as any;
|
|
161
|
+
const resolved = await (ctx as any).resolveJsonTemplate(template);
|
|
162
|
+
expect(resolved).toEqual({ x: 'hello' });
|
|
163
|
+
expect((ctx as any).api.request).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('buildVariablesParams merges ids and objects in toMany and filters falsy', async () => {
|
|
167
|
+
const { collection } = setupEngineWithCollections();
|
|
168
|
+
const obj = { tags: [11, { id: 12 }, {}, null, undefined, { id: null }] };
|
|
169
|
+
const mf = createAssociationAwareObjectMetaFactory(
|
|
170
|
+
() => collection,
|
|
171
|
+
'Current object',
|
|
172
|
+
() => obj,
|
|
173
|
+
);
|
|
174
|
+
const meta = await mf();
|
|
175
|
+
const ctx = new FlowContext();
|
|
176
|
+
const params = await (meta as any).buildVariablesParams(ctx);
|
|
177
|
+
expect(params).toMatchObject({ tags: { collection: 'tags', filterByTk: [11, 12] } });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('supports custom primary key via filterTargetKey', async () => {
|
|
181
|
+
const engine = new FlowEngine();
|
|
182
|
+
const ds = engine.context.dataSourceManager.getDataSource('main');
|
|
183
|
+
ds.addCollection({
|
|
184
|
+
name: 'profiles',
|
|
185
|
+
filterTargetKey: 'uuid',
|
|
186
|
+
fields: [
|
|
187
|
+
{ name: 'uuid', type: 'string', interface: 'text' },
|
|
188
|
+
{ name: 'name', type: 'string', interface: 'text' },
|
|
189
|
+
],
|
|
190
|
+
});
|
|
191
|
+
ds.addCollection({
|
|
192
|
+
name: 'articles',
|
|
193
|
+
filterTargetKey: 'id',
|
|
194
|
+
fields: [
|
|
195
|
+
{ name: 'id', type: 'integer', interface: 'number' },
|
|
196
|
+
{ name: 'editor', type: 'belongsTo', target: 'profiles', interface: 'm2o' },
|
|
197
|
+
],
|
|
198
|
+
});
|
|
199
|
+
const collection = ds.getCollection('articles');
|
|
200
|
+
const obj = { editor: { uuid: 'p-1' } };
|
|
201
|
+
const mf = createAssociationAwareObjectMetaFactory(
|
|
202
|
+
() => collection,
|
|
203
|
+
'Current object',
|
|
204
|
+
() => obj,
|
|
205
|
+
);
|
|
206
|
+
const meta = await mf();
|
|
207
|
+
const ctx = new FlowContext();
|
|
208
|
+
const params = await (meta as any).buildVariablesParams(ctx);
|
|
209
|
+
expect(params).toMatchObject({ editor: { collection: 'profiles', filterByTk: 'p-1' } });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('supports composite primary key via filterTargetKey array (toOne/toMany)', async () => {
|
|
213
|
+
const engine = new FlowEngine();
|
|
214
|
+
const ds = engine.context.dataSourceManager.getDataSource('main');
|
|
215
|
+
ds.addCollection({
|
|
216
|
+
name: 'composites',
|
|
217
|
+
filterTargetKey: ['country', 'code'],
|
|
218
|
+
fields: [
|
|
219
|
+
{ name: 'country', type: 'string', interface: 'text' },
|
|
220
|
+
{ name: 'code', type: 'string', interface: 'text' },
|
|
221
|
+
{ name: 'name', type: 'string', interface: 'text' },
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
ds.addCollection({
|
|
225
|
+
name: 'items',
|
|
226
|
+
filterTargetKey: 'id',
|
|
227
|
+
fields: [
|
|
228
|
+
{ name: 'id', type: 'integer', interface: 'number' },
|
|
229
|
+
{ name: 'ref', type: 'belongsTo', target: 'composites', interface: 'm2o' },
|
|
230
|
+
{ name: 'refs', type: 'belongsToMany', target: 'composites', interface: 'm2m' },
|
|
231
|
+
],
|
|
232
|
+
});
|
|
233
|
+
const collection = ds.getCollection('items');
|
|
234
|
+
|
|
235
|
+
// toOne
|
|
236
|
+
const obj1 = { ref: { country: 'US', code: '001', name: 'United States' } };
|
|
237
|
+
const mf1 = createAssociationAwareObjectMetaFactory(
|
|
238
|
+
() => collection,
|
|
239
|
+
'Current object',
|
|
240
|
+
() => obj1,
|
|
241
|
+
);
|
|
242
|
+
const meta1 = await mf1();
|
|
243
|
+
const ctx1 = new FlowContext();
|
|
244
|
+
const params1 = await (meta1 as any).buildVariablesParams(ctx1);
|
|
245
|
+
expect(params1).toMatchObject({
|
|
246
|
+
ref: { collection: 'composites', filterByTk: { country: 'US', code: '001' } },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// toMany with mixed valid/invalid entries
|
|
250
|
+
const obj2 = {
|
|
251
|
+
refs: [
|
|
252
|
+
{ country: 'CN', code: '086' },
|
|
253
|
+
{ country: 'JP' }, // missing code => ignored
|
|
254
|
+
'raw', // unsupported raw value for composite => ignored
|
|
255
|
+
],
|
|
256
|
+
};
|
|
257
|
+
const mf2 = createAssociationAwareObjectMetaFactory(
|
|
258
|
+
() => collection,
|
|
259
|
+
'Current object',
|
|
260
|
+
() => obj2,
|
|
261
|
+
);
|
|
262
|
+
const meta2 = await mf2();
|
|
263
|
+
const ctx2 = new FlowContext();
|
|
264
|
+
const params2 = await (meta2 as any).buildVariablesParams(ctx2);
|
|
265
|
+
expect(params2).toMatchObject({
|
|
266
|
+
refs: { collection: 'composites', filterByTk: [{ country: 'CN', code: '086' }] },
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('ignores incomplete composite key values when building params', async () => {
|
|
271
|
+
const engine = new FlowEngine();
|
|
272
|
+
const ds = engine.context.dataSourceManager.getDataSource('main');
|
|
273
|
+
ds.addCollection({
|
|
274
|
+
name: 'dept',
|
|
275
|
+
filterTargetKey: ['org', 'id'],
|
|
276
|
+
fields: [
|
|
277
|
+
{ name: 'org', type: 'string', interface: 'text' },
|
|
278
|
+
{ name: 'id', type: 'string', interface: 'text' },
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
ds.addCollection({
|
|
282
|
+
name: 'employees',
|
|
283
|
+
filterTargetKey: 'id',
|
|
284
|
+
fields: [
|
|
285
|
+
{ name: 'id', type: 'integer', interface: 'number' },
|
|
286
|
+
{ name: 'department', type: 'belongsTo', target: 'dept', interface: 'm2o' },
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
const collection = ds.getCollection('employees');
|
|
290
|
+
const obj = { department: { org: 'HQ' } }; // missing id
|
|
291
|
+
const mf = createAssociationAwareObjectMetaFactory(
|
|
292
|
+
() => collection,
|
|
293
|
+
'Current object',
|
|
294
|
+
() => obj,
|
|
295
|
+
);
|
|
296
|
+
const meta = await mf();
|
|
297
|
+
const ctx = new FlowContext();
|
|
298
|
+
const params = await (meta as any).buildVariablesParams(ctx);
|
|
299
|
+
expect(params).toEqual({});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('contextParams only includes used association subpaths (filters out unused)', async () => {
|
|
303
|
+
const { engine, collection } = setupEngineWithCollections();
|
|
304
|
+
const obj = { author: 1, tags: [11, 12] };
|
|
305
|
+
const ctx = engine.context as any;
|
|
306
|
+
const calls: any[] = [];
|
|
307
|
+
(ctx as any).api = {
|
|
308
|
+
request: vi.fn(async ({ url, data, method }) => {
|
|
309
|
+
calls.push({ url, data, method });
|
|
310
|
+
const batch = (data?.values?.batch as any[]) || [];
|
|
311
|
+
const results = batch.map((it) => ({ id: it.id, data: it.template }));
|
|
312
|
+
return { data: { data: { results } } };
|
|
313
|
+
}),
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const metaFactory = createAssociationAwareObjectMetaFactory(
|
|
317
|
+
() => collection,
|
|
318
|
+
'Current object',
|
|
319
|
+
() => obj,
|
|
320
|
+
);
|
|
321
|
+
ctx.defineProperty('obj', {
|
|
322
|
+
get: () => obj,
|
|
323
|
+
cache: false,
|
|
324
|
+
meta: metaFactory,
|
|
325
|
+
resolveOnServer: createAssociationSubpathResolver(() => collection),
|
|
326
|
+
serverOnlyWhenContextParams: true,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// 仅引用 author 子属性,不使用 tags
|
|
330
|
+
const template = { a: '{{ ctx.obj.author.name }}' } as any;
|
|
331
|
+
await (ctx as any).resolveJsonTemplate(template);
|
|
332
|
+
|
|
333
|
+
const call = calls.find((c) => c.url === 'variables:resolve');
|
|
334
|
+
expect(call).toBeTruthy();
|
|
335
|
+
const cp = call.data?.values?.batch?.[0]?.contextParams as Record<string, any>;
|
|
336
|
+
const keys = Object.keys(cp || {});
|
|
337
|
+
expect(keys).toContain('obj.author');
|
|
338
|
+
expect(keys).not.toContain('obj.tags');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('bracket without dot does not call server (tags[0])', async () => {
|
|
342
|
+
const { engine, collection } = setupEngineWithCollections();
|
|
343
|
+
const obj = { tags: [11, 12] };
|
|
344
|
+
const ctx = engine.context as any;
|
|
345
|
+
(ctx as any).api = { request: vi.fn() };
|
|
346
|
+
|
|
347
|
+
const metaFactory = createAssociationAwareObjectMetaFactory(
|
|
348
|
+
() => collection,
|
|
349
|
+
'Current object',
|
|
350
|
+
() => obj,
|
|
351
|
+
);
|
|
352
|
+
ctx.defineProperty('obj', {
|
|
353
|
+
get: () => obj,
|
|
354
|
+
cache: false,
|
|
355
|
+
meta: metaFactory,
|
|
356
|
+
resolveOnServer: createAssociationSubpathResolver(() => collection),
|
|
357
|
+
serverOnlyWhenContextParams: true,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const template = { t0: '{{ ctx.obj.tags[0] }}' } as any;
|
|
361
|
+
const resolved = await (ctx as any).resolveJsonTemplate(template);
|
|
362
|
+
expect(resolved).toEqual({ t0: 11 });
|
|
363
|
+
expect((ctx as any).api.request).not.toHaveBeenCalled();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('no association values => buildVariablesParams returns empty object', async () => {
|
|
367
|
+
const { collection } = setupEngineWithCollections();
|
|
368
|
+
const obj = { title: 'hello' };
|
|
369
|
+
const mf = createAssociationAwareObjectMetaFactory(
|
|
370
|
+
() => collection,
|
|
371
|
+
'Current object',
|
|
372
|
+
() => obj,
|
|
373
|
+
);
|
|
374
|
+
const meta = await mf();
|
|
375
|
+
const ctx = new FlowContext();
|
|
376
|
+
const params = await (meta as any).buildVariablesParams(ctx);
|
|
377
|
+
expect(params).toEqual({});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('top-level association value (no dot) does not call server', async () => {
|
|
381
|
+
const { engine, collection } = setupEngineWithCollections();
|
|
382
|
+
const obj = { author: 7 };
|
|
383
|
+
const ctx = engine.context as any;
|
|
384
|
+
(ctx as any).api = { request: vi.fn() };
|
|
385
|
+
|
|
386
|
+
const metaFactory = createAssociationAwareObjectMetaFactory(
|
|
387
|
+
() => collection,
|
|
388
|
+
'Current object',
|
|
389
|
+
() => obj,
|
|
390
|
+
);
|
|
391
|
+
ctx.defineProperty('obj', {
|
|
392
|
+
get: () => obj,
|
|
393
|
+
cache: false,
|
|
394
|
+
meta: metaFactory,
|
|
395
|
+
resolveOnServer: createAssociationSubpathResolver(() => collection),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const template = { a: '{{ ctx.obj.author }}' } as any;
|
|
399
|
+
const resolved = await (ctx as any).resolveJsonTemplate(template);
|
|
400
|
+
expect(resolved).toEqual({ a: 7 });
|
|
401
|
+
expect((ctx as any).api.request).not.toHaveBeenCalled();
|
|
402
|
+
});
|
|
403
|
+
});
|
|
@@ -139,7 +139,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
139
139
|
// 当 value 存在但 currentMetaTreeNode 还未恢复,尝试按路径逐级加载(支持 children 为函数的场景)
|
|
140
140
|
useEffect(() => {
|
|
141
141
|
const restoreFromValue = async () => {
|
|
142
|
-
if (!Array.isArray(resolvedMetaTree) ||
|
|
142
|
+
if (!Array.isArray(resolvedMetaTree) || value == null) return;
|
|
143
143
|
|
|
144
144
|
// 若已存在且路径匹配,跳过
|
|
145
145
|
if (currentMetaTreeNode) {
|
|
@@ -203,7 +203,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
203
203
|
|
|
204
204
|
useEffect(() => {
|
|
205
205
|
if (!resolvedMetaTreeNode) return;
|
|
206
|
-
if (!Array.isArray(resolvedMetaTree) ||
|
|
206
|
+
if (!Array.isArray(resolvedMetaTree) || innerValue == null) return;
|
|
207
207
|
const finalValue = resolveValueFromPath?.(resolvedMetaTreeNode) || innerValue;
|
|
208
208
|
emitChange(finalValue, resolvedMetaTreeNode);
|
|
209
209
|
setCurrentMetaTreeNode(resolvedMetaTreeNode);
|
|
@@ -286,7 +286,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
286
286
|
|
|
287
287
|
const inputProps = useMemo(() => {
|
|
288
288
|
const baseProps = {
|
|
289
|
-
value: innerValue
|
|
289
|
+
value: innerValue ?? '',
|
|
290
290
|
onChange: handleInputChange,
|
|
291
291
|
disabled,
|
|
292
292
|
};
|
package/src/flowContext.ts
CHANGED
|
@@ -136,6 +136,10 @@ export interface PropertyOptions {
|
|
|
136
136
|
// - boolean: true 表示整个顶层变量交给服务端;false 表示仅前端解析
|
|
137
137
|
// - function: 根据子路径决定是否交给服务端(子路径示例:'record.roles[0].name'、'id'、'')
|
|
138
138
|
resolveOnServer?: boolean | ((subPath: string) => boolean);
|
|
139
|
+
// 优化:当需要服务端解析但本属性在 buildVariablesParams 返回空时,是否跳过调用服务端。
|
|
140
|
+
// - 典型场景:formValues / currentObject 仅在“已选关联值”存在时才需要服务端;否则没有必要请求。
|
|
141
|
+
// - 默认 false:保持兼容,其他变量即使没有 contextParams 也可选择调用服务端。
|
|
142
|
+
serverOnlyWhenContextParams?: boolean;
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
type RouteOptions = {
|
|
@@ -1056,6 +1060,17 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
1056
1060
|
? _buildServerContextParams(this, autoInput)
|
|
1057
1061
|
: undefined;
|
|
1058
1062
|
|
|
1063
|
+
// 优化:若所有需要服务端解析的变量都声明了 “仅当有 contextParams 时才请求服务端”,
|
|
1064
|
+
// 且本次未能构建出任何 contextParams,则跳过服务端请求,回退到前端解析。
|
|
1065
|
+
if (!autoContextParams) {
|
|
1066
|
+
const keys = Object.keys(serverVarPaths);
|
|
1067
|
+
const allOptional =
|
|
1068
|
+
keys.length > 0 && keys.every((k) => this.getPropertyOptions(k)?.serverOnlyWhenContextParams);
|
|
1069
|
+
if (allOptional) {
|
|
1070
|
+
return resolveExpressions(template, this);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1059
1074
|
if (this.api) {
|
|
1060
1075
|
try {
|
|
1061
1076
|
serverResolved = await enqueueVariablesResolve(this as FlowRuntimeContext<FlowModel>, {
|
|
@@ -423,6 +423,24 @@ describe('ForkFlowModel', () => {
|
|
|
423
423
|
});
|
|
424
424
|
});
|
|
425
425
|
|
|
426
|
+
test('should clear local props with clearProps', () => {
|
|
427
|
+
const masterProps = { master: 'value', conflict: 'master' };
|
|
428
|
+
mockMaster.getProps = vi.fn(() => masterProps);
|
|
429
|
+
|
|
430
|
+
// 先设置一些本地属性
|
|
431
|
+
fork.setProps({ local: 'v', conflict: 'local' });
|
|
432
|
+
expect(fork.localProps).toEqual({ initial: 'value', local: 'v', conflict: 'local' });
|
|
433
|
+
|
|
434
|
+
// 调用 clearProps,应当返回一个空对象
|
|
435
|
+
const result = (fork as any).clearProps();
|
|
436
|
+
expect(result).toStrictEqual({});
|
|
437
|
+
expect(fork.localProps).toStrictEqual({});
|
|
438
|
+
|
|
439
|
+
// 清空后,合并的 props 应回退为仅 master 的 props
|
|
440
|
+
expect(fork.getProps()).toEqual(masterProps);
|
|
441
|
+
expect(fork.props).toEqual(masterProps);
|
|
442
|
+
});
|
|
443
|
+
|
|
426
444
|
test('should get merged props from master and local', () => {
|
|
427
445
|
const masterProps = { master: 'value', shared: 'master' };
|
|
428
446
|
const localProps = { local: 'value', shared: 'local' };
|
|
@@ -207,6 +207,13 @@ export class ForkFlowModel<TMaster extends FlowModel = FlowModel> {
|
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* 清理局部 props,仅影响当前 fork
|
|
212
|
+
*/
|
|
213
|
+
clearProps() {
|
|
214
|
+
return (this.localProps = {});
|
|
215
|
+
}
|
|
216
|
+
|
|
210
217
|
/**
|
|
211
218
|
* render 依旧使用 master 的方法,但合并后的 props 需要透传
|
|
212
219
|
*/
|
|
@@ -168,6 +168,11 @@ export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDa
|
|
|
168
168
|
options,
|
|
169
169
|
);
|
|
170
170
|
await this.runAction('destroy', config);
|
|
171
|
+
const currentPage = this.getPage();
|
|
172
|
+
const lastPage = Math.ceil((this.getCount() - _.castArray(filterByTk).length) / this.getPageSize());
|
|
173
|
+
if (currentPage > lastPage) {
|
|
174
|
+
this.setPage(lastPage);
|
|
175
|
+
}
|
|
171
176
|
await this.refresh();
|
|
172
177
|
}
|
|
173
178
|
|
|
@@ -0,0 +1,38 @@
|
|
|
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, it, expect } from 'vitest';
|
|
11
|
+
import { pruneFilter } from '../pruneFilter';
|
|
12
|
+
|
|
13
|
+
describe('pruneFilter', () => {
|
|
14
|
+
it('keeps boolean false and number 0', () => {
|
|
15
|
+
const input = { a: { $eq: false }, b: { $eq: 0 } };
|
|
16
|
+
const out = pruneFilter(input);
|
|
17
|
+
expect(out).toEqual(input);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('removes null/undefined/empty string and empty containers', () => {
|
|
21
|
+
const input = { a: null, b: undefined, c: '', d: [], e: {} } as any;
|
|
22
|
+
const out = pruneFilter(input);
|
|
23
|
+
expect(out).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('works recursively with nested objects/arrays', () => {
|
|
27
|
+
const input = {
|
|
28
|
+
$and: [{ isRead: { $eq: false } }, { name: { $eq: '' } }, {}, [], { nested: { empty: {}, ok: 1 } }],
|
|
29
|
+
} as any;
|
|
30
|
+
const out = pruneFilter(input);
|
|
31
|
+
expect(out).toEqual({ $and: [{ isRead: { $eq: false } }, { nested: { ok: 1 } }] });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns undefined for empty arrays/objects produced by pruning', () => {
|
|
35
|
+
expect(pruneFilter([null, undefined, '', [], {}] as any)).toBeUndefined();
|
|
36
|
+
expect(pruneFilter({ a: { b: '' } } as any)).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
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 type { Collection, CollectionField } from '../data-source';
|
|
11
|
+
import type { FlowContext, PropertyMeta, PropertyMetaFactory } from '../flowContext';
|
|
12
|
+
import { createCollectionContextMeta } from './createCollectionContextMeta';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 提取变量子路径的顶层字段名。
|
|
16
|
+
* 例如:
|
|
17
|
+
* - 'author.name' => 'author'
|
|
18
|
+
* - 'tags[0].name' => 'tags'
|
|
19
|
+
*
|
|
20
|
+
* @param subPath 变量在对象中的子路径字符串
|
|
21
|
+
* @returns 顶层字段名,找不到时返回 undefined
|
|
22
|
+
*/
|
|
23
|
+
function baseFieldNameOf(subPath: string): string | undefined {
|
|
24
|
+
if (!subPath) return undefined;
|
|
25
|
+
const m = subPath.match(/^([^.[]+)/);
|
|
26
|
+
return m?.[1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 在集合中根据字段名查找字段定义,兼容 getField/getFields 两种方式。
|
|
31
|
+
*
|
|
32
|
+
* @param collection 集合对象
|
|
33
|
+
* @param name 字段名
|
|
34
|
+
* @returns 匹配的字段定义,找不到时返回 undefined
|
|
35
|
+
*/
|
|
36
|
+
function findFieldByName(collection: Collection | null | undefined, name?: string): CollectionField | undefined {
|
|
37
|
+
if (!collection || !name) return undefined;
|
|
38
|
+
const direct = collection.getField(name);
|
|
39
|
+
if (direct) return direct;
|
|
40
|
+
const fields = collection.getFields?.() || [];
|
|
41
|
+
return fields.find((f) => f.name === name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 从值中提取主键:
|
|
46
|
+
* - 支持主键原始值(string/number)
|
|
47
|
+
* - 支持对象(按主键名取值)
|
|
48
|
+
*
|
|
49
|
+
* @param value 字段当前值
|
|
50
|
+
* @param primaryKey 主键字段名
|
|
51
|
+
* @returns 解析出的主键值,无法解析时返回 undefined
|
|
52
|
+
*/
|
|
53
|
+
function toFilterByTk(value: unknown, primaryKey: string | string[]) {
|
|
54
|
+
if (value == null) return undefined;
|
|
55
|
+
if (Array.isArray(primaryKey)) {
|
|
56
|
+
if (typeof value !== 'object' || !value) return undefined;
|
|
57
|
+
const out: Record<string, any> = {};
|
|
58
|
+
for (const k of primaryKey) {
|
|
59
|
+
const v = (value as any)[k];
|
|
60
|
+
if (typeof v === 'undefined' || v === null) return undefined;
|
|
61
|
+
out[k] = v;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
if (typeof value === 'string' || typeof value === 'number') return value;
|
|
66
|
+
if (typeof value === 'object') {
|
|
67
|
+
return (value as any)[primaryKey];
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 创建一个用于“对象类变量”(如 formValues / currentObject)的 `resolveOnServer` 判定函数。
|
|
74
|
+
* 仅当访问路径以“关联字段名”开头(且继续访问其子属性)时,返回 true 交由服务端解析;
|
|
75
|
+
* 否则在前端解析即可。
|
|
76
|
+
*
|
|
77
|
+
* @param collectionAccessor 返回当前对象所在collection
|
|
78
|
+
* @returns `(subPath) => boolean` 判断是否需要服务端解析
|
|
79
|
+
*/
|
|
80
|
+
export function createAssociationSubpathResolver(
|
|
81
|
+
collectionAccessor: () => Collection | null,
|
|
82
|
+
): (subPath: string) => boolean {
|
|
83
|
+
return (p: string) => {
|
|
84
|
+
if (!p || !p.includes('.')) return false; // 只在访问子属性时才需要后端
|
|
85
|
+
const base = baseFieldNameOf(p);
|
|
86
|
+
if (!base) return false;
|
|
87
|
+
const collection = collectionAccessor();
|
|
88
|
+
const field = findFieldByName(collection, base);
|
|
89
|
+
return !!field?.isAssociationField();
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 构建“对象类变量”的 PropertyMetaFactory:
|
|
95
|
+
* - 暴露集合字段结构(通过 createCollectionContextMeta)用于变量选择器;
|
|
96
|
+
* - 提供 buildVariablesParams:基于对象当前值,收集所有“已选择的关联字段”
|
|
97
|
+
* 以便服务端在 variables:resolve 时按需补全关联数据。
|
|
98
|
+
*
|
|
99
|
+
* @param collectionAccessor 获取集合对象,用于字段/元信息来源
|
|
100
|
+
* @param title 变量组标题(用于 UI 展示)
|
|
101
|
+
* @param valueAccessor 获取当前对象值(如 ctx.form.getFieldsValue() / ctx.currentObject)
|
|
102
|
+
* @returns PropertyMetaFactory
|
|
103
|
+
*/
|
|
104
|
+
export function createAssociationAwareObjectMetaFactory(
|
|
105
|
+
collectionAccessor: () => Collection | null,
|
|
106
|
+
title: string,
|
|
107
|
+
valueAccessor: (ctx: FlowContext) => any,
|
|
108
|
+
): PropertyMetaFactory {
|
|
109
|
+
const baseFactory = createCollectionContextMeta(collectionAccessor, title);
|
|
110
|
+
const factory: PropertyMetaFactory = async () => {
|
|
111
|
+
const base = (await baseFactory()) as PropertyMeta | null;
|
|
112
|
+
if (!base) return null;
|
|
113
|
+
|
|
114
|
+
const meta: PropertyMeta = {
|
|
115
|
+
...base,
|
|
116
|
+
buildVariablesParams: (ctx: FlowContext) => {
|
|
117
|
+
const collection = collectionAccessor();
|
|
118
|
+
const obj = valueAccessor(ctx);
|
|
119
|
+
if (!collection || !obj || typeof obj !== 'object') return {};
|
|
120
|
+
const params: Record<string, any> = {};
|
|
121
|
+
|
|
122
|
+
const fields: CollectionField[] = collection.getFields?.() || [];
|
|
123
|
+
for (const field of fields) {
|
|
124
|
+
const name = field.name as string | undefined;
|
|
125
|
+
if (!name) continue;
|
|
126
|
+
if (!field.isAssociationField()) continue;
|
|
127
|
+
const target = field.target as string | undefined;
|
|
128
|
+
const targetCollection = field.targetCollection;
|
|
129
|
+
if (!target || !targetCollection) continue;
|
|
130
|
+
const primaryKey = targetCollection.filterTargetKey as string | string[];
|
|
131
|
+
|
|
132
|
+
const associationValue = (obj as any)[name];
|
|
133
|
+
if (associationValue == null) continue;
|
|
134
|
+
|
|
135
|
+
if (Array.isArray(associationValue)) {
|
|
136
|
+
const ids = associationValue.map((item) => toFilterByTk(item, primaryKey)).filter((v) => v != null);
|
|
137
|
+
if (ids.length) {
|
|
138
|
+
params[name] = {
|
|
139
|
+
collection: target,
|
|
140
|
+
dataSourceKey: targetCollection.dataSourceKey,
|
|
141
|
+
filterByTk: ids,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
const id = toFilterByTk(associationValue, primaryKey);
|
|
146
|
+
if (id != null) {
|
|
147
|
+
params[name] = {
|
|
148
|
+
collection: target,
|
|
149
|
+
dataSourceKey: targetCollection.dataSourceKey,
|
|
150
|
+
filterByTk: id,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return params;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return meta;
|
|
161
|
+
};
|
|
162
|
+
factory.title = title;
|
|
163
|
+
return factory;
|
|
164
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ export { setupRuntimeContextSteps } from './setupRuntimeContextSteps';
|
|
|
41
41
|
|
|
42
42
|
// Record Proxy 工具
|
|
43
43
|
export { createCollectionContextMeta } from './createCollectionContextMeta';
|
|
44
|
+
export { createAssociationAwareObjectMetaFactory, createAssociationSubpathResolver } from './associationObjectVariable';
|
|
44
45
|
export {
|
|
45
46
|
buildRecordMeta,
|
|
46
47
|
collectContextParamsForTemplate,
|
|
@@ -61,3 +62,6 @@ export { buildSettingsViewInputArgs } from './buildSettingsViewInputArgs';
|
|
|
61
62
|
|
|
62
63
|
// 安全全局对象(window/document)
|
|
63
64
|
export { createSafeDocument, createSafeWindow, createSafeNavigator } from './safeGlobals';
|
|
65
|
+
|
|
66
|
+
// Filter helpers
|
|
67
|
+
export { pruneFilter } from './pruneFilter';
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* 递归清理筛选对象中的“空值”。
|
|
12
|
+
*
|
|
13
|
+
* 规则:
|
|
14
|
+
* - 移除:`null`、`undefined`、空字符串 `''`、空数组 `[]`、空对象 `{}`;
|
|
15
|
+
* - 保留:`false`、`0` 等有意义的“假值”。
|
|
16
|
+
*
|
|
17
|
+
* 注意:当清理后整体变为空结构时,返回 `undefined`,用于表示无需下发该条件。
|
|
18
|
+
*
|
|
19
|
+
* @param input 任意筛选对象/数组/原始值
|
|
20
|
+
* @returns 清理后的对象;若为空则返回 `undefined`
|
|
21
|
+
*/
|
|
22
|
+
export function pruneFilter<T = any>(input: T): T | undefined {
|
|
23
|
+
// Arrays: prune items and drop empty arrays
|
|
24
|
+
if (Array.isArray(input)) {
|
|
25
|
+
const arr = (input as unknown[]).map((v) => pruneFilter(v)).filter((v) => v !== undefined);
|
|
26
|
+
return (arr.length ? (arr as unknown as T) : undefined) as any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Objects: prune properties and drop empty objects
|
|
30
|
+
if (input && typeof input === 'object') {
|
|
31
|
+
const out: Record<string, any> = {};
|
|
32
|
+
Object.keys(input as Record<string, any>).forEach((k) => {
|
|
33
|
+
const v = pruneFilter((input as any)[k]);
|
|
34
|
+
if (v !== undefined) out[k] = v;
|
|
35
|
+
});
|
|
36
|
+
return (Object.keys(out).length ? (out as unknown as T) : undefined) as any;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Primitives: keep false/0; drop null/undefined/''
|
|
40
|
+
return input === null || input === undefined || (typeof input === 'string' && input === '') ? undefined : input;
|
|
41
|
+
}
|