@nocobase/client-v2 2.1.0-beta.29 → 2.1.0-beta.32

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 (100) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/PluginManager.d.ts +1 -0
  3. package/es/components/form/DrawerFormLayout.d.ts +49 -0
  4. package/es/components/form/EnvVariableInput.d.ts +42 -0
  5. package/es/components/form/FileSizeInput.d.ts +27 -0
  6. package/es/components/form/createFormRegistry.d.ts +33 -0
  7. package/es/components/form/index.d.ts +13 -0
  8. package/es/components/index.d.ts +1 -1
  9. package/es/flow/actions/index.d.ts +1 -1
  10. package/es/flow/actions/linkageRules.d.ts +2 -0
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +4 -0
  12. package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -0
  13. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +5 -0
  14. package/es/flow/components/FieldAssignRulesEditor.d.ts +1 -0
  15. package/es/flow/internal/utils/enumOptionsUtils.d.ts +5 -0
  16. package/es/flow/models/actions/AssociationActionUtils.d.ts +5 -0
  17. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +4 -0
  18. package/es/flow/models/blocks/filter-manager/FilterManager.d.ts +5 -1
  19. package/es/flow/models/blocks/table/TableSelectModel.d.ts +8 -0
  20. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +3 -0
  21. package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +1 -1
  22. package/es/flow/models/utils/displayValueUtils.d.ts +12 -0
  23. package/es/flow-compat/passwordUtils.d.ts +1 -1
  24. package/es/index.mjs +122 -106
  25. package/es/utils/remotePlugins.d.ts +0 -4
  26. package/lib/index.js +121 -105
  27. package/package.json +6 -5
  28. package/src/BaseApplication.tsx +14 -8
  29. package/src/PluginManager.ts +1 -0
  30. package/src/__tests__/app.test.tsx +28 -1
  31. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +67 -46
  32. package/src/__tests__/remotePlugins.test.ts +29 -18
  33. package/src/__tests__/settings-center.test.tsx +30 -0
  34. package/src/components/form/DrawerFormLayout.tsx +103 -0
  35. package/src/components/form/EnvVariableInput.tsx +126 -0
  36. package/src/components/form/FileSizeInput.tsx +105 -0
  37. package/src/components/form/createFormRegistry.ts +60 -0
  38. package/src/components/form/index.tsx +14 -0
  39. package/src/components/index.ts +1 -1
  40. package/src/flow/__tests__/FlowRoute.test.tsx +4 -5
  41. package/src/flow/actions/__tests__/actionLinkageRules.race.repro.test.ts +199 -0
  42. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +92 -13
  43. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +6 -1
  44. package/src/flow/actions/__tests__/linkageRules.menu.test.ts +90 -0
  45. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +476 -1
  46. package/src/flow/actions/index.ts +2 -0
  47. package/src/flow/actions/linkageRules.tsx +316 -280
  48. package/src/flow/actions/linkageRulesFormValueRefresh.ts +2 -8
  49. package/src/flow/actions/setTargetDataScope.tsx +32 -3
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +8 -1
  51. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +70 -12
  52. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +26 -87
  53. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +11 -0
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -1
  55. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +292 -31
  56. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +50 -12
  57. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +77 -56
  58. package/src/flow/components/AdminLayout.tsx +2 -2
  59. package/src/flow/components/FieldAssignRulesEditor.tsx +2 -0
  60. package/src/flow/components/FlowRoute.tsx +17 -4
  61. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  62. package/src/flow/components/filter/LinkageFilterItem.tsx +9 -2
  63. package/src/flow/components/filter/VariableFilterItem.tsx +2 -6
  64. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +71 -0
  65. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -0
  66. package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -1
  67. package/src/flow/internal/utils/enumOptionsUtils.ts +29 -0
  68. package/src/flow/models/actions/AssociateActionModel.tsx +2 -2
  69. package/src/flow/models/actions/AssociationActionUtils.ts +14 -0
  70. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +63 -0
  71. package/src/flow/models/base/CollectionBlockModel.tsx +7 -0
  72. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +33 -9
  73. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +53 -13
  74. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +63 -3
  75. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +33 -1
  76. package/src/flow/models/blocks/filter-manager/FilterManager.ts +66 -2
  77. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +270 -0
  78. package/src/flow/models/blocks/form/FormBlockModel.tsx +8 -5
  79. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +30 -0
  80. package/src/flow/models/blocks/form/value-runtime/rules.ts +6 -1
  81. package/src/flow/models/blocks/form/value-runtime/runtime.ts +6 -1
  82. package/src/flow/models/blocks/table/TableBlockModel.tsx +11 -6
  83. package/src/flow/models/blocks/table/TableColumnModel.tsx +3 -0
  84. package/src/flow/models/blocks/table/TableSelectModel.tsx +36 -26
  85. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowClick.test.ts +69 -0
  86. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +96 -1
  87. package/src/flow/models/blocks/table/__tests__/TableSelectModel.test.ts +41 -0
  88. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -0
  89. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +7 -0
  90. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +4 -1
  91. package/src/flow/models/fields/ClickableFieldModel.tsx +9 -4
  92. package/src/flow/models/fields/DisplayTitleFieldModel.tsx +12 -4
  93. package/src/flow/models/fields/SelectFieldModel.tsx +31 -1
  94. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +23 -0
  95. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +2 -1
  96. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +7 -0
  97. package/src/flow/models/utils/displayValueUtils.ts +57 -0
  98. package/src/flow/system-settings/useSystemSettings.tsx +36 -1
  99. package/src/utils/globalDeps.ts +2 -0
  100. package/src/utils/remotePlugins.ts +7 -27
