@nocobase/client-v2 2.3.0-beta.2 → 2.3.0-beta.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.
Files changed (91) hide show
  1. package/es/RouteRepository.d.ts +6 -0
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
  3. package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
  4. package/es/flow/admin-shell/admin-layout/MobileTopbarActionsContext.d.ts +10 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  6. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
  7. package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
  8. package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
  9. package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
  10. package/es/flow/models/actions/LinkActionUtils.d.ts +1 -0
  11. package/es/flow/models/blocks/assign-form/assignFormValue.d.ts +29 -0
  12. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
  13. package/es/index.mjs +100 -100
  14. package/lib/index.js +102 -102
  15. package/package.json +9 -8
  16. package/src/BaseApplication.tsx +2 -0
  17. package/src/RouteRepository.ts +14 -0
  18. package/src/__tests__/RouteRepository.test.ts +51 -0
  19. package/src/__tests__/app.test.tsx +13 -0
  20. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  21. package/src/collection-manager/field-configure.ts +2 -2
  22. package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
  23. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
  24. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
  25. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  26. package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
  27. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  28. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  29. package/src/flow/admin-shell/admin-layout/MobileTopbarActionsContext.ts +13 -0
  30. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +13 -2
  31. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.mobile.test.tsx +205 -0
  32. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  33. package/src/flow/components/FieldAssignValueInput.tsx +4 -0
  34. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
  35. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
  36. package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
  37. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  38. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  39. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  40. package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
  41. package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
  42. package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
  43. package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
  44. package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
  45. package/src/flow/models/actions/LinkActionModel.tsx +11 -9
  46. package/src/flow/models/actions/LinkActionUtils.ts +4 -2
  47. package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
  48. package/src/flow/models/actions/__tests__/LinkActionModel.test.ts +14 -0
  49. package/src/flow/models/base/GridModel.tsx +0 -1
  50. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  51. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +40 -12
  52. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  53. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.multipleSelect.test.tsx +216 -0
  54. package/src/flow/models/blocks/assign-form/__tests__/assignFormValue.test.ts +41 -0
  55. package/src/flow/models/blocks/assign-form/assignFormValue.ts +64 -0
  56. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  57. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  58. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  59. package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
  60. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
  61. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
  62. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
  63. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  64. package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
  65. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
  66. package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
  67. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
  68. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +11 -2
  69. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
  70. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +6 -3
  71. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +13 -1
  72. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +47 -17
  73. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
  74. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.largeField.test.tsx +178 -0
  75. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
  76. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +26 -2
  77. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
  78. package/src/flow/models/fields/AssociationFieldModel/__tests__/associationSelect.search.test.ts +113 -0
  79. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  80. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  81. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  82. package/src/flow/models/fields/InputFieldModel.tsx +1 -1
  83. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  84. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  85. package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
  86. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +16 -0
  87. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
  88. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
  89. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -0
  90. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
  91. package/src/flow/models/topbar/UserCenterTopbarActionModel.tsx +8 -4
@@ -10,7 +10,14 @@
10
10
  import React from 'react';
11
11
  import { Input } from 'antd';
12
12
  import { define, observable } from '@formily/reactive';
13
- import { FlowModelRenderer, FormItem, tExpr, EditableItemModel, jioToJoiSchema } from '@nocobase/flow-engine';
13
+ import {
14
+ EditableItemModel,
15
+ FieldDeletePlaceholder,
16
+ FlowModelRenderer,
17
+ FormItem,
18
+ jioToJoiSchema,
19
+ tExpr,
20
+ } from '@nocobase/flow-engine';
14
21
  // 无需类型导入(避免未使用的类型)
15
22
  import { FormItemModel } from '../form/FormItemModel';
16
23
  import { EditFormModel } from '../form/EditFormModel';
@@ -24,6 +31,7 @@ import {
24
31
  isDateLikeField,
25
32
  resolveDateVariableComponentProps,
26
33
  } from '../../../components/field-value-variable';
