@nocobase/client-v2 2.2.30 → 2.2.32

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.2.30",
3
+ "version": "2.2.32",
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.2.30",
31
- "@nocobase/flow-engine": "2.2.30",
32
- "@nocobase/sdk": "2.2.30",
33
- "@nocobase/shared": "2.2.30",
34
- "@nocobase/utils": "2.2.30",
30
+ "@nocobase/evaluators": "2.2.32",
31
+ "@nocobase/flow-engine": "2.2.32",
32
+ "@nocobase/sdk": "2.2.32",
33
+ "@nocobase/shared": "2.2.32",
34
+ "@nocobase/utils": "2.2.32",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -49,5 +49,5 @@
49
49
  "react-router-dom": "^6.30.1",
50
50
  "zxing-wasm": "^3.1.3"
51
51
  },
52
- "gitHead": "7042ebef45742005aa38cd78c366e3e7f4ffed52"
52
+ "gitHead": "04fa0d6f0fbc637f59a1c8a40a7ce038e13d267c"
53
53
  }
@@ -31,6 +31,7 @@ import {
31
31
  isDateLikeField,
32
32
  resolveDateVariableComponentProps,
33
33
  } from '../../../components/field-value-variable';
34
+ import { isArrayValueField, normalizeAssignFormValue } from './assignFormValue';
34
35
 
