@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.
- 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/admin-shell/admin-layout/MobileTopbarActionsContext.d.ts +10 -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 +100 -100
- package/lib/index.js +102 -102
- 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/MobileTopbarActionsContext.ts +13 -0
- package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +13 -2
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.mobile.test.tsx +205 -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 +11 -2
- 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 +13 -1
- 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/AssociationFieldModel/__tests__/associationSelect.search.test.ts +113 -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/topbar/UserCenterTopbarActionModel.tsx +8 -4
package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
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 { Form, type FormInstance, type TableProps } from 'antd';
|
|
12
|
+
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
13
|
+
import { DisplayItemModel, FlowEngine, FlowEngineProvider, FlowModelProvider } from '@nocobase/flow-engine';
|
|
14
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
15
|
+
import {
|
|
16
|
+
DetailsItemModel,
|
|
17
|
+
DisplaySubTableFieldModel,
|
|
18
|
+
DisplayTextFieldModel,
|
|
19
|
+
FormAssociationItemModel,
|
|
20
|
+
FormItemModel,
|
|
21
|
+
TableColumnModel,
|
|
22
|
+
aclCheck,
|
|
23
|
+
displayFieldComponent,
|
|
24
|
+
fixed,
|
|
25
|
+
overflowMode,
|
|
26
|
+
titleField,
|
|
27
|
+
} from '../../../../index';
|
|
28
|
+
import { buildAssociationOptions } from '../../../../actions/displayFieldComponent';
|
|
29
|
+
import { rebuildFieldSubModel } from '../../../../internal/utils/rebuildFieldSubModel';
|
|
30
|
+
|
|
31
|
+
type Row = Record<string, unknown>;
|
|
32
|
+
type TableOptions = TableProps<Row>;
|
|
33
|
+
|
|
34
|
+
const { tableRender } = vi.hoisted(() => ({ tableRender: vi.fn() }));
|
|
35
|
+
|
|
36
|
+
vi.mock('react-i18next', async (importOriginal) => ({
|
|
37
|
+
...(await importOriginal<typeof import('react-i18next')>()),
|
|
38
|
+
useTranslation: () => ({ t: (value: string) => value }),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock('antd', async (importOriginal) => ({
|
|
42
|
+
...(await importOriginal<typeof import('antd')>()),
|
|
43
|
+
Table: (props: TableOptions) => {
|
|
44
|
+
tableRender(props);
|
|
45
|
+
return <div data-testid="rows">{JSON.stringify(props.dataSource)}</div>;
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
cleanup();
|
|
51
|
+
vi.restoreAllMocks();
|
|
52
|
+
tableRender.mockClear();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function setup(parentUse = 'FormAssociationItemModel') {
|
|
56
|
+
const engine = new FlowEngine();
|
|
57
|
+
engine.registerActions({ aclCheck, displayFieldComponent, fixed, overflowMode, titleField });
|
|
58
|
+
engine.registerModels({
|
|
59
|
+
DetailsItemModel,
|
|
60
|
+
DisplaySubTableFieldModel,
|
|
61
|
+
DisplayTextFieldModel,
|
|
62
|
+
FormAssociationItemModel,
|
|
63
|
+
FormItemModel,
|
|
64
|
+
TableColumnModel,
|
|
65
|
+
});
|
|
66
|
+
const dataSource = engine.dataSourceManager.getDataSource('main');
|
|
67
|
+
dataSource.addCollection({
|
|
68
|
+
name: 'users',
|
|
69
|
+
filterTargetKey: 'id',
|
|
70
|
+
titleField: 'name',
|
|
71
|
+
fields: [
|
|
72
|
+
{ name: 'id', type: 'integer', interface: 'integer' },
|
|
73
|
+
{ name: 'name', type: 'string', interface: 'input' },
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
dataSource.addCollection({
|
|
77
|
+
name: 'orgs',
|
|
78
|
+
fields: [
|
|
79
|
+
{ name: 'staff', type: 'belongsToMany', interface: 'm2m', target: 'users' },
|
|
80
|
+
{ name: 'reports', type: 'hasMany', interface: 'o2m', target: 'users' },
|
|
81
|
+
{ name: 'members', type: 'belongsToArray', interface: 'mbm', target: 'users' },
|
|
82
|
+
{ name: 'manager', type: 'belongsTo', interface: 'm2o', target: 'users' },
|
|
83
|
+
{ name: 'name', type: 'string', interface: 'input' },
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
dataSource.addCollection({
|
|
87
|
+
name: 'posts',
|
|
88
|
+
fields: [{ name: 'org', type: 'belongsTo', interface: 'm2o', target: 'orgs' }],
|
|
89
|
+
});
|
|
90
|
+
const parent = engine.createModel<FormAssociationItemModel>({
|
|
91
|
+
use: parentUse,
|
|
92
|
+
stepParams: {
|
|
93
|
+
fieldSettings: {
|
|
94
|
+
init: { dataSourceKey: 'main', collectionName: 'posts', fieldPath: 'org.staff' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const resource = { getAppends: vi.fn(() => ['staff']), setAppends: vi.fn(), refresh: vi.fn() };
|
|
99
|
+
parent.context.defineProperty('blockModel', {
|
|
100
|
+
value: { collection: dataSource.getCollection('posts'), resource, addAppends: vi.fn() },
|
|
101
|
+
});
|
|
102
|
+
parent.context.defineProperty('flowSettingsEnabled', { value: false });
|
|
103
|
+
parent.context.defineProperty('aclCheck', { value: vi.fn().mockResolvedValue(true) });
|
|
104
|
+
const field = parent.setSubModel('field', { use: 'DisplaySubTableFieldModel', props: { pageSize: 10 } });
|
|
105
|
+
return { engine, parent, field: field as DisplaySubTableFieldModel, resource, dataSource };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tableProps(): TableOptions {
|
|
109
|
+
return tableRender.mock.lastCall[0] as TableOptions;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe('DisplaySubTableFieldModel in association display fields', () => {
|
|
113
|
+
it.each(['staff', 'reports', 'members'])('offers a subtable for the to-many field %s', (fieldName) => {
|
|
114
|
+
const { parent, dataSource } = setup();
|
|
115
|
+
const collectionField = dataSource.getCollection('orgs').getField(fieldName);
|
|
116
|
+
const bindings = FormAssociationItemModel.getBindingsByField(parent.context, collectionField);
|
|
117
|
+
expect(bindings.map((binding) => binding.modelName)).toContain('DisplaySubTableFieldModel');
|
|
118
|
+
expect(buildAssociationOptions(parent.context, FormAssociationItemModel)).toContainEqual({
|
|
119
|
+
label: expect.any(String),
|
|
120
|
+
value: 'DisplaySubTableFieldModel',
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('keeps the binding scoped and preserves the default title-field display', () => {
|
|
125
|
+
const { parent, dataSource } = setup();
|
|
126
|
+
const collection = dataSource.getCollection('orgs');
|
|
127
|
+
for (const itemModel of [DisplayItemModel, TableColumnModel, FormItemModel]) {
|
|
128
|
+
expect(itemModel.getBindingsByField(parent.context, collection.getField('staff'))).not.toEqual(
|
|
129
|
+
expect.arrayContaining([expect.objectContaining({ modelName: 'DisplaySubTableFieldModel' })]),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
for (const fieldName of ['manager', 'name']) {
|
|
133
|
+
expect(FormAssociationItemModel.getBindingsByField(parent.context, collection.getField(fieldName))).not.toEqual(
|
|
134
|
+
expect.arrayContaining([expect.objectContaining({ modelName: 'DisplaySubTableFieldModel' })]),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
expect(DetailsItemModel.getBindingsByField(parent.context, collection.getField('staff'))).toEqual(
|
|
138
|
+
expect.arrayContaining([expect.objectContaining({ modelName: 'DisplaySubTableFieldModel' })]),
|
|
139
|
+
);
|
|
140
|
+
expect(
|
|
141
|
+
FormAssociationItemModel.getDefaultBindingByField(parent.context, collection.getField('staff'), {
|
|
142
|
+
fallbackToTargetTitleField: true,
|
|
143
|
+
})?.modelName,
|
|
144
|
+
).toBe('DisplayTextFieldModel');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it.each([undefined, null, [], { rows: [] }])('renders an empty table for %j', (value) => {
|
|
148
|
+
const { field } = setup();
|
|
149
|
+
field.setProps({ value });
|
|
150
|
+
render(field.render());
|
|
151
|
+
expect(tableProps().dataSource).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('updates rows and resets pagination when the association changes or is cleared', async () => {
|
|
155
|
+
const { field, resource } = setup();
|
|
156
|
+
field.setProps({ value: [{ id: 1, name: 'Alice' }] });
|
|
157
|
+
const view = render(field.render());
|
|
158
|
+
await act(async () => {
|
|
159
|
+
const pagination = tableProps().pagination;
|
|
160
|
+
if (pagination) pagination.onChange?.(3, 10);
|
|
161
|
+
});
|
|
162
|
+
expect(tableProps().pagination).toMatchObject({ current: 3 });
|
|
163
|
+
act(() => {
|
|
164
|
+
field.setProps({ value: { rows: [{ id: 2, name: 'Bob' }] } });
|
|
165
|
+
view.rerender(field.render());
|
|
166
|
+
});
|
|
167
|
+
expect(screen.getByTestId('rows').textContent).toContain('Bob');
|
|
168
|
+
expect(screen.getByTestId('rows').textContent).not.toContain('Alice');
|
|
169
|
+
expect(tableProps().pagination).toMatchObject({ current: 1 });
|
|
170
|
+
act(() => {
|
|
171
|
+
field.setProps({ value: null });
|
|
172
|
+
view.rerender(field.render());
|
|
173
|
+
});
|
|
174
|
+
expect(tableProps().dataSource).toEqual([]);
|
|
175
|
+
expect(resource.refresh).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('sorts nested columns locally without mutating form values or refreshing the form resource', async () => {
|
|
179
|
+
const { field, resource } = setup();
|
|
180
|
+
const rows = [
|
|
181
|
+
{ id: 1, name: 'Bob' },
|
|
182
|
+
{ id: 2, name: 'Alice' },
|
|
183
|
+
];
|
|
184
|
+
const original = structuredClone(rows);
|
|
185
|
+
field.setProps({ value: rows });
|
|
186
|
+
render(field.render());
|
|
187
|
+
await act(async () => {
|
|
188
|
+
await tableProps().onChange?.(
|
|
189
|
+
{},
|
|
190
|
+
{},
|
|
191
|
+
{ field: 'org.staff.name', order: 'ascend' },
|
|
192
|
+
{
|
|
193
|
+
action: 'sort',
|
|
194
|
+
currentDataSource: rows,
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
expect(tableProps().dataSource).toEqual([rows[1], rows[0]]);
|
|
199
|
+
await act(async () => {
|
|
200
|
+
await tableProps().onChange?.(
|
|
201
|
+
{},
|
|
202
|
+
{},
|
|
203
|
+
{ field: 'org.staff.name', order: 'descend' },
|
|
204
|
+
{
|
|
205
|
+
action: 'sort',
|
|
206
|
+
currentDataSource: rows,
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
expect(tableProps().dataSource).toEqual(rows);
|
|
211
|
+
await act(async () => {
|
|
212
|
+
await tableProps().onChange?.(
|
|
213
|
+
{},
|
|
214
|
+
{},
|
|
215
|
+
{ field: 'org.staff.name' },
|
|
216
|
+
{
|
|
217
|
+
action: 'sort',
|
|
218
|
+
currentDataSource: rows,
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
expect(tableProps().dataSource).toEqual(rows);
|
|
223
|
+
expect(rows).toEqual(original);
|
|
224
|
+
expect(resource.setAppends).not.toHaveBeenCalled();
|
|
225
|
+
expect(resource.refresh).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('retains resource-based sorting in details fields', async () => {
|
|
229
|
+
const { field, resource } = setup('DetailsItemModel');
|
|
230
|
+
field.setProps({ value: [{ id: 1 }] });
|
|
231
|
+
render(field.render());
|
|
232
|
+
await act(async () => {
|
|
233
|
+
await tableProps().onChange?.(
|
|
234
|
+
{},
|
|
235
|
+
{},
|
|
236
|
+
{ field: 'name', order: 'ascend' },
|
|
237
|
+
{
|
|
238
|
+
action: 'sort',
|
|
239
|
+
currentDataSource: [],
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
expect(resource.setAppends).toHaveBeenCalledWith(['staff(sort=name)']);
|
|
244
|
+
expect(resource.refresh).toHaveBeenCalledOnce();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('uses the configured sort field and keeps local ordering when paginating', async () => {
|
|
248
|
+
const { field, resource } = setup();
|
|
249
|
+
const rows = [{ id: 10 }, { id: 2 }];
|
|
250
|
+
field.setProps({ value: rows });
|
|
251
|
+
render(field.render());
|
|
252
|
+
const column: NonNullable<TableOptions['columns']>[number] & { sortField: string } = {
|
|
253
|
+
sortField: 'org.staff.id',
|
|
254
|
+
};
|
|
255
|
+
await act(async () => {
|
|
256
|
+
await tableProps().onChange?.(
|
|
257
|
+
{},
|
|
258
|
+
{},
|
|
259
|
+
{ field: 'other', column, order: 'ascend' },
|
|
260
|
+
{
|
|
261
|
+
action: 'sort',
|
|
262
|
+
currentDataSource: rows,
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
expect(tableProps().dataSource).toEqual([rows[1], rows[0]]);
|
|
267
|
+
await act(async () => {
|
|
268
|
+
await tableProps().onChange?.(
|
|
269
|
+
{ current: 2 },
|
|
270
|
+
{},
|
|
271
|
+
{},
|
|
272
|
+
{
|
|
273
|
+
action: 'paginate',
|
|
274
|
+
currentDataSource: rows,
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
expect(tableProps().dataSource).toEqual([rows[1], rows[0]]);
|
|
279
|
+
expect(resource.refresh).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('follows form association changes without writing displayed records into the form', async () => {
|
|
283
|
+
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
|
284
|
+
matches: false,
|
|
285
|
+
media: query,
|
|
286
|
+
onchange: null,
|
|
287
|
+
addListener: vi.fn(),
|
|
288
|
+
removeListener: vi.fn(),
|
|
289
|
+
addEventListener: vi.fn(),
|
|
290
|
+
removeEventListener: vi.fn(),
|
|
291
|
+
dispatchEvent: vi.fn(),
|
|
292
|
+
}));
|
|
293
|
+
const { engine, parent, resource } = setup();
|
|
294
|
+
let form: FormInstance;
|
|
295
|
+
const firstOrg = { id: 1, staff: [{ id: 1, name: 'Alice' }] };
|
|
296
|
+
const secondOrg = { id: 2, staff: [{ id: 2, name: 'Bob' }] };
|
|
297
|
+
function TestForm() {
|
|
298
|
+
const [formInstance] = Form.useForm();
|
|
299
|
+
form = formInstance;
|
|
300
|
+
parent.context.defineProperty('form', { value: formInstance });
|
|
301
|
+
parent.context.defineProperty('formValues', { get: () => formInstance.getFieldsValue(true), cache: false });
|
|
302
|
+
return (
|
|
303
|
+
<Form form={formInstance} initialValues={{ org: firstOrg, note: 'unsaved' }}>
|
|
304
|
+
<FlowModelProvider model={parent}>{parent.renderItem()}</FlowModelProvider>
|
|
305
|
+
</Form>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
render(
|
|
309
|
+
<FlowEngineProvider engine={engine}>
|
|
310
|
+
<TestForm />
|
|
311
|
+
</FlowEngineProvider>,
|
|
312
|
+
);
|
|
313
|
+
await waitFor(() => expect(screen.getByTestId('rows').textContent).toContain('Alice'));
|
|
314
|
+
act(() => form.setFieldValue('org', secondOrg));
|
|
315
|
+
await waitFor(() => expect(screen.getByTestId('rows').textContent).toContain('Bob'));
|
|
316
|
+
expect(form.getFieldsValue(true)).toEqual({ org: secondOrg, note: 'unsaved' });
|
|
317
|
+
act(() => form.setFieldValue('org', null));
|
|
318
|
+
await waitFor(() => expect(tableProps().dataSource).toEqual([]));
|
|
319
|
+
expect(form.getFieldsValue(true)).toEqual({ org: null, note: 'unsaved' });
|
|
320
|
+
expect(resource.refresh).not.toHaveBeenCalled();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('keeps nested column paths and column configuration when switching components', async () => {
|
|
324
|
+
const { parent, field } = setup();
|
|
325
|
+
vi.spyOn(parent, 'save').mockResolvedValue(undefined);
|
|
326
|
+
const column = field.addSubModel('columns', {
|
|
327
|
+
use: 'TableColumnModel',
|
|
328
|
+
stepParams: {
|
|
329
|
+
fieldSettings: {
|
|
330
|
+
init: { dataSourceKey: 'main', collectionName: 'posts', fieldPath: 'org.staff.name' },
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
subModels: { field: { use: 'DisplayTextFieldModel' } },
|
|
334
|
+
});
|
|
335
|
+
expect(field.context.prefixFieldPath).toBe('org.staff');
|
|
336
|
+
expect(field.collection.name).toBe('users');
|
|
337
|
+
const columnField = column.subModels.field as DisplayTextFieldModel;
|
|
338
|
+
const createFork = vi.spyOn(columnField, 'createFork');
|
|
339
|
+
(column as TableColumnModel).renderItem()(undefined, { id: 1, name: 'Alice' }, 0);
|
|
340
|
+
expect(createFork.mock.results[0].value.props.value).toBe('Alice');
|
|
341
|
+
expect(createFork.mock.results[0].value.context.record).toEqual({ id: 1, name: 'Alice' });
|
|
342
|
+
await rebuildFieldSubModel({ parentModel: parent, targetUse: 'DisplayTextFieldModel' });
|
|
343
|
+
await rebuildFieldSubModel({ parentModel: parent, targetUse: 'DisplaySubTableFieldModel' });
|
|
344
|
+
const rebuilt = parent.subModels.field as DisplaySubTableFieldModel;
|
|
345
|
+
expect(rebuilt.uid).toBe(field.uid);
|
|
346
|
+
expect(rebuilt.serialize().subModels.columns[0]).toMatchObject({
|
|
347
|
+
uid: column.uid,
|
|
348
|
+
stepParams: { fieldSettings: { init: { fieldPath: 'org.staff.name' } } },
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { DisplayItemModel, tExpr } from '@nocobase/flow-engine';
|
|
11
|
+
import { sanitizeRichTextHtml } from '@nocobase/utils/client';
|
|
11
12
|
import React from 'react';
|
|
12
13
|
import { DisplayMarkdown } from '../../internal/components/Markdown/DisplayMarkdown';
|
|
13
14
|
import { DisplayTitleFieldModel } from './DisplayTitleFieldModel';
|
|
@@ -15,7 +16,7 @@ import { DisplayTitleFieldModel } from './DisplayTitleFieldModel';
|
|
|
15
16
|
export class DisplayHtmlFieldModel extends DisplayTitleFieldModel {
|
|
16
17
|
public renderComponent(value) {
|
|
17
18
|
const { textOnly = true } = this.props;
|
|
18
|
-
return <DisplayMarkdown {...this.props} textOnly={textOnly} value={value} />;
|
|
19
|
+
return <DisplayMarkdown {...this.props} sanitizeHtml={sanitizeRichTextHtml} textOnly={textOnly} value={value} />;
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -58,7 +58,7 @@ export class InputFieldModel extends FieldModel {
|
|
|
58
58
|
return <ScanInput {...this.props} />;
|
|
59
59
|
}
|
|
60
60
|
const { enableScan, disableManualInput, ...inputProps } = this.props;
|
|
61
|
-
return <IMESafeInput {...inputProps} />;
|
|
61
|
+
return <IMESafeInput {...inputProps} style={{ verticalAlign: 'top', ...inputProps.style }} />;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -0,0 +1,74 @@
|
|
|
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 { act, render } from '@nocobase/test/client';
|
|
11
|
+
import { sanitizeRichTextHtml } from '@nocobase/utils/client';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import { RichTextField } from '..';
|
|
15
|
+
|
|
16
|
+
const mockState = vi.hoisted(() => ({
|
|
17
|
+
onChange: undefined as ((value: string) => void) | undefined,
|
|
18
|
+
value: undefined as unknown,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('../../../../../flow-compat', async (importOriginal) => {
|
|
22
|
+
const actual = await importOriginal<typeof import('../../../../../flow-compat')>();
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
lazy: () => (props: { onChange?: (value: string) => void; value?: unknown }) => {
|
|
26
|
+
mockState.onChange = props.onChange;
|
|
27
|
+
mockState.value = props.value;
|
|
28
|
+
return null;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('RichTextField edit mode sanitization', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
mockState.onChange = undefined;
|
|
36
|
+
mockState.value = undefined;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('sanitizes stored HTML before passing it to ReactQuill', () => {
|
|
40
|
+
const payload = '<p>safe</p><img src="https://example.com/image.png" onerror="alert(1)"><script>alert(1)</script>';
|
|
41
|
+
|
|
42
|
+
render(<RichTextField value={payload} onChange={vi.fn()} />);
|
|
43
|
+
|
|
44
|
+
expect(mockState.value).toBe(sanitizeRichTextHtml(payload));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('preserves Delta values', () => {
|
|
48
|
+
const delta = { ops: [{ insert: 'safe\n' }] };
|
|
49
|
+
|
|
50
|
+
render(<RichTextField value={delta} onChange={vi.fn()} />);
|
|
51
|
+
|
|
52
|
+
expect(mockState.value).toBe(delta);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('does not normalize active editor feedback', () => {
|
|
56
|
+
const editorValue = '<p>Hello<br>World</p>';
|
|
57
|
+
expect(sanitizeRichTextHtml(editorValue)).not.toBe(editorValue);
|
|
58
|
+
|
|
59
|
+
const { rerender } = render(<RichTextField value="" onChange={vi.fn()} />);
|
|
60
|
+
act(() => mockState.onChange?.(editorValue));
|
|
61
|
+
rerender(<RichTextField value={editorValue} onChange={vi.fn()} />);
|
|
62
|
+
|
|
63
|
+
expect(mockState.value).toBe(editorValue);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('sanitizes subsequent external values', () => {
|
|
67
|
+
const payload = '<img src=x onerror="alert(1)">';
|
|
68
|
+
const { rerender } = render(<RichTextField value="" onChange={vi.fn()} />);
|
|
69
|
+
|
|
70
|
+
rerender(<RichTextField value={payload} onChange={vi.fn()} />);
|
|
71
|
+
|
|
72
|
+
expect(mockState.value).toBe(sanitizeRichTextHtml(payload));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { largeField, EditableItemModel } from '@nocobase/flow-engine';
|
|
12
|
+
import { sanitizeRichTextHtml } from '@nocobase/utils/client';
|
|
13
|
+
import type ReactQuillComponent from 'react-quill';
|
|
12
14
|
import { lazy } from '../../../../flow-compat';
|
|
13
15
|
import { useRichTextStyles } from './style';
|
|
14
16
|
import { FieldModel } from '../../base';
|
|
@@ -26,6 +28,8 @@ const ReactQuill = lazy(async () => {
|
|
|
26
28
|
return import('react-quill');
|
|
27
29
|
});
|
|
28
30
|
|
|
31
|
+
type ReactQuillValue = ReactQuillComponent.ReactQuillProps['value'];
|
|
32
|
+
|
|
29
33
|
export const RichTextField = (props) => {
|
|
30
34
|
const richTextClass = useRichTextStyles();
|
|
31
35
|
const boundsClass = React.useMemo(() => `quill-bounds-${Math.random().toString(36).slice(2, 9)}`, []);
|
|
@@ -83,19 +87,36 @@ export const RichTextField = (props) => {
|
|
|
83
87
|
'break',
|
|
84
88
|
];
|
|
85
89
|
const { value, onChange, disabled, modules: propsModules, formats: propsFormats } = props;
|
|
90
|
+
const previousIncomingValueRef = React.useRef(value);
|
|
91
|
+
const pendingEditorValueRef = React.useRef<{ value: ReactQuillValue }>();
|
|
92
|
+
const [editorValue, setEditorValue] = React.useState<ReactQuillValue>(() =>
|
|
93
|
+
typeof value === 'string' ? sanitizeRichTextHtml(value) : value,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
React.useEffect(() => {
|
|
97
|
+
if (Object.is(value, previousIncomingValueRef.current)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
previousIncomingValueRef.current = value;
|
|
101
|
+
const pendingEditorValue = pendingEditorValueRef.current;
|
|
102
|
+
pendingEditorValueRef.current = undefined;
|
|
103
|
+
if (pendingEditorValue && Object.is(value, pendingEditorValue.value)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
setEditorValue(typeof value === 'string' ? sanitizeRichTextHtml(value) : value);
|
|
107
|
+
}, [value]);
|
|
86
108
|
|
|
87
109
|
return (
|
|
88
110
|
<ReactQuill
|
|
89
111
|
className={`${richTextClass} ${boundsClass}`}
|
|
90
112
|
modules={propsModules || modules}
|
|
91
113
|
formats={propsFormats || formats}
|
|
92
|
-
value={
|
|
114
|
+
value={editorValue}
|
|
93
115
|
onChange={(value) => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
116
|
+
const nextValue = value === '<p><br></p>' ? '' : value;
|
|
117
|
+
pendingEditorValueRef.current = { value: nextValue };
|
|
118
|
+
setEditorValue(nextValue);
|
|
119
|
+
onChange(nextValue);
|
|
99
120
|
}}
|
|
100
121
|
readOnly={disabled}
|
|
101
122
|
bounds={`.${boundsClass}`}
|
|
@@ -13,6 +13,7 @@ import React from 'react';
|
|
|
13
13
|
import { FieldModel } from '../base/FieldModel';
|
|
14
14
|
import { MobileSelect } from './mobile-components/MobileSelect';
|
|
15
15
|
import { enumToOptions, getSelectedEnumLabels, translateOptionLabel } from '../../internal/utils/enumOptionsUtils';
|
|
16
|
+
import { syncQuickEditPreventClose } from '../../internal/utils/quickEditViewContainer';
|
|
16
17
|
|
|
17
18
|
const getOriginalEnumOptions = (model: SelectFieldModel) => {
|
|
18
19
|
const fromEnum = enumToOptions(model.context.collectionField?.uiSchema?.enum, model.translate) || [];
|
|
@@ -62,6 +63,10 @@ export class SelectFieldModel extends FieldModel {
|
|
|
62
63
|
this.props.onChange?.(nextValue?.value);
|
|
63
64
|
}}
|
|
64
65
|
options={options}
|
|
66
|
+
onDropdownVisibleChange={(open) => {
|
|
67
|
+
syncQuickEditPreventClose(this, open);
|
|
68
|
+
this.props.onDropdownVisibleChange?.(open);
|
|
69
|
+
}}
|
|
65
70
|
labelRender={(item) => item.label}
|
|
66
71
|
maxTagCount="responsive"
|
|
67
72
|
maxTagPlaceholder={(omittedValues) => (
|
|
@@ -27,6 +27,22 @@ function createInputFieldModel(props?: Record<string, unknown>) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
describe('InputFieldModel', () => {
|
|
30
|
+
it('aligns plain inputs with the top of other filter form controls', () => {
|
|
31
|
+
const model = createInputFieldModel();
|
|
32
|
+
|
|
33
|
+
render(<>{model.render()}</>);
|
|
34
|
+
|
|
35
|
+
expect(screen.getByRole('textbox')).toHaveStyle({ verticalAlign: 'top' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('preserves explicitly configured input styles', () => {
|
|
39
|
+
const model = createInputFieldModel({ style: { verticalAlign: 'middle', width: 200 } });
|
|
40
|
+
|
|
41
|
+
render(<>{model.render()}</>);
|
|
42
|
+
|
|
43
|
+
expect(screen.getByRole('textbox')).toHaveStyle({ verticalAlign: 'middle', width: '200px' });
|
|
44
|
+
});
|
|
45
|
+
|
|
30
46
|
it('renders the normal input when scan input is disabled', () => {
|
|
31
47
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
32
48
|
const model = createInputFieldModel({ disableManualInput: true, enableScan: false });
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import React from 'react';
|
|
11
|
-
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
12
|
import { SelectFieldModel } from '../SelectFieldModel';
|
|
13
13
|
|
|
14
14
|
function mockT(text: string) {
|
|
@@ -40,4 +40,32 @@ describe('SelectFieldModel', () => {
|
|
|
40
40
|
|
|
41
41
|
expect(element.props.value).toEqual({ label: '是', value: true });
|
|
42
42
|
});
|
|
43
|
+
|
|
44
|
+
it('keeps the quick edit popover open while the dropdown is open', () => {
|
|
45
|
+
vi.useFakeTimers();
|
|
46
|
+
try {
|
|
47
|
+
const update = vi.fn();
|
|
48
|
+
const onDropdownVisibleChange = vi.fn();
|
|
49
|
+
const model = {
|
|
50
|
+
props: { value: undefined, onDropdownVisibleChange },
|
|
51
|
+
parent: { use: 'QuickEditFormModel', viewContainer: { update } },
|
|
52
|
+
context: { collectionField: { uiSchema: { enum: [] } } },
|
|
53
|
+
translate: mockT,
|
|
54
|
+
} as unknown as SelectFieldModel;
|
|
55
|
+
|
|
56
|
+
const element = SelectFieldModel.prototype.render.call(model) as React.ReactElement;
|
|
57
|
+
|
|
58
|
+
element.props.onDropdownVisibleChange(true);
|
|
59
|
+
expect(update).toHaveBeenCalledWith({ preventClose: true });
|
|
60
|
+
expect(onDropdownVisibleChange).toHaveBeenCalledWith(true);
|
|
61
|
+
|
|
62
|
+
element.props.onDropdownVisibleChange(false);
|
|
63
|
+
expect(onDropdownVisibleChange).toHaveBeenCalledWith(false);
|
|
64
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
65
|
+
vi.runAllTimers();
|
|
66
|
+
expect(update).toHaveBeenLastCalledWith({ preventClose: false });
|
|
67
|
+
} finally {
|
|
68
|
+
vi.useRealTimers();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
43
71
|
});
|
|
@@ -388,6 +388,26 @@ describe('MobileLazySelect', () => {
|
|
|
388
388
|
resetMockState();
|
|
389
389
|
});
|
|
390
390
|
|
|
391
|
+
it.each([false, true])('updates the disabled trigger state in %s multiple mode', (multiple) => {
|
|
392
|
+
const onDropdownVisibleChange = vi.fn();
|
|
393
|
+
const { rerender } = renderMobileLazySelect({ multiple, onDropdownVisibleChange });
|
|
394
|
+
|
|
395
|
+
rerender({ disabled: true });
|
|
396
|
+
|
|
397
|
+
expect(mockState.selectProps?.disabled).toBe(true);
|
|
398
|
+
act(() => {
|
|
399
|
+
mockState.selectProps?.onClick?.();
|
|
400
|
+
});
|
|
401
|
+
expect(screen.queryByTestId('popup')).not.toBeInTheDocument();
|
|
402
|
+
expect(onDropdownVisibleChange).not.toHaveBeenCalled();
|
|
403
|
+
|
|
404
|
+
rerender({ disabled: false });
|
|
405
|
+
|
|
406
|
+
expect(mockState.selectProps?.disabled).toBe(false);
|
|
407
|
+
openLazyPopup();
|
|
408
|
+
expect(onDropdownVisibleChange).toHaveBeenCalledWith(true);
|
|
409
|
+
});
|
|
410
|
+
|
|
391
411
|
it('renders a translated cancel action after searching', () => {
|
|
392
412
|
renderMobileLazySelect();
|
|
393
413
|
|
|
@@ -201,12 +201,12 @@ function TopbarInternalSettingsLabel(props: { title: React.ReactNode; path?: str
|
|
|
201
201
|
adminRoutePath: getTopbarAdminRoutePath(app),
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
+
if (!shouldOpenInNewWindow) {
|
|
205
|
+
return <Link to={stripTopbarRouterBasePath(targetPathInCurrentApp, basename)}>{props.title}</Link>;
|
|
206
|
+
}
|
|
207
|
+
|
|
204
208
|
return (
|
|
205
|
-
<a
|
|
206
|
-
href={href}
|
|
207
|
-
target={shouldOpenInNewWindow ? '_blank' : undefined}
|
|
208
|
-
rel={shouldOpenInNewWindow ? 'noopener noreferrer' : undefined}
|
|
209
|
-
>
|
|
209
|
+
<a href={href} target="_blank" rel="noopener noreferrer">
|
|
210
210
|
{props.title}
|
|
211
211
|
</a>
|
|
212
212
|
);
|