@nocobase/app-template-default 1.0.0-beta.67 → 1.0.0-beta.69

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 (87) hide show
  1. package/.agents/rules/components.md +2 -2
  2. package/.agents/rules/shell.md +5 -1
  3. package/AGENTS.md +5 -3
  4. package/Dockerfile.dockerignore +0 -1
  5. package/README.MD +3 -1
  6. package/client/app.ts +15 -0
  7. package/client/components/data-table/column-header.tsx +91 -0
  8. package/client/components/data-table/data-table.tsx +216 -0
  9. package/client/components/data-table/index.ts +13 -0
  10. package/client/components/data-table/pagination.tsx +165 -0
  11. package/client/components/data-table/view-options.tsx +77 -0
  12. package/client/components/page-header.tsx +1 -1
  13. package/client/components/permission-editor.tsx +589 -0
  14. package/client/components/permission-filter.tsx +455 -0
  15. package/client/components/route-overlay.tsx +26 -13
  16. package/client/components/status-badge.tsx +7 -1
  17. package/client/components/ui/checkbox.tsx +25 -0
  18. package/client/components/ui/sidebar.tsx +1 -4
  19. package/client/components/ui/switch.tsx +30 -0
  20. package/client/components/ui/table.tsx +113 -0
  21. package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
  22. package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
  23. package/client/index.tsx +4 -0
  24. package/client/layouts/components/app-sidebar.tsx +6 -6
  25. package/client/layouts/components/layout-header.tsx +1 -1
  26. package/client/layouts/components/navigation-menu.tsx +223 -12
  27. package/client/layouts/components/sidebar-footer.tsx +1 -1
  28. package/client/layouts/components/user-menu.tsx +1 -1
  29. package/client/layouts/use-sidebar-preference.ts +62 -42
  30. package/client/lib/navigation-transform.ts +8 -0
  31. package/client/lib/scrollbar.ts +88 -0
  32. package/client/locales/system/en-US.ts +11 -0
  33. package/client/locales/system/settings-en-US.ts +141 -0
  34. package/client/locales/system/settings-zh-CN.ts +135 -0
  35. package/client/locales/system/zh-CN.ts +22 -12
  36. package/client/pages/auth/auth-page.css +52 -0
  37. package/client/pages/auth/login.tsx +1 -1
  38. package/client/pages/auth/reset-password.tsx +57 -14
  39. package/client/pages/auth/shared.tsx +26 -24
  40. package/client/pages/home/index.tsx +2 -2
  41. package/client/pages/settings/hooks.tsx +137 -0
  42. package/client/pages/settings/permission-filters.tsx +119 -0
  43. package/client/pages/settings/permissions-model.ts +260 -0
  44. package/client/pages/settings/role-form.tsx +426 -0
  45. package/client/pages/settings/roles.tsx +163 -0
  46. package/client/pages/settings/routes.ts +79 -0
  47. package/client/pages/settings/shared.tsx +119 -0
  48. package/client/pages/settings/user-detail.tsx +268 -0
  49. package/client/pages/settings/user-form.tsx +427 -0
  50. package/client/pages/settings/users.tsx +451 -0
  51. package/client/routing/app-router.tsx +4 -1
  52. package/client/routing/route-navigation.ts +10 -1
  53. package/client/routing/session-preparation.tsx +65 -0
  54. package/client/styles.css +257 -24
  55. package/client/theme/theme-settings.tsx +12 -7
  56. package/client/theme/themes/compact.css +9 -14
  57. package/client/theme/themes/default.css +7 -12
  58. package/package.json +26 -25
  59. package/server/app.ts +2 -0
  60. package/server/config/notification.ts +4 -7
  61. package/tests/components/app-layout-routes.test.tsx +14 -7
  62. package/tests/components/auth-i18n.test.tsx +57 -5
  63. package/tests/components/data-table.test.tsx +127 -0
  64. package/tests/components/layout-i18n.test.tsx +10 -3
  65. package/tests/components/navigation-menu.test.tsx +155 -16
  66. package/tests/components/permission-editor.test.tsx +477 -0
  67. package/tests/components/permission-filter-binding.test.tsx +150 -0
  68. package/tests/components/permission-filter.test.tsx +202 -0
  69. package/tests/components/settings.test.tsx +294 -0
  70. package/tests/components/sidebar-permissions.test.tsx +17 -19
  71. package/tests/components/sidebar-preference.test.tsx +209 -14
  72. package/tests/components/status-badge.test.tsx +22 -0
  73. package/tests/components/user-permissions.test.tsx +142 -0
  74. package/tests/fixtures/theme-tokens.tsx +9 -1
  75. package/tests/logic/asset-url-build.test.ts +0 -1
  76. package/tests/logic/client-routes.test.ts +40 -0
  77. package/tests/logic/client-shell.test.tsx +10 -2
  78. package/tests/logic/config-example.test.ts +30 -0
  79. package/tests/logic/dev-client-watch.test.ts +8 -1
  80. package/tests/logic/route-overlay.test.tsx +42 -0
  81. package/tests/logic/scrollbar.test.ts +88 -0
  82. package/tests/logic/session-preparation.test.tsx +223 -0
  83. package/tests/logic/settings-permissions.test.ts +168 -0
  84. package/tests/logic/theme-tokens.test.ts +1 -0
  85. package/vite.config.ts +0 -22
  86. package/vitest.config.ts +1 -0
  87. package/client/agent-annotations-host.ts +0 -25
