@nocobase/client-v2 2.3.0-alpha.1 → 2.3.0-beta.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 (173) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
  3. package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
  4. package/es/flow/actions/linkageRules.d.ts +25 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +6 -30
  6. package/es/flow/components/FlowRoute.d.ts +2 -2
  7. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  8. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +32 -0
  9. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  10. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  11. package/es/flow/components/filter/VariableFilterItem.d.ts +7 -0
  12. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  13. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  14. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  15. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  16. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  17. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  18. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  19. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  20. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  21. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  22. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  23. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  24. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  25. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  26. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  27. package/es/flow/models/blocks/table/TableColumnModel.d.ts +12 -0
  28. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  29. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  30. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  31. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  32. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  33. package/es/flow-compat/passwordUtils.d.ts +1 -1
  34. package/es/index.d.ts +1 -0
  35. package/es/index.mjs +148 -145
  36. package/es/ui-operation/index.d.ts +15 -0
  37. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  38. package/lib/index.js +170 -167
  39. package/lib/locale/languageCodes.js +1 -0
  40. package/package.json +10 -8
  41. package/src/APIClient.ts +92 -0
  42. package/src/Application.tsx +3 -1
  43. package/src/__tests__/APIClient.test.tsx +58 -0
  44. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
  45. package/src/__tests__/settings-center.test.tsx +270 -6
  46. package/src/acl/ACLProvider.tsx +2 -0
  47. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  48. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  49. package/src/collection-manager/field-configure.ts +2 -2
  50. package/src/components/form/ScanInput/CodeScanner.tsx +64 -35
  51. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +107 -0
  52. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +294 -24
  53. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  54. package/src/components/form/ScanInput/useCodeScanner.ts +281 -23
  55. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  56. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  57. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  58. package/src/components/form/table/Table.tsx +81 -6
  59. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  60. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  61. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  62. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  63. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  64. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  65. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  66. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  67. package/src/flow/actions/linkageRules.tsx +59 -16
  68. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  69. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  70. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  71. package/src/flow/common/Markdown/style.ts +4 -0
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/FieldAssignValueInput.tsx +42 -621
  74. package/src/flow/components/FlowRoute.tsx +71 -29
  75. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  76. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +335 -0
  77. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +389 -0
  78. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  79. package/src/flow/components/field-value-variable/index.ts +12 -0
  80. package/src/flow/components/filter/VariableFilterItem.tsx +23 -6
  81. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  82. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  83. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  84. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  85. package/src/flow/index.ts +2 -0
  86. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  87. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  88. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  89. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  90. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  91. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  92. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  93. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  94. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  95. package/src/flow/models/base/CollectionBlockModel.tsx +38 -3
  96. package/src/flow/models/base/GridModel.tsx +0 -1
  97. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  98. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  99. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  100. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  101. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  102. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  103. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  104. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +36 -50
  105. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  106. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  107. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  108. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  109. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  110. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  111. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  112. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  113. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  114. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  115. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  116. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  117. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  118. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  119. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  120. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  121. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  122. package/src/flow/models/blocks/form/FormBlockModel.tsx +73 -5
  123. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  124. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +153 -2
  125. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  126. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  127. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  128. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  129. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  130. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  131. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  132. package/src/flow/models/blocks/table/TableBlockModel.tsx +28 -2
  133. package/src/flow/models/blocks/table/TableColumnModel.tsx +48 -9
  134. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  135. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  136. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  137. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  138. package/src/flow/models/blocks/table/utils.ts +7 -0
  139. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  140. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +12 -3
  141. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  142. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  143. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +29 -3
  144. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  145. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +5 -1
  146. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +191 -3
  147. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  148. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  149. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  150. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  151. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  152. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  153. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  154. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  155. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  156. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  157. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  158. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  159. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  160. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  161. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  162. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  163. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  164. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
  165. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  166. package/src/index.ts +1 -0
  167. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  168. package/src/layout-manager/LayoutRoute.tsx +2 -1
  169. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  170. package/src/locale/languageCodes.ts +1 -0
  171. package/src/settings-center/AdminSettingsLayout.tsx +23 -2
  172. package/src/ui-operation/index.ts +33 -0
  173. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -64,6 +64,7 @@ const languageCodes = {
64
64
  "ml-IN": { label: "\u0D2E\u0D32\u0D2F\u0D3E\u0D33\u0D02" },
65
65
  "mn-MN": { label: "\u041C\u043E\u043D\u0433\u043E\u043B \u0445\u044D\u043B" },
66
66
  "ms-MY": { label: "\u0628\u0647\u0627\u0633 \u0645\u0644\u0627\u064A\u0648" },
67
+ "my-MM": { label: "\u1019\u103C\u1014\u103A\u1019\u102C\u1018\u102C\u101E\u102C" },
67
68
  "nb-NO": { label: "Norsk bokm\xE5l" },
68
69
  "ne-NP": { label: "\u0928\u0947\u092A\u093E\u0932\u0940" },
69
70
  "nl-BE": { label: "Vlaams" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.3.0-alpha.1",
3
+ "version": "2.3.0-beta.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": "2.3.0-alpha.1",
31
- "@nocobase/flow-engine": "2.3.0-alpha.1",
32
- "@nocobase/sdk": "2.3.0-alpha.1",
33
- "@nocobase/shared": "2.3.0-alpha.1",
34
- "@nocobase/utils": "2.3.0-alpha.1",
30
+ "@nocobase/evaluators": "2.3.0-beta.11",
31
+ "@nocobase/flow-engine": "2.3.0-beta.11",
32
+ "@nocobase/sdk": "2.3.0-beta.11",
33
+ "@nocobase/shared": "2.3.0-beta.11",
34
+ "@nocobase/utils": "2.3.0-beta.11",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -44,8 +44,10 @@
44
44
  "json5": "^2.2.3",
45
45
  "jsqr": "^1.4.0",
46
46
  "lodash": "4.17.21",
47
+ "react-device-detect": "2.2.3",
47
48
  "react-i18next": "^11.15.1",
48
- "react-router-dom": "^6.30.1"
49
+ "react-router-dom": "^6.30.1",
50
+ "zxing-wasm": "^3.1.3"
49
51
  },
50
- "gitHead": "2377df8ceb12549149017f7f14a61207bf6e49a2"
52
+ "gitHead": "e81c2c69d55f13e4d237c420a51ebad1e9dae515"
51
53
  }
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
+ });
@@ -415,7 +415,7 @@ describe('nocobase buildin plugin auth redirect', () => {
415
415
  });
