@nocobase/client-v2 2.1.0-alpha.34 → 2.1.0-alpha.36

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 (101) 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/components/FieldAssignRulesEditor.d.ts +1 -0
  10. package/es/flow/components/code-editor/index.d.ts +1 -0
  11. package/es/flow/internal/utils/enumOptionsUtils.d.ts +5 -0
  12. package/es/flow/models/actions/AssociationActionUtils.d.ts +5 -0
  13. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +4 -0
  14. package/es/flow/models/blocks/filter-manager/FilterManager.d.ts +5 -1
  15. package/es/flow/models/blocks/shared/legacyDefaultValueMigrationBase.d.ts +1 -0
  16. package/es/flow/models/blocks/table/TableSelectModel.d.ts +8 -0
  17. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +4 -0
  18. package/es/flow/models/fields/ClickableFieldModel.d.ts +3 -0
  19. package/es/flow/models/fields/DisplayEnumFieldModel.d.ts +6 -0
  20. package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +2 -2
  21. package/es/flow/models/utils/displayValueUtils.d.ts +12 -0
  22. package/es/flow-compat/passwordUtils.d.ts +1 -1
  23. package/es/index.mjs +119 -108
  24. package/es/utils/remotePlugins.d.ts +0 -4
  25. package/lib/index.js +122 -111
  26. package/package.json +9 -5
  27. package/src/BaseApplication.tsx +14 -8
  28. package/src/PluginManager.ts +1 -0
  29. package/src/__tests__/app.test.tsx +28 -1
  30. package/src/__tests__/globalDeps.test.ts +1 -0
  31. package/src/__tests__/remotePlugins.test.ts +29 -18
  32. package/src/components/form/DrawerFormLayout.tsx +103 -0
  33. package/src/components/form/EnvVariableInput.tsx +126 -0
  34. package/src/components/form/FileSizeInput.tsx +105 -0
  35. package/src/components/form/createFormRegistry.ts +60 -0
  36. package/src/components/form/index.tsx +14 -0
  37. package/src/components/index.ts +1 -1
  38. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +92 -13
  39. package/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx +173 -0
  40. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +476 -1
  41. package/src/flow/actions/__tests__/pattern.test.ts +134 -0
  42. package/src/flow/actions/__tests__/titleField.test.ts +45 -0
  43. package/src/flow/actions/filterFormDefaultValues.tsx +30 -9
  44. package/src/flow/actions/formAssignRules.tsx +24 -9
  45. package/src/flow/actions/linkageRules.tsx +240 -258
  46. package/src/flow/actions/pattern.tsx +41 -6
  47. package/src/flow/actions/setTargetDataScope.tsx +32 -3
  48. package/src/flow/actions/titleField.tsx +4 -2
  49. package/src/flow/actions/validation.tsx +1 -1
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -4
  51. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +13 -5
  52. package/src/flow/components/DynamicFlowsIcon.tsx +87 -13
  53. package/src/flow/components/FieldAssignRulesEditor.tsx +2 -0
  54. package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +195 -8
  55. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  56. package/src/flow/components/code-editor/index.tsx +12 -8
  57. package/src/flow/components/filter/LinkageFilterItem.tsx +9 -2
  58. package/src/flow/components/filter/VariableFilterItem.tsx +2 -6
  59. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +71 -0
  60. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -0
  61. package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -1
  62. package/src/flow/internal/utils/enumOptionsUtils.ts +29 -0
  63. package/src/flow/models/actions/AssociateActionModel.tsx +2 -2
  64. package/src/flow/models/actions/AssociationActionUtils.ts +14 -0
  65. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +63 -0
  66. package/src/flow/models/base/CollectionBlockModel.tsx +7 -0
  67. package/src/flow/models/base/PageModel/RootPageModel.tsx +1 -1
  68. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +33 -9
  69. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +53 -13
  70. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +63 -3
  71. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +33 -1
  72. package/src/flow/models/blocks/filter-form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  73. package/src/flow/models/blocks/filter-manager/FilterManager.ts +66 -2
  74. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +270 -0
  75. package/src/flow/models/blocks/form/FormActionModel.tsx +2 -8
  76. package/src/flow/models/blocks/form/FormBlockModel.tsx +8 -5
  77. package/src/flow/models/blocks/form/FormItemModel.tsx +1 -1
  78. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +30 -0
  79. package/src/flow/models/blocks/form/__tests__/legacyDefaultValueMigration.test.ts +3 -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/shared/legacyDefaultValueMigrationBase.ts +21 -5
  83. package/src/flow/models/blocks/table/TableBlockModel.tsx +11 -6
  84. package/src/flow/models/blocks/table/TableColumnModel.tsx +8 -2
  85. package/src/flow/models/blocks/table/TableSelectModel.tsx +36 -26
  86. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowClick.test.ts +69 -0
  87. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +132 -1
  88. package/src/flow/models/blocks/table/__tests__/TableSelectModel.test.ts +41 -0
  89. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +144 -3
  90. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +170 -1
  91. package/src/flow/models/fields/ClickableFieldModel.tsx +55 -6
  92. package/src/flow/models/fields/DisplayEnumFieldModel.tsx +44 -0
  93. package/src/flow/models/fields/DisplayTitleFieldModel.tsx +40 -7
  94. package/src/flow/models/fields/SelectFieldModel.tsx +31 -1
  95. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +202 -1
  96. package/src/flow/models/fields/__tests__/DisplayEnumFieldModel.test.tsx +39 -0
  97. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +2 -1
  98. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +7 -0
  99. package/src/flow/models/utils/displayValueUtils.ts +57 -0
  100. package/src/utils/globalDeps.ts +11 -0
  101. package/src/utils/remotePlugins.ts +7 -27