34
+ import { isArrayValueField, normalizeAssignFormValue } from './assignFormValue';
27
35
 
28
36
  type AssignFormTempOriginField = {
29
37
  uid?: string;
@@ -105,8 +113,6 @@ export class AssignFormItemModel extends FormItemModel {
105
113
  }
106
114
 
107
115
  onInit(options: any) {
108
- const initAssign = this.getStepParams('fieldSettings', 'assignValue')?.value;
109
- if (typeof initAssign !== 'undefined') this.assignValue = initAssign;
110
116
  this.context.defineProperty('collectionField', {
111
117
  get: () => {
112
118
  const params = this.getStepParams('fieldSettings', 'init') || {};
@@ -115,6 +121,10 @@ export class AssignFormItemModel extends FormItemModel {
115
121
  return dsm?.getCollectionField?.(key);
116
122
  },
117
123
  });
124
+ const initAssign = this.getStepParams('fieldSettings', 'assignValue')?.value;
125
+ if (typeof initAssign !== 'undefined') {
126
+ this.assignValue = normalizeAssignFormValue(initAssign, isArrayValueField(this.collectionField));
127
+ }
118
128
 
119
129
  // 确保存在 subModels.field,便于沿用 FormItemModel 的组件切换/设置步骤(fieldComponent 等)
120
130
  const ctx: any = this.context;
@@ -160,11 +170,15 @@ export class AssignFormItemModel extends FormItemModel {
160
170
 
161
171
  getAssignedEntry(): [string, any] | null {
162
172
  const name = this.fieldPath;
163
- if (!name) return null;
164
- return [name, this.assignValue];
173
+ if (!name || !this.collectionField) return null;
174
+ return [name, normalizeAssignFormValue(this.assignValue, isArrayValueField(this.collectionField))];
165
175
  }
166
176
 
167
177
  render() {
178
+ if (!this.collectionField) {
179
+ return <FieldDeletePlaceholder />;
180
+ }
181
+
168
182
  // 与 FormItemModel.render 结构保持一致,仅替换内部渲染为 VariableInput + 常量编辑器
169
183
  const ctx: any = this.context;
170
184
  const collection = ctx.collection;
@@ -173,6 +187,7 @@ export class AssignFormItemModel extends FormItemModel {
173
187
  const fieldPath = this.fieldPath;
174
188
  const originField = this.subModels?.field as AssignFormTempOriginField | undefined;
175
189
  const tempFieldRefreshKey = getAssignFormTempFieldRefreshKey(originField);
190
+ const arrayValueField = isArrayValueField(this.collectionField);
176
191
 
177
192
  const FieldRow: React.FC = () => {
178
193
  const [tempRoot, setTempRoot] = React.useState<any>(null);
@@ -241,6 +256,14 @@ export class AssignFormItemModel extends FormItemModel {
241
256
  fm?.dispatchEvent?.('beforeRender', undefined, { sequential: true, useCache: true });
242
257
  // 为本地枚举型字段补全可选项(仅在未显式传入 options 时处理)
243
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
+ }
244
267
  if (!fm?.props?.fieldNames && cfForMultiple?.targetCollection) {
245
268
  const targetCol = cfForMultiple.targetCollection;
246
269
  const valueKey = cfForMultiple?.targetKey || targetCol?.filterTargetKey || 'id';
@@ -257,16 +280,17 @@ export class AssignFormItemModel extends FormItemModel {
257
280
  const ConstantValueEditor: React.FC<any> = (inputProps: any) => {
258
281
  React.useEffect(() => {
259
282
  if (!tempRoot) return;
283
+ const isArrayField = isArrayValueField(this.collectionField);
260
284
  // 同步根 props(变量输入的受控 value/onChange)
261
285
  tempRoot.setProps?.({ ...inputProps });
262
286
  // 同步到具体字段模型,确保字段组件能触发 onChange
263
287
  const fm = tempRoot?.subModels?.fields?.[0];
264
288
  if (fm) {
265
289
  fm.setProps?.({
266
- value: inputProps?.value,
290
+ value: normalizeAssignFormValue(inputProps?.value, isArrayField),
267
291
  onChange: (...args: unknown[]) => {
268
292
  const nextArg = args && args.length ? args[0] : undefined;
269
- const nextValue = normalizeAssignFormInputValue(nextArg);
293
+ const nextValue = normalizeAssignFormValue(normalizeAssignFormInputValue(nextArg), isArrayField);
270
294
  fm.setProps?.({ value: nextValue });
271
295
  this.assignValue = nextValue;
272
296
  inputProps?.onChange?.(nextValue);
@@ -319,20 +343,24 @@ export class AssignFormItemModel extends FormItemModel {
319
343
  {...this.props}
320
344
  label={labelText}
321
345
  name={namePath}
322
- initialValue={this.assignValue}
346
+ initialValue={normalizeAssignFormValue(this.assignValue, arrayValueField)}
323
347
  valuePropName="__assign_value__"
324
348
  trigger="__assign_trigger__"
325
349
  validateTrigger="__assign_trigger__"
326
350
  >
327
351
  {React.createElement((formBindingProps: any) => {
328
352
  const formValue = formBindingProps?.__assign_value__;
329
- const mergedValue = typeof formValue === 'undefined' ? this.assignValue : formValue;
353
+ const mergedValue = normalizeAssignFormValue(
354
+ typeof formValue === 'undefined' ? this.assignValue : formValue,
355
+ arrayValueField,
356
+ );
330
357
  return (
331
358
  <FieldValueVariableInput
332
359
  value={mergedValue}
333
360
  onChange={(v) => {
334
- this.assignValue = v;
335
- formBindingProps?.__assign_trigger__?.(v);
361
+ const nextValue = normalizeAssignFormValue(v, arrayValueField);
362
+ this.assignValue = nextValue;
363
+ formBindingProps?.__assign_trigger__?.(nextValue);
336
364
  }}
337
365
  baseMetaTree={baseMetaTree}
338
366
  constantComponent={ConstantValueEditor}
@@ -392,7 +420,7 @@ AssignFormItemModel.registerFlow({
392
420
  },
393
421
  defaultParams: (ctx) => {
394
422
  return {
395
- label: (ctx.model as any).collectionField.title,
423
+ label: (ctx.model as any).collectionField?.title || (ctx.model as any).fieldPath,
396
424
  };
397
425
  },
398
426
  handler(ctx, params) {
@@ -47,6 +47,146 @@ class MockFlowModelRepository implements IFlowModelRepository {
47
47
  }
48
48
 
49
49
  describe('assignFieldValuesFlow (editor)', () => {
50
+ it('shows the deleted-field placeholder for a stale assigned value', async () => {
51
+ const engine = new FlowEngine();
52
+ engine.setModelRepository(new MockFlowModelRepository());
53
+ engine.registerModels({
54
+ AssignFormModel,
55
+ AssignFormGridModel,
56
+ AssignFormItemModel,
57
+ InputFieldModel,
58
+ VariableFieldFormModel,
59
+ });
60
+ engine.context.defineProperty('location', { value: { search: '' } });
61
+ engine.context.defineProperty('themeToken', { value: { marginLG: 24 } });
62
+ engine.context.defineProperty('flowSettingsEnabled', { value: true });
63
+
64
+ const main = engine.context.dataSourceManager.getDataSource('main');
65
+ main.addCollection({
66
+ name: 'users',
67
+ fields: [{ name: 'nickname', type: 'string', interface: 'input' }],
68
+ });
69
+ const users = engine.context.dataSourceManager.getCollection('main', 'users');
70
+
71
+ const action = engine.createModel({
72
+ use: 'FlowModel',
73
+ uid: 'act-assign-deleted-field',
74
+ });
75
+ action.setStepParams('assignSettings', 'assignFieldValues', {
76
+ assignedValues: { deletedField: 'stale value' },
77
+ });
78
+ action.context.defineProperty('blockModel', { value: { collection: users } });
79
+
80
+ const step = createAssignFieldValuesStep({ settingsFlowKey: 'assignSettings' });
81
+ const Editor = step.uiSchema().editor?.['x-component'] as React.ComponentType;
82
+ const flowSettingsCtx = new FlowRuntimeContext(action, 'assignSettings', 'settings');
83
+
84
+ render(
85
+ <FlowEngineProvider engine={engine}>
86
+ <ConfigProvider>
87
+ <App>
88
+ <FlowSettingsContextProvider value={flowSettingsCtx}>
89
+ <Editor />
90
+ </FlowSettingsContextProvider>
91
+ </App>
92
+ </ConfigProvider>
93
+ </FlowEngineProvider>,
94
+ );
95
+
96
+ await waitFor(() => {
97
+ expect(screen.getByRole('button', { name: /Fields/ })).toBeInTheDocument();
98
+ expect(screen.getByText(/deletedField.*may have been deleted/)).toBeInTheDocument();
99
+ const form = engine.findModelByParentId<AssignFormModel>(action.uid, 'assignForm');
100
+ expect(form).toBeDefined();
101
+ expect(form?.subModels.grid.subModels.items || []).toHaveLength(1);
102
+ expect(form?.getAssignedValues()).toEqual({});
103
+ });
104
+ });
105
+
106
+ it('shows the standard deleted-field placeholder for a persisted assigned field model', async () => {
107
+ const engine = new FlowEngine();
108
+ engine.setModelRepository(new MockFlowModelRepository());
109
+ engine.registerModels({
110
+ AssignFormModel,
111
+ AssignFormGridModel,
112
+ AssignFormItemModel,
113
+ InputFieldModel,
114
+ VariableFieldFormModel,
115
+ });
116
+ engine.context.defineProperty('location', { value: { search: '' } });
117
+ engine.context.defineProperty('themeToken', { value: { marginLG: 24 } });
118
+ engine.context.defineProperty('flowSettingsEnabled', { value: true });
119
+
120
+ const main = engine.context.dataSourceManager.getDataSource('main');
121
+ main.addCollection({
122
+ name: 'users',
123
+ fields: [{ name: 'nickname', type: 'string', interface: 'input' }],
124
+ });
125
+ const users = engine.context.dataSourceManager.getCollection('main', 'users');
126
+
127
+ const action = engine.createModel({
128
+ use: 'FlowModel',
129
+ uid: 'act-assign-persisted-deleted-field',
130
+ });
131
+ action.setStepParams('assignSettings', 'assignFieldValues', {
132
+ assignedValues: { deletedField: 'stale value' },
133
+ });
134
+ action.context.defineProperty('blockModel', { value: { collection: users } });
135
+
136
+ const form = engine.createModel<AssignFormModel>({
137
+ use: 'AssignFormModel',
138
+ uid: 'form-assign-persisted-deleted-field',
139
+ parentId: action.uid,
140
+ subKey: 'assignForm',
141
+ stepParams: {
142
+ resourceSettings: {
143
+ init: {
144
+ dataSourceKey: 'main',
145
+ collectionName: 'users',
146
+ },
147
+ },
148
+ },
149
+ });
150
+ action.setSubModel('assignForm', form);
151
+ form.subModels.grid.addSubModel('items', {
152
+ use: 'AssignFormItemModel',
153
+ uid: 'item-assign-persisted-deleted-field',
154
+ stepParams: {
155
+ fieldSettings: {
156
+ init: {
157
+ dataSourceKey: 'main',
158
+ collectionName: 'users',
159
+ fieldPath: 'deletedField',
160
+ },
161
+ assignValue: { value: 'stale value' },
162
+ },
163
+ },
164
+ });
165
+ form.subModels.grid.resetRows(true);
166
+
167
+ const step = createAssignFieldValuesStep({ settingsFlowKey: 'assignSettings' });
168
+ const Editor = step.uiSchema().editor?.['x-component'] as React.ComponentType;
169
+ const flowSettingsCtx = new FlowRuntimeContext(action, 'assignSettings', 'settings');
170
+
171
+ render(
172
+ <FlowEngineProvider engine={engine}>
173
+ <ConfigProvider>
174
+ <App>
175
+ <FlowSettingsContextProvider value={flowSettingsCtx}>
176
+ <Editor />
177
+ </FlowSettingsContextProvider>
178
+ </App>
179
+ </ConfigProvider>
180
+ </FlowEngineProvider>,
181
+ );
182
+
183
+ await waitFor(() => {
184
+ expect(screen.getByText(/deletedField.*may have been deleted/)).toBeInTheDocument();
185
+ expect(form.subModels.grid.subModels.items).toHaveLength(1);
186
+ expect(form.getAssignedValues()).toEqual({});
187
+ });
188
+ });
189
+
50
190
  it('repairs AssignFormModel resource init and clears cached collection', async () => {
51
191
  const engine = new FlowEngine();
52
192
  engine.setModelRepository(new MockFlowModelRepository());
@@ -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
+ }
@@ -21,6 +21,35 @@ describe('FilterFormGridModel.toggleFormFieldsCollapse', () => {
21
21
  engine.registerModels({ FilterFormGridModel });
22
22
  });
23
23
 
24
+ it.each([false, true])('preserves collapsed fields in mobile layout with settings enabled: %s', (settingsEnabled) => {
25
+ engine.flowSettings[settingsEnabled ? 'enable' : 'disable']();
26
+ const rows = {
27
+ first: [['field-1']],
28
+ second: [['field-2']],
29
+ third: [['field-3']],
30
+ };
31
+ const model = engine.createModel<FilterFormGridModel>({
32
+ uid: 'mobile-filter-grid',
33
+ use: 'FilterFormGridModel',
34
+ props: { rows },
35
+ subModels: {
36
+ items: ['field-1', 'field-2', 'field-3'].map((uid) => ({ use: 'FlowModel', uid })),
37
+ },
38
+ });
39
+ model.context.defineProperty('isMobileLayout', { value: true });
40
+ model.setStepParams(GRID_FLOW_KEY, GRID_STEP, { rows });
41
+ type VisibleLayoutReader = { getVisibleLayout(): { rows: Record<string, string[][]> } };
42
+ const renderedItems = () =>
43
+ Object.values((model as unknown as VisibleLayoutReader).getVisibleLayout().rows).flat(2);
44
+
45
+ expect(renderedItems()).toEqual(['field-1', 'field-2', 'field-3']);
46
+ model.toggleFormFieldsCollapse(true, 1);
47
+ expect(renderedItems()).toEqual(['field-1']);
48
+ model.toggleFormFieldsCollapse(false, 1);
49
+ expect(renderedItems()).toEqual(['field-1', 'field-2', 'field-3']);
50
+ expect(model.getStepParams(GRID_FLOW_KEY, GRID_STEP).rows).toEqual(rows);
51
+ });
52
+
24
53
  it('uses rowOrder from the full layout when collapsing after reorder', () => {
25
54
  const model = engine.createModel<FilterFormGridModel>({
26
55
  uid: 'filter-grid-collapse-order',
@@ -351,7 +351,7 @@ export const FieldComponentProps: React.FC<{ fieldModel: string; source: string[
351
351
  <FormItem label={t('Options')}>
352
352
  <Space direction="vertical" size={8} style={{ width: '100%' }}>
353
353
  {options.map((option: any, index: number) => (
354
- <Space key={`${option.value}-${index}`} style={{ width: '100%' }} size={8} wrap align="start">
354
+ <Space key={index} style={{ width: '100%' }} size={8} wrap align="start">
355
355
  <Input
356
356
  style={{ flex: 1, minWidth: 120 }}
357
357
  placeholder={t('Option label')}