@nocobase/client-v2 3.0.0-alpha.1 → 3.0.0-alpha.12

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 (133) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +6 -2
  4. package/es/flow/actions/linkageRules.d.ts +24 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
  6. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  7. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
  8. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  9. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  10. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  11. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  12. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  13. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  14. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  15. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  16. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  17. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  18. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  19. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  20. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  21. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
  23. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  24. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  25. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  26. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  27. package/es/flow-compat/passwordUtils.d.ts +1 -1
  28. package/es/index.d.ts +2 -1
  29. package/es/index.mjs +140 -140
  30. package/es/nocobase-buildin-plugin/index.d.ts +1 -0
  31. package/es/settings-app/SettingsGroupNav.d.ts +3 -2
  32. package/es/settings-app/SettingsSearch.d.ts +1 -1
  33. package/es/ui-operation/index.d.ts +15 -0
  34. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  35. package/lib/index.js +145 -145
  36. package/package.json +7 -7
  37. package/src/APIClient.ts +92 -0
  38. package/src/Application.tsx +3 -1
  39. package/src/__tests__/APIClient.test.tsx +58 -0
  40. package/src/__tests__/app.test.tsx +122 -3
  41. package/src/__tests__/authRedirect.test.ts +17 -0
  42. package/src/__tests__/browserChecker.test.ts +81 -33
  43. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
  44. package/src/__tests__/settings-center.test.tsx +312 -11
  45. package/src/__tests__/settings-layout-root.test.tsx +346 -6
  46. package/src/__tests__/settings-runtime-paths.test.ts +1 -1
  47. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  48. package/src/__tests__/settings-shell.test.tsx +62 -1
  49. package/src/acl/ACLProvider.tsx +34 -3
  50. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  51. package/src/acl/aclCheckReadiness.ts +44 -0
  52. package/src/authRedirect.ts +13 -5
  53. package/src/components/AppComponents.tsx +16 -7
  54. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  55. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  56. package/src/components/form/table/Table.tsx +81 -6
  57. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  58. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  59. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  60. package/src/flow/actions/linkageRules.tsx +33 -10
  61. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  62. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  63. package/src/flow/components/FieldAssignValueInput.tsx +38 -621
  64. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  65. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  66. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  67. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  68. package/src/flow/components/field-value-variable/index.ts +12 -0
  69. package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
  70. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  71. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  72. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  73. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  74. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  75. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  76. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  77. package/src/flow/models/base/CollectionBlockModel.tsx +32 -1
  78. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  79. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  80. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  81. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  82. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  83. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  84. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  85. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  86. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  87. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  88. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  89. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  90. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  91. package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
  92. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  93. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  94. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  95. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  96. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  97. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  98. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  99. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
  100. package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
  101. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  102. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  103. package/src/flow/models/blocks/table/utils.ts +7 -0
  104. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  105. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  106. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  107. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  108. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  109. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  110. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
  111. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  112. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  113. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  114. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  115. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  116. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  117. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  118. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  119. package/src/index.ts +2 -0
  120. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  121. package/src/layout-manager/LayoutRoute.tsx +2 -1
  122. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  123. package/src/nocobase-buildin-plugin/index.tsx +14 -1
  124. package/src/settings-app/SettingsGroupNav.tsx +11 -11
  125. package/src/settings-app/SettingsSearch.tsx +115 -10
  126. package/src/settings-app/SettingsShell.tsx +23 -6
  127. package/src/settings-app/runtimePaths.ts +0 -1
  128. package/src/settings-app/settingsTheme.ts +82 -14
  129. package/src/settings-center/AdminSettingsLayout.tsx +132 -107
  130. package/src/settings-center/__tests__/groups.test.ts +25 -0
  131. package/src/settings-center/groups.ts +1 -1
  132. package/src/ui-operation/index.ts +33 -0
  133. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -28,6 +28,7 @@ import { observer } from '@formily/reactive-react';
28
28
  import React, { useEffect, useMemo, useState, useRef } from 'react';
29
29
  import { useTranslation } from 'react-i18next';
30
30
  import { buildRecordPickerPopupContextInputArgs, RecordPickerContent } from '../RecordPickerFieldModel';
31
+ import { buildOpenerUids } from '../recordSelectShared';
31
32
  import { AssociationFieldModel } from '../AssociationFieldModel';
32
33
  import { adjustColumnOrder } from '../../../blocks/table/utils';
33
34
  import { isSubTableColumnFieldComponentContext } from '../SubTableFieldModel/SubTableColumnModel';