35
36
  type AssignFormTempOriginField = {
36
37
  uid?: string;
@@ -112,8 +113,6 @@ export class AssignFormItemModel extends FormItemModel {
112
113
  }
113
114
 
114
115
  onInit(options: any) {
115
- const initAssign = this.getStepParams('fieldSettings', 'assignValue')?.value;
116
- if (typeof initAssign !== 'undefined') this.assignValue = initAssign;
117
116
  this.context.defineProperty('collectionField', {
118
117
  get: () => {
119
118
  const params = this.getStepParams('fieldSettings', 'init') || {};
@@ -122,6 +121,10 @@ export class AssignFormItemModel extends FormItemModel {
122
121
  return dsm?.getCollectionField?.(key);
123
122
  },
124
123
  });
124
+ const initAssign = this.getStepParams('fieldSettings', 'assignValue')?.value;
125
+ if (typeof initAssign !== 'undefined') {
126
+ this.assignValue = normalizeAssignFormValue(initAssign, isArrayValueField(this.collectionField));
127
+ }
125
128
 
126
129
  // 确保存在 subModels.field,便于沿用 FormItemModel 的组件切换/设置步骤(fieldComponent 等)
127
130
  const ctx: any = this.context;
@@ -168,7 +171,7 @@ export class AssignFormItemModel extends FormItemModel {
168
171
  getAssignedEntry(): [string, any] | null {
169
172
  const name = this.fieldPath;
170
173
  if (!name || !this.collectionField) return null;
171
- return [name, this.assignValue];
174
+ return [name, normalizeAssignFormValue(this.assignValue, isArrayValueField(this.collectionField))];
172
175
  }
173
176
 
174
177
  render() {
@@ -184,6 +187,7 @@ export class AssignFormItemModel extends FormItemModel {
184
187
  const fieldPath = this.fieldPath;
185
188
  const originField = this.subModels?.field as AssignFormTempOriginField | undefined;
186
189
  const tempFieldRefreshKey = getAssignFormTempFieldRefreshKey(originField);
190
+ const arrayValueField = isArrayValueField(this.collectionField);
187
191
 
188
192
  const FieldRow: React.FC = () => {
189
193
  const [tempRoot, setTempRoot] = React.useState<any>(null);
@@ -252,6 +256,14 @@ export class AssignFormItemModel extends FormItemModel {
252
256
  fm?.dispatchEvent?.('beforeRender', undefined, { sequential: true, useCache: true });
253
257
  // 为本地枚举型字段补全可选项(仅在未显式传入 options 时处理)
254
258
  ensureOptionsFromUiSchemaEnumIfAbsent(fm as any, cfForMultiple as any);
259
+ // `mode` is the antd Select switch for multi-select. It is normally injected by
260
+ // `collectionField.getComponentProps()`, which the temp field does not apply.
261
+ // Apply the fallback after `beforeRender` so field flows keep precedence; a
262
+ // schema-provided mode is used when present, otherwise default to `multiple`.
263
+ if (cfForMultiple?.interface === 'multipleSelect' && fm?.props?.mode === undefined) {
264
+ const schemaMode = cfForMultiple?.uiSchema?.['x-component-props']?.mode as string | undefined;
265
+ fm?.setProps?.({ mode: schemaMode ?? 'multiple' });
266
+ }
255
267
  if (!fm?.props?.fieldNames && cfForMultiple?.targetCollection) {
256
268
  const targetCol = cfForMultiple.targetCollection;
257
269
  const valueKey = cfForMultiple?.targetKey || targetCol?.filterTargetKey || 'id';
@@ -268,16 +280,17 @@ export class AssignFormItemModel extends FormItemModel {
268
280
  const ConstantValueEditor: React.FC<any> = (inputProps: any) => {
269
281
  React.useEffect(() => {
270
282
  if (!tempRoot) return;
283
+ const isArrayField = isArrayValueField(this.collectionField);
271
284
  // 同步根 props(变量输入的受控 value/onChange)
272
285
  tempRoot.setProps?.({ ...inputProps });
273
286
  // 同步到具体字段模型,确保字段组件能触发 onChange
274
287
  const fm = tempRoot?.subModels?.fields?.[0];
275
288
  if (fm) {
276
289
  fm.setProps?.({
277
- value: inputProps?.value,
290
+ value: normalizeAssignFormValue(inputProps?.value, isArrayField),
278
291
  onChange: (...args: unknown[]) => {
279
292
  const nextArg = args && args.length ? args[0] : undefined;
280
- const nextValue = normalizeAssignFormInputValue(nextArg);
293
+ const nextValue = normalizeAssignFormValue(normalizeAssignFormInputValue(nextArg), isArrayField);
281
294
  fm.setProps?.({ value: nextValue });
282
295
  this.assignValue = nextValue;
283
296
  inputProps?.onChange?.(nextValue);
@@ -330,20 +343,24 @@ export class AssignFormItemModel extends FormItemModel {
330
343
  {...this.props}
331
344
  label={labelText}
332
345
  name={namePath}
333
- initialValue={this.assignValue}
346
+ initialValue={normalizeAssignFormValue(this.assignValue, arrayValueField)}
334
347
  valuePropName="__assign_value__"
335
348
  trigger="__assign_trigger__"
336
349
  validateTrigger="__assign_trigger__"
337
350
  >
338
351
  {React.createElement((formBindingProps: any) => {
339
352
  const formValue = formBindingProps?.__assign_value__;
340
- const mergedValue = typeof formValue === 'undefined' ? this.assignValue : formValue;
353
+ const mergedValue = normalizeAssignFormValue(
354
+ typeof formValue === 'undefined' ? this.assignValue : formValue,
355
+ arrayValueField,
356
+ );
341
357
  return (
342
358
  <FieldValueVariableInput
343
359
  value={mergedValue}
344
360
  onChange={(v) => {
345
- this.assignValue = v;
346
- formBindingProps?.__assign_trigger__?.(v);
361
+ const nextValue = normalizeAssignFormValue(v, arrayValueField);
362
+ this.assignValue = nextValue;
363
+ formBindingProps?.__assign_trigger__?.(nextValue);
347
364
  }}
348
365
  baseMetaTree={baseMetaTree}
349
366
  constantComponent={ConstantValueEditor}
@@ -0,0 +1,216 @@
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 { App, ConfigProvider } from 'antd';
12
+ import { describe, expect, it } from 'vitest';
13
+ import { render, waitFor } from '@nocobase/test/client';
14
+ import {
15
+ FlowEngine,
16
+ FlowEngineProvider,
17
+ FlowRuntimeContext,
18
+ FlowSettingsContextProvider,
19
+ type FlowModel,
20
+ type IFlowModelRepository,
21
+ } from '@nocobase/flow-engine';
22
+ import { AssignFormGridModel } from '../AssignFormGridModel';
23
+ import { AssignFormItemModel } from '../AssignFormItemModel';
24
+ import { AssignFormModel } from '../AssignFormModel';
25
+ import { createAssignFieldValuesStep } from '../assignFieldValuesFlow';
26
+ import { InputFieldModel } from '../../../fields/InputFieldModel';
27
+ import { SelectFieldModel } from '../../../fields/SelectFieldModel';
28
+ import { VariableFieldFormModel } from '../../../fields/VariableFieldFormModel';
29
+
30
+ class MockFlowModelRepository implements IFlowModelRepository {
31
+ async findOne(): Promise<Record<string, any> | null> {
32
+ return null;
33
+ }
34
+
35
+ async save(model: FlowModel): Promise<Record<string, any>> {
36
+ return { uid: model.uid };
37
+ }
38
+
39
+ async destroy(): Promise<boolean> {
40
+ return true;
41
+ }
42
+
43
+ async move(): Promise<void> {}
44
+
45
+ async duplicate(): Promise<Record<string, any> | null> {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function createEngine() {
51
+ const engine = new FlowEngine();
52
+ engine.setModelRepository(new MockFlowModelRepository());
53
+ engine.registerModels({
54
+ AssignFormModel,
55
+ AssignFormGridModel,
56
+ AssignFormItemModel,
57
+ InputFieldModel,
58
+ SelectFieldModel,
59
+ VariableFieldFormModel,
60
+ });
61
+ engine.context.defineProperty('location', { value: { search: '' } });
62
+ engine.context.defineProperty('themeToken', { value: { marginLG: 24 } });
63
+ return engine;
64
+ }
65
+
66
+ function addMultipleSelectCollection(engine: FlowEngine) {
67
+ const dsm = engine.context.dataSourceManager;
68
+ const main = dsm.getDataSource('main');
69
+ main.addCollection({
70
+ name: 'assign_multiple_select',
71
+ fields: [
72
+ {
73
+ name: 'tags',
74
+ type: 'array',
75
+ interface: 'multipleSelect',
76
+ uiSchema: {
77
+ type: 'array',
78
+ 'x-component': 'Select',
79
+ 'x-component-props': { mode: 'multiple' },
80
+ enum: [
81
+ { value: 'a', label: 'A' },
82
+ { value: 'b', label: 'B' },
83
+ ],
84
+ },
85
+ },
86
+ {
87
+ // Fields created before the interface default was persisted may miss `mode`.
88
+ name: 'legacyTags',
89
+ type: 'array',
90
+ interface: 'multipleSelect',
91
+ uiSchema: {
92
+ type: 'array',
93
+ 'x-component': 'Select',
94
+ enum: [{ value: 'c', label: 'C' }],
95
+ },
96
+ },
97
+ {
98
+ name: 'tagMode',
99
+ type: 'array',
100
+ interface: 'multipleSelect',
101
+ uiSchema: {
102
+ type: 'array',
103
+ 'x-component': 'Select',
104
+ 'x-component-props': { mode: 'tags' },
105
+ enum: [{ value: 't', label: 'T' }],
106
+ },
107
+ },
108
+ {
109
+ name: 'single',
110
+ type: 'string',
111
+ interface: 'select',
112
+ uiSchema: {
113
+ type: 'string',
114
+ 'x-component': 'Select',
115
+ enum: [{ value: 'x', label: 'X' }],
116
+ },
117
+ },
118
+ { name: 'nickname', type: 'string', interface: 'input' },
119
+ ],
120
+ });
121
+ return dsm.getCollection('main', 'assign_multiple_select');
122
+ }
123
+
124
+ async function renderAssignEditor(engine: FlowEngine, action: FlowModel) {
125
+ const step = createAssignFieldValuesStep({ settingsFlowKey: 'assignSettings' });
126
+ const Editor = step.uiSchema().editor?.['x-component'] as React.ComponentType;
127
+ const flowSettingsCtx = new FlowRuntimeContext(action as any, 'assignSettings', 'settings');
128
+
129
+ render(
130
+ <FlowEngineProvider engine={engine}>
131
+ <ConfigProvider>
132
+ <App>
133
+ <FlowSettingsContextProvider value={flowSettingsCtx}>
134
+ <Editor />
135
+ </FlowSettingsContextProvider>
136
+ </App>
137
+ </ConfigProvider>
138
+ </FlowEngineProvider>,
139
+ );
140
+ }
141
+
142
+ describe('assignFieldValuesFlow multipleSelect', () => {
143
+ it('renders multipleSelect as multi select and normalizes historical scalar values', async () => {
144
+ const engine = createEngine();
145
+ const collection = addMultipleSelectCollection(engine);
146
+
147
+ const action = engine.createModel({ use: 'FlowModel', uid: 'act-assign-multiple-select' });
148
+ action.setStepParams('assignSettings', 'assignFieldValues', {
149
+ assignedValues: {
150
+ tags: 'a',
151
+ legacyTags: 'c',
152
+ tagMode: 't',
153
+ single: 'x',
154
+ nickname: 'hello',
155
+ },
156
+ });
157
+ action.context.defineProperty('blockModel', { value: { collection } });
158
+
159
+ const tempFields: any[] = [];
160
+ const originalCreateModel = engine.createModel.bind(engine);
161
+ (engine as any).createModel = (options: any) => {
162
+ const model = originalCreateModel(options);
163
+ if (options?.use === 'VariableFieldFormModel') {
164
+ tempFields.push(model);
165
+ }
166
+ return model;
167
+ };
168
+
169
+ await renderAssignEditor(engine, action);
170
+
171
+ await waitFor(() => {
172
+ const form = engine.findModelByParentId<AssignFormModel>(action.uid, 'assignForm');
173
+ expect(form?.subModels.grid.subModels.items || []).toHaveLength(5);
174
+ });
175
+
176
+ const form = engine.findModelByParentId<AssignFormModel>(action.uid, 'assignForm');
177
+ await waitFor(() => {
178
+ expect(form?.getAssignedValues()).toEqual({
179
+ tags: ['a'],
180
+ legacyTags: ['c'],
181
+ tagMode: ['t'],
182
+ single: 'x',
183
+ nickname: 'hello',
184
+ });
185
+ });
186
+
187
+ await waitFor(() => {
188
+ // All three multipleSelect fields must render as antd multi select, including the
189
+ // field without schema `mode`; the normal select stays single.
190
+ expect(document.querySelectorAll('.ant-select-multiple')).toHaveLength(3);
191
+ expect(document.querySelectorAll('.ant-select-single').length).toBeGreaterThanOrEqual(1);
192
+ });
193
+
194
+ const findTempField = (fieldPath: string) =>
195
+ tempFields
196
+ .map((root) => root?.subModels?.fields?.[0])
197
+ .find((field) => field?.getStepParams?.('fieldSettings', 'init')?.fieldPath === fieldPath);
198
+ await waitFor(() => {
199
+ expect(findTempField('tags')?.props?.onChange).toBeTypeOf('function');
200
+ expect(findTempField('tags')?.props?.mode).toBe('multiple');
201
+ expect(findTempField('legacyTags')?.props?.mode).toBe('multiple');
202
+ // An explicit schema mode must be preserved by the fallback.
203
+ expect(findTempField('tagMode')?.props?.mode).toBe('tags');
204
+ });
205
+
206
+ findTempField('tags').props.onChange(['a', 'b']);
207
+ await waitFor(() => {
208
+ expect(form?.getAssignedValues()).toMatchObject({ tags: ['a', 'b'], single: 'x' });
209
+ });
210
+
211
+ findTempField('tags').props.onChange('');
212
+ await waitFor(() => {
213
+ expect(form?.getAssignedValues()).toMatchObject({ tags: [] });
214
+ });
215
+ });
216
+ });
@@ -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
+ import { describe, expect, it } from 'vitest';
11
+ import { isArrayValueField, normalizeAssignFormValue } from '../assignFormValue';
12
+
13
+ describe('assignFormValue', () => {
14
+ it('detects array-valued fields', () => {
15
+ expect(isArrayValueField({ interface: 'multipleSelect' })).toBe(true);
16
+ expect(isArrayValueField({ interface: 'checkboxGroup' })).toBe(true);
17
+ expect(isArrayValueField({ uiSchema: { type: 'array' } })).toBe(true);
18
+ expect(isArrayValueField({ interface: 'select', uiSchema: { type: 'string' } })).toBe(false);
19
+ expect(isArrayValueField(undefined)).toBe(false);
20
+ });
21
+
22
+ it('wraps scalar constants for array fields', () => {
23
+ expect(normalizeAssignFormValue('a', true)).toEqual(['a']);
24
+ expect(normalizeAssignFormValue(0, true)).toEqual([0]);
25
+ expect(normalizeAssignFormValue(false, true)).toEqual([false]);
26
+ expect(normalizeAssignFormValue(['a', 'b'], true)).toEqual(['a', 'b']);
27
+ });
28
+
29
+ it('keeps non-constant and empty values unchanged', () => {
30
+ expect(normalizeAssignFormValue('', true)).toEqual([]);
31
+ expect(normalizeAssignFormValue(null, true)).toBeNull();
32
+ expect(normalizeAssignFormValue(undefined, true)).toBeUndefined();
33
+ expect(normalizeAssignFormValue('{{ ctx.record.name }}', true)).toBe('{{ ctx.record.name }}');
34
+ expect(normalizeAssignFormValue({ code: 'return 1;' }, true)).toEqual({ code: 'return 1;' });
35
+ });
36
+
37
+ it('does not touch non-array fields', () => {
38
+ expect(normalizeAssignFormValue('a', false)).toBe('a');
39
+ expect(normalizeAssignFormValue(['a'], false)).toEqual(['a']);
40
+ });
41
+ });
@@ -0,0 +1,64 @@
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 { isRunJSValue, isVariableExpression } from '@nocobase/flow-engine';
11
+
12
+ type ArrayValueCollectionField =
13
+ | {
14
+ interface?: string;
15
+ uiSchema?: {
16
+ type?: string;
17
+ } | null;
18
+ }
19
+ | null
20
+ | undefined;
21
+
22
+ /**
23
+ * Fields whose stored value is an array. Besides `multipleSelect`/`checkboxGroup`,
24
+ * some legacy fields declare `uiSchema.type === 'array'` explicitly.
25
+ */
26
+ export function isArrayValueField(collectionField: ArrayValueCollectionField): boolean {
27
+ if (!collectionField) {
28
+ return false;
29
+ }
30
+ if (collectionField.interface === 'multipleSelect' || collectionField.interface === 'checkboxGroup') {
31
+ return true;
32
+ }
33
+ return collectionField.uiSchema?.type === 'array';
34
+ }
35
+
36
+ /**
37
+ * Normalize a constant assignment value for array-valued fields.
38
+ *
39
+ * Historical configurations may store a scalar (before the multi-select control
40
+ * was rendered correctly), so wrap it into an array instead of submitting a
41
+ * string to the server. Variable expressions, RunJS values, Null and empty
42
+ * values are kept as-is so the assignment mode is not changed.
43
+ */
44
+ export function normalizeAssignFormValue(value: unknown, arrayValueField: boolean): unknown {
45
+ if (!arrayValueField) {
46
+ return value;
47
+ }
48
+ if (isVariableExpression(value) || isRunJSValue(value)) {
49
+ return value;
50
+ }
51
+ if (value === '') {
52
+ return [];
53
+ }
54
+ if (Array.isArray(value)) {
55
+ return value;
56
+ }
57
+ if (value === null || typeof value === 'undefined') {
58
+ return value;
59
+ }
60
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
61
+ return [value];
62
+ }
63
+ return value;
64
+ }