@nocobase/client-v2 2.3.0-beta.2 → 2.3.0-beta.20
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/es/RouteRepository.d.ts +6 -0
- package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
- package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
- package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
- package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
- package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
- package/es/flow/models/actions/LinkActionUtils.d.ts +1 -0
- package/es/flow/models/blocks/assign-form/assignFormValue.d.ts +29 -0
- package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
- package/es/index.mjs +79 -79
- package/lib/index.js +84 -84
- package/package.json +9 -8
- package/src/BaseApplication.tsx +2 -0
- package/src/RouteRepository.ts +14 -0
- package/src/__tests__/RouteRepository.test.ts +51 -0
- package/src/__tests__/app.test.tsx +13 -0
- package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
- package/src/collection-manager/field-configure.ts +2 -2
- package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
- package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
- package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
- package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
- package/src/flow/actions/__tests__/validation.test.ts +48 -0
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
- package/src/flow/components/FieldAssignValueInput.tsx +4 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
- package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
- package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
- package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
- package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
- package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
- package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
- package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
- package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
- package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
- package/src/flow/models/actions/LinkActionModel.tsx +11 -9
- package/src/flow/models/actions/LinkActionUtils.ts +4 -2
- package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
- package/src/flow/models/actions/__tests__/LinkActionModel.test.ts +14 -0
- package/src/flow/models/base/GridModel.tsx +0 -1
- package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +40 -12
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.multipleSelect.test.tsx +216 -0
- package/src/flow/models/blocks/assign-form/__tests__/assignFormValue.test.ts +41 -0
- package/src/flow/models/blocks/assign-form/assignFormValue.ts +64 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
- package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
- package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
- package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
- package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
- package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
- package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
- package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
- package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +7 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +6 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +47 -17
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.largeField.test.tsx +178 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +26 -2
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
- package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
- package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
- package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
- package/src/flow/models/fields/InputFieldModel.tsx +1 -1
- package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
- package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
- package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
- package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +16 -0
- package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -0
- package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.multipleSelect.test.tsx
ADDED
|
@@ -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={
|
|
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')}
|
package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
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 } from 'vitest';
|
|
12
|
+
import { createForm } from '@formily/core';
|
|
13
|
+
import { Field, FormProvider } from '@formily/react';
|
|
14
|
+
import { render, screen } from '@nocobase/test/client';
|
|
15
|
+
import userEvent from '@testing-library/user-event';
|
|
16
|
+
import { FlowEngine, FlowEngineProvider, FlowModel, FlowModelProvider } from '@nocobase/flow-engine';
|
|
17
|
+
import { FieldComponentProps } from '../FieldComponentProps';
|
|
18
|
+
|
|
19
|
+
class HostModel extends FlowModel {
|
|
20
|
+
render() {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe.each(['RadioGroupFieldModel', 'CheckboxGroupFieldModel', 'SelectFieldModel'])('%s options', (fieldModel) => {
|
|
26
|
+
it('keeps focus while editing option values and preserves remaining rows after removal', async () => {
|
|
27
|
+
const form = createForm();
|
|
28
|
+
const engine = new FlowEngine();
|
|
29
|
+
engine.registerModels({ HostModel });
|
|
30
|
+
const model = engine.createModel<HostModel>({ use: 'HostModel' });
|
|
31
|
+
render(
|
|
32
|
+
<FlowEngineProvider engine={engine}>
|
|
33
|
+
<FlowModelProvider model={model}>
|
|
34
|
+
<FormProvider form={form}>
|
|
35
|
+
<Field name="props" component={[FieldComponentProps, { fieldModel, source: [] }]} />
|
|
36
|
+
</FormProvider>
|
|
37
|
+
</FlowModelProvider>
|
|
38
|
+
</FlowEngineProvider>,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
await userEvent.click(screen.getByRole('button', { name: 'plus Add' }));
|
|
42
|
+
const valueInput = screen.getByPlaceholderText('Option value');
|
|
43
|
+
await userEvent.type(valueInput, 'abcdef');
|
|
44
|
+
expect(screen.getByPlaceholderText('Option value')).toHaveValue('abcdef');
|
|
45
|
+
expect(valueInput).toHaveFocus();
|
|
46
|
+
|
|
47
|
+
await userEvent.keyboard('{Backspace}{Backspace}xy');
|
|
48
|
+
expect(valueInput).toHaveValue('abcdxy');
|
|
49
|
+
expect(valueInput).toHaveFocus();
|
|
50
|
+
await userEvent.type(screen.getByPlaceholderText('Option label'), 'First');
|
|
51
|
+
await userEvent.click(screen.getByRole('button', { name: 'plus Add' }));
|
|
52
|
+
await userEvent.type(screen.getAllByPlaceholderText('Option label')[1], 'Second');
|
|
53
|
+
await userEvent.type(screen.getAllByPlaceholderText('Option value')[1], 'second');
|
|
54
|
+
await userEvent.click(screen.getAllByRole('button', { name: 'close' })[0]);
|
|
55
|
+
expect(screen.getByPlaceholderText('Option label')).toHaveValue('Second');
|
|
56
|
+
expect(screen.getByPlaceholderText('Option value')).toHaveValue('second');
|
|
57
|
+
await userEvent.type(screen.getByPlaceholderText('Option value'), '2');
|
|
58
|
+
expect(screen.getByPlaceholderText('Option value')).toHaveFocus();
|
|
59
|
+
expect(form.values.props.options).toEqual([{ label: 'Second', value: 'second2' }]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -399,20 +399,41 @@ export class FormBlockModel<
|
|
|
399
399
|
if (Array.isArray(topValue) && topValue.length === 0) return false;
|
|
400
400
|
|
|
401
401
|
// 本地优先:支持对多关系的 dot 聚合路径(例如 assignees.name)。
|
|
402
|
-
//
|
|
403
|
-
// 因而这里先用 getValuesByPath 做一次前端可解析性检查,命中则直接前端解析。
|
|
402
|
+
// 关联字段只有在本地值包含目标标题字段时才算完整;标量外键或仅含主键的轻量对象仍需服务端补全。
|
|
404
403
|
const formValuesSnapshot = runtime.getFormValuesSnapshot();
|
|
404
|
+
let shouldResolveAssociationValueOnServer = false;
|
|
405
405
|
if (formValuesSnapshot && typeof formValuesSnapshot === 'object') {
|
|
406
|
-
const localResolved = getValuesByPath(formValuesSnapshot as Record<string,
|
|
406
|
+
const localResolved = getValuesByPath(formValuesSnapshot as Record<string, unknown>, subPath);
|
|
407
407
|
if (typeof localResolved !== 'undefined') {
|
|
408
|
-
|
|
408
|
+
const fieldPath = subPath
|
|
409
|
+
.replace(/\[\d+\]/g, '')
|
|
410
|
+
.split('.')
|
|
411
|
+
.filter((segment) => !/^\d+$/.test(segment))
|
|
412
|
+
.join('.');
|
|
413
|
+
const resolvedField = this.collection?.getFieldByPath?.(fieldPath);
|
|
414
|
+
const titleFieldName = resolvedField?.targetCollectionTitleFieldName;
|
|
415
|
+
const isLoadedAssociationRecord = (value: unknown) => {
|
|
416
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
417
|
+
if (!titleFieldName) return true;
|
|
418
|
+
return typeof (value as Record<string, unknown>)[titleFieldName] !== 'undefined';
|
|
419
|
+
};
|
|
420
|
+
const isAssociationValueLoaded =
|
|
421
|
+
localResolved === null ||
|
|
422
|
+
(Array.isArray(localResolved)
|
|
423
|
+
? localResolved.length === 0 ||
|
|
424
|
+
localResolved.every((value) => value === null || isLoadedAssociationRecord(value))
|
|
425
|
+
: isLoadedAssociationRecord(localResolved));
|
|
426
|
+
if (!resolvedField?.isAssociationField?.() || isAssociationValueLoaded) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
shouldResolveAssociationValueOnServer = true;
|
|
409
430
|
}
|
|
410
431
|
}
|
|
411
432
|
|
|
412
433
|
// 已配置字段:仅关联字段的子路径按需服务端补全(保持现有语义)
|
|
413
434
|
const assocResolver = createAssociationSubpathResolver(
|
|
414
435
|
() => this.collection,
|
|
415
|
-
() => runtime.getFormValuesSnapshot(),
|
|
436
|
+
shouldResolveAssociationValueOnServer ? undefined : () => runtime.getFormValuesSnapshot(),
|
|
416
437
|
);
|
|
417
438
|
return assocResolver(subPath);
|
|
418
439
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from '@nocobase/flow-engine';
|
|
24
24
|
import { Button, Form, Skeleton, Space } from 'antd';
|
|
25
25
|
import React from 'react';
|
|
26
|
+
import { findDataScopeStep, getQuickEditDataScopeFilter } from '../../../internal/utils/quickEditDataScope';
|
|
26
27
|
import { FieldModel } from '../../base/FieldModel';
|
|
27
28
|
import { FormComponent } from './FormBlockModel';
|
|
28
29
|
import { FormItemModel } from './FormItemModel';
|
|
@@ -36,6 +37,14 @@ const QUICK_EDIT_MOBILE_CONTENT_PADDING = '8px var(--nb-mobile-page-tabs-content
|
|
|
36
37
|
const QUICK_EDIT_MOBILE_ACTIONS_PADDING =
|
|
37
38
|
'8px var(--nb-mobile-page-tabs-content-padding, 12px) calc(80px + env(safe-area-inset-bottom, 0px))';
|
|
38
39
|
const QUICK_EDIT_MOBILE_MEDIA_QUERY = '(max-width: 768px)';
|
|
40
|
+
// Dropdown-style editors open upwards inside the desktop popover so the option list does not cover the Submit and
|
|
41
|
+
// Cancel buttons rendered right below the field. antd still flips the list downwards when there is no room above.
|
|
42
|
+
const QUICK_EDIT_DROPDOWN_UP_MODELS = [
|
|
43
|
+
'SelectFieldModel',
|
|
44
|
+
'RecordSelectFieldModel',
|
|
45
|
+
'CascadeSelectFieldModel',
|
|
46
|
+
'CascadeSelectListFieldModel',
|
|
47
|
+
];
|
|
39
48
|
|
|
40
49
|
type QuickEditViewBeforeClosePayload = {
|
|
41
50
|
result?: unknown;
|
|
@@ -159,6 +168,7 @@ export class QuickEditFormModel extends FlowModel {
|
|
|
159
168
|
__onSubmitSuccess;
|
|
160
169
|
_fieldProps: any;
|
|
161
170
|
_onOk: any;
|
|
171
|
+
_dataScopeFilter: Record<string, any> | undefined;
|
|
162
172
|
|
|
163
173
|
get form() {
|
|
164
174
|
return this.context.form;
|
|
@@ -212,6 +222,7 @@ export class QuickEditFormModel extends FlowModel {
|
|
|
212
222
|
},
|
|
213
223
|
sourceFieldModel ? { delegate: sourceFieldModel.context } : undefined,
|
|
214
224
|
) as QuickEditFormModel;
|
|
225
|
+
model._dataScopeFilter = getQuickEditDataScopeFilter(sourceFieldModel);
|
|
215
226
|
|
|
216
227
|
const bodyStyles = {
|
|
217
228
|
maxHeight: QUICK_EDIT_POPOVER_MAX_HEIGHT,
|
|
@@ -410,6 +421,13 @@ QuickEditFormModel.registerFlow({
|
|
|
410
421
|
resource.setDataSourceKey(dataSourceKey);
|
|
411
422
|
resource.setResourceName(collectionName);
|
|
412
423
|
ctx.model.resource = resource;
|
|
424
|
+
// Seed the edited row before dispatching the field model's flows. An association data scope resolves
|
|
425
|
+
// `ctx.record` while the field renders, and so does the foreign key filter that one-to-one and one-to-many
|
|
426
|
+
// editors build, so the record has to be in place first.
|
|
427
|
+
if (ctx.inputArgs.filterByTk || ctx.inputArgs.record) {
|
|
428
|
+
resource.setFilterByTk(ctx.inputArgs.filterByTk);
|
|
429
|
+
resource.setData(ctx.inputArgs.record);
|
|
430
|
+
}
|
|
413
431
|
const collectionField = ctx.model.collection.getField(fieldPath) as CollectionField;
|
|
414
432
|
if (collectionField) {
|
|
415
433
|
const binding = FormItemModel.getDefaultBindingByField(ctx, collectionField);
|
|
@@ -417,24 +435,42 @@ QuickEditFormModel.registerFlow({
|
|
|
417
435
|
return;
|
|
418
436
|
}
|
|
419
437
|
const use = binding.modelName;
|
|
438
|
+
const stepParams: Record<string, Record<string, Record<string, unknown>>> = {
|
|
439
|
+
fieldSettings: {
|
|
440
|
+
init: {
|
|
441
|
+
dataSourceKey,
|
|
442
|
+
collectionName,
|
|
443
|
+
fieldPath,
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
const dataScopeFilter = ctx.model._dataScopeFilter;
|
|
448
|
+
if (dataScopeFilter) {
|
|
449
|
+
const dataScopeStep = findDataScopeStep(ctx.engine.getModelClass(use));
|
|
450
|
+
if (dataScopeStep) {
|
|
451
|
+
stepParams[dataScopeStep.flowKey] = {
|
|
452
|
+
...stepParams[dataScopeStep.flowKey],
|
|
453
|
+
[dataScopeStep.stepKey]: { filter: dataScopeFilter },
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
420
457
|
const fieldModel = ctx.model.addSubModel<FieldModel>('fields', {
|
|
421
458
|
use,
|
|
422
459
|
props:
|
|
423
460
|
typeof binding.defaultProps === 'function'
|
|
424
461
|
? binding.defaultProps(ctx, collectionField)
|
|
425
462
|
: binding.defaultProps,
|
|
426
|
-
stepParams
|
|
427
|
-
fieldSettings: {
|
|
428
|
-
init: {
|
|
429
|
-
dataSourceKey,
|
|
430
|
-
collectionName,
|
|
431
|
-
fieldPath,
|
|
432
|
-
},
|
|
433
|
-
},
|
|
434
|
-
},
|
|
463
|
+
stepParams,
|
|
435
464
|
});
|
|
436
465
|
fieldModel.setProps(getQuickEditFieldProps(collectionField, ctx.model._fieldProps));
|
|
437
466
|
fieldModel.setProps({ sourceFieldModelUid: ctx.inputArgs.sourceFieldModelUid });
|
|
467
|
+
if (
|
|
468
|
+
!ctx.model.context.isMobileLayout &&
|
|
469
|
+
QUICK_EDIT_DROPDOWN_UP_MODELS.includes(use) &&
|
|
470
|
+
fieldModel.props.placement == null
|
|
471
|
+
) {
|
|
472
|
+
fieldModel.setProps({ placement: 'topLeft' });
|
|
473
|
+
}
|
|
438
474
|
ctx.model.context.defineProperty('collectionField', {
|
|
439
475
|
get: () => collectionField,
|
|
440
476
|
});
|
|
@@ -442,8 +478,6 @@ QuickEditFormModel.registerFlow({
|
|
|
442
478
|
await fieldModel.dispatchEvent('beforeRender');
|
|
443
479
|
}
|
|
444
480
|
if (ctx.inputArgs.filterByTk || ctx.inputArgs.record) {
|
|
445
|
-
resource.setFilterByTk(ctx.inputArgs.filterByTk);
|
|
446
|
-
resource.setData(ctx.inputArgs.record);
|
|
447
481
|
ctx.model.form?.setFieldsValue(resource.getData());
|
|
448
482
|
}
|
|
449
483
|
},
|