@@ -1,42 +1,237 @@
1
- import { act, renderHook } from '@testing-library/react';
2
- import { beforeEach, describe, expect, it } from 'vitest';
3
- import { useSidebarPreference } from '../../client/layouts/use-sidebar-preference';
1
+ import { createApp } from '../../client/app.js';
2
+ import { createAppClientConfig } from '@nocobase/app-client';
3
+ import { defineClientPlugins } from '@nocobase/app-client/plugins';
4
+ import {
5
+ defineAppRuntime,
6
+ resolveAppRuntime,
7
+ } from '@nocobase/app-client/runtime';
8
+ import { createElement, type PropsWithChildren } from 'react';
9
+ import {
10
+ act,
11
+ fireEvent,
12
+ render,
13
+ renderHook,
14
+ screen,
15
+ } from '@testing-library/react';
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
17
+ import {
18
+ createSidebarPreferenceStore,
19
+ createAppSidebarPreferenceStore,
20
+ SidebarPreferenceProvider,
21
+ useSidebarPreference,
22
+ } from '../../client/layouts/use-sidebar-preference.js';
23
+ import {
24
+ SidebarProvider,
25
+ useSidebar,
26
+ } from '../../client/components/ui/sidebar.js';
4
27
 
5
- const key = 'nocobase:sidebar:collapsed';
28
+ const key = 'nocobase:sidebar:collapsed:app:/main';
29
+ function wrapper(store: ReturnType<typeof createSidebarPreferenceStore>) {
30
+ return function PreferenceWrapper({ children }: PropsWithChildren) {
31
+ return createElement(SidebarPreferenceProvider, { value: store }, children);
32
+ };
33
+ }
34
+ function hook(scope = 'app:/main') {
35
+ return renderHook(() => useSidebarPreference(), {
36
+ wrapper: wrapper(createSidebarPreferenceStore(scope)),
37
+ });
38
+ }
6
39
 
