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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +6 -2
  4. package/es/flow/actions/linkageRules.d.ts +24 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
  6. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  7. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
  8. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  9. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  10. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  11. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  12. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  13. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  14. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  15. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  16. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  17. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  18. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  19. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  20. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  21. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
  23. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  24. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  25. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  26. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  27. package/es/flow-compat/passwordUtils.d.ts +1 -1
  28. package/es/index.d.ts +2 -1
  29. package/es/index.mjs +140 -140
  30. package/es/nocobase-buildin-plugin/index.d.ts +1 -0
  31. package/es/settings-app/SettingsGroupNav.d.ts +3 -2
  32. package/es/settings-app/SettingsSearch.d.ts +1 -1
  33. package/es/ui-operation/index.d.ts +15 -0
  34. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  35. package/lib/index.js +145 -145
  36. package/package.json +7 -7
  37. package/src/APIClient.ts +92 -0
  38. package/src/Application.tsx +3 -1
  39. package/src/__tests__/APIClient.test.tsx +58 -0
  40. package/src/__tests__/app.test.tsx +122 -3
  41. package/src/__tests__/authRedirect.test.ts +17 -0
  42. package/src/__tests__/browserChecker.test.ts +81 -33
  43. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
  44. package/src/__tests__/settings-center.test.tsx +312 -11
  45. package/src/__tests__/settings-layout-root.test.tsx +346 -6
  46. package/src/__tests__/settings-runtime-paths.test.ts +1 -1
  47. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  48. package/src/__tests__/settings-shell.test.tsx +62 -1
  49. package/src/acl/ACLProvider.tsx +34 -3
  50. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  51. package/src/acl/aclCheckReadiness.ts +44 -0
  52. package/src/authRedirect.ts +13 -5
  53. package/src/components/AppComponents.tsx +16 -7
  54. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  55. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  56. package/src/components/form/table/Table.tsx +81 -6
  57. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  58. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  59. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  60. package/src/flow/actions/linkageRules.tsx +33 -10
  61. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  62. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  63. package/src/flow/components/FieldAssignValueInput.tsx +38 -621
  64. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  65. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  66. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  67. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  68. package/src/flow/components/field-value-variable/index.ts +12 -0
  69. package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
  70. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  71. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  72. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  73. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  74. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  75. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  76. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  77. package/src/flow/models/base/CollectionBlockModel.tsx +32 -1
  78. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  79. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  80. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  81. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  82. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  83. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  84. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  85. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  86. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  87. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  88. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  89. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  90. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  91. package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
  92. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  93. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  94. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  95. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  96. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  97. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  98. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  99. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
  100. package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
  101. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  102. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  103. package/src/flow/models/blocks/table/utils.ts +7 -0
  104. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  105. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  106. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  107. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  108. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  109. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  110. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
  111. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  112. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  113. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  114. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  115. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  116. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  117. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  118. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  119. package/src/index.ts +2 -0
  120. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  121. package/src/layout-manager/LayoutRoute.tsx +2 -1
  122. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  123. package/src/nocobase-buildin-plugin/index.tsx +14 -1
  124. package/src/settings-app/SettingsGroupNav.tsx +11 -11
  125. package/src/settings-app/SettingsSearch.tsx +115 -10
  126. package/src/settings-app/SettingsShell.tsx +23 -6
  127. package/src/settings-app/runtimePaths.ts +0 -1
  128. package/src/settings-app/settingsTheme.ts +82 -14
  129. package/src/settings-center/AdminSettingsLayout.tsx +132 -107
  130. package/src/settings-center/__tests__/groups.test.ts +25 -0
  131. package/src/settings-center/groups.ts +1 -1
  132. package/src/ui-operation/index.ts +33 -0
  133. package/src/ui-operation/ui-operation-codec.ts +54 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "3.0.0-alpha.1",
3
+ "version": "3.0.0-alpha.11",
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.1",
31
- "@nocobase/flow-engine": "3.0.0-alpha.1",
32
- "@nocobase/sdk": "3.0.0-alpha.1",
33
- "@nocobase/shared": "3.0.0-alpha.1",
34
- "@nocobase/utils": "3.0.0-alpha.1",
30
+ "@nocobase/evaluators": "3.0.0-alpha.11",
31
+ "@nocobase/flow-engine": "3.0.0-alpha.11",
32
+ "@nocobase/sdk": "3.0.0-alpha.11",
33
+ "@nocobase/shared": "3.0.0-alpha.11",
34
+ "@nocobase/utils": "3.0.0-alpha.11",
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": "22d8be8ece179cfa5c8a4ebb2c896c92bd418e03"
51
+ "gitHead": "03574f3a2d10b1ed8469dc7405aa0c4696d6d1d1"
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
  }
@@ -45,7 +45,7 @@ export class Application extends BaseApplication<
45
45
  public hasLoadError = false;
46
46
 
47
47
  protected createApiClient(options: ApplicationOptions) {
48
- return new APIClient({
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('should keep current content behind maintained dialog state', async () => {
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: 'pm.enable' }, message: 'starting sub applications...' };
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('Enabling plugin')).toBeInTheDocument();
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(['/v', '/v/'])('redirects the main modern root %s to Settings', (pathname) => {
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
- expect(replace).toHaveBeenCalledOnce();
193
- expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/settings');
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
- ['/v/apps/demo', '/settings/apps/demo'],
198
- ['/v/apps/demo/', '/settings/apps/demo'],
199
- ['/v/_app/demo', '/settings/_app/demo'],
200
- ['/v/_app/demo/', '/settings/_app/demo'],
201
- ])('redirects the scoped modern root %s to %s', (pathname, expectedPathname) => {
202
- const replace = executeBrowserChecker(scriptPath, {
203
- pathname,
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
- expect(replace).toHaveBeenCalledOnce();
209
- expect(replace).toHaveBeenCalledWith(`http://c.local.nocobase.com${expectedPathname}`);
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/', '/tenant/apps/root/settings/apps/demo'],
237
- ['/tenant/_app/root/modern/_app/demo/', '/tenant/_app/root/modern/', '/tenant/_app/root/settings/_app/demo'],
238
- ])(
239
- 'preserves public path, query, and hash for %s without treating public-path segments as app scope',
240
- (pathname, publicPath, expectedPathname) => {
241
- const replace = executeBrowserChecker(scriptPath, {
242
- pathname,
243
- publicPath,
244
- modernClientPrefix: 'modern',
245
- search: '?tab=overview',
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
- expect(replace).toHaveBeenCalledOnce();
250
- expect(replace).toHaveBeenCalledWith(`http://c.local.nocobase.com${expectedPathname}?tab=overview#panel`);
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/',