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

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 (173) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
  3. package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
  4. package/es/flow/actions/linkageRules.d.ts +25 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +6 -30
  6. package/es/flow/components/FlowRoute.d.ts +2 -2
  7. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  8. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +32 -0
  9. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  10. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  11. package/es/flow/components/filter/VariableFilterItem.d.ts +7 -0
  12. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  13. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  14. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  15. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  16. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  17. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  18. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  19. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  20. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  21. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  22. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  23. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  24. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  25. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  26. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  27. package/es/flow/models/blocks/table/TableColumnModel.d.ts +12 -0
  28. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  29. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  30. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  31. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  32. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  33. package/es/flow-compat/passwordUtils.d.ts +1 -1
  34. package/es/index.d.ts +1 -0
  35. package/es/index.mjs +148 -145
  36. package/es/ui-operation/index.d.ts +15 -0
  37. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  38. package/lib/index.js +170 -167
  39. package/lib/locale/languageCodes.js +1 -0
  40. package/package.json +10 -8
  41. package/src/APIClient.ts +92 -0
  42. package/src/Application.tsx +3 -1
  43. package/src/__tests__/APIClient.test.tsx +58 -0
  44. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
  45. package/src/__tests__/settings-center.test.tsx +270 -6
  46. package/src/acl/ACLProvider.tsx +2 -0
  47. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  48. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  49. package/src/collection-manager/field-configure.ts +2 -2
  50. package/src/components/form/ScanInput/CodeScanner.tsx +64 -35
  51. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +107 -0
  52. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +294 -24
  53. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  54. package/src/components/form/ScanInput/useCodeScanner.ts +281 -23
  55. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  56. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  57. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  58. package/src/components/form/table/Table.tsx +81 -6
  59. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  60. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  61. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  62. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  63. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  64. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  65. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  66. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  67. package/src/flow/actions/linkageRules.tsx +59 -16
  68. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  69. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  70. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  71. package/src/flow/common/Markdown/style.ts +4 -0
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/FieldAssignValueInput.tsx +42 -621
  74. package/src/flow/components/FlowRoute.tsx +71 -29
  75. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  76. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +335 -0
  77. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +389 -0
  78. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  79. package/src/flow/components/field-value-variable/index.ts +12 -0
  80. package/src/flow/components/filter/VariableFilterItem.tsx +23 -6
  81. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  82. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  83. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  84. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  85. package/src/flow/index.ts +2 -0
  86. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  87. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  88. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  89. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  90. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  91. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  92. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  93. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  94. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  95. package/src/flow/models/base/CollectionBlockModel.tsx +38 -3
  96. package/src/flow/models/base/GridModel.tsx +0 -1
  97. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  98. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  99. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  100. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  101. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  102. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  103. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  104. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +36 -50
  105. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  106. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  107. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  108. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  109. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  110. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  111. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  112. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  113. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  114. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  115. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  116. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  117. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  118. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  119. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  120. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  121. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  122. package/src/flow/models/blocks/form/FormBlockModel.tsx +73 -5
  123. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  124. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +153 -2
  125. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  126. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  127. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  128. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  129. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  130. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  131. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  132. package/src/flow/models/blocks/table/TableBlockModel.tsx +28 -2
  133. package/src/flow/models/blocks/table/TableColumnModel.tsx +48 -9
  134. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  135. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  136. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  137. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  138. package/src/flow/models/blocks/table/utils.ts +7 -0
  139. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  140. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +12 -3
  141. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  142. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  143. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +29 -3
  144. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  145. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +5 -1
  146. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +191 -3
  147. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  148. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  149. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  150. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  151. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  152. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  153. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  154. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  155. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  156. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  157. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  158. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  159. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  160. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  161. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  162. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  163. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  164. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
  165. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  166. package/src/index.ts +1 -0
  167. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  168. package/src/layout-manager/LayoutRoute.tsx +2 -1
  169. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  170. package/src/locale/languageCodes.ts +1 -0
  171. package/src/settings-center/AdminSettingsLayout.tsx +23 -2
  172. package/src/ui-operation/index.ts +33 -0
  173. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -0,0 +1,120 @@
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 { PopupSubTableFieldModel } from '../PopupSubTableFieldModel';
12
+ import { PopupSubTableEditActionModel } from '../actions/PopupSubTableEditActionModel';
13
+
14
+ function getOpenViewHandler(modelClass: typeof PopupSubTableFieldModel | typeof PopupSubTableEditActionModel) {
15
+ const flow = modelClass.globalFlowRegistry.getFlow('popupSettings');
16
+ const step = flow?.getStep('openView');
17
+ const handler = step?.serialize().handler;
18
+
19
+ if (!handler) {
20
+ throw new Error('popupSettings.openView handler is not registered');
21
+ }
22
+
23
+ return handler;
24
+ }
25
+
26
+ function createContext(record?: Record<string, unknown>, parentRecord: Record<string, unknown> = { id: 1 }) {
27
+ const open = vi.fn();
28
+ const sourceCollection = {
29
+ getFilterByTK: vi.fn((sourceRecord: Record<string, unknown>) => sourceRecord.id),
30
+ };
31
+ const model = {
32
+ uid: 'popup-subtable-uid',
33
+ props: {
34
+ value: record ? [record] : [],
35
+ },
36
+ context: {
37
+ inputArgs: {},
38
+ },
39
+ flowEngine: {
40
+ context: {
41
+ themeToken: {
42
+ colorBgLayout: '#fff',
43
+ },
44
+ },
45
+ },
46
+ };
47
+
48
+ return {
49
+ open,
50
+ context: {
51
+ inputArgs: {},
52
+ item: {
53
+ value: parentRecord,
54
+ },
55
+ view: {
56
+ inputArgs: {
57
+ viewUid: 'parent-popup-uid',
58
+ openerUids: ['root-page-uid'],
59
+ },
60
+ },
61
+ viewer: { open },
62
+ layoutContentElement: {},
63
+ model,
64
+ associationModel: model,
65
+ collection: {
66
+ dataSourceKey: 'main',
67
+ filterTargetKey: 'id',
68
+ },
69
+ collectionField: {
70
+ target: 'roles',
71
+ resourceName: 'users.roles',
72
+ collection: sourceCollection,
73
+ },
74
+ record,
75
+ getFormValues: () => ({ id: 1 }),
76
+ getPropertyOptions: () => undefined,
77
+ },
78
+ };
79
+ }
80
+
81
+ describe('PopupSubTable popup context', () => {
82
+ it('passes popup and parent record context to the add-new popup', () => {
83
+ const { context, open } = createContext();
84
+ const handler = getOpenViewHandler(PopupSubTableFieldModel);
85
+
86
+ handler(context, { mode: 'drawer', size: 'medium' });
87
+
88
+ expect(open).toHaveBeenCalledOnce();
89
+ expect(open.mock.calls[0][0].inputArgs).toMatchObject({
90
+ openerUids: ['root-page-uid', 'parent-popup-uid'],
91
+ associationName: 'users.roles',
92
+ sourceId: 1,
93
+ });
94
+ });
95
+
96
+ it('passes popup and parent record context to the edit popup', () => {
97
+ const { context, open } = createContext({ id: 2, name: 'Member' });
98
+ const handler = getOpenViewHandler(PopupSubTableEditActionModel);
99
+
100
+ handler(context, { mode: 'dialog', size: 'medium' });
101
+
102
+ expect(open).toHaveBeenCalledOnce();
103
+ expect(open.mock.calls[0][0].inputArgs).toMatchObject({
104
+ openerUids: ['root-page-uid', 'parent-popup-uid'],
105
+ associationName: 'users.roles',
106
+ sourceId: 1,
107
+ });
108
+ });
109
+
110
+ it('does not expose a parent record reference before the parent record is persisted', () => {
111
+ const { context, open } = createContext(undefined, { nickname: 'Draft user' });
112
+ const handler = getOpenViewHandler(PopupSubTableFieldModel);
113
+
114
+ handler(context, { mode: 'drawer', size: 'medium' });
115
+
116
+ expect(open).toHaveBeenCalledOnce();
117
+ expect(open.mock.calls[0][0].inputArgs).not.toHaveProperty('associationName');
118
+ expect(open.mock.calls[0][0].inputArgs).not.toHaveProperty('sourceId');
119
+ });
120
+ });
@@ -17,6 +17,7 @@ import { capitalize } from 'lodash';
17
17
  import '../../../../base/ActionModel';
