@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.12",
|
|
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": "3.0.0-alpha.
|
|
31
|
-
"@nocobase/flow-engine": "3.0.0-alpha.
|
|
32
|
-
"@nocobase/sdk": "3.0.0-alpha.
|
|
33
|
-
"@nocobase/shared": "3.0.0-alpha.
|
|
34
|
-
"@nocobase/utils": "3.0.0-alpha.
|
|
30
|
+
"@nocobase/evaluators": "3.0.0-alpha.12",
|
|
31
|
+
"@nocobase/flow-engine": "3.0.0-alpha.12",
|
|
32
|
+
"@nocobase/sdk": "3.0.0-alpha.12",
|
|
33
|
+
"@nocobase/shared": "3.0.0-alpha.12",
|
|
34
|
+
"@nocobase/utils": "3.0.0-alpha.12",
|
|
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": "2ba035885741a9977d76bbabf8f2caf44b199139"
|
|
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
|
+
});
|
|
@@ -379,7 +379,11 @@ describe('app', () => {
|
|
|
379
379
|
expect(screen.getByText('maintaining dialog message')).toBeInTheDocument();
|
|
380
380
|
});
|
|
381
381
|
|
|
382
|
-
it(
|
|
382
|
+
it.each([
|
|
383
|
+
['pm.enable', 'Enabling plugin'],
|
|
384
|
+
['pm.disable', 'Disabling plugin'],
|
|
385
|
+
['future.command', 'future.command'],
|
|
386
|
+
])('should keep current content behind the default dialog while app commanding: %s', async (commandName, title) => {
|
|
383
387
|
const CurrentPage = () => {
|
|
384
388
|
const [count, setCount] = React.useState(0);
|
|
385
389
|
return <button onClick={() => setCount((value) => value + 1)}>Current page count: {count}</button>;
|
|
@@ -398,12 +402,127 @@ describe('app', () => {
|
|
|
398
402
|
act(() => {
|
|
399
403
|
app.maintained = true;
|
|
400
404
|
app.maintaining = true;
|
|
401
|
-
app.error = { code: 'APP_COMMANDING', command: { name:
|
|
405
|
+
app.error = { code: 'APP_COMMANDING', command: { name: commandName }, message: 'starting sub applications...' };
|
|
402
406
|
});
|
|
403
407
|
|
|
404
408
|
expect(screen.getByRole('button', { name: 'Current page count: 1' })).toBeInTheDocument();
|
|
405
409
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
406
|
-
expect(screen.getByText(
|
|
410
|
+
expect(screen.getByText(title)).toBeInTheDocument();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('should keep current content without a dialog while app upgrading', async () => {
|
|
414
|
+
const CurrentPage = () => <div>Current page</div>;
|
|
415
|
+
|
|
416
|
+
class PluginHelloClient extends Plugin {
|
|
417
|
+
async load() {
|
|
418
|
+
this.router.add('root', { path: '/', Component: CurrentPage });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const app = createMockClient({ plugins: [PluginHelloClient] });
|
|
423
|
+
await renderApp(app);
|
|
424
|
+
|
|
425
|
+
act(() => {
|
|
426
|
+
app.maintained = true;
|
|
427
|
+
app.maintaining = true;
|
|
428
|
+
app.error = Object.assign(new Error('Loading data sources...'), {
|
|
429
|
+
code: 'APP_COMMANDING',
|
|
430
|
+
command: { name: 'upgrade' },
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
expect(screen.getByText('Current page')).toBeInTheDocument();
|
|
435
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it.each([
|
|
439
|
+
['upgrade', 'App upgrading'],
|
|
440
|
+
['pm.enable', 'Enabling plugin'],
|
|
441
|
+
['pm.disable', 'Disabling plugin'],
|
|
442
|
+
['future.command', 'future.command'],
|
|
443
|
+
])('should not show Try again for an app commanding error state: %s', async (commandName, title) => {
|
|
444
|
+
class PluginHelloClient extends Plugin {}
|
|
445
|
+
const app = createMockClient({ plugins: [PluginHelloClient] });
|
|
446
|
+
app.error = Object.assign(new Error('Loading data sources...'), {
|
|
447
|
+
code: 'APP_COMMANDING',
|
|
448
|
+
command: { name: commandName },
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
await renderApp(app);
|
|
452
|
+
|
|
453
|
+
expect(screen.getByText(title)).toBeInTheDocument();
|
|
454
|
+
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('should clear upgrade feedback after an existing page error', async () => {
|
|
458
|
+
const BrokenPage = () => {
|
|
459
|
+
throw new Error('page error');
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
class PluginHelloClient extends Plugin {
|
|
463
|
+
async load() {
|
|
464
|
+
this.router.add('root', { path: '/', Component: BrokenPage });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const app = createMockClient({
|
|
469
|
+
plugins: [PluginHelloClient],
|
|
470
|
+
ws: { url: 'ws://localhost:3000/ws' },
|
|
471
|
+
});
|
|
472
|
+
app.maintained = true;
|
|
473
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
474
|
+
try {
|
|
475
|
+
await renderApp(app);
|
|
476
|
+
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
|
|
477
|
+
|
|
478
|
+
act(() => {
|
|
479
|
+
app.ws.emit('message', {
|
|
480
|
+
type: 'maintaining',
|
|
481
|
+
payload: {
|
|
482
|
+
code: 'APP_COMMANDING',
|
|
483
|
+
command: { name: 'upgrade' },
|
|
484
|
+
message: 'Loading data sources...',
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
expect(screen.getByText('App upgrading')).toBeInTheDocument();
|
|
490
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
491
|
+
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
|
|
492
|
+
} finally {
|
|
493
|
+
errorSpy.mockRestore();
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('should keep a custom maintaining dialog while app upgrading', async () => {
|
|
498
|
+
class PluginHelloClient extends Plugin {}
|
|
499
|
+
const app = createMockClient({ plugins: [PluginHelloClient] });
|
|
500
|
+
app.addComponents({
|
|
501
|
+
CustomUpgradeStatus: () => <div>Custom upgrade status</div>,
|
|
502
|
+
});
|
|
503
|
+
app.maintained = true;
|
|
504
|
+
app.maintaining = true;
|
|
505
|
+
app.error = Object.assign(new Error('Loading data sources...'), {
|
|
506
|
+
code: 'APP_COMMANDING',
|
|
507
|
+
command: {
|
|
508
|
+
name: 'upgrade',
|
|
509
|
+
components: { maintainingDialog: 'CustomUpgradeStatus' },
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
await renderApp(app);
|
|
514
|
+
|
|
515
|
+
expect(screen.getByText('Custom upgrade status')).toBeInTheDocument();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should keep Try again for recoverable app errors', async () => {
|
|
519
|
+
class PluginHelloClient extends Plugin {}
|
|
520
|
+
const app = createMockClient({ plugins: [PluginHelloClient] });
|
|
521
|
+
app.error = Object.assign(new Error('load error'), { code: 'LOAD_ERROR' });
|
|
522
|
+
|
|
523
|
+
await renderApp(app);
|
|
524
|
+
|
|
525
|
+
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
|
|
407
526
|
});
|
|
408
527
|
|
|
409
528
|
it('should handle long loading state gracefully', async () => {
|
|
@@ -347,6 +347,23 @@ describe('auth redirect helpers', () => {
|
|
|
347
347
|
);
|
|
348
348
|
});
|
|
349
349
|
|
|
350
|
+
it('should derive a scoped Portal signin href under the current sub-app basename', () => {
|
|
351
|
+
const app = {
|
|
352
|
+
getPublicPath: () => '/nocobase/v2/',
|
|
353
|
+
router: {
|
|
354
|
+
getBasename: () => '/nocobase/v2/apps/test-app/',
|
|
355
|
+
},
|
|
356
|
+
} satisfies Parameters<typeof buildV2SigninHref>[0];
|
|
357
|
+
|
|
358
|
+
expect(
|
|
359
|
+
buildV2SigninHref(app, '/nocobase/v2/apps/test-app/customer/orders', {
|
|
360
|
+
signInRoutePath: '/customer/signin',
|
|
361
|
+
}),
|
|
362
|
+
).toBe(
|
|
363
|
+
'/nocobase/v2/apps/test-app/customer/signin?redirect=%2Fnocobase%2Fv2%2Fapps%2Ftest-app%2Fcustomer%2Forders',
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
350
367
|
it('should redirect to sub-app signin with window.location.replace', () => {
|
|
351
368
|
const replace = vi.fn();
|
|
352
369
|
Object.defineProperty(globalThis.window, 'location', {
|
|
@@ -136,6 +136,30 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
|
|
|
136
136
|
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/');
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
+
it('redirects app root to modern entry for modern-only', () => {
|
|
140
|
+
const replace = executeBrowserChecker(scriptPath, {
|
|
141
|
+
pathname: '/',
|
|
142
|
+
publicPath: '/',
|
|
143
|
+
modernClientPrefix: 'v',
|
|
144
|
+
appClientEntryMode: 'modern-only',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it.each(['/', '/index.html'])('redirects app root entry %s to Settings for settings-default', (pathname) => {
|
|
151
|
+
const replace = executeBrowserChecker(scriptPath, {
|
|
152
|
+
pathname,
|
|
153
|
+
publicPath: '/',
|
|
154
|
+
modernClientPrefix: 'v',
|
|
155
|
+
appClientEntryMode: 'settings-default',
|
|
156
|
+
search: '?from=entry',
|
|
157
|
+
hash: '#panel',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/settings/?from=entry#panel');
|
|
161
|
+
});
|
|
162
|
+
|
|
139
163
|
it('does not redirect legacy deep links for modern-default', () => {
|
|
140
164
|
const replace = executeBrowserChecker(scriptPath, {
|
|
141
165
|
pathname: '/admin',
|
|
@@ -147,6 +171,17 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
|
|
|
147
171
|
expect(replace).not.toHaveBeenCalled();
|
|
148
172
|
});
|
|
149
173
|
|
|
174
|
+
it('does not redirect legacy deep links for settings-default', () => {
|
|
175
|
+
const replace = executeBrowserChecker(scriptPath, {
|
|
176
|
+
pathname: '/admin',
|
|
177
|
+
publicPath: '/',
|
|
178
|
+
modernClientPrefix: 'v',
|
|
179
|
+
appClientEntryMode: 'settings-default',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(replace).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
150
185
|
it('rewrites legacy document paths for modern-only', () => {
|
|
151
186
|
const replace = executeBrowserChecker(scriptPath, {
|
|
152
187
|
pathname: '/admin/settings/workflow',
|
|
@@ -169,6 +204,19 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
|
|
|
169
204
|
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/');
|
|
170
205
|
});
|
|
171
206
|
|
|
207
|
+
it('redirects a sub-path app root directly to Settings for settings-default', () => {
|
|
208
|
+
const replace = executeBrowserChecker(scriptPath, {
|
|
209
|
+
pathname: '/nocobase/',
|
|
210
|
+
publicPath: '/nocobase/',
|
|
211
|
+
modernClientPrefix: 'v',
|
|
212
|
+
appClientEntryMode: 'settings-default',
|
|
213
|
+
search: '?from=entry',
|
|
214
|
+
hash: '#panel',
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/settings/?from=entry#panel');
|
|
218
|
+
});
|
|
219
|
+
|
|
172
220
|
it('rewrites sub-app legacy deep links for modern-only without collapsing the sub-app segment', () => {
|
|
173
221
|
const replace = executeBrowserChecker(scriptPath, {
|
|
174
222
|
pathname: '/nocobase/apps/a_31itq60q4kg/admin/',
|
|
@@ -182,32 +230,36 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
|
|
|
182
230
|
}
|
|
183
231
|
|
|
184
232
|
if (scriptPath.includes('/app/client-v2/public/')) {
|
|
185
|
-
it.each([
|
|
233
|
+
it.each([
|
|
234
|
+
['/v', 'http://c.local.nocobase.com/v/'],
|
|
235
|
+
['/v/', undefined],
|
|
236
|
+
])('normalizes the modern client root %s without redirecting to Settings', (pathname, expectedRedirect) => {
|
|
186
237
|
const replace = executeBrowserChecker(scriptPath, {
|
|
187
238
|
pathname,
|
|
188
239
|
publicPath: '/v/',
|
|
189
240
|
modernClientPrefix: 'v',
|
|
190
241
|
});
|
|
191
242
|
|
|
192
|
-
|
|
193
|
-
|
|
243
|
+
if (expectedRedirect) {
|
|
244
|
+
expect(replace).toHaveBeenCalledOnce();
|
|
245
|
+
expect(replace).toHaveBeenCalledWith(expectedRedirect);
|
|
246
|
+
} else {
|
|
247
|
+
expect(replace).not.toHaveBeenCalled();
|
|
248
|
+
}
|
|
194
249
|
});
|
|
195
250
|
|
|
196
|
-
it.each([
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
publicPath: '/v/',
|
|
205
|
-
modernClientPrefix: 'v',
|
|
206
|
-
});
|
|
251
|
+
it.each(['/v/apps/demo', '/v/apps/demo/', '/v/_app/demo', '/v/_app/demo/'])(
|
|
252
|
+
'lets the scoped modern client handle its root %s',
|
|
253
|
+
(pathname) => {
|
|
254
|
+
const replace = executeBrowserChecker(scriptPath, {
|
|
255
|
+
pathname,
|
|
256
|
+
publicPath: '/v/',
|
|
257
|
+
modernClientPrefix: 'v',
|
|
258
|
+
});
|
|
207
259
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
260
|
+
expect(replace).not.toHaveBeenCalled();
|
|
261
|
+
},
|
|
262
|
+
);
|
|
211
263
|
|
|
212
264
|
it.each([
|
|
213
265
|
['/settings/apps/demo', '/', '/settings/apps/demo/', 'v'],
|
|
@@ -233,23 +285,19 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
|
|
|
233
285
|
});
|
|
234
286
|
|
|
235
287
|
it.each([
|
|
236
|
-
['/tenant/apps/root/modern/apps/demo/', '/tenant/apps/root/modern/'
|
|
237
|
-
['/tenant/_app/root/modern/_app/demo/', '/tenant/_app/root/modern/'
|
|
238
|
-
])(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
hash: '#panel',
|
|
247
|
-
});
|
|
288
|
+
['/tenant/apps/root/modern/apps/demo/', '/tenant/apps/root/modern/'],
|
|
289
|
+
['/tenant/_app/root/modern/_app/demo/', '/tenant/_app/root/modern/'],
|
|
290
|
+
])('lets the modern client handle %s under a nested public path', (pathname, publicPath) => {
|
|
291
|
+
const replace = executeBrowserChecker(scriptPath, {
|
|
292
|
+
pathname,
|
|
293
|
+
publicPath,
|
|
294
|
+
modernClientPrefix: 'modern',
|
|
295
|
+
search: '?tab=overview',
|
|
296
|
+
hash: '#panel',
|
|
297
|
+
});
|
|
248
298
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
},
|
|
252
|
-
);
|
|
299
|
+
expect(replace).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
253
301
|
|
|
254
302
|
it.each([
|
|
255
303
|
['/v/admin', '/v/'],
|
|
@@ -72,6 +72,21 @@ class TestRootPortalPlugin extends Plugin {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
class TestScopedPortalPlugin extends Plugin {
|
|
76
|
+
async load() {
|
|
77
|
+
this.router.add('customer-portal', {
|
|
78
|
+
path: '/customer/*',
|
|
79
|
+
authCheck: true,
|
|
80
|
+
Component: () => <div>customer portal</div>,
|
|
81
|
+
});
|
|
82
|
+
this.router.add('customer-portal-signin', {
|
|
83
|
+
path: '/customer/signin',
|
|
84
|
+
skipAuthCheck: true,
|
|
85
|
+
Component: () => <div>customer portal signin</div>,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
describe('nocobase buildin plugin auth redirect', () => {
|
|
76
91
|
const originalLocation = globalThis.window.location;
|
|
77
92
|
|
|
@@ -187,6 +202,28 @@ describe('nocobase buildin plugin auth redirect', () => {
|
|
|
187
202
|
});
|
|
188
203
|
});
|
|
189
204
|
|
|
205
|
+
it('should use an explicitly registered Portal signin route when unauthenticated', async () => {
|
|
206
|
+
const app = createMockClient({
|
|
207
|
+
publicPath: '/v2/',
|
|
208
|
+
plugins: [NocoBaseBuildInPlugin as unknown as typeof Plugin, TestScopedPortalPlugin as typeof Plugin],
|
|
209
|
+
router: { type: 'memory', initialEntries: ['/v2/customer/dashboard?tab=overview#panel'] },
|
|
210
|
+
});
|
|
211
|
+
app.apiMock.onGet('app:getLang').reply(200, {
|
|
212
|
+
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
|
213
|
+
});
|
|
214
|
+
app.apiMock.onGet('/auth:check').reply(200, { data: {} });
|
|
215
|
+
|
|
216
|
+
const Root = app.getRootComponent();
|
|
217
|
+
render(<Root />);
|
|
218
|
+
|
|
219
|
+
await waitFor(() => {
|
|
220
|
+
expect(app.router.router.state.location.pathname).toBe('/v2/customer/signin');
|
|
221
|
+
expect(app.router.router.state.location.search).toBe(
|
|
222
|
+
'?redirect=%2Fv2%2Fcustomer%2Fdashboard%3Ftab%3Doverview%23panel',
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
190
227
|
it('should check current user after navigating from skipped route to v2 admin', async () => {
|
|
191
228
|
const app = createMockClient({
|
|
192
229
|
publicPath: '/v2/',
|