@@ -46,6 +46,35 @@ export function enumToOptions(uiEnum: UiSchemaEnumItem[] | undefined, t: (s: str
46
46
  return translateOptions(normalized, t);
47
47
  }
48
48
 
49
+ export function normalizeSelectRenderValue(value: unknown, props?: Record<string, any>) {
50
+ const mode = props?.mode;
51
+ const isMultiple = mode === 'multiple' || mode === 'tags' || props?.multiple === true;
52
+
53
+ if (Array.isArray(value)) {
54
+ return value.filter((item) => item !== '' && item !== null && typeof item !== 'undefined');
55
+ }
56
+
57
+ if (value === '' || value === null || typeof value === 'undefined') {
58
+ return isMultiple ? [] : undefined;
59
+ }
60
+
61
+ return value;
62
+ }
63
+
64
+ export function getSelectedEnumLabels(value: any, fallbackOptions: Option[] = []): Array<{ value: any; label: any }> {
65
+ const values = Array.isArray(value) ? value : value == null ? [] : [value];
66
+ return values.map((item) => {
67
+ const fallback = fallbackOptions.find((option) => option?.value === item);
68
+ if (fallback) {
69
+ return { value: item, label: fallback.label };
70
+ }
71
+ return {
72
+ value: item,
73
+ label: item?.toString?.() ?? item,
74
+ };
75
+ });
76
+ }
77
+
49
78
  // Populate model.props.options from uiSchema.enum if missing; returns whether options were injected
50
79
  export function ensureOptionsFromUiSchemaEnumIfAbsent(model: FlowModel, collectionField: CollectionField): boolean {
51
80
  const iface = collectionField?.interface;
@@ -16,6 +16,7 @@ import { SkeletonFallback } from '../../components/SkeletonFallback';
16
16
  import { ActionModel, ActionSceneEnum } from '../base';
17
17
  import {
18
18
  applyAssociateAction,
19
+ getAssociationSelectorContextInputArgs,
19
20
  getAssociationTargetResourceSettings,
20
21
  isAssociationBlockContext,
21
22
  } from './AssociationActionUtils';
@@ -139,7 +140,6 @@ AssociateActionModel.registerFlow({
139
140
  steps: {
140
141
  openSelector: {
141
142
  async handler(ctx, params) {
142
- const blockModel = ctx.blockModel;
143
143
  const targetResourceSettings = getAssociationTargetResourceSettings(ctx);
144
144
  const openMode = ctx.inputArgs?.isMobileLayout ? 'embed' : ctx.inputArgs?.mode || params?.mode || 'drawer';
145
145
  const size = ctx.inputArgs?.size || params?.size || 'medium';
@@ -168,9 +168,9 @@ AssociateActionModel.registerFlow({
168
168
  scene: 'select',
169
169
  dataSourceKey: targetResourceSettings.dataSourceKey,
170
170
  collectionName: targetResourceSettings.collectionName,
171
+ ...getAssociationSelectorContextInputArgs(ctx),
171
172
  rowSelectionProps: {
172
173
  type: 'checkbox',
173
- defaultSelectedRows: () => blockModel?.resource?.getData?.() || [],
174
174
  renderCell: undefined,
175
175
  selectedRowKeys: undefined,
176
176
  onChange: (_, selectedRows) => {
@@ -33,6 +33,20 @@ export const getAssociationTargetResourceSettings = (ctx: FlowModelContext | any
33
33
  };
34
34
  };
35
35
 
36
+ export const getAssociationSelectorContextInputArgs = (ctx: FlowModelContext | any) => {
37
+ const blockModel = ctx?.blockModel || ctx?.model?.context?.blockModel;
38
+ const association = blockModel?.association;
39
+ const resourceSettings = getAssociationBlockResourceSettings(ctx);
40
+ const sourceId = blockModel?.resource?.getSourceId?.() ?? resourceSettings?.sourceId;
41
+ const associatedRecords = blockModel?.resource?.getData?.() || [];
42
+
43
+ return {
44
+ collectionField: association,
45
+ sourceId,
46
+ associatedRecords,
47
+ };
48
+ };
49
+
36
50
  const callAssociationResourceAction = async (resource: any, action: 'add' | 'remove', values: any[]) => {
37
51
  if (typeof resource?.[action] === 'function') {
38
52
  return await resource[action]({ values });
@@ -16,6 +16,7 @@ import {
16
16
  AssociateActionModel,
17
17
  CollectionActionGroupModel,
18
18
  DisassociateActionModel,
19
+ getAssociationSelectorContextInputArgs,
19
20
  getAssociationTargetResourceSettings,
20
21
  PopupActionModel,
21
22
  RecordActionGroupModel,
@@ -87,6 +88,68 @@ describe('association action models', () => {
87
88
  });
88
89
  });
89
90
 
91
+ it('passes one-to-many association context to selector table blocks', () => {
92
+ const association = {
93
+ name: 'orders',
94
+ interface: 'o2m',
95
+ target: 'orders',
96
+ sourceKey: 'id',
97
+ foreignKey: 'userId',
98
+ };
99
+ const ctx: any = {
100
+ blockModel: {
101
+ association,
102
+ resource: {
103
+ getSourceId: () => 362872646860800,
104
+ },
105
+ getResourceSettingsInitParams: () => ({
106
+ dataSourceKey: 'main',
107
+ collectionName: 'orders',
108
+ associationName: 'users.orders',
109
+ sourceId: '{{ctx.popup.record.id}}',
110
+ }),
111
+ },
112
+ };
113
+
114
+ expect(getAssociationSelectorContextInputArgs(ctx)).toEqual({
115
+ collectionField: association,
116
+ sourceId: 362872646860800,
117
+ associatedRecords: [],
118
+ });
119
+ });
120
+
121
+ it('passes current associated records to selector table blocks', () => {
122
+ const associatedRecords = [{ id: 11 }, { id: 12 }];
123
+ const association = {
124
+ name: 'tags',
125
+ interface: 'm2m',
126
+ target: 'tags',
127
+ sourceKey: 'id',
128
+ targetKey: 'id',
129
+ };
130
+ const ctx: any = {
131
+ blockModel: {
132
+ association,
133
+ resource: {
134
+ getSourceId: () => 1,
135
+ getData: () => associatedRecords,
136
+ },
137
+ getResourceSettingsInitParams: () => ({
138
+ dataSourceKey: 'main',
139
+ collectionName: 'tags',
140
+ associationName: 'posts.tags',
141
+ sourceId: '{{ctx.popup.record.id}}',
142
+ }),
143
+ },
144
+ };
145
+
146
+ expect(getAssociationSelectorContextInputArgs(ctx)).toEqual({
147
+ collectionField: association,
148
+ sourceId: 1,
149
+ associatedRecords,
150
+ });
151
+ });
152
+
90
153
  it('shows Associate only in collection actions of association blocks', async () => {
91
154
  const engine = createEngine();
92
155
 
@@ -662,6 +662,9 @@ CollectionBlockModel.registerFlow({
662
662
  async handler(ctx) {
663
663
  const blockModel = ctx.model as CollectionBlockModel;
664
664
  const filterManager: FilterManager = ctx.model.context.filterManager;
665
+ const preparedFilterBlocks = filterManager?.prepareFiltersForTarget
666
+ ? await filterManager.prepareFiltersForTarget(ctx.model.uid)
667
+ : undefined;
665
668
  if (filterManager) {
666
669
  filterManager.bindToTarget(ctx.model.uid);
667
670
  }
@@ -679,6 +682,10 @@ CollectionBlockModel.registerFlow({
679
682
  return;
680
683
  }
681
684
 
685
+ preparedFilterBlocks?.forEach((filterBlockModel: any) => {
686
+ filterBlockModel?.markInitialTargetRefreshHandled?.(ctx.model.uid);
687
+ });
688
+
682
689
  if (ctx.model.isManualRefresh) {
683
690
  ctx.model.resource.loading = false;
684
691
  } else {
@@ -32,7 +32,7 @@ import { BlockSceneEnum } from '../../base/BlockModel';
32
32
  import { FilterBlockModel } from '../../base/FilterBlockModel';
33
33
  import { FormComponent } from '../form/FormBlockModel';
34
34
  import { isEmptyValue } from '../form/value-runtime/utils';
35
- import { FilterManager } from '../filter-manager/FilterManager';
35
+ import { FilterManager, type RefreshTargetsByFilterOptions } from '../filter-manager/FilterManager';
36
36
  import { FilterFormItemModel } from './FilterFormItemModel';
37
37
  import { clearLegacyDefaultValuesFromFilterFormModel } from './legacyDefaultValueMigration';
38
38
  import { findFormItemModelByFieldPath } from '../../../internal/utils/modelUtils';
@@ -54,6 +54,8 @@ export class FilterFormBlockModel extends FilterBlockModel<{
54
54
  autoTriggerFilter = true;
55
55
 
56
56
  private removeTargetBlockListener?: () => void;
57
+ private initialDefaultsPromise?: Promise<void>;
58
+ private initialRefreshHandledTargetIds = new Set<string>();
57
59
 
58
60
  get form() {
59
61
  return this.context.form;
@@ -82,13 +84,13 @@ export class FilterFormBlockModel extends FilterBlockModel<{
82
84
  this.context.defineProperty('blockModel', {
83
85
  value: this,
84
86
  });
85
- this.context.defineMethod('refreshTargets', async () => {
87
+ this.context.defineMethod('refreshTargets', async (options?: RefreshTargetsByFilterOptions) => {
86
88
  const gridModel = this.subModels.grid;
87
89
  const fieldModels: FilterFormItemModel[] = gridModel.subModels.items;
88
- if (fieldModels) {
89
- fieldModels.forEach((fieldModel) => {
90
- fieldModel?.doFilter?.();
91
- });
90
+ const filterIds = fieldModels?.map((fieldModel) => fieldModel?.uid).filter(Boolean);
91
+ if (filterIds?.length) {
92
+ const filterManager: FilterManager = this.context.filterManager;
93
+ await filterManager.refreshTargetsByFilter(filterIds, options);
92
94
  }
93
95
  });
94
96
  }
@@ -124,9 +126,31 @@ export class FilterFormBlockModel extends FilterBlockModel<{
124
126
  }
125
127
 
126
128
  private async applyDefaultsAndInitialFilter() {
127
- await this.ensureFilterItemsBeforeRender();
128
- await this.applyFormDefaultValues();
129
- await this.context.refreshTargets?.();
129
+ const prepared = await this.prepareInitialFilterValues();
130
+ if (prepared) {
131
+ await this.context.refreshTargets?.({ excludeTargetIds: this.initialRefreshHandledTargetIds });
132
+ }
133
+ }
134
+
135
+ async prepareInitialFilterValues() {
136
+ if (!this.form) {
137
+ return false;
138
+ }
139
+
140
+ if (!this.initialDefaultsPromise) {
141
+ this.initialDefaultsPromise = (async () => {
142
+ await this.ensureFilterItemsBeforeRender();
143
+ await this.applyFormDefaultValues();
144
+ })();
145
+ }
146
+
147
+ await this.initialDefaultsPromise;
148
+ return true;
149
+ }
150
+
151
+ markInitialTargetRefreshHandled(targetId: string) {
152
+ if (!targetId) return;
153
+ this.initialRefreshHandledTargetIds.add(targetId);
130
154
  }
131
155
 
132
156
  private async ensureFilterItemsBeforeRender() {
@@ -20,8 +20,9 @@ import _, { cloneDeep, debounce, isEqual } from 'lodash';
20
20
  import React from 'react';
21
21
  import { CollectionBlockModel, FieldModel } from '../../base';
22
22
  import { RecordSelectFieldModel } from '../../fields/AssociationFieldModel/RecordSelectFieldModel';
23
+ import { normalizeAssociationFieldNames } from '../../fields/AssociationFieldModel/recordSelectShared';
23
24
  import { FilterManager } from '../filter-manager';
24
- import { getAllDataModels, getDefaultOperator } from '../filter-manager/utils';
25
+ import { getAllDataModels, getDefaultOperator, isFilterValueEmpty } from '../filter-manager/utils';
25
26
  import { FilterFormFieldModel } from './fields';
26
27
  import { normalizeFilterValueByOperator } from './valueNormalization';
27
28
 
@@ -58,13 +59,11 @@ const normalizeAssociationDefaultFilterValue = (value: any, fieldModel: any) =>
58
59
  return value;
59
60
  }
60
61
 
61
- const fieldNames = fieldModel?.props?.fieldNames || collectionField?.fieldNames || {};
62
- const valueKey =
63
- fieldNames?.value ||
64
- collectionField?.targetKey ||
65
- collectionField?.targetCollection?.filterTargetKey ||
66
- collectionField?.collection?.filterTargetKey ||
67
- 'id';
62
+ const normalizedFieldNames = normalizeAssociationFieldNames(
63
+ fieldModel?.props?.fieldNames || collectionField?.fieldNames,
64
+ collectionField?.targetCollection,
65
+ );
66
+ const valueKey = normalizedFieldNames.value;
68
67
  const pickValue = (item: any) => {
69
68
  const target = item && typeof item === 'object' && typeof item?.data !== 'undefined' ? item.data : item;
70
69
  if (!target || typeof target !== 'object') {
@@ -89,6 +88,30 @@ const normalizeAssociationDefaultFilterValue = (value: any, fieldModel: any) =>
89
88
  return pickValue(value);
90
89
  };
91
90
 
91
+ const hasAssociationRecordValue = (value: any) => {
92
+ const hasRecord = (item: any) => {
93
+ const target = item && typeof item === 'object' && typeof item?.data !== 'undefined' ? item.data : item;
94
+ return !!target && typeof target === 'object';
95
+ };
96
+
97
+ return Array.isArray(value) ? value.some(hasRecord) : hasRecord(value);
98
+ };
99
+
100
+ const isToOneAssociationField = (collectionField: any) => {
101
+ return (
102
+ ['belongsTo', 'hasOne'].includes(collectionField?.type) ||
103
+ ['m2o', 'o2o', 'oho', 'obo', 'updatedBy', 'createdBy'].includes(collectionField?.interface)
104
+ );
105
+ };
106
+
107
+ const isMultipleAssociationField = (fieldModel: any, collectionField: any) => {
108
+ if (fieldModel?.props?.allowMultiple === true || fieldModel?.props?.multiple === true) {
109
+ return true;
110
+ }
111
+
112
+ return !isToOneAssociationField(collectionField);
113
+ };
114
+
92
115
  const buildVirtualFilterCollectionField = (ctx: FlowModelContext, filterField: any) => {
93
116
  if (!filterField) {
94
117
  return;
@@ -480,9 +503,21 @@ export class FilterFormItemModel extends FilterableItemModel<{
480
503
  */
481
504
  getFilterValue() {
482
505
  const fieldModel = this.subModels.field as FieldModel & { getFilterValue?: () => any };
483
- const fieldValue = fieldModel.getFilterValue
484
- ? fieldModel.getFilterValue()
485
- : this.context.form?.getFieldValue(this.props.name);
506
+ const formValue = this.context.form?.getFieldValue(this.props.name);
507
+ const modelValue = fieldModel.getFilterValue ? fieldModel.getFilterValue() : formValue;
508
+ const collectionField = (fieldModel as any)?.context?.collectionField;
509
+ const isAssociationField =
510
+ typeof collectionField?.isAssociationField === 'function'
511
+ ? collectionField.isAssociationField()
512
+ : !!collectionField?.target;
513
+ const shouldUseFormValue =
514
+ isAssociationField &&
515
+ !this.mounted &&
516
+ !isFilterValueEmpty(formValue) &&
517
+ hasAssociationRecordValue(formValue) &&
518
+ !hasAssociationRecordValue(modelValue);
519
+ const shouldFallbackToFormValue = isFilterValueEmpty(modelValue) && !isFilterValueEmpty(formValue);
520
+ const fieldValue = shouldUseFormValue || shouldFallbackToFormValue ? formValue : modelValue;
486
521
 
487
522
  let rawValue = fieldValue;
488
523
 
@@ -517,10 +552,15 @@ export class FilterFormItemModel extends FilterableItemModel<{
517
552
  if (!isAssociation) {
518
553
  return value;
519
554
  }
520
- const valueKey = collectionField?.targetKey || collectionField?.targetCollection?.filterTargetKey || 'id';
555
+ const normalizedFieldNames = normalizeAssociationFieldNames(
556
+ (fieldModel as any)?.props?.fieldNames || collectionField?.fieldNames,
557
+ collectionField?.targetCollection,
558
+ );
559
+ const valueKey = normalizedFieldNames.value;
521
560
  if (Array.isArray(value)) {
522
561
  if (value.length === 0) return value;
523
- return value.map((item) => (item && typeof item === 'object' ? item[valueKey] : item));
562
+ const normalizedValue = value.map((item) => (item && typeof item === 'object' ? item[valueKey] : item));
563
+ return isMultipleAssociationField(fieldModel, collectionField) ? normalizedValue : normalizedValue[0];
524
564
  }
525
565
  if (typeof value === 'object') {
526
566
  return (value as any)?.[valueKey];
@@ -18,19 +18,21 @@ describe('FilterFormItemModel getFilterValue', () => {
18
18
  association = true,
19
19
  fieldNames = { label: 'nickname', value: 'id' },
20
20
  collectionField = {},
21
+ fieldProps = {},
21
22
  }: {
22
23
  defaultValue: any;
23
24
  fieldValue?: any;
24
25
  association?: boolean;
25
26
  fieldNames?: { label: string; value: string };
26
27
  collectionField?: Record<string, any>;
28
+ fieldProps?: Record<string, any>;
27
29
  }) => {
28
30
  return {
29
31
  mounted: false,
30
32
  props: { name: 'ownerId' },
31
33
  subModels: {
32
34
  field: {
33
- props: { fieldNames },
35
+ props: { fieldNames, ...fieldProps },
34
36
  context: {
35
37
  collectionField: {
36
38
  isAssociationField: () => association,
@@ -99,13 +101,71 @@ describe('FilterFormItemModel getFilterValue', () => {
99
101
  expect(value).toBe(23);
100
102
  });
101
103
 
104
+ it('uses current form value when association field props are not synchronized yet', () => {
105
+ const model = createModelMock({
106
+ defaultValue: undefined,
107
+ fieldValue: [3],
108
+ fieldNames: { label: 'nickname', value: 'uuid' },
109
+ collectionField: { type: 'belongsTo', interface: 'm2o' },
110
+ });
111
+ model.context.form.getFieldValue.mockReturnValue([{ id: 3, uuid: 'org-3', nickname: 'Org 3' }]);
112
+
113
+ const value = FilterFormItemModel.prototype.getFilterValue.call(model as any);
114
+ expect(value).toBe('org-3');
115
+ });
116
+
117
+ it('keeps to-one association arrays when allowMultiple is enabled', () => {
118
+ const model = createModelMock({
119
+ defaultValue: undefined,
120
+ fieldValue: [
121
+ { id: 5, nickname: 'Org 5' },
122
+ { id: 6, nickname: 'Org 6' },
123
+ ],
124
+ collectionField: { type: 'belongsTo', interface: 'm2o' },
125
+ fieldProps: { allowMultiple: true, multiple: true },
126
+ });
127
+
128
+ const value = FilterFormItemModel.prototype.getFilterValue.call(model as any);
129
+ expect(value).toEqual([5, 6]);
130
+ });
131
+
132
+ it('keeps association arrays for to-many fields', () => {
133
+ const model = createModelMock({
134
+ defaultValue: [
135
+ { id: 3, uuid: 'org-3', nickname: 'Org 3' },
136
+ { id: 4, uuid: 'org-4', nickname: 'Org 4' },
137
+ ],
138
+ fieldValue: undefined,
139
+ fieldNames: { label: 'nickname', value: 'uuid' },
140
+ collectionField: { type: 'belongsToMany', interface: 'm2m' },
141
+ });
142
+
143
+ const value = FilterFormItemModel.prototype.getFilterValue.call(model as any);
144
+ expect(value).toEqual(['org-3', 'org-4']);
145
+ });
146
+
147
+ it('uses target collection filter target key for association default records', () => {
148
+ const model = createModelMock({
149
+ defaultValue: { id: 3, uuid: 'org-3', nickname: 'Org 3' },
150
+ fieldValue: undefined,
151
+ fieldNames: null as any,
152
+ collectionField: {
153
+ targetKey: 'id',
154
+ targetCollection: { filterTargetKey: 'uuid' },
155
+ },
156
+ });
157
+
158
+ const value = FilterFormItemModel.prototype.getFilterValue.call(model as any);
159
+ expect(value).toBe('org-3');
160
+ });
161
+
102
162
  it('falls back to collection target key when fieldNames value is missing', () => {
103
163
  const model = createModelMock({
104
164
  defaultValue: { id: 25, nickname: 'User 25' },
105
165
  fieldValue: undefined,
106
- fieldNames: undefined as any,
166
+ fieldNames: null as any,
107
167
  collectionField: {
108
- fieldNames: undefined,
168
+ fieldNames: null,
109
169
  targetKey: 'id',
110
170
  targetCollection: { filterTargetKey: 'id' },
111
171
  },
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { describe, expect, it } from 'vitest';
10
+ import { describe, expect, it, vi } from 'vitest';
11
11
  import { filterFormDefaultValues } from '../../../../actions/filterFormDefaultValues';
12
12
  import { FilterFormBlockModel } from '../FilterFormBlockModel';
13
13
 
@@ -16,4 +16,36 @@ describe('filter-form defaultValues wiring', () => {
16
16
  expect(filterFormDefaultValues).toBeTruthy();
17
17
  expect(FilterFormBlockModel).toBeTruthy();
18
18
  });
19
+
20
+ it('only excludes targets already handled by their own initial refresh', async () => {
21
+ const refreshTargets = vi.fn().mockResolvedValue(undefined);
22
+ const model = {
23
+ initialRefreshHandledTargetIds: new Set<string>(),
24
+ form: {},
25
+ context: {
26
+ refreshTargets,
27
+ },
28
+ prepareInitialFilterValues: vi.fn().mockResolvedValue(true),
29
+ };
30
+
31
+ FilterFormBlockModel.prototype.markInitialTargetRefreshHandled.call(model as any, 'target-1');
32
+ await (FilterFormBlockModel.prototype as any).applyDefaultsAndInitialFilter.call(model);
33
+
34
+ expect(refreshTargets).toHaveBeenCalledWith({ excludeTargetIds: new Set(['target-1']) });
35
+ });
36
+
37
+ it('does not cache initial defaults when form is not ready', async () => {
38
+ const model = {
39
+ form: undefined,
40
+ initialDefaultsPromise: undefined,
41
+ ensureFilterItemsBeforeRender: vi.fn().mockResolvedValue(undefined),
42
+ applyFormDefaultValues: vi.fn().mockResolvedValue(undefined),
43
+ };
44
+
45
+ const prepared = await FilterFormBlockModel.prototype.prepareInitialFilterValues.call(model as any);
46
+
47
+ expect(prepared).toBe(false);
48
+ expect(model.initialDefaultsPromise).toBeUndefined();
49
+ expect(model.applyFormDefaultValues).not.toHaveBeenCalled();
50
+ });
19
51
  });
@@ -24,6 +24,10 @@ type FilterConfig = {
24
24
  operator?: string;
25
25
  };
26
26
 
27
+ export type RefreshTargetsByFilterOptions = {
28
+ excludeTargetIds?: Set<string> | string[];
29
+ };
30
+
27
31
  const ARRAY_FIELD_OPERATORS_TO_SCALAR_OPERATORS: Record<string, string> = {
28
32
  $match: '$in',
29
33
  $anyOf: '$in',
@@ -78,6 +82,36 @@ function normalizeOperatorForTargetField(operator: string, targetModel: any, fie
78
82
  return scalarOperator;
79
83
  }
80
84
 
85
+ async function waitForResourceIdle(resource: any) {
86
+ if (!resource?.loading) {
87
+ return;
88
+ }
89
+
90
+ await new Promise<void>((resolve) => {
91
+ const check = () => {
92
+ if (!resource.loading) {
93
+ resolve();
94
+ return;
95
+ }
96
+ setTimeout(check, 16);
97
+ };
98
+
99
+ check();
100
+ });
101
+ }
102
+
103
+ function getErrorMessage(error: unknown) {
104
+ if (error instanceof Error) {
105
+ return error.message;
106
+ }
107
+
108
+ try {
109
+ return String(error);
110
+ } catch {
111
+ return 'Unknown error';
112
+ }
113
+ }
114
+
81
115
  export class FilterManager {
82
116
  private filterConfigs: FilterConfig[];
83
117
  private readonly gridModel: FlowModel;
@@ -111,6 +145,32 @@ export class FilterManager {
111
145
  };
112
146
  }
113
147
 
148
+ async prepareFiltersForTarget(targetId: string) {
149
+ const relatedConfigs = this.filterConfigs.filter((config) => config.targetId === targetId);
150
+ const preparedBlockModels = new Set<any>();
151
+
152
+ await Promise.all(
153
+ relatedConfigs.map(async (config) => {
154
+ try {
155
+ const filterModel: any = this.gridModel.flowEngine.getModel(config.filterId);
156
+ const blockModel = filterModel?.context?.blockModel;
157
+ if (!blockModel || typeof blockModel.prepareInitialFilterValues !== 'function') {
158
+ return;
159
+ }
160
+
161
+ const prepared = await blockModel.prepareInitialFilterValues();
162
+ if (prepared !== false) {
163
+ preparedBlockModels.add(blockModel);
164
+ }
165
+ } catch (error) {
166
+ console.error(`Failed to prepare filter defaults for target "${targetId}": ${getErrorMessage(error)}`);
167
+ }
168
+ }),
169
+ );
170
+
171
+ return preparedBlockModels;
172
+ }
173
+
114
174
  async saveConnectFieldsConfig(filterModelUid: string, config: ConnectFieldsConfig) {
115
175
  // 1. 把参数 FilterModelUid 和 config 转换成一个 filterConfigs
116
176
  const newFilterConfigs: FilterConfig[] = config.targets.map((target) => ({
@@ -459,7 +519,7 @@ export class FilterManager {
459
519
  * await filterManager.refreshTargetsByFilter(['filter-1', 'filter-2']);
460
520
  * ```
461
521
  */
462
- async refreshTargetsByFilter(filterId: string | string[]): Promise<void> {
522
+ async refreshTargetsByFilter(filterId: string | string[], options?: RefreshTargetsByFilterOptions): Promise<void> {
463
523
  // 1. 参数验证和标准化
464
524
  if (!filterId) {
465
525
  console.error('filterId must be provided');
@@ -482,7 +542,10 @@ export class FilterManager {
482
542
  }
483
543
 
484
544
  // 3. 提取所有相关的 targetId 并去重
485
- const targetIds = [...new Set(relatedConfigs.map((config) => config.targetId))];
545
+ const excludeTargetIds = options?.excludeTargetIds ? new Set(options.excludeTargetIds) : undefined;
546
+ const targetIds = [...new Set(relatedConfigs.map((config) => config.targetId))].filter(
547
+ (targetId) => !excludeTargetIds?.has(targetId),
548
+ );
486
549
 
487
550
  // 4. 并行处理所有目标模型
488
551
  const refreshPromises = targetIds.map(async (targetId) => {
@@ -522,6 +585,7 @@ export class FilterManager {
522
585
  }
523
586
 
524
587
  // 4.5 调用 refresh 方法
588
+ await waitForResourceIdle(resource);
525
589
  if (typeof resource.setPage === 'function') {
526
590
  resource.setPage(1); // 重置到第一页
527
591
  }