7
- describe('shared sidebar preference', () => {
8
- beforeEach(() => localStorage.clear());
40
+ describe('application sidebar preference', () => {
41
+ beforeEach(() => {
42
+ localStorage.clear();
43
+ vi.stubGlobal('matchMedia', () => ({
44
+ matches: false,
45
+ addEventListener: vi.fn(),
46
+ removeEventListener: vi.fn(),
47
+ }));
48
+ });
49
+ afterEach(() => {
50
+ vi.restoreAllMocks();
51
+ vi.unstubAllGlobals();
52
+ });
9
53
 
10
54
  it('shares changes across layouts and restores them after remount', () => {
11
- const first = renderHook(() => useSidebarPreference());
12
- const second = renderHook(() => useSidebarPreference());
55
+ const first = hook();
56
+ const second = hook();
13
57
  expect(first.result.current[0]).toBe(false);
14
58
  act(() => first.result.current[1]((value) => !value));
15
59
  expect(second.result.current[0]).toBe(true);
16
60
  expect(localStorage.getItem(key)).toBe('true');
17
61
  first.unmount();
18
62
  second.unmount();
19
- const restored = renderHook(() => useSidebarPreference());
63
+ const restored = hook();
20
64
  expect(restored.result.current[0]).toBe(true);
21
65
  act(() => restored.result.current[1](false));
22
66
  expect(localStorage.getItem(key)).toBe('false');
23
67
  });
24
68
 
25
- it('reads saved state and follows storage changes from another tab', () => {
69
+ it('follows its own cross-tab events and clear, ignoring other application keys', () => {
26
70
  localStorage.setItem(key, 'true');
27
- const { result } = renderHook(() => useSidebarPreference());
71
+ const { result } = hook();
28
72
  expect(result.current[0]).toBe(true);
29
73
  act(() => {
30
74
  localStorage.removeItem(key);
75
+ window.dispatchEvent(
76
+ new StorageEvent('storage', {
77
+ key: 'nocobase:sidebar:collapsed:app:/other',
78
+ }),
79
+ );
80
+ });
81
+ expect(result.current[0]).toBe(true);
82
+ act(() => window.dispatchEvent(new StorageEvent('storage', { key })));
83
+ expect(result.current[0]).toBe(false);
84
+ act(() => {
85
+ localStorage.setItem(key, 'true');
31
86
  window.dispatchEvent(new StorageEvent('storage', { key }));
32
87
  });
88
+ expect(result.current[0]).toBe(true);
89
+ act(() => {
90
+ localStorage.clear();
91
+ window.dispatchEvent(new StorageEvent('storage', { key: null }));
92
+ });
33
93
  expect(result.current[0]).toBe(false);
34
94
  });
35
95
 
36
- it('defaults to expanded for an invalid stored value', () => {
37
- localStorage.setItem(key, 'invalid');
38
- expect(renderHook(() => useSidebarPreference()).result.current[0]).toBe(
96
+ it.each(['false', 'invalid'])(
97
+ 'defaults to expanded for stored %s',
98
+ (value) => {
99
+ localStorage.setItem(key, value);
100
+ expect(hook().result.current[0]).toBe(false);
101
+ },
102
+ );
103
+
104
+ it('does not migrate the old shared preference', () => {
105
+ localStorage.setItem('nocobase:sidebar:collapsed', 'true');
106
+ expect(hook().result.current[0]).toBe(false);
107
+ expect(localStorage.getItem(key)).toBeNull();
108
+ });
109
+
110
+ it('isolates Studio and multiple applications, including an application named studio', () => {
111
+ const scopes = ['studio', 'app:/studio', 'app:/a', 'app:/b'];
112
+ const hooks = scopes.map((scope) => hook(scope));
113
+ hooks.forEach((item, index) => {
114
+ act(() => item.result.current[1](true));
115
+ hooks.forEach((other, otherIndex) =>
116
+ expect(other.result.current[0]).toBe(otherIndex <= index),
117
+ );
118
+ scopes.forEach((scope, otherIndex) =>
119
+ expect(
120
+ localStorage.getItem(`nocobase:sidebar:collapsed:${scope}`),
121
+ ).toBe(otherIndex <= index ? 'true' : null),
122
+ );
123
+ });
124
+ act(() => hooks[0].result.current[1](false));
125
+ expect(hooks.map((item) => item.result.current[0])).toEqual([
39
126
  false,
127
+ true,
128
+ true,
129
+ true,
130
+ ]);
131
+ });
132
+
133
+ it('isolates memory fallback when storage writes fail', () => {
134
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
135
+ throw new Error('blocked');
136
+ });
137
+ const store = createSidebarPreferenceStore('app:/blocked');
138
+ const first = renderHook(() => useSidebarPreference(), {
139
+ wrapper: wrapper(store),
140
+ });
141
+ const second = renderHook(() => useSidebarPreference(), {
142
+ wrapper: wrapper(store),
143
+ });
144
+ const other = hook('app:/other');
145
+ act(() => first.result.current[1](true));
146
+ expect(second.result.current[0]).toBe(true);
147
+ expect(other.result.current[0]).toBe(false);
148
+ act(() => other.result.current[1](true));
149
+ act(() => first.result.current[1](false));
150
+ expect(second.result.current[0]).toBe(false);
151
+ expect(other.result.current[0]).toBe(true);
152
+ });
153
+
154
+ it('does not write a shared cookie when the real SidebarProvider toggles', () => {
155
+ const cookie = vi.spyOn(document, 'cookie', 'set');
156
+ function Toggle() {
157
+ const { toggleSidebar, state } = useSidebar();
158
+ return <button onClick={toggleSidebar}>{state}</button>;
159
+ }
160
+ render(
161
+ <SidebarProvider>
162
+ <Toggle />
163
+ </SidebarProvider>,
40
164
  );
165
+ fireEvent.click(screen.getByRole('button', { name: 'expanded' }));
166
+ expect(
167
+ screen.getByRole('button', { name: 'collapsed' }),
168
+ ).toBeInTheDocument();
169
+ expect(cookie).not.toHaveBeenCalled();
170
+ });
171
+ });
172
+
173
+ describe('runtime sidebar scope', () => {
174
+ it.each(['', '/', '///', 'a', '/a/', '/Team/Nested///'])(
175
+ 'normalizes only boundary slashes for %s',
176
+ (basename) => {
177
+ localStorage.clear();
178
+ const store = createAppSidebarPreferenceStore(basename);
179
+ store.setCollapsed(true);
180
+ const normalized = '/' + basename.replace(/^\/+|\/+$/g, '');
181
+ expect(
182
+ localStorage.getItem(`nocobase:sidebar:collapsed:app:${normalized}`),
183
+ ).toBe('true');
184
+ },
185
+ );
186
+
187
+ it('provides stable, independent stores from each actual runtime basename', async () => {
188
+ localStorage.clear();
189
+ async function application(basename: string) {
190
+ const runtime = await resolveAppRuntime(
191
+ defineAppRuntime({
192
+ packageName: '@example/app',
193
+ createAppConfig: createAppClientConfig,
194
+ serviceProviders: [],
195
+ reactProviders: [],
196
+ routes: [],
197
+ plugins: defineClientPlugins([]),
198
+ }),
199
+ { rawConfig: { app: { basePath: basename } } },
200
+ );
201
+ const app = createApp(runtime);
202
+ await app.start();
203
+ return app;
204
+ }
205
+ const a = await application('/studio');
206
+ const b = await application('/Team/Nested');
207
+ try {
208
+ const providerA = a.renderConfig.reactProviders![1];
209
+ const providerB = b.renderConfig.reactProviders![1];
210
+ const first = renderHook(() => useSidebarPreference(), {
211
+ wrapper: providerA,
212
+ });
213
+ const second = renderHook(() => useSidebarPreference(), {
214
+ wrapper: providerA,
215
+ });
216
+ const other = renderHook(() => useSidebarPreference(), {
217
+ wrapper: providerB,
218
+ });
219
+ act(() => first.result.current[1](true));
220
+ expect(second.result.current[0]).toBe(true);
221
+ expect(other.result.current[0]).toBe(false);
222
+ expect(
223
+ localStorage.getItem('nocobase:sidebar:collapsed:app:/studio'),
224
+ ).toBe('true');
225
+ act(() => other.result.current[1](true));
226
+ expect(
227
+ localStorage.getItem('nocobase:sidebar:collapsed:app:/Team/Nested'),
228
+ ).toBe('true');
229
+ first.unmount();
230
+ second.unmount();
231
+ other.unmount();
232
+ } finally {
233
+ await a.shutdown();
234
+ await b.shutdown();
235
+ }
41
236
  });
42
237
  });
@@ -0,0 +1,22 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { StatusBadge } from '#components/status-badge';
5
+
6
+ // The two badge sizes are drawn by client/styles.css from `data-size`, which jsdom does not apply, so this checks the
7
+ // attribute that selects them.
8
+ describe('StatusBadge', () => {
9
+ it('is the default badge unless asked for the small one', () => {
10
+ render(
11
+ <>
12
+ <StatusBadge tone='success'>Done</StatusBadge>
13
+ <StatusBadge tone='warning' size='sm'>
14
+ Waiting
15
+ </StatusBadge>
16
+ </>,
17
+ );
18
+ expect(screen.getByText('Done')).not.toHaveAttribute('data-size');
19
+ expect(screen.getByText('Waiting')).toHaveAttribute('data-size', 'sm');
20
+ expect(screen.getByText('Waiting')).toHaveAttribute('data-slot', 'badge');
21
+ });
22
+ });
@@ -0,0 +1,142 @@
1
+ import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
2
+ import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
3
+ import {
4
+ AuthorizationClient,
5
+ authorizationClientToken,
6
+ type PermissionGrant,
7
+ } from '@nocobase/app-plugin-authorization/client';
8
+ import { screen, waitFor } from '@testing-library/react';
9
+ import userEvent from '@testing-library/user-event';
10
+ import { Routes, Route } from 'react-router';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import RoleForm from '../../client/pages/settings/role-form.js';
13
+ import RolesPage from '../../client/pages/settings/roles.js';
14
+ import enUS from '../../client/locales/en-US.js';
15
+ import zhCN from '../../client/locales/zh-CN.js';
16
+
17
+ const namespace = '@nocobase/user-permissions-test';
18
+ const namespaces = {
19
+ [namespace]: {
20
+ 'en-US': async () => ({ default: enUS }),
21
+ 'zh-CN': async () => ({ default: zhCN }),
22
+ },
23
+ };
24
+
25
+ describe('user permission configuration', () => {
26
+ it.each(['en-US', 'zh-CN'] as const)(
27
+ 'saves, reloads and revokes the missing actions in %s without replacing existing grants',
28
+ async (locale) => {
29
+ const user = userEvent.setup();
30
+ const words = locale === 'zh-CN' ? zhCN.settings : enUS.settings;
31
+ const role = {
32
+ key: 'operators',
33
+ title: 'Operators',
34
+ revision: 'original',
35
+ grants: [
36
+ {
37
+ resource: { type: 'user', id: '*' },
38
+ actions: [{ action: 'read' }, { action: 'disable' }],
39
+ },
40
+ ] as readonly PermissionGrant[],
41
+ };
42
+ const calls = vi.fn(
43
+ ({
44
+ path,
45
+ method,
46
+ json,
47
+ }: {
48
+ path: string;
49
+ method: string;
50
+ json?: {
51
+ grants: readonly PermissionGrant[];
52
+ expectedRevision?: string;
53
+ };
54
+ }) => {
55
+ if (path === 'authorization/permissions')
56
+ return { data: { unrestricted: true, permissions: [] } };
57
+ if (path.endsWith('/options'))
58
+ return {
59
+ data: {
60
+ sections: [],
61
+ recordAccess: [],
62
+ subjectTypes: [],
63
+ collections: [],
64
+ },
65
+ };
66
+ if (path === 'authorization/permissionSets') return { data: [role] };
67
+ if (method === 'PATCH' && json) {
68
+ role.grants = json.grants;
69
+ role.revision = 'updated';
70
+ }
71
+ return { data: role };
72
+ },
73
+ );
74
+ const mount = () =>
75
+ renderWithApp(
76
+ <Routes>
77
+ <Route path='/settings/roles' element={<RolesPage />}>
78
+ <Route path=':key/edit' element={<RoleForm />} />
79
+ </Route>
80
+ </Routes>,
81
+ {
82
+ route: '/settings/roles/operators/edit',
83
+ namespace,
84
+ namespaces,
85
+ locale,
86
+ services: (app: ClientApplication) =>
87
+ app.container.instance(
88
+ authorizationClientToken,
89
+ new AuthorizationClient(app.container.resolve(apiClientToken)),
90
+ ),
91
+ fetch: answerApi(calls),
92
+ },
93
+ );
94
+ const first = await mount();
95
+ const labels = [
96
+ words.inviteUsers,
97
+ words.deleteUsers,
98
+ words.resetUserPassword,
99
+ ];
100
+ for (const label of labels) {
101
+ const checkbox = await screen.findByRole('checkbox', { name: label });
102
+ await waitFor(() =>
103
+ expect(checkbox).not.toHaveAttribute('aria-disabled', 'true'),
104
+ );
105
+ expect(checkbox).not.toBeChecked();
106
+ await user.click(checkbox);
107
+ }
108
+ await user.click(
109
+ screen.getByRole('button', { name: words.save, exact: true }),
110
+ );
111
+ await waitFor(() =>
112
+ expect(calls).toHaveBeenCalledWith(
113
+ expect.objectContaining({
114
+ method: 'PATCH',
115
+ json: expect.objectContaining({ expectedRevision: 'original' }),
116
+ }),
117
+ ),
118
+ );
119
+ const all = ['read', 'disable', 'invite', 'delete', 'reset-password'];
120
+ expect(role.grants[0]?.actions.map((action) => action.action)).toEqual(
121
+ all,
122
+ );
123
+ first.unmount();
124
+ await mount();
125
+ for (const label of labels)
126
+ expect(
127
+ await screen.findByRole('checkbox', { name: label }),
128
+ ).toBeChecked();
129
+ await user.click(
130
+ screen.getByRole('checkbox', { name: words.deleteUsers }),
131
+ );
132
+ await user.click(
133
+ screen.getByRole('button', { name: words.save, exact: true }),
134
+ );
135
+ await waitFor(() =>
136
+ expect(role.grants[0]?.actions.map((action) => action.action)).toEqual(
137
+ all.filter((action) => action !== 'delete'),
138
+ ),
139
+ );
140
+ },
141
+ );
142
+ });
@@ -1,3 +1,7 @@
1
+ import {
2
+ createAppSidebarPreferenceStore,
3
+ SidebarPreferenceProvider,
4
+ } from '../../client/layouts/use-sidebar-preference.js';
1
5
  import { Refine } from '@refinedev/core';
2
6
  import { createRoot } from 'react-dom/client';
3
7
  import { MemoryRouter } from 'react-router';
@@ -114,5 +118,9 @@ export default function Fixture() {
114
118
  }
115
119
 
116
120
  const root = createRoot(document.getElementById('root')!);
117
- root.render(<Fixture />);
121
+ root.render(
122
+ <SidebarPreferenceProvider value={createAppSidebarPreferenceStore('/main')}>
123
+ <Fixture />
124
+ </SidebarPreferenceProvider>,
125
+ );
118
126
  import.meta.hot?.dispose(() => root.unmount());
@@ -55,7 +55,6 @@ describe('asset URLs in the application build', () => {
55
55
  'uses the compiled asset base while keeping API URLs on the runtime mount (%s)',
56
56
  async (cdn) => {
57
57
  vi.stubEnv('CDN_BASE_URL', cdn);
58
- vi.stubEnv('AGENT_ANNOTATIONS_ENABLED', 'false');
59
58
  // The same build must work when the deployment chooses a different mount path.
60
59
  vi.stubEnv('APP_BASE_PATH', '/build-machine');
61
60
  const result = await build({
@@ -5,6 +5,7 @@ import {
5
5
  } from '@nocobase/app-client/plugins';
6
6
  import { describe, expect, it } from 'vitest';
7
7
 
8
+ import systemSettingsRoutes from '../../client/pages/settings/routes.js';
8
9
  import applicationRoutes from '../../client/routes.ts';
9
10
 
10
11
  describe('app client routes', () => {
@@ -57,6 +58,45 @@ describe('app client routes', () => {
57
58
  ]);
58
59
  });
59
60
 
61
+ it('keeps a bare application home-only and supports settings registration as business capabilities are developed', async () => {
62
+ expect(
63
+ pagePaths(resolveRoutes().routes).some((path) =>
64
+ path.startsWith('/settings'),
65
+ ),
66
+ ).toBe(false);
67
+ const built = resolveAppClientContributions([
68
+ {
69
+ packageName: '@nocobase/app-template-default',
70
+ routes: [...applicationRoutes, systemSettingsRoutes],
71
+ source: 'application',
72
+ },
73
+ ]);
74
+ expect(pageAuthorizations(built.routes)).toEqual([
75
+ { name: 'home', authorizedAs: null },
76
+ { name: 'inbox', authorizedAs: null },
77
+ { name: 'settingsUsers', authorizedAs: 'user:*' },
78
+ { name: 'settingsUserCreate', authorizedAs: 'user:*' },
79
+ { name: 'settingsUser', authorizedAs: 'user:*' },
80
+ { name: 'settingsUserEdit', authorizedAs: 'user:*' },
81
+ {
82
+ name: 'settingsRoles',
83
+ authorizedAs: 'settings:authorization.permission-sets',
84
+ },
85
+ {
86
+ name: 'settingsRoleCreate',
87
+ authorizedAs: 'settings:authorization.permission-sets',
88
+ },
89
+ {
90
+ name: 'settingsRoleEdit',
91
+ authorizedAs: 'settings:authorization.permission-sets',
92
+ },
93
+ ]);
94
+ for (const loader of componentLoadersIn(built.routes))
95
+ await expect(loader()).resolves.toMatchObject({
96
+ default: expect.any(Function),
97
+ });
98
+ });
99
+
60
100
  it('keeps the inbox out of the application navigation', () => {
61
101
  // The inbox is reached from the header's inbox button, so it deliberately declares no menu entry.
62
102
  expect(
@@ -1,3 +1,7 @@
1
+ import {
2
+ createAppSidebarPreferenceStore,
3
+ SidebarPreferenceProvider,
4
+ } from '../../client/layouts/use-sidebar-preference.js';
1
5
  import {
2
6
  apiClientToken,
3
7
  ClientApplicationContext,
@@ -305,7 +309,11 @@ function renderApplication(
305
309
  <AuthenticationProvider>
306
310
  <MemoryRouter initialEntries={[initialEntry]}>
307
311
  <AppThemeProvider>
308
- <AppRouter clientRoutes={clientRoutes} />
312
+ <SidebarPreferenceProvider
313
+ value={createAppSidebarPreferenceStore('/main')}
314
+ >
315
+ <AppRouter clientRoutes={clientRoutes} />
316
+ </SidebarPreferenceProvider>
309
317
  </AppThemeProvider>
310
318
  </MemoryRouter>
311
319
  </AuthenticationProvider>
@@ -318,7 +326,7 @@ function createTestAuthClient(authenticated: boolean) {
318
326
  getSession: vi.fn().mockResolvedValue({
319
327
  data: authenticated
320
328
  ? {
321
- session: null,
329
+ session: { id: 'session-1' },
322
330
  user: {
323
331
  email: 'alice@example.com',
324
332
  id: '1',
@@ -0,0 +1,30 @@
1
+ // @vitest-environment node
2
+
3
+ import { readFile } from 'node:fs/promises';
4
+
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ // `pnpm nocobase config init` writes config.yml from this file: it replaces the instruction on the first line with
8
+ // its own header and fills the placeholder key of the live `secrets` section, keeping everything else verbatim. These
9
+ // are the parts of the example it relies on.
10
+ describe('config.example.yml', () => {
11
+ const read = () =>
12
+ readFile(new URL('../../config.example.yml', import.meta.url), 'utf8');
13
+
14
+ it('opens with the instruction config init replaces', async () => {
15
+ expect((await read()).split('\n')[0]).toMatch(
16
+ /^# Copy this file to config\.yml\b/u,
17
+ );
18
+ });
19
+
20
+ it('carries a live secrets section whose first key is a placeholder', async () => {
21
+ expect(await read()).toContain(
22
+ [
23
+ 'secrets:',
24
+ ' keys:',
25
+ ' - version: 1',
26
+ ' key: replace-with-a-unique-secret',
27
+ ].join('\n'),
28
+ );
29
+ });
30
+ });
@@ -60,7 +60,8 @@ describe('development client file watching', () => {
60
60
  'junction',
61
61
  );
62
62
 
63
- vi.stubEnv('AGENT_ANNOTATIONS_ENABLED', 'false');
63
+ // A leftover opt-in from an existing application must not restore the removed toolbar.
64
+ vi.stubEnv('AGENT_ANNOTATIONS_ENABLED', 'true');
64
65
  vi.stubEnv('PROXY_TARGET_URL', '');
65
66
  // `pnpm dev` always passes the mount path; the Vite preset refuses to serve without one.
66
67
  vi.stubEnv('APP_BASE_PATH', '/main');
@@ -77,6 +78,12 @@ describe('development client file watching', () => {
77
78
  watch: { usePolling: true, interval: 20 },
78
79
  },
79
80
  });
81
+ const html = await server.transformIndexHtml(
82
+ '/main/',
83
+ '<!doctype html><html><head></head><body></body></html>',
84
+ );
85
+ expect(html).not.toContain('agent-annotations');
86
+
80
87
  const watcher = server.watcher;
81
88
  const changes: string[] = [];
82
89
  watcher.on('change', (file) => changes.push(file));
@@ -4,6 +4,7 @@ import {
4
4
  render,
5
5
  screen,
6
6
  waitFor,
7
+ within,
7
8
  } from '@testing-library/react';
8
9
  import userEvent from '@testing-library/user-event';
9
10
  import {
@@ -158,6 +159,47 @@ describe('route overlays', () => {
158
159
  });
159
160
  });
160
161
 
162
+ describe('route overlay presentation', () => {
163
+ it('composes the dialog primitives so it follows the dialog styles', async () => {
164
+ setup();
165
+ const dialog = await screen.findByRole('dialog', { name: 'Edit' });
166
+ expect(dialog).toHaveAttribute('data-slot', 'route-overlay-content');
167
+ expect(dialog).toHaveAttribute('data-presentation', 'dialog');
168
+ const header = dialog.querySelector(
169
+ ':scope > [data-slot="route-overlay-header"] > [data-slot="dialog-header"]',
170
+ );
171
+ expect(
172
+ header?.querySelector('[data-slot="dialog-title"]'),
173
+ ).toHaveTextContent('Edit');
174
+ expect(
175
+ dialog.querySelector(':scope > [data-slot="route-overlay-body"]'),
176
+ ).toContainElement(screen.getByRole('textbox', { name: 'Name' }));
177
+ const footer = dialog.querySelector(
178
+ ':scope > [data-slot="route-overlay-footer"] > [data-slot="dialog-footer"]',
179
+ );
180
+ expect(footer).toContainElement(
181
+ screen.getByRole('button', { name: 'Cancel' }),
182
+ );
183
+ // The footer sits at the panel's edges, so the margins it uses inside `DialogContent` are reset.
184
+ expect(footer).toHaveClass('mx-0', 'mb-0');
185
+ expect(footer).not.toHaveClass('-mx-6', '-mb-6');
186
+ expect(
187
+ dialog.querySelector(':scope > [data-slot="dialog-close"]'),
188
+ ).toHaveAccessibleName('Close');
189
+ });
190
+
191
+ it('presents a drawer with the same regions', async () => {
192
+ setup(undefined, undefined, true);
193
+ const drawer = await screen.findByRole('dialog', { name: 'Details' });
194
+ expect(drawer).toHaveAttribute('data-presentation', 'drawer');
195
+ expect(
196
+ drawer.querySelector(
197
+ ':scope > [data-slot="route-overlay-footer"] > [data-slot="dialog-footer"]',
198
+ ),
199
+ ).toContainElement(within(drawer).getByRole('button', { name: 'Cancel' }));
200
+ });
201
+ });
202
+
161
203
  describe('route overlay interactions', () => {
162
204
  it('renders an owning backdrop for the nested layer and dismisses only that layer', async () => {
163
205
  const user = userEvent.setup();