@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
@@ -8,11 +8,13 @@
8
8
  */
9
9
 
10
10
  import { ACLRolesCheckProvider, Plugin } from '@nocobase/client-v2';
11
- import { render, screen, waitFor, fireEvent } from '@testing-library/react';
11
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
12
12
  import React from 'react';
13
13
  import { message } from 'antd';
14
+ import { vi } from 'vitest';
14
15
  import { AdminSettingsLayoutModel as ClientV2AdminSettingsLayoutModel } from '../settings-center';
15
16
  import { AdminSettingsLayoutModel as ClientV1AdminSettingsLayoutModel } from '../../../client/src/pm/AdminSettingsLayoutModel';
17
+ import zhCN from '../../../client/src/locale/zh-CN.json';
16
18
  import { SettingsBuildInPlugin } from '../settings-app/SettingsBuildInPlugin';
17
19
  import { matchSettingsRoute, sortTopLevelSettings } from '../settings-center/utils';
18
20
  import { createMockSettingsClient } from './mockSettingsApplication';
@@ -44,12 +46,20 @@ const waitForGetRequests = async (app: MockClientApplication, urls: string[]) =>
44
46
  const mockAdminRuntime = (
45
47
  app: MockClientApplication,
46
48
  options: {
49
+ lang?: string;
47
50
  snippets?: string[];
48
51
  pmList?: any[];
52
+ resources?: Record<string, string>;
49
53
  systemSettings?: Record<string, any>;
50
54
  } = {},
51
55
  ) => {
52
- const { snippets = ['pm', 'pm.system-settings.system-settings'], pmList = [], systemSettings = {} } = options;
56
+ const {
57
+ lang = 'en-US',
58
+ snippets = ['pm', 'pm.system-settings.system-settings'],
59
+ pmList = [],
60
+ resources = {},
61
+ systemSettings = {},
62
+ } = options;
53
63
 
54
64
  app.dataSourceManager.getCollection = ((name: string, collectionName: string) => {
55
65
  if (name === 'main' && collectionName === 'attachments') {
@@ -74,9 +84,9 @@ const mockAdminRuntime = (
74
84
  });
75
85
  app.apiMock.onGet('app:getLang').reply(200, {
76
86
  data: {
77
- lang: 'en-US',
87
+ lang,
78
88
  resources: {
79
- client: {},
89
+ client: resources,
80
90
  },
81
91
  cron: {},
82
92
  },
@@ -244,6 +254,50 @@ describe('settings center', () => {
244
254
  expect(await screen.findByText('Demo plugin')).toBeInTheDocument();
245
255
  });
246
256
 
257
+ it('should redirect to the admin app when the role cannot access settings', async () => {
258
+ const originalLocation = globalThis.window.location;
259
+ const originalModernClientPrefix = window.__nocobase_modern_client_prefix__;
260
+ const replace = vi.fn();
261
+ Object.defineProperty(globalThis.window, 'location', {
262
+ configurable: true,
263
+ value: {
264
+ ...originalLocation,
265
+ replace,
266
+ },
267
+ });
268
+ window.__nocobase_modern_client_prefix__ = 'v';
269
+
270
+ try {
271
+ const app = createMockSettingsClient({
272
+ plugins: [SettingsBuildInPlugin, TestAclPlugin],
273
+ router: { type: 'memory', initialEntries: ['/settings/system-settings'] },
274
+ });
275
+ mockAdminRuntime(app, {
276
+ snippets: ['!pm', '!pm.system-settings.system-settings'],
277
+ });
278
+
279
+ await renderApp(app);
280
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
281
+
282
+ await waitFor(() => {
283
+ expect(replace).toHaveBeenCalledWith('/v/admin');
284
+ });
285
+ expect(
286
+ screen.queryByRole('heading', { name: 'Your current role cannot access Settings' }),
287
+ ).not.toBeInTheDocument();
288
+ } finally {
289
+ Object.defineProperty(globalThis.window, 'location', {
290
+ configurable: true,
291
+ value: originalLocation,
292
+ });
293
+ if (originalModernClientPrefix === undefined) {
294
+ delete window.__nocobase_modern_client_prefix__;
295
+ } else {
296
+ window.__nocobase_modern_client_prefix__ = originalModernClientPrefix;
297
+ }
298
+ }
299
+ });
300
+
247
301
  it('should hide plugin-manager menu item when pm snippet is missing', async () => {
248
302
  const app = createMockSettingsClient({
249
303
  plugins: [SettingsBuildInPlugin, TestAclPlugin],
@@ -265,12 +319,19 @@ describe('settings center', () => {
265
319
  plugins: [SettingsBuildInPlugin, TestAclPlugin],
266
320
  router: { type: 'memory', initialEntries: ['/settings/unknown'] },
267
321
  });
268
- mockAdminRuntime(app);
322
+ mockAdminRuntime(app, {
323
+ snippets: ['!pm', '!pm.system-settings.system-settings'],
324
+ });
269
325
 
270
326
  await renderApp(app);
271
327
  await waitForGetRequests(app, ['/auth:check', 'roles:check']);
272
328
 
273
- expect(await screen.findByText('Current settings page is unavailable')).toBeInTheDocument();
329
+ const result = await screen.findByRole('status');
330
+ expect(within(result).getByRole('heading', { name: 'Settings page not found' })).toBeInTheDocument();
331
+ expect(
332
+ within(result).getByText('The settings page you requested does not exist or has been removed.'),
333
+ ).toBeInTheDocument();
334
+ expect(app.router.state.location.pathname).toBe('/settings/unknown');
274
335
  });
275
336
 
276
337
  it('should allow direct access to hidden page without showing menu entry', async () => {
@@ -298,6 +359,7 @@ describe('settings center', () => {
298
359
 
299
360
  expect(await screen.findByText('Hidden settings page')).toBeInTheDocument();
300
361
  expect(screen.queryByRole('menuitem', { name: 'Hidden demo' })).not.toBeInTheDocument();
362
+ expect(app.router.state.location.pathname).toBe('/settings/hidden-demo');
301
363
  });
302
364
 
303
365
  it('should show route empty state when direct access page has no permission', async () => {
@@ -319,14 +381,248 @@ describe('settings center', () => {
319
381
  router: { type: 'memory', initialEntries: ['/settings/secure-demo'] },
320
382
  });
321
383
  mockAdminRuntime(app, {
384
+ lang: 'zh-CN',
385
+ resources: zhCN,
322
386
  snippets: ['pm', 'pm.system-settings.system-settings', '!pm.secure-demo.index'],
323
387
  });
324
388
 
325
389
  await renderApp(app);
326
390
  await waitForGetRequests(app, ['/auth:check', 'roles:check']);
327
391
 
328
- expect(await screen.findByText('Current settings page is unavailable')).toBeInTheDocument();
392
+ const result = await screen.findByRole('status');
393
+ expect(within(result).getByRole('heading', { name: '当前角色无权访问设置中心' })).toBeInTheDocument();
394
+ expect(within(result).getByText('请切换至有权限的角色,或联系管理员获取访问权限。')).toBeInTheDocument();
329
395
  expect(screen.queryByText('Secure settings page')).not.toBeInTheDocument();
396
+ expect(app.router.state.location.pathname).toBe('/settings/secure-demo');
397
+ });
398
+
399
+ it('should redirect a denied settings tab to the first accessible tab', async () => {
400
+ const renderDeniedTab = vi.fn(() => <div>Denied tab content</div>);
401
+
402
+ class ProtectedSettingsTabsPlugin extends Plugin {
403
+ async load() {
404
+ this.pluginSettingsManager.addMenuItem({ key: 'protected-tabs', title: 'Protected tabs' });
405
+ this.pluginSettingsManager.addPageTabItem({
406
+ menuKey: 'protected-tabs',
407
+ key: 'a-second',
408
+ title: 'Second accessible tab',
409
+ aclSnippet: 'pm.protected-tabs.second',
410
+ sort: 20,
411
+ Component: () => <div>Second accessible tab content</div>,
412
+ });
413
+ this.pluginSettingsManager.addPageTabItem({
414
+ menuKey: 'protected-tabs',
415
+ key: 'z-first',
416
+ title: 'First accessible tab',
417
+ aclSnippet: 'pm.protected-tabs.first',
418
+ sort: 10,
419
+ Component: () => <div>First accessible tab content</div>,
420
+ });
421
+ this.pluginSettingsManager.addPageTabItem({
422
+ menuKey: 'protected-tabs',
423
+ key: 'denied',
424
+ title: 'Denied tab',
425
+ aclSnippet: 'pm.protected-tabs.denied',
426
+ sort: 30,
427
+ Component: renderDeniedTab,
428
+ });
429
+ }
430
+ }
431
+
432
+ const app = createMockSettingsClient({
433
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, ProtectedSettingsTabsPlugin],
434
+ router: { type: 'memory', initialEntries: ['/settings/protected-tabs/denied'] },
435
+ });
436
+ mockAdminRuntime(app, {
437
+ snippets: ['pm', '!pm.protected-tabs.denied'],
438
+ });
439
+
440
+ await renderApp(app);
441
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
442
+
443
+ await waitFor(() => {
444
+ expect(app.router.router.state.location.pathname).toBe('/settings/protected-tabs/z-first');
445
+ });
446
+ expect(app.router.router.state.historyAction).toBe('REPLACE');
447
+ expect(await screen.findByText('First accessible tab content')).toBeInTheDocument();
448
+ expect(screen.queryByText('Denied tab content')).not.toBeInTheDocument();
449
+ expect(renderDeniedTab).not.toHaveBeenCalled();
450
+
451
+ await act(async () => {
452
+ await app.router.router.navigate('/settings/protected-tabs/a-second');
453
+ });
454
+
455
+ expect(await screen.findByText('Second accessible tab content')).toBeInTheDocument();
456
+ expect(app.router.router.state.location.pathname).toBe('/settings/protected-tabs/a-second');
457
+ });
458
+
459
+ it.each(['/settings/fallback-access', '/settings/fallback-access/'])(
460
+ 'should redirect a denied index page to an accessible sibling from %s',
461
+ async (initialEntry) => {
462
+ class FallbackAccessSettingsPlugin extends Plugin {
463
+ async load() {
464
+ this.pluginSettingsManager.addMenuItem({ key: 'fallback-access', title: 'Fallback access' });
465
+ this.pluginSettingsManager.addPageTabItem({
466
+ menuKey: 'fallback-access',
467
+ key: 'index',
468
+ title: 'Restricted index settings',
469
+ aclSnippet: 'pm.fallback-access.index',
470
+ Component: () => <div>Restricted index settings page</div>,
471
+ });
472
+ this.pluginSettingsManager.addPageTabItem({
473
+ menuKey: 'fallback-access',
474
+ key: 'allowed',
475
+ title: 'Allowed fallback settings',
476
+ Component: () => <div>Allowed fallback settings page</div>,
477
+ });
478
+ }
479
+ }
480
+
481
+ const app = createMockSettingsClient({
482
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, FallbackAccessSettingsPlugin],
483
+ router: { type: 'memory', initialEntries: [initialEntry] },
484
+ });
485
+ mockAdminRuntime(app, {
486
+ snippets: ['pm', 'pm.system-settings.system-settings', '!pm.fallback-access.index'],
487
+ });
488
+
489
+ await renderApp(app);
490
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
491
+
492
+ expect(await screen.findByText('Allowed fallback settings page')).toBeInTheDocument();
493
+ expect(
494
+ screen.queryByRole('heading', { name: 'Your current role cannot access Settings' }),
495
+ ).not.toBeInTheDocument();
496
+ expect(screen.queryByText('Restricted index settings page')).not.toBeInTheDocument();
497
+ },
498
+ );
499
+
500
+ it('should skip an accessible dynamic tab when its route params cannot be resolved', async () => {
501
+ class DynamicSettingsTabsPlugin extends Plugin {
502
+ async load() {
503
+ this.pluginSettingsManager.addMenuItem({ key: 'dynamic-tabs', title: 'Dynamic tabs' });
504
+ this.pluginSettingsManager.addPageTabItem({
505
+ menuKey: 'dynamic-tabs',
506
+ key: ':name',
507
+ title: 'Dynamic tab',
508
+ aclSnippet: 'pm.dynamic-tabs.dynamic',
509
+ sort: 1,
510
+ Component: () => <div>Dynamic tab content</div>,
511
+ });
512
+ this.pluginSettingsManager.addPageTabItem({
513
+ menuKey: 'dynamic-tabs',
514
+ key: 'fallback',
515
+ title: 'Static fallback tab',
516
+ aclSnippet: 'pm.dynamic-tabs.fallback',
517
+ sort: 2,
518
+ Component: () => <div>Static fallback tab content</div>,
519
+ });
520
+ this.pluginSettingsManager.addPageTabItem({
521
+ menuKey: 'dynamic-tabs',
522
+ key: 'denied',
523
+ title: 'Denied tab',
524
+ aclSnippet: 'pm.dynamic-tabs.denied',
525
+ sort: 3,
526
+ Component: () => <div>Denied dynamic tab content</div>,
527
+ });
528
+ }
529
+ }
530
+
531
+ const app = createMockSettingsClient({
532
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, DynamicSettingsTabsPlugin],
533
+ router: { type: 'memory', initialEntries: ['/settings/dynamic-tabs/denied'] },
534
+ });
535
+ mockAdminRuntime(app, {
536
+ snippets: ['pm', '!pm.dynamic-tabs.denied'],
537
+ });
538
+
539
+ await renderApp(app);
540
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
541
+
542
+ await waitFor(() => {
543
+ expect(app.router.router.state.location.pathname).toBe('/settings/dynamic-tabs/fallback');
544
+ });
545
+ expect(await screen.findByText('Static fallback tab content')).toBeInTheDocument();
546
+ });
547
+
548
+ it('should preserve resolved route params when redirecting between dynamic tabs', async () => {
549
+ class DynamicSiblingTabsPlugin extends Plugin {
550
+ async load() {
551
+ this.pluginSettingsManager.addMenuItem({ key: 'dynamic-siblings', title: 'Dynamic sibling tabs' });
552
+ this.pluginSettingsManager.addPageTabItem({
553
+ menuKey: 'dynamic-siblings',
554
+ key: ':name/channels',
555
+ title: 'Dynamic channels tab',
556
+ aclSnippet: 'pm.dynamic-siblings.channels',
557
+ sort: 1,
558
+ Component: () => <div>Dynamic channels tab content</div>,
559
+ });
560
+ this.pluginSettingsManager.addPageTabItem({
561
+ menuKey: 'dynamic-siblings',
562
+ key: ':name/logs',
563
+ title: 'Dynamic logs tab',
564
+ aclSnippet: 'pm.dynamic-siblings.logs',
565
+ sort: 2,
566
+ Component: () => <div>Dynamic logs tab content</div>,
567
+ });
568
+ }
569
+ }
570
+
571
+ const app = createMockSettingsClient({
572
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, DynamicSiblingTabsPlugin],
573
+ router: { type: 'memory', initialEntries: ['/settings/dynamic-siblings/email:primary/logs'] },
574
+ });
575
+ mockAdminRuntime(app, {
576
+ snippets: ['pm', '!pm.dynamic-siblings.logs'],
577
+ });
578
+
579
+ await renderApp(app);
580
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
581
+
582
+ await waitFor(() => {
583
+ expect(app.router.router.state.location.pathname).toBe('/settings/dynamic-siblings/email:primary/channels');
584
+ });
585
+ expect(await screen.findByText('Dynamic channels tab content')).toBeInTheDocument();
586
+ });
587
+
588
+ it('should resolve hyphenated route param names when redirecting between dynamic tabs', async () => {
589
+ class HyphenatedParamTabsPlugin extends Plugin {
590
+ async load() {
591
+ this.pluginSettingsManager.addMenuItem({ key: 'hyphenated-params', title: 'Hyphenated param tabs' });
592
+ this.pluginSettingsManager.addPageTabItem({
593
+ menuKey: 'hyphenated-params',
594
+ key: ':data-source/channels',
595
+ title: 'Hyphenated channels tab',
596
+ aclSnippet: 'pm.hyphenated-params.channels',
597
+ sort: 1,
598
+ Component: () => <div>Hyphenated channels tab content</div>,
599
+ });
600
+ this.pluginSettingsManager.addPageTabItem({
601
+ menuKey: 'hyphenated-params',
602
+ key: ':data-source/logs',
603
+ title: 'Hyphenated logs tab',
604
+ aclSnippet: 'pm.hyphenated-params.logs',
605
+ sort: 2,
606
+ Component: () => <div>Hyphenated logs tab content</div>,
607
+ });
608
+ }
609
+ }
610
+
611
+ const app = createMockSettingsClient({
612
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, HyphenatedParamTabsPlugin],
613
+ router: { type: 'memory', initialEntries: ['/settings/hyphenated-params/email/logs'] },
614
+ });
615
+ mockAdminRuntime(app, {
616
+ snippets: ['pm', '!pm.hyphenated-params.logs'],
617
+ });
618
+
619
+ await renderApp(app);
620
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
621
+
622
+ await waitFor(() => {
623
+ expect(app.router.router.state.location.pathname).toBe('/settings/hyphenated-params/email/channels');
624
+ });
625
+ expect(await screen.findByText('Hyphenated channels tab content')).toBeInTheDocument();
330
626
  });
331
627
 
332
628
  it('should keep menu visible when menu acl is denied but child page is visible', async () => {
@@ -358,10 +654,13 @@ describe('settings center', () => {
358
654
  await waitForGetRequests(app, ['/auth:check', 'roles:check']);
359
655
 
360
656
  expect(await screen.findByText('Menu ACL child page')).toBeInTheDocument();
361
- expect(screen.getByRole('menuitem', { name: 'Menu ACL Demo' })).toBeInTheDocument();
657
+ // The sidebar is gone; this group is reached from the "other settings" hover
658
+ // dropdown in the top bar. The dropdown only renders on hover, so this just
659
+ // checks the group entry survived (ACL did not drop the whole menu).
660
+ expect(screen.getByRole('menuitem', { name: 'Other settings' })).toBeInTheDocument();
362
661
  });
363
662
 
364
- it('should allow the settings sidebar menu to scroll independently', async () => {
663
+ it('should keep rendering the active page when a group has many settings', async () => {
365
664
  class ManySettingsPlugin extends Plugin {
366
665
  async load() {
367
666
  for (let index = 0; index < 30; index += 1) {
@@ -390,8 +689,10 @@ describe('settings center', () => {
390
689
 
391
690
  expect(await screen.findByText('Scroll demo page 29')).toBeInTheDocument();
392
691
 
393
- const sidebar = screen.getByRole('menuitem', { name: 'Scroll demo 29' }).closest('.ant-layout-sider');
394
- expect(sidebar).toHaveStyle({ overflowY: 'auto' });
692
+ // Without the sidebar there is no separate scroll area: all 30 settings live in
693
+ // the top bar dropdown and the page renders only the active one, so this just
694
+ // checks it still holds up.
695
+ expect(document.querySelector('.ant-layout-sider')).toBeNull();
395
696
  });
396
697
 
397
698
  it('should save system settings through systemSettings:put', async () => {