@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.
- package/es/APIClient.d.ts +12 -0
- package/es/acl/aclCheckReadiness.d.ts +11 -0
- package/es/authRedirect.d.ts +6 -2
- package/es/flow/actions/linkageRules.d.ts +24 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
- package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
- package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
- package/es/flow/components/field-value-variable/index.d.ts +11 -0
- package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
- package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
- package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
- package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
- package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
- package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
- package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
- package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
- package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
- package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
- package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
- package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
- package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
- package/es/flow/models/blocks/table/utils.d.ts +1 -0
- package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
- package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
- package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.d.ts +2 -1
- package/es/index.mjs +140 -140
- package/es/nocobase-buildin-plugin/index.d.ts +1 -0
- package/es/settings-app/SettingsGroupNav.d.ts +3 -2
- package/es/settings-app/SettingsSearch.d.ts +1 -1
- package/es/ui-operation/index.d.ts +15 -0
- package/es/ui-operation/ui-operation-codec.d.ts +10 -0
- package/lib/index.js +145 -145
- package/package.json +7 -7
- package/src/APIClient.ts +92 -0
- package/src/Application.tsx +3 -1
- package/src/__tests__/APIClient.test.tsx +58 -0
- package/src/__tests__/app.test.tsx +122 -3
- package/src/__tests__/authRedirect.test.ts +17 -0
- package/src/__tests__/browserChecker.test.ts +81 -33
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
- package/src/__tests__/settings-center.test.tsx +312 -11
- package/src/__tests__/settings-layout-root.test.tsx +346 -6
- package/src/__tests__/settings-runtime-paths.test.ts +1 -1
- package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
- package/src/__tests__/settings-shell.test.tsx +62 -1
- package/src/acl/ACLProvider.tsx +34 -3
- package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
- package/src/acl/aclCheckReadiness.ts +44 -0
- package/src/authRedirect.ts +13 -5
- package/src/components/AppComponents.tsx +16 -7
- package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
- package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
- package/src/components/form/table/Table.tsx +81 -6
- package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
- package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
- package/src/flow/actions/linkageRules.tsx +33 -10
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
- package/src/flow/components/FieldAssignValueInput.tsx +38 -621
- package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
- package/src/flow/components/field-value-variable/dateValue.ts +223 -0
- package/src/flow/components/field-value-variable/index.ts +12 -0
- package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
- package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
- package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
- package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
- package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
- package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
- package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
- package/src/flow/models/base/CollectionBlockModel.tsx +32 -1
- package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
- package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
- package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
- package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
- package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
- package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
- package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
- package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
- package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
- package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
- package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
- package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
- package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
- package/src/flow/models/blocks/table/utils.ts +7 -0
- package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
- package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
- package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
- package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
- package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
- package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
- package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
- package/src/index.ts +2 -0
- package/src/layout-manager/LayoutContentRoute.tsx +2 -1
- package/src/layout-manager/LayoutRoute.tsx +2 -1
- package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
- package/src/nocobase-buildin-plugin/index.tsx +14 -1
- package/src/settings-app/SettingsGroupNav.tsx +11 -11
- package/src/settings-app/SettingsSearch.tsx +115 -10
- package/src/settings-app/SettingsShell.tsx +23 -6
- package/src/settings-app/runtimePaths.ts +0 -1
- package/src/settings-app/settingsTheme.ts +82 -14
- package/src/settings-center/AdminSettingsLayout.tsx +132 -107
- package/src/settings-center/__tests__/groups.test.ts +25 -0
- package/src/settings-center/groups.ts +1 -1
- package/src/ui-operation/index.ts +33 -0
- 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('>', '>');
|
|
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 {
|
|
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, {
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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 {
|
|
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 = {
|