@nocobase/app-template-default 1.0.0-beta.87 → 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 (49) hide show
  1. package/.agents/rules/notification-templates.md +5 -0
  2. package/.agents/rules/shell.md +2 -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/layouts/app-layout.tsx +8 -4
  9. package/client/layouts/components/sidebar-footer.tsx +18 -25
  10. package/client/lib/navigation-transform.ts +156 -22
  11. package/client/locales/system/en-US.ts +7 -5
  12. package/client/locales/system/settings-en-US.ts +17 -0
  13. package/client/locales/system/settings-zh-CN.ts +15 -0
  14. package/client/locales/system/zh-CN.ts +7 -5
  15. package/client/pages/auth/register.tsx +39 -22
  16. package/client/pages/auth/shared.tsx +1 -3
  17. package/client/pages/settings/hooks.tsx +3 -68
  18. package/client/pages/settings/notification-routes.ts +109 -0
  19. package/client/pages/settings/notification-templates.tsx +26 -0
  20. package/client/pages/settings/permission-management-page.tsx +15 -90
  21. package/client/pages/settings/routes.ts +96 -98
  22. package/client/pages/settings/settings-home.tsx +109 -0
  23. package/client/pages/settings/settings-module-page.tsx +158 -0
  24. package/client/pages/settings/shell-routes.ts +53 -0
  25. package/client/routes.ts +17 -1
  26. package/client/routing/app-router.tsx +1 -14
  27. package/client/routing/client-route.tsx +16 -9
  28. package/client/routing/route-context.tsx +30 -3
  29. package/client/routing/route-leave-boundary.tsx +249 -0
  30. package/client/routing/route-navigation.ts +72 -8
  31. package/client/routing/route-tree.tsx +7 -12
  32. package/client/runtime.ts +2 -2
  33. package/config.example.yml +1 -1
  34. package/package.json +12 -12
  35. package/server/config/auth.ts +1 -1
  36. package/server/providers/index.ts +2 -0
  37. package/server/providers/notification-management.ts +27 -0
  38. package/tests/client/account-permissions.test.tsx +1 -1
  39. package/tests/client/client-auth.test.tsx +10 -1
  40. package/tests/client/client-routes.test.ts +137 -4
  41. package/tests/client/client-shell.test.tsx +23 -8
  42. package/tests/client/foundation-integration.test.ts +38 -17
  43. package/tests/client/layout-i18n.test.tsx +9 -4
  44. package/tests/client/permission-management-tabs.test.tsx +421 -16
  45. package/tests/client/registration-feedback.test.tsx +143 -0
  46. package/tests/client/settings-home.test.tsx +110 -0
  47. package/tests/client/settings-navigation.test.ts +230 -0
  48. package/tests/server/notification-templates.test.ts +144 -0
  49. 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';