18
18
  import { ActionModel, ActionWithoutPermission } from '../../../../base/ActionModelCore';
19
19
  import { SkeletonFallback } from '../../../../../components/SkeletonFallback';
20
+ import { buildOpenerUids } from '../../recordSelectShared';
20
21
  import { bindPopupSubTableBeforeClose } from './popupSubTableBeforeClose';
21
22
 
22
23
  function FieldWithoutPermissionPlaceholder({ targetModel, children }) {
@@ -29,7 +30,7 @@ function FieldWithoutPermissionPlaceholder({ targetModel, children }) {
29
30
  const dataSourcePrefix = `${t(dataSource.displayName || dataSource.key)} > `;
30
31
  const collectionPrefix = collection ? `${t(collection.title) || collection.name || collection.tableName} > ` : '';
31
32
  return `${dataSourcePrefix}${collectionPrefix}${name}`;
32
- }, []);
33
+ }, [collection, dataSource.displayName, dataSource.key, name, t]);
33
34
  const { actionName } = fieldModel.forbidden || {};
34
35
  const messageValue = useMemo(() => {
35
36
  return t(
@@ -39,7 +40,7 @@ function FieldWithoutPermissionPlaceholder({ targetModel, children }) {
39
40
  actionName: t(capitalize(actionName)),
40
41
  },
41
42
  ).replaceAll('&gt;', '>');
42
- }, [nameValue, t]);
43
+ }, [actionName, nameValue, t]);
43
44
  return <Tooltip title={messageValue}>{children}</Tooltip>;
