@nocobase/app-template-default 1.0.0-beta.86 → 1.0.0-beta.88

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 (63) hide show
  1. package/.agents/rules/notification-templates.md +5 -0
  2. package/.agents/rules/shell.md +4 -2
  3. package/AGENTS.md +3 -1
  4. package/README.MD +11 -5
  5. package/client/components/body-portal.tsx +25 -0
  6. package/client/components/breadcrumbs.tsx +13 -0
  7. package/client/components/flow-diagram.tsx +2 -2
  8. package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +1 -1
  9. package/client/layouts/app-layout.tsx +8 -4
  10. package/client/layouts/components/header-actions.tsx +0 -2
  11. package/client/layouts/components/sidebar-footer.tsx +18 -25
  12. package/client/layouts/components/user-menu.tsx +6 -13
  13. package/client/lib/navigation-transform.ts +156 -22
  14. package/client/locales/system/en-US.ts +8 -6
  15. package/client/locales/system/settings-en-US.ts +17 -0
  16. package/client/locales/system/settings-zh-CN.ts +15 -0
  17. package/client/locales/system/zh-CN.ts +8 -6
  18. package/client/pages/auth/register.tsx +39 -22
  19. package/client/pages/auth/shared.tsx +1 -3
  20. package/client/pages/settings/hooks.tsx +3 -68
  21. package/client/pages/settings/notification-routes.ts +109 -0
  22. package/client/pages/settings/notification-templates.tsx +26 -0
  23. package/client/pages/settings/permission-management-page.tsx +15 -90
  24. package/client/pages/settings/routes.ts +96 -98
  25. package/client/pages/settings/settings-home.tsx +109 -0
  26. package/client/pages/settings/settings-module-page.tsx +158 -0
  27. package/client/pages/settings/shell-routes.ts +53 -0
  28. package/client/routes.ts +17 -1
  29. package/client/routing/app-router.tsx +1 -14
  30. package/client/routing/client-route.tsx +16 -9
  31. package/client/routing/route-context.tsx +30 -3
  32. package/client/routing/route-leave-boundary.tsx +249 -0
  33. package/client/routing/route-navigation.ts +72 -8
  34. package/client/routing/route-tree.tsx +7 -12
  35. package/client/routing/standalone-page-layout.tsx +0 -5
  36. package/client/runtime.ts +2 -2
  37. package/client/theme/theme-settings.tsx +60 -162
  38. package/config.example.yml +51 -32
  39. package/package.json +28 -28
  40. package/server/config/auth.ts +2 -2
  41. package/server/config/index.ts +3 -0
  42. package/server/config/mailers.ts +11 -0
  43. package/server/providers/index.ts +2 -0
  44. package/server/providers/notification-management.ts +27 -0
  45. package/tests/client/account-permissions.test.tsx +1 -1
  46. package/tests/client/client-auth.test.tsx +10 -1
  47. package/tests/client/client-routes.test.ts +137 -4
  48. package/tests/client/client-shell.test.tsx +29 -10
  49. package/tests/client/client-theme.test.tsx +118 -39
  50. package/tests/client/foundation-integration.test.ts +38 -17
  51. package/tests/client/header-hover.test.tsx +33 -5
  52. package/tests/client/layout-i18n.test.tsx +9 -4
  53. package/tests/client/permission-management-tabs.test.tsx +421 -16
  54. package/tests/client/registration-feedback.test.tsx +143 -0
  55. package/tests/client/settings-home.test.tsx +110 -0
  56. package/tests/client/settings-navigation.test.ts +230 -0
  57. package/tests/client/standalone-page-layout.test.tsx +45 -0
  58. package/tests/client/user-menu-sign-out.test.tsx +38 -14
  59. package/tests/client/user-menu.test.tsx +65 -0
  60. package/tests/fixtures/theme-tokens.tsx +13 -1
  61. package/tests/server/auth-email-config.test.ts +51 -17
  62. package/tests/server/notification-templates.test.ts +144 -0
  63. package/tests/client/permission-management-routes.test.ts +0 -64
