@nocobase/client-v2 2.2.0-beta.9 → 2.2.1

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 (224) hide show
  1. package/es/APIClient.d.ts +13 -3
  2. package/es/BaseApplication.d.ts +1 -0
  3. package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
  4. package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
  5. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  6. package/es/components/category-tabs/index.d.ts +9 -0
  7. package/es/components/form/ScanInput/useCodeScanner.d.ts +3 -2
  8. package/es/components/form/TypedVariableInput.d.ts +31 -5
  9. package/es/components/form/filter/CollectionFilter.d.ts +2 -0
  10. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  11. package/es/components/form/filter/CollectionFilterPanel.d.ts +2 -0
  12. package/es/components/form/filter/index.d.ts +2 -0
  13. package/es/components/form/filter/useFilterActionProps.d.ts +2 -0
  14. package/es/components/index.d.ts +1 -0
  15. package/es/flow/actions/index.d.ts +1 -1
  16. package/es/flow/actions/linkageRules.d.ts +27 -0
  17. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  18. package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
  19. package/es/flow/components/FieldAssignValueInput.d.ts +7 -24
  20. package/es/flow/components/FlowRoute.d.ts +2 -2
  21. package/es/flow/components/RunJSValueEditor.d.ts +1 -0
  22. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  23. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
  24. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  25. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  26. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  27. package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
  28. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  29. package/es/flow/index.d.ts +1 -0
  30. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  31. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  32. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  33. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  34. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  35. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  36. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  37. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  38. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  39. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  40. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  41. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  42. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  43. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  44. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
  45. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  46. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  47. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  48. package/es/flow/models/blocks/table/TableColumnModel.d.ts +12 -0
  49. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  50. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  51. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  52. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  53. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  54. package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
  55. package/es/flow-compat/passwordUtils.d.ts +1 -1
  56. package/es/flow-compat/routeTypes.d.ts +1 -0
  57. package/es/index.d.ts +5 -0
  58. package/es/index.mjs +199 -161
  59. package/es/ui-operation/index.d.ts +15 -0
  60. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  61. package/es/utils/getRouteRuntimeVersion.d.ts +23 -0
  62. package/es/utils/index.d.ts +1 -0
  63. package/lib/index.js +215 -177
  64. package/lib/locale/languageCodes.js +3 -1
  65. package/package.json +9 -7
  66. package/src/APIClient.ts +90 -7
  67. package/src/Application.tsx +7 -1
  68. package/src/BaseApplication.tsx +11 -6
  69. package/src/__tests__/APIClient.test.tsx +58 -0
  70. package/src/__tests__/app.test.tsx +73 -0
  71. package/src/__tests__/browserChecker.test.ts +61 -0
  72. package/src/__tests__/getRouteRuntimeVersion.test.ts +68 -0
  73. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +35 -2
  74. package/src/__tests__/settings-center.test.tsx +270 -6
  75. package/src/acl/ACLProvider.tsx +2 -0
  76. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  77. package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
  78. package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
  79. package/src/collection-manager/field-validation.ts +1 -1
  80. package/src/components/README.md +7 -1
  81. package/src/components/README.zh-CN.md +6 -1
  82. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  83. package/src/components/category-tabs/index.ts +10 -0
  84. package/src/components/form/JsonTextArea.tsx +4 -0
  85. package/src/components/form/ScanInput/CodeScanner.tsx +79 -35
  86. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  87. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +284 -11
  88. package/src/components/form/ScanInput/useCodeScanner.ts +332 -24
  89. package/src/components/form/TypedVariableInput.tsx +452 -101
  90. package/src/components/form/__tests__/TypedVariableInput.test.tsx +364 -12
  91. package/src/components/form/filter/CollectionFilter.tsx +4 -0
  92. package/src/components/form/filter/CollectionFilterItem.tsx +36 -9
  93. package/src/components/form/filter/CollectionFilterPanel.tsx +4 -0
  94. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +55 -0
  95. package/src/components/form/filter/__tests__/useFilterActionProps.test.tsx +95 -0
  96. package/src/components/form/filter/index.ts +2 -0
  97. package/src/components/form/filter/useFilterActionProps.ts +37 -6
  98. package/src/components/form/table/Table.tsx +81 -6
  99. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  100. package/src/components/index.ts +1 -0
  101. package/src/flow/FlowPage.tsx +9 -1
  102. package/src/flow/__tests__/FlowPage.test.tsx +50 -3
  103. package/src/flow/__tests__/FlowRoute.test.tsx +128 -10
  104. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  105. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  106. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  107. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  108. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  109. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  110. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  111. package/src/flow/actions/index.ts +2 -0
  112. package/src/flow/actions/linkageRules.tsx +144 -26
  113. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  114. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +0 -1
  115. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  116. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +253 -6
  117. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  118. package/src/flow/common/Markdown/style.ts +4 -0
  119. package/src/flow/components/BlockItemCard.tsx +2 -2
  120. package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
  121. package/src/flow/components/FieldAssignValueInput.tsx +72 -584
  122. package/src/flow/components/FlowRoute.tsx +78 -32
  123. package/src/flow/components/RunJSValueEditor.tsx +9 -1
  124. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +216 -0
  125. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  126. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  127. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  128. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  129. package/src/flow/components/field-value-variable/index.ts +12 -0
  130. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  131. package/src/flow/components/filter/VariableFilterItem.tsx +170 -13
  132. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  133. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  134. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +195 -1
  135. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  136. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  137. package/src/flow/index.ts +3 -0
  138. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  139. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  140. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  141. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  142. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  143. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  144. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  145. package/src/flow/models/base/CollectionBlockModel.tsx +38 -3
  146. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  147. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  148. package/src/flow/models/base/PageModel/PageTabModel.tsx +249 -18
  149. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
  150. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  151. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  152. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +1075 -7
  153. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  154. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  155. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  156. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  157. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  158. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  159. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  160. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +28 -3
  161. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  162. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +155 -25
  163. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  164. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  165. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +395 -1
  166. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  167. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  168. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +66 -4
  169. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  170. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  171. package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
  172. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  173. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
  174. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  175. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
  176. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  177. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  178. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  179. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  180. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  181. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  182. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
  183. package/src/flow/models/blocks/table/TableBlockModel.tsx +29 -3
  184. package/src/flow/models/blocks/table/TableColumnModel.tsx +48 -9
  185. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  186. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  187. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  188. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  189. package/src/flow/models/blocks/table/utils.ts +7 -0
  190. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  191. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +10 -1
  192. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  193. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  194. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  195. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  196. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
  197. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  198. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  199. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  200. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  201. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  202. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  203. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  204. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  205. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  206. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  207. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  208. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +26 -10
  209. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +51 -14
  210. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  211. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  212. package/src/flow-compat/fieldValidationConstants.ts +1 -1
  213. package/src/flow-compat/routeTypes.ts +1 -0
  214. package/src/index.ts +5 -0
  215. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  216. package/src/layout-manager/LayoutRoute.tsx +2 -1
  217. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  218. package/src/locale/languageCodes.ts +3 -1
  219. package/src/nocobase-buildin-plugin/index.tsx +12 -0
  220. package/src/settings-center/AdminSettingsLayout.tsx +23 -2
  221. package/src/ui-operation/index.ts +33 -0
  222. package/src/ui-operation/ui-operation-codec.ts +54 -0
  223. package/src/utils/getRouteRuntimeVersion.ts +185 -0
  224. package/src/utils/index.tsx +1 -0
