@nocobase/client-v2 2.2.0-beta.17 → 2.2.0-beta.18

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 (69) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  3. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  4. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  5. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  6. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  7. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  8. package/es/flow/models/blocks/form/FormBlockModel.d.ts +1 -0
  9. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  10. package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
  11. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  12. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  13. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  14. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  15. package/es/flow-compat/passwordUtils.d.ts +1 -1
  16. package/es/index.mjs +125 -116
  17. package/lib/index.js +162 -153
  18. package/package.json +7 -7
  19. package/src/APIClient.ts +92 -0
  20. package/src/Application.tsx +3 -1
  21. package/src/__tests__/APIClient.test.tsx +58 -0
  22. package/src/__tests__/settings-center.test.tsx +270 -6
  23. package/src/acl/ACLProvider.tsx +2 -0
  24. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  25. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  26. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  27. package/src/components/form/table/Table.tsx +81 -6
  28. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  29. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  30. package/src/flow/actions/linkageRules.tsx +8 -3
  31. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  32. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  33. package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
  34. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  35. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  36. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  37. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  38. package/src/flow/models/base/CollectionBlockModel.tsx +23 -1
  39. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +65 -9
  40. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  41. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  42. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  43. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  44. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  45. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  46. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  47. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  48. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  49. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  50. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  51. package/src/flow/models/blocks/form/FormBlockModel.tsx +43 -0
  52. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  53. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
  54. package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
  55. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  56. package/src/flow/models/blocks/table/utils.ts +7 -0
  57. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  58. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  59. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  60. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +121 -3
  61. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  62. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  63. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  64. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  65. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  66. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -0
  67. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  68. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +122 -14
  69. package/src/settings-center/AdminSettingsLayout.tsx +23 -2
