@nocobase/flow-engine 2.0.0-alpha.50 → 2.0.0-alpha.51

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.
@@ -40,6 +40,7 @@ export interface FlowModelRendererProps {
40
40
  settingsMenuLevel?: number;
41
41
  /** 额外的工具栏项目,仅应用于此实例 */
42
42
  extraToolbarItems?: ToolbarItemConfig[];
43
+ useCache?: boolean;
43
44
  }
44
45
  /**
45
46
  * A React component responsible for rendering a FlowModel.
@@ -63,7 +63,10 @@ const FlowModelRendererWithAutoFlows = (0, import_reactive_react.observer)(
63
63
  extraToolbarItems,
64
64
  fallback
65
65
  }) => {
66
- const { loading: pending, error: autoFlowsError } = (0, import_hooks.useApplyAutoFlows)(model, inputArgs, { throwOnError: false });
66
+ const { loading: pending, error: autoFlowsError } = (0, import_hooks.useApplyAutoFlows)(model, inputArgs, {
67
+ throwOnError: false,
68
+ useCache: model.context.useCache
69
+ });
67
70
  (0, import_utils.setAutoFlowError)(model, autoFlowsError || null);
68
71
  if (pending) {
69
72
  return /* @__PURE__ */ import_react.default.createElement(import_react.default.Fragment, null, fallback);
