@nocobase/app-template-default 0.0.0-stage → 1.0.0-beta.58

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 (249) hide show
  1. package/.cursor/mcp.json +8 -0
  2. package/.mcp.json +8 -0
  3. package/.prettierignore +20 -0
  4. package/.vscode/mcp.json +8 -0
  5. package/AGENTS.md +434 -0
  6. package/CLAUDE.md +9 -0
  7. package/Dockerfile +132 -0
  8. package/Dockerfile.dockerignore +45 -0
  9. package/README.MD +485 -0
  10. package/cli/plugins.ts +8 -0
  11. package/client/agent-annotations-host.ts +19 -0
  12. package/client/app.ts +38 -0
  13. package/client/components/back-button.tsx +49 -0
  14. package/client/components/breadcrumbs.tsx +167 -0
  15. package/client/components/inbox-badge.ts +49 -0
  16. package/client/components/inbox-button.tsx +132 -0
  17. package/client/components/inbox-header-button.tsx +25 -0
  18. package/client/components/loading.tsx +35 -0
  19. package/client/components/page-container.tsx +24 -0
  20. package/client/components/page-header.tsx +33 -0
  21. package/client/components/route-child-page.tsx +121 -0
  22. package/client/components/route-dialog.tsx +10 -0
  23. package/client/components/route-drawer.tsx +10 -0
  24. package/client/components/route-overlay.tsx +190 -0
  25. package/client/components/ui/alert.tsx +75 -0
  26. package/client/components/ui/badge.tsx +51 -0
  27. package/client/components/ui/breadcrumb.tsx +121 -0
  28. package/client/components/ui/button.tsx +58 -0
  29. package/client/components/ui/collapsible.tsx +19 -0
  30. package/client/components/ui/context-menu.tsx +271 -0
  31. package/client/components/ui/dialog.tsx +160 -0
  32. package/client/components/ui/dropdown-menu.tsx +270 -0
  33. package/client/components/ui/empty.tsx +103 -0
  34. package/client/components/ui/field.tsx +236 -0
  35. package/client/components/ui/input-group.tsx +158 -0
  36. package/client/components/ui/input.tsx +20 -0
  37. package/client/components/ui/kbd.tsx +26 -0
  38. package/client/components/ui/label.tsx +18 -0
  39. package/client/components/ui/popover.tsx +89 -0
  40. package/client/components/ui/select.tsx +200 -0
  41. package/client/components/ui/separator.tsx +22 -0
  42. package/client/components/ui/sheet.tsx +137 -0
  43. package/client/components/ui/sidebar.tsx +723 -0
  44. package/client/components/ui/skeleton.tsx +13 -0
  45. package/client/components/ui/spinner.tsx +20 -0
  46. package/client/components/ui/tabs.tsx +79 -0
  47. package/client/components/ui/textarea.tsx +17 -0
  48. package/client/components/ui/toast.tsx +231 -0
  49. package/client/components/ui/tooltip.tsx +63 -0
  50. package/client/components/use-route-overlay.ts +26 -0
  51. package/client/config/api.ts +13 -0
  52. package/client/config/app.ts +8 -0
  53. package/client/config/auth.ts +18 -0
  54. package/client/config/index.ts +17 -0
  55. package/client/extensions/nocobase-auth-forms/field-props.ts +13 -0
  56. package/client/extensions/nocobase-auth-forms/form-parts.tsx +101 -0
  57. package/client/extensions/nocobase-auth-forms/password-input.tsx +41 -0
  58. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +123 -0
  59. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +193 -0
  60. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +132 -0
  61. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +99 -0
  62. package/client/extensions/nocobase-auth-methods/auth-methods.tsx +170 -0
  63. package/client/extensions/nocobase-auth-split-layout/auth-split-layout.tsx +126 -0
  64. package/client/extensions/nocobase-device-approval/device-approval.tsx +277 -0
  65. package/client/extensions/nocobase-device-approval/locales/en-US.ts +55 -0
  66. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +27 -0
  67. package/client/extensions/nocobase-device-approval/use-device-approval.ts +176 -0
  68. package/client/extensions/nocobase-file-component-ui/README.md +13 -0
  69. package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +147 -0
  70. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +263 -0
  71. package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +68 -0
  72. package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +73 -0
  73. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +386 -0
  74. package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +255 -0
  75. package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +226 -0
  76. package/client/extensions/nocobase-file-component-ui/index.ts +8 -0
  77. package/client/extensions/nocobase-file-component-ui/lib/file-preview.ts +194 -0
  78. package/client/extensions/nocobase-file-component-ui/lib/file-url.ts +22 -0
  79. package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +18 -0
  80. package/client/extensions/nocobase-file-component-ui/types.ts +69 -0
  81. package/client/extensions/nocobase-inbox/decision-actions-bar.tsx +140 -0
  82. package/client/extensions/nocobase-inbox/frame.ts +31 -0
  83. package/client/extensions/nocobase-inbox/inbox-actions.ts +21 -0
  84. package/client/extensions/nocobase-inbox/inbox-detail.tsx +386 -0
  85. package/client/extensions/nocobase-inbox/inbox-item.tsx +274 -0
  86. package/client/extensions/nocobase-inbox/inbox-list.tsx +433 -0
  87. package/client/extensions/nocobase-inbox/inbox-page.tsx +545 -0
  88. package/client/extensions/nocobase-inbox/locales/en-US.ts +111 -0
  89. package/client/extensions/nocobase-inbox/locales/zh-CN.ts +57 -0
  90. package/client/extensions/nocobase-inbox/model.ts +428 -0
  91. package/client/extensions/nocobase-inbox/registry-scope.tsx +49 -0
  92. package/client/extensions/nocobase-inbox/registry.ts +303 -0
  93. package/client/extensions/nocobase-inbox/source.ts +117 -0
  94. package/client/hooks/use-mobile.ts +21 -0
  95. package/client/index.tsx +60 -0
  96. package/client/layouts/app-layout.tsx +105 -0
  97. package/client/layouts/components/app-brand.tsx +49 -0
  98. package/client/layouts/components/app-sidebar.tsx +196 -0
  99. package/client/layouts/components/header-actions.tsx +50 -0
  100. package/client/layouts/components/language-switcher.tsx +95 -0
  101. package/client/layouts/components/layout-header.tsx +17 -0
  102. package/client/layouts/components/navigation-menu.tsx +411 -0
  103. package/client/layouts/components/sidebar-footer.tsx +107 -0
  104. package/client/layouts/components/surface-empty.tsx +35 -0
  105. package/client/layouts/components/user-menu.tsx +127 -0
  106. package/client/layouts/index.ts +2 -0
  107. package/client/layouts/settings-layout.tsx +172 -0
  108. package/client/layouts/use-sidebar-preference.ts +57 -0
  109. package/client/lib/constants.ts +5 -0
  110. package/client/lib/toaster.ts +56 -0
  111. package/client/lib/utils.ts +1 -0
  112. package/client/locales/en-US.ts +178 -0
  113. package/client/locales/index.ts +9 -0
  114. package/client/locales/zh-CN.ts +161 -0
  115. package/client/pages/auth/device.tsx +41 -0
  116. package/client/pages/auth/forgot-password.tsx +39 -0
  117. package/client/pages/auth/login.tsx +66 -0
  118. package/client/pages/auth/register.tsx +46 -0
  119. package/client/pages/auth/reset-password.tsx +38 -0
  120. package/client/pages/auth/shared.tsx +111 -0
  121. package/client/pages/home.tsx +16 -0
  122. package/client/pages/inbox.tsx +12 -0
  123. package/client/plugins.ts +37 -0
  124. package/client/react-providers.ts +27 -0
  125. package/client/route-overrides.ts +9 -0
  126. package/client/routes.ts +75 -0
  127. package/client/routing/app-router.tsx +127 -0
  128. package/client/routing/client-route.tsx +129 -0
  129. package/client/routing/route-context.tsx +85 -0
  130. package/client/routing/route-navigation.ts +153 -0
  131. package/client/routing/route-tree.tsx +42 -0
  132. package/client/routing/standalone-page-layout.tsx +15 -0
  133. package/client/runtime.ts +26 -0
  134. package/client/service-provider.ts +40 -0
  135. package/client/source-extensions.ts +19 -0
  136. package/client/startup.tsx +22 -0
  137. package/client/styles.css +122 -0
  138. package/client/theme/index.ts +4 -0
  139. package/client/theme/theme-context.ts +12 -0
  140. package/client/theme/theme-preferences.ts +84 -0
  141. package/client/theme/theme-presets.ts +7 -0
  142. package/client/theme/theme-provider.tsx +106 -0
  143. package/client/theme/theme-settings.tsx +176 -0
  144. package/client/theme/themes/compact.css +140 -0
  145. package/client/theme/themes/default.css +117 -0
  146. package/client/vite-env.d.ts +5 -0
  147. package/components.json +27 -0
  148. package/config.example.yml +156 -0
  149. package/database/main/migrations/.gitkeep +0 -0
  150. package/database/main/migrations/202610060001_create_device_code.ts +37 -0
  151. package/database/main/seeds/.gitkeep +0 -0
  152. package/database/tsconfig.json +4 -0
  153. package/ecosystem.config.js +35 -0
  154. package/eslint.config.js +24 -0
  155. package/index.html +100 -0
  156. package/package.json +198 -3
  157. package/playwright.config.ts +10 -0
  158. package/public/assets/favicon.ico +0 -0
  159. package/public/assets/logo-dark.png +0 -0
  160. package/public/assets/logo-mark-dark.png +0 -0
  161. package/public/assets/logo-mark.png +0 -0
  162. package/public/assets/logo.png +0 -0
  163. package/server/app.ts +50 -0
  164. package/server/config/api.ts +8 -0
  165. package/server/config/app.ts +40 -0
  166. package/server/config/auth.ts +36 -0
  167. package/server/config/authorization.ts +18 -0
  168. package/server/config/caching.ts +21 -0
  169. package/server/config/database.ts +22 -0
  170. package/server/config/drive.ts +30 -0
  171. package/server/config/i18n.ts +17 -0
  172. package/server/config/index.ts +67 -0
  173. package/server/config/jobs.ts +27 -0
  174. package/server/config/logging.ts +23 -0
  175. package/server/config/notification.ts +11 -0
  176. package/server/config/queue.ts +28 -0
  177. package/server/config/scheduler.ts +23 -0
  178. package/server/config/secrets.ts +10 -0
  179. package/server/config/server.ts +26 -0
  180. package/server/config/session.ts +60 -0
  181. package/server/config/snowflake.ts +21 -0
  182. package/server/config/spa.ts +24 -0
  183. package/server/config/users.ts +11 -0
  184. package/server/config/workflow.ts +24 -0
  185. package/server/config.ts +24 -0
  186. package/server/embedded.ts +23 -0
  187. package/server/locales/en-US.ts +10 -0
  188. package/server/locales/index.ts +10 -0
  189. package/server/locales/zh-CN.ts +5 -0
  190. package/server/plugins.ts +37 -0
  191. package/server/providers/index.ts +5 -0
  192. package/server/routes/index.ts +6 -0
  193. package/server/runtime.ts +24 -0
  194. package/server/standalone.ts +29 -0
  195. package/server/tsconfig.json +4 -0
  196. package/tailwind.config.mjs +59 -0
  197. package/tests/components/app-layout-routes.test.tsx +132 -0
  198. package/tests/components/app-title.test.tsx +57 -0
  199. package/tests/components/auth-i18n.test.tsx +87 -0
  200. package/tests/components/back-button.test.tsx +80 -0
  201. package/tests/components/file-preview.test.tsx +101 -0
  202. package/tests/components/header-hover.test.tsx +87 -0
  203. package/tests/components/inbox.test.tsx +139 -0
  204. package/tests/components/language-switcher.test.tsx +298 -0
  205. package/tests/components/layout-containers.test.tsx +15 -0
  206. package/tests/components/layout-i18n.test.tsx +187 -0
  207. package/tests/components/loading.test.tsx +25 -0
  208. package/tests/components/navigation-menu.test.tsx +355 -0
  209. package/tests/components/page-harness.test.tsx +318 -0
  210. package/tests/components/primitive-labels.test.tsx +80 -0
  211. package/tests/components/sidebar-permissions.test.tsx +110 -0
  212. package/tests/components/sidebar-preference.test.tsx +42 -0
  213. package/tests/fixtures/theme-tokens.html +12 -0
  214. package/tests/fixtures/theme-tokens.tsx +111 -0
  215. package/tests/logic/account-permissions.test.tsx +204 -0
  216. package/tests/logic/app-locale-coverage.test.ts +136 -0
  217. package/tests/logic/app-server.test.ts +1589 -0
  218. package/tests/logic/asset-url-build.test.ts +106 -0
  219. package/tests/logic/client-auth.test.tsx +122 -0
  220. package/tests/logic/client-plugin-registry.test.ts +63 -0
  221. package/tests/logic/client-route-recovery.test.tsx +80 -0
  222. package/tests/logic/client-routes.test.ts +126 -0
  223. package/tests/logic/client-runtime.test.ts +112 -0
  224. package/tests/logic/client-settings.test.tsx +841 -0
  225. package/tests/logic/client-shell.test.tsx +466 -0
  226. package/tests/logic/client-theme.test.tsx +441 -0
  227. package/tests/logic/config.test.ts +197 -0
  228. package/tests/logic/dev-client-watch.test.ts +108 -0
  229. package/tests/logic/dev-routes-production-build.test.ts +168 -0
  230. package/tests/logic/device-code-migration.test.ts +70 -0
  231. package/tests/logic/lifecycle.test.ts +221 -0
  232. package/tests/logic/plugin-commands.test.ts +69 -0
  233. package/tests/logic/plugin-page-authz.test.ts +53 -0
  234. package/tests/logic/route-navigation.test.ts +66 -0
  235. package/tests/logic/route-overlay.test.tsx +353 -0
  236. package/tests/logic/server-imports.test.ts +88 -0
  237. package/tests/logic/tailwind-sources.test.ts +151 -0
  238. package/tests/logic/template-package-name.test.ts +88 -0
  239. package/tests/logic/theme-preferences.test.ts +86 -0
  240. package/tests/logic/theme-tokens.test.ts +248 -0
  241. package/tests/logic/toaster.test.tsx +108 -0
  242. package/tests/logic/user-menu-sign-out.test.tsx +98 -0
  243. package/tests/setup/client-config.ts +27 -0
  244. package/tsconfig.json +11 -0
  245. package/tsconfig.node.json +12 -0
  246. package/tsconfig.server.json +19 -0
  247. package/vite.config.ts +92 -0
  248. package/vitest.config.ts +44 -0
  249. package/README.md +0 -3