@@ -0,0 +1,131 @@
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 { FlowEngine } from '@nocobase/flow-engine';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { CreateFormModel } from '../CreateFormModel';
13
+ import { EditFormModel } from '../EditFormModel';
14
+ import { FormActionModel } from '../FormActionModel';
15
+
16
+ function prepareFormModel<T extends CreateFormModel | EditFormModel>(model: T, publicFormRuntime = false) {
17
+ const engine = new FlowEngine();
18
+ engine.registerModels({ CreateFormModel, EditFormModel });
19
+ model.flowEngine = engine;
20
+ Object.defineProperty(model, 'context', {
21
+ value: {
22
+ publicFormRuntime,
23
+ message: { error: vi.fn() },
24
+ t: (value: string) => value,
25
+ },
26
+ });
27
+ return model;
28
+ }
29
+
30
+ function createSubmitAction() {
31
+ const onClick = vi.fn();
32
+ const action = {
33
+ getFlow: vi.fn((key: string) => (key === 'submitSettings' ? {} : undefined)),
34
+ onClick,
35
+ } as unknown as FormActionModel;
36
+ return { action, onClick };
37
+ }
38
+
39
+ describe('FormBlockModel.submitFromRunJs', () => {
40
+ it('dispatches the first action with a submitSettings flow', () => {
41
+ const { action, onClick } = createSubmitAction();
42
+ const { action: secondAction, onClick: secondOnClick } = createSubmitAction();
43
+ const blockModel = prepareFormModel(
44
+ Object.assign(Object.create(CreateFormModel.prototype), {
45
+ mapSubModels: (_key: string, callback: (item: FormActionModel) => FormActionModel) => [
46
+ callback(action),
47
+ callback(secondAction),
48
+ ],
49
+ }) as CreateFormModel,
50
+ );
51
+
52
+ blockModel.submitFromRunJs();
53
+
54
+ expect(onClick).toHaveBeenCalledWith(undefined);
55
+ expect(secondOnClick).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it('does not dispatch an edit submit action without an available record', () => {
59
+ const { action, onClick } = createSubmitAction();
60
+ const blockModel = prepareFormModel(
61
+ Object.assign(Object.create(EditFormModel.prototype), {
62
+ hasAvailableData: () => false,
63
+ mapSubModels: (_key: string, callback: (item: FormActionModel) => FormActionModel) => [callback(action)],
64
+ }) as EditFormModel,
65
+ );
66
+
67
+ blockModel.submitFromRunJs();
68
+
69
+ expect(onClick).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('falls back to the core form submit handler when no submit action exists', () => {
73
+ const submit = vi.fn().mockResolvedValue(undefined);
74
+ const blockModel = prepareFormModel(
75
+ Object.assign(Object.create(CreateFormModel.prototype), {
76
+ mapSubModels: vi.fn(() => []),
77
+ submit,
78
+ }) as CreateFormModel,
79
+ );
80
+
81
+ blockModel.submitFromRunJs();
82
+
83
+ expect(submit).toHaveBeenCalledOnce();
84
+ });
85
+
86
+ it('falls back to the core edit submit handler when a record exists', () => {
87
+ const submit = vi.fn().mockResolvedValue(undefined);
88
+ const blockModel = prepareFormModel(
89
+ Object.assign(Object.create(EditFormModel.prototype), {
90
+ hasAvailableData: () => true,
91
+ mapSubModels: vi.fn(() => []),
92
+ submit,
93
+ }) as EditFormModel,
94
+ );
95
+
96
+ blockModel.submitFromRunJs();
97
+
98
+ expect(submit).toHaveBeenCalledOnce();
99
+ });
100
+
101
+ it('uses the core fallback for specialized form subclasses', () => {
102
+ class SpecializedCreateFormModel extends CreateFormModel {}
103
+
104
+ const submit = vi.fn().mockResolvedValue(undefined);
105
+ const blockModel = prepareFormModel(
106
+ Object.assign(Object.create(SpecializedCreateFormModel.prototype), {
107
+ mapSubModels: vi.fn(() => []),
108
+ submit,
109
+ }) as SpecializedCreateFormModel,
110
+ );
111
+
112
+ blockModel.submitFromRunJs();
113
+
114
+ expect(submit).toHaveBeenCalledOnce();
115
+ });
116
+
117
+ it('does not use the core fallback for public forms without a submit action', () => {
118
+ const submit = vi.fn().mockResolvedValue(undefined);
119
+ const blockModel = prepareFormModel(
120
+ Object.assign(Object.create(CreateFormModel.prototype), {
121
+ mapSubModels: vi.fn(() => []),
122
+ submit,
123
+ }) as CreateFormModel,
124
+ true,
125
+ );
126
+
127
+ blockModel.submitFromRunJs();
128
+
129
+ expect(submit).not.toHaveBeenCalled();
130
+ });
131
+ });
@@ -36,7 +36,15 @@ import { BlockSceneEnum } from '../../base/BlockModel';
36
36
  import { CollectionBlockModel } from '../../base/CollectionBlockModel';
37
37
  import { QuickEditFormModel } from '../form/QuickEditFormModel';
38
38
  import { TableColumnModel } from './TableColumnModel';
39
- import { extractIndex, adjustColumnOrder, setNestedValue, extractIds, getRowKey, useBlockHeight } from './utils';
39
+ import {
40
+ extractIndex,
41
+ adjustColumnOrder,
42
+ setNestedValue,
43
+ extractIds,
44
+ extractRowKeys,
45
+ getRowKey,
46
+ useBlockHeight,
47
+ } from './utils';
40
48
  import { resolveTableSorterField } from './sortUtils';
41
49
  import { commonConditionHandler, ConditionBuilder } from '../../../components/ConditionBuilder';
42
50
  import { BulkDeleteActionModel } from '../../actions/BulkDeleteActionModel';
@@ -222,6 +230,22 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
222
230
  return super.resource as MultiRecordResource;
223
231
  }
224
232
 
233
+ resetAfterFilterChange() {
234
+ if (!this.props.treeTable) {
235
+ return;
236
+ }
237
+
238
+ const expandAll = !!this.props.defaultExpandAllRows;
239
+ const expandedRowKeys = expandAll ? extractRowKeys(this.resource.getData(), this.collection.filterTargetKey) : [];
240
+
241
+ this.setProps('expandedRowKeys', expandedRowKeys);
242
+ this.mapSubModels('actions', (action) => {
243
+ if ('setExpandFlag' in action && typeof action.setExpandFlag === 'function') {
244
+ action.setExpandFlag(expandAll);
245
+ }
246
+ });
247
+ }
248
+
225
249
  private readonly columns = observable.ref([]);
226
250
  private disposeAutorun: () => void;
227
251
 
@@ -27,6 +27,7 @@ import {
27
27
  ModelRenderMode,
28
28
  useFlowModel,
29
29
  } from '@nocobase/flow-engine';
30
+ import type { FlowModel } from '@nocobase/flow-engine';
30
31
  import { useTranslation } from 'react-i18next';
31
32
  import { TableColumnProps, Tooltip, Space, InputNumber, Button, Divider } from 'antd';
32
33
  import { get, omit, capitalize } from 'lodash';
@@ -61,19 +62,28 @@ export function FieldDeletePlaceholder(props: any) {
61
62
  );
62
63
  }
63
64
 
64
- function FieldWithoutPermissionPlaceholder() {
65
+ type FieldPermissionPlaceholderModel = FlowModel & {
66
+ fieldPath?: string;
67
+ forbidden?: { actionName?: string } | null;
68
+ };
69
+
70
+ export function FieldWithoutPermissionPlaceholder({
71
+ targetModel,
72
+ }: {
73
+ targetModel?: FieldPermissionPlaceholderModel;
74
+ } = {}) {
65
75
  const { t } = useTranslation();
66
- const model: any = useFlowModel();
67
- const blockModel = model.context.blockModel;
68
- const collection = model.context.collectionField?.collection || blockModel.collection;
69
- const dataSource = collection.dataSource;
76
+ const contextModel = useFlowModel<FieldPermissionPlaceholderModel>();
77
+ const model = targetModel || contextModel;
78
+ const collection = model.context.collectionField?.collection || model.context.blockModel?.collection;
79
+ const dataSource = collection?.dataSource;
70
80
  const name = model.context.collectionField?.name || model.fieldPath;
71
81
  const nameValue = useMemo(() => {
72
- const dataSourcePrefix = `${t(dataSource.displayName || dataSource.key)} > `;
82
+ const dataSourcePrefix = dataSource ? `${t(dataSource.displayName || dataSource.key)} > ` : '';
73
83
  const collectionPrefix = collection ? `${t(collection.title) || collection.name || collection.tableName} > ` : '';
74
84
  return `${dataSourcePrefix}${collectionPrefix}${name}`;
75
- }, [collection, dataSource.displayName, dataSource.key, name, t]);
76
- const { actionName } = model.forbidden;
85
+ }, [collection, dataSource, name, t]);
86
+ const actionName = model.forbidden?.actionName || 'view';
77
87
  const messageValue = useMemo(() => {
78
88
  return t(
79
89
  `The current user only has the UI configuration permission, but don't have "{{actionName}}" permission for field "{{name}}"`,
@@ -0,0 +1,78 @@
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 { describe, expect, it, vi } from 'vitest';
11
+ import { TableBlockModel } from '../TableBlockModel';
12
+
13
+ describe('TableBlockModel filter reset', () => {
14
+ it('collapses the filtered tree when rows are not expanded by default', () => {
15
+ const setExpandFlag = vi.fn();
16
+ const setProps = vi.fn();
17
+ const model = {
18
+ props: { treeTable: true, defaultExpandAllRows: false, expandedRowKeys: [1, 2] },
19
+ resource: { getData: () => [{ id: 3, children: [{ id: 4 }] }] },
20
+ setProps,
21
+ mapSubModels: (_key, callback) => callback({ setExpandFlag }),
22
+ };
23
+
24
+ TableBlockModel.prototype.resetAfterFilterChange.call(model);
25
+
26
+ expect(setProps).toHaveBeenCalledWith('expandedRowKeys', []);
27
+ expect(setExpandFlag).toHaveBeenCalledWith(false);
28
+ });
29
+
30
+ it('expands the filtered tree when rows are expanded by default', () => {
31
+ const setExpandFlag = vi.fn();
32
+ const setProps = vi.fn();
33
+ const model = {
34
+ props: { treeTable: true, defaultExpandAllRows: true, expandedRowKeys: [1, 2] },
35
+ collection: { filterTargetKey: 'id' },
36
+ resource: { getData: () => [{ id: 3, children: [{ id: 4 }] }] },
37
+ setProps,
38
+ mapSubModels: (_key, callback) => callback({ setExpandFlag }),
39
+ };
40
+
41
+ TableBlockModel.prototype.resetAfterFilterChange.call(model);
42
+
43
+ expect(setProps).toHaveBeenCalledWith('expandedRowKeys', [3, 4]);
44
+ expect(setExpandFlag).toHaveBeenCalledWith(true);
45
+ });
46
+
47
+ it('uses the collection filter target key when expanding the filtered tree', () => {
48
+ const setProps = vi.fn();
49
+ const model = {
50
+ props: { treeTable: true, defaultExpandAllRows: true },
51
+ collection: { filterTargetKey: ['tenant', 'slug'] },
52
+ resource: {
53
+ getData: () => [{ tenant: 'acme', slug: 'root', children: [{ tenant: 'acme', slug: 'child' }] }],
54
+ },
55
+ setProps,
56
+ mapSubModels: vi.fn(),
57
+ };
58
+
59
+ TableBlockModel.prototype.resetAfterFilterChange.call(model);
60
+
61
+ expect(setProps).toHaveBeenCalledWith('expandedRowKeys', ['acme-root', 'acme-child']);
62
+ });
63
+
64
+ it('does not change expansion state for a regular table', () => {
65
+ const setProps = vi.fn();
66
+ const mapSubModels = vi.fn();
67
+ const model = {
68
+ props: { treeTable: false },
69
+ setProps,
70
+ mapSubModels,
71
+ };
72
+
73
+ TableBlockModel.prototype.resetAfterFilterChange.call(model);
74
+
75
+ expect(setProps).not.toHaveBeenCalled();
76
+ expect(mapSubModels).not.toHaveBeenCalled();
77
+ });
78
+ });
@@ -67,6 +67,13 @@ export function extractIds(data) {
67
67
  return ids;
68
68
  }
69
69
 
70
+ export function extractRowKeys(data: Record<string, unknown>[], key: string | string[]) {
71
+ return data.flatMap((item) => {
72
+ const children = Array.isArray(item.children) ? extractRowKeys(item.children, key) : [];
73
+ return [getRowKey(item, key), ...children];
74
+ });
75
+ }
76
+
70
77
  /**
71
78
  * 生成表格行唯一 key
72
79
  * @param record 当前行数据
@@ -8,7 +8,13 @@
8
8
  */
9
9
 
10
10
  import { Cascader, Space, Button } from 'antd';
11
- import { CollectionField, EditableItemModel, tExpr, MultiRecordResource } from '@nocobase/flow-engine';
11
+ import {
12
+ CollectionField,
13
+ EditableItemModel,
14
+ FilterableItemModel,
15
+ MultiRecordResource,
16
+ tExpr,
17
+ } from '@nocobase/flow-engine';
12
18
  import { DeleteOutlined } from '@ant-design/icons';
13
19
  import { css, cx } from '@emotion/css';
14
20
  import { debounce, castArray, omit, last, isEqual } from 'lodash';
@@ -31,6 +37,23 @@ function normalizeLabelKeys(labelKeyOrKeys?: string | string[]): string[] {
31
37
  return Array.from(new Set(list.filter((item): item is string => !!item && typeof item === 'string')));
32
38
  }
33
39
 
40
+ function normalizeCascadeFieldKey(key: unknown): string | undefined {
41
+ if (typeof key === 'string' && key) {
42
+ return key;
43
+ }
44
+ if (Array.isArray(key) && key.length === 1 && typeof key[0] === 'string') {
45
+ return key[0];
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ function getDefaultCascadeFieldNames(field: CollectionField) {
51
+ const targetCollection = field?.targetCollection;
52
+ const value = normalizeCascadeFieldKey(targetCollection?.filterTargetKey) || 'id';
53
+ const label = targetCollection?.titleCollectionField?.name || value;
54
+ return { label, value };
55
+ }
56
+
34
57
  function getRecordDisplayLabel(item: any, labelKeyOrKeys?: string | string[]): string | null {
35
58
  if (!isPlainRecord(item)) return null;
36
59
  const labelKeys = normalizeLabelKeys(labelKeyOrKeys);
@@ -815,6 +838,15 @@ EditableItemModel.bindModelToInterface('CascadeSelectFieldModel', ['m2o', 'o2o',
815
838
  order: 60,
816
839
  });
817
840
 
841
+ FilterableItemModel.bindModelToInterface('CascadeSelectFieldModel', ['m2o', 'o2o', 'oho', 'obo'], {
842
+ when: (ctx, field) => field.targetCollection?.template === 'tree',
843
+ isDefault: true,
844
+ order: 60,
845
+ defaultProps: (ctx, field) => ({
846
+ fieldNames: getDefaultCascadeFieldNames(field),
847
+ }),
848
+ });
849
+
818
850
  EditableItemModel.bindModelToInterface('CascadeSelectListFieldModel', ['m2m', 'o2m', 'mbm'], {
819
851
  when: (ctx, field) => field.targetCollection?.template === 'tree',
820
852
  isDefault: true,
@@ -9,6 +9,7 @@
9
9
 
10
10
  import {
11
11
  CollectionField,
12
+ MultiRecordResource,
12
13
  EditableItemModel,
13
14
  tExpr,
14
15
  FlowModel,
@@ -31,7 +32,12 @@ import {
31
32
  createRootItemChain,
32
33
  type ItemChain,
33
34
  } from './itemChain';
34
- import { buildOpenerUids, LabelByField, type AssociationFieldNames } from './recordSelectShared';
35
+ import {
36
+ buildOpenerUids,
37
+ LabelByField,
38
+ type AssociationFieldNames,
39
+ useAssociationValueHydration,
40
+ } from './recordSelectShared';
35
41
 
36
42
  const MULTIPLE_ASSOCIATION_TYPES = ['belongsToMany', 'hasMany', 'belongsToArray'];
37
43
 
@@ -328,6 +334,13 @@ function RecordPickerField(props) {
328
334
  useEffect(() => {
329
335
  ctx.model.selectedRows.value = props.value;
330
336
  }, [ctx.model.selectedRows, props.value]);
337
+ useAssociationValueHydration({
338
+ model: ctx.model,
339
+ value: props.value,
340
+ isMultiple: allowMultiple,
341
+ fieldNames,
342
+ onChange: props.onChange,
343
+ });
331
344
 
332
345
  return (
333
346
  <Select
@@ -400,6 +413,7 @@ function RecordPickerField(props) {
400
413
  }
401
414
 
402
415
  export class RecordPickerFieldModel extends FieldModel {
416
+ declare resource: MultiRecordResource;
403
417
  selectedRows = observable.ref([]);
404
418
  _closeView;
405
419
  selectBlockModel;
@@ -564,6 +578,15 @@ RecordPickerFieldModel.registerFlow({
564
578
  title: tExpr('RecordPicker settings'),
565
579
  sort: 200,
566
580
  steps: {
581
+ init: {
582
+ handler(ctx) {
583
+ const { target, dataSourceKey } = ctx.model.collectionField;
584
+ const resource = ctx.createResource(MultiRecordResource);
585
+ resource.setDataSourceKey(dataSourceKey);
586
+ resource.setResourceName(target);
587
+ ctx.model.resource = resource;
588
+ },
589
+ },
567
590
  fieldNames: {
568
591
  use: 'titleField',
569
592
  },
@@ -22,7 +22,7 @@ import { css } from '@emotion/css';
22
22
  import { debounce } from 'lodash';
23
23
  import { useRequest } from 'ahooks';
24
24
  import { PlusOutlined } from '@ant-design/icons';
25
- import React, { useEffect, useMemo, useRef, useState } from 'react';
25
+ import React, { useMemo, useRef, useState } from 'react';
26
26
  import { useTranslation } from 'react-i18next';
27
27
  import { SkeletonFallback } from '../../../components/SkeletonFallback';
28
28
  import { AssociationFieldModel } from './AssociationFieldModel';
@@ -32,6 +32,7 @@ import {
32
32
  normalizeAssociationFieldNames,
33
33
  resolveOptions,
34
34
  toSelectValue,
35
+ useAssociationValueHydration,
35
36
  type AssociationOption,
36
37
  type LazySelectProps,
37
38
  } from './recordSelectShared';
@@ -41,81 +42,6 @@ import { ActionWithoutPermission } from '../../base/ActionModel';
41
42
  import { EditFormModel } from '../../blocks';
42
43
  import { hasAncestorModel } from './recordSelectSettingsUtils';
43
44
 
44
- function isPlainObject(val: unknown): val is Record<string, any> {
45
- return !!val && typeof val === 'object' && !Array.isArray(val);
46
- }
47
-
48
- type HydrateStatus = 'pending' | 'done';
49
-
50
- type HydrationCandidate = {
51
- item: AssociationOption;
52
- tk: any;
53
- tkKey: string;
54
- };
55
-
56
- export function collectAssociationHydrationCandidates(options: {
57
- value: LazySelectProps['value'];
58
- isMultiple: boolean;
59
- valueKey: string;
60
- labelKey: string;
61
- statusMap: Map<string, HydrateStatus>;
62
- }): HydrationCandidate[] {
63
- const { value, isMultiple, valueKey, labelKey, statusMap } = options;
64
- const list = isMultiple ? (Array.isArray(value) ? value : []) : value != null ? [value] : [];
65
-
66
- const activeTkKeys = new Set<string>();
67
- for (const item of list) {
68
- if (!isPlainObject(item)) continue;
69
- const tk = item?.[valueKey];
70
- if (tk == null) continue;
71
- const tkKey = typeof tk === 'object' ? JSON.stringify(tk) : String(tk);
72
- if (!tkKey) continue;
73
- activeTkKeys.add(tkKey);
74
- }
75
-
76
- for (const key of Array.from(statusMap.keys())) {
77
- if (!activeTkKeys.has(key)) {
78
- statusMap.delete(key);
79
- }
80
- }
81
-
82
- const candidates: HydrationCandidate[] = [];
83
- for (const item of list) {
84
- if (!isPlainObject(item)) continue;
85
- const tk = item?.[valueKey];
86
- if (tk == null) continue;
87
- if (item?.[labelKey] != null) continue;
88
-
89
- const tkKey = typeof tk === 'object' ? JSON.stringify(tk) : String(tk);
90
- if (!tkKey) continue;
91
-
92
- const status = statusMap.get(tkKey);
93
- if (status === 'pending' || status === 'done') continue;
94
-
95
- statusMap.set(tkKey, 'pending');
96
- candidates.push({ item, tk, tkKey });
97
- }
98
-
99
- return candidates;
100
- }
101
-
102
- export function getAssociationHydrationNamePath(model: any) {
103
- return model?.context?.fieldPathArray ?? model?.context?.fieldPath ?? model?.props?.name;
104
- }
105
-
106
- export function getAssociationHydrationSetterContext(model: any) {
107
- if (typeof model?.context?.setFormValue === 'function') {
108
- return model.context;
109
- }
110
-
111
- return model?.context?.blockModel?.context;
112
- }
113
-
114
- function markAssociationHydrationDone(statusMap: Map<string, HydrateStatus>, tkKey: string | null | undefined) {
115
- if (!tkKey) return;
116
- statusMap.set(tkKey, 'done');
117
- }
118
-
119
45
  function RemoteModelRenderer({ options }) {
120
46
  const ctx = useFlowViewContext();
121
47
  const { data, loading } = useRequest(
@@ -281,66 +207,13 @@ const LazySelect = (props: Readonly<LazySelectProps>) => {
281
207
  };
282
208
  const isConfigMode = !!model.context.flowSettingsEnabled;
283
209
  const { t } = useTranslation();
284
- const hydrateStatusRef = useRef<Map<string, HydrateStatus>>(new Map());
285
-
286
- useEffect(() => {
287
- const resource: any = model?.resource;
288
- if (!resource || typeof resource.get !== 'function') return;
289
- const valueKey = normalizedFieldNames.value;
290
- const labelKey = normalizedFieldNames.label;
291
- if (!valueKey || !labelKey) return;
292
-
293
- const current = value;
294
- const candidates = collectAssociationHydrationCandidates({
295
- value: current,
296
- isMultiple,
297
- valueKey,
298
- labelKey,
299
- statusMap: hydrateStatusRef.current,
300
- });
301
- if (!candidates.length) return;
302
-
303
- const namePath = getAssociationHydrationNamePath(model);
304
- const setterCtx: any = getAssociationHydrationSetterContext(model);
305
-
306
- candidates.forEach(({ item, tk, tkKey }) => {
307
- void (async () => {
308
- try {
309
- const record = await resource.get(tk);
310
- if (!record || typeof record !== 'object') {
311
- return;
312
- }
313
-
314
- const merge = { ...(item as any), ...(record as any) };
315
- if (merge?.[labelKey] == null) {
316
- return;
317
- }
318
-
319
- const nextValue = isMultiple
320
- ? (Array.isArray(current) ? current : []).map((v: any) => {
321
- if (!isPlainObject(v)) return v;
322
- return v?.[valueKey] === tk ? merge : v;
323
- })
324
- : merge;
325
-
326
- if (setterCtx && typeof setterCtx.setFormValue === 'function' && namePath != null) {
327
- await setterCtx.setFormValue(namePath, nextValue, {
328
- source: 'default',
329
- markExplicit: false,
330
- triggerEvent: false,
331
- });
332
- return;
333
- }
334
-
335
- onChange(nextValue as any);
336
- } catch (error) {
337
- // ignore
338
- } finally {
339
- markAssociationHydrationDone(hydrateStatusRef.current, tkKey);
340
- }
341
- })();
342
- });
343
- }, [isMultiple, model, normalizedFieldNames.label, normalizedFieldNames.value, onChange, value]);
210
+ useAssociationValueHydration({
211
+ model,
212
+ value,
213
+ isMultiple,
214
+ fieldNames: normalizedFieldNames,
215
+ onChange,
216
+ });
344
217
 
345
218
  const QuickAddContent = ({ searchText }) => {
346
219
  return (