@nocobase/client-v2 2.1.19 → 2.1.21

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.19",
3
+ "version": "2.1.21",
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.19",
31
- "@nocobase/flow-engine": "2.1.19",
32
- "@nocobase/sdk": "2.1.19",
33
- "@nocobase/shared": "2.1.19",
34
- "@nocobase/utils": "2.1.19",
30
+ "@nocobase/evaluators": "2.1.21",
31
+ "@nocobase/flow-engine": "2.1.21",
32
+ "@nocobase/sdk": "2.1.21",
33
+ "@nocobase/shared": "2.1.21",
34
+ "@nocobase/utils": "2.1.21",
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": "c316683666cfb478cb355b971c8f067a4f925fcb"
49
+ "gitHead": "491141417d8338022651c9e99afbd2d84c1e2b0b"
50
50
  }
@@ -431,6 +431,10 @@ export abstract class BaseApplication<
431
431
  return getSubAppName(this.getPublicPath()) || null;
432
432
  }
433
433
 
434
+ getCdnUrl() {
435
+ return window['__webpack_public_path__'] || this.getPublicPath();
436
+ }
437
+
434
438
  getPublicPath() {
435
439
  let publicPath = this.options.publicPath || '/';
436
440
  if (!publicPath.endsWith('/')) {
@@ -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));
@@ -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
  [
@@ -22,7 +22,6 @@ import {
22
22
  createRecordResolveOnServerWithLocal,
23
23
  } from '@nocobase/flow-engine';
24
24
  import { Button, Form, Skeleton, Space } from 'antd';
25
- import _ from 'lodash';
26
25
  import React from 'react';
27
26
  import { FieldModel } from '../../base/FieldModel';
28
27
  import { FormComponent } from './FormBlockModel';
@@ -46,8 +45,6 @@ export class QuickEditFormModel extends FlowModel {
46
45
  declare resource: SingleRecordResource;
47
46
  declare collection: Collection;
48
47
 
49
- now: number = Date.now();
50
-
51
48
  viewContainer: any;
52
49
  __onSubmitSuccess;
53
50
  _fieldProps: any;
@@ -89,21 +86,22 @@ export class QuickEditFormModel extends FlowModel {
89
86
  onOk,
90
87
  sourceFieldModelUid,
91
88
  } = options;
92
- const model = flowEngine.createModel({
93
- use: 'QuickEditFormModel',
94
- stepParams: {
95
- quickEditFormSettings: {
96
- init: {
97
- dataSourceKey,
98
- collectionName,
99
- fieldPath,
89
+ const sourceFieldModel = sourceFieldModelUid ? flowEngine.getModel<FlowModel>(sourceFieldModelUid) : undefined;
90
+ const model = flowEngine.createModel(
91
+ {
92
+ use: 'QuickEditFormModel',
93
+ stepParams: {
94
+ quickEditFormSettings: {
95
+ init: {
96
+ dataSourceKey,
97
+ collectionName,
98
+ fieldPath,
99
+ },
100
100
  },
101
101
  },
102
102
  },
103
- }) as QuickEditFormModel;
104
-
105
- console.log('QuickEditFormModel.open2', Date.now() - model.now);
106
- model.now = Date.now();
103
+ sourceFieldModel ? { delegate: sourceFieldModel.context } : undefined,
104
+ ) as QuickEditFormModel;
107
105
 
108
106
  await flowEngine.context.viewer.open({
109
107
  type: 'popover',
@@ -122,7 +120,6 @@ export class QuickEditFormModel extends FlowModel {
122
120
  model.__onSubmitSuccess = onSuccess;
123
121
  model._fieldProps = fieldProps;
124
122
  model._onOk = onOk;
125
- console.log('QuickEditFormModel.open3', Date.now() - model.now);
126
123
  return (
127
124
  <FlowModelRenderer
128
125
  fallback={<Skeleton.Input size="small" />}
@@ -171,8 +168,6 @@ export class QuickEditFormModel extends FlowModel {
171
168
  }
172
169
 
173
170
  render() {
174
- console.log('QuickEditFormModel.open4', Date.now() - this.now);
175
-
176
171
  return (
177
172
  <FormComponent model={this}>
178
173
  <div
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, it, expect, vi, beforeEach } from 'vitest';
11
- import { FlowEngine, SingleRecordResource } from '@nocobase/flow-engine';
11
+ import { FlowEngine, FlowModel, SingleRecordResource } from '@nocobase/flow-engine';
12
12
  import { QuickEditFormModel } from '../QuickEditFormModel';
13
13
 
14
14
  describe('QuickEditFormModel - quick edit save triggers API (regression)', () => {
@@ -18,6 +18,35 @@ describe('QuickEditFormModel - quick edit save triggers API (regression)', () =>
18
18
  engine = new FlowEngine();
19
19
  });
20
20
 
21
+ it('uses source field context when opening quick edit', async () => {
22
+ engine.registerModels({ QuickEditFormModel });
23
+ engine.context.defineProperty('pageActive', { value: { value: false } });
24
+ engine.context.defineProperty('viewer', { value: { open: vi.fn(async () => undefined) } });
25
+
26
+ const page = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'page' });
27
+ page.context.defineProperty('pageActive', { value: { value: true } });
28
+ const source = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'source-field', parentId: page.uid });
29
+
30
+ await QuickEditFormModel.open({
31
+ flowEngine: engine,
32
+ target: document.createElement('div'),
33
+ dataSourceKey: 'main',
34
+ collectionName: 'users',
35
+ fieldPath: 'name',
36
+ record: {},
37
+ sourceFieldModelUid: source.uid,
38
+ });
39
+
40
+ let quickEditModel: QuickEditFormModel | undefined;
41
+ engine.forEachModel((model) => {
42
+ if (model instanceof QuickEditFormModel) {
43
+ quickEditModel = model;
44
+ }
45
+ });
46
+
47
+ expect(quickEditModel?.context.pageActive.value).toBe(true);
48
+ });
49
+
21
50
  it('calls update with filterByTk and merges primary key from ctx.collection/record', async () => {
22
51
  // 1) 准备数据源与集合(含主键字段)
23
52
  const dsm = engine.context.dataSourceManager;
@@ -126,6 +126,7 @@ const RenderCell = observer<any>((props) => {
126
126
  fieldPath: dataIndex,
127
127
  record: record,
128
128
  fieldProps: { ...columnModel.props, ...columnModel.subModels.field.props },
129
+ sourceFieldModelUid: columnModel.subModels.field.uid,
129
130
  onOk: (values) => {
130
131
  record[dataIndex] = values[dataIndex];
131
132
  // 仅重渲染单元格