416
416
 
417
417
  it.each(['/v2/admin/legacy-page/tab/tab-1', '/v2/admin/legacy-page/view/detail'])(
418
- 'should show 404 for authenticated direct v1-style v2 page access: %s',
418
+ 'should explain authenticated direct v1-style v2 page access: %s',
419
419
  async (pathname) => {
420
420
  const app = createMockClient({
421
421
  publicPath: '/v2/',
@@ -441,7 +441,12 @@ describe('nocobase buildin plugin auth redirect', () => {
441
441
  const Root = app.getRootComponent();
442
442
  render(<Root />);
443
443
 
444
- expect(await screen.findByText('404')).toBeInTheDocument();
444
+ expect(await screen.findByText('This page is not supported in the /v2/ branch')).toBeInTheDocument();
445
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
446
+ expect(screen.getByRole('link', { name: 'Open from the original entry' })).toHaveAttribute(
447
+ 'href',
448
+ pathname.replace(/^\/v2/, ''),
449
+ );
445
450
  expect(app.router.router.state.location.pathname).toBe(pathname);
446
451
  },
447
452
  );
@@ -7,10 +7,17 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { ACLRolesCheckProvider, createMockClient, Plugin } from '@nocobase/client-v2';
11
- import { render, screen, waitFor, fireEvent } from '@testing-library/react';
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 { snippets = ['pm', 'pm.system-settings.system-settings'], pmList = [], systemSettings = {} } = options;
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 () => {
@@ -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) {