@@ -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
  });
@@ -18,6 +18,7 @@ function createMockFieldModel(options: { uid: string; props?: Record<string, any
18
18
  const model: any = {
19
19
  uid: options.uid,
20
20
  props: { ...(options.props || {}) },
21
+ _options: { props: { ...(options.props || {}) } },
21
22
  stepParams: { ...(options.stepParams || {}) },
22
23
  emitter: { emit: vi.fn() },
23
24
  setProps(patch: any) {
@@ -97,7 +98,9 @@ describe('filter-form legacyDefaultValueMigration', () => {
97
98
  clearLegacyDefaultValuesFromFilterFormModel(filterFormModel);
98
99
 
99
100
  expect(field1.props.initialValue).toBeUndefined();
101
+ expect(field1._options.props.initialValue).toBeUndefined();
100
102
  expect(field1.props.keep).toBe(true);
103
+ expect(field1._options.props.keep).toBe(true);
101
104
  expect(field1.stepParams.filterFormItemSettings?.initialValue).toBeUndefined();
102
105
  expect(field1.stepParams.otherFlow?.s?.x).toBe(1);
103
106
 
@@ -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
  }
@@ -243,6 +243,125 @@ describe('FilterManager', () => {
243
243
  });
244
244
  });
245
245
 
246
+ describe('prepareFiltersForTarget', () => {
247
+ it('should prepare filter form initial values before target refresh', async () => {
248
+ (filterManager as any).filterConfigs = [
249
+ {
250
+ filterId: 'filter1',
251
+ targetId: 'target1',
252
+ filterPaths: ['name'],
253
+ },
254
+ ];
255
+ const prepareInitialFilterValues = vi.fn().mockResolvedValue(undefined);
256
+ const filterBlockModel = { prepareInitialFilterValues };
257
+ const filterModel = {
258
+ context: {
259
+ blockModel: filterBlockModel,
260
+ },
261
+ };
262
+
263
+ (mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
264
+ if (uid === 'filter1') return filterModel;
265
+ return null;
266
+ });
267
+
268
+ const prepared = await filterManager.prepareFiltersForTarget('target1');
269
+
270
+ expect(prepareInitialFilterValues).toHaveBeenCalledTimes(1);
271
+ expect(prepared.has(filterBlockModel)).toBe(true);
272
+ });
273
+
274
+ it('should skip marking a filter block when initial values are not ready', async () => {
275
+ (filterManager as any).filterConfigs = [
276
+ {
277
+ filterId: 'filter1',
278
+ targetId: 'target1',
279
+ filterPaths: ['name'],
280
+ },
281
+ ];
282
+ const filterBlockModel = { prepareInitialFilterValues: vi.fn().mockResolvedValue(false) };
283
+ const filterModel = {
284
+ context: {
285
+ blockModel: filterBlockModel,
286
+ },
287
+ };
288
+
289
+ (mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
290
+ if (uid === 'filter1') return filterModel;
291
+ return null;
292
+ });
293
+
294
+ const prepared = await filterManager.prepareFiltersForTarget('target1');
295
+
296
+ expect(prepared.has(filterBlockModel)).toBe(false);
297
+ });
298
+
299
+ it('should continue preparing other filter blocks when one fails', async () => {
300
+ (filterManager as any).filterConfigs = [
301
+ {
302
+ filterId: 'filter1',
303
+ targetId: 'target1',
304
+ filterPaths: ['name'],
305
+ },
306
+ {
307
+ filterId: 'filter2',
308
+ targetId: 'target1',
309
+ filterPaths: ['title'],
310
+ },
311
+ ];
312
+ const failingBlockModel = { prepareInitialFilterValues: vi.fn().mockRejectedValue(new Error('bad default')) };
313
+ const preparedBlockModel = { prepareInitialFilterValues: vi.fn().mockResolvedValue(true) };
314
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
315
+
316
+ (mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
317
+ if (uid === 'filter1') return { context: { blockModel: failingBlockModel } };
318
+ if (uid === 'filter2') return { context: { blockModel: preparedBlockModel } };
319
+ return null;
320
+ });
321
+
322
+ const prepared = await filterManager.prepareFiltersForTarget('target1');
323
+
324
+ expect(prepared.has(failingBlockModel)).toBe(false);
325
+ expect(prepared.has(preparedBlockModel)).toBe(true);
326
+ expect(errorSpy).toHaveBeenCalled();
327
+ errorSpy.mockRestore();
328
+ });
329
+
330
+ it('should keep error isolation when a filter block rejects a non-error value', async () => {
331
+ (filterManager as any).filterConfigs = [
332
+ {
333
+ filterId: 'filter1',
334
+ targetId: 'target1',
335
+ filterPaths: ['name'],
336
+ },
337
+ {
338
+ filterId: 'filter2',
339
+ targetId: 'target1',
340
+ filterPaths: ['title'],
341
+ },
342
+ ];
343
+ const failingBlockModel = { prepareInitialFilterValues: vi.fn().mockRejectedValue('bad default') };
344
+ const preparedBlockModel = { prepareInitialFilterValues: vi.fn().mockResolvedValue(true) };
345
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
346
+
347
+ (mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
348
+ if (uid === 'filter1') return { context: { blockModel: failingBlockModel } };
349
+ if (uid === 'filter2') return { context: { blockModel: preparedBlockModel } };
350
+ return null;
351
+ });
352
+
353
+ try {
354
+ const prepared = await filterManager.prepareFiltersForTarget('target1');
355
+
356
+ expect(prepared.has(failingBlockModel)).toBe(false);
357
+ expect(prepared.has(preparedBlockModel)).toBe(true);
358
+ expect(errorSpy).toHaveBeenCalledWith('Failed to prepare filter defaults for target "target1": bad default');
359
+ } finally {
360
+ errorSpy.mockRestore();
361
+ }
362
+ });
363
+ });
364
+
246
365
  describe('addFilterConfig', () => {
247
366
  it('should add a new filter config successfully', async () => {
248
367
  const filterConfig = {
@@ -496,6 +615,157 @@ describe('FilterManager', () => {
496
615
  expect(mockTargetModel2.resource.refresh).toHaveBeenCalledTimes(1);
497
616
  });
498
617
 
618
+ it('should wait for target resource idle before refreshing', async () => {
619
+ vi.useFakeTimers();
620
+ (filterManager as any).filterConfigs = [
621
+ {
622
+ filterId: 'filter-1',
623
+ targetId: 'target-1',
624
+ filterPaths: ['name'],
625
+ operator: '$eq',
626
+ },
627
+ ];
628
+
629
+ const resource = {
630
+ loading: true,
631
+ addFilterGroup: vi.fn(),
632
+ removeFilterGroup: vi.fn(),
633
+ refresh: vi.fn().mockImplementation(() => {
634
+ expect(resource.loading).toBe(false);
635
+ return Promise.resolve();
636
+ }),
637
+ };
638
+ const mockTargetModel = {
639
+ resource,
640
+ setFilterActive: vi.fn(),
641
+ getDataLoadingMode: vi.fn().mockReturnValue('auto'),
642
+ };
643
+ const mockFilterModel = {
644
+ getFilterValue: vi.fn().mockReturnValue('test-value'),
645
+ };
646
+
647
+ (mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
648
+ if (uid === 'target-1') return mockTargetModel;
649
+ if (uid === 'filter-1') return mockFilterModel;
650
+ return null;
651
+ });
652
+
653
+ const refreshPromise = filterManager.refreshTargetsByFilter('filter-1');
654
+
655
+ try {
656
+ await vi.advanceTimersByTimeAsync(16);
657
+ expect(resource.refresh).not.toHaveBeenCalled();
658
+
659
+ resource.loading = false;
660
+ await vi.advanceTimersByTimeAsync(16);
661
+ await refreshPromise;
662
+
663
+ expect(resource.refresh).toHaveBeenCalledTimes(1);
664
+ } finally {
665
+ vi.useRealTimers();
666
+ }
667
+ });
668
+
669
+ it('should not refresh before a slow target resource becomes idle', async () => {
670
+ vi.useFakeTimers();
671
+ (filterManager as any).filterConfigs = [
672
+ {
673
+ filterId: 'filter-1',
674
+ targetId: 'target-1',
675
+ filterPaths: ['name'],
676
+ operator: '$eq',
677
+ },
678
+ ];
679
+
680
+ const resource = {
681
+ loading: true,
682
+ addFilterGroup: vi.fn(),
683
+ removeFilterGroup: vi.fn(),
684
+ refresh: vi.fn().mockResolvedValue(undefined),
685
+ };
686
+ const mockTargetModel = {
687
+ resource,
688
+ setFilterActive: vi.fn(),
689
+ getDataLoadingMode: vi.fn().mockReturnValue('auto'),
690
+ };
691
+ const mockFilterModel = {
692
+ getFilterValue: vi.fn().mockReturnValue('test-value'),
693
+ };
694
+
695
+ (mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
696
+ if (uid === 'target-1') return mockTargetModel;
697
+ if (uid === 'filter-1') return mockFilterModel;
698
+ return null;
699
+ });
700
+
701
+ const refreshPromise = filterManager.refreshTargetsByFilter('filter-1');
702
+
703
+ try {
704
+ await vi.advanceTimersByTimeAsync(2100);
705
+
706
+ expect(resource.refresh).not.toHaveBeenCalled();
707
+
708
+ resource.loading = false;
709
+ await vi.advanceTimersByTimeAsync(16);
710
+ await refreshPromise;
711
+
712
+ expect(resource.refresh).toHaveBeenCalledTimes(1);
713
+ } finally {
714
+ vi.useRealTimers();
715
+ }
716
+ });
717
+
718
+ it('should skip excluded target ids when refreshing', async () => {
719
+ (filterManager as any).filterConfigs = [
720
+ {
721
+ filterId: 'filter-1',
722
+ targetId: 'target-1',
723
+ filterPaths: ['name'],
724
+ operator: '$eq',
725
+ },
726
+ {
727
+ filterId: 'filter-1',
728
+ targetId: 'target-2',
729
+ filterPaths: ['name'],
730
+ operator: '$eq',
731
+ },
732
+ ];
733
+
734
+ const mockTargetModel1 = {
735
+ resource: {
736
+ addFilterGroup: vi.fn(),
737
+ removeFilterGroup: vi.fn(),
738
+ refresh: vi.fn().mockResolvedValue(undefined),
739
+ },
740
+ setFilterActive: vi.fn(),
741
+ getDataLoadingMode: vi.fn().mockReturnValue('auto'),
742
+ };
743
+ const mockTargetModel2 = {
744
+ resource: {
745
+ addFilterGroup: vi.fn(),
746
+ removeFilterGroup: vi.fn(),
747
+ refresh: vi.fn().mockResolvedValue(undefined),
748
+ },
749
+ setFilterActive: vi.fn(),
750
+ getDataLoadingMode: vi.fn().mockReturnValue('auto'),
751
+ };
752
+ const mockFilterModel = {
753
+ getFilterValue: vi.fn().mockReturnValue('test-value'),
754
+ };
755
+
756
+ (mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
757
+ if (uid === 'target-1') return mockTargetModel1;
758
+ if (uid === 'target-2') return mockTargetModel2;
759
+ if (uid === 'filter-1') return mockFilterModel;
760
+ return null;
761
+ });
762
+
763
+ await filterManager.refreshTargetsByFilter('filter-1', { excludeTargetIds: new Set(['target-1']) });
764
+
765
+ expect(mockTargetModel1.resource.refresh).not.toHaveBeenCalled();
766
+ expect(mockTargetModel2.resource.refresh).toHaveBeenCalledTimes(1);
767
+ });
768
+
499
769
  it('should normalize array operators when the target field is scalar', async () => {
500
770
  const filterConfigs = [
501
771
  {
@@ -94,8 +94,6 @@ FormSubmitActionModel.registerFlow({
94
94
  ctx.model.setProps('loading', true);
95
95
  const { submitHandler } = await import('./submitHandler');
96
96
  await submitHandler(ctx, params);
97
- ctx.message.success(ctx.t('Saved successfully'));
98
- ctx.model.setProps('loading', false);
99
97
  } catch (error) {
100
98
  ctx.model.setProps('loading', false);
101
99
  // 显示保存失败提示
@@ -107,12 +105,8 @@ FormSubmitActionModel.registerFlow({
107
105
  }
108
106
  },
109
107
  },
110
- refreshAndClose: {
111
- async handler(ctx) {
112
- if (ctx.view) {
113
- ctx.view.close();
114
- }
115
- },
108
+ afterSuccess: {
109
+ use: 'afterSuccess',
116
110
  },
117
111
  },
118
112
  });