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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -7,11 +7,11 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { render, screen, waitFor } from '@testing-library/react';
10
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
11
11
  import MockAdapter from 'axios-mock-adapter';
12
12
  import React from 'react';
13
13
  import { afterEach, describe, expect, it, vi } from 'vitest';
14
- import { ACLRolesCheckProvider } from '../acl';
14
+ import { ACLRolesCheckProvider, useRoleRecheck } from '../acl';
15
15
  import { Plugin } from '../Plugin';
16
16
  import { SettingsApplication } from '../settings-app/SettingsApplication';
17
17
  import { SettingsBuildInPlugin } from '../settings-app/SettingsBuildInPlugin';
@@ -69,11 +69,32 @@ class PrimarySettingsPlugin extends Plugin {
69
69
  }
70
70
  }
71
71
 
72
+ let recheckRole: (() => Promise<void>) | null = null;
73
+
74
+ const RoleRecheckPage = () => {
75
+ recheckRole = useRoleRecheck();
76
+ return <div>Role recheck page</div>;
77
+ };
78
+
79
+ class RoleRecheckSettingsPlugin extends Plugin {
80
+ async load() {
81
+ this.pluginSettingsManager.addMenuItem({ key: 'role-recheck', title: 'Role recheck' });
82
+ this.pluginSettingsManager.addPageTabItem({
83
+ menuKey: 'role-recheck',
84
+ key: 'index',
85
+ title: 'Role recheck',
86
+ Component: RoleRecheckPage,
87
+ });
88
+ }
89
+ }
90
+
72
91
  describe('standalone settings layout root', () => {
73
92
  const originalLocation = window.location;
74
93
  const originalModernClientPrefix = window.__nocobase_modern_client_prefix__;
75
94
 
76
95
  afterEach(() => {
96
+ recheckRole = null;
97
+ vi.restoreAllMocks();
77
98
  Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
78
99
  if (originalModernClientPrefix === undefined) {
79
100
  delete window.__nocobase_modern_client_prefix__;
@@ -163,7 +184,7 @@ describe('standalone settings layout root', () => {
163
184
  });
164
185
  });
165
186
 
166
- it('keeps ordinary negative-sort settings in the active Settings group sidebar', async () => {
187
+ it('keeps ordinary negative-sort settings reachable in the active Settings group', async () => {
167
188
  const app = new SettingsApplication({
168
189
  plugins: [SettingsBuildInPlugin, TestAclPlugin, PrimarySettingsPlugin],
169
190
  router: { type: 'memory', initialEntries: ['/settings/portal-manager'] },
@@ -188,9 +209,18 @@ describe('standalone settings layout root', () => {
188
209
 
189
210
  expect(await screen.findByText('Portal manager page')).toBeInTheDocument();
190
211
 
191
- const portalManagerItem = screen.getByRole('menuitem', { name: 'Portal manager' });
192
- expect(portalManagerItem.closest('aside')).toBeInTheDocument();
193
- expect(screen.queryByRole('menuitem', { name: /Plugin manager$/ })).not.toBeInTheDocument();
212
+ // The sidebar is gone: a negative-sort setting no longer gets its own column and
213
+ // follows whichever group it belongs to. This checks it did not drift into a
214
+ // different group.
215
+ expect(document.querySelector('.ant-layout-sider')).toBeNull();
216
+ const groupNav = document.querySelector('.ant-layout-header .ant-menu') as HTMLElement;
217
+ expect(groupNav).toBeInTheDocument();
218
+ // Being an ordinary negative-sort setting, it lands in the catch-all "other
219
+ // settings" group: never promoted to a top-level entry, never folded into the
220
+ // plugin manager group.
221
+ expect(within(groupNav).getByRole('menuitem', { name: 'Other settings' })).toBeInTheDocument();
222
+ expect(within(groupNav).getByRole('menuitem', { name: /Plugin manager$/ })).toBeInTheDocument();
223
+ expect(within(groupNav).queryByRole('menuitem', { name: 'Portal manager' })).not.toBeInTheDocument();
194
224
  });
195
225
 
196
226
  it('uses document navigation to the standalone Settings signin page when unauthenticated', async () => {
@@ -257,4 +287,314 @@ describe('standalone settings layout root', () => {
257
287
  resolveAuthCheck([200, { data: { id: 1 } }]);
258
288
  expect(await screen.findByRole('banner')).toBeInTheDocument();
259
289
  });
290
+
291
+ it('does not render Settings navigation or search before the initial role check completes', async () => {
292
+ let resolveRoleCheck: (response: [number, { data: { role: string; snippets: string[] } }]) => void = () => {
293
+ throw new Error('Role check resolver is not initialized');
294
+ };
295
+ const roleCheckResponse = new Promise<[number, { data: { role: string; snippets: string[] } }]>((resolve) => {
296
+ resolveRoleCheck = resolve;
297
+ });
298
+ const app = new SettingsApplication({
299
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin],
300
+ router: { type: 'memory', initialEntries: ['/settings/system-settings'] },
301
+ ws: false,
302
+ });
303
+ const apiMock = new MockAdapter(app.apiClient.axios);
304
+ app.dataSourceManager.ensureLoaded = async () => {};
305
+ apiMock.onGet('app:getLang').reply(200, {
306
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
307
+ });
308
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
309
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
310
+ apiMock.onGet('roles:check').reply(() => roleCheckResponse);
311
+ apiMock.onGet('systemSettings:get').reply(200, {
312
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
313
+ });
314
+
315
+ const Root = app.getRootComponent();
316
+ render(<Root />);
317
+
318
+ await waitFor(() => {
319
+ expect(apiMock.history.get.some((request) => request.url === 'roles:check')).toBe(true);
320
+ });
321
+ const header = await screen.findByRole('banner');
322
+ expect(within(header).queryByRole('menu')).not.toBeInTheDocument();
323
+ expect(within(header).queryByTitle('Search settings')).not.toBeInTheDocument();
324
+
325
+ resolveRoleCheck([200, { data: { role: 'root', snippets: ['pm'] } }]);
326
+ await waitFor(() => {
327
+ expect(within(header).getByRole('menu')).toBeInTheDocument();
328
+ expect(within(header).getByTitle('Search settings')).toBeInTheDocument();
329
+ });
330
+ });
331
+
332
+ it('keeps Settings navigation hidden until the latest overlapping role check completes', async () => {
333
+ let resolveOlderCheck: (response: [number, { data: { role: string; snippets: string[] } }]) => void = () => {
334
+ throw new Error('Older role check resolver is not initialized');
335
+ };
336
+ let resolveLatestCheck: (response: [number, { data: { role: string; snippets: string[] } }]) => void = () => {
337
+ throw new Error('Latest role check resolver is not initialized');
338
+ };
339
+ const olderCheckResponse = new Promise<[number, { data: { role: string; snippets: string[] } }]>((resolve) => {
340
+ resolveOlderCheck = resolve;
341
+ });
342
+ const latestCheckResponse = new Promise<[number, { data: { role: string; snippets: string[] } }]>((resolve) => {
343
+ resolveLatestCheck = resolve;
344
+ });
345
+ const app = new SettingsApplication({
346
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin, RoleRecheckSettingsPlugin],
347
+ router: { type: 'memory', initialEntries: ['/settings/role-recheck'] },
348
+ ws: false,
349
+ });
350
+ const apiMock = new MockAdapter(app.apiClient.axios);
351
+ app.dataSourceManager.ensureLoaded = async () => {};
352
+ apiMock.onGet('app:getLang').reply(200, {
353
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
354
+ });
355
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
356
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
357
+ apiMock.onGet('roles:check').replyOnce(200, { data: { role: 'root', snippets: ['pm'] } });
358
+ apiMock.onGet('roles:check').replyOnce(() => olderCheckResponse);
359
+ apiMock.onGet('roles:check').replyOnce(() => latestCheckResponse);
360
+ apiMock.onGet('systemSettings:get').reply(200, {
361
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
362
+ });
363
+
364
+ const Root = app.getRootComponent();
365
+ render(<Root />);
366
+
367
+ expect(await screen.findByText('Role recheck page')).toBeInTheDocument();
368
+ const header = await screen.findByRole('banner');
369
+ expect(within(header).getByRole('menu')).toBeInTheDocument();
370
+ if (!recheckRole) {
371
+ throw new Error('Expected the role recheck callback to be available');
372
+ }
373
+
374
+ let olderCheck: Promise<void> = Promise.resolve();
375
+ let latestCheck: Promise<void> = Promise.resolve();
376
+ act(() => {
377
+ olderCheck = recheckRole?.() || Promise.resolve();
378
+ latestCheck = recheckRole?.() || Promise.resolve();
379
+ });
380
+ await waitFor(() => {
381
+ expect(apiMock.history.get.filter((request) => request.url === 'roles:check')).toHaveLength(3);
382
+ });
383
+ expect(within(header).queryByRole('menu')).not.toBeInTheDocument();
384
+
385
+ await act(async () => {
386
+ resolveOlderCheck([200, { data: { role: 'root', snippets: ['pm'] } }]);
387
+ await olderCheck;
388
+ });
389
+ expect(within(header).queryByRole('menu')).not.toBeInTheDocument();
390
+
391
+ await act(async () => {
392
+ resolveLatestCheck([200, { data: { role: 'member', snippets: ['!pm.*'] } }]);
393
+ await latestCheck;
394
+ });
395
+ expect(within(header).queryByRole('menu')).not.toBeInTheDocument();
396
+ expect(within(header).getByTitle('Search settings')).toBeInTheDocument();
397
+ });
398
+
399
+ it('ignores a pending role check from an unmounted provider', async () => {
400
+ let resolveUnmountedCheck: (response: [number, { data: { role: string; snippets: string[] } }]) => void = () => {
401
+ throw new Error('Unmounted role check resolver is not initialized');
402
+ };
403
+ let resolveRemountedCheck: (response: [number, { data: { role: string; snippets: string[] } }]) => void = () => {
404
+ throw new Error('Remounted role check resolver is not initialized');
405
+ };
406
+ const unmountedCheckResponse = new Promise<[number, { data: { role: string; snippets: string[] } }]>((resolve) => {
407
+ resolveUnmountedCheck = resolve;
408
+ });
409
+ const remountedCheckResponse = new Promise<[number, { data: { role: string; snippets: string[] } }]>((resolve) => {
410
+ resolveRemountedCheck = resolve;
411
+ });
412
+ const app = new SettingsApplication({
413
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin, RoleRecheckSettingsPlugin],
414
+ router: { type: 'memory', initialEntries: ['/settings/role-recheck'] },
415
+ ws: false,
416
+ });
417
+ const apiMock = new MockAdapter(app.apiClient.axios);
418
+ app.dataSourceManager.ensureLoaded = async () => {};
419
+ apiMock.onGet('app:getLang').reply(200, {
420
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
421
+ });
422
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
423
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
424
+ apiMock.onGet('roles:check').replyOnce(200, { data: { role: 'root', snippets: ['pm'] } });
425
+ apiMock.onGet('roles:check').replyOnce(() => unmountedCheckResponse);
426
+ apiMock.onGet('roles:check').replyOnce(() => remountedCheckResponse);
427
+ apiMock.onGet('systemSettings:get').reply(200, {
428
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
429
+ });
430
+
431
+ const Root = app.getRootComponent();
432
+ const firstRender = render(<Root />);
433
+
434
+ expect(await screen.findByText('Role recheck page')).toBeInTheDocument();
435
+ if (!recheckRole) {
436
+ throw new Error('Expected the role recheck callback to be available');
437
+ }
438
+
439
+ let unmountedCheck: Promise<void> = Promise.resolve();
440
+ act(() => {
441
+ unmountedCheck = recheckRole?.() || Promise.resolve();
442
+ });
443
+ await waitFor(() => {
444
+ expect(apiMock.history.get.filter((request) => request.url === 'roles:check')).toHaveLength(2);
445
+ });
446
+
447
+ firstRender.unmount();
448
+ render(<Root />);
449
+
450
+ await waitFor(() => {
451
+ expect(apiMock.history.get.filter((request) => request.url === 'roles:check')).toHaveLength(3);
452
+ });
453
+ const remountedHeader = await screen.findByRole('banner');
454
+ expect(within(remountedHeader).queryByRole('menu')).not.toBeInTheDocument();
455
+
456
+ await act(async () => {
457
+ resolveUnmountedCheck([200, { data: { role: 'root', snippets: ['pm'] } }]);
458
+ await unmountedCheck;
459
+ });
460
+ expect(within(remountedHeader).queryByRole('menu')).not.toBeInTheDocument();
461
+
462
+ await act(async () => {
463
+ resolveRemountedCheck([200, { data: { role: 'member', snippets: ['!pm.*'] } }]);
464
+ });
465
+ await waitFor(() => {
466
+ expect(within(remountedHeader).getByTitle('Search settings')).toBeInTheDocument();
467
+ });
468
+ });
469
+
470
+ it('does not reopen Settings search after a role recheck', async () => {
471
+ let resolveRoleCheck: (response: [number, { data: { role: string; snippets: string[] } }]) => void = () => {
472
+ throw new Error('Role check resolver is not initialized');
473
+ };
474
+ const roleCheckResponse = new Promise<[number, { data: { role: string; snippets: string[] } }]>((resolve) => {
475
+ resolveRoleCheck = resolve;
476
+ });
477
+ const app = new SettingsApplication({
478
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin, RoleRecheckSettingsPlugin],
479
+ router: { type: 'memory', initialEntries: ['/settings/role-recheck'] },
480
+ ws: false,
481
+ });
482
+ const apiMock = new MockAdapter(app.apiClient.axios);
483
+ app.dataSourceManager.ensureLoaded = async () => {};
484
+ apiMock.onGet('app:getLang').reply(200, {
485
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
486
+ });
487
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
488
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
489
+ apiMock.onGet('roles:check').replyOnce(200, { data: { role: 'root', snippets: ['pm'] } });
490
+ apiMock.onGet('roles:check').replyOnce(() => roleCheckResponse);
491
+ apiMock.onGet('systemSettings:get').reply(200, {
492
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
493
+ });
494
+
495
+ const Root = app.getRootComponent();
496
+ render(<Root />);
497
+
498
+ expect(await screen.findByText('Role recheck page')).toBeInTheDocument();
499
+ const header = await screen.findByRole('banner');
500
+ fireEvent.click(within(header).getByTitle('Search settings'));
501
+ expect(await screen.findByRole('dialog')).toBeInTheDocument();
502
+ if (!recheckRole) {
503
+ throw new Error('Expected the role recheck callback to be available');
504
+ }
505
+
506
+ let roleCheck: Promise<void> = Promise.resolve();
507
+ act(() => {
508
+ roleCheck = recheckRole?.() || Promise.resolve();
509
+ });
510
+ await waitFor(() => {
511
+ expect(apiMock.history.get.filter((request) => request.url === 'roles:check')).toHaveLength(2);
512
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
513
+ });
514
+
515
+ await act(async () => {
516
+ resolveRoleCheck([200, { data: { role: 'member', snippets: ['!pm.*'] } }]);
517
+ await roleCheck;
518
+ });
519
+ expect(within(header).getByTitle('Search settings')).toBeInTheDocument();
520
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
521
+ });
522
+
523
+ it('keeps Settings navigation and search unavailable when the initial role check fails', async () => {
524
+ vi.spyOn(console, 'error').mockImplementation(() => {});
525
+ const app = new SettingsApplication({
526
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin],
527
+ router: { type: 'memory', initialEntries: ['/settings/system-settings'] },
528
+ ws: false,
529
+ });
530
+ const apiMock = new MockAdapter(app.apiClient.axios);
531
+ app.dataSourceManager.ensureLoaded = async () => {};
532
+ apiMock.onGet('app:getLang').reply(200, {
533
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
534
+ });
535
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
536
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
537
+ apiMock.onGet('roles:check').reply(500, { errors: [{ message: 'Temporary failure' }] });
538
+ apiMock.onGet('systemSettings:get').reply(200, {
539
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
540
+ });
541
+
542
+ const Root = app.getRootComponent();
543
+ render(<Root />);
544
+
545
+ await waitFor(() => {
546
+ expect(apiMock.history.get.some((request) => request.url === 'roles:check')).toBe(true);
547
+ });
548
+ const header = await screen.findByRole('banner');
549
+ expect(within(header).queryByRole('menu')).not.toBeInTheDocument();
550
+ expect(within(header).queryByTitle('Search settings')).not.toBeInTheDocument();
551
+
552
+ const isMac = /Mac|iPhone|iPad/.test(window.navigator.platform || '');
553
+ const shortcutEvent = new KeyboardEvent('keydown', {
554
+ key: 'f',
555
+ ctrlKey: !isMac,
556
+ metaKey: isMac,
557
+ bubbles: true,
558
+ cancelable: true,
559
+ });
560
+ fireEvent(window, shortcutEvent);
561
+ expect(shortcutEvent.defaultPrevented).toBe(false);
562
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
563
+ });
564
+
565
+ it('restores the last successful Settings readiness after a recheck error', async () => {
566
+ vi.spyOn(console, 'error').mockImplementation(() => {});
567
+ const app = new SettingsApplication({
568
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MultiPortalSettingsPlugin, RoleRecheckSettingsPlugin],
569
+ router: { type: 'memory', initialEntries: ['/settings/role-recheck'] },
570
+ ws: false,
571
+ });
572
+ const apiMock = new MockAdapter(app.apiClient.axios);
573
+ app.dataSourceManager.ensureLoaded = async () => {};
574
+ apiMock.onGet('app:getLang').reply(200, {
575
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
576
+ });
577
+ apiMock.onGet('/auth:check').reply(200, { data: { id: 1, nickname: 'Admin' } });
578
+ apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app', version: 'test' } });
579
+ apiMock.onGet('roles:check').replyOnce(200, { data: { role: 'root', snippets: ['pm'] } });
580
+ apiMock.onGet('roles:check').replyOnce(500, { errors: [{ message: 'Temporary failure' }] });
581
+ apiMock.onGet('systemSettings:get').reply(200, {
582
+ data: { id: 1, title: 'NocoBase', raw_title: 'NocoBase', logo: null },
583
+ });
584
+
585
+ const Root = app.getRootComponent();
586
+ render(<Root />);
587
+
588
+ expect(await screen.findByText('Role recheck page')).toBeInTheDocument();
589
+ const header = await screen.findByRole('banner');
590
+ expect(within(header).getByRole('menu')).toBeInTheDocument();
591
+ if (!recheckRole) {
592
+ throw new Error('Expected the role recheck callback to be available');
593
+ }
594
+
595
+ await act(async () => {
596
+ await recheckRole?.();
597
+ });
598
+ expect(within(header).getByRole('menu')).toBeInTheDocument();
599
+ });
260
600
  });
@@ -38,7 +38,7 @@ describe('standalone settings runtime paths', () => {
38
38
  expect(resolveStandaloneSettingsPath(app, '/nocobase/v/admin/workflow/executions/88')).toBe(
39
39
  '/nocobase/settings/workflow/executions/88',
40
40
  );
41
- expect(resolveStandaloneSettingsPath(app, '/nocobase/v/admin/ai/knowledge-base/detail/k1/documents')).toBe(
41
+ expect(resolveStandaloneSettingsPath(app, '/nocobase/v/admin/settings/ai/knowledge-base/detail/k1/documents')).toBe(
42
42
  '/nocobase/settings/ai/knowledge-base/detail/k1/documents',
43
43
  );
44
44
  });