@nocobase/client-v2 2.2.0-beta.16 → 2.2.0-beta.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/APIClient.d.ts +12 -0
- 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 +1 -0
- package/es/index.mjs +132 -132
- package/es/ui-operation/index.d.ts +15 -0
- package/es/ui-operation/ui-operation-codec.d.ts +10 -0
- package/lib/index.js +162 -162
- 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__/settings-center.test.tsx +270 -6
- package/src/acl/ACLProvider.tsx +2 -0
- package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
- 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 +1 -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/settings-center/AdminSettingsLayout.tsx +23 -2
- package/src/ui-operation/index.ts +33 -0
- package/src/ui-operation/ui-operation-codec.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.2.0-beta.
|
|
3
|
+
"version": "2.2.0-beta.18",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "es/index.mjs",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"@formily/antd-v5": "1.2.3",
|
|
28
28
|
"@formily/react": "^2.2.27",
|
|
29
29
|
"@formily/shared": "^2.2.27",
|
|
30
|
-
"@nocobase/evaluators": "2.2.0-beta.
|
|
31
|
-
"@nocobase/flow-engine": "2.2.0-beta.
|
|
32
|
-
"@nocobase/sdk": "2.2.0-beta.
|
|
33
|
-
"@nocobase/shared": "2.2.0-beta.
|
|
34
|
-
"@nocobase/utils": "2.2.0-beta.
|
|
30
|
+
"@nocobase/evaluators": "2.2.0-beta.18",
|
|
31
|
+
"@nocobase/flow-engine": "2.2.0-beta.18",
|
|
32
|
+
"@nocobase/sdk": "2.2.0-beta.18",
|
|
33
|
+
"@nocobase/shared": "2.2.0-beta.18",
|
|
34
|
+
"@nocobase/utils": "2.2.0-beta.18",
|
|
35
35
|
"ahooks": "^3.7.2",
|
|
36
36
|
"antd": "5.24.2",
|
|
37
37
|
"antd-style": "3.7.1",
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"react-i18next": "^11.15.1",
|
|
49
49
|
"react-router-dom": "^6.30.1"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "b74c962f7d35aa12012d908d16bd74fe6ac20a3d"
|
|
52
52
|
}
|
package/src/APIClient.ts
CHANGED
|
@@ -8,6 +8,68 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { APIClient as APIClientSDK, hasHeaderValue } from '@nocobase/sdk';
|
|
11
|
+
import type { NotificationInstance } from 'antd/es/notification/interface';
|
|
12
|
+
import type { AxiosRequestConfig } from 'axios';
|
|
13
|
+
import React from 'react';
|
|
14
|
+
|
|
15
|
+
type ResponseMessage = string | { message?: unknown };
|
|
16
|
+
|
|
17
|
+
interface APIClientApplication {
|
|
18
|
+
getName?: () => string | undefined;
|
|
19
|
+
context?: {
|
|
20
|
+
notification?: NotificationInstance;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface NotificationError {
|
|
25
|
+
config?: AxiosRequestConfig & {
|
|
26
|
+
skipNotify?: boolean | ((error: unknown) => boolean);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const notificationCache = new Map<string, number>();
|
|
31
|
+
|
|
32
|
+
function getMessageText(item: ResponseMessage): string {
|
|
33
|
+
if (typeof item === 'string') {
|
|
34
|
+
return item;
|
|
35
|
+
}
|
|
36
|
+
return typeof item?.message === 'string' ? item.message : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deduplicateMessages(messages: ResponseMessage[]): ResponseMessage[] {
|
|
40
|
+
if (notificationCache.size > 10) {
|
|
41
|
+
notificationCache.clear();
|
|
42
|
+
}
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
return messages.filter((item) => {
|
|
45
|
+
const message = getMessageText(item);
|
|
46
|
+
if (!message) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const lastTime = notificationCache.get(message);
|
|
50
|
+
if (lastTime && now - lastTime < 500) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
notificationCache.set(message, now);
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function notify(type: 'success' | 'error', messages: ResponseMessage[], instance?: NotificationInstance) {
|
|
59
|
+
if (!instance || messages.length === 0) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const filteredMessages = deduplicateMessages(messages);
|
|
63
|
+
if (filteredMessages.length === 0) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
instance[type]({
|
|
67
|
+
message: filteredMessages.map((item, index) => {
|
|
68
|
+
const message = getMessageText(item);
|
|
69
|
+
return React.createElement('div', { key: `${index}_${message}` }, message);
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
11
73
|
|
|
12
74
|
function offsetToTimeZone(offset: number) {
|
|
13
75
|
const hours = Math.floor(Math.abs(offset));
|
|
@@ -23,6 +85,12 @@ function getCurrentTimezone() {
|
|
|
23
85
|
}
|
|
24
86
|
|
|
25
87
|
export class APIClient extends APIClientSDK {
|
|
88
|
+
app?: APIClientApplication;
|
|
89
|
+
|
|
90
|
+
get notification() {
|
|
91
|
+
return this.app?.context?.notification;
|
|
92
|
+
}
|
|
93
|
+
|
|
26
94
|
getHostname() {
|
|
27
95
|
if (process.env.API_BASE_URL) {
|
|
28
96
|
try {
|
|
@@ -55,5 +123,29 @@ export class APIClient extends APIClientSDK {
|
|
|
55
123
|
return config;
|
|
56
124
|
});
|
|
57
125
|
super.interceptors();
|
|
126
|
+
this.useNotificationMiddleware();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
handleNotificationError(error: unknown) {
|
|
130
|
+
const notificationError = error as NotificationError;
|
|
131
|
+
const skipNotify = notificationError.config?.skipNotify;
|
|
132
|
+
if (skipNotify && (skipNotify === true || (typeof skipNotify === 'function' && skipNotify(error)))) {
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
const messages = this.toErrMessages(error);
|
|
136
|
+
if (Array.isArray(messages)) {
|
|
137
|
+
notify('error', messages, this.notification);
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
useNotificationMiddleware() {
|
|
143
|
+
this.axios.interceptors.response.use((response) => {
|
|
144
|
+
const messages = response.data?.messages;
|
|
145
|
+
if (Array.isArray(messages)) {
|
|
146
|
+
notify('success', messages, this.notification);
|
|
147
|
+
}
|
|
148
|
+
return response;
|
|
149
|
+
}, this.handleNotificationError.bind(this));
|
|
58
150
|
}
|
|
59
151
|
}
|
package/src/Application.tsx
CHANGED
|
@@ -45,7 +45,7 @@ export class Application extends BaseApplication<
|
|
|
45
45
|
public hasLoadError = false;
|
|
46
46
|
|
|
47
47
|
protected createApiClient(options: ApplicationOptions) {
|
|
48
|
-
|
|
48
|
+
const apiClient = new APIClient({
|
|
49
49
|
// Cross-origin API deployments rely on cookies for auth (e.g. permanent file
|
|
50
50
|
// URLs); trust is enforced server-side via the CORS origin whitelist and the
|
|
51
51
|
// CSRF middleware, not by omitting credentials.
|
|
@@ -53,6 +53,8 @@ export class Application extends BaseApplication<
|
|
|
53
53
|
...options.apiClient,
|
|
54
54
|
appName: options.name || getSubAppName(options.publicPath),
|
|
55
55
|
});
|
|
56
|
+
apiClient.app = this;
|
|
57
|
+
return apiClient;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
protected configureRuntimeAdapters() {
|
|
@@ -0,0 +1,58 @@
|
|
|
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 { APIClient } from '@nocobase/client-v2';
|
|
11
|
+
import type { NotificationInstance } from 'antd/es/notification/interface';
|
|
12
|
+
import MockAdapter from 'axios-mock-adapter';
|
|
13
|
+
import React from 'react';
|
|
14
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
15
|
+
|
|
16
|
+
function createAPIClient() {
|
|
17
|
+
const notification = {
|
|
18
|
+
error: vi.fn(),
|
|
19
|
+
success: vi.fn(),
|
|
20
|
+
} as unknown as NotificationInstance;
|
|
21
|
+
const apiClient = new APIClient();
|
|
22
|
+
apiClient.app = { context: { notification } };
|
|
23
|
+
const apiMock = new MockAdapter(apiClient.axios);
|
|
24
|
+
return { apiClient, apiMock, notification };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('APIClient response notifications', () => {
|
|
28
|
+
it('shows error messages returned by failed requests', async () => {
|
|
29
|
+
const { apiClient, apiMock, notification } = createAPIClient();
|
|
30
|
+
apiMock.onPost('/posts:create').reply(400, { errors: [{ message: 'Request rejected by workflow' }] });
|
|
31
|
+
|
|
32
|
+
await expect(apiClient.request({ url: '/posts:create', method: 'post' })).rejects.toBeDefined();
|
|
33
|
+
|
|
34
|
+
expect(notification.error).toHaveBeenCalledOnce();
|
|
35
|
+
const [{ message }] = vi.mocked(notification.error).mock.calls[0];
|
|
36
|
+
expect(renderToStaticMarkup(<>{message}</>)).toContain('Request rejected by workflow');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('does not show errors when skipNotify is enabled', async () => {
|
|
40
|
+
const { apiClient, apiMock, notification } = createAPIClient();
|
|
41
|
+
apiMock.onGet('/health').reply(500, { errors: [{ message: 'Health check failed' }] });
|
|
42
|
+
|
|
43
|
+
await expect(apiClient.request({ url: '/health', skipNotify: true })).rejects.toBeDefined();
|
|
44
|
+
|
|
45
|
+
expect(notification.error).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('shows messages returned by successful requests', async () => {
|
|
49
|
+
const { apiClient, apiMock, notification } = createAPIClient();
|
|
50
|
+
apiMock.onPost('/posts:create').reply(200, { messages: [{ message: 'Created successfully' }] });
|
|
51
|
+
|
|
52
|
+
await apiClient.request({ url: '/posts:create', method: 'post' });
|
|
53
|
+
|
|
54
|
+
expect(notification.success).toHaveBeenCalledOnce();
|
|
55
|
+
const [{ message }] = vi.mocked(notification.success).mock.calls[0];
|
|
56
|
+
expect(renderToStaticMarkup(<>{message}</>)).toContain('Created successfully');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -7,10 +7,17 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
ACLRolesCheckProvider,
|
|
12
|
+
createMockClient,
|
|
13
|
+
NocoBaseDesktopRouteType,
|
|
14
|
+
type NocoBaseDesktopRoute,
|
|
15
|
+
Plugin,
|
|
16
|
+
} from '@nocobase/client-v2';
|
|
17
|
+
import { act, render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
12
18
|
import React from 'react';
|
|
13
19
|
import { message } from 'antd';
|
|
20
|
+
import { vi } from 'vitest';
|
|
14
21
|
import { AdminSettingsLayoutModel as ClientV2AdminSettingsLayoutModel } from '../settings-center';
|
|
15
22
|
import { AdminSettingsLayoutModel as ClientV1AdminSettingsLayoutModel } from '../../../client/src/pm/AdminSettingsLayoutModel';
|
|
16
23
|
import { NocoBaseBuildInPlugin } from '../nocobase-buildin-plugin';
|
|
@@ -46,9 +53,15 @@ const mockAdminRuntime = (
|
|
|
46
53
|
snippets?: string[];
|
|
47
54
|
pmList?: any[];
|
|
48
55
|
systemSettings?: Record<string, any>;
|
|
56
|
+
desktopRoutes?: NocoBaseDesktopRoute[];
|
|
49
57
|
} = {},
|
|
50
58
|
) => {
|
|
51
|
-
const {
|
|
59
|
+
const {
|
|
60
|
+
snippets = ['pm', 'pm.system-settings.system-settings'],
|
|
61
|
+
pmList = [],
|
|
62
|
+
systemSettings = {},
|
|
63
|
+
desktopRoutes = [],
|
|
64
|
+
} = options;
|
|
52
65
|
|
|
53
66
|
app.dataSourceManager.getCollection = ((name: string, collectionName: string) => {
|
|
54
67
|
if (name === 'main' && collectionName === 'attachments') {
|
|
@@ -92,7 +105,7 @@ const mockAdminRuntime = (
|
|
|
92
105
|
},
|
|
93
106
|
});
|
|
94
107
|
app.apiMock.onGet('/desktopRoutes:listAccessible').reply(200, {
|
|
95
|
-
data:
|
|
108
|
+
data: desktopRoutes,
|
|
96
109
|
});
|
|
97
110
|
app.apiMock.onGet('systemSettings:get').reply(200, {
|
|
98
111
|
data: {
|
|
@@ -243,6 +256,39 @@ describe('settings center', () => {
|
|
|
243
256
|
expect(await screen.findByText('Demo plugin')).toBeInTheDocument();
|
|
244
257
|
});
|
|
245
258
|
|
|
259
|
+
it('should redirect to the first accessible page when the role cannot access settings', async () => {
|
|
260
|
+
const app = createMockClient({
|
|
261
|
+
plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
|
|
262
|
+
router: { type: 'memory', initialEntries: ['/admin/settings/system-settings'] },
|
|
263
|
+
});
|
|
264
|
+
mockAdminRuntime(app, {
|
|
265
|
+
snippets: ['!pm', '!pm.system-settings.system-settings'],
|
|
266
|
+
desktopRoutes: [
|
|
267
|
+
{
|
|
268
|
+
id: 1,
|
|
269
|
+
schemaUid: 'first-accessible-page',
|
|
270
|
+
title: 'First accessible page',
|
|
271
|
+
type: NocoBaseDesktopRouteType.flowPage,
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
id: 2,
|
|
275
|
+
schemaUid: 'second-accessible-page',
|
|
276
|
+
title: 'Second accessible page',
|
|
277
|
+
type: NocoBaseDesktopRouteType.flowPage,
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await renderApp(app);
|
|
283
|
+
await waitForGetRequests(app, ['/auth:check', 'roles:check', '/desktopRoutes:listAccessible']);
|
|
284
|
+
|
|
285
|
+
await waitFor(() => {
|
|
286
|
+
expect(app.router.state.location.pathname).toBe('/admin/first-accessible-page');
|
|
287
|
+
});
|
|
288
|
+
expect(app.router.state.historyAction).toBe('REPLACE');
|
|
289
|
+
expect(screen.queryByText('Current settings page is unavailable')).not.toBeInTheDocument();
|
|
290
|
+
});
|
|
291
|
+
|
|
246
292
|
it('should hide plugin-manager menu item when pm snippet is missing', async () => {
|
|
247
293
|
const app = createMockClient({
|
|
248
294
|
plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
|
|
@@ -264,12 +310,23 @@ describe('settings center', () => {
|
|
|
264
310
|
plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
|
|
265
311
|
router: { type: 'memory', initialEntries: ['/admin/settings/unknown'] },
|
|
266
312
|
});
|
|
267
|
-
mockAdminRuntime(app
|
|
313
|
+
mockAdminRuntime(app, {
|
|
314
|
+
snippets: ['!pm', '!pm.system-settings.system-settings'],
|
|
315
|
+
desktopRoutes: [
|
|
316
|
+
{
|
|
317
|
+
id: 1,
|
|
318
|
+
schemaUid: 'first-accessible-page',
|
|
319
|
+
title: 'First accessible page',
|
|
320
|
+
type: NocoBaseDesktopRouteType.flowPage,
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
});
|
|
268
324
|
|
|
269
325
|
await renderApp(app);
|
|
270
326
|
await waitForGetRequests(app, ['/auth:check', 'roles:check']);
|
|
271
327
|
|
|
272
328
|
expect(await screen.findByText('Current settings page is unavailable')).toBeInTheDocument();
|
|
329
|
+
expect(app.router.state.location.pathname).toBe('/admin/settings/unknown');
|
|
273
330
|
});
|
|
274
331
|
|
|
275
332
|
it('should allow direct access to hidden page without showing menu entry', async () => {
|
|
@@ -290,13 +347,23 @@ describe('settings center', () => {
|
|
|
290
347
|
plugins: [NocoBaseBuildInPlugin, TestAclPlugin, HiddenSettingsPlugin],
|
|
291
348
|
router: { type: 'memory', initialEntries: ['/admin/settings/hidden-demo'] },
|
|
292
349
|
});
|
|
293
|
-
mockAdminRuntime(app
|
|
350
|
+
mockAdminRuntime(app, {
|
|
351
|
+
desktopRoutes: [
|
|
352
|
+
{
|
|
353
|
+
id: 1,
|
|
354
|
+
schemaUid: 'first-accessible-page',
|
|
355
|
+
title: 'First accessible page',
|
|
356
|
+
type: NocoBaseDesktopRouteType.flowPage,
|
|
357
|
+
},
|
|
358
|
+
],
|
|
359
|
+
});
|
|
294
360
|
|
|
295
361
|
await renderApp(app);
|
|
296
362
|
await waitForGetRequests(app, ['/auth:check', 'roles:check']);
|
|
297
363
|
|
|
298
364
|
expect(await screen.findByText('Hidden settings page')).toBeInTheDocument();
|
|
299
365
|
expect(screen.queryByRole('menuitem', { name: 'Hidden demo' })).not.toBeInTheDocument();
|
|
366
|
+
expect(app.router.state.location.pathname).toBe('/admin/settings/hidden-demo');
|
|
300
367
|
});
|
|
301
368
|
|
|
302
369
|
it('should show route empty state when direct access page has no permission', async () => {
|
|
@@ -319,6 +386,14 @@ describe('settings center', () => {
|
|
|
319
386
|
});
|
|
320
387
|
mockAdminRuntime(app, {
|
|
321
388
|
snippets: ['pm', 'pm.system-settings.system-settings', '!pm.secure-demo.index'],
|
|
389
|
+
desktopRoutes: [
|
|
390
|
+
{
|
|
391
|
+
id: 1,
|
|
392
|
+
schemaUid: 'first-accessible-page',
|
|
393
|
+
title: 'First accessible page',
|
|
394
|
+
type: NocoBaseDesktopRouteType.flowPage,
|
|
395
|
+
},
|
|
396
|
+
],
|
|
322
397
|
});
|
|
323
398
|
|
|
324
399
|
await renderApp(app);
|
|
@@ -326,6 +401,195 @@ describe('settings center', () => {
|
|
|
326
401
|
|
|
327
402
|
expect(await screen.findByText('Current settings page is unavailable')).toBeInTheDocument();
|
|
328
403
|
expect(screen.queryByText('Secure settings page')).not.toBeInTheDocument();
|
|
404
|
+
expect(app.router.state.location.pathname).toBe('/admin/settings/secure-demo');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should redirect a denied settings tab to the first accessible tab', async () => {
|
|
408
|
+
const renderDeniedTab = vi.fn(() => <div>Denied tab content</div>);
|
|
409
|
+
|
|
410
|
+
class ProtectedSettingsTabsPlugin extends Plugin {
|
|
411
|
+
async load() {
|
|
412
|
+
this.pluginSettingsManager.addMenuItem({ key: 'protected-tabs', title: 'Protected tabs' });
|
|
413
|
+
this.pluginSettingsManager.addPageTabItem({
|
|
414
|
+
menuKey: 'protected-tabs',
|
|
415
|
+
key: 'a-second',
|
|
416
|
+
title: 'Second accessible tab',
|
|
417
|
+
aclSnippet: 'pm.protected-tabs.second',
|
|
418
|
+
sort: 20,
|
|
419
|
+
Component: () => <div>Second accessible tab content</div>,
|
|
420
|
+
});
|
|
421
|
+
this.pluginSettingsManager.addPageTabItem({
|
|
422
|
+
menuKey: 'protected-tabs',
|
|
423
|
+
key: 'z-first',
|
|
424
|
+
title: 'First accessible tab',
|
|
425
|
+
aclSnippet: 'pm.protected-tabs.first',
|
|
426
|
+
sort: 10,
|
|
427
|
+
Component: () => <div>First accessible tab content</div>,
|
|
428
|
+
});
|
|
429
|
+
this.pluginSettingsManager.addPageTabItem({
|
|
430
|
+
menuKey: 'protected-tabs',
|
|
431
|
+
key: 'denied',
|
|
432
|
+
title: 'Denied tab',
|
|
433
|
+
aclSnippet: 'pm.protected-tabs.denied',
|
|
434
|
+
sort: 30,
|
|
435
|
+
Component: renderDeniedTab,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const app = createMockClient({
|
|
441
|
+
plugins: [NocoBaseBuildInPlugin, TestAclPlugin, ProtectedSettingsTabsPlugin],
|
|
442
|
+
router: { type: 'memory', initialEntries: ['/admin/settings/protected-tabs/denied'] },
|
|
443
|
+
});
|
|
444
|
+
mockAdminRuntime(app, {
|
|
445
|
+
snippets: ['pm', '!pm.protected-tabs.denied'],
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
await renderApp(app);
|
|
449
|
+
await waitForGetRequests(app, ['/auth:check', 'roles:check']);
|
|
450
|
+
|
|
451
|
+
await waitFor(() => {
|
|
452
|
+
expect(app.router.router.state.location.pathname).toBe('/admin/settings/protected-tabs/z-first');
|
|
453
|
+
});
|
|
454
|
+
expect(app.router.router.state.historyAction).toBe('REPLACE');
|
|
455
|
+
expect(await screen.findByText('First accessible tab content')).toBeInTheDocument();
|
|
456
|
+
expect(screen.queryByText('Denied tab content')).not.toBeInTheDocument();
|
|
457
|
+
expect(renderDeniedTab).not.toHaveBeenCalled();
|
|
458
|
+
|
|
459
|
+
await act(async () => {
|
|
460
|
+
await app.router.router.navigate('/admin/settings/protected-tabs/a-second');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
expect(await screen.findByText('Second accessible tab content')).toBeInTheDocument();
|
|
464
|
+
expect(app.router.router.state.location.pathname).toBe('/admin/settings/protected-tabs/a-second');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('should skip an accessible dynamic tab when its route params cannot be resolved', async () => {
|
|
468
|
+
class DynamicSettingsTabsPlugin extends Plugin {
|
|
469
|
+
async load() {
|
|
470
|
+
this.pluginSettingsManager.addMenuItem({ key: 'dynamic-tabs', title: 'Dynamic tabs' });
|
|
471
|
+
this.pluginSettingsManager.addPageTabItem({
|
|
472
|
+
menuKey: 'dynamic-tabs',
|
|
473
|
+
key: ':name',
|
|
474
|
+
title: 'Dynamic tab',
|
|
475
|
+
aclSnippet: 'pm.dynamic-tabs.dynamic',
|
|
476
|
+
sort: 1,
|
|
477
|
+
Component: () => <div>Dynamic tab content</div>,
|
|
478
|
+
});
|
|
479
|
+
this.pluginSettingsManager.addPageTabItem({
|
|
480
|
+
menuKey: 'dynamic-tabs',
|
|
481
|
+
key: 'fallback',
|
|
482
|
+
title: 'Static fallback tab',
|
|
483
|
+
aclSnippet: 'pm.dynamic-tabs.fallback',
|
|
484
|
+
sort: 2,
|
|
485
|
+
Component: () => <div>Static fallback tab content</div>,
|
|
486
|
+
});
|
|
487
|
+
this.pluginSettingsManager.addPageTabItem({
|
|
488
|
+
menuKey: 'dynamic-tabs',
|
|
489
|
+
key: 'denied',
|
|
490
|
+
title: 'Denied tab',
|
|
491
|
+
aclSnippet: 'pm.dynamic-tabs.denied',
|
|
492
|
+
sort: 3,
|
|
493
|
+
Component: () => <div>Denied dynamic tab content</div>,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const app = createMockClient({
|
|
499
|
+
plugins: [NocoBaseBuildInPlugin, TestAclPlugin, DynamicSettingsTabsPlugin],
|
|
500
|
+
router: { type: 'memory', initialEntries: ['/admin/settings/dynamic-tabs/denied'] },
|
|
501
|
+
});
|
|
502
|
+
mockAdminRuntime(app, {
|
|
503
|
+
snippets: ['pm', '!pm.dynamic-tabs.denied'],
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
await renderApp(app);
|
|
507
|
+
await waitForGetRequests(app, ['/auth:check', 'roles:check']);
|
|
508
|
+
|
|
509
|
+
await waitFor(() => {
|
|
510
|
+
expect(app.router.router.state.location.pathname).toBe('/admin/settings/dynamic-tabs/fallback');
|
|
511
|
+
});
|
|
512
|
+
expect(await screen.findByText('Static fallback tab content')).toBeInTheDocument();
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('should preserve resolved route params when redirecting between dynamic tabs', async () => {
|
|
516
|
+
class DynamicSiblingTabsPlugin extends Plugin {
|
|
517
|
+
async load() {
|
|
518
|
+
this.pluginSettingsManager.addMenuItem({ key: 'dynamic-siblings', title: 'Dynamic sibling tabs' });
|
|
519
|
+
this.pluginSettingsManager.addPageTabItem({
|
|
520
|
+
menuKey: 'dynamic-siblings',
|
|
521
|
+
key: ':name/channels',
|
|
522
|
+
title: 'Dynamic channels tab',
|
|
523
|
+
aclSnippet: 'pm.dynamic-siblings.channels',
|
|
524
|
+
sort: 1,
|
|
525
|
+
Component: () => <div>Dynamic channels tab content</div>,
|
|
526
|
+
});
|
|
527
|
+
this.pluginSettingsManager.addPageTabItem({
|
|
528
|
+
menuKey: 'dynamic-siblings',
|
|
529
|
+
key: ':name/logs',
|
|
530
|
+
title: 'Dynamic logs tab',
|
|
531
|
+
aclSnippet: 'pm.dynamic-siblings.logs',
|
|
532
|
+
sort: 2,
|
|
533
|
+
Component: () => <div>Dynamic logs tab content</div>,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const app = createMockClient({
|
|
539
|
+
plugins: [NocoBaseBuildInPlugin, TestAclPlugin, DynamicSiblingTabsPlugin],
|
|
540
|
+
router: { type: 'memory', initialEntries: ['/admin/settings/dynamic-siblings/email:primary/logs'] },
|
|
541
|
+
});
|
|
542
|
+
mockAdminRuntime(app, {
|
|
543
|
+
snippets: ['pm', '!pm.dynamic-siblings.logs'],
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
await renderApp(app);
|
|
547
|
+
await waitForGetRequests(app, ['/auth:check', 'roles:check']);
|
|
548
|
+
|
|
549
|
+
await waitFor(() => {
|
|
550
|
+
expect(app.router.router.state.location.pathname).toBe('/admin/settings/dynamic-siblings/email:primary/channels');
|
|
551
|
+
});
|
|
552
|
+
expect(await screen.findByText('Dynamic channels tab content')).toBeInTheDocument();
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('should resolve hyphenated route param names when redirecting between dynamic tabs', async () => {
|
|
556
|
+
class HyphenatedParamTabsPlugin extends Plugin {
|
|
557
|
+
async load() {
|
|
558
|
+
this.pluginSettingsManager.addMenuItem({ key: 'hyphenated-params', title: 'Hyphenated param tabs' });
|
|
559
|
+
this.pluginSettingsManager.addPageTabItem({
|
|
560
|
+
menuKey: 'hyphenated-params',
|
|
561
|
+
key: ':data-source/channels',
|
|
562
|
+
title: 'Hyphenated channels tab',
|
|
563
|
+
aclSnippet: 'pm.hyphenated-params.channels',
|
|
564
|
+
sort: 1,
|
|
565
|
+
Component: () => <div>Hyphenated channels tab content</div>,
|
|
566
|
+
});
|
|
567
|
+
this.pluginSettingsManager.addPageTabItem({
|
|
568
|
+
menuKey: 'hyphenated-params',
|
|
569
|
+
key: ':data-source/logs',
|
|
570
|
+
title: 'Hyphenated logs tab',
|
|
571
|
+
aclSnippet: 'pm.hyphenated-params.logs',
|
|
572
|
+
sort: 2,
|
|
573
|
+
Component: () => <div>Hyphenated logs tab content</div>,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const app = createMockClient({
|
|
579
|
+
plugins: [NocoBaseBuildInPlugin, TestAclPlugin, HyphenatedParamTabsPlugin],
|
|
580
|
+
router: { type: 'memory', initialEntries: ['/admin/settings/hyphenated-params/email/logs'] },
|
|
581
|
+
});
|
|
582
|
+
mockAdminRuntime(app, {
|
|
583
|
+
snippets: ['pm', '!pm.hyphenated-params.logs'],
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
await renderApp(app);
|
|
587
|
+
await waitForGetRequests(app, ['/auth:check', 'roles:check']);
|
|
588
|
+
|
|
589
|
+
await waitFor(() => {
|
|
590
|
+
expect(app.router.router.state.location.pathname).toBe('/admin/settings/hyphenated-params/email/channels');
|
|
591
|
+
});
|
|
592
|
+
expect(await screen.findByText('Hyphenated channels tab content')).toBeInTheDocument();
|
|
329
593
|
});
|
|
330
594
|
|
|
331
595
|
it('should keep menu visible when menu acl is denied but child page is visible', async () => {
|
package/src/acl/ACLProvider.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { observable } from '@formily/reactive';
|
|
|
11
11
|
import React, { useRef } from 'react';
|
|
12
12
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type FC } from 'react';
|
|
13
13
|
import { useLocation } from 'react-router-dom';
|
|
14
|
+
import { writeFlowSettingsPreference } from '../flow/admin-shell/admin-layout/flowSettingsPreference';
|
|
14
15
|
import { useApp } from '../hooks/useApp';
|
|
15
16
|
import { createAclSnippetAllow } from './createAclSnippetAllow';
|
|
16
17
|
|
|
@@ -118,6 +119,7 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
if (!createAclSnippetAllow(nextData?.snippets || [], !!nextData?.allowAll)('ui.*')) {
|
|
122
|
+
writeFlowSettingsPreference(false);
|
|
121
123
|
await app.flowEngine.flowSettings.disable();
|
|
122
124
|
}
|
|
123
125
|
} catch (error) {
|
|
@@ -0,0 +1,92 @@
|
|
|
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 { render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
13
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import {
|
|
15
|
+
FLOW_SETTINGS_PREFERENCE_STORAGE_KEY,
|
|
16
|
+
writeFlowSettingsPreference,
|
|
17
|
+
} from '../../flow/admin-shell/admin-layout/flowSettingsPreference';
|
|
18
|
+
import { ACLRolesCheckProvider } from '../ACLProvider';
|
|
19
|
+
|
|
20
|
+
const mocks = vi.hoisted(() => ({
|
|
21
|
+
app: undefined as unknown,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('../../hooks/useApp', () => ({
|
|
25
|
+
useApp: () => mocks.app,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
describe('ACLRolesCheckProvider', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
window.localStorage.removeItem(FLOW_SETTINGS_PREFERENCE_STORAGE_KEY);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('exits UI editing mode when the current role cannot configure pages', async () => {
|
|
34
|
+
const disable = vi.fn(async () => undefined);
|
|
35
|
+
const setRole = vi.fn();
|
|
36
|
+
const aclContext: Record<string, unknown> = {};
|
|
37
|
+
const appContext = {
|
|
38
|
+
acl: undefined,
|
|
39
|
+
defineProperty(name: string, descriptor: PropertyDescriptor) {
|
|
40
|
+
Object.defineProperty(this, name, descriptor);
|
|
41
|
+
},
|
|
42
|
+
...aclContext,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
mocks.app = {
|
|
46
|
+
apiClient: {
|
|
47
|
+
auth: {
|
|
48
|
+
role: 'member',
|
|
49
|
+
setRole,
|
|
50
|
+
},
|
|
51
|
+
request: vi.fn(async () => ({
|
|
52
|
+
data: {
|
|
53
|
+
data: {
|
|
54
|
+
role: 'viewer',
|
|
55
|
+
snippets: [],
|
|
56
|
+
},
|
|
57
|
+
meta: {},
|
|
58
|
+
},
|
|
59
|
+
})),
|
|
60
|
+
},
|
|
61
|
+
context: appContext,
|
|
62
|
+
flowEngine: {
|
|
63
|
+
flowSettings: {
|
|
64
|
+
disable,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
pluginSettingsManager: {
|
|
68
|
+
setAclSnippets: vi.fn(),
|
|
69
|
+
},
|
|
70
|
+
renderComponent: () => <div>Loading</div>,
|
|
71
|
+
router: {
|
|
72
|
+
isSkippedAuthCheckRoute: () => false,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
writeFlowSettingsPreference(true);
|
|
76
|
+
|
|
77
|
+
render(
|
|
78
|
+
<MemoryRouter initialEntries={['/admin']}>
|
|
79
|
+
<ACLRolesCheckProvider>
|
|
80
|
+
<div>Page content</div>
|
|
81
|
+
</ACLRolesCheckProvider>
|
|
82
|
+
</MemoryRouter>,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(await screen.findByText('Page content')).toBeInTheDocument();
|
|
86
|
+
await waitFor(() => {
|
|
87
|
+
expect(disable).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
expect(window.localStorage.getItem(FLOW_SETTINGS_PREFERENCE_STORAGE_KEY)).toBe('0');
|
|
90
|
+
expect(setRole).toHaveBeenCalledWith('viewer');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -130,8 +130,10 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
|
|
|
130
130
|
[operatorOptions, operator],
|
|
131
131
|
);
|
|
132
132
|
|
|
133
|
-
const handleFieldChange = (next
|
|
134
|
-
|
|
133
|
+
const handleFieldChange = (next?: (string | number)[]) => {
|
|
134
|
+
// rc-cascader emits `undefined` when its clear icon is clicked in single-select mode, despite its public type
|
|
135
|
+
// declaring an array. Treat that runtime value as an empty path so clearing the field also resets the row.
|
|
136
|
+
const path = (next ?? []).map(String);
|
|
135
137
|
props.value.path = path.join('.');
|
|
136
138
|
const leaf = findOptionByPath(options, path);
|
|
137
139
|
props.value.operator = leaf?.operators?.[0]?.value || '';
|