@nocobase/client-v2 2.3.0-alpha.1 → 2.3.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
  3. package/es/flow/actions/linkageRules.d.ts +25 -0
  4. package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
  5. package/es/flow/components/FlowRoute.d.ts +2 -2
  6. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  7. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
  8. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  9. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  10. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  11. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  12. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  13. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  14. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  15. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  16. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  17. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  18. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  19. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  20. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  21. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  22. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  23. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  24. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  25. package/es/flow/models/blocks/table/TableColumnModel.d.ts +12 -0
  26. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  27. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  28. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  29. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  30. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  31. package/es/flow-compat/passwordUtils.d.ts +1 -1
  32. package/es/index.d.ts +1 -0
  33. package/es/index.mjs +147 -144
  34. package/es/ui-operation/index.d.ts +15 -0
  35. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  36. package/lib/index.js +170 -167
  37. package/lib/locale/languageCodes.js +1 -0
  38. package/package.json +8 -7
  39. package/src/APIClient.ts +92 -0
  40. package/src/Application.tsx +3 -1
  41. package/src/__tests__/APIClient.test.tsx +58 -0
  42. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
  43. package/src/__tests__/settings-center.test.tsx +270 -6
  44. package/src/acl/ACLProvider.tsx +2 -0
  45. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  46. package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
  47. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  48. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
  49. package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
  50. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  51. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  52. package/src/components/form/table/Table.tsx +81 -6
  53. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  54. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  55. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  56. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  57. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  58. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  59. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  60. package/src/flow/actions/linkageRules.tsx +59 -16
  61. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  62. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  63. package/src/flow/common/Markdown/style.ts +4 -0
  64. package/src/flow/components/BlockItemCard.tsx +2 -2
  65. package/src/flow/components/FieldAssignValueInput.tsx +38 -621
  66. package/src/flow/components/FlowRoute.tsx +71 -29
  67. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  68. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  69. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  70. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  71. package/src/flow/components/field-value-variable/index.ts +12 -0
  72. package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
  73. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  74. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  75. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  76. package/src/flow/index.ts +2 -0
  77. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  78. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  79. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  80. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  81. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  82. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  83. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  84. package/src/flow/models/base/CollectionBlockModel.tsx +38 -3
  85. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  86. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  87. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  88. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  89. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  90. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  91. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  92. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  93. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  94. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  95. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  96. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  97. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  98. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  99. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  100. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  101. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  102. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  103. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  104. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  105. package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
  106. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  107. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  108. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  109. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  110. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  111. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  112. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  113. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  114. package/src/flow/models/blocks/table/TableBlockModel.tsx +28 -2
  115. package/src/flow/models/blocks/table/TableColumnModel.tsx +48 -9
  116. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  117. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  118. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  119. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  120. package/src/flow/models/blocks/table/utils.ts +7 -0
  121. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  122. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  123. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  124. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  125. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  126. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  127. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
  128. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  129. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  130. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  131. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  132. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  133. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  134. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  135. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  136. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  137. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  138. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  139. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  140. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  141. package/src/index.ts +1 -0
  142. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  143. package/src/layout-manager/LayoutRoute.tsx +2 -1
  144. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  145. package/src/locale/languageCodes.ts +1 -0
  146. package/src/settings-center/AdminSettingsLayout.tsx +23 -2
  147. package/src/ui-operation/index.ts +33 -0
  148. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -9,11 +9,13 @@
9
9
 
10
10
  import { describe, expect, it } from 'vitest';
11
11
  import '../../../../index';
12
- import { FlowEngine } from '@nocobase/flow-engine';
12
+ import { FilterableItemModel, FlowEngine } from '@nocobase/flow-engine';
13
13
  import { CollectionBlockModel } from '../../../base';
14
+ import { CascadeSelectFieldModel } from '../../../fields/AssociationFieldModel/CascadeSelectFieldModel';
14
15
  import { InputFieldModel } from '../../../fields/InputFieldModel';
15
16
  import { NumberFieldModel } from '../../../fields/NumberFieldModel';
16
17
  import { FilterFormRecordSelectFieldModel } from '../fields/FilterFormRecordSelectFieldModel';
18
+ import { FilterFormFieldModel } from '../fields/FilterFormFieldModel';
17
19
  import { FilterFormItemModel } from '../FilterFormItemModel';
18
20
 
