@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.
@@ -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) || !value) return;
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) || !innerValue) return;
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
  };
@@ -72,6 +72,7 @@ export interface PropertyOptions {
72
72
  observable?: boolean;
73
73
  meta?: PropertyMetaOrFactory;
74
74
  resolveOnServer?: boolean | ((subPath: string) => boolean);
75
+ serverOnlyWhenContextParams?: boolean;
75
76
  }
76
77
  type RouteOptions = {
77
78
  name?: string;
@@ -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
+ });
@@ -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';
@@ -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.32",
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.32",
12
- "@nocobase/shared": "2.0.0-alpha.32",
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": "50bb60d2f42e0fc2c1cd1eee397f9c0da94084df"
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) || !value) return;
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) || !innerValue) return;
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
  };
@@ -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
+ }
@@ -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
+ }