@@ -189,8 +192,16 @@ const FlowModelRenderer = (0, import_reactive_react.observer)(
189
192
  inputArgs,
190
193
  showErrorFallback = true,
191
194
  settingsMenuLevel,
192
- extraToolbarItems
195
+ extraToolbarItems,
196
+ useCache
193
197
  }) => {
198
+ (0, import_react.useEffect)(() => {
199
+ if (model == null ? void 0 : model.context) {
200
+ model.context.defineProperty("useCache", {
201
+ value: typeof useCache === "boolean" ? useCache : model.context.useCache
202
+ });
203
+ }
204
+ }, [model == null ? void 0 : model.context, useCache]);
194
205
  if (!model || typeof model.render !== "function") {
195
206
  console.warn("FlowModelRenderer: Invalid model or render method not found.", model);
196
207
  return null;
@@ -227,27 +227,15 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
227
227
  );
228
228
  const getModelConfigurableFlowsAndSteps = (0, import_react.useCallback)(
229
229
  async (targetModel, modelKey) => {
230
- var _a;
231
230
  try {
232
231
  const flowsMap = new Map(targetModel.constructor.globalFlowRegistry.getFlows());
233
- const originUse = targetModel == null ? void 0 : targetModel.use;
234
- if (typeof originUse === "string" && originUse !== targetModel.constructor.name) {
235
- const originCls = (_a = targetModel.flowEngine) == null ? void 0 : _a.getModelClass(originUse);
236
- if (originCls == null ? void 0 : originCls.globalFlowRegistry) {
237
- for (const [k, v] of originCls.globalFlowRegistry.getFlows()) {
238
- if (!flowsMap.has(k)) {
239
- flowsMap.set(k, v);
240
- }
241
- }
242
- }
243
- }
244
232
  const flows = flowsMap;
245
233
  const flowsArray = Array.from(flows.values());
246
234
  const flowsWithSteps = await Promise.all(
247
235
  flowsArray.map(async (flow) => {
248
236
  const configurableSteps = await Promise.all(
249
237
  Object.entries(flow.steps).map(async ([stepKey, stepDefinition]) => {
250
- var _a2;
238
+ var _a;
251
239
  const actionStep = stepDefinition;
252
240
  if (actionStep.hideInSettings) {
253
241
  return null;
@@ -257,7 +245,7 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
257
245
  let stepTitle = actionStep.title;
258
246
  if (actionStep.use) {
259
247
  try {
260
- const action = (_a2 = targetModel.getAction) == null ? void 0 : _a2.call(targetModel, actionStep.use);
248
+ const action = (_a = targetModel.getAction) == null ? void 0 : _a.call(targetModel, actionStep.use);
261
249
  hasActionUiSchema = action && action.uiSchema != null;
262
250
  stepTitle = stepTitle || (action == null ? void 0 : action.title);
263
251
  } catch (error) {
@@ -104,6 +104,7 @@ function jioToJoiSchema(jioConfig) {
104
104
  });
105
105
  if (!hasRequired) {
106
106
  schema = schema.optional().allow("");
107
+ schema = schema.optional().allow(null);
107
108
  }
108
109
  return schema;
109
110
  }
@@ -263,6 +263,12 @@ export declare class FlowEngine {
263
263
  * @returns {boolean} Returns true if successfully destroyed, false otherwise (e.g. instance does not exist)
264
264
  */
265
265
  removeModel(uid: string): boolean;
266
+ /**
267
+ * Remove a local model instance and all its sub-models recursively.
268
+ * @param {string} uid UID of the model instance to destroy
269
+ * @returns {boolean} Returns true if successfully destroyed, false otherwise
270
+ */
271
+ removeModelWithSubModels(uid: string): boolean;
266
272
  /**
267
273
  * Check if the model repository is set.
268
274
  * @returns {boolean} Returns true if set, false otherwise.
package/lib/flowEngine.js CHANGED
@@ -586,6 +586,39 @@ const _FlowEngine = class _FlowEngine {
586
586
  });
587
587
  return true;
588
588
  }
589
+ /**
590
+ * Remove a local model instance and all its sub-models recursively.
591
+ * @param {string} uid UID of the model instance to destroy
592
+ * @returns {boolean} Returns true if successfully destroyed, false otherwise
593
+ */
594
+ removeModelWithSubModels(uid) {
595
+ const model = this.getModel(uid);
596
+ if (!model) {
597
+ return false;
598
+ }
599
+ const collectDescendants = /* @__PURE__ */ __name((m, acc) => {
600
+ if (m.subModels) {
601
+ for (const key in m.subModels) {
602
+ const sub = m.subModels[key];
603
+ if (Array.isArray(sub)) {
604
+ [...sub].forEach((s) => collectDescendants(s, acc));
605
+ } else if (sub) {
606
+ collectDescendants(sub, acc);
607
+ }
608
+ }
609
+ }
610
+ acc.push(m);
611
+ }, "collectDescendants");
612
+ const allModels = [];
613
+ collectDescendants(model, allModels);
614
+ let success = true;
615
+ for (const m of allModels) {
616
+ if (!this.removeModel(m.uid)) {
617
+ success = false;
618
+ }
619
+ }
620
+ return success;
621
+ }
589
622
  /**
590
623
  * Check if the model repository is set.
591
624
  * @returns {boolean} Returns true if set, false otherwise.
@@ -15,6 +15,7 @@ import { FlowModel } from '../models';
15
15
  */
16
16
  export declare function useApplyAutoFlows(modelOrUid: FlowModel | string, inputArgs?: Record<string, any>, options?: {
17
17
  throwOnError?: boolean;
18
+ useCache?: boolean;
18
19
  }): {
19
20
  readonly loading: boolean;
20
21
  readonly error: Error;
@@ -45,10 +45,10 @@ function useApplyAutoFlows(modelOrUid, inputArgs, options) {
45
45
  const { loading, error } = (0, import_ahooks.useRequest)(
46
46
  async () => {
47
47
  if (!model) return;
48
- await model.dispatchEvent("beforeRender", inputArgs);
48
+ await model.dispatchEvent("beforeRender", inputArgs, { useCache: options == null ? void 0 : options.useCache });
49
49
  },
50
50
  {
51
- refreshDeps: [model, inputArgs]
51
+ refreshDeps: [model, inputArgs, options == null ? void 0 : options.useCache]
52
52
  }
53
53
  );
54
54
  if ((options == null ? void 0 : options.throwOnError) !== false && error) {
@@ -98,7 +98,7 @@ function createAssociationSubpathResolver(collectionAccessor, valueAccessor) {
98
98
  }
99
99
  __name(createAssociationSubpathResolver, "createAssociationSubpathResolver");
100
100
  function createAssociationAwareObjectMetaFactory(collectionAccessor, title, valueAccessor) {
101
- const baseFactory = (0, import_createCollectionContextMeta.createCollectionContextMeta)(collectionAccessor, title);
101
+ const baseFactory = (0, import_createCollectionContextMeta.createCollectionContextMeta)(collectionAccessor, title, true);
102
102
  const factory = /* @__PURE__ */ __name(async () => {
103
103
  const base = await baseFactory();
104
104
  if (!base) return null;
@@ -8,4 +8,4 @@
8
8
  */
9
9
  import type { Collection } from '../data-source';
10
10
  import type { PropertyMetaFactory } from '../flowContext';
11
- export declare function createCollectionContextMeta(collectionOrFactory: Collection | (() => Collection | null), title?: string): PropertyMetaFactory;
11
+ export declare function createCollectionContextMeta(collectionOrFactory: Collection | (() => Collection | null), title?: string, includeNonFilterable?: boolean): PropertyMetaFactory;
@@ -32,7 +32,7 @@ __export(createCollectionContextMeta_exports, {
32
32
  module.exports = __toCommonJS(createCollectionContextMeta_exports);
33
33
  const RELATION_FIELD_TYPES = ["belongsTo", "hasOne", "hasMany", "belongsToMany", "belongsToArray"];
34
34
  const NUMERIC_FIELD_TYPES = ["integer", "float", "double", "decimal"];
35
- function createFieldMetadata(field) {
35
+ function createFieldMetadata(field, includeNonFilterable) {
36
36
  const baseProperties = createMetaBaseProperties(field);
37
37
  if (field.isAssociationField()) {
38
38
  const targetCollection = field.targetCollection;
@@ -49,7 +49,9 @@ function createFieldMetadata(field) {
49
49
  properties: /* @__PURE__ */ __name(async () => {
50
50
  const subProperties = {};
51
51
  targetCollection.fields.forEach((subField) => {
52
- subProperties[subField.name] = createFieldMetadata(subField);
52
+ if (includeNonFilterable || subField.filterable) {
53
+ subProperties[subField.name] = createFieldMetadata(subField, includeNonFilterable);
54
+ }
53
55
  });
54
56
  return subProperties;
55
57
  }, "properties")
@@ -89,7 +91,7 @@ function createMetaBaseProperties(field) {
89
91
  };
90
92
  }
91
93
  __name(createMetaBaseProperties, "createMetaBaseProperties");
92
- function createCollectionContextMeta(collectionOrFactory, title) {
94
+ function createCollectionContextMeta(collectionOrFactory, title, includeNonFilterable) {
93
95
  const metaFn = /* @__PURE__ */ __name(async () => {
94
96
  const collection = typeof collectionOrFactory === "function" ? collectionOrFactory() : collectionOrFactory;
95
97
  if (!collection) {
@@ -101,8 +103,8 @@ function createCollectionContextMeta(collectionOrFactory, title) {
101
103
  properties: /* @__PURE__ */ __name(async () => {
102
104
  const properties = {};
103
105
  collection.fields.forEach((field) => {
104
- if (field.filterable) {
105
- properties[field.name] = createFieldMetadata(field);
106
+ if (includeNonFilterable || field.filterable) {
107
+ properties[field.name] = createFieldMetadata(field, includeNonFilterable);
106
108
  }
107
109
  });
108
110
  return properties;
@@ -164,7 +164,7 @@ function inferParentRecordRef(ctx) {
164
164
  }
165
165
  __name(inferParentRecordRef, "inferParentRecordRef");
166
166
  async function buildRecordMeta(collectionAccessor, title, paramsBuilder) {
167
- const base = await (0, import_createCollectionContextMeta.createCollectionContextMeta)(collectionAccessor, title)();
167
+ const base = await (0, import_createCollectionContextMeta.createCollectionContextMeta)(collectionAccessor, title, true)();
168
168
  if (!base) return null;
169
169
  return {
170
170
  ...base,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.0.0-alpha.50",
3
+ "version": "2.0.0-alpha.51",
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.50",
12
- "@nocobase/shared": "2.0.0-alpha.50",
11
+ "@nocobase/sdk": "2.0.0-alpha.51",
12
+ "@nocobase/shared": "2.0.0-alpha.51",
13
13
  "ahooks": "^3.7.2",
14
14
  "dayjs": "^1.11.9",
15
15
  "dompurify": "^3.0.2",
@@ -36,5 +36,5 @@
36
36
  ],
37
37
  "author": "NocoBase Team",
38
38
  "license": "AGPL-3.0",
39
- "gitHead": "a6eb64abf3632e116ad0b295a7f410270a1059d1"
39
+ "gitHead": "a1e34dd97f370d54f3d80a6b83ab7ddb9c72dc18"
40
40
  }
@@ -0,0 +1,72 @@
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 { beforeEach, describe, expect, it } from 'vitest';
11
+ import { FlowEngine } from '../flowEngine';
12
+ import { FlowModel } from '../models';
13
+
14
+ describe('FlowEngine removeModel', () => {
15
+ let engine: FlowEngine;
16
+
17
+ beforeEach(() => {
18
+ engine = new FlowEngine();
19
+ engine.registerModels({ FlowModel });
20
+ });
21
+
22
+ it('removeModel should remove model but keep sub-models in cache (current behavior)', () => {
23
+ const parent = engine.createModel({ uid: 'parent', use: 'FlowModel' });
24
+ const child = engine.createModel({
25
+ uid: 'child',
26
+ use: 'FlowModel',
27
+ parentId: 'parent',
28
+ subKey: 'child',
29
+ subType: 'object',
30
+ });
31
+
32
+ expect(engine.getModel('parent')).toBe(parent);
33
+ expect(engine.getModel('child')).toBe(child);
34
+
35
+ engine.removeModel('parent');
36
+
37
+ expect(engine.getModel('parent')).toBeUndefined();
38
+ // Current behavior: child is still in cache
39
+ expect(engine.getModel('child')).toBeDefined();
40
+ });
41
+
42
+ it('removeModelWithSubModels should remove model and all sub-models from cache', () => {
43
+ const parent = engine.createModel({ uid: 'parent', use: 'FlowModel' });
44
+ const child = engine.createModel({
45
+ uid: 'child',
46
+ use: 'FlowModel',
47
+ parentId: 'parent',
48
+ subKey: 'child',
49
+ subType: 'object',
50
+ });
51
+ parent.setSubModel('child', child);
52
+
53
+ const grandChild = engine.createModel({
54
+ uid: 'grandChild',
55
+ use: 'FlowModel',
56
+ parentId: 'child',
57
+ subKey: 'grandChild',
58
+ subType: 'object',
59
+ });
60
+ child.setSubModel('grandChild', grandChild);
61
+
62
+ expect(engine.getModel('parent')).toBe(parent);
63
+ expect(engine.getModel('child')).toBe(child);
64
+ expect(engine.getModel('grandChild')).toBe(grandChild);
65
+
66
+ engine.removeModelWithSubModels('parent');
67
+
68
+ expect(engine.getModel('parent')).toBeUndefined();
69
+ expect(engine.getModel('child')).toBeUndefined();
70
+ expect(engine.getModel('grandChild')).toBeUndefined();
71
+ });
72
+ });
@@ -35,7 +35,7 @@ describe('ACL', () => {
35
35
  };
36
36
  const engine = makeEngine(payload);
37
37
  const acl = new ACL(engine);
38
- await acl.load();
38
+ acl.setData(payload.data);
39
39
  expect(acl.getActionAlias('remove')).toBe('destroy');
40
40
  expect(acl.inResources('posts')).toBe(true);
41
41
  expect(acl.getResourceActionParams('posts', 'remove')).toEqual({ whitelist: ['title'] });
@@ -54,6 +54,7 @@ describe('ACL', () => {
54
54
  };
55
55
  const engine = makeEngine(payload);
56
56
  const acl = new ACL(engine);
57
+ acl.setData(payload.data);
57
58
  const ok = await acl.aclCheck({
58
59
  dataSourceKey: 'main',
59
60
  resourceName: 'posts',
@@ -101,15 +102,13 @@ describe('ACL', () => {
101
102
  engine.context.defineProperty('api', { value: api });
102
103
 
103
104
  const acl = new ACL(engine);
104
- await acl.load();
105
+ acl.setData(payload1.data);
105
106
  expect(acl.getActionAlias('remove')).toBe('destroy');
106
107
 
107
108
  // 切换 token,应触发下次校验时的 ACL 重载
108
109
  api.auth.token = 't2';
110
+ acl.setData(payload2.data);
109
111
  await acl.aclCheck({ dataSourceKey: 'main', resourceName: 'posts', actionName: 'remove' });
110
112
  expect(acl.getActionAlias('remove')).toBe('erase');
111
-
112
- // 确认 roles:check 请求至少调用两次(初次 + 重载)
113
- expect(api.request).toHaveBeenCalledTimes(2);
114
113
  });
115
114
  });
@@ -44,7 +44,7 @@
44
44
 
45
45
  import { observer } from '@formily/reactive-react';
46
46
  import _ from 'lodash';
47
- import React from 'react';
47
+ import React, { useEffect } from 'react';
48
48
  import { ErrorBoundary } from 'react-error-boundary';
49
49
  import { FlowModelProvider, useApplyAutoFlows } from '../hooks';
50
50
  import { getAutoFlowError, setAutoFlowError } from '../utils';
@@ -97,6 +97,8 @@ export interface FlowModelRendererProps {
97
97
 
98
98
  /** 额外的工具栏项目,仅应用于此实例 */
99
99
  extraToolbarItems?: ToolbarItemConfig[];
100
+
101
+ useCache?: boolean;
100
102
  }
101
103
 
102
104
  /**
@@ -139,7 +141,10 @@ const FlowModelRendererWithAutoFlows: React.FC<{
139
141
  }) => {
140
142
  // hidden 占位由模型自身处理;无需在此注入
141
143
 
142
- const { loading: pending, error: autoFlowsError } = useApplyAutoFlows(model, inputArgs, { throwOnError: false });
144
+ const { loading: pending, error: autoFlowsError } = useApplyAutoFlows(model, inputArgs, {
145
+ throwOnError: false,
146
+ useCache: model.context.useCache,
147
+ });
143
148
  // 将错误下沉到 model 实例上,供内容层读取(类型安全的 WeakMap 存储)
144
149
  setAutoFlowError(model, autoFlowsError || null);
145
150
 
@@ -339,7 +344,16 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
339
344
  showErrorFallback = true,
340
345
  settingsMenuLevel,
341
346
  extraToolbarItems,
347
+ useCache,
342
348
  }) => {
349
+ useEffect(() => {
350
+ if (model?.context) {
351
+ model.context.defineProperty('useCache', {
352
+ value: typeof useCache === 'boolean' ? useCache : model.context.useCache,
353
+ });
354
+ }
355
+ }, [model?.context, useCache]);
356
+
343
357
  if (!model || typeof model.render !== 'function') {
344
358
  // 可以选择渲染 null 或者一个错误/提示信息
345
359
  console.warn('FlowModelRenderer: Invalid model or render method not found.', model);
@@ -0,0 +1,89 @@
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 { render, waitFor } from '@testing-library/react';
11
+ import React from 'react';
12
+ import { vi } from 'vitest';
13
+ import { FlowEngine } from '../../flowEngine';
14
+ import { FlowModel } from '../../models/flowModel';
15
+ import { FlowEngineProvider } from '../../provider';
16
+ import { FlowModelRenderer } from '../FlowModelRenderer';
17
+
18
+ describe('FlowModelRenderer', () => {
19
+ let flowEngine: FlowEngine;
20
+ let model: FlowModel;
21
+
22
+ beforeEach(() => {
23
+ flowEngine = new FlowEngine();
24
+ model = new FlowModel({
25
+ uid: 'test-model',
26
+ flowEngine,
27
+ });
28
+ // Mock dispatchEvent to track calls
29
+ model.dispatchEvent = vi.fn().mockResolvedValue([]);
30
+ // Mock render to return something
31
+ model.render = vi.fn().mockReturnValue(<div>Model Content</div>);
32
+ });
33
+
34
+ const renderWithProvider = (ui: React.ReactNode) => {
35
+ return render(<FlowEngineProvider engine={flowEngine}>{ui}</FlowEngineProvider>);
36
+ };
37
+
38
+ test('should pass useCache to useApplyAutoFlows and set it on context', async () => {
39
+ const { unmount } = renderWithProvider(<FlowModelRenderer model={model} useCache={true} />);
40
+
41
+ // Check if dispatchEvent was called with useCache: true
42
+ // useApplyAutoFlows calls dispatchEvent('beforeRender', inputArgs, { useCache })
43
+ await waitFor(() => {
44
+ expect(model.dispatchEvent).toHaveBeenCalledWith(
45
+ 'beforeRender',
46
+ undefined,
47
+ expect.objectContaining({ useCache: true }),
48
+ );
49
+ });
50
+
51
+ // Check if useCache is set on context
52
+ expect(model.context.useCache).toBe(true);
53
+
54
+ unmount();
55
+ });
56
+
57
+ test('should pass useCache=false to useApplyAutoFlows and set it on context', async () => {
58
+ const { unmount } = renderWithProvider(<FlowModelRenderer model={model} useCache={false} />);
59
+
60
+ await waitFor(() => {
61
+ expect(model.dispatchEvent).toHaveBeenCalledWith(
62
+ 'beforeRender',
63
+ undefined,
64
+ expect.objectContaining({ useCache: false }),
65
+ );
66
+ });
67
+
68
+ expect(model.context.useCache).toBe(false);
69
+
70
+ unmount();
71
+ });
72
+
73
+ test('should not pass useCache if not provided', async () => {
74
+ const { unmount } = renderWithProvider(<FlowModelRenderer model={model} />);
75
+
76
+ await waitFor(() => {
77
+ expect(model.dispatchEvent).toHaveBeenCalledWith(
78
+ 'beforeRender',
79
+ undefined,
80
+ expect.objectContaining({ useCache: undefined }),
81
+ );
82
+ });
83
+
84
+ // context.useCache should be undefined (or default)
85
+ expect(model.context.useCache).toBeUndefined();
86
+
87
+ unmount();
88
+ });
89
+ });
@@ -270,19 +270,6 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
270
270
  // 仅使用静态流(类级全局注册的 flows)
271
271
  const flowsMap = new Map((targetModel.constructor as typeof FlowModel).globalFlowRegistry.getFlows());
272
272
 
273
- // 如果有原始 use 且与当前类不同,合并原始模型类的静态 flows(用于入口模型 resolveUse 场景)
274
- const originUse = targetModel?.use;
275
- if (typeof originUse === 'string' && originUse !== targetModel.constructor.name) {
276
- const originCls = targetModel.flowEngine?.getModelClass(originUse) as typeof FlowModel | undefined;
277
- if (originCls?.globalFlowRegistry) {
278
- for (const [k, v] of originCls.globalFlowRegistry.getFlows()) {
279
- if (!flowsMap.has(k)) {
280
- flowsMap.set(k, v);
281
- }
282
- }
283
- }
284
- }
285
-
286
273
  const flows = flowsMap;
287
274
 
288
275
  const flowsArray = Array.from(flows.values());
@@ -422,59 +422,4 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
422
422
  menu.onClick?.({ key: 'copy-pop-uid:items[0]:popupSettings:stage' });
423
423
  expect((navigator as any).clipboard.writeText).toHaveBeenCalledWith('child-2');
424
424
  });
425
-
426
- it('merges static flows from origin use when instance class differs', async () => {
427
- class TargetModel extends FlowModel {
428
- static setupFlows() {
429
- this.registerFlow({
430
- key: 'targetFlow',
431
- title: 'Target Flow',
432
- steps: {
433
- targetStep: { title: 'Target Step', uiSchema: { x: { type: 'string', 'x-component': 'Input' } } },
434
- },
435
- });
436
- }
437
- }
438
- class EntryModel extends FlowModel {
439
- static resolveUse() {
440
- return TargetModel;
441
- }
442
- static setupFlows() {
443
- this.registerFlow({
444
- key: 'originFlow',
445
- title: 'Origin Flow',
446
- steps: {
447
- originStep: { title: 'Origin Step', uiSchema: { y: { type: 'string', 'x-component': 'Input' } } },
448
- },
449
- });
450
- }
451
- }
452
-
453
- TargetModel.setupFlows();
454
- EntryModel.setupFlows();
455
-
456
- const engine = new FlowEngine();
457
- engine.registerModels({ EntryModel, TargetModel });
458
-
459
- const model = engine.createModel({ use: 'EntryModel', uid: 'merge-origin', flowEngine: engine });
460
-
461
- render(
462
- React.createElement(
463
- ConfigProvider as any,
464
- null,
465
- React.createElement(App as any, null, React.createElement(DefaultSettingsIcon as any, { model })),
466
- ),
467
- );
468
-
469
- await waitFor(() => {
470
- const menu = (globalThis as any).__lastDropdownMenu;
471
- expect(menu).toBeTruthy();
472
- const items = (menu?.items || []) as any[];
473
- const groups = items.filter((it) => it.type === 'group').map((it) => String(it.label));
474
- expect(groups).toContain('Origin Flow');
475
- expect(groups).toContain('Target Flow');
476
- expect(items.some((it) => String(it.key || '').startsWith('originFlow:originStep'))).toBe(true);
477
- expect(items.some((it) => String(it.key || '').startsWith('targetFlow:targetStep'))).toBe(true);
478
- });
479
- });
480
425
  });
@@ -97,6 +97,7 @@ export function jioToJoiSchema<T extends JioType>(jioConfig: {
97
97
  // 4️⃣ 如果没有 required,默认可选并允许空字符串
98
98
  if (!hasRequired) {
99
99
  schema = schema.optional().allow('');
100
+ schema = schema.optional().allow(null);
100
101
  }
101
102
 
102
103
  return schema;
package/src/flowEngine.ts CHANGED
@@ -701,6 +701,44 @@ export class FlowEngine {
701
701
  return true;
702
702
  }
703
703
 
704
+ /**
705
+ * Remove a local model instance and all its sub-models recursively.
706
+ * @param {string} uid UID of the model instance to destroy
707
+ * @returns {boolean} Returns true if successfully destroyed, false otherwise
708
+ */
709
+ public removeModelWithSubModels(uid: string): boolean {
710
+ const model = this.getModel(uid);
711
+ if (!model) {
712
+ return false;
713
+ }
714
+
715
+ const collectDescendants = (m: FlowModel, acc: FlowModel[]) => {
716
+ if (m.subModels) {
717
+ for (const key in m.subModels) {
718
+ const sub = m.subModels[key];
719
+ if (Array.isArray(sub)) {
720
+ [...sub].forEach((s) => collectDescendants(s, acc));
721
+ } else if (sub) {
722
+ collectDescendants(sub, acc);
723
+ }
724
+ }
725
+ }
726
+ acc.push(m);
727
+ };
728
+
729
+ const allModels: FlowModel[] = [];
730
+ collectDescendants(model, allModels);
731
+
732
+ let success = true;
733
+ for (const m of allModels) {
734
+ if (!this.removeModel(m.uid)) {
735
+ success = false;
736
+ }
737
+ }
738
+
739
+ return success;
740
+ }
741
+
704
742
  /**
705
743
  * Check if the model repository is set.
706
744
  * @returns {boolean} Returns true if set, false otherwise.
@@ -21,7 +21,7 @@ import { useFlowEngine } from '../provider';
21
21
  export function useApplyAutoFlows(
22
22
  modelOrUid: FlowModel | string,
23
23
  inputArgs?: Record<string, any>,
24
- options?: { throwOnError?: boolean },
24
+ options?: { throwOnError?: boolean; useCache?: boolean },
25
25
  ) {
26
26
  const flowEngine = useFlowEngine();
27
27
  const model = useMemo(() => {
@@ -37,10 +37,10 @@ export function useApplyAutoFlows(
37
37
  async () => {
38
38
  if (!model) return;
39
39
  // beforeRender 在模型层默认顺序执行并默认使用缓存(可覆盖)
40
- await model.dispatchEvent('beforeRender', inputArgs);
40
+ await model.dispatchEvent('beforeRender', inputArgs, { useCache: options?.useCache });
41
41
  },
42
42
  {
43
- refreshDeps: [model, inputArgs],
43
+ refreshDeps: [model, inputArgs, options?.useCache],
44
44
  },
45
45
  );
46
46
 
@@ -0,0 +1,51 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import { createCollectionContextMeta } from '../createCollectionContextMeta';
12
+ import { FlowEngine } from '../../flowEngine';
13
+ import { CollectionFieldInterfaceManager } from '../../../../client/src/data-source/collection-field-interface/CollectionFieldInterfaceManager';
14
+
15
+ describe('createCollectionContextMeta', () => {
16
+ it('filters association sub fields when includeNonFilterable is false', async () => {
17
+ const engine = new FlowEngine();
18
+ const dm = engine.dataSourceManager as any;
19
+ dm.collectionFieldInterfaceManager = new CollectionFieldInterfaceManager([], {}, dm);
20
+ engine.context.defineProperty('app', { value: { dataSourceManager: dm } });
21
+ const ds = dm.getDataSource('main')!;
22
+
23
+ ds.addCollection({
24
+ name: 'users',
25
+ fields: [
26
+ { name: 'id', type: 'integer', interface: 'number', filterable: true },
27
+ { name: 'email', type: 'string', interface: 'text', filterable: true },
28
+ { name: 'nickname', type: 'string', interface: 'text' }, // 未声明 filterable
29
+ ],
30
+ });
31
+
32
+ ds.addCollection({
33
+ name: 'posts',
34
+ fields: [
35
+ { name: 'title', type: 'string', interface: 'text', filterable: true },
36
+ { name: 'author', type: 'belongsTo', target: 'users', interface: 'm2o', filterable: true },
37
+ ],
38
+ });
39
+
40
+ const posts = ds.getCollection('posts')!;
41
+ const metaFactory = createCollectionContextMeta(posts, 'Posts', false);
42
+ const meta = await metaFactory();
43
+ const props = await (meta?.properties as any)?.();
44
+ const authorMeta: any = props?.author;
45
+ const authorFields = await authorMeta?.properties?.();
46
+
47
+ expect(authorFields).toBeTruthy();
48
+ expect(authorFields).toHaveProperty('email');
49
+ expect(authorFields).not.toHaveProperty('nickname');
50
+ });
51
+ });
@@ -122,7 +122,7 @@ export function createAssociationAwareObjectMetaFactory(
122
122
  title: string,
123
123
  valueAccessor: (ctx: FlowContext) => any,
124
124
  ): PropertyMetaFactory {
125
- const baseFactory = createCollectionContextMeta(collectionAccessor, title);
125
+ const baseFactory = createCollectionContextMeta(collectionAccessor, title, true);
126
126
  const factory: PropertyMetaFactory = async () => {
127
127
  const base = (await baseFactory()) as PropertyMeta | null;
128
128
  if (!base) return null;
@@ -17,7 +17,7 @@ const NUMERIC_FIELD_TYPES = ['integer', 'float', 'double', 'decimal'] as const;
17
17
  /**
18
18
  * 创建字段的完整元数据(统一处理关联和非关联字段)
19
19
  */
20
- function createFieldMetadata(field: CollectionField) {
20
+ function createFieldMetadata(field: CollectionField, includeNonFilterable?: boolean) {
21
21
  const baseProperties = createMetaBaseProperties(field);
22
22
 
23
23
  if (field.isAssociationField()) {
@@ -36,7 +36,9 @@ function createFieldMetadata(field: CollectionField) {
36
36
  properties: async () => {
37
37
  const subProperties: Record<string, any> = {};
38
38
  targetCollection.fields.forEach((subField) => {
39
- subProperties[subField.name] = createFieldMetadata(subField);
39
+ if (includeNonFilterable || subField.filterable) {
40
+ subProperties[subField.name] = createFieldMetadata(subField, includeNonFilterable);
41
+ }
40
42
  });
41
43
  return subProperties;
42
44
  },
@@ -93,6 +95,7 @@ function createMetaBaseProperties(field: CollectionField) {
93
95
  export function createCollectionContextMeta(
94
96
  collectionOrFactory: Collection | (() => Collection | null),
95
97
  title?: string,
98
+ includeNonFilterable?: boolean,
96
99
  ): PropertyMetaFactory {
97
100
  const metaFn: PropertyMetaFactory = async () => {
98
101
  const collection = typeof collectionOrFactory === 'function' ? collectionOrFactory() : collectionOrFactory;
@@ -110,8 +113,8 @@ export function createCollectionContextMeta(
110
113
 
111
114
  // 添加所有字段
112
115
  collection.fields.forEach((field) => {
113
- if (field.filterable) {
114
- properties[field.name] = createFieldMetadata(field);
116
+ if (includeNonFilterable || field.filterable) {
117
+ properties[field.name] = createFieldMetadata(field, includeNonFilterable);
115
118
  }
116
119
  });
117
120
 
@@ -174,7 +174,7 @@ export async function buildRecordMeta(
174
174
  title?: string,
175
175
  paramsBuilder?: RecordParamsBuilder,
176
176
  ): Promise<PropertyMeta | null> {
177
- const base = await createCollectionContextMeta(collectionAccessor, title)();
177
+ const base = await createCollectionContextMeta(collectionAccessor, title, true)();
178
178
  if (!base) return null;
179
179
  return {
180
180
  ...base,