@@ -0,0 +1,187 @@
1
+ import {
2
+ ClientApplicationContext,
3
+ type ClientApplication,
4
+ createAppClientConfig,
5
+ } from '@nocobase/app-client';
6
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
7
+ import { I18nRuntime } from '@nocobase/i18n';
8
+ import { I18nProvider } from '@nocobase/i18n/client';
9
+ import userEvent from '@testing-library/user-event';
10
+ import { act, render, screen } from '@testing-library/react';
11
+ import type { ReactNode } from 'react';
12
+ import { MemoryRouter, Route, Routes } from 'react-router';
13
+ import { describe, expect, it, vi } from 'vitest';
14
+
15
+ import locales from '../../client/locales/index.js';
16
+ import { AppLayout } from '../../client/layouts/app-layout.js';
17
+ vi.mock('@nocobase/app-plugin-i18n/client', () => ({
18
+ useSyncServerLocale: () => {},
19
+ }));
20
+ import { SettingsLayout } from '../../client/layouts/settings-layout.js';
21
+
22
+ // The header's inbox button reads the in-app notification plugin's API, which these tests do not serve; it is
23
+ // covered by tests/components/inbox.test.tsx.
24
+ vi.mock('../../client/components/inbox-header-button', () => ({
25
+ InboxHeaderButton: () => null,
26
+ }));
27
+
28
+ vi.mock('../../client/routing/client-route.js', () => ({
29
+ ClientRoute: () => <p>Preferences content</p>,
30
+ }));
31
+ vi.mock('../../client/theme/index.js', () => ({ ThemeSettings: () => null }));
32
+ vi.mock('../../client/layouts/components/user-menu.js', () => ({
33
+ UserMenu: () => null,
34
+ }));
35
+ vi.mock('../../client/routing/route-navigation.js', async (importOriginal) => ({
36
+ ...(await importOriginal<
37
+ typeof import('../../client/routing/route-navigation.js')
38
+ >()),
39
+ useRouteNavigation: (routes: readonly AppClientRegisteredRoute[]) => ({
40
+ items: routes.map((route) => ({ route, children: [] })),
41
+ loading: false,
42
+ denied: new Set<string>(),
43
+ }),
44
+ }));
45
+
46
+ const route: AppClientRegisteredRoute = {
47
+ id: 'preferences',
48
+ name: 'preferences',
49
+ path: '/settings/preferences',
50
+ packageName: 'test',
51
+ source: 'application',
52
+ auth: 'optional',
53
+ navigation: { title: 'Preferences' },
54
+ componentLoader: async () => ({ default: () => <p>Preferences content</p> }),
55
+ };
56
+
57
+ /** The page the shell opens on: the header names it. */
58
+ const overview: AppClientRegisteredRoute = {
59
+ id: 'overview',
60
+ name: 'overview',
61
+ path: '/',
62
+ packageName: 'test',
63
+ source: 'application',
64
+ auth: 'optional',
65
+ breadcrumb: { title: 'Overview' },
66
+ componentLoader: async () => ({ default: () => null }),
67
+ };
68
+
69
+ async function setup(children: ReactNode, path = '/') {
70
+ vi.stubGlobal('matchMedia', () => ({
71
+ matches: true,
72
+ addEventListener: vi.fn(),
73
+ removeEventListener: vi.fn(),
74
+ }));
75
+ const runtime = new I18nRuntime({
76
+ defaultLocale: 'en-US',
77
+ locales: ['en-US', 'zh-CN'],
78
+ applicationNamespace: 'test-app',
79
+ });
80
+ runtime.registerApplicationNamespace('test-app', locales);
81
+ await runtime.init('en-US');
82
+ const app = {
83
+ config: createAppClientConfig({ rawConfig: {} }),
84
+ runtime: { settingsRouteTree: [route] },
85
+ } as unknown as ClientApplication;
86
+ render(
87
+ <I18nProvider runtime={runtime}>
88
+ <ClientApplicationContext.Provider value={app}>
89
+ <MemoryRouter initialEntries={[path]}>{children}</MemoryRouter>
90
+ </ClientApplicationContext.Provider>
91
+ </I18nProvider>,
92
+ );
93
+ return runtime;
94
+ }
95
+
96
+ describe('shell translations', () => {
97
+ it('updates header, footer, tooltips and accessible labels without remounting', async () => {
98
+ const runtime = await setup(<AppLayout routes={[overview]} />);
99
+ // The header's trail, where a tagline used to be.
100
+ expect(
101
+ screen.getByRole('navigation', { name: 'Breadcrumb' }),
102
+ ).toHaveTextContent('Overview');
103
+ expect(screen.getByText('AI builds freely.')).toBeVisible();
104
+ await act(() => runtime.changeLanguage('zh-CN'));
105
+ expect(screen.getByRole('navigation', { name: '面包屑' })).toBeVisible();
106
+ expect(screen.getByText('AI 自由构建。')).toBeVisible();
107
+ expect(
108
+ screen.getByRole('link', { name: 'NocoBase' }).parentElement,
109
+ ).toHaveTextContent('NocoBase 保障可靠。');
110
+ expect(
111
+ screen.getByRole('button', { name: '展开或收起导航' }),
112
+ ).toHaveAttribute('title', '展开或收起导航');
113
+ expect(screen.getByRole('link', { name: 'NocoBase' })).toHaveAttribute(
114
+ 'href',
115
+ 'https://www.nocobase.com',
116
+ );
117
+ const user = userEvent.setup();
118
+ const settings = screen.getByRole('link', { name: '设置' });
119
+ expect(settings).not.toHaveAttribute('title');
120
+ await user.hover(settings);
121
+ expect(
122
+ await screen.findByText('设置', {
123
+ selector: '[data-slot=tooltip-content]',
124
+ }),
125
+ ).toBeVisible();
126
+ await user.unhover(settings);
127
+ await act(() => runtime.changeLanguage('en-US'));
128
+ expect(
129
+ screen.getByRole('navigation', { name: 'Breadcrumb' }),
130
+ ).toBeVisible();
131
+ act(() => settings.focus());
132
+ expect(
133
+ await screen.findByText('Settings', {
134
+ selector: '[data-slot=tooltip-content]',
135
+ }),
136
+ ).toBeVisible();
137
+ });
138
+
139
+ it.each([
140
+ [
141
+ 'settings',
142
+ SettingsLayout,
143
+ '暂无可用设置',
144
+ '没有已启用的插件提供你有权访问的设置页面。',
145
+ ],
146
+ ] as const)(
147
+ 'translates the %s empty state',
148
+ async (surface, Layout, title, description) => {
149
+ const runtime = await setup(<Layout routeTree={[]} />, `/${surface}`);
150
+ await act(() => runtime.changeLanguage('zh-CN'));
151
+ expect(screen.getByRole('heading', { name: title })).toBeVisible();
152
+ expect(screen.getByText(description)).toBeVisible();
153
+ expect(screen.getByRole('link', { name: '返回应用' })).toHaveAttribute(
154
+ 'href',
155
+ '/',
156
+ );
157
+ },
158
+ );
159
+
160
+ it.each(['settings'] as const)(
161
+ 'translates the %s page header',
162
+ async (surface) => {
163
+ const Layout = SettingsLayout;
164
+ const page = { ...route, path: `/${surface}/preferences` };
165
+ const runtime = await setup(
166
+ <Routes>
167
+ <Route
168
+ path={`/${surface}/*`}
169
+ element={<Layout routeTree={[page]} />}
170
+ />
171
+ </Routes>,
172
+ page.path,
173
+ );
174
+ await screen.findByText('Preferences content');
175
+ await act(() => runtime.changeLanguage('zh-CN'));
176
+ expect(screen.getByRole('link', { name: '返回应用' })).toHaveAttribute(
177
+ 'href',
178
+ '/',
179
+ );
180
+ expect(
181
+ screen.getByRole('navigation', {
182
+ name: '设置',
183
+ }),
184
+ ).toBeVisible();
185
+ },
186
+ );
187
+ });
@@ -0,0 +1,25 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { Loading } from '../../client/components/loading';
5
+
6
+ describe('Loading', () => {
7
+ it('renders one accessible loading status', () => {
8
+ render(<Loading label='Loading page' />);
9
+
10
+ expect(
11
+ screen.getByRole('status', { name: 'Loading page' }),
12
+ ).toBeInTheDocument();
13
+ expect(screen.getAllByRole('status')).toHaveLength(1);
14
+ });
15
+
16
+ it('supports the standard fullscreen presentation', () => {
17
+ render(<Loading fullscreen />);
18
+
19
+ expect(screen.getByRole('status')).toHaveClass(
20
+ 'min-h-svh',
21
+ 'w-full',
22
+ 'bg-background',
23
+ );
24
+ });
25
+ });
@@ -0,0 +1,355 @@
1
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
+ import {
3
+ act,
4
+ fireEvent,
5
+ render,
6
+ screen,
7
+ waitFor,
8
+ within,
9
+ } from '@testing-library/react';
10
+ import userEvent from '@testing-library/user-event';
11
+ import {
12
+ TestI18nProvider,
13
+ createTestI18nRuntime,
14
+ } from '@nocobase/i18n/testing';
15
+ import type { ReactNode } from 'react';
16
+ import { MemoryRouter } from 'react-router';
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
18
+
19
+ import {
20
+ routeKey,
21
+ type RouteNavigationItem,
22
+ } from '../../client/routing/route-navigation.js';
23
+ import {
24
+ AppSidebar,
25
+ AppSidebarProvider,
26
+ AppSidebarToggle,
27
+ } from '../../client/layouts/components/app-sidebar.js';
28
+ import { NavigationMenu } from '../../client/layouts/components/navigation-menu.js';
29
+
30
+ // Navigation titles are route data, not keys any namespace owns: the menu translates each through its package's
31
+ // namespace with the title itself as `defaultValue`, so the runtime is not strict.
32
+ const runtime = await createTestI18nRuntime({ strict: false });
33
+
34
+ function I18n({ children }: { readonly children: ReactNode }) {
35
+ return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
36
+ }
37
+
38
+ const preferenceKey = 'nocobase:sidebar:collapsed';
39
+
40
+ function setCollapsed(collapsed: boolean) {
41
+ localStorage.setItem(preferenceKey, String(collapsed));
42
+ window.dispatchEvent(new Event('nocobase:sidebar-preference-change'));
43
+ }
44
+
45
+ function page(name: string): AppClientRegisteredRoute {
46
+ return {
47
+ name,
48
+ id: name,
49
+ path: `/${name}`,
50
+ auth: 'required',
51
+ packageName: 'test',
52
+ source: 'application',
53
+ navigation: { title: name },
54
+ componentLoader: async () => ({ default: () => null }),
55
+ };
56
+ }
57
+
58
+ const child = page('child');
59
+ const sibling = page('sibling');
60
+
61
+ function shell(items: readonly RouteNavigationItem[], selectedKey?: string) {
62
+ return (
63
+ <MemoryRouter>
64
+ <AppSidebarProvider>
65
+ <AppSidebar label='Navigation'>
66
+ <NavigationMenu
67
+ items={items}
68
+ label='Menu'
69
+ selectedKey={selectedKey}
70
+ />
71
+ </AppSidebar>
72
+ <AppSidebarToggle />
73
+ </AppSidebarProvider>
74
+ </MemoryRouter>
75
+ );
76
+ }
77
+
78
+ beforeEach(() => {
79
+ localStorage.clear();
80
+ vi.stubGlobal('matchMedia', () => ({
81
+ matches: false,
82
+ addEventListener: vi.fn(),
83
+ removeEventListener: vi.fn(),
84
+ }));
85
+ });
86
+ afterEach(() => vi.unstubAllGlobals());
87
+
88
+ describe.each([
89
+ { title: 'navigation group', clickable: false },
90
+ { title: 'clickable parent', clickable: true },
91
+ ])('$title', ({ clickable }) => {
92
+ const item: RouteNavigationItem = {
93
+ route: {
94
+ ...page('Group'),
95
+ componentLoader: clickable ? page('Group').componentLoader : undefined,
96
+ },
97
+ children: [
98
+ { route: child, children: [] },
99
+ { route: sibling, children: [] },
100
+ ],
101
+ };
102
+ const tree = (selectedKey: string | undefined) => shell([item], selectedKey);
103
+ function toggle() {
104
+ return screen.getByRole('button', { name: 'Group' });
105
+ }
106
+ function expectExpanded(expanded: boolean) {
107
+ expect(toggle()).toHaveAttribute('aria-expanded', String(expanded));
108
+ }
109
+
110
+ it('keeps an active group open when navigating to another group', () => {
111
+ const { rerender } = render(tree(routeKey(child)), { wrapper: I18n });
112
+ expectExpanded(true);
113
+ rerender(tree('elsewhere'));
114
+ expectExpanded(true);
115
+ });
116
+
117
+ it('preserves a manually expanded group across navigation', async () => {
118
+ const user = userEvent.setup();
119
+ const { rerender } = render(tree('elsewhere'), { wrapper: I18n });
120
+ await user.click(toggle());
121
+ expectExpanded(true);
122
+ rerender(tree('another-page'));
123
+ expectExpanded(true);
124
+ rerender(tree(routeKey(child)));
125
+ rerender(tree('elsewhere'));
126
+ expectExpanded(true);
127
+ });
128
+
129
+ it('preserves manual collapse until navigating into the group', async () => {
130
+ const user = userEvent.setup();
131
+ const { rerender } = render(tree(routeKey(child)), { wrapper: I18n });
132
+ await user.click(toggle());
133
+ expectExpanded(false);
134
+ rerender(tree(routeKey(child)));
135
+ expectExpanded(false);
136
+ rerender(tree('elsewhere'));
137
+ expectExpanded(false);
138
+ rerender(tree(routeKey(sibling)));
139
+ expectExpanded(true);
140
+ });
141
+ });
142
+
143
+ it('marks the selected page with the selected navigation colors', () => {
144
+ render(
145
+ shell([{ route: page('Home'), children: [] }], routeKey(page('Home'))),
146
+ {
147
+ wrapper: I18n,
148
+ },
149
+ );
150
+ const link = screen.getByRole('link', { name: 'Home' });
151
+ expect(link).toHaveAttribute('aria-current', 'page');
152
+ expect(link).toHaveAttribute('data-active');
153
+ expect(link).toHaveClass(
154
+ 'data-active:bg-sidebar-primary',
155
+ 'data-active:text-sidebar-primary-foreground',
156
+ );
157
+ });
158
+
159
+ describe('collapsed navigation', () => {
160
+ beforeEach(() => setCollapsed(true));
161
+
162
+ function show(item: RouteNavigationItem, selectedKey = routeKey(child)) {
163
+ return render(shell([item], selectedKey), { wrapper: I18n });
164
+ }
165
+
166
+ it('shows a leaf label immediately on hover and dismisses it on leave', async () => {
167
+ const user = userEvent.setup();
168
+ show({ route: page('Home'), children: [] });
169
+ const link = screen.getByRole('link', { name: 'Home' });
170
+ await user.hover(link);
171
+ expect(screen.getByRole('tooltip')).toHaveTextContent('Home');
172
+ expect(link).not.toHaveAttribute('title');
173
+ await user.unhover(link);
174
+ await waitFor(() =>
175
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(),
176
+ );
177
+ });
178
+
179
+ it.each([false, true])(
180
+ 'opens a group list immediately on hover (clickable parent: %s)',
181
+ async (clickable) => {
182
+ const user = userEvent.setup();
183
+ show({
184
+ route: {
185
+ ...page('Group'),
186
+ componentLoader: clickable
187
+ ? page('Group').componentLoader
188
+ : undefined,
189
+ },
190
+ children: [{ route: child, children: [] }],
191
+ });
192
+ const trigger = screen.getByRole(clickable ? 'link' : 'button', {
193
+ name: 'Group',
194
+ });
195
+ // The group holding the selected page is highlighted.
196
+ expect(trigger).toHaveAttribute('data-active');
197
+ fireEvent.mouseEnter(trigger);
198
+ const popup = screen.getByRole('dialog', { name: 'Group' });
199
+ const link = within(popup).getByRole('link', { name: 'child' });
200
+ expect(link).toHaveAttribute('aria-current', 'page');
201
+ expect(link).toHaveAttribute('href', '/child');
202
+ // user-event omits mouseleave.relatedTarget; with zero close delay and
203
+ // JSDOM's empty rectangles, safePolygon would close before pointer entry.
204
+ fireEvent.mouseLeave(trigger, { relatedTarget: popup });
205
+ await user.hover(popup);
206
+ await user.click(link);
207
+ await waitFor(() =>
208
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
209
+ );
210
+ },
211
+ );
212
+
213
+ it('opens on keyboard focus and closes with Escape', async () => {
214
+ const user = userEvent.setup();
215
+ show({
216
+ route: { ...page('Group'), componentLoader: undefined },
217
+ children: [{ route: child, children: [] }],
218
+ });
219
+ // The brand link comes first in the sidebar.
220
+ await user.tab();
221
+ await user.tab();
222
+ expect(await screen.findByRole('dialog', { name: 'Group' })).toBeVisible();
223
+ await user.tab();
224
+ expect(screen.getByRole('link', { name: 'child' })).toHaveFocus();
225
+ await user.keyboard('{Escape}');
226
+ await waitFor(() =>
227
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
228
+ );
229
+ });
230
+
231
+ it('keeps a parent link navigable and expands nested groups inside the popup', async () => {
232
+ const user = userEvent.setup();
233
+ show({
234
+ route: page('Parent'),
235
+ children: [
236
+ {
237
+ route: { ...page('Nested'), componentLoader: undefined },
238
+ children: [{ route: sibling, children: [] }],
239
+ },
240
+ ],
241
+ });
242
+ const parent = screen.getByRole('link', { name: 'Parent' });
243
+ expect(parent).toHaveAttribute('href', '/Parent');
244
+ fireEvent.mouseEnter(parent);
245
+ const popup = screen.getByRole('dialog', { name: 'Parent' });
246
+ fireEvent.mouseLeave(parent, { relatedTarget: popup });
247
+ await user.hover(popup);
248
+ await user.click(within(popup).getByText('Nested'));
249
+ expect(within(popup).getByRole('link', { name: 'sibling' })).toBeVisible();
250
+ await user.click(parent);
251
+ await waitFor(() =>
252
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
253
+ );
254
+ });
255
+
256
+ it('does not reopen an old popup after expanding and collapsing the sidebar', async () => {
257
+ const user = userEvent.setup();
258
+ show(
259
+ {
260
+ route: { ...page('Group'), componentLoader: undefined },
261
+ children: [{ route: child, children: [] }],
262
+ },
263
+ 'elsewhere',
264
+ );
265
+ await user.hover(screen.getByRole('button', { name: 'Group' }));
266
+ expect(await screen.findByRole('dialog')).toBeVisible();
267
+ act(() => setCollapsed(false));
268
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
269
+ act(() => setCollapsed(true));
270
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
271
+ });
272
+
273
+ it.each(['expanded', 'mobile'])(
274
+ 'does not add hover overlays when %s',
275
+ async (mode) => {
276
+ if (mode === 'expanded') setCollapsed(false);
277
+ else vi.stubGlobal('innerWidth', 390);
278
+ const user = userEvent.setup();
279
+ show({ route: page('Home'), children: [] });
280
+ if (mode === 'mobile')
281
+ await user.click(
282
+ screen.getByRole('button', { name: 'Open navigation' }),
283
+ );
284
+ await user.hover(screen.getByRole('link', { name: 'Home' }));
285
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
286
+ expect(
287
+ screen.queryByRole('dialog', { name: 'Home' }),
288
+ ).not.toBeInTheDocument();
289
+ },
290
+ );
291
+ });
292
+
293
+ describe('sidebar shell', () => {
294
+ it('collapses from the header toggle into the shared preference', async () => {
295
+ const user = userEvent.setup();
296
+ render(shell([{ route: page('Home'), children: [] }]), { wrapper: I18n });
297
+ const sidebar = screen.getByRole('complementary', { name: 'Navigation' });
298
+ await user.click(
299
+ screen.getByRole('button', { name: 'Collapse navigation' }),
300
+ );
301
+ expect(localStorage.getItem(preferenceKey)).toBe('true');
302
+ expect(sidebar.closest('[data-state]')).toHaveAttribute(
303
+ 'data-state',
304
+ 'collapsed',
305
+ );
306
+ expect(
307
+ screen.getByRole('button', { name: 'Expand navigation' }),
308
+ ).toHaveAttribute('aria-pressed', 'true');
309
+ });
310
+
311
+ it('ignores Ctrl/Cmd+B, leaving the key to whatever is focused', async () => {
312
+ const user = userEvent.setup();
313
+ const onKeyDown = vi.fn<(key: string) => void>();
314
+ render(
315
+ <>
316
+ {shell([{ route: page('Home'), children: [] }])}
317
+ <textarea
318
+ aria-label='Editor'
319
+ onKeyDown={(event) => onKeyDown(event.key)}
320
+ />
321
+ </>,
322
+ { wrapper: I18n },
323
+ );
324
+ await user.click(screen.getByRole('textbox', { name: 'Editor' }));
325
+ await user.keyboard('{Control>}b{/Control}');
326
+ await user.keyboard('{Meta>}b{/Meta}');
327
+ expect(onKeyDown.mock.calls.filter(([key]) => key === 'b')).toHaveLength(2);
328
+ expect(localStorage.getItem(preferenceKey)).toBeNull();
329
+ expect(
330
+ screen.getByRole('button', { name: 'Collapse navigation' }),
331
+ ).toBeInTheDocument();
332
+ });
333
+
334
+ it('opens the phone sheet with a translated title and closes it on navigation', async () => {
335
+ vi.stubGlobal('innerWidth', 390);
336
+ const user = userEvent.setup();
337
+ render(shell([{ route: page('Home'), children: [] }]), { wrapper: I18n });
338
+ await user.click(screen.getByRole('button', { name: 'Open navigation' }));
339
+ const sheet = await screen.findByRole('dialog', { name: 'Navigation' });
340
+ expect(sheet).toHaveAttribute('data-sidebar', 'sidebar');
341
+ await user.click(within(sheet).getByRole('link', { name: 'Home' }));
342
+ await waitFor(() =>
343
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
344
+ );
345
+ await user.click(screen.getByRole('button', { name: 'Open navigation' }));
346
+ await user.click(
347
+ within(await screen.findByRole('dialog')).getByRole('button', {
348
+ name: 'Close navigation',
349
+ }),
350
+ );
351
+ await waitFor(() =>
352
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
353
+ );
354
+ });
355
+ });