@nocobase/client-v2 2.1.20 → 2.1.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.20",
3
+ "version": "2.1.22",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "2.1.20",
31
- "@nocobase/flow-engine": "2.1.20",
32
- "@nocobase/sdk": "2.1.20",
33
- "@nocobase/shared": "2.1.20",
34
- "@nocobase/utils": "2.1.20",
30
+ "@nocobase/evaluators": "2.1.22",
31
+ "@nocobase/flow-engine": "2.1.22",
32
+ "@nocobase/sdk": "2.1.22",
33
+ "@nocobase/shared": "2.1.22",
34
+ "@nocobase/utils": "2.1.22",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -46,5 +46,5 @@
46
46
  "react-i18next": "^11.15.1",
47
47
  "react-router-dom": "^6.30.1"
48
48
  },
49
- "gitHead": "ca646ba28bb11da083e3436866552932ed780b6f"
49
+ "gitHead": "811fc3d272389739babdfa4338faf843d5bf04ff"
50
50
  }
@@ -115,6 +115,7 @@ export abstract class CollectionFieldInterface {
115
115
  titleUsable?: boolean;
116
116
  usePathOptions?(field: any): any;
117
117
  hidden?: boolean;
118
+ creatable?: boolean;
118
119
 
119
120
  addComponentOption(componentOption: CollectionFieldInterfaceComponentOption) {
120
121
  if (!this.componentOptions) {
@@ -31,6 +31,7 @@ interface FieldInterfaceLike {
31
31
  title?: ReactNode;
32
32
  group?: string;
33
33
  order?: number;
34
+ creatable?: boolean;
34
35
  isAssociation?: boolean;
35
36
  supportDataSourceType?: string[];
36
37
  notSupportDataSourceType?: string[];
@@ -60,7 +60,9 @@ type FlowPageProps = {
60
60
  type FlowPageViewContext = FlowEngineContext & {
61
61
  view?: {
62
62
  inputArgs?: {
63
+ filterByTk?: unknown;
63
64
  isMobileLayout?: unknown;
65
+ sourceId?: unknown;
64
66
  };
65
67
  };
66
68
  };
@@ -102,6 +104,10 @@ export const FlowPage = React.memo((props: FlowPageProps & Record<string, unknow
102
104
  subType: 'object',
103
105
  use: pageModelClass,
104
106
  };
107
+ const isRuntimeRecordScopedPage =
108
+ !flowEngine.context.flowSettingsEnabled &&
109
+ (typeof ctx?.view?.inputArgs?.filterByTk !== 'undefined' ||
110
+ typeof ctx?.view?.inputArgs?.sourceId !== 'undefined');
105
111
  if (shouldInjectDefaultChildTab) {
106
112
  const tabTitle = defaultTabTitle || flowEngine.translate?.('Details');
107
113
  options['subModels'] = {
@@ -128,7 +134,9 @@ export const FlowPage = React.memo((props: FlowPageProps & Record<string, unknow
128
134
  },
129
135
  };
130
136
  }
131
- const data = await flowEngine.loadOrCreateModel(options, { skipSave: !flowEngine.context.flowSettingsEnabled });
137
+ const data =
138
+ (isRuntimeRecordScopedPage && (await flowEngine.loadModel({ ...options, refresh: true }))) ||
139
+ (await flowEngine.loadOrCreateModel(options, { skipSave: !flowEngine.context.flowSettingsEnabled }));
132
140
  if (data?.uid && onModelLoaded) {
133
141
  data.context.addDelegate(ctx);
134
142
  bindViewLayoutState(data, ctx);
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { render, waitFor } from '@testing-library/react';
11
11
  import React from 'react';
12
- import { afterEach, describe, expect, it, vi } from 'vitest';
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
13
  import { FlowPage } from '../FlowPage';
14
14
 
15
15
  type TestContextProperty = {
@@ -71,10 +71,15 @@ const createPageModel = (): TestPageModel => {
71
71
  };
72
72
 
73
73
  const mocks = vi.hoisted(() => ({
74
+ flowSettingsEnabled: false,
75
+ loadModel: vi.fn(),
76
+ loadOrCreateModel: vi.fn(),
74
77
  rendererMobileStates: [] as boolean[],
75
78
  rendererProps: undefined as RendererProps | undefined,
76
79
  model: undefined as TestPageModel | undefined,
80
+ viewFilterByTk: undefined as unknown,
77
81
  viewInputMobileLayout: true as boolean | undefined,
82
+ viewSourceId: undefined as unknown,
78
83
  contextMobileLayout: undefined as boolean | undefined,
79
84
  }));
80
85
 
@@ -94,15 +99,20 @@ vi.mock('@nocobase/flow-engine', async () => {
94
99
  }),
95
100
  useFlowEngine: vi.fn(() => ({
96
101
  getModelClassAsync: vi.fn(async () => TestPageModelClass),
97
- loadOrCreateModel: vi.fn(async () => mocks.model),
98
- context: {},
102
+ loadModel: mocks.loadModel,
103
+ loadOrCreateModel: mocks.loadOrCreateModel,
104
+ context: {
105
+ flowSettingsEnabled: mocks.flowSettingsEnabled,
106
+ },
99
107
  translate: vi.fn((value: string) => value),
100
108
  })),
101
109
  useFlowModelById: vi.fn(() => mocks.model),
102
110
  useFlowViewContext: vi.fn(() => ({
103
111
  view: {
104
112
  inputArgs: {
113
+ ...(typeof mocks.viewFilterByTk !== 'undefined' ? { filterByTk: mocks.viewFilterByTk } : {}),
105
114
  ...(typeof mocks.viewInputMobileLayout === 'boolean' ? { isMobileLayout: mocks.viewInputMobileLayout } : {}),
115
+ ...(typeof mocks.viewSourceId !== 'undefined' ? { sourceId: mocks.viewSourceId } : {}),
106
116
  },
107
117
  },
108
118
  ...(typeof mocks.contextMobileLayout === 'boolean' ? { isMobileLayout: mocks.contextMobileLayout } : {}),
@@ -154,11 +164,21 @@ vi.mock('ahooks', async () => {
154
164
  });
155
165
 
156
166
  describe('FlowPage', () => {
167
+ beforeEach(() => {
168
+ mocks.loadModel.mockImplementation(async () => null);
169
+ mocks.loadOrCreateModel.mockImplementation(async () => mocks.model);
170
+ });
171
+
157
172
  afterEach(() => {
173
+ mocks.flowSettingsEnabled = false;
174
+ mocks.loadModel.mockReset();
175
+ mocks.loadOrCreateModel.mockReset();
158
176
  mocks.rendererMobileStates = [];
159
177
  mocks.rendererProps = undefined;
160
178
  mocks.model = undefined;
179
+ mocks.viewFilterByTk = undefined;
161
180
  mocks.viewInputMobileLayout = true;
181
+ mocks.viewSourceId = undefined;
162
182
  mocks.contextMobileLayout = undefined;
163
183
  });
164
184
 
@@ -203,4 +223,31 @@ describe('FlowPage', () => {
203
223
  expect(mocks.rendererMobileStates[0]).toBe(expected);
204
224
  },
205
225
  );
226
+
227
+ it('reloads runtime page models for record scoped views', async () => {
228
+ mocks.model = createPageModel();
229
+ mocks.viewFilterByTk = 1;
230
+ mocks.loadModel.mockImplementation(async () => mocks.model);
231
+
232
+ render(<FlowPage onModelLoaded={vi.fn()} />);
233
+
234
+ await waitFor(() => {
235
+ expect(mocks.loadModel).toHaveBeenCalled();
236
+ });
237
+ expect(mocks.loadModel.mock.calls[0]?.[0]).toMatchObject({ refresh: true });
238
+ expect(mocks.loadOrCreateModel).not.toHaveBeenCalled();
239
+ });
240
+
241
+ it('keeps configured page model cache behavior in flow settings mode', async () => {
242
+ mocks.flowSettingsEnabled = true;
243
+ mocks.model = createPageModel();
244
+ mocks.viewFilterByTk = 1;
245
+
246
+ render(<FlowPage onModelLoaded={vi.fn()} />);
247
+
248
+ await waitFor(() => {
249
+ expect(mocks.loadOrCreateModel).toHaveBeenCalled();
250
+ });
251
+ expect(mocks.loadModel).not.toHaveBeenCalled();
252
+ });
206
253
  });
@@ -1028,18 +1028,22 @@ export const FieldAssignValueInput: React.FC<Props> = ({
1028
1028
  collectionField: effectiveCollectionField,
1029
1029
  });
1030
1030
 
1031
- const created = engine?.createModel?.({
1032
- use: 'VariableFieldFormModel',
1033
- subModels: {
1034
- fields: [
1035
- {
1036
- use: effectiveFieldModelUse,
1037
- stepParams: tempFieldStepParams,
1038
- props: tempFieldProps,
1039
- },
1040
- ],
1031
+ const sourceContext = resolved?.itemModel?.context || flowCtx.model?.context;
1032
+ const created = engine?.createModel?.(
1033
+ {
1034
+ use: 'VariableFieldFormModel',
1035
+ subModels: {
1036
+ fields: [
1037
+ {
1038
+ use: effectiveFieldModelUse,
1039
+ stepParams: tempFieldStepParams,
1040
+ props: tempFieldProps,
1041
+ },
1042
+ ],
1043
+ },
1041
1044
  },
1042
- });
1045
+ sourceContext ? { delegate: sourceContext } : undefined,
1046
+ );
1043
1047
  if (!created) return;
1044
1048
 
1045
1049
  // 注入上下文(集合/数据源/字段/区块/资源)
@@ -0,0 +1,134 @@
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 React from 'react';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { render, waitFor } from '@nocobase/test/client';
13
+ import { FieldAssignValueInput } from '../FieldAssignValueInput';
14
+
15
+ const { mockUseFlowContext, mockGetDefaultBindingByField } = vi.hoisted(() => ({
16
+ mockUseFlowContext: vi.fn(),
17
+ mockGetDefaultBindingByField: vi.fn(),
18
+ }));
19
+
20
+ vi.mock('@nocobase/flow-engine', async () => {
21
+ const actual = await vi.importActual<typeof import('@nocobase/flow-engine')>('@nocobase/flow-engine');
22
+ class MockEditableItemModel extends actual.EditableItemModel {}
23
+ MockEditableItemModel.getDefaultBindingByField = mockGetDefaultBindingByField;
24
+
25
+ return {
26
+ ...actual,
27
+ useFlowContext: () => mockUseFlowContext(),
28
+ VariableInput: () => <div data-testid="variable-input" />,
29
+ FlowModelRenderer: () => <div data-testid="flow-model-renderer" />,
30
+ EditableItemModel: MockEditableItemModel,
31
+ };
32
+ });
33
+
34
+ describe('FieldAssignValueInput context', () => {
35
+ it('delegates temporary field model context to the source form context', async () => {
36
+ type MockContext = {
37
+ dataSourceManager: { getDataSource: ReturnType<typeof vi.fn> };
38
+ t: (key: string) => string;
39
+ engine?: { createModel: ReturnType<typeof vi.fn> };
40
+ collection?: MockCollection;
41
+ blockModel?: MockFormModel;
42
+ };
43
+ type MockFieldModel = {
44
+ props: Record<string, unknown>;
45
+ setProps: ReturnType<typeof vi.fn>;
46
+ dispatchEvent: ReturnType<typeof vi.fn>;
47
+ remove: ReturnType<typeof vi.fn>;
48
+ };
49
+ type MockCollectionField = {
50
+ name: string;
51
+ interface: string;
52
+ uiSchema: { enum: Array<{ label: string; value: string }> };
53
+ isAssociationField: () => boolean;
54
+ getComponentProps: () => Record<string, unknown>;
55
+ };
56
+ type MockCollection = {
57
+ dataSourceKey: string;
58
+ name: string;
59
+ getField: (name: string) => MockCollectionField | null;
60
+ getFields: () => MockCollectionField[];
61
+ };
62
+ type MockFormModel = {
63
+ context: MockContext;
64
+ collection: MockCollection;
65
+ subModels: Record<string, unknown>;
66
+ };
67
+
68
+ const sourceContext: MockContext = {
69
+ dataSourceManager: {
70
+ getDataSource: vi.fn(() => ({})),
71
+ },
72
+ t: (key: string) => key,
73
+ };
74
+ const fieldModel: MockFieldModel = {
75
+ props: {},
76
+ setProps: vi.fn((props: Record<string, unknown>) => {
77
+ fieldModel.props = { ...fieldModel.props, ...props };
78
+ }),
79
+ dispatchEvent: vi.fn(),
80
+ remove: vi.fn(),
81
+ };
82
+ const tempRoot = {
83
+ context: {
84
+ defineProperty: vi.fn(),
85
+ },
86
+ subModels: {
87
+ fields: [fieldModel],
88
+ },
89
+ setProps: vi.fn(),
90
+ remove: vi.fn(),
91
+ };
92
+ const engine = {
93
+ createModel: vi.fn(() => tempRoot),
94
+ };
95
+ sourceContext.engine = engine;
96
+
97
+ const collectionField: MockCollectionField = {
98
+ name: 'status',
99
+ interface: 'select',
100
+ uiSchema: {
101
+ enum: [{ label: 'Open', value: 'open' }],
102
+ },
103
+ isAssociationField: () => false,
104
+ getComponentProps: () => ({}),
105
+ };
106
+ const collection: MockCollection = {
107
+ dataSourceKey: 'main',
108
+ name: 'tasks',
109
+ getField: (name: string) => (name === 'status' ? collectionField : null),
110
+ getFields: () => [collectionField],
111
+ };
112
+ const formModel: MockFormModel = {
113
+ context: sourceContext,
114
+ collection,
115
+ subModels: {},
116
+ };
117
+ sourceContext.collection = collection;
118
+ sourceContext.blockModel = formModel;
119
+
120
+ mockGetDefaultBindingByField.mockReturnValue({ modelName: 'SelectFieldModel' });
121
+ mockUseFlowContext.mockReturnValue({
122
+ model: formModel,
123
+ t: (key: string) => key,
124
+ getPropertyMetaTree: vi.fn(async () => []),
125
+ });
126
+
127
+ render(<FieldAssignValueInput targetPath="status" value="" onChange={vi.fn()} />);
128
+
129
+ await waitFor(() => {
130
+ expect(engine.createModel).toHaveBeenCalled();
131
+ });
132
+ expect(engine.createModel).toHaveBeenCalledWith(expect.any(Object), { delegate: sourceContext });
133
+ });
134
+ });
@@ -432,6 +432,7 @@ export class FilterFormBlockModel extends FilterBlockModel<{
432
432
 
433
433
  private canApplyFormDefaultValue(name: string, current: any, force?: boolean) {
434
434
  if (force) return true;
435
+ if (this.userEditedFieldNames.has(name)) return false;
435
436
  if (isEmptyValue(current)) return true;
436
437
  if (!this.lastDefaultValueByFieldName.has(name)) return false;
437
438
  return isEqual(current, this.lastDefaultValueByFieldName.get(name));
@@ -62,9 +62,25 @@ function isRecord(value: unknown): value is Record<string, unknown> {
62
62
  return !!value && typeof value === 'object' && !Array.isArray(value);
63
63
  }
64
64
 
65
+ interface CollectionFieldWithAssociationMarker {
66
+ isAssociationField?: () => boolean;
67
+ target?: unknown;
68
+ }
69
+
70
+ function hasAssociationMarker(value: unknown): value is CollectionFieldWithAssociationMarker {
71
+ return isRecord(value) && (typeof value.isAssociationField === 'function' || 'target' in value);
72
+ }
73
+
74
+ const isAssociationCollectionField = (collectionField: unknown) => {
75
+ if (!hasAssociationMarker(collectionField)) {
76
+ return false;
77
+ }
78
+ return collectionField.isAssociationField ? collectionField.isAssociationField() : Boolean(collectionField.target);
79
+ };
80
+
65
81
  const normalizeAssociationDefaultFilterValue = (value: any, fieldModel: any) => {
66
82
  const collectionField = fieldModel?.context?.collectionField;
67
- if (!collectionField?.isAssociationField?.()) {
83
+ if (!isAssociationCollectionField(collectionField)) {
68
84
  return value;
69
85
  }
70
86
 
@@ -171,8 +187,7 @@ const buildFilterFormFieldItem = ({
171
187
  if (!binding) {
172
188
  return;
173
189
  }
174
- const isAssociation =
175
- typeof field?.isAssociationField === 'function' ? field.isAssociationField() : Boolean(field?.target);
190
+ const isAssociation = isAssociationCollectionField(field);
176
191
  const fieldModel =
177
192
  isAssociation && ctxWithFlags.engine?.getModelClass?.('FilterFormRecordSelectFieldModel')
178
193
  ? 'FilterFormRecordSelectFieldModel'
@@ -507,10 +522,7 @@ export class FilterFormItemModel extends FilterableItemModel<{
507
522
  const formValue = this.context.form?.getFieldValue(this.props.name);
508
523
  const modelValue = fieldModel.getFilterValue ? fieldModel.getFilterValue() : formValue;
509
524
  const collectionField = (fieldModel as any)?.context?.collectionField;
510
- const isAssociationField =
511
- typeof collectionField?.isAssociationField === 'function'
512
- ? collectionField.isAssociationField()
513
- : !!collectionField?.target;
525
+ const isAssociationField = isAssociationCollectionField(collectionField);
514
526
  const shouldUseFormValue =
515
527
  isAssociationField &&
516
528
  !this.mounted &&
@@ -547,11 +559,7 @@ export class FilterFormItemModel extends FilterableItemModel<{
547
559
  return value;
548
560
  }
549
561
  const collectionField = (fieldModel as any)?.context?.collectionField;
550
- const isAssociation =
551
- typeof collectionField?.isAssociationField === 'function'
552
- ? collectionField.isAssociationField()
553
- : !!collectionField?.target;
554
- if (!isAssociation) {
562
+ if (!isAssociationCollectionField(collectionField)) {
555
563
  return value;
556
564
  }
557
565
  const normalizedFieldNames = normalizeAssociationFieldNames(
@@ -798,6 +806,13 @@ FilterFormItemModel.registerFlow({
798
806
  },
799
807
  defaultOperator: {
800
808
  use: 'defaultOperator',
809
+ hideInSettings(ctx) {
810
+ const collectionField =
811
+ ctx.collectionField ||
812
+ ctx.model?.context?.collectionField ||
813
+ ctx.model?.subModels?.field?.context?.collectionField;
814
+ return isAssociationCollectionField(collectionField);
815
+ },
801
816
  },
802
817
  operatorComponentProps: {
803
818
  use: 'operatorComponentProps',
@@ -33,6 +33,42 @@ class DummyCollectionBlockModel extends CollectionBlockModel {
33
33
  }
34
34
 
35
35
  describe('FilterFormItemModel defineChildren association fields', () => {
36
+ it('hides default operator setting for association filter fields', () => {
37
+ const engine = new FlowEngine();
38
+ engine.registerModels({
39
+ FilterFormItemModel,
40
+ });
41
+
42
+ const filterItem = engine.createModel<FilterFormItemModel>({
43
+ uid: 'association-filter-item-settings',
44
+ use: 'FilterFormItemModel',
45
+ });
46
+
47
+ const defaultOperatorStep = filterItem.getFlow('filterFormItemSettings')?.steps?.defaultOperator as {
48
+ hideInSettings?: (ctx: {
49
+ collectionField?: unknown;
50
+ model?: {
51
+ subModels?: {
52
+ field?: {
53
+ context?: {
54
+ collectionField?: unknown;
55
+ };
56
+ };
57
+ };
58
+ };
59
+ }) => boolean;
60
+ };
61
+ expect(defaultOperatorStep?.hideInSettings?.({ collectionField: { isAssociationField: () => true } })).toBe(true);
62
+ expect(
63
+ defaultOperatorStep?.hideInSettings?.({
64
+ model: { subModels: { field: { context: { collectionField: { target: 'departments' } } } } },
65
+ }),
66
+ ).toBe(true);
67
+ expect(defaultOperatorStep?.hideInSettings?.({ collectionField: { interface: 'input', type: 'string' } })).toBe(
68
+ false,
69
+ );
70
+ });
71
+
36
72
  it('groups association target fields and supports recursive paths', async () => {
37
73
  const engine = new FlowEngine();
38
74
  engine.registerModels({
@@ -301,6 +301,40 @@ describe('filter-form defaultValues wiring', () => {
301
301
  expect(values.username_user).toBe('Manual');
302
302
  });
303
303
 
304
+ it('does not reapply a filter form default value after user clears the field', async () => {
305
+ const { model, values } = createFilterFormDefaultValuesModel([
306
+ {
307
+ key: 'username-default',
308
+ enable: true,
309
+ targetPath: 'username',
310
+ mode: 'default',
311
+ value: 'admin',
312
+ },
313
+ ]);
314
+
315
+ await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any);
316
+ expect(values.username_user).toBe('admin');
317
+
318
+ values.username_user = undefined;
319
+ (model as any).handleFilterFormValuesChange({ username_user: undefined }, { username_user: undefined });
320
+
321
+ await waitFor(() => {
322
+ expect(model.dispatchEvent).toHaveBeenCalledWith(
323
+ 'formValuesChange',
324
+ {
325
+ changedValues: {
326
+ username_user: undefined,
327
+ },
328
+ allValues: {
329
+ username_user: undefined,
330
+ },
331
+ },
332
+ { debounce: true },
333
+ );
334
+ });
335
+ expect(values.username_user).toBeUndefined();
336
+ });
337
+
304
338
  it('applies fixed values even when the target filter field already has a value', async () => {
305
339
  const { model, values } = createFilterFormDefaultValuesModel(
306
340
  [