@nocobase/client-v2 2.1.0-alpha.30 → 2.1.0-alpha.31
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/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
- package/es/flow/index.d.ts +1 -0
- package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
- package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
- package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
- package/es/flow/models/actions/index.d.ts +3 -0
- package/es/flow/models/base/GridModel.d.ts +3 -1
- package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +15 -6
- package/es/flow/models/blocks/shared/filterOperators.d.ts +9 -0
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +1 -0
- package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
- package/es/flow-compat/data.d.ts +9 -2
- package/es/flow-compat/index.d.ts +1 -1
- package/es/index.d.ts +1 -0
- package/es/index.mjs +90 -90
- package/lib/index.js +87 -87
- package/package.json +5 -5
- package/src/BaseApplication.tsx +1 -1
- package/src/__tests__/app.test.tsx +23 -6
- package/src/__tests__/globalDeps.test.ts +5 -0
- package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
- package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
- package/src/flow/actions/linkageRules.tsx +8 -1
- package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
- package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
- package/src/flow/actions/titleField.tsx +8 -3
- package/src/flow/components/FieldAssignValueInput.tsx +1 -0
- package/src/flow/components/filter/LinkageFilterItem.tsx +6 -5
- package/src/flow/components/filter/VariableFilterItem.tsx +14 -13
- package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +33 -0
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -5
- package/src/flow/index.ts +1 -0
- package/src/flow/internal/utils/__tests__/titleFieldQuickSync.test.ts +1 -0
- package/src/flow/internal/utils/titleFieldQuickSync.ts +2 -2
- package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
- package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
- package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
- package/src/flow/models/actions/FilterActionModel.tsx +17 -9
- package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
- package/src/flow/models/actions/index.ts +3 -0
- package/src/flow/models/base/GridModel.tsx +21 -1
- package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
- package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
- package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +200 -36
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +270 -1
- package/src/flow/models/blocks/filter-form/__tests__/customFieldOperators.test.tsx +23 -0
- package/src/flow/models/blocks/filter-form/customFieldOperators.ts +12 -1
- package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +22 -8
- package/src/flow/models/blocks/filter-form/fields/__tests__/FilterFormCustomFieldModel.recordSelect.test.tsx +18 -0
- package/src/flow/models/blocks/filter-manager/FilterManager.ts +51 -1
- package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +75 -0
- package/src/flow/models/blocks/form/FormItemModel.tsx +48 -28
- package/src/flow/models/blocks/shared/filterOperators.ts +14 -0
- package/src/flow/models/blocks/table/TableBlockModel.tsx +19 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +21 -5
- package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
- package/src/flow/models/fields/DividerItemModel.tsx +30 -15
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
- package/src/flow-compat/data.ts +25 -3
- package/src/flow-compat/index.ts +7 -1
- package/src/index.ts +1 -0
- package/src/utils/globalDeps.ts +6 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
11
|
-
import { FlowEngine } from '@nocobase/flow-engine';
|
|
11
|
+
import { FlowEngine, projectLayoutToLegacyRows } from '@nocobase/flow-engine';
|
|
12
12
|
import '../../../../index';
|
|
13
13
|
import { GRID_FLOW_KEY, GRID_STEP } from '../../../base';
|
|
14
14
|
import { FilterFormGridModel } from '../FilterFormGridModel';
|
|
@@ -147,6 +147,71 @@ describe('FilterFormGridModel.toggleFormFieldsCollapse', () => {
|
|
|
147
147
|
expect(model.props.layout.rows[0].cells[0].items).toEqual(['field-1', 'field-2', 'field-3']);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
it('keeps nested columns inside the first visible row when collapsing a v2 layout', () => {
|
|
151
|
+
const model = engine.createModel<FilterFormGridModel>({
|
|
152
|
+
uid: 'filter-grid-collapse-nested-v2',
|
|
153
|
+
use: 'FilterFormGridModel',
|
|
154
|
+
props: {
|
|
155
|
+
layout: {
|
|
156
|
+
version: 2,
|
|
157
|
+
rows: [
|
|
158
|
+
{
|
|
159
|
+
id: 'first',
|
|
160
|
+
cells: [
|
|
161
|
+
{
|
|
162
|
+
id: 'first-cell',
|
|
163
|
+
rows: [
|
|
164
|
+
{
|
|
165
|
+
id: 'first-nested-row',
|
|
166
|
+
cells: [
|
|
167
|
+
{ id: 'first-nested-cell-1', items: ['field-1'] },
|
|
168
|
+
{ id: 'first-nested-cell-2', items: ['field-2'] },
|
|
169
|
+
],
|
|
170
|
+
sizes: [12, 12],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
sizes: [24],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: 'second',
|
|
179
|
+
cells: [{ id: 'second-cell', items: ['field-3'] }],
|
|
180
|
+
sizes: [24],
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
structure: {} as any,
|
|
186
|
+
});
|
|
187
|
+
(model as any).subModels = {
|
|
188
|
+
items: [
|
|
189
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-1' }),
|
|
190
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-2' }),
|
|
191
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-3' }),
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
model.setStepParams(GRID_FLOW_KEY, GRID_STEP, {
|
|
196
|
+
layout: model.props.layout,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
model.toggleFormFieldsCollapse(true, 1);
|
|
200
|
+
|
|
201
|
+
const collapsedNestedRow = model.props.layout.rows[0].cells[0].rows?.[0];
|
|
202
|
+
expect(collapsedNestedRow?.cells.map((cell) => cell.items)).toEqual([['field-1'], ['field-2']]);
|
|
203
|
+
expect(collapsedNestedRow?.sizes).toEqual([12, 12]);
|
|
204
|
+
expect(model.props.layout.rows).toHaveLength(1);
|
|
205
|
+
|
|
206
|
+
model.toggleFormFieldsCollapse(false, 1);
|
|
207
|
+
|
|
208
|
+
expect(model.props.layout.rows).toHaveLength(2);
|
|
209
|
+
expect(model.props.layout.rows[0].cells[0].rows?.[0].cells.map((cell) => cell.items)).toEqual([
|
|
210
|
+
['field-1'],
|
|
211
|
+
['field-2'],
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
150
215
|
it('restores the persisted full layout when current props rows were already truncated', () => {
|
|
151
216
|
const model = engine.createModel<FilterFormGridModel>({
|
|
152
217
|
uid: 'filter-grid-collapse-restore',
|
|
@@ -185,4 +250,208 @@ describe('FilterFormGridModel.toggleFormFieldsCollapse', () => {
|
|
|
185
250
|
});
|
|
186
251
|
expect(model.props.rowOrder).toEqual(['first', 'second', 'third']);
|
|
187
252
|
});
|
|
253
|
+
|
|
254
|
+
it('does not reinsert collapsed items when the render layout is normalized again', () => {
|
|
255
|
+
const model = engine.createModel<FilterFormGridModel>({
|
|
256
|
+
uid: 'filter-grid-collapse-visible-item-uids',
|
|
257
|
+
use: 'FilterFormGridModel',
|
|
258
|
+
props: {
|
|
259
|
+
rows: {
|
|
260
|
+
first: [['field-1']],
|
|
261
|
+
second: [['field-2']],
|
|
262
|
+
third: [['field-3']],
|
|
263
|
+
},
|
|
264
|
+
rowOrder: ['first', 'second', 'third'],
|
|
265
|
+
},
|
|
266
|
+
structure: {} as any,
|
|
267
|
+
});
|
|
268
|
+
(model as any).subModels = {
|
|
269
|
+
items: [
|
|
270
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-1' }),
|
|
271
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-2' }),
|
|
272
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-3' }),
|
|
273
|
+
],
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
model.setStepParams(GRID_FLOW_KEY, GRID_STEP, {
|
|
277
|
+
rows: {
|
|
278
|
+
first: [['field-1']],
|
|
279
|
+
second: [['field-2']],
|
|
280
|
+
third: [['field-3']],
|
|
281
|
+
},
|
|
282
|
+
rowOrder: ['first', 'second', 'third'],
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
model.toggleFormFieldsCollapse(true, 1);
|
|
286
|
+
|
|
287
|
+
expect(projectLayoutToLegacyRows(model.getGridLayout()).rows).toEqual({
|
|
288
|
+
first: [['field-1']],
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
model.toggleFormFieldsCollapse(false, 1);
|
|
292
|
+
|
|
293
|
+
expect(projectLayoutToLegacyRows(model.getGridLayout()).rows).toEqual({
|
|
294
|
+
first: [['field-1']],
|
|
295
|
+
second: [['field-2']],
|
|
296
|
+
third: [['field-3']],
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('keeps the persisted layout intact when resetRows runs during collapsed mode', () => {
|
|
301
|
+
const model = engine.createModel<FilterFormGridModel>({
|
|
302
|
+
uid: 'filter-grid-collapse-reset-rows',
|
|
303
|
+
use: 'FilterFormGridModel',
|
|
304
|
+
props: {
|
|
305
|
+
rows: {
|
|
306
|
+
first: [['field-1']],
|
|
307
|
+
second: [['field-2']],
|
|
308
|
+
third: [['field-3']],
|
|
309
|
+
},
|
|
310
|
+
rowOrder: ['first', 'second', 'third'],
|
|
311
|
+
},
|
|
312
|
+
structure: {} as any,
|
|
313
|
+
});
|
|
314
|
+
(model as any).subModels = {
|
|
315
|
+
items: [
|
|
316
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-1' }),
|
|
317
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-2' }),
|
|
318
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-3' }),
|
|
319
|
+
],
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
model.setStepParams(GRID_FLOW_KEY, GRID_STEP, {
|
|
323
|
+
rows: {
|
|
324
|
+
first: [['field-1']],
|
|
325
|
+
second: [['field-2']],
|
|
326
|
+
third: [['field-3']],
|
|
327
|
+
},
|
|
328
|
+
rowOrder: ['first', 'second', 'third'],
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
model.toggleFormFieldsCollapse(true, 1);
|
|
332
|
+
model.resetRows(true);
|
|
333
|
+
|
|
334
|
+
const persistedLayout = model.getStepParams(GRID_FLOW_KEY, GRID_STEP).layout;
|
|
335
|
+
const persistedRows = projectLayoutToLegacyRows(persistedLayout).rows;
|
|
336
|
+
const persistedItems = Object.values(persistedRows).flat().flat().sort();
|
|
337
|
+
expect(persistedItems).toEqual(['field-1', 'field-2', 'field-3']);
|
|
338
|
+
expect(projectLayoutToLegacyRows(model.getGridLayout()).rows).toEqual({
|
|
339
|
+
first: [['field-1']],
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('uses the full layout for flow-settings layout reads during collapsed mode', () => {
|
|
344
|
+
const model = engine.createModel<FilterFormGridModel>({
|
|
345
|
+
uid: 'filter-grid-collapse-settings-full-layout',
|
|
346
|
+
use: 'FilterFormGridModel',
|
|
347
|
+
props: {
|
|
348
|
+
layout: {
|
|
349
|
+
version: 2,
|
|
350
|
+
rows: [
|
|
351
|
+
{
|
|
352
|
+
id: 'first',
|
|
353
|
+
cells: [{ id: 'first-cell', items: ['field-1'] }],
|
|
354
|
+
sizes: [24],
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
id: 'second',
|
|
358
|
+
cells: [
|
|
359
|
+
{ id: 'second-cell-1', items: ['field-2'] },
|
|
360
|
+
{ id: 'second-cell-2', items: ['field-3'] },
|
|
361
|
+
],
|
|
362
|
+
sizes: [12, 12],
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
structure: {} as any,
|
|
368
|
+
});
|
|
369
|
+
(model as any).subModels = {
|
|
370
|
+
items: [
|
|
371
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-1' }),
|
|
372
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-2' }),
|
|
373
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-3' }),
|
|
374
|
+
],
|
|
375
|
+
};
|
|
376
|
+
(model.context as any).flowSettingsEnabled = true;
|
|
377
|
+
|
|
378
|
+
model.setStepParams(GRID_FLOW_KEY, GRID_STEP, {
|
|
379
|
+
layout: model.props.layout,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
model.toggleFormFieldsCollapse(true, 1);
|
|
383
|
+
|
|
384
|
+
expect(projectLayoutToLegacyRows((model as any).normalizeLayoutFromSource()).rows).toEqual({
|
|
385
|
+
first: [['field-1']],
|
|
386
|
+
});
|
|
387
|
+
expect(projectLayoutToLegacyRows(model.getGridLayout()).rows).toEqual({
|
|
388
|
+
first: [['field-1']],
|
|
389
|
+
second: [['field-2'], ['field-3']],
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
model.saveGridLayout(model.getGridLayout());
|
|
393
|
+
|
|
394
|
+
expect(projectLayoutToLegacyRows(model.getStepParams(GRID_FLOW_KEY, GRID_STEP).layout).rows).toEqual({
|
|
395
|
+
first: [['field-1']],
|
|
396
|
+
second: [['field-2'], ['field-3']],
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('restores the latest saved full layout after editing while collapsed in flow settings', () => {
|
|
401
|
+
const model = engine.createModel<FilterFormGridModel>({
|
|
402
|
+
uid: 'filter-grid-collapse-settings-latest-layout',
|
|
403
|
+
use: 'FilterFormGridModel',
|
|
404
|
+
props: {
|
|
405
|
+
layout: {
|
|
406
|
+
version: 2,
|
|
407
|
+
rows: [
|
|
408
|
+
{
|
|
409
|
+
id: 'first',
|
|
410
|
+
cells: [{ id: 'first-cell', items: ['field-1'] }],
|
|
411
|
+
sizes: [24],
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
id: 'second',
|
|
415
|
+
cells: [{ id: 'second-cell', items: ['field-2'] }],
|
|
416
|
+
sizes: [24],
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
structure: {} as any,
|
|
422
|
+
});
|
|
423
|
+
(model as any).subModels = {
|
|
424
|
+
items: [
|
|
425
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-1' }),
|
|
426
|
+
engine.createModel({ use: 'FlowModel', uid: 'field-2' }),
|
|
427
|
+
],
|
|
428
|
+
};
|
|
429
|
+
(model.context as any).flowSettingsEnabled = true;
|
|
430
|
+
|
|
431
|
+
model.setStepParams(GRID_FLOW_KEY, GRID_STEP, {
|
|
432
|
+
layout: model.props.layout,
|
|
433
|
+
});
|
|
434
|
+
model.toggleFormFieldsCollapse(true, 1);
|
|
435
|
+
|
|
436
|
+
const editedLayout = {
|
|
437
|
+
version: 2 as const,
|
|
438
|
+
rows: [
|
|
439
|
+
{
|
|
440
|
+
id: 'first',
|
|
441
|
+
cells: [
|
|
442
|
+
{ id: 'first-cell-1', items: ['field-1'] },
|
|
443
|
+
{ id: 'first-cell-2', items: ['field-2'] },
|
|
444
|
+
],
|
|
445
|
+
sizes: [12, 12],
|
|
446
|
+
},
|
|
447
|
+
],
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
model.saveGridLayout(editedLayout);
|
|
451
|
+
model.toggleFormFieldsCollapse(false, 1);
|
|
452
|
+
|
|
453
|
+
expect(projectLayoutToLegacyRows(model.props.layout).rows).toEqual({
|
|
454
|
+
first: [['field-1'], ['field-2']],
|
|
455
|
+
});
|
|
456
|
+
});
|
|
188
457
|
});
|
|
@@ -37,6 +37,8 @@ function createEngineWithCollections() {
|
|
|
37
37
|
fields: [
|
|
38
38
|
{ name: 'id', type: 'integer', interface: 'number', filterable: { operators: [] } },
|
|
39
39
|
{ name: 'uid', type: 'string', interface: 'input', filterable: { operators: [] } },
|
|
40
|
+
{ name: 'status', type: 'string', interface: 'select', filterable: { operators: [] } },
|
|
41
|
+
{ name: 'tags', type: 'array', interface: 'multipleSelect', filterable: { operators: [] } },
|
|
40
42
|
{ name: 'createdAt', type: 'date', interface: 'datetime', filterable: { operators: [] } },
|
|
41
43
|
{
|
|
42
44
|
name: 'pluginInSource',
|
|
@@ -106,6 +108,27 @@ describe('custom field operators', () => {
|
|
|
106
108
|
expect(checkboxGroupOps.some((item) => item.value === '$match')).toBe(true);
|
|
107
109
|
});
|
|
108
110
|
|
|
111
|
+
it('uses multi-value scalar operators for multiple select custom fields bound to scalar source fields', () => {
|
|
112
|
+
const engine = createEngineWithCollections();
|
|
113
|
+
|
|
114
|
+
const scalarSourceOps = resolveCustomFieldOperatorList({
|
|
115
|
+
flowEngine: engine,
|
|
116
|
+
fieldModel: 'SelectFieldModel',
|
|
117
|
+
fieldModelProps: { mode: 'multiple' },
|
|
118
|
+
source: ['main', 'users', 'status'],
|
|
119
|
+
});
|
|
120
|
+
expect(scalarSourceOps[0]?.value).toBe('$in');
|
|
121
|
+
expect(scalarSourceOps.some((item) => item.value === '$match')).toBe(false);
|
|
122
|
+
|
|
123
|
+
const arraySourceOps = resolveCustomFieldOperatorList({
|
|
124
|
+
flowEngine: engine,
|
|
125
|
+
fieldModel: 'SelectFieldModel',
|
|
126
|
+
fieldModelProps: { mode: 'multiple' },
|
|
127
|
+
source: ['main', 'users', 'tags'],
|
|
128
|
+
});
|
|
129
|
+
expect(arraySourceOps[0]?.value).toBe('$match');
|
|
130
|
+
});
|
|
131
|
+
|
|
109
132
|
it('resolves record select operators by value field and multiple mode', () => {
|
|
110
133
|
const engine = createEngineWithCollections();
|
|
111
134
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { operators } from '../../../../flow-compat';
|
|
11
|
+
import { isArrayLikeField } from '../shared/filterOperators';
|
|
11
12
|
|
|
12
13
|
type OperatorMeta = {
|
|
13
14
|
label: string;
|
|
@@ -186,12 +187,22 @@ function resolveByModelOrSource(params: ResolveOperatorParams): { operatorList:
|
|
|
186
187
|
return resolveByRecordSelect(params);
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
const sourceField = getSourceField(flowEngine, source);
|
|
191
|
+
if (fieldModel === 'SelectFieldModel' && fieldModelProps?.mode === 'multiple' && sourceField) {
|
|
192
|
+
const sourceOperators = getFieldOperators(sourceField);
|
|
193
|
+
return {
|
|
194
|
+
operatorList: isArrayLikeField(sourceField)
|
|
195
|
+
? getOperatorListByModel(fieldModel, fieldModelProps)
|
|
196
|
+
: toMultiValueOperators(sourceOperators),
|
|
197
|
+
meta: sourceField,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
189
201
|
const modelOperators = getOperatorListByModel(fieldModel, fieldModelProps);
|
|
190
202
|
if (modelOperators.length > 0) {
|
|
191
203
|
return { operatorList: modelOperators, meta: { fieldModel } };
|
|
192
204
|
}
|
|
193
205
|
|
|
194
|
-
const sourceField = getSourceField(flowEngine, source);
|
|
195
206
|
return {
|
|
196
207
|
operatorList: getFieldOperators(sourceField),
|
|
197
208
|
meta: sourceField,
|
|
@@ -14,7 +14,11 @@ import { Input, Radio, Checkbox, Space, Button, Select } from 'antd';
|
|
|
14
14
|
import { PlusOutlined, CloseOutlined } from '@ant-design/icons';
|
|
15
15
|
import { useTranslation } from 'react-i18next';
|
|
16
16
|
import { FilterableItemModel, useFlowContext, useFlowEngine } from '@nocobase/flow-engine';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
getFlowFieldInterfaceOptions,
|
|
19
|
+
hasFlowFieldInterfaceLookup,
|
|
20
|
+
isTitleFieldInterface,
|
|
21
|
+
} from '../../../../../flow-compat';
|
|
18
22
|
|
|
19
23
|
const RECORD_SELECT_DATA_SOURCE_KEY = 'recordSelectDataSourceKey';
|
|
20
24
|
const RECORD_SELECT_COLLECTION_KEY = 'recordSelectTargetCollection';
|
|
@@ -36,7 +40,12 @@ export const FieldComponentProps: React.FC<{ fieldModel: string; source: string[
|
|
|
36
40
|
const resolvedFieldModel = fieldModel || propsValue?.fieldModel;
|
|
37
41
|
const flowEngine = useFlowEngine();
|
|
38
42
|
const ctx = useFlowContext();
|
|
39
|
-
const
|
|
43
|
+
const dataSourceManager = ctx?.dataSourceManager || flowEngine?.dataSourceManager || ctx?.app?.dataSourceManager;
|
|
44
|
+
const hasFieldInterfaceLookup = hasFlowFieldInterfaceLookup(dataSourceManager);
|
|
45
|
+
const getFieldInterface = useCallback(
|
|
46
|
+
(interfaceName: string | undefined) => getFlowFieldInterfaceOptions(interfaceName, dataSourceManager),
|
|
47
|
+
[dataSourceManager],
|
|
48
|
+
);
|
|
40
49
|
|
|
41
50
|
const getCurrentValue = () => field.value || {};
|
|
42
51
|
const updateProps = (key: string, value: any) => {
|
|
@@ -165,15 +174,20 @@ export const FieldComponentProps: React.FC<{ fieldModel: string; source: string[
|
|
|
165
174
|
}, [activeDataSource, translateLabel]);
|
|
166
175
|
const titleFieldOptions = useMemo(() => {
|
|
167
176
|
if (!activeCollection?.getFields) return [];
|
|
168
|
-
const shouldKeep = (fieldItem: any) =>
|
|
169
|
-
|
|
177
|
+
const shouldKeep = (fieldItem: any) => {
|
|
178
|
+
if (!hasFieldInterfaceLookup) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
const fieldOptions = fieldItem?.options || fieldItem;
|
|
182
|
+
return isTitleFieldInterface(getFieldInterface(fieldOptions?.interface));
|
|
183
|
+
};
|
|
170
184
|
return (activeCollection.getFields() || [])
|
|
171
185
|
.filter((fieldItem: any) => shouldKeep(fieldItem))
|
|
172
186
|
.map((fieldItem: any) => ({
|
|
173
187
|
label: translateLabel(fieldItem.options?.uiSchema?.title) || translateLabel(fieldItem.title) || fieldItem.name,
|
|
174
188
|
value: fieldItem.name,
|
|
175
189
|
}));
|
|
176
|
-
}, [activeCollection,
|
|
190
|
+
}, [activeCollection, getFieldInterface, hasFieldInterfaceLookup, translateLabel]);
|
|
177
191
|
const valueFieldOptions = useMemo(() => {
|
|
178
192
|
if (!activeCollection?.getFields) return [];
|
|
179
193
|
const shouldKeep = (fieldItem: any) => {
|
|
@@ -182,8 +196,8 @@ export const FieldComponentProps: React.FC<{ fieldModel: string; source: string[
|
|
|
182
196
|
if (fieldOptions.filterable === false || !interfaceName) {
|
|
183
197
|
return false;
|
|
184
198
|
}
|
|
185
|
-
if (
|
|
186
|
-
const fieldInterface =
|
|
199
|
+
if (hasFieldInterfaceLookup) {
|
|
200
|
+
const fieldInterface = getFieldInterface(interfaceName);
|
|
187
201
|
if (!fieldInterface?.filterable) {
|
|
188
202
|
return false;
|
|
189
203
|
}
|
|
@@ -197,7 +211,7 @@ export const FieldComponentProps: React.FC<{ fieldModel: string; source: string[
|
|
|
197
211
|
label: translateLabel(fieldItem.options?.uiSchema?.title) || translateLabel(fieldItem.title) || fieldItem.name,
|
|
198
212
|
value: fieldItem.name,
|
|
199
213
|
}));
|
|
200
|
-
}, [activeCollection,
|
|
214
|
+
}, [activeCollection, getFieldInterface, hasFieldInterfaceLookup, translateLabel]);
|
|
201
215
|
|
|
202
216
|
useEffect(() => {
|
|
203
217
|
if (resolvedFieldModel !== 'FilterFormCustomRecordSelectFieldModel') return;
|
|
@@ -222,6 +222,17 @@ describe('FilterForm custom field record select', () => {
|
|
|
222
222
|
it('hides association fields from value field options', async () => {
|
|
223
223
|
const engine = new FlowEngine();
|
|
224
224
|
engine.registerModels({ HostModel, FilterFormCustomRecordSelectFieldModel });
|
|
225
|
+
engine.dataSourceManager.setCollectionFieldInterfaceManager({
|
|
226
|
+
getFieldInterface: vi.fn((name: string) => {
|
|
227
|
+
if (['input', 'number'].includes(name)) {
|
|
228
|
+
return { titleUsable: true, filterable: { operators: [] } };
|
|
229
|
+
}
|
|
230
|
+
if (name === 'm2m') {
|
|
231
|
+
return { filterable: { operators: [] } };
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
225
236
|
|
|
226
237
|
const ds = engine.dataSourceManager.getDataSource('main');
|
|
227
238
|
ds?.addCollection({
|
|
@@ -242,6 +253,12 @@ describe('FilterForm custom field record select', () => {
|
|
|
242
253
|
title: 'Hidden text',
|
|
243
254
|
filterable: false,
|
|
244
255
|
},
|
|
256
|
+
{
|
|
257
|
+
name: 'jsonPayload',
|
|
258
|
+
type: 'json',
|
|
259
|
+
interface: 'json',
|
|
260
|
+
title: 'JSON payload',
|
|
261
|
+
},
|
|
245
262
|
{
|
|
246
263
|
name: 'roles',
|
|
247
264
|
type: 'belongsToMany',
|
|
@@ -298,6 +315,7 @@ describe('FilterForm custom field record select', () => {
|
|
|
298
315
|
);
|
|
299
316
|
expect(optionTexts).toContain('nickname');
|
|
300
317
|
expect(optionTexts).not.toContain('Hidden text');
|
|
318
|
+
expect(optionTexts).not.toContain('JSON payload');
|
|
301
319
|
expect(optionTexts).not.toContain('Roles relation');
|
|
302
320
|
});
|
|
303
321
|
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { FilterGroup, FilterItem, FlowModel } from '@nocobase/flow-engine';
|
|
11
11
|
import _ from 'lodash';
|
|
12
12
|
import { CollectionBlockModel } from '../../base/CollectionBlockModel';
|
|
13
|
+
import { isArrayLikeField } from '../shared/filterOperators';
|
|
13
14
|
import { getDefaultOperator, isFilterValueEmpty } from './utils';
|
|
14
15
|
|
|
15
16
|
type FilterConfig = {
|
|
@@ -23,6 +24,13 @@ type FilterConfig = {
|
|
|
23
24
|
operator?: string;
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
const ARRAY_FIELD_OPERATORS_TO_SCALAR_OPERATORS: Record<string, string> = {
|
|
28
|
+
$match: '$in',
|
|
29
|
+
$anyOf: '$in',
|
|
30
|
+
$notMatch: '$notIn',
|
|
31
|
+
$noneOf: '$notIn',
|
|
32
|
+
};
|
|
33
|
+
|
|
26
34
|
export type ConnectFieldsConfig = {
|
|
27
35
|
targets: {
|
|
28
36
|
/** 数据区块或者图表区块的 model uid */
|
|
@@ -32,6 +40,44 @@ export type ConnectFieldsConfig = {
|
|
|
32
40
|
}[];
|
|
33
41
|
};
|
|
34
42
|
|
|
43
|
+
function getTargetField(targetModel: any, fieldPath: string) {
|
|
44
|
+
const dataSourceManager = targetModel?.context?.dataSourceManager;
|
|
45
|
+
const collection = targetModel?.collection;
|
|
46
|
+
if (!dataSourceManager || !collection?.dataSourceKey || !collection?.name || !fieldPath) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let collectionName = collection.name;
|
|
51
|
+
const fieldNames = fieldPath.split('.').filter(Boolean);
|
|
52
|
+
for (let index = 0; index < fieldNames.length; index += 1) {
|
|
53
|
+
const field = dataSourceManager.getCollectionField?.(
|
|
54
|
+
`${collection.dataSourceKey}.${collectionName}.${fieldNames[index]}`,
|
|
55
|
+
);
|
|
56
|
+
if (!field || index === fieldNames.length - 1) {
|
|
57
|
+
return field;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
collectionName = field.target || field.targetCollection?.name;
|
|
61
|
+
if (!collectionName) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeOperatorForTargetField(operator: string, targetModel: any, fieldPath: string) {
|
|
68
|
+
const scalarOperator = ARRAY_FIELD_OPERATORS_TO_SCALAR_OPERATORS[operator];
|
|
69
|
+
if (!scalarOperator) {
|
|
70
|
+
return operator;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const targetField = getTargetField(targetModel, fieldPath);
|
|
74
|
+
if (!targetField || isArrayLikeField(targetField)) {
|
|
75
|
+
return operator;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return scalarOperator;
|
|
79
|
+
}
|
|
80
|
+
|
|
35
81
|
export class FilterManager {
|
|
36
82
|
private filterConfigs: FilterConfig[];
|
|
37
83
|
private readonly gridModel: FlowModel;
|
|
@@ -300,7 +346,11 @@ export class FilterManager {
|
|
|
300
346
|
// 构建筛选条件
|
|
301
347
|
const filterConditions = config.filterPaths.map((fieldPath) => ({
|
|
302
348
|
path: fieldPath,
|
|
303
|
-
operator:
|
|
349
|
+
operator: normalizeOperatorForTargetField(
|
|
350
|
+
config.operator || getDefaultOperator(filterModel),
|
|
351
|
+
targetModel,
|
|
352
|
+
fieldPath,
|
|
353
|
+
),
|
|
304
354
|
value: filterValue,
|
|
305
355
|
}));
|
|
306
356
|
|
|
@@ -496,6 +496,81 @@ describe('FilterManager', () => {
|
|
|
496
496
|
expect(mockTargetModel2.resource.refresh).toHaveBeenCalledTimes(1);
|
|
497
497
|
});
|
|
498
498
|
|
|
499
|
+
it('should normalize array operators when the target field is scalar', async () => {
|
|
500
|
+
const filterConfigs = [
|
|
501
|
+
{
|
|
502
|
+
filterId: 'filter-1',
|
|
503
|
+
targetId: 'target-scalar',
|
|
504
|
+
filterPaths: ['status'],
|
|
505
|
+
operator: '$match',
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
filterId: 'filter-1',
|
|
509
|
+
targetId: 'target-array',
|
|
510
|
+
filterPaths: ['tags'],
|
|
511
|
+
operator: '$match',
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
filterId: 'filter-1',
|
|
515
|
+
targetId: 'target-deep-array',
|
|
516
|
+
filterPaths: ['org.company.tags'],
|
|
517
|
+
operator: '$match',
|
|
518
|
+
},
|
|
519
|
+
];
|
|
520
|
+
|
|
521
|
+
(filterManager as any).filterConfigs = filterConfigs;
|
|
522
|
+
|
|
523
|
+
const createTargetModel = (getCollectionField: any) => ({
|
|
524
|
+
collection: {
|
|
525
|
+
dataSourceKey: 'main',
|
|
526
|
+
name: 'users',
|
|
527
|
+
},
|
|
528
|
+
context: {
|
|
529
|
+
dataSourceManager: {
|
|
530
|
+
getCollectionField,
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
resource: {
|
|
534
|
+
addFilterGroup: vi.fn(),
|
|
535
|
+
removeFilterGroup: vi.fn(),
|
|
536
|
+
refresh: vi.fn().mockResolvedValue(undefined),
|
|
537
|
+
},
|
|
538
|
+
setFilterActive: vi.fn(),
|
|
539
|
+
getDataLoadingMode: vi.fn().mockReturnValue('auto'),
|
|
540
|
+
});
|
|
541
|
+
const mockScalarTargetModel = createTargetModel(vi.fn().mockReturnValue({ interface: 'select', type: 'string' }));
|
|
542
|
+
const mockArrayTargetModel = createTargetModel(
|
|
543
|
+
vi.fn().mockReturnValue({ interface: 'multipleSelect', type: 'array' }),
|
|
544
|
+
);
|
|
545
|
+
const mockDeepArrayTargetModel = createTargetModel(
|
|
546
|
+
vi.fn((key: string) => {
|
|
547
|
+
const fields = {
|
|
548
|
+
'main.users.org': { target: 'orgs' },
|
|
549
|
+
'main.orgs.company': { target: 'companies' },
|
|
550
|
+
'main.companies.tags': { interface: 'multipleSelect', type: 'array' },
|
|
551
|
+
};
|
|
552
|
+
return fields[key];
|
|
553
|
+
}),
|
|
554
|
+
);
|
|
555
|
+
const mockFilterModel = {
|
|
556
|
+
getFilterValue: vi.fn().mockReturnValue(['a1']),
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
(mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
|
|
560
|
+
if (uid === 'target-scalar') return mockScalarTargetModel;
|
|
561
|
+
if (uid === 'target-array') return mockArrayTargetModel;
|
|
562
|
+
if (uid === 'target-deep-array') return mockDeepArrayTargetModel;
|
|
563
|
+
if (uid === 'filter-1') return mockFilterModel;
|
|
564
|
+
return null;
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
await filterManager.refreshTargetsByFilter('filter-1');
|
|
568
|
+
|
|
569
|
+
expect(mockScalarTargetModel.resource.addFilterGroup.mock.calls[0][1].options.operator).toBe('$in');
|
|
570
|
+
expect(mockArrayTargetModel.resource.addFilterGroup.mock.calls[0][1].options.operator).toBe('$match');
|
|
571
|
+
expect(mockDeepArrayTargetModel.resource.addFilterGroup.mock.calls[0][1].options.operator).toBe('$match');
|
|
572
|
+
});
|
|
573
|
+
|
|
499
574
|
it('should process multiple filterIds successfully', async () => {
|
|
500
575
|
// Setup filter configs
|
|
501
576
|
const filterConfigs = [
|