@@ -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) {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { render, screen, waitFor } from '@testing-library/react';
11
+ import React from 'react';
12
+ import { MemoryRouter } from 'react-router-dom';
13
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import {
15
+ FLOW_SETTINGS_PREFERENCE_STORAGE_KEY,
16
+ writeFlowSettingsPreference,
17
+ } from '../../flow/admin-shell/admin-layout/flowSettingsPreference';
18
+ import { ACLRolesCheckProvider } from '../ACLProvider';
19
+
20
+ const mocks = vi.hoisted(() => ({
21
+ app: undefined as unknown,
22
+ }));
23
+
24
+ vi.mock('../../hooks/useApp', () => ({
25
+ useApp: () => mocks.app,
26
+ }));
27
+
28
+ describe('ACLRolesCheckProvider', () => {
29
+ beforeEach(() => {
30
+ window.localStorage.removeItem(FLOW_SETTINGS_PREFERENCE_STORAGE_KEY);
31
+ });
32
+
33
+ it('exits UI editing mode when the current role cannot configure pages', async () => {
34
+ const disable = vi.fn(async () => undefined);
35
+ const setRole = vi.fn();
36
+ const aclContext: Record<string, unknown> = {};
37
+ const appContext = {
38
+ acl: undefined,
39
+ defineProperty(name: string, descriptor: PropertyDescriptor) {
40
+ Object.defineProperty(this, name, descriptor);
41
+ },
42
+ ...aclContext,
43
+ };
44
+
45
+ mocks.app = {
46
+ apiClient: {
47
+ auth: {
48
+ role: 'member',
49
+ setRole,
50
+ },
51
+ request: vi.fn(async () => ({
52
+ data: {
53
+ data: {
54
+ role: 'viewer',
55
+ snippets: [],
56
+ },
57
+ meta: {},
58
+ },
59
+ })),
60
+ },
61
+ context: appContext,
62
+ flowEngine: {
63
+ flowSettings: {
64
+ disable,
65
+ },
66
+ },
67
+ pluginSettingsManager: {
68
+ setAclSnippets: vi.fn(),
69
+ },
70
+ renderComponent: () => <div>Loading</div>,
71
+ router: {
72
+ isSkippedAuthCheckRoute: () => false,
73
+ },
74
+ };
75
+ writeFlowSettingsPreference(true);
76
+
77
+ render(
78
+ <MemoryRouter initialEntries={['/admin']}>
79
+ <ACLRolesCheckProvider>
80
+ <div>Page content</div>
81
+ </ACLRolesCheckProvider>
82
+ </MemoryRouter>,
83
+ );
84
+
85
+ expect(await screen.findByText('Page content')).toBeInTheDocument();
86
+ await waitFor(() => {
87
+ expect(disable).toHaveBeenCalledTimes(1);
88
+ });
89
+ expect(window.localStorage.getItem(FLOW_SETTINGS_PREFERENCE_STORAGE_KEY)).toBe('0');
90
+ expect(setRole).toHaveBeenCalledWith('viewer');
91
+ });
92
+ });
@@ -115,6 +115,7 @@ export abstract class CollectionFieldInterface {
115
115
  titleUsable?: boolean;
116
116
  usePathOptions?(field: any): any;
117
117
  hidden?: boolean;
118
+ creatable?: boolean;
118
119
 
119
120
  addComponentOption(componentOption: CollectionFieldInterfaceComponentOption) {
120
121
  if (!this.componentOptions) {
@@ -31,6 +31,7 @@ interface FieldInterfaceLike {
31
31
  title?: ReactNode;
32
32
  group?: string;
33
33
  order?: number;
34
+ creatable?: boolean;
34
35
  isAssociation?: boolean;
35
36
  supportDataSourceType?: string[];
36
37
  notSupportDataSourceType?: string[];
@@ -132,7 +132,7 @@ fieldValidationConfigureRegistry.registerMany([
132
132
  { key: 'uri', label: 'URI', hasValue: false, params: [], paramsType: 'object' },
133
133
  {
134
134
  key: 'multiple',
135
- label: 'Multiple',
135
+ label: 'Multiple of',
136
136
  hasValue: true,
137
137
  params: [{ key: 'base', label: 'Base', componentType: 'inputNumber', required: true }],
138
138
  },
@@ -190,6 +190,10 @@ import { TypedVariableInput } from '@nocobase/client-v2';
190
190
  <Form.Item name={['options', 'secure']} label={t('Secure')} initialValue={true}>
191
191
  <TypedVariableInput types={['boolean']} namespaces={['$env']} />
192
192
  </Form.Item>
193
+
194
+ // Inject a custom variable tree (e.g. a workflow node's upstream outputs,
195
+ // which are not in the global registry)
196
+ <TypedVariableInput types={['string', 'number']} metaTree={workflowMetaTree} />
193
197
  ```
194
198
 
195
199
  Key props:
@@ -197,6 +201,7 @@ Key props:
197
201
  - `types`: allowed constant types. Shape mirrors v1 `useTypedConstant` — pass bare type names (`['number', 'boolean']`) or `[type, editorProps]` tuples (`[['number', { min, max, step }]]`) to forward props to the underlying antd editor. Defaults to `['string', 'number', 'boolean', 'date']`. **Even when only one type is allowed, the `Constant` entry still expands into a typed submenu** (Number / Boolean / Date / String) — matches v1 so users can see what type the constant is
198
202
  - `namespaces`: restrict the variable picker to specific top-level namespaces (e.g. `['$env']`). Omit to expose every namespace registered on `flowEngine.context`
199
203
  - `extraNodes`: static leaves appended after the namespace-filtered nodes
204
+ - `metaTree`: **inject the variable tree directly** instead of reading the global `flowEngine.context` meta tree. When set, `namespaces`/`extraNodes` are ignored and this tree is used verbatim — for context-scoped variable sources that are not in the global registry (typically a workflow node's upstream outputs, `$jobsMapByNodeKey`). Nodes whose `children` is a thunk (`() => Promise<MetaTreeNode[]>`) are **lazy-loaded on expand** (via flow-engine's `loadMetaTreeChildren`)
200
205
  - `nullable`: whether to expose the `Null` switcher entry. Default `true`. Combined with `Form.Item.rules={[{ required: true }]}`, the user can explicitly clear the field but submission is still blocked by validation — mirrors v1's "Null + required" pairing
201
206
  - `delimiters`: variable-token delimiters, default `['{{', '}}']` — same as `VariableInput`
202
207
  - `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`: standard controlled-input props
@@ -212,9 +217,10 @@ When **not** to use it:
212
217
  - **Pure literal fields** (users will never pass a variable) → use the antd primitive directly (`InputNumber` / `Select` / `DatePicker` / `Input`) and skip the Cascader column overhead
213
218
  - **Pure variable fields** (users will never pass a literal) → use `EnvVariableInput` (`$env`-only, with optional password masking) or `VariableInput` (general-purpose)
214
219
 
220
+ Supported constant types: `string` / `number` / `boolean` / `date` / `object`. The `object` (JSON) type renders an inline monospace textarea (2 rows by default, drag-resizable) — it keeps the raw text as a draft while editing and `JSON.parse`s it back into an object on blur. On a parse failure it shows the raw `JSON.parse` message (e.g. `Expected property name or '}' in JSON at position …`, matching v1) on its own row below the input, and does not emit. Mirrors v1 `useTypedConstant`'s object form (default value `{}`).
221
+
215
222
  Capabilities skipped (present in v1, not yet ported to v2):
216
223
 
217
- - `object` constant type (JSON editor) — v2 has no inline "JSON editor + Cascader switcher" yet; add when there's a concrete caller
218
224
  - Async `loadChildren` cascading — most MetaTree namespaces are already eagerly resolved by `useFilteredMetaTree`, so this hasn't been needed
219
225
 
220
226
  #### FileSizeInput
@@ -190,6 +190,9 @@ import { TypedVariableInput } from '@nocobase/client-v2';
190
190
  <Form.Item name={['options', 'secure']} label={t('安全模式')} initialValue={true}>
191
191
  <TypedVariableInput types={['boolean']} namespaces={['$env']} />
192
192
  </Form.Item>
193
+
194
+ // 注入自定义变量树(如工作流节点的上游输出,不在全局注册表里)
195
+ <TypedVariableInput types={['string', 'number']} metaTree={workflowMetaTree} />
193
196
  ```
194
197
 
195
198
  主要属性:
@@ -197,6 +200,7 @@ import { TypedVariableInput } from '@nocobase/client-v2';
197
200
  - `types`:允许的常量类型。形态对齐 v1 `useTypedConstant`,可以传裸类型名 `['number', 'boolean']`,也可以传 `[type, editorProps]` 元组 `[['number', { min, max, step }]]` 把 props 透传给底层 antd 编辑器。默认 `['string', 'number', 'boolean', 'date']`。**即使只允许一种类型,「常量」入口也会展开二级菜单**(数字 / 逻辑值 / 日期 / 字符串)——跟 v1 一致,让用户能直观看到当前常量是什么类型
198
201
  - `namespaces`:限定变量 picker 可选的顶层命名空间(如 `['$env']`)。不传就用 `flowEngine.context` 里所有已注册命名空间
199
202
  - `extraNodes`:在命名空间过滤后追加几条静态变量节点
203
+ - `metaTree`:**直接注入变量树**,取代读取全局 `flowEngine.context` 的 MetaTree。传了它就**忽略** `namespaces`/`extraNodes`,原样使用这棵树——用于不在全局注册表里的、上下文相关的变量源(典型如工作流节点的上游节点输出 `$jobsMapByNodeKey`)。树里 `children` 为函数(`() => Promise<MetaTreeNode[]>`)的节点会在用户展开 Cascader 时**按需懒加载**(复用 flow-engine 的 `loadMetaTreeChildren`)
200
204
  - `nullable`:是否暴露「空值」入口,默认 `true`。配合 `Form.Item.rules={[{ required: true }]}` 可以让用户能手动清空、但提交时会被校验拦截——跟 v1 的「空值 + required」组合一致
201
205
  - `delimiters`:变量 token 开闭分隔符,默认 `['{{', '}}']`,跟 `VariableInput` 一致
202
206
  - `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`:标准受控字段属性
@@ -212,9 +216,10 @@ import { TypedVariableInput } from '@nocobase/client-v2';
212
216
  - **纯字面量字段**(用户不会想填变量)→ 直接用 antd `InputNumber` / `Select` / `DatePicker` / `Input`,省掉 Cascader 那一格的视觉开销
213
217
  - **纯变量字段**(用户不会想填字面量)→ 用 `EnvVariableInput`(`$env` 专用,带 password mask)或 `VariableInput`(更通用)
214
218
 
219
+ 支持的常量类型:`string` / `number` / `boolean` / `date` / `object`。其中 `object`(即 JSON)渲染为一个等宽字体的内联 textarea(默认两行、可拖拽拉伸),编辑时保留原始文本草稿、失焦(blur)时 `JSON.parse` 回写为对象;解析失败则在输入框**下方单独一行**显示原生 `JSON.parse` 的错误信息(如 `Expected property name or '}' in JSON at position …`,对齐 v1)且不回写。对齐 v1 `useTypedConstant` 的 object 形态(默认值 `{}`)。
220
+
215
221
  跳过的能力(v1 有但 v2 还没补):
216
222
 
217
- - `object` 类型(JSON 编辑器)——v2 还没对应的「内联 JSON 编辑器 + Cascader 切换」组件,等真有需求再补
218
223
  - 异步 `loadChildren` 分支——大多数命名空间的 MetaTree 已经由 `useFilteredMetaTree` 提前展平,没遇到刚需
219
224
 
220
225
  #### FileSizeInput