19
21
  class DummyResource {
@@ -32,6 +34,20 @@ class DummyCollectionBlockModel extends CollectionBlockModel {
32
34
  }
33
35
  }
34
36
 
37
+ class TestCascaderFilterFieldModel extends FilterFormFieldModel {}
38
+
39
+ FilterableItemModel.bindModelToInterface('TestCascaderFilterFieldModel', ['testChinaRegion'], {
40
+ isDefault: true,
41
+ defaultProps: {
42
+ fieldNames: {
43
+ label: 'name',
44
+ value: 'code',
45
+ },
46
+ labelInValue: true,
47
+ multiple: false,
48
+ },
49
+ });
50
+
35
51
  describe('FilterFormItemModel defineChildren association fields', () => {
36
52
  it('hides default operator setting for association filter fields', () => {
37
53
  const engine = new FlowEngine();
@@ -191,6 +207,348 @@ describe('FilterFormItemModel defineChildren association fields', () => {
191
207
  expect(filterItem.collectionField).toBeTruthy();
192
208
  });
193
209
 
210
+ it('lists filterable children from association field interfaces', async () => {
211
+ const engine = new FlowEngine();
212
+ engine.registerModels({
213
+ FilterFormItemModel: FilterFormItemModel as any,
214
+ DummyCollectionBlockModel,
215
+ InputFieldModel,
216
+ FilterFormRecordSelectFieldModel,
217
+ });
218
+ engine.context.defineProperty('t', {
219
+ value: (value: string) => (value === '{{t("Province/city/area name")}}' ? '省/市/区名称' : value),
220
+ });
221
+
222
+ const ds = engine.dataSourceManager.getDataSource('main');
223
+ ds?.addCollection({
224
+ name: 'students',
225
+ filterTargetKey: 'id',
226
+ fields: [
227
+ { name: 'id', type: 'integer', interface: 'number', filterable: { operators: [] } },
228
+ {
229
+ name: 'studentName',
230
+ title: 'Student name',
231
+ type: 'string',
232
+ interface: 'input',
233
+ filterable: { operators: [] },
234
+ },
235
+ {
236
+ name: 'birthPlace',
237
+ title: 'Birth place',
238
+ type: 'belongsToMany',
239
+ interface: 'chinaRegion',
240
+ target: 'chinaRegions',
241
+ filterable: {
242
+ children: [
243
+ {
244
+ name: 'name',
245
+ title: '{{t("Province/city/area name")}}',
246
+ operators: 'string',
247
+ schema: {
248
+ title: '{{t("Province/city/area name")}}',
249
+ type: 'string',
250
+ 'x-component': 'Input',
251
+ },
252
+ },
253
+ ],
254
+ },
255
+ },
256
+ ],
257
+ });
258
+
259
+ const model = engine.createModel<DummyCollectionBlockModel>({
260
+ uid: 'students-block',
261
+ use: 'DummyCollectionBlockModel',
262
+ stepParams: {
263
+ resourceSettings: {
264
+ init: {
265
+ dataSourceKey: 'main',
266
+ collectionName: 'students',
267
+ },
268
+ },
269
+ },
270
+ });
271
+ const getFilterFields = model.getFilterFields.bind(model);
272
+ model.getFilterFields = async () => {
273
+ const fields = await getFilterFields();
274
+ return fields.map((field: any) =>
275
+ field.name === 'birthPlace'
276
+ ? {
277
+ name: field.name,
278
+ title: field.title,
279
+ type: field.type,
280
+ interface: field.interface,
281
+ target: field.target,
282
+ filterable: field.filterable,
283
+ isAssociationField: () => true,
284
+ }
285
+ : field,
286
+ ) as any;
287
+ };
288
+
289
+ const children = (await FilterFormItemModel.defineChildren({
290
+ blockGridModel: {
291
+ filterSubModels: (_key: string, predicate: (item: any) => boolean) => [model].filter(predicate),
292
+ },
293
+ t: (value: string) => value,
294
+ } as any)) as any[];
295
+
296
+ const groups = await children[0].children();
297
+ const fieldsGroup = groups.find((group: any) => group.key === 'fields');
298
+ const associationGroup = groups.find((group: any) => group.key === 'relation-fields');
299
+ const fieldKeys = (fieldsGroup?.children || []).map((item: any) => item.key);
300
+ const associationKeys = (associationGroup?.children || []).map((item: any) => item.key);
301
+
302
+ expect(fieldKeys).toContain('studentName');
303
+ expect(associationKeys).toContain('birthPlace-associationField');
304
+ expect(associationKeys).not.toContain('studentName-associationField');
305
+
306
+ const regionAssociation = associationGroup?.children?.find(
307
+ (item: any) => item.key === 'birthPlace-associationField',
308
+ );
309
+ const regionGroups = await regionAssociation.children();
310
+ const regionFieldsGroup = regionGroups.find((group: any) => group.key === 'birthPlace-fields');
311
+ const regionNameItem = regionFieldsGroup?.children?.find((item: any) => item.key === 'birthPlace.name');
312
+
313
+ expect(regionNameItem?.label).toBe('Birth place / 省/市/区名称');
314
+
315
+ const createOptions = await regionNameItem.createModelOptions();
316
+ const filterItem = engine.createModel({
317
+ uid: 'filter-item-region-name',
318
+ ...createOptions,
319
+ } as any) as unknown as FilterFormItemModel;
320
+
321
+ expect(filterItem.fieldPath).toBe('birthPlace.name');
322
+ expect(filterItem.context.filterField).toMatchObject({
323
+ name: 'name',
324
+ title: 'Birth place / 省/市/区名称',
325
+ interface: 'input',
326
+ type: 'string',
327
+ });
328
+ expect(filterItem.context.filterField?.filterable?.operators).toBe('string');
329
+ expect(filterItem.collectionField).toMatchObject({
330
+ name: 'name',
331
+ title: 'Birth place / 省/市/区名称',
332
+ interface: 'input',
333
+ type: 'string',
334
+ });
335
+ expect(filterItem.collectionField?.filterable?.operators).toBe('string');
336
+ expect(filterItem.subModels.field).toBeInstanceOf(InputFieldModel);
337
+ });
338
+
339
+ it('uses explicit filterable binding for association fields that provide a filter model', async () => {
340
+ const engine = new FlowEngine();
341
+ engine.registerModels({
342
+ FilterFormItemModel: FilterFormItemModel as any,
343
+ DummyCollectionBlockModel,
344
+ InputFieldModel,
345
+ TestCascaderFilterFieldModel,
346
+ FilterFormRecordSelectFieldModel,
347
+ });
348
+
349
+ const ds = engine.dataSourceManager.getDataSource('main');
350
+ ds?.addCollection({
351
+ name: 'departments',
352
+ filterTargetKey: 'id',
353
+ fields: [
354
+ { name: 'id', type: 'integer', interface: 'number', filterable: { operators: [] } },
355
+ { name: 'name', type: 'string', interface: 'input', filterable: { operators: [] } },
356
+ ],
357
+ });
358
+ ds?.addCollection({
359
+ name: 'chinaRegions',
360
+ fields: [
361
+ { name: 'code', type: 'string', interface: 'input', filterable: { operators: [] } },
362
+ { name: 'name', type: 'string', interface: 'input', filterable: { operators: [] } },
363
+ ],
364
+ });
365
+ ds?.addCollection({
366
+ name: 'students',
367
+ filterTargetKey: 'id',
368
+ fields: [
369
+ {
370
+ name: 'department',
371
+ title: 'Department',
372
+ type: 'belongsTo',
373
+ interface: 'm2o',
374
+ target: 'departments',
375
+ filterable: { operators: [] },
376
+ },
377
+ {
378
+ name: 'birthPlace',
379
+ title: 'Birth place',
380
+ type: 'belongsToMany',
381
+ interface: 'testChinaRegion',
382
+ target: 'chinaRegions',
383
+ targetKey: 'code',
384
+ filterable: { operators: [] },
385
+ },
386
+ ],
387
+ });
388
+
389
+ const model = engine.createModel<DummyCollectionBlockModel>({
390
+ uid: 'students-direct-region-block',
391
+ use: 'DummyCollectionBlockModel',
392
+ stepParams: {
393
+ resourceSettings: {
394
+ init: {
395
+ dataSourceKey: 'main',
396
+ collectionName: 'students',
397
+ },
398
+ },
399
+ },
400
+ });
401
+
402
+ const children = (await FilterFormItemModel.defineChildren({
403
+ blockGridModel: {
404
+ filterSubModels: (_key: string, predicate: (item: any) => boolean) => [model].filter(predicate),
405
+ },
406
+ t: (value: string) => value,
407
+ } as any)) as any[];
408
+ const groups = await children[0].children();
409
+ const fieldsGroup = groups.find((group: any) => group.key === 'fields');
410
+ const departmentItem = fieldsGroup?.children?.find((item: any) => item.key === 'department');
411
+ const birthPlaceItem = fieldsGroup?.children?.find((item: any) => item.key === 'birthPlace');
412
+
413
+ const departmentCreateOptions = await departmentItem.createModelOptions();
414
+ const birthPlaceCreateOptions = await birthPlaceItem.createModelOptions();
415
+
416
+ expect(departmentCreateOptions.subModels.field.use).toBe('FilterFormRecordSelectFieldModel');
417
+ expect(birthPlaceCreateOptions.subModels.field.use).toBe('TestCascaderFilterFieldModel');
418
+ expect(birthPlaceCreateOptions.subModels.field.props).toMatchObject({
419
+ fieldNames: {
420
+ label: 'name',
421
+ value: 'code',
422
+ },
423
+ labelInValue: true,
424
+ multiple: false,
425
+ });
426
+
427
+ const filterItem = engine.createModel({
428
+ uid: 'filter-item-birth-place-direct',
429
+ ...birthPlaceCreateOptions,
430
+ } as any) as unknown as FilterFormItemModel;
431
+
432
+ expect(filterItem.fieldPath).toBe('birthPlace');
433
+ expect(filterItem.subModels.field).toBeInstanceOf(TestCascaderFilterFieldModel);
434
+ });
435
+
436
+ it('uses Cascader by default for tree to-one associations and keeps Dropdown for other associations', async () => {
437
+ const engine = new FlowEngine();
438
+ engine.registerModels({
439
+ FilterFormItemModel: FilterFormItemModel as any,
440
+ DummyCollectionBlockModel,
441
+ CascadeSelectFieldModel,
442
+ FilterFormRecordSelectFieldModel,
443
+ });
444
+
445
+ const ds = engine.dataSourceManager.getDataSource('main');
446
+ ds?.addCollection({
447
+ name: 'organizations',
448
+ template: 'tree',
449
+ filterTargetKey: 'id',
450
+ titleField: 'name',
451
+ fields: [
452
+ { name: 'id', type: 'integer', interface: 'number', filterable: { operators: [] } },
453
+ { name: 'name', type: 'string', interface: 'input', filterable: { operators: [] } },
454
+ ],
455
+ });
456
+ ds?.addCollection({
457
+ name: 'departments',
458
+ filterTargetKey: 'id',
459
+ fields: [
460
+ { name: 'id', type: 'integer', interface: 'number', filterable: { operators: [] } },
461
+ { name: 'name', type: 'string', interface: 'input', filterable: { operators: [] } },
462
+ ],
463
+ });
464
+ ds?.addCollection({
465
+ name: 'users',
466
+ filterTargetKey: 'id',
467
+ fields: [
468
+ {
469
+ name: 'organization',
470
+ title: 'Organization',
471
+ type: 'belongsTo',
472
+ interface: 'm2o',
473
+ target: 'organizations',
474
+ filterable: { operators: [] },
475
+ },
476
+ {
477
+ name: 'department',
478
+ title: 'Department',
479
+ type: 'belongsTo',
480
+ interface: 'm2o',
481
+ target: 'departments',
482
+ filterable: { operators: [] },
483
+ },
484
+ {
485
+ name: 'organizations',
486
+ title: 'Organizations',
487
+ type: 'belongsToMany',
488
+ interface: 'm2m',
489
+ target: 'organizations',
490
+ filterable: { operators: [] },
491
+ },
492
+ ],
493
+ });
494
+
495
+ const model = engine.createModel<DummyCollectionBlockModel>({
496
+ uid: 'users-tree-association-block',
497
+ use: 'DummyCollectionBlockModel',
498
+ stepParams: {
499
+ resourceSettings: {
500
+ init: {
501
+ dataSourceKey: 'main',
502
+ collectionName: 'users',
503
+ },
504
+ },
505
+ },
506
+ });
507
+
508
+ const children = (await FilterFormItemModel.defineChildren({
509
+ blockGridModel: {
510
+ filterSubModels: (_key: string, predicate: (item: any) => boolean) => [model].filter(predicate),
511
+ },
512
+ t: (value: string) => value,
513
+ } as any)) as any[];
514
+ const groups = await children[0].children();
515
+ const fieldsGroup = groups.find((group: any) => group.key === 'fields');
516
+ const organizationItem = fieldsGroup?.children?.find((item: any) => item.key === 'organization');
517
+ const departmentItem = fieldsGroup?.children?.find((item: any) => item.key === 'department');
518
+ const organizationsItem = fieldsGroup?.children?.find((item: any) => item.key === 'organizations');
519
+
520
+ const organizationCreateOptions = await organizationItem.createModelOptions();
521
+ const departmentCreateOptions = await departmentItem.createModelOptions();
522
+ const organizationsCreateOptions = await organizationsItem.createModelOptions();
523
+
524
+ expect(organizationCreateOptions.subModels.field.use).toBe('CascadeSelectFieldModel');
525
+ expect(organizationCreateOptions.subModels.field.props).toMatchObject({
526
+ fieldNames: { label: 'name', value: 'id' },
527
+ });
528
+ expect(departmentCreateOptions.subModels.field.use).toBe('FilterFormRecordSelectFieldModel');
529
+ expect(organizationsCreateOptions.subModels.field.use).toBe('FilterFormRecordSelectFieldModel');
530
+
531
+ const filterItem = engine.createModel<FilterFormItemModel>({
532
+ uid: 'filter-item-tree-organization',
533
+ ...organizationCreateOptions,
534
+ });
535
+ const modelStep = filterItem.getFlow('filterFormItemSettings')?.steps?.model as any;
536
+ const settingsContext = {
537
+ engine,
538
+ collectionField: ds?.getCollection('users')?.getField('organization'),
539
+ t: (value: string) => engine.translate(value),
540
+ };
541
+ const options = modelStep.uiMode(settingsContext).props.options;
542
+
543
+ expect(modelStep.hideInSettings(settingsContext)).not.toBe(true);
544
+ expect(options).toEqual(
545
+ expect.arrayContaining([
546
+ { label: 'Dropdown select', value: 'FilterFormRecordSelectFieldModel' },
547
+ { label: 'Cascader', value: 'CascadeSelectFieldModel' },
548
+ ]),
549
+ );
550
+ });
551
+
194
552
  it('provides fallback field metadata for sql fields without collection context', async () => {
195
553
  const engine = new FlowEngine();
196
554
  engine.registerModels({
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { describe, expect, it, vi } from 'vitest';
11
11
  import '../../../../index';
12
+ import { FlowEngine } from '@nocobase/flow-engine';
13
+ import { CascadeSelectFieldModel } from '../../../fields/AssociationFieldModel/CascadeSelectFieldModel';
12
14
  import { FilterFormItemModel } from '../FilterFormItemModel';
13
15
 
14
16
  describe('FilterFormItemModel getFilterValue', () => {
@@ -174,4 +176,74 @@ describe('FilterFormItemModel getFilterValue', () => {
174
176
  const value = FilterFormItemModel.prototype.getFilterValue.call(model as any);
175
177
  expect(value).toBe(25);
176
178
  });
179
+
180
+ it('uses the tree Cascader leaf target key with the equality operator', () => {
181
+ const engine = new FlowEngine();
182
+ engine.registerModels({ CascadeSelectFieldModel });
183
+
184
+ const ds = engine.dataSourceManager.getDataSource('main');
185
+ ds?.addCollection({
186
+ name: 'organizations',
187
+ template: 'tree',
188
+ filterTargetKey: 'code',
189
+ fields: [
190
+ { name: 'code', type: 'string', interface: 'input', filterable: { operators: [] } },
191
+ { name: 'name', type: 'string', interface: 'input', filterable: { operators: [] } },
192
+ ],
193
+ });
194
+ ds?.addCollection({
195
+ name: 'users',
196
+ fields: [
197
+ {
198
+ name: 'organization',
199
+ type: 'belongsTo',
200
+ interface: 'm2o',
201
+ target: 'organizations',
202
+ targetKey: 'code',
203
+ filterable: { operators: [] },
204
+ },
205
+ ],
206
+ });
207
+
208
+ const fieldModel = engine.createModel<CascadeSelectFieldModel>({
209
+ uid: 'tree-cascader-filter-field',
210
+ use: 'CascadeSelectFieldModel',
211
+ stepParams: {
212
+ fieldSettings: {
213
+ init: {
214
+ dataSourceKey: 'main',
215
+ collectionName: 'users',
216
+ fieldPath: 'organization',
217
+ },
218
+ },
219
+ },
220
+ props: {
221
+ fieldNames: { label: 'name', value: 'code' },
222
+ value: {
223
+ code: 'leaf',
224
+ name: 'Leaf organization',
225
+ parent: { code: 'root', name: 'Root organization', parent: null },
226
+ },
227
+ },
228
+ });
229
+ const model = {
230
+ mounted: true,
231
+ props: { name: 'organization' },
232
+ subModels: { field: fieldModel },
233
+ context: {
234
+ form: {
235
+ getFieldValue: vi.fn(() => fieldModel.props.value),
236
+ },
237
+ },
238
+ getDefaultValue: vi.fn(() => undefined),
239
+ getStepParams: vi.fn(() => undefined),
240
+ getCurrentOperatorMeta: vi.fn(() => null),
241
+ normalizeAssociationFilterValue: FilterFormItemModel.prototype.normalizeAssociationFilterValue,
242
+ };
243
+
244
+ const value = FilterFormItemModel.prototype.getFilterValue.call(model as unknown as FilterFormItemModel);
245
+
246
+ expect(fieldModel.operator).toBe('$eq');
247
+ expect(value).toBe('leaf');
248
+ });
177
249
  });
@@ -0,0 +1,90 @@
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 { define, observable } from '@formily/reactive';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { FilterFormSubmitActionModel } from '../FilterFormSubmitActionModel';
13
+
14
+ describe('FilterFormSubmitActionModel lifecycle', () => {
15
+ const createAction = (blockModel: { autoTriggerFilter: boolean }, hidden = false, lifecycleAction?: object) => {
16
+ const action = Object.create(FilterFormSubmitActionModel.prototype) as FilterFormSubmitActionModel;
17
+
18
+ Object.defineProperty(action, 'props', {
19
+ value: {},
20
+ });
21
+ Object.defineProperty(action, 'context', {
22
+ value: { blockModel, model: lifecycleAction || action, defineProperty: () => undefined },
23
+ });
24
+ action.hidden = hidden;
25
+ define(action, {
26
+ hidden: observable,
27
+ });
28
+ action.onInit({});
29
+
30
+ return action;
31
+ };
32
+
33
+ it('keeps automatic filtering disabled when the submit action remounts', () => {
34
+ const blockModel = { autoTriggerFilter: true };
35
+ const action = createAction(blockModel);
36
+
37
+ action.onMount();
38
+ expect(blockModel.autoTriggerFilter).toBe(false);
39
+
40
+ action.onUnmount();
41
+ expect(blockModel.autoTriggerFilter).toBe(true);
42
+
43
+ action.onMount();
44
+ expect(blockModel.autoTriggerFilter).toBe(false);
45
+ });
46
+
47
+ it('keeps automatic filtering disabled until every mounted submit action unmounts', () => {
48
+ const blockModel = { autoTriggerFilter: true };
49
+ const firstAction = createAction(blockModel);
50
+ const secondAction = createAction(blockModel);
51
+
52
+ firstAction.onMount();
53
+ secondAction.onMount();
54
+ firstAction.onUnmount();
55
+ expect(blockModel.autoTriggerFilter).toBe(false);
56
+
57
+ secondAction.onUnmount();
58
+ expect(blockModel.autoTriggerFilter).toBe(true);
59
+ });
60
+
61
+ it('only disables automatic filtering while a visible submit action is mounted', () => {
62
+ const blockModel = { autoTriggerFilter: true };
63
+ const action = createAction(blockModel, true);
64
+
65
+ action.onMount();
66
+ expect(blockModel.autoTriggerFilter).toBe(true);
67
+
68
+ action.hidden = false;
69
+ expect(blockModel.autoTriggerFilter).toBe(false);
70
+
71
+ action.hidden = true;
72
+ expect(blockModel.autoTriggerFilter).toBe(true);
73
+ });
74
+
75
+ it('tracks fork lifecycles independently when submit actions share the same uid', () => {
76
+ const blockModel = { autoTriggerFilter: true };
77
+ const firstFork = {};
78
+ const secondFork = {};
79
+ const firstAction = createAction(blockModel, false, firstFork);
80
+ const secondAction = createAction(blockModel, false, secondFork);
81
+
82
+ firstAction.onMount();
83
+ secondAction.onMount();
84
+ firstAction.onUnmount();
85
+ expect(blockModel.autoTriggerFilter).toBe(false);
86
+
87
+ secondAction.onUnmount();
88
+ expect(blockModel.autoTriggerFilter).toBe(true);
89
+ });
90
+ });
@@ -17,7 +17,15 @@ import { FilterFormBlockModel } from '../FilterFormBlockModel';
17
17
  function resolveTemplateValue(raw: any, values: Record<string, any>): any {
18
18
  if (typeof raw === 'string') {
19
19
  const matched = raw.match(/^\{\{\s*ctx\.formValues\.([^}]+?)\s*\}\}$/);
20
- return matched ? values[matched[1]] : raw;
20
+ if (matched) {
21
+ return values[matched[1]];
22
+ }
23
+
24
+ if (/^\{\{\s*ctx\.date\.relative\.past\.day\.n7\s*\}\}$/.test(raw)) {
25
+ return '2026-06-15';
26
+ }
27
+
28
+ return raw;
21
29
  }
22
30
  if (Array.isArray(raw)) {
23
31
  return raw.map((item) => resolveTemplateValue(item, values));
@@ -30,7 +38,7 @@ function resolveTemplateValue(raw: any, values: Record<string, any>): any {
30
38
 
31
39
  function createFilterFormDefaultValuesModel(rules: any[], initialValues: Record<string, any> = {}) {
32
40
  const values = { ...initialValues };
33
- const createItem = (fieldPath: string, uid: string) => ({
41
+ const createItem = (fieldPath: string, uid: string, operator?: string) => ({
34
42
  uid,
35
43
  fieldPath,
36
44
  props: { name: `${fieldPath}_${uid}` },
@@ -44,7 +52,7 @@ function createFilterFormDefaultValuesModel(rules: any[], initialValues: Record<
44
52
  return undefined;
45
53
  },
46
54
  subModels: {
47
- field: {},
55
+ field: operator ? { operator } : {},
48
56
  },
49
57
  });
50
58
  const model = {
@@ -76,7 +84,11 @@ function createFilterFormDefaultValuesModel(rules: any[], initialValues: Record<
76
84
  subModels: {
77
85
  grid: {
78
86
  subModels: {
79
- items: [createItem('nickname', 'nick'), createItem('username', 'user')],
87
+ items: [
88
+ createItem('nickname', 'nick'),
89
+ createItem('username', 'user'),
90
+ createItem('birthdate_tz', 'birthdate', '$dateOn'),
91
+ ],
80
92
  },
81
93
  },
82
94
  },
@@ -354,6 +366,22 @@ describe('filter-form defaultValues wiring', () => {
354
366
  expect(values.username_user).toBe('Bob');
355
367
  });
356
368
 
369
+ it('preserves relative date descriptors for date filter default values', async () => {
370
+ const { model, values } = createFilterFormDefaultValuesModel([
371
+ {
372
+ key: 'birthdate-default',
373
+ enable: true,
374
+ targetPath: 'birthdate_tz',
375
+ mode: 'assign',
376
+ value: '{{ ctx.date.relative.past.day.n7 }}',
377
+ },
378
+ ]);
379
+
380
+ await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any);
381
+
382
+ expect(values.birthdate_tz_birthdate).toEqual({ type: 'past', unit: 'day', number: 7 });
383
+ });
384
+
357
385
  it('applies override values until the target filter field is changed by user', async () => {
358
386
  const { model, values } = createFilterFormDefaultValuesModel(
359
387
  [
@@ -569,6 +569,9 @@ export class FilterManager {
569
569
 
570
570
  const blockModel = targetModel as CollectionBlockModel;
571
571
  const resource = (targetModel as any).resource;
572
+ const filterResettableTarget = targetModel as CollectionBlockModel & {
573
+ resetAfterFilterChange?: () => void;
574
+ };
572
575
 
573
576
  // 4.4 检查数据加载模式
574
577
  const loadingMode = blockModel.getDataLoadingMode();
@@ -581,6 +584,7 @@ export class FilterManager {
581
584
  resource.setPage(1);
582
585
  }
583
586
  resource.loading = false;
587
+ filterResettableTarget.resetAfterFilterChange?.();
584
588
  return;
585
589
  }
586
590
 
@@ -590,6 +594,7 @@ export class FilterManager {
590
594
  resource.setPage(1); // 重置到第一页
591
595
  }
592
596
  await resource.refresh();
597
+ filterResettableTarget.resetAfterFilterChange?.();
593
598
  } catch (error) {
594
599
  console.error(`Failed to refresh target model "${targetId}": ${error.message}`);
595
600
  return;