@@ -653,6 +654,11 @@ PopupSubTableFieldModel.registerFlow({
653
654
  const parentItemOptions = ctx?.getPropertyOptions?.('item');
654
655
  const itemIndex = Array.isArray(ctx.model?.props?.value) ? ctx.model.props.value.length : 0;
655
656
  const itemLength = itemIndex + 1;
657
+ const associationName = ctx.collectionField?.resourceName;
658
+ const sourceId = parentItem?.value
659
+ ? ctx.collectionField?.collection?.getFilterByTK?.(parentItem.value)
660
+ : undefined;
661
+ const openerUids = buildOpenerUids(ctx, ctx.inputArgs);
656
662
  ctx.viewer.open({
657
663
  type: openMode,
658
664
  width: sizeToWidthMap[openMode][size],
@@ -663,12 +669,14 @@ PopupSubTableFieldModel.registerFlow({
663
669
  scene: 'subForm',
664
670
  dataSourceKey: ctx.collection.dataSourceKey,
665
671
  collectionName: ctx.collectionField?.target,
672
+ ...(associationName && sourceId != null ? { associationName, sourceId } : {}),
666
673
  collectionField: ctx.collectionField,
667
674
  parentItem,
668
675
  parentItemMeta: parentItemOptions?.meta,
669
676
  parentItemResolver: parentItemOptions?.resolveOnServer,
670
677
  itemIndex,
671
678
  itemLength,
679
+ openerUids,
672
680
  },
673
681
  content: () => <EditFormContent model={ctx.model} scene="create" />,
674
682
  styles: {
@@ -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,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 (
@@ -7,7 +7,8 @@
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 { FlowContext, FlowEngine, FlowModel } from '@nocobase/flow-engine';
11
+ import { renderHook, waitFor } from '@testing-library/react';
11
12
  import { describe, expect, it, vi } from 'vitest';
12
13
  import {
13
14
  buildCurrentItemTitle,
@@ -21,6 +22,7 @@ import {
21
22
  } from '../itemChain';
22
23
  import { injectRecordPickerPopupContext } from '@nocobase/client-v2';
23
24
  import {
25
+ RecordPickerFieldModel,
24
26
  canRecordPickerSelectMultiple,
25
27
  getRecordPickerClearedValue,
26
28
  getRecordPickerEmptyValue,
@@ -28,8 +30,12 @@ import {
28
30
  normalizeRecordPickerValue,
29
31
  shouldClearRecordPickerValueOnMultipleChange,
30
32
  } from '../RecordPickerFieldModel';
31
- import { getAssociationHydrationNamePath, getAssociationHydrationSetterContext } from '../RecordSelectFieldModel';
32
-
33
+ import {
34
+ collectAssociationHydrationCandidates,
35
+ useAssociationValueHydration,
36
+ getAssociationHydrationNamePath,
37
+ getAssociationHydrationSetterContext,
38
+ } from '../recordSelectShared';
33
39
  function createMockCollection() {
34
40
  return {
35
41
  name: 'users',
@@ -78,6 +84,118 @@ describe('RecordPickerFieldModel item context', () => {
78
84
  expect(normalizeRecordPickerValue(undefined, fieldNames, false)).toBeUndefined();
79
85
  });
80
86
 
87
+ it('queues ID-only picker values for association label hydration', () => {
88
+ const statusMap = new Map<string, 'pending' | 'done'>();
89
+ const fieldNames = { label: 'name', value: 'id' };
90
+
91
+ expect(
92
+ collectAssociationHydrationCandidates({
93
+ value: [{ id: 1 }, { id: 2, name: 'Already hydrated' }],
94
+ isMultiple: true,
95
+ valueKey: fieldNames.value,
96
+ labelKey: fieldNames.label,
97
+ statusMap,
98
+ }),
99
+ ).toEqual([{ item: { id: 1 }, tk: 1, tkKey: '1' }]);
100
+ expect(statusMap).toEqual(new Map([['1', 'pending']]));
101
+
102
+ expect(
103
+ collectAssociationHydrationCandidates({
104
+ value: { id: 1 },
105
+ isMultiple: false,
106
+ valueKey: fieldNames.value,
107
+ labelKey: fieldNames.label,
108
+ statusMap,
109
+ }),
110
+ ).toEqual([]);
111
+ });
112
+
113
+ it('hydrates an ID-only picker value back into the form', async () => {
114
+ const resource = {
115
+ get: vi.fn().mockResolvedValue({ id: 1, name: '北京启明教育' }),
116
+ };
117
+ const setFormValue = vi.fn();
118
+ const onChange = vi.fn();
119
+ const model = {
120
+ resource,
121
+ props: { name: 'company' },
122
+ context: {
123
+ fieldPathArray: ['company'],
124
+ setFormValue,
125
+ },
126
+ };
127
+
128
+ const { rerender } = renderHook(() =>
129
+ useAssociationValueHydration({
130
+ model,
131
+ value: { id: 1 },
132
+ isMultiple: false,
133
+ fieldNames: { label: 'name', value: 'id' },
134
+ onChange,
135
+ }),
136
+ );
137
+
138
+ await waitFor(() => {
139
+ expect(setFormValue).toHaveBeenCalledWith(
140
+ ['company'],
141
+ { id: 1, name: '北京启明教育' },
142
+ { source: 'default', markExplicit: false, triggerEvent: false },
143
+ );
144
+ });
145
+ rerender();
146
+
147
+ expect(resource.get).toHaveBeenCalledTimes(1);
148
+ expect(onChange).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it('initializes a target resource before hydrating an ID-only picker value', async () => {
152
+ const engine = new FlowEngine();
153
+ engine.registerModels({ RecordPickerFieldModel });
154
+
155
+ const formItem = engine.createModel<FlowModel>({
156
+ use: 'FlowModel',
157
+ uid: 'form-item',
158
+ });
159
+ const field = engine.createModel<RecordPickerFieldModel>({
160
+ use: 'RecordPickerFieldModel',
161
+ uid: 'company-field',
162
+ parentId: formItem.uid,
163
+ });
164
+ const setFormValue = vi.fn();
165
+ field.context.defineProperty('collectionField', {
166
+ value: {
167
+ dataSourceKey: 'main',
168
+ target: 'rp_na_companies',
169
+ },
170
+ });
171
+ field.context.defineProperty('fieldPathArray', { value: ['company'] });
172
+ field.context.defineProperty('setFormValue', { value: setFormValue });
173
+
174
+ await field.applyFlow('recordPickerSettings');
175
+ const get = vi.spyOn(field.resource, 'get').mockResolvedValue({ id: 1, name: '北京启明教育' });
176
+ const onChange = vi.fn();
177
+
178
+ renderHook(() =>
179
+ useAssociationValueHydration({
180
+ model: field,
181
+ value: { id: 1 },
182
+ isMultiple: false,
183
+ fieldNames: { label: 'name', value: 'id' },
184
+ onChange,
185
+ }),
186
+ );
187
+
188
+ await waitFor(() => {
189
+ expect(get).toHaveBeenCalledWith(1);
190
+ expect(setFormValue).toHaveBeenCalledWith(
191
+ ['company'],
192
+ { id: 1, name: '北京启明教育' },
193
+ { source: 'default', markExplicit: false, triggerEvent: false },
194
+ );
195
+ });
196
+ expect(onChange).not.toHaveBeenCalled();
197
+ });
198
+
81
199
  it('returns the empty popup select value for the current multiple mode', () => {
82
200
  expect(getRecordPickerEmptyValue(true)).toEqual([]);
83
201
  expect(getRecordPickerEmptyValue(false)).toBeUndefined();
@@ -243,6 +361,52 @@ describe('RecordPickerFieldModel item context', () => {
243
361
  expect(associationOpts.serverOnlyWhenContextParams).toBe(true);
244
362
  });
245
363
 
364
+ it('builds whole-association descriptors for current and recursive parent item values', async () => {
365
+ const people = {
366
+ name: 'people',
367
+ dataSourceKey: 'main',
368
+ filterTargetKey: 'id',
369
+ getFields: () => [],
370
+ getField: () => undefined,
371
+ } as any;
372
+ const owner = {
373
+ name: 'owner',
374
+ target: 'people',
375
+ targetCollection: people,
376
+ isAssociationField: () => true,
377
+ } as any;
378
+ const collection = {
379
+ name: 'tasks',
380
+ dataSourceKey: 'main',
381
+ getFields: () => [owner],
382
+ getField: (name: string) => (name === 'owner' ? owner : undefined),
383
+ } as any;
384
+ const options = createAssociationItemChainContextPropertyOptions({
385
+ t: (value) => value,
386
+ title: 'Current item',
387
+ collectionAccessor: () => collection,
388
+ propertiesAccessor: (ctx) => ctx.item.value,
389
+ resolverPropertiesAccessor: () => ({ owner: { id: 7 } }),
390
+ parentCollectionAccessor: () => collection,
391
+ parentAccessors: {
392
+ parentPropertiesAccessor: () => ({ owner: { id: 8 } }),
393
+ parentItemMetaAccessor: () => undefined,
394
+ parentItemResolverAccessor: () => undefined,
395
+ },
396
+ });
397
+ const meta = await options.meta();
398
+ const params = await meta.buildVariablesParams({
399
+ item: { value: { owner: { id: 7 } }, parentItem: { value: { owner: { id: 8 } } } },
400
+ });
401
+
402
+ expect(params).toEqual({
403
+ parentItem: { value: { owner: { collection: 'people', dataSourceKey: 'main', filterByTk: 8 } } },
404
+ value: { owner: { collection: 'people', dataSourceKey: 'main', filterByTk: 7 } },
405
+ });
406
+ expect(options.resolveOnServer('value.owner.name')).toBe(true);
407
+ expect(options.resolveOnServer('parentItem.value.owner.name')).toBe(true);
408
+ });
409
+
246
410
  it('disables current item attributes for select-record popup item context', async () => {
247
411
  const parentCtx = new FlowContext();
248
412
  const departmentsCollection = {