@@ -89,15 +90,15 @@ describe('application shell', () => {
89
90
  expect(
90
91
  screen.queryByRole('link', { name: 'Settings' }),
91
92
  ).not.toBeInTheDocument();
92
- expect(screen.getByText('AI builds freely.')).toBeVisible();
93
+ expect(screen.getByText('Build reliable business systems')).toBeVisible();
93
94
  expect(screen.getByRole('link', { name: 'NocoBase' })).toHaveAttribute(
94
95
  'href',
95
96
  'https://www.nocobase.com',
96
97
  );
97
98
  expect(
98
99
  screen.getByText('NocoBase', { selector: 'a' }).parentElement,
99
- ).toHaveTextContent('NocoBase keeps it reliable.');
100
- expect(screen.getByText('Default Template')).toHaveClass('truncate');
100
+ ).toHaveTextContent('AI + NocoBase');
101
+ expect(screen.queryByText('Default Template')).not.toBeInTheDocument();
101
102
  expect(screen.getByText('v0.0.0')).toHaveClass('truncate');
102
103
  // shadcn's edge rail, with a translated label in place of its built-in one.
103
104
  expect(
@@ -201,13 +202,25 @@ describe('application shell', () => {
201
202
  expect(
202
203
  await screen.findByRole('button', { name: 'Expand navigation' }),
203
204
  ).toHaveAttribute('aria-pressed', 'true');
204
- // 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.
205
206
  const footerIcon = screen.getByRole('img', {
206
- name: 'AI builds freely. NocoBase keeps it reliable. Default Template v0.0.0',
207
+ name: 'AI + NocoBase Build reliable business systems v0.0.0',
207
208
  });
208
209
  act(() => footerIcon.focus());
209
210
  expect(
210
- 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', {
211
224
  selector: '[data-slot="tooltip-content"] span',
212
225
  }),
213
226
  ).toBeVisible();
@@ -223,7 +236,7 @@ describe('application shell', () => {
223
236
  ),
224
237
  );
225
238
  expect(
226
- screen.queryByRole('img', { name: /Default Template/ }),
239
+ screen.queryByRole('img', { name: /AI \+ NocoBase/ }),
227
240
  ).not.toBeInTheDocument();
228
241
  });
229
242
 
@@ -316,7 +329,9 @@ function renderApplication(
316
329
  <SidebarPreferenceProvider
317
330
  value={createAppSidebarPreferenceStore('/main')}
318
331
  >
319
- <AppRouter clientRoutes={clientRoutes} />
332
+ <NavigationGuardProvider>
333
+ <AppRouter clientRoutes={clientRoutes} />
334
+ </NavigationGuardProvider>
320
335
  </SidebarPreferenceProvider>
321
336
  </AppThemeProvider>
322
337
  </MemoryRouter>
@@ -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
  });
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  ClientApplicationContext,
3
+ NavigationGuardProvider,
3
4
  type ClientApplication,
4
5
  createAppClientConfig,
5
6
  } from '@nocobase/app-client';
@@ -70,7 +71,9 @@ async function setup(children: ReactNode) {
70
71
  <I18nProvider runtime={runtime}>
71
72
  <ClientApplicationContext.Provider value={app}>
72
73
  <SidebarPreferenceProvider value={sidebarPreference}>
73
- <MemoryRouter initialEntries={['/']}>{children}</MemoryRouter>
74
+ <MemoryRouter initialEntries={['/']}>
75
+ <NavigationGuardProvider>{children}</NavigationGuardProvider>
76
+ </MemoryRouter>
74
77
  </SidebarPreferenceProvider>
75
78
  </ClientApplicationContext.Provider>
76
79
  </I18nProvider>,
@@ -85,13 +88,15 @@ describe('shell translations', () => {
85
88
  expect(
86
89
  screen.getByRole('navigation', { name: 'Breadcrumb' }),
87
90
  ).toHaveTextContent('Overview');
88
- expect(screen.getByText('AI builds freely.')).toBeVisible();
91
+ expect(screen.getByText('Build reliable business systems')).toBeVisible();
92
+ expect(screen.queryByText('Default Template')).not.toBeInTheDocument();
89
93
  await act(() => runtime.changeLanguage('zh-CN'));
90
94
  expect(screen.getByRole('navigation', { name: '面包屑' })).toBeVisible();
91
- expect(screen.getByText('AI 自由构建')).toBeVisible();
95
+ expect(screen.getByText('Build reliable business systems')).toBeVisible();
96
+ expect(screen.queryByText('Default Template')).not.toBeInTheDocument();
92
97
  expect(
93
98
  screen.getByRole('link', { name: 'NocoBase' }).parentElement,
94
- ).toHaveTextContent('NocoBase 保障可靠');
99
+ ).toHaveTextContent('AI + NocoBase');
95
100
  expect(
96
101
  screen.getByRole('button', { name: '展开或收起导航' }),
97
102
  ).toHaveAttribute('title', '展开或收起导航');