44
45
  }
45
46
 
@@ -347,6 +348,11 @@ PopupSubTableEditActionModel.registerFlow({
347
348
  return undefined;
348
349
  }
349
350
  })();
351
+ const associationName = ctx.collectionField?.resourceName;
352
+ const sourceId = parentItem?.value
353
+ ? ctx.collectionField?.collection?.getFilterByTK?.(parentItem.value)
354
+ : undefined;
355
+ const openerUids = buildOpenerUids(ctx, ctx.inputArgs);
350
356
  ctx.viewer.open({
351
357
  type: openMode,
352
358
  width: sizeToWidthMap[openMode][size],
@@ -357,6 +363,7 @@ PopupSubTableEditActionModel.registerFlow({
357
363
  scene: 'subForm',
358
364
  dataSourceKey: ctx.collection.dataSourceKey,
359
365
  collectionName: ctx.collectionField?.target,
366
+ ...(associationName && sourceId != null ? { associationName, sourceId } : {}),
360
367
  collectionField: ctx.collectionField,
361
368
  record: ctx.record,
362
369
  parentItem,
@@ -364,6 +371,7 @@ PopupSubTableEditActionModel.registerFlow({
364
371
  parentItemResolver: parentItemOptions?.resolveOnServer,
365
372
  itemIndex,
366
373
  itemLength,
374
+ openerUids,
367
375
  },
368
376
  content: () => <EditFormContent model={ctx.model} />,
369
377
  styles: {
@@ -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,13 @@ 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
+ normalizeAssociationFieldNames,
39
+ type AssociationFieldNames,
40
+ useAssociationValueHydration,
41
+ } from './recordSelectShared';
35
42
 
36
43
  const MULTIPLE_ASSOCIATION_TYPES = ['belongsToMany', 'hasMany', 'belongsToArray'];
37
44
 
@@ -325,22 +332,31 @@ function RecordPickerField(props) {
325
332
  const { fieldNames, onClick, disabled } = props;
326
333
  const ctx = useFlowContext();
327
334
  const allowMultiple = canRecordPickerSelectMultiple(ctx.collectionField, props.allowMultiple);
335
+ const normalizedFieldNames = normalizeAssociationFieldNames(fieldNames, ctx.collectionField?.targetCollection);
328
336
  useEffect(() => {
329
337
  ctx.model.selectedRows.value = props.value;
330
338
  }, [ctx.model.selectedRows, props.value]);
339
+ useAssociationValueHydration({
340
+ model: ctx.model,
341
+ value: props.value,
342
+ isMultiple: allowMultiple,
343
+ fieldNames: normalizedFieldNames,
344
+ onChange: props.onChange,
345
+ });
331
346
 
332
347
  return (
333
348
  <Select
334
349
  {...props}
350
+ fieldNames={normalizedFieldNames}
335
351
  open={false}
336
352
  onClick={(e) => {
337
353
  if (!disabled) {
338
354
  onClick(e);
339
355
  }
340
356
  }}
341
- value={normalizeRecordPickerValue(props.value, fieldNames, allowMultiple)}
357
+ value={normalizeRecordPickerValue(props.value, normalizedFieldNames, allowMultiple)}
342
358
  labelRender={(item) => {
343
- return <LabelByField option={item} fieldNames={fieldNames} />;
359
+ return <LabelByField option={item} fieldNames={normalizedFieldNames} />;
344
360
  }}
345
361
  labelInValue
346
362
  mode={allowMultiple ? 'multiple' : undefined}
@@ -400,6 +416,7 @@ function RecordPickerField(props) {
400
416
  }
401
417
 
402
418
  export class RecordPickerFieldModel extends FieldModel {
419
+ declare resource: MultiRecordResource;
403
420
  selectedRows = observable.ref([]);
404
421
  _closeView;
405
422
  selectBlockModel;
@@ -564,6 +581,15 @@ RecordPickerFieldModel.registerFlow({
564
581
  title: tExpr('RecordPicker settings'),
565
582
  sort: 200,
566
583
  steps: {
584
+ init: {
585
+ handler(ctx) {
586
+ const { target, dataSourceKey } = ctx.model.collectionField;
587
+ const resource = ctx.createResource(MultiRecordResource);
588
+ resource.setDataSourceKey(dataSourceKey);
589
+ resource.setResourceName(target);
590
+ ctx.model.resource = resource;
591
+ },
592
+ },
567
593
  fieldNames: {
568
594
  use: 'titleField',
569
595
  },
@@ -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 (
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { CloseOutlined, ZoomInOutlined } from '@ant-design/icons';
11
- import { Table, Form, Space, Button } from 'antd';
11
+ import { Table, Form, Space, Button, theme } from 'antd';
12
12
  import { css } from '@emotion/css';
13
13
  import { useTranslation } from 'react-i18next';
14
14
  import { PlusOutlined } from '@ant-design/icons';
@@ -51,6 +51,7 @@ function shouldRefreshForChangedPaths(fieldPath: unknown, changedPaths: unknown)
51
51
 
52
52
  export function SubTableField(props) {
53
53
  const { t } = useTranslation();
54
+ const { token } = theme.useToken();
54
55
  const {
55
56
  onChange,
56
57
  columns,
@@ -180,6 +181,9 @@ export function SubTableField(props) {
180
181
  // the current array index so Form.Item can rebind after reordering/removal.
181
182
  const rowBindingKey = `${rowIdentity}:${pageRowIdx}`;
182
183
  const columnKey = col.dataIndex ?? col.key ?? 'cell';
184
+ if (col.key === '_empty') {
185
+ return <div style={{ minHeight: token.controlHeight }} />;
186
+ }
183
187
  if (!col.render) {
184
188
  return;
185
189
  }
@@ -7,7 +7,9 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { FlowContext } from '@nocobase/flow-engine';
10
+ import React from 'react';
11
+ import { FlowContext, FlowEngine, FlowModel, FlowModelProvider } from '@nocobase/flow-engine';
12
+ import { render, renderHook, waitFor } from '@testing-library/react';
11
13
  import { describe, expect, it, vi } from 'vitest';
12
14
  import {
13
15
  buildCurrentItemTitle,
@@ -21,6 +23,7 @@ import {
21
23
  } from '../itemChain';
22
24
  import { injectRecordPickerPopupContext } from '@nocobase/client-v2';
23
25
  import {
26
+ RecordPickerFieldModel,
24
27
  canRecordPickerSelectMultiple,
25
28
  getRecordPickerClearedValue,
26
29
  getRecordPickerEmptyValue,
@@ -28,8 +31,12 @@ import {
28
31
  normalizeRecordPickerValue,
29
32
  shouldClearRecordPickerValueOnMultipleChange,
30
33
  } from '../RecordPickerFieldModel';
31
- import { getAssociationHydrationNamePath, getAssociationHydrationSetterContext } from '../RecordSelectFieldModel';
32
-
34
+ import {
35
+ collectAssociationHydrationCandidates,
36
+ useAssociationValueHydration,
37
+ getAssociationHydrationNamePath,
38
+ getAssociationHydrationSetterContext,
39
+ } from '../recordSelectShared';
33
40
  function createMockCollection() {
34
41
  return {
35
42
  name: 'users',
@@ -78,6 +85,141 @@ describe('RecordPickerFieldModel item context', () => {
78
85
  expect(normalizeRecordPickerValue(undefined, fieldNames, false)).toBeUndefined();
79
86
  });
80
87
 
88
+ it('renders existing values before field names are initialized', () => {
89
+ const engine = new FlowEngine();
90
+ engine.registerModels({ RecordPickerFieldModel });
91
+ const field = engine.createModel<RecordPickerFieldModel>({
92
+ use: 'RecordPickerFieldModel',
93
+ uid: 'record-picker-without-field-names',
94
+ });
95
+ field.context.defineProperty('collectionField', {
96
+ value: {
97
+ type: 'hasMany',
98
+ targetCollection: {
99
+ filterTargetKey: 'id',
100
+ titleCollectionField: { name: 'name' },
101
+ },
102
+ },
103
+ });
104
+ field.setProps({ value: [{ id: 1, name: 'Alice' }], onClick: vi.fn(), onChange: vi.fn() });
105
+
106
+ const result = render(React.createElement(FlowModelProvider, { model: field }, field.render()));
107
+
108
+ expect(result.getByRole('combobox')).toBeTruthy();
109
+ });
110
+
111
+ it('queues ID-only picker values for association label hydration', () => {
112
+ const statusMap = new Map<string, 'pending' | 'done'>();
113
+ const fieldNames = { label: 'name', value: 'id' };
114
+
115
+ expect(
116
+ collectAssociationHydrationCandidates({
117
+ value: [{ id: 1 }, { id: 2, name: 'Already hydrated' }],
118
+ isMultiple: true,
119
+ valueKey: fieldNames.value,
120
+ labelKey: fieldNames.label,
121
+ statusMap,
122
+ }),
123
+ ).toEqual([{ item: { id: 1 }, tk: 1, tkKey: '1' }]);
124
+ expect(statusMap).toEqual(new Map([['1', 'pending']]));
125
+
126
+ expect(
127
+ collectAssociationHydrationCandidates({
128
+ value: { id: 1 },
129
+ isMultiple: false,
130
+ valueKey: fieldNames.value,
131
+ labelKey: fieldNames.label,
132
+ statusMap,
133
+ }),
134
+ ).toEqual([]);
135
+ });
136
+
137
+ it('hydrates an ID-only picker value back into the form', async () => {
138
+ const resource = {
139
+ get: vi.fn().mockResolvedValue({ id: 1, name: '北京启明教育' }),
140
+ };
141
+ const setFormValue = vi.fn();
142
+ const onChange = vi.fn();
143
+ const model = {
144
+ resource,
145
+ props: { name: 'company' },
146
+ context: {
147
+ fieldPathArray: ['company'],
148
+ setFormValue,
149
+ },
150
+ };
151
+
152
+ const { rerender } = renderHook(() =>
153
+ useAssociationValueHydration({
154
+ model,
155
+ value: { id: 1 },
156
+ isMultiple: false,
157
+ fieldNames: { label: 'name', value: 'id' },
158
+ onChange,
159
+ }),
160
+ );
161
+
162
+ await waitFor(() => {
163
+ expect(setFormValue).toHaveBeenCalledWith(
164
+ ['company'],
165
+ { id: 1, name: '北京启明教育' },
166
+ { source: 'default', markExplicit: false, triggerEvent: false },
167
+ );
168
+ });
169
+ rerender();
170
+
171
+ expect(resource.get).toHaveBeenCalledTimes(1);
172
+ expect(onChange).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it('initializes a target resource before hydrating an ID-only picker value', async () => {
176
+ const engine = new FlowEngine();
177
+ engine.registerModels({ RecordPickerFieldModel });
178
+
179
+ const formItem = engine.createModel<FlowModel>({
180
+ use: 'FlowModel',
181
+ uid: 'form-item',
182
+ });
183
+ const field = engine.createModel<RecordPickerFieldModel>({
184
+ use: 'RecordPickerFieldModel',
185
+ uid: 'company-field',
186
+ parentId: formItem.uid,
187
+ });
188
+ const setFormValue = vi.fn();
189
+ field.context.defineProperty('collectionField', {
190
+ value: {
191
+ dataSourceKey: 'main',
192
+ target: 'rp_na_companies',
193
+ },
194
+ });
195
+ field.context.defineProperty('fieldPathArray', { value: ['company'] });
196
+ field.context.defineProperty('setFormValue', { value: setFormValue });
197
+
198
+ await field.applyFlow('recordPickerSettings');
199
+ const get = vi.spyOn(field.resource, 'get').mockResolvedValue({ id: 1, name: '北京启明教育' });
200
+ const onChange = vi.fn();
201
+
202
+ renderHook(() =>
203
+ useAssociationValueHydration({
204
+ model: field,
205
+ value: { id: 1 },
206
+ isMultiple: false,
207
+ fieldNames: { label: 'name', value: 'id' },
208
+ onChange,
209
+ }),
210
+ );
211
+
212
+ await waitFor(() => {
213
+ expect(get).toHaveBeenCalledWith(1);
214
+ expect(setFormValue).toHaveBeenCalledWith(
215
+ ['company'],
216
+ { id: 1, name: '北京启明教育' },
217
+ { source: 'default', markExplicit: false, triggerEvent: false },
218
+ );
219
+ });
220
+ expect(onChange).not.toHaveBeenCalled();
221
+ });
222
+
81
223
  it('returns the empty popup select value for the current multiple mode', () => {
82
224
  expect(getRecordPickerEmptyValue(true)).toEqual([]);
83
225
  expect(getRecordPickerEmptyValue(false)).toBeUndefined();
@@ -243,6 +385,52 @@ describe('RecordPickerFieldModel item context', () => {
243
385
  expect(associationOpts.serverOnlyWhenContextParams).toBe(true);
244
386
  });
245
387
 
388
+ it('builds whole-association descriptors for current and recursive parent item values', async () => {
389
+ const people = {
390
+ name: 'people',
391
+ dataSourceKey: 'main',
392
+ filterTargetKey: 'id',
393
+ getFields: () => [],
394
+ getField: () => undefined,
395
+ } as any;
396
+ const owner = {
397
+ name: 'owner',
398
+ target: 'people',
399
+ targetCollection: people,
400
+ isAssociationField: () => true,
401
+ } as any;
402
+ const collection = {
403
+ name: 'tasks',
404
+ dataSourceKey: 'main',
405
+ getFields: () => [owner],
406
+ getField: (name: string) => (name === 'owner' ? owner : undefined),
407
+ } as any;
408
+ const options = createAssociationItemChainContextPropertyOptions({
409
+ t: (value) => value,
410
+ title: 'Current item',
411
+ collectionAccessor: () => collection,
412
+ propertiesAccessor: (ctx) => ctx.item.value,
413
+ resolverPropertiesAccessor: () => ({ owner: { id: 7 } }),
414
+ parentCollectionAccessor: () => collection,
415
+ parentAccessors: {
416
+ parentPropertiesAccessor: () => ({ owner: { id: 8 } }),
417
+ parentItemMetaAccessor: () => undefined,
418
+ parentItemResolverAccessor: () => undefined,
419
+ },
420
+ });
421
+ const meta = await options.meta();
422
+ const params = await meta.buildVariablesParams({
423
+ item: { value: { owner: { id: 7 } }, parentItem: { value: { owner: { id: 8 } } } },
424
+ });
425
+
426
+ expect(params).toEqual({
427
+ parentItem: { value: { owner: { collection: 'people', dataSourceKey: 'main', filterByTk: 8 } } },
428
+ value: { owner: { collection: 'people', dataSourceKey: 'main', filterByTk: 7 } },
429
+ });
430
+ expect(options.resolveOnServer('value.owner.name')).toBe(true);
431
+ expect(options.resolveOnServer('parentItem.value.owner.name')).toBe(true);
432
+ });
433
+
246
434
  it('disables current item attributes for select-record popup item context', async () => {
247
435
  const parentCtx = new FlowContext();
248
436
  const departmentsCollection = {