@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,318 @@
1
+ // The model to copy for a page test: a page that loads data through `useApiClient`, checks a permission with
2
+ // `useCan`, formats with `useLocale`, raises a toast and opens a child-route `RouteDialog`. The page under test is
3
+ // defined in this file so the template has one to run; in an application, import the real page and its child routes
4
+ // instead and keep the setup below.
5
+ import { ApiClientError, useApiClient, useToaster } from '@nocobase/app-client';
6
+ import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
7
+ import { useCan } from '@nocobase/app-plugin-authorization/client';
8
+ import { useLocale, useTranslation } from '@nocobase/i18n/client';
9
+ import {
10
+ TestI18nProvider,
11
+ createTestI18nRuntime,
12
+ } from '@nocobase/i18n/testing';
13
+ import { render, screen, waitFor } from '@testing-library/react';
14
+ import userEvent from '@testing-library/user-event';
15
+ import { type ReactElement, type ReactNode, useEffect, useState } from 'react';
16
+ import { createMemoryRouter, Link, Outlet, RouterProvider } from 'react-router';
17
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
18
+
19
+ import { RouteDialog } from '#components/route-dialog';
20
+ import { Button } from '#components/ui/button';
21
+ import { useRouteOverlay } from '#components/use-route-overlay';
22
+
23
+ import enUS from '../../client/locales/en-US.js';
24
+
25
+ // `vi.mock` factories run before this file's imports and code, so everything a factory uses is created here.
26
+ const { api, toaster, permission, refresh } = vi.hoisted(() => ({
27
+ // One object for the whole file: a new one per call would restart every effect that depends on `api`.
28
+ api: { request: vi.fn() },
29
+ // One toaster too, so a test asserts what `show` was called with rather than how Base UI renders it.
30
+ toaster: { show: vi.fn(), close: vi.fn() },
31
+ // What `useCan` returns; a test sets `can`, `isPending` or `error` before rendering.
32
+ permission: {
33
+ can: true,
34
+ isPending: false,
35
+ error: undefined as unknown,
36
+ },
37
+ refresh: vi.fn(),
38
+ }));
39
+
40
+ vi.mock('@nocobase/app-client', async (original) => ({
41
+ // Keep the real module, so `ApiClientError` stays the class the page checks with `instanceof`.
42
+ ...(await original<typeof import('@nocobase/app-client')>()),
43
+ useApiClient: () => api,
44
+ useToaster: () => toaster,
45
+ }));
46
+ vi.mock('@nocobase/app-plugin-authentication/client', () => ({
47
+ useAuthentication: () => ({ refresh }),
48
+ }));
49
+ vi.mock('@nocobase/app-plugin-authorization/client', () => ({
50
+ useCan: () => ({
51
+ can: permission.can,
52
+ isPending: permission.isPending,
53
+ error: permission.error,
54
+ retry: vi.fn(),
55
+ }),
56
+ }));
57
+
58
+ // The inline page's own copy. A real page finds its keys in `client/locales/`; this one brings them, merged over the
59
+ // application's so the dialog's close button and the shared `status.*` keys resolve as they would there.
60
+ const copy = {
61
+ 'status.sessionExpired': 'Your session has ended.',
62
+ actions: { ...enUS.actions, create: 'Create', signInAgain: 'Sign in again' },
63
+ items: {
64
+ create: {
65
+ action: 'New item',
66
+ title: 'Create an item',
67
+ success: 'Created the item',
68
+ },
69
+ form: { description: 'Name the item.' },
70
+ error: {
71
+ forbidden: 'You cannot view items.',
72
+ failed: 'Items could not be loaded.',
73
+ },
74
+ },
75
+ };
76
+
77
+ // The real runtime, strict: a key the resources lack fails the test instead of rendering as text.
78
+ const runtime = await createTestI18nRuntime({
79
+ application: {
80
+ namespace: '@nocobase/app-template-default',
81
+ resources: { ...enUS, ...copy },
82
+ },
83
+ });
84
+
85
+ function I18n({ children }: { readonly children: ReactNode }): ReactElement {
86
+ return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
87
+ }
88
+
89
+ interface Item {
90
+ readonly id: number;
91
+ readonly name: string;
92
+ readonly updatedAt: string;
93
+ }
94
+
95
+ function ItemsPage(): ReactElement {
96
+ const { t } = useTranslation();
97
+ const { locale } = useLocale();
98
+ const client = useApiClient();
99
+ const { refresh: refreshSession } = useAuthentication();
100
+ const { can: canCreate } = useCan({
101
+ resource: { type: 'composite', id: 'items' },
102
+ action: 'create',
103
+ });
104
+ const [result, setResult] = useState<{
105
+ readonly items?: Item[];
106
+ readonly error?: unknown;
107
+ }>();
108
+
109
+ useEffect(() => {
110
+ const controller = new AbortController();
111
+ client
112
+ .request<{ data: Item[] }>({ path: 'items', signal: controller.signal })
113
+ .then(
114
+ ({ data }) => {
115
+ if (!controller.signal.aborted) setResult({ items: data });
116
+ },
117
+ (error: unknown) => {
118
+ if (!controller.signal.aborted) setResult({ error });
119
+ },
120
+ );
121
+ return () => controller.abort();
122
+ }, [client]);
123
+
124
+ const dateFormat = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' });
125
+ let content: ReactElement;
126
+ const status =
127
+ result?.error instanceof ApiClientError ? result.error.status : undefined;
128
+ if (status === 401) {
129
+ // The session ended: no retry, only a way to sign in again (in an application, SessionExpiredAlert).
130
+ content = (
131
+ <p role='alert'>
132
+ {t('status.sessionExpired')}
133
+ <Button onClick={() => void refreshSession()}>
134
+ {t('actions.signInAgain')}
135
+ </Button>
136
+ </p>
137
+ );
138
+ } else if (result?.error) {
139
+ const forbidden = status === 403;
140
+ content = (
141
+ <p role='alert'>
142
+ {forbidden ? t('items.error.forbidden') : t('items.error.failed')}
143
+ {forbidden ? null : <Button>{t('status.retry')}</Button>}
144
+ </p>
145
+ );
146
+ } else if (!result?.items) {
147
+ content = <p role='status'>{t('status.loading')}</p>;
148
+ } else {
149
+ content = (
150
+ <ul>
151
+ {result.items.map((item) => (
152
+ <li key={item.id}>
153
+ {item.name} {dateFormat.format(new Date(item.updatedAt))}
154
+ </li>
155
+ ))}
156
+ </ul>
157
+ );
158
+ }
159
+
160
+ return (
161
+ <div>
162
+ {canCreate ? (
163
+ <Button nativeButton={false} render={<Link to='new' />}>
164
+ {t('items.create.action')}
165
+ </Button>
166
+ ) : null}
167
+ {content}
168
+ <Outlet />
169
+ </div>
170
+ );
171
+ }
172
+
173
+ function NewItemFooter(): ReactElement {
174
+ const { t } = useTranslation();
175
+ const { close } = useRouteOverlay();
176
+ const toaster = useToaster();
177
+ return (
178
+ <Button
179
+ onClick={() => {
180
+ toaster.show({ type: 'success', title: t('items.create.success') });
181
+ void close();
182
+ }}
183
+ >
184
+ {t('actions.create')}
185
+ </Button>
186
+ );
187
+ }
188
+
189
+ function NewItemPage(): ReactElement {
190
+ const { t } = useTranslation();
191
+ return (
192
+ <RouteDialog title={t('items.create.title')} footer={<NewItemFooter />}>
193
+ <p>{t('items.form.description')}</p>
194
+ </RouteDialog>
195
+ );
196
+ }
197
+
198
+ /** The page as the application routes it: the list at /items with the create dialog as its child route. */
199
+ function renderAt(url: string) {
200
+ const router = createMemoryRouter(
201
+ [
202
+ {
203
+ path: '/items',
204
+ element: <ItemsPage />,
205
+ children: [{ path: 'new', element: <NewItemPage /> }],
206
+ },
207
+ ],
208
+ { initialEntries: [url] },
209
+ );
210
+ render(<RouterProvider router={router} />, { wrapper: I18n });
211
+ return router;
212
+ }
213
+
214
+ describe('page test harness', () => {
215
+ beforeEach(() => {
216
+ api.request.mockReset();
217
+ toaster.show.mockReset();
218
+ permission.can = true;
219
+ permission.isPending = false;
220
+ permission.error = undefined;
221
+ refresh.mockReset();
222
+ });
223
+
224
+ it('loads the list with the request the page is expected to send', async () => {
225
+ api.request.mockResolvedValue({
226
+ data: [{ id: 1, name: 'Alpha', updatedAt: '2026-01-02T00:00:00Z' }],
227
+ });
228
+ renderAt('/items');
229
+
230
+ expect(screen.getByRole('status')).toHaveTextContent(
231
+ enUS['status.loading'],
232
+ );
233
+ expect(await screen.findByText(/Alpha/)).toBeInTheDocument();
234
+ expect(api.request).toHaveBeenCalledWith(
235
+ expect.objectContaining({ path: 'items' }),
236
+ );
237
+ });
238
+
239
+ it('explains a 403 without offering a retry', async () => {
240
+ api.request.mockRejectedValue(
241
+ new ApiClientError('Forbidden', {
242
+ status: 403,
243
+ method: 'GET',
244
+ url: '/api/items',
245
+ }),
246
+ );
247
+ renderAt('/items');
248
+
249
+ expect(await screen.findByRole('alert')).toHaveTextContent(
250
+ copy.items.error.forbidden,
251
+ );
252
+ expect(
253
+ screen.queryByRole('button', { name: enUS['status.retry'] }),
254
+ ).not.toBeInTheDocument();
255
+ });
256
+
257
+ it('treats a 401 as an ended session, not as a failure to retry', async () => {
258
+ api.request.mockRejectedValue(
259
+ new ApiClientError('Unauthorized', {
260
+ status: 401,
261
+ method: 'GET',
262
+ url: '/api/items',
263
+ }),
264
+ );
265
+ renderAt('/items');
266
+
267
+ expect(await screen.findByRole('alert')).toHaveTextContent(
268
+ copy['status.sessionExpired'],
269
+ );
270
+ expect(
271
+ screen.queryByRole('button', { name: enUS['status.retry'] }),
272
+ ).not.toBeInTheDocument();
273
+ await userEvent.click(
274
+ screen.getByRole('button', { name: copy.actions.signInAgain }),
275
+ );
276
+ expect(refresh).toHaveBeenCalledTimes(1);
277
+ });
278
+
279
+ it('shows the create action only with permission', async () => {
280
+ api.request.mockResolvedValue({ data: [] });
281
+ renderAt('/items');
282
+
283
+ // A Button rendered as a Link (nativeButton={false}) is announced as a button, so query it by that role.
284
+ expect(
285
+ await screen.findByRole('button', { name: copy.items.create.action }),
286
+ ).toHaveAttribute('href', '/items/new');
287
+ });
288
+
289
+ it('hides the create action without permission', async () => {
290
+ permission.can = false;
291
+ api.request.mockResolvedValue({ data: [] });
292
+ renderAt('/items');
293
+
294
+ await waitFor(() => expect(screen.queryByRole('status')).toBeNull());
295
+ expect(
296
+ screen.queryByRole('button', { name: copy.items.create.action }),
297
+ ).not.toBeInTheDocument();
298
+ });
299
+
300
+ it('opens the create dialog from its URL and closes it back to the list', async () => {
301
+ api.request.mockResolvedValue({ data: [] });
302
+ const router = renderAt('/items/new');
303
+
304
+ expect(
305
+ await screen.findByRole('dialog', { name: copy.items.create.title }),
306
+ ).toBeInTheDocument();
307
+ await userEvent.click(
308
+ screen.getByRole('button', { name: copy.actions.create }),
309
+ );
310
+
311
+ expect(toaster.show).toHaveBeenCalledWith({
312
+ type: 'success',
313
+ title: copy.items.create.success,
314
+ });
315
+ await waitFor(() => expect(router.state.location.pathname).toBe('/items'));
316
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
317
+ });
318
+ });
@@ -0,0 +1,80 @@
1
+ import {
2
+ TestI18nProvider,
3
+ createTestI18nRuntime,
4
+ } from '@nocobase/i18n/testing';
5
+ import { act, render, screen } from '@testing-library/react';
6
+ import type { ReactElement, ReactNode } from 'react';
7
+ import { describe, expect, it } from 'vitest';
8
+
9
+ import { Dialog, DialogContent, DialogTitle } from '#components/ui/dialog';
10
+ import { Sheet, SheetContent, SheetTitle } from '#components/ui/sheet';
11
+ import { Spinner } from '#components/ui/spinner';
12
+ import { createToastManager, Toaster } from '#components/ui/toast';
13
+
14
+ import zhCN from '../../client/locales/zh-CN.js';
15
+
16
+ // Chinese, because the registry's built-in English and the application's English wording are both "Close": only a
17
+ // label that goes through the application's locale reads 关闭.
18
+ const runtime = await createTestI18nRuntime({
19
+ locale: 'zh-CN',
20
+ application: { namespace: '@nocobase/app-template-default', resources: zhCN },
21
+ });
22
+
23
+ function I18n({ children }: { readonly children: ReactNode }): ReactElement {
24
+ return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
25
+ }
26
+
27
+ // These shipped primitives replace the English the registry builds into them with translation keys. Updating one
28
+ // by overwriting it with the registry's file brings the English back, and the matching test fails.
29
+ describe('labels built into the shipped primitives', () => {
30
+ it('names the Dialog close button through actions.close', () => {
31
+ render(
32
+ <Dialog open>
33
+ <DialogContent>
34
+ <DialogTitle>Panel</DialogTitle>
35
+ </DialogContent>
36
+ </Dialog>,
37
+ { wrapper: I18n },
38
+ );
39
+
40
+ expect(
41
+ screen.getByRole('button', { name: zhCN.actions.close }),
42
+ ).toBeInTheDocument();
43
+ });
44
+
45
+ it('names the Sheet close button through actions.close', () => {
46
+ render(
47
+ <Sheet open>
48
+ <SheetContent>
49
+ <SheetTitle>Panel</SheetTitle>
50
+ </SheetContent>
51
+ </Sheet>,
52
+ { wrapper: I18n },
53
+ );
54
+
55
+ expect(
56
+ screen.getByRole('button', { name: zhCN.actions.close }),
57
+ ).toBeInTheDocument();
58
+ });
59
+
60
+ it('names the toast close button through actions.close', async () => {
61
+ const manager = createToastManager();
62
+ render(<Toaster toastManager={manager} />, { wrapper: I18n });
63
+
64
+ act(() => {
65
+ manager.add({ title: 'Saved' });
66
+ });
67
+
68
+ // Base UI keeps the close button aria-hidden until the toast list is expanded, so find it by its label.
69
+ const [close] = await screen.findAllByLabelText(zhCN.actions.close);
70
+ expect(close).toHaveAttribute('data-slot', 'toast-close');
71
+ });
72
+
73
+ it('labels the Spinner through status.loading', () => {
74
+ render(<Spinner />, { wrapper: I18n });
75
+
76
+ expect(
77
+ screen.getByRole('status', { name: zhCN['status.loading'] }),
78
+ ).toBeInTheDocument();
79
+ });
80
+ });
@@ -0,0 +1,110 @@
1
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
+ import { act, render, screen, waitFor, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { MemoryRouter } from 'react-router';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import {
7
+ useRouteNavigation,
8
+ routeKey,
9
+ } from '../../client/routing/route-navigation';
10
+ import { NavigationMenu } from '../../client/layouts/components/navigation-menu';
11
+ import { AppSidebarProvider } from '../../client/layouts/components/app-sidebar';
12
+
13
+ const auth = vi.hoisted(() => ({ revision: 0, client: { can: vi.fn() } }));
14
+ vi.mock('@nocobase/app-plugin-authorization/client', () => ({
15
+ useAuthorizationClient: () => auth.client,
16
+ useAuthorizationRevision: () => auth.revision,
17
+ }));
18
+ vi.mock('@nocobase/i18n/client', () => ({
19
+ useTranslation: () => ({ t: (key: string) => key }),
20
+ }));
21
+ beforeEach(() => {
22
+ auth.revision = 0;
23
+ localStorage.clear();
24
+ vi.stubGlobal('matchMedia', () => ({
25
+ matches: false,
26
+ addEventListener: vi.fn(),
27
+ removeEventListener: vi.fn(),
28
+ }));
29
+ });
30
+ afterEach(() => vi.unstubAllGlobals());
31
+ function page(name: string): AppClientRegisteredRoute {
32
+ return {
33
+ id: name,
34
+ name,
35
+ path: `/${name}`,
36
+ packageName: 'test',
37
+ source: 'application',
38
+ auth: 'required',
39
+ authz: { resource: { type: 'page', id: name }, action: 'access' },
40
+ navigation: { title: name },
41
+ componentLoader: async () => ({ default: () => null }),
42
+ };
43
+ }
44
+ const visible = page('Allowed');
45
+ const hidden = page('Denied');
46
+ const routes: AppClientRegisteredRoute[] = [
47
+ { ...page('Group'), componentLoader: undefined, children: [visible, hidden] },
48
+ { ...page('Empty'), componentLoader: undefined, children: [hidden] },
49
+ ];
50
+ function Menu() {
51
+ const { items } = useRouteNavigation(routes);
52
+ return (
53
+ <NavigationMenu
54
+ items={items}
55
+ label='Navigation'
56
+ selectedKey={routeKey(visible)}
57
+ />
58
+ );
59
+ }
60
+ describe.each(['expanded', 'collapsed', 'mobile'])(
61
+ 'permissions in %s navigation',
62
+ (mode) => {
63
+ it('hides pending and denied entries, removes empty groups, and refreshes open menus', async () => {
64
+ if (mode === 'mobile') vi.stubGlobal('innerWidth', 390);
65
+ if (mode === 'collapsed')
66
+ localStorage.setItem('nocobase:sidebar:collapsed', 'true');
67
+ const resolve: ((allowed: boolean) => void)[] = [];
68
+ auth.client.can.mockImplementation(
69
+ () => new Promise<boolean>((done) => resolve.push(done)),
70
+ );
71
+ const tree = () => (
72
+ <MemoryRouter>
73
+ <AppSidebarProvider>
74
+ <Menu />
75
+ </AppSidebarProvider>
76
+ </MemoryRouter>
77
+ );
78
+ const { rerender } = render(tree());
79
+ expect(screen.queryByText('Group')).not.toBeInTheDocument();
80
+ await act(async () => {
81
+ resolve.forEach((done, i) => done(i === 0));
82
+ });
83
+ const user = userEvent.setup();
84
+ if (mode === 'collapsed') {
85
+ await user.hover(await screen.findByRole('button', { name: 'Group' }));
86
+ expect(
87
+ within(await screen.findByRole('dialog')).getByRole('link', {
88
+ name: 'Allowed',
89
+ }),
90
+ ).toHaveAttribute('href', '/Allowed');
91
+ } else {
92
+ expect(
93
+ await screen.findByRole('link', { name: 'Allowed' }),
94
+ ).toBeVisible();
95
+ }
96
+ expect(screen.queryByText('Denied')).not.toBeInTheDocument();
97
+ expect(screen.queryByText('Empty')).not.toBeInTheDocument();
98
+ auth.client.can.mockResolvedValue(false);
99
+ auth.revision++;
100
+ rerender(tree());
101
+ await waitFor(() =>
102
+ expect(screen.queryByText('Group')).not.toBeInTheDocument(),
103
+ );
104
+ expect(
105
+ screen.queryByRole('link', { name: 'Allowed' }),
106
+ ).not.toBeInTheDocument();
107
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
108
+ });
109
+ },
110
+ );
@@ -0,0 +1,42 @@
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';
4
+
5
+ const key = 'nocobase:sidebar:collapsed';
6
+
7
+ describe('shared sidebar preference', () => {
8
+ beforeEach(() => localStorage.clear());
9
+
10
+ it('shares changes across layouts and restores them after remount', () => {
11
+ const first = renderHook(() => useSidebarPreference());
12
+ const second = renderHook(() => useSidebarPreference());
13
+ expect(first.result.current[0]).toBe(false);
14
+ act(() => first.result.current[1]((value) => !value));
15
+ expect(second.result.current[0]).toBe(true);
16
+ expect(localStorage.getItem(key)).toBe('true');
17
+ first.unmount();
18
+ second.unmount();
19
+ const restored = renderHook(() => useSidebarPreference());
20
+ expect(restored.result.current[0]).toBe(true);
21
+ act(() => restored.result.current[1](false));
22
+ expect(localStorage.getItem(key)).toBe('false');
23
+ });
24
+
25
+ it('reads saved state and follows storage changes from another tab', () => {
26
+ localStorage.setItem(key, 'true');
27
+ const { result } = renderHook(() => useSidebarPreference());
28
+ expect(result.current[0]).toBe(true);
29
+ act(() => {
30
+ localStorage.removeItem(key);
31
+ window.dispatchEvent(new StorageEvent('storage', { key }));
32
+ });
33
+ expect(result.current[0]).toBe(false);
34
+ });
35
+
36
+ it('defaults to expanded for an invalid stored value', () => {
37
+ localStorage.setItem(key, 'invalid');
38
+ expect(renderHook(() => useSidebarPreference()).result.current[0]).toBe(
39
+ false,
40
+ );
41
+ });
42
+ });
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Theme token verification</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="./theme-tokens.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,111 @@
1
+ import { Refine } from '@refinedev/core';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { MemoryRouter } from 'react-router';
4
+ import { Button } from '../../client/components/ui/button';
5
+ import { Input } from '../../client/components/ui/input';
6
+ import {
7
+ Popover,
8
+ PopoverTrigger,
9
+ PopoverContent,
10
+ PopoverTitle,
11
+ } from '../../client/components/ui/popover';
12
+ import {
13
+ AppSidebar,
14
+ AppSidebarProvider,
15
+ AppSidebarToggle,
16
+ } from '../../client/layouts/components/app-sidebar';
17
+ import { AppThemeProvider, ThemeSettings } from '../../client/theme';
18
+ import '../../client/styles.css';
19
+
20
+ // A server-free browser fixture using the real shell, primitives and theme provider.
21
+ // Run Vite and open /main/tests/fixtures/theme-tokens.html.
22
+ export default function Fixture() {
23
+ return (
24
+ <MemoryRouter>
25
+ <AppThemeProvider>
26
+ <Refine options={{ disableTelemetry: true }}>
27
+ <AppSidebarProvider>
28
+ <AppSidebar label='Fixture sidebar'>
29
+ <div className='p-2'>
30
+ <Input aria-label='Sidebar content' />
31
+ </div>
32
+ </AppSidebar>
33
+ <main className='min-w-0 flex-1 space-y-6 overflow-y-auto p-6'>
34
+ <div className='flex flex-wrap gap-2'>
35
+ <ThemeSettings />
36
+ <AppSidebarToggle />
37
+ </div>
38
+ <h1 className='text-3xl'>Theme tokens · 主题样式</h1>
39
+ <p data-testid='body'>Body text · 中文内容与 English text</p>
40
+ <p className='font-serif'>Serif text</p>
41
+ <code>const theme = 'compact';</code>
42
+ <div className='flex flex-wrap gap-2'>
43
+ <Button>Default action</Button>
44
+ <Button size='sm'>Small action</Button>
45
+ <Button size='xs'>Extra small</Button>
46
+ <Button variant='destructive'>Delete</Button>
47
+ </div>
48
+ <Input
49
+ aria-label='Example input'
50
+ placeholder='输入内容 / Enter text'
51
+ />
52
+ <Popover>
53
+ <PopoverTrigger render={<Button variant='outline' />}>
54
+ Open popover
55
+ </PopoverTrigger>
56
+ <PopoverContent>
57
+ <PopoverTitle>Portal heading</PopoverTitle>
58
+ <p>Portal body · 浮层内容</p>
59
+ </PopoverContent>
60
+ </Popover>
61
+ <section className='rounded-xl border bg-card p-4 text-card-foreground shadow-md ring-1 ring-ring'>
62
+ <h2 className='text-xl'>Card heading</h2>
63
+ <p className='text-sm'>
64
+ A long description that wraps without clipping when fonts and
65
+ spacing change. 长文本应该正常换行,不应被控件裁切。
66
+ </p>
67
+ </section>
68
+ <svg
69
+ aria-label='Chart palette'
70
+ viewBox='0 0 120 30'
71
+ className='h-8'
72
+ >
73
+ {[1, 2, 3, 4, 5].map((i) => (
74
+ <rect
75
+ key={i}
76
+ x={(i - 1) * 24}
77
+ width='20'
78
+ height='30'
79
+ fill={`var(--chart-${i})`}
80
+ />
81
+ ))}
82
+ </svg>
83
+ <div className='flex flex-wrap gap-2'>
84
+ <div className='rounded-sm border p-2'>sm</div>
85
+ <div className='rounded-md border p-2'>md</div>
86
+ <div className='rounded-lg border p-2'>lg</div>
87
+ <div className='rounded-xl border p-2'>xl</div>
88
+ <div className='rounded-2xl border p-2'>2xl</div>
89
+ <div className='rounded-3xl border p-2'>3xl</div>
90
+ <div className='rounded-4xl border p-2'>4xl</div>
91
+ </div>
92
+ <div className='flex flex-wrap gap-4'>
93
+ <div className='shadow-2xs p-2'>2xs</div>
94
+ <div className='shadow-xs p-2'>xs</div>
95
+ <div className='shadow-sm p-2'>sm</div>
96
+ <div className='shadow-md p-2'>md</div>
97
+ <div className='shadow-lg p-2'>lg</div>
98
+ <div className='shadow-xl p-2'>xl</div>
99
+ <div className='shadow-2xl p-2'>2xl</div>
100
+ </div>
101
+ </main>
102
+ </AppSidebarProvider>
103
+ </Refine>
104
+ </AppThemeProvider>
105
+ </MemoryRouter>
106
+ );
107
+ }
108
+
109
+ const root = createRoot(document.getElementById('root')!);
110
+ root.render(<Fixture />);
111
+ import.meta.hot?.dispose(() => root.unmount());