@@ -6,9 +6,139 @@ import {
6
6
  import { describe, expect, it } from 'vitest';
7
7
 
8
8
  import systemSettingsRoutes from '../../client/pages/settings/routes.js';
9
- import applicationRoutes from '../../client/routes.ts';
9
+ import applicationRoutes, {
10
+ createApplicationRoutes,
11
+ } from '../../client/routes.ts';
12
+ import { includeNotificationTemplateRoutes } from '../../client/pages/settings/notification-routes.js';
13
+ import { defineAppRoutes } from '@nocobase/app-client/plugins';
10
14
 
11
15
  describe('app client routes', () => {
16
+ it('joins the settings system category across contributions without moving the log URL', () => {
17
+ const shell = defineAppRoutes([
18
+ {
19
+ name: 'settings',
20
+ path: '/settings',
21
+ navigation: { title: 'Settings' },
22
+ children: [
23
+ {
24
+ name: 'settingsSystem',
25
+ navigation: { title: 'System' },
26
+ children: [],
27
+ },
28
+ ],
29
+ },
30
+ ]);
31
+ const logs = defineAppRoutes([
32
+ {
33
+ name: 'notificationManagement',
34
+ parent: 'settingsSystem',
35
+ navigation: { title: 'Notifications' },
36
+ children: [
37
+ {
38
+ name: 'notificationLogs',
39
+ path: '/notifications/logs',
40
+ auth: 'required',
41
+ authz: {
42
+ resource: { type: 'page', id: 'notification.logs' },
43
+ action: 'access',
44
+ },
45
+ componentLoader: () =>
46
+ import('../../client/pages/settings/notification-templates.js'),
47
+ },
48
+ ],
49
+ },
50
+ ]);
51
+ const routes = includeNotificationTemplateRoutes([shell, logs], true);
52
+ const built = resolveAppClientContributions([
53
+ {
54
+ packageName: '@nocobase/app-template-default',
55
+ source: 'application',
56
+ routes,
57
+ },
58
+ ]);
59
+ expect(pagePaths(built.routes)).toEqual([
60
+ '/settings/notifications/logs',
61
+ '/settings/notifications/templates',
62
+ ]);
63
+ expect(built.routes[0].children[0].children).toHaveLength(1);
64
+ const created = includeNotificationTemplateRoutes([shell], true);
65
+ expect(
66
+ created
67
+ .flatMap((part) => part.routes)
68
+ .find((route) => route.name === 'notificationManagement')?.parent,
69
+ ).toBe('settingsSystem');
70
+ });
71
+
72
+ it('exposes configured invitation templates through the real application route factory', async () => {
73
+ const built = resolveAppClientContributions([
74
+ {
75
+ packageName: '@nocobase/app-template-default',
76
+ source: 'application',
77
+ routes: createApplicationRoutes({
78
+ notificationTemplates: { invitationConfigured: true },
79
+ }),
80
+ },
81
+ ]);
82
+ expect(pagePaths(built.routes)).toContain(
83
+ '/settings/notifications/templates',
84
+ );
85
+ expect(pageAuthorizations(built.routes)).toContainEqual({
86
+ name: 'notificationTemplates',
87
+ authorizedAs: 'notification.templates',
88
+ });
89
+ for (const loader of componentLoadersIn(built.routes))
90
+ await expect(loader()).resolves.toMatchObject({
91
+ default: expect.any(Function),
92
+ });
93
+ });
94
+
95
+ it('reuses the notification module and preserves its logs and existing grants', () => {
96
+ const existing = defineAppRoutes([
97
+ {
98
+ name: 'settings',
99
+ path: '/settings',
100
+ navigation: { title: 'Settings' },
101
+ children: [
102
+ {
103
+ name: 'notificationManagement',
104
+ navigation: { title: 'Notifications' },
105
+ children: [
106
+ {
107
+ name: 'notificationLogs',
108
+ path: '/notification-logs',
109
+ auth: 'required',
110
+ authz: {
111
+ resource: { type: 'page', id: 'existing.notification.logs' },
112
+ action: 'access',
113
+ },
114
+ componentLoader: () =>
115
+ import('../../client/pages/settings/notification-templates.js'),
116
+ },
117
+ ],
118
+ },
119
+ ],
120
+ },
121
+ ]);
122
+ const once = includeNotificationTemplateRoutes([existing], false);
123
+ expect(includeNotificationTemplateRoutes(once, true)).toBe(once);
124
+ const built = resolveAppClientContributions([
125
+ {
126
+ packageName: '@nocobase/app-template-default',
127
+ source: 'application',
128
+ routes: once,
129
+ },
130
+ ]);
131
+ expect(pagePaths(built.routes)).toEqual([
132
+ '/settings/notification-logs',
133
+ '/settings/notifications/templates',
134
+ ]);
135
+ expect(pageAuthorizations(built.routes)).toEqual([
136
+ { name: 'notificationLogs', authorizedAs: 'existing.notification.logs' },
137
+ { name: 'notificationTemplates', authorizedAs: 'notification.templates' },
138
+ ]);
139
+ expect(built.routes[0].children).toHaveLength(1);
140
+ });
141
+
12
142
  it('keeps the landing page and the authentication pages', () => {
13
143
  // The authentication plugin and this application have to agree on these paths: it sends an unknown visitor to
14
144
  // /login, sends a signed-in user who opens a guest page back to /, and mails a reset link to /reset-password,
@@ -55,15 +185,17 @@ describe('app client routes', () => {
55
185
  expect(pageAuthorizations(resolved.routes)).toEqual([
56
186
  { name: 'home', authorizedAs: null },
57
187
  { name: 'inbox', authorizedAs: null },
188
+ { name: 'settingsHome', authorizedAs: null },
58
189
  ]);
59
190
  });
60
191
 
61
- it('keeps a bare application home-only and supports settings registration as business capabilities are developed', async () => {
192
+ it('keeps a bare application free of settings modules and supports settings registration as business capabilities are developed', async () => {
193
+ // Only the settings home, which lists what the viewer may open and grants nothing itself.
62
194
  expect(
63
- pagePaths(resolveRoutes().routes).some((path) =>
195
+ pagePaths(resolveRoutes().routes).filter((path) =>
64
196
  path.startsWith('/settings'),
65
197
  ),
66
- ).toBe(false);
198
+ ).toEqual(['/settings']);
67
199
  const built = resolveAppClientContributions([
68
200
  {
69
201
  packageName: '@nocobase/app-template-default',
@@ -74,6 +206,7 @@ describe('app client routes', () => {
74
206
  expect(pageAuthorizations(built.routes)).toEqual([
75
207
  { name: 'home', authorizedAs: null },
76
208
  { name: 'inbox', authorizedAs: null },
209
+ { name: 'settingsHome', authorizedAs: null },
77
210
  { name: 'settingsUsers', authorizedAs: 'user:*' },
78
211
  { name: 'settingsUserCreate', authorizedAs: 'user:*' },
79
212
  { name: 'settingsUser', authorizedAs: 'user:*' },
@@ -5,6 +5,7 @@ import {
5
5
  import {
6
6
  apiClientToken,
7
7
  ClientApplicationContext,
8
+ NavigationGuardProvider,
8
9
  type ClientApplication,
9
10
  createAppClientConfig,
10
11
  } from '@nocobase/app-client';
@@ -83,19 +84,21 @@ describe('application shell', () => {
83
84
  expect(
84
85
  await screen.findByRole('button', { name: 'Open account menu' }),
85
86
  ).not.toHaveAttribute('title');
86
- expect(screen.getByRole('button', { name: 'Appearance' })).toBeVisible();
87
+ expect(
88
+ screen.queryByRole('button', { name: 'Theme' }),
89
+ ).not.toBeInTheDocument();
87
90
  expect(
88
91
  screen.queryByRole('link', { name: 'Settings' }),
89
92
  ).not.toBeInTheDocument();
90
- expect(screen.getByText('AI builds freely.')).toBeVisible();
93
+ expect(screen.getByText('Build reliable business systems')).toBeVisible();
91
94
  expect(screen.getByRole('link', { name: 'NocoBase' })).toHaveAttribute(
92
95
  'href',
93
96
  'https://www.nocobase.com',
94
97
  );
95
98
  expect(
96
99
  screen.getByText('NocoBase', { selector: 'a' }).parentElement,
97
- ).toHaveTextContent('NocoBase keeps it reliable.');
98
- expect(screen.getByText('Default Template')).toHaveClass('truncate');
100
+ ).toHaveTextContent('AI + NocoBase');
101
+ expect(screen.queryByText('Default Template')).not.toBeInTheDocument();
99
102
  expect(screen.getByText('v0.0.0')).toHaveClass('truncate');
100
103
  // shadcn's edge rail, with a translated label in place of its built-in one.
101
104
  expect(
@@ -199,13 +202,25 @@ describe('application shell', () => {
199
202
  expect(
200
203
  await screen.findByRole('button', { name: 'Expand navigation' }),
201
204
  ).toHaveAttribute('aria-pressed', 'true');
202
- // The footer keeps only its shield; focusing it shows the slogan, name and version.
205
+ // The footer keeps only its shield; focusing it shows the slogan and version.
203
206
  const footerIcon = screen.getByRole('img', {
204
- name: 'AI builds freely. NocoBase keeps it reliable. Default Template v0.0.0',
207
+ name: 'AI + NocoBase Build reliable business systems v0.0.0',
205
208
  });
206
209
  act(() => footerIcon.focus());
207
210
  expect(
208
- await screen.findByText('Default Template v0.0.0', {
211
+ await screen.findByText('v0.0.0', {
212
+ selector: '[data-slot="tooltip-content"] span',
213
+ }),
214
+ ).toBeVisible();
215
+
216
+ expect(screen.queryByText(/Default Template/)).not.toBeInTheDocument();
217
+ expect(
218
+ screen.getByText('AI + NocoBase', {
219
+ selector: '[data-slot="tooltip-content"] span',
220
+ }),
221
+ ).toBeVisible();
222
+ expect(
223
+ screen.getByText('Build reliable business systems', {
209
224
  selector: '[data-slot="tooltip-content"] span',
210
225
  }),
211
226
  ).toBeVisible();
@@ -221,7 +236,7 @@ describe('application shell', () => {
221
236
  ),
222
237
  );
223
238
  expect(
224
- screen.queryByRole('img', { name: /Default Template/ }),
239
+ screen.queryByRole('img', { name: /AI \+ NocoBase/ }),
225
240
  ).not.toBeInTheDocument();
226
241
  });
227
242
 
@@ -261,7 +276,9 @@ describe('application shell', () => {
261
276
  expect(
262
277
  screen.queryByRole('navigation', { name: 'Application navigation' }),
263
278
  ).not.toBeInTheDocument();
264
- expect(screen.getByRole('button', { name: 'Appearance' })).toBeVisible();
279
+ expect(
280
+ screen.queryByRole('button', { name: 'Theme' }),
281
+ ).not.toBeInTheDocument();
265
282
  });
266
283
  });
267
284
 
@@ -312,7 +329,9 @@ function renderApplication(
312
329
  <SidebarPreferenceProvider
313
330
  value={createAppSidebarPreferenceStore('/main')}
314
331
  >
315
- <AppRouter clientRoutes={clientRoutes} />
332
+ <NavigationGuardProvider>
333
+ <AppRouter clientRoutes={clientRoutes} />
334
+ </NavigationGuardProvider>
316
335
  </SidebarPreferenceProvider>
317
336
  </AppThemeProvider>
318
337
  </MemoryRouter>
@@ -16,10 +16,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
16
 
17
17
  import {
18
18
  AppThemeProvider,
19
- ThemeSettings,
19
+ ThemeSettings as ThemeMenu,
20
20
  useTheme,
21
21
  } from '../../client/theme/index.ts';
22
22
 
23
+ import {
24
+ DropdownMenu,
25
+ DropdownMenuContent,
26
+ DropdownMenuTrigger,
27
+ } from '../../client/components/ui/dropdown-menu';
28
+
29
+ function ThemeSettings(): ReactElement {
30
+ return (
31
+ <DropdownMenu>
32
+ <DropdownMenuTrigger>Account</DropdownMenuTrigger>
33
+ <DropdownMenuContent>
34
+ <ThemeMenu />
35
+ </DropdownMenuContent>
36
+ </DropdownMenu>
37
+ );
38
+ }
39
+
23
40
  /**
24
41
  * Renders the client configuration the server would, mounted at `/crm` so the theme's storage keys are
25
42
  * `nocobase:crm:*`. It replaces any block already on the page, since the client reads the first one it finds.
@@ -38,19 +55,65 @@ function renderClientConfig(
38
55
  document.head.append(element);
39
56
  }
40
57
 
41
- /**
42
- * Opens the appearance popover from the keyboard. Its trigger opens on hover, and a pointer press landing more than
43
- * Base UI's 500 ms patient-click threshold after that, as on a loaded runner, closes it again.
44
- */
45
- async function openAppearance(name = 'Appearance'): Promise<void> {
46
- act(() => screen.getByRole('button', { name }).focus());
58
+ /** Opens the account menu and its Theme submenu using the keyboard. */
59
+ async function openTheme(name = 'Theme'): Promise<void> {
60
+ act(() => screen.getByRole('button', { name: 'Account' }).focus());
47
61
  await userEvent.keyboard('{Enter}');
62
+ act(() => screen.getByRole('menuitem', { name }).focus());
63
+ await userEvent.keyboard('{ArrowRight}');
48
64
  }
49
65
 
50
66
  describe('app client theme', () => {
51
67
  it.each([
52
- ['en-US', 'Appearance', 'Spacious'],
53
- ['zh-CN', '外观', '宽松'],
68
+ ['Light', 'light', 'Compact', 'compact'],
69
+ ['Dark', 'dark', 'Compact', 'compact'],
70
+ ['System', 'system', 'Compact', 'compact'],
71
+ ['Light', 'light', 'Default', 'default'],
72
+ ['Dark', 'dark', 'Default', 'default'],
73
+ ['System', 'system', 'Default', 'default'],
74
+ ])(
75
+ 'saves and restores %s mode with %s / %s density',
76
+ async (modeLabel, mode, presetLabel, preset) => {
77
+ const settings = (
78
+ <AppThemeProvider>
79
+ <ThemeSettings />
80
+ </AppThemeProvider>
81
+ );
82
+ const view = render(settings);
83
+ await openTheme();
84
+ await userEvent.click(
85
+ screen.getByRole('menuitemradio', { name: modeLabel }),
86
+ );
87
+ await userEvent.click(
88
+ screen.getByRole('menuitemradio', { name: presetLabel }),
89
+ );
90
+ expect(localStorage.getItem('nocobase:crm:theme:color-scheme')).toBe(
91
+ mode,
92
+ );
93
+ expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe(preset);
94
+ view.unmount();
95
+ initializeTheme(
96
+ '/crm/',
97
+ themePresets.map(({ id }) => id),
98
+ );
99
+ render(settings);
100
+ await openTheme();
101
+ expect(document.documentElement).toHaveClass(
102
+ mode === 'light' ? 'light' : 'dark',
103
+ );
104
+ expect(document.documentElement).toHaveAttribute('data-theme', preset);
105
+ expect(
106
+ screen.getByRole('menuitemradio', { name: modeLabel }),
107
+ ).toHaveAttribute('aria-checked', 'true');
108
+ expect(
109
+ screen.getByRole('menuitemradio', { name: presetLabel }),
110
+ ).toHaveAttribute('aria-checked', 'true');
111
+ },
112
+ );
113
+
114
+ it.each([
115
+ ['en-US', 'Theme', 'Spacious'],
116
+ ['zh-CN', '主题', '宽松'],
54
117
  ])(
55
118
  'labels the default preset without implying default selection (%s)',
56
119
  async (locale, appearance, label) => {
@@ -68,28 +131,32 @@ describe('app client theme', () => {
68
131
  </AppThemeProvider>
69
132
  </I18nProvider>,
70
133
  );
71
- await openAppearance(appearance);
72
- const option = await screen.findByRole('radio', { name: label });
134
+ await openTheme(appearance);
135
+ const option = await screen.findByRole('menuitemradio', { name: label });
73
136
  await userEvent.click(option);
74
- expect(option).toBeChecked();
137
+ expect(option).toHaveAttribute('aria-checked', 'true');
75
138
  expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('default');
76
139
  },
77
140
  );
78
141
 
79
- it('changes presets with arrow keys and returns focus on Escape', async () => {
142
+ it('selects a preset by keyboard and returns focus on Escape', async () => {
80
143
  render(
81
144
  <AppThemeProvider>
82
145
  <ThemeSettings />
83
146
  </AppThemeProvider>,
84
147
  );
85
- await openAppearance();
86
- const compact = await screen.findByRole('radio', { name: 'Compact' });
148
+ await openTheme();
149
+ const compact = await screen.findByRole('menuitemradio', {
150
+ name: 'Compact',
151
+ });
87
152
  act(() => compact.focus());
88
- await userEvent.keyboard('{ArrowRight}');
89
- expect(screen.getByRole('radio', { name: 'Default' })).toBeChecked();
153
+ await userEvent.keyboard('{ArrowDown}{Enter}');
154
+ expect(
155
+ screen.getByRole('menuitemradio', { name: 'Default' }),
156
+ ).toHaveAttribute('aria-checked', 'true');
90
157
  expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('default');
91
158
  await userEvent.keyboard('{Escape}');
92
- expect(screen.getByRole('button', { name: 'Appearance' })).toHaveFocus();
159
+ expect(screen.getByRole('menuitem', { name: 'Theme' })).toHaveFocus();
93
160
  });
94
161
 
95
162
  it('falls back from the removed Ant Design preset without changing mode', async () => {
@@ -100,11 +167,13 @@ describe('app client theme', () => {
100
167
  <ThemeSettings />
101
168
  </AppThemeProvider>,
102
169
  );
103
- await openAppearance();
170
+ await openTheme();
104
171
  expect(
105
- screen.queryByRole('radio', { name: 'Ant-design' }),
172
+ screen.queryByRole('menuitemradio', { name: 'Ant-design' }),
106
173
  ).not.toBeInTheDocument();
107
- expect(await screen.findByRole('radio', { name: 'Compact' })).toBeChecked();
174
+ expect(
175
+ await screen.findByRole('menuitemradio', { name: 'Compact' }),
176
+ ).toHaveAttribute('aria-checked', 'true');
108
177
  expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
109
178
  expect(document.documentElement).toHaveClass('light');
110
179
  });
@@ -115,13 +184,15 @@ describe('app client theme', () => {
115
184
  <ThemeSettings />
116
185
  </AppThemeProvider>,
117
186
  );
118
- await openAppearance();
187
+ await openTheme();
119
188
  expect(
120
- screen.queryByRole('radio', { name: 'Ocean' }),
189
+ screen.queryByRole('menuitemradio', { name: 'Ocean' }),
121
190
  ).not.toBeInTheDocument();
122
- expect(await screen.findByRole('radio', { name: 'Compact' })).toBeChecked();
123
191
  expect(
124
- await screen.findByRole('radio', { name: 'Default' }),
192
+ await screen.findByRole('menuitemradio', { name: 'Compact' }),
193
+ ).toHaveAttribute('aria-checked', 'true');
194
+ expect(
195
+ await screen.findByRole('menuitemradio', { name: 'Default' }),
125
196
  ).toBeInTheDocument();
126
197
  expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
127
198
  });
@@ -218,12 +289,12 @@ describe('app client theme', () => {
218
289
  <ThemeSettings />
219
290
  </AppThemeProvider>,
220
291
  );
221
- await openAppearance();
292
+ await openTheme();
222
293
  expect(
223
- await screen.findByRole('radio', {
294
+ await screen.findByRole('menuitemradio', {
224
295
  name: expected === 'compact' ? 'Compact' : 'Default',
225
296
  }),
226
- ).toBeChecked();
297
+ ).toHaveAttribute('aria-checked', 'true');
227
298
  expect(document.documentElement).toHaveAttribute('data-theme', expected);
228
299
  expect(localStorage.getItem(key)).toBe(saved ?? null);
229
300
  },
@@ -334,14 +405,16 @@ describe('app client theme', () => {
334
405
  </AppThemeProvider>,
335
406
  );
336
407
 
337
- await openAppearance();
408
+ await openTheme();
338
409
  await userEvent.click(
339
- await screen.findByRole('radio', { name: 'Compact' }),
410
+ await screen.findByRole('menuitemradio', { name: 'Compact' }),
340
411
  );
341
412
  expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
342
413
  expect(document.documentElement).toHaveClass('dark');
343
414
  expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('compact');
344
- await userEvent.click(await screen.findByRole('radio', { name: 'Light' }));
415
+ await userEvent.click(
416
+ await screen.findByRole('menuitemradio', { name: 'Light' }),
417
+ );
345
418
  expect(document.documentElement).toHaveClass('light');
346
419
  expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
347
420
  fireEvent(
@@ -359,10 +432,12 @@ describe('app client theme', () => {
359
432
  newValue: 'default',
360
433
  }),
361
434
  );
362
- expect(await screen.findByRole('radio', { name: 'Default' })).toBeChecked();
435
+ expect(
436
+ await screen.findByRole('menuitemradio', { name: 'Default' }),
437
+ ).toHaveAttribute('aria-checked', 'true');
363
438
  await userEvent.keyboard('{Escape}');
364
439
  await waitFor(() =>
365
- expect(screen.getByRole('button', { name: 'Appearance' })).toHaveFocus(),
440
+ expect(screen.getByRole('menuitem', { name: 'Theme' })).toHaveFocus(),
366
441
  );
367
442
  });
368
443
 
@@ -395,11 +470,13 @@ describe('app client theme', () => {
395
470
  <ThemeSettings />
396
471
  </AppThemeProvider>,
397
472
  );
398
- await openAppearance();
473
+ await openTheme();
474
+ await userEvent.click(
475
+ await screen.findByRole('menuitemradio', { name: 'Default' }),
476
+ );
399
477
  await userEvent.click(
400
- await screen.findByRole('radio', { name: 'Default' }),
478
+ await screen.findByRole('menuitemradio', { name: 'Light' }),
401
479
  );
402
- await userEvent.click(await screen.findByRole('radio', { name: 'Light' }));
403
480
  expect(document.documentElement).toHaveAttribute('data-theme', 'default');
404
481
  expect(document.documentElement).toHaveClass('light');
405
482
  });
@@ -410,9 +487,9 @@ describe('app client theme', () => {
410
487
  <ThemeSettings />
411
488
  </AppThemeProvider>,
412
489
  );
413
- await openAppearance();
490
+ await openTheme();
414
491
  await userEvent.click(
415
- await screen.findByRole('radio', { name: 'Default' }),
492
+ await screen.findByRole('menuitemradio', { name: 'Default' }),
416
493
  );
417
494
  fireEvent(
418
495
  window,
@@ -438,7 +515,9 @@ describe('app client theme', () => {
438
515
  }),
439
516
  );
440
517
  await waitFor(() => expect(document.documentElement).toHaveClass('dark'));
441
- expect(await screen.findByRole('radio', { name: 'System' })).toBeChecked();
518
+ expect(
519
+ await screen.findByRole('menuitemradio', { name: 'System' }),
520
+ ).toHaveAttribute('aria-checked', 'true');
442
521
  });
443
522
  });
444
523
 
@@ -2,10 +2,14 @@ import { describe, expect, it } from 'vitest';
2
2
  import { resolveAppRuntime } from '@nocobase/app-client/runtime';
3
3
  import { defineAppRuntime } from '@nocobase/app-client/runtime';
4
4
  import appRuntime from '../../client/runtime.js';
5
+ import { createApplicationRoutes } from '../../client/routes.js';
5
6
  import cliPlugins from '../../cli/plugins.js';
6
7
  import serverPlugins from '../../server/plugins.js';
7
8
  import settingsRoutes from '../../client/pages/settings/routes.js';
8
- import { buildRouteNavigation } from '../../client/routing/route-navigation.js';
9
+ import {
10
+ buildRouteNavigation,
11
+ buildSettingsCatalog,
12
+ } from '../../client/routing/route-navigation.js';
9
13
 
10
14
  describe('default Foundation integration', () => {
11
15
  it('registers the Foundation deployment evidence build hook', () => {
@@ -15,11 +19,18 @@ describe('default Foundation integration', () => {
15
19
  ),
16
20
  ).toHaveLength(1);
17
21
  });
18
- it('exposes Foundation in settings for an unrestricted administrator and removes the empty group when denied', async () => {
22
+ it('exposes Foundation in System settings for an unrestricted administrator and removes the entry when denied', async () => {
19
23
  const runtime = await resolveAppRuntime(appRuntime);
20
24
  const menu = buildRouteNavigation(runtime.routes, new Set());
21
- const group = menu.find(({ route }) => route.name === 'foundationSettings');
22
- const page = group?.children.find(
25
+ // One System settings entry; Foundation is a card on its home, not a sidebar entry of its own.
26
+ expect(menu.map(({ route }) => route.name)).toEqual([
27
+ 'home',
28
+ 'settingsHome',
29
+ ]);
30
+ const category = buildSettingsCatalog(runtime.routes, new Set()).find(
31
+ ({ route }) => route.name === 'settingsSystem',
32
+ );
33
+ const page = category?.modules.find(
23
34
  ({ route }) => route.name === 'foundation',
24
35
  )?.route;
25
36
  expect(page).toMatchObject({
@@ -28,12 +39,13 @@ describe('default Foundation integration', () => {
28
39
  authz: 'unrestricted',
29
40
  });
30
41
  if (!page) throw new Error('Foundation route missing');
42
+ const denied = new Set([`${page.packageName}:${page.path}:${page.id}`]);
31
43
  expect(
32
- buildRouteNavigation(
33
- runtime.routes,
34
- new Set([`${page.packageName}:${page.path}:${page.id}`]),
35
- ).some(({ route }) => route.name === 'foundationSettings'),
36
- ).toBe(false);
44
+ buildRouteNavigation(runtime.routes, denied).map(
45
+ ({ route }) => route.name,
46
+ ),
47
+ ).toEqual(['home']);
48
+ expect(buildSettingsCatalog(runtime.routes, denied)).toEqual([]);
37
49
  expect(
38
50
  serverPlugins.plugins.filter(
39
51
  (plugin) => plugin.packageName === '@nocobase/app-plugin-foundation',
@@ -41,22 +53,31 @@ describe('default Foundation integration', () => {
41
53
  ).toHaveLength(1);
42
54
  });
43
55
 
44
- it('merges Foundation into business settings without changing its protected URL', async () => {
56
+ it('keeps Foundation beside business settings without changing its protected URL', async () => {
45
57
  const runtime = await resolveAppRuntime(
46
58
  defineAppRuntime({
47
59
  ...appRuntime,
48
- routes: [...appRuntime.routes, settingsRoutes],
60
+ routes: () => [...createApplicationRoutes(), settingsRoutes],
49
61
  }),
50
62
  );
51
63
  const menu = buildRouteNavigation(runtime.routes, new Set());
52
64
  expect(menu.some(({ route }) => route.name === 'foundationSettings')).toBe(
53
65
  false,
54
66
  );
55
- const settings = menu.find(({ route }) => route.name === 'settings');
56
- expect(settings?.children.at(-1)?.route).toMatchObject({
57
- name: 'foundation',
58
- path: '/foundation',
59
- authz: 'unrestricted',
60
- });
67
+ expect(
68
+ buildSettingsCatalog(runtime.routes, new Set()).map((category) => [
69
+ category.route.name,
70
+ category.modules.map(({ route, target }) => [route.name, target.path]),
71
+ ]),
72
+ ).toEqual([
73
+ [
74
+ 'settingsAccount',
75
+ [
76
+ ['settingsUsers', '/settings/users'],
77
+ ['permissionManagement', '/settings/roles'],
78
+ ],
79
+ ],
80
+ ['settingsSystem', [['foundation', '/foundation']]],
81
+ ]);
61
82
  });
62
83
  });