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

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 (250) 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 +432 -0
  6. package/CLAUDE.md +9 -0
  7. package/Dockerfile +132 -0
  8. package/Dockerfile.dockerignore +45 -0
  9. package/README.MD +481 -0
  10. package/cli/plugins.ts +14 -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 +41 -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 +172 -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 +200 -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/ai.ts +27 -0
  165. package/server/config/api.ts +8 -0
  166. package/server/config/app.ts +40 -0
  167. package/server/config/auth.ts +36 -0
  168. package/server/config/authorization.ts +18 -0
  169. package/server/config/caching.ts +21 -0
  170. package/server/config/database.ts +22 -0
  171. package/server/config/drive.ts +30 -0
  172. package/server/config/i18n.ts +17 -0
  173. package/server/config/index.ts +73 -0
  174. package/server/config/jobs.ts +27 -0
  175. package/server/config/logging.ts +23 -0
  176. package/server/config/notification.ts +11 -0
  177. package/server/config/queue.ts +28 -0
  178. package/server/config/scheduler.ts +23 -0
  179. package/server/config/secrets.ts +10 -0
  180. package/server/config/server.ts +26 -0
  181. package/server/config/session.ts +60 -0
  182. package/server/config/snowflake.ts +21 -0
  183. package/server/config/spa.ts +24 -0
  184. package/server/config/users.ts +11 -0
  185. package/server/config/workflow.ts +24 -0
  186. package/server/config.ts +24 -0
  187. package/server/embedded.ts +23 -0
  188. package/server/locales/en-US.ts +10 -0
  189. package/server/locales/index.ts +10 -0
  190. package/server/locales/zh-CN.ts +5 -0
  191. package/server/plugins.ts +41 -0
  192. package/server/providers/index.ts +5 -0
  193. package/server/routes/index.ts +6 -0
  194. package/server/runtime.ts +24 -0
  195. package/server/standalone.ts +29 -0
  196. package/server/tsconfig.json +4 -0
  197. package/tailwind.config.mjs +59 -0
  198. package/tests/components/app-layout-routes.test.tsx +132 -0
  199. package/tests/components/app-title.test.tsx +57 -0
  200. package/tests/components/auth-i18n.test.tsx +87 -0
  201. package/tests/components/back-button.test.tsx +80 -0
  202. package/tests/components/file-preview.test.tsx +101 -0
  203. package/tests/components/header-hover.test.tsx +87 -0
  204. package/tests/components/inbox.test.tsx +139 -0
  205. package/tests/components/language-switcher.test.tsx +298 -0
  206. package/tests/components/layout-containers.test.tsx +15 -0
  207. package/tests/components/layout-i18n.test.tsx +187 -0
  208. package/tests/components/loading.test.tsx +25 -0
  209. package/tests/components/navigation-menu.test.tsx +355 -0
  210. package/tests/components/page-harness.test.tsx +318 -0
  211. package/tests/components/primitive-labels.test.tsx +80 -0
  212. package/tests/components/sidebar-permissions.test.tsx +110 -0
  213. package/tests/components/sidebar-preference.test.tsx +42 -0
  214. package/tests/fixtures/theme-tokens.html +12 -0
  215. package/tests/fixtures/theme-tokens.tsx +111 -0
  216. package/tests/logic/account-permissions.test.tsx +204 -0
  217. package/tests/logic/app-locale-coverage.test.ts +136 -0
  218. package/tests/logic/app-server.test.ts +1589 -0
  219. package/tests/logic/asset-url-build.test.ts +106 -0
  220. package/tests/logic/client-auth.test.tsx +122 -0
  221. package/tests/logic/client-plugin-registry.test.ts +63 -0
  222. package/tests/logic/client-route-recovery.test.tsx +80 -0
  223. package/tests/logic/client-routes.test.ts +126 -0
  224. package/tests/logic/client-runtime.test.ts +112 -0
  225. package/tests/logic/client-settings.test.tsx +841 -0
  226. package/tests/logic/client-shell.test.tsx +466 -0
  227. package/tests/logic/client-theme.test.tsx +441 -0
  228. package/tests/logic/config.test.ts +197 -0
  229. package/tests/logic/dev-client-watch.test.ts +108 -0
  230. package/tests/logic/dev-routes-production-build.test.ts +168 -0
  231. package/tests/logic/device-code-migration.test.ts +70 -0
  232. package/tests/logic/lifecycle.test.ts +221 -0
  233. package/tests/logic/plugin-commands.test.ts +69 -0
  234. package/tests/logic/plugin-page-authz.test.ts +76 -0
  235. package/tests/logic/route-navigation.test.ts +66 -0
  236. package/tests/logic/route-overlay.test.tsx +353 -0
  237. package/tests/logic/server-imports.test.ts +88 -0
  238. package/tests/logic/tailwind-sources.test.ts +151 -0
  239. package/tests/logic/template-package-name.test.ts +88 -0
  240. package/tests/logic/theme-preferences.test.ts +86 -0
  241. package/tests/logic/theme-tokens.test.ts +248 -0
  242. package/tests/logic/toaster.test.tsx +108 -0
  243. package/tests/logic/user-menu-sign-out.test.tsx +98 -0
  244. package/tests/setup/client-config.ts +27 -0
  245. package/tsconfig.json +11 -0
  246. package/tsconfig.node.json +12 -0
  247. package/tsconfig.server.json +19 -0
  248. package/vite.config.ts +92 -0
  249. package/vitest.config.ts +37 -0
  250. package/README.md +0 -3
@@ -0,0 +1,10 @@
1
+ import type { LocaleLoaders } from '@nocobase/i18n';
2
+
3
+ // The languages the application offers. A plugin's locale file supplies translations for the languages listed here;
4
+ // it never adds one, so a language belongs in this map before anything can be served in it.
5
+ const locales: LocaleLoaders = {
6
+ 'en-US': () => import('./en-US.js'),
7
+ 'zh-CN': () => import('./zh-CN.js'),
8
+ };
9
+
10
+ export default locales;
@@ -0,0 +1,5 @@
1
+ import type { AppServerResource } from './en-US.js';
2
+
3
+ const zhCN: AppServerResource = {};
4
+
5
+ export default zhCN;
@@ -0,0 +1,41 @@
1
+ import defaultAccess from '@nocobase/app-plugin-authz-default-access/server';
2
+ import sharingRules from '@nocobase/app-plugin-authz-sharing-rules/server';
3
+ import restrictionRules from '@nocobase/app-plugin-authz-restriction-rules/server';
4
+ import apiKeys from '@nocobase/app-plugin-api-keys/server';
5
+ import authentication from '@nocobase/app-plugin-authentication/server';
6
+ import aiEmployee from '@nocobase/app-plugin-ai-employee/server/plugin';
7
+ import authorization from '@nocobase/app-plugin-authorization/server';
8
+ import databaseExplorer from '@nocobase/app-plugin-database-explorer/server';
9
+ import users from '@nocobase/app-plugin-users/server';
10
+ import i18n from '@nocobase/app-plugin-i18n/server';
11
+ import notification from '@nocobase/app-plugin-notification/server';
12
+ import notificationInApp from '@nocobase/app-plugin-notification-in-app/server';
13
+ import notificationProviders from '@nocobase/app-plugin-notification-providers/server';
14
+ import workflow from '@nocobase/app-plugin-workflow/server';
15
+ import {
16
+ defineServerPlugins,
17
+ type AppServerPlugins,
18
+ } from '@nocobase/app-server/plugins';
19
+ import scheduler from '@nocobase/app-plugin-scheduler/server';
20
+ import file from '@nocobase/app-plugin-file/server';
21
+
22
+ const serverPlugins: AppServerPlugins = defineServerPlugins([
23
+ authentication,
24
+ aiEmployee,
25
+ authorization,
26
+ defaultAccess,
27
+ sharingRules,
28
+ restrictionRules,
29
+ databaseExplorer,
30
+ users,
31
+ apiKeys,
32
+ i18n,
33
+ notification,
34
+ notificationInApp,
35
+ notificationProviders,
36
+ workflow,
37
+ file,
38
+ scheduler,
39
+ ]);
40
+
41
+ export default serverPlugins;
@@ -0,0 +1,5 @@
1
+ import type { ApplicationServiceProviderConstructor } from '@nocobase/app-server/application';
2
+
3
+ const serviceProviders: readonly ApplicationServiceProviderConstructor[] = [];
4
+
5
+ export default serviceProviders;
@@ -0,0 +1,6 @@
1
+ import type { Application } from '@nocobase/app-server/application';
2
+ import type { AppRouteContribution } from '@nocobase/app-server/router';
3
+
4
+ const routes: readonly AppRouteContribution<Application>[] = [];
5
+
6
+ export default routes;
@@ -0,0 +1,24 @@
1
+ import defaultConfigs from './config/index.js';
2
+ import {
3
+ defineAppRuntime,
4
+ type AppRuntimeDefinition,
5
+ } from '@nocobase/app-server/runtime';
6
+
7
+ import { createAppConfig } from './config.js';
8
+ import plugins from './plugins.js';
9
+ import serviceProviders from './providers/index.js';
10
+ import routes from './routes/index.js';
11
+ import locales from './locales/index.js';
12
+
13
+ const appRuntime: AppRuntimeDefinition = defineAppRuntime({
14
+ deploymentRootDir: import.meta.filename.endsWith('.ts') ? '.' : '..',
15
+ createAppConfig,
16
+ defaultConfigs,
17
+ plugins,
18
+ serviceProviders,
19
+ routes,
20
+ // The application's own server locale files, which are what decides the languages the server offers.
21
+ locales,
22
+ });
23
+
24
+ export default appRuntime;
@@ -0,0 +1,29 @@
1
+ import path from 'node:path';
2
+
3
+ import {
4
+ defineStandaloneServer,
5
+ type StandaloneServer as CoreStandaloneServer,
6
+ type StandaloneServerOptions as CoreStandaloneServerOptions,
7
+ } from '@nocobase/app-server/node';
8
+
9
+ import { createServer } from './embedded.js';
10
+ import appRuntime from './runtime.js';
11
+
12
+ const standalone = defineStandaloneServer({
13
+ rootDir: path.resolve(import.meta.dirname, '..'),
14
+ appRuntime,
15
+ createServer,
16
+ });
17
+
18
+ export type StandaloneServer = CoreStandaloneServer;
19
+
20
+ export type StandaloneServerOptions = CoreStandaloneServerOptions;
21
+
22
+ export const createStandaloneServer: (
23
+ options?: StandaloneServerOptions,
24
+ ) => Promise<StandaloneServer> = standalone.create;
25
+
26
+ export const startServer: (options?: StandaloneServerOptions) => void =
27
+ standalone.start;
28
+
29
+ if (import.meta.main) startServer();
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../tsconfig.server.json",
3
+ "include": ["**/*.ts"]
4
+ }
@@ -0,0 +1,59 @@
1
+ // Tells Tailwind which plugin files to scan for utility classes.
2
+ //
3
+ // `@source "../node_modules/@nocobase/app-plugin-*/client"` cannot do this job. pnpm links every dependency as a
4
+ // symlink into its store, and Tailwind's scanner does not expand a wildcard through one — the glob silently matches
5
+ // nothing, in this repository and in a generated application alike. Reading the directory first and resolving each
6
+ // match to its real path gets past that, because `readdirSync` sees the symlinks and `realpathSync` resolves them.
7
+ //
8
+ // Client runtime files and canonical Registry source are both searched. A workspace plugin exposes TypeScript sources,
9
+ // while an installed one may expose build output plus application-owned Registry recipes. Missing directories are skipped.
10
+ import { existsSync, globSync, readdirSync, realpathSync } from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ const SOURCE_DIRECTORIES = [
14
+ 'client',
15
+ 'dist/client',
16
+ 'src',
17
+ 'dist/src',
18
+ 'registry',
19
+ ];
20
+ const SCANNED_FILES = '**/*.{js,jsx,ts,tsx}';
21
+
22
+ /** Every scannable directory a workspace or installed `@nocobase` package exposes. */
23
+ function scannedDirectories(appRoot) {
24
+ const scope = path.resolve(appRoot, 'node_modules/@nocobase');
25
+ if (!existsSync(scope)) {
26
+ return [];
27
+ }
28
+
29
+ const directories = [];
30
+ for (const packageName of readdirSync(scope)) {
31
+ // app-client contributes the shared components an application renders; the plugins contribute their own pages.
32
+ if (
33
+ !packageName.startsWith('app-plugin-') &&
34
+ packageName !== 'app-client'
35
+ ) {
36
+ continue;
37
+ }
38
+ for (const directory of SOURCE_DIRECTORIES) {
39
+ const candidate = path.join(scope, packageName, directory);
40
+ if (existsSync(candidate)) {
41
+ directories.push(realpathSync(candidate));
42
+ }
43
+ }
44
+ }
45
+ return directories;
46
+ }
47
+
48
+ /** Exported so a test can drive the resolution over a fixture laid out the way pnpm lays out an installed package. */
49
+ export function contentFilesIn(appRoot) {
50
+ const files = [];
51
+ for (const directory of scannedDirectories(appRoot)) {
52
+ for (const file of globSync(SCANNED_FILES, { cwd: directory })) {
53
+ files.push(path.join(directory, file));
54
+ }
55
+ }
56
+ return files;
57
+ }
58
+
59
+ export default { content: contentFilesIn(import.meta.dirname) };
@@ -0,0 +1,132 @@
1
+ import { usePageBreadcrumb } from '@nocobase/app-client';
2
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
3
+ import {
4
+ TestI18nProvider,
5
+ createTestI18nRuntime,
6
+ } from '@nocobase/i18n/testing';
7
+ import { render, screen, within } from '@testing-library/react';
8
+ import type { ReactElement, ReactNode } from 'react';
9
+ import { MemoryRouter, Route, Routes } from 'react-router';
10
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
11
+
12
+ import { AppLayout } from '../../client/layouts/app-layout.js';
13
+ import enUS from '../../client/locales/en-US.js';
14
+
15
+ // Breadcrumb titles are route data, not keys any namespace owns: the trail translates each through its package's
16
+ // namespace with the title itself as `defaultValue`, so the runtime is not strict.
17
+ const runtime = await createTestI18nRuntime({
18
+ application: { namespace: '@nocobase/app-template-default', resources: enUS },
19
+ strict: false,
20
+ });
21
+
22
+ function I18n({ children }: { readonly children: ReactNode }) {
23
+ return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
24
+ }
25
+
26
+ vi.mock('@nocobase/app-plugin-i18n/client', async (importOriginal) => ({
27
+ ...(await importOriginal<
28
+ typeof import('@nocobase/app-plugin-i18n/client')
29
+ >()),
30
+ useSyncServerLocale: () => {},
31
+ }));
32
+ vi.mock('@nocobase/app-client', async (original) => ({
33
+ ...(await original<typeof import('@nocobase/app-client')>()),
34
+ useClientApplication: () => ({ runtime: { settingsRouteTree: [] } }),
35
+ }));
36
+ vi.mock('../../client/routing/route-navigation.js', async (original) => ({
37
+ ...(await original<
38
+ typeof import('../../client/routing/route-navigation.js')
39
+ >()),
40
+ useRouteNavigation: () => ({ items: [], denied: new Set(), loading: false }),
41
+ }));
42
+ vi.mock('../../client/layouts/components/app-sidebar.js', async (original) => ({
43
+ ...(await original<
44
+ typeof import('../../client/layouts/components/app-sidebar.js')
45
+ >()),
46
+ AppSidebar: () => null,
47
+ }));
48
+ beforeEach(() =>
49
+ vi.stubGlobal('matchMedia', () => ({
50
+ matches: false,
51
+ addEventListener: vi.fn(),
52
+ removeEventListener: vi.fn(),
53
+ })),
54
+ );
55
+ afterEach(() => vi.unstubAllGlobals());
56
+ vi.mock('../../client/layouts/components/header-actions.js', () => ({
57
+ HeaderActions: () => null,
58
+ }));
59
+
60
+ const detail: AppClientRegisteredRoute = {
61
+ id: 'detail',
62
+ name: 'detail',
63
+ packageName: 'test',
64
+ source: 'application',
65
+ auth: 'required',
66
+ path: '/orders/:id',
67
+ breadcrumb: { title: 'Detail' },
68
+ componentLoader: async () => ({ default: () => null }),
69
+ };
70
+ const routes = [
71
+ {
72
+ ...detail,
73
+ id: 'orders',
74
+ name: 'orders',
75
+ path: '/orders',
76
+ breadcrumb: { title: 'Orders' },
77
+ children: [detail],
78
+ },
79
+ ];
80
+
81
+ function OrderPage(): ReactElement {
82
+ usePageBreadcrumb([
83
+ { label: 'Orders', to: '/orders?status=open' },
84
+ { label: 'Order #42' },
85
+ ]);
86
+ return <h1>Order #42</h1>;
87
+ }
88
+
89
+ function renderLayout(page: ReactElement): HTMLElement {
90
+ render(
91
+ <MemoryRouter initialEntries={['/orders/42']}>
92
+ <Routes>
93
+ <Route element={<AppLayout routes={routes} />}>
94
+ <Route path='/orders/:id' element={page} />
95
+ </Route>
96
+ </Routes>
97
+ </MemoryRouter>,
98
+ { wrapper: I18n },
99
+ );
100
+ return screen.getByRole('banner');
101
+ }
102
+
103
+ it('shows the route trail of a nested page in its header', () => {
104
+ const header = renderLayout(<h1>Detail page</h1>);
105
+ const trail = within(header).getByRole('navigation', { name: 'Breadcrumb' });
106
+ expect(within(trail).getByRole('link', { name: 'Orders' })).toHaveAttribute(
107
+ 'href',
108
+ '/orders',
109
+ );
110
+ expect(within(trail).getByText('Detail')).toHaveAttribute(
111
+ 'aria-current',
112
+ 'page',
113
+ );
114
+ // The header carries the only trail: the page renders none of its own.
115
+ expect(
116
+ screen.getAllByRole('navigation', { name: 'Breadcrumb' }),
117
+ ).toHaveLength(1);
118
+ });
119
+
120
+ it('shows the trail a page declares, with its record names, in place of the route trail', () => {
121
+ const header = renderLayout(<OrderPage />);
122
+ const trail = within(header).getByRole('navigation', { name: 'Breadcrumb' });
123
+ expect(within(trail).getByRole('link', { name: 'Orders' })).toHaveAttribute(
124
+ 'href',
125
+ '/orders?status=open',
126
+ );
127
+ expect(within(trail).getByText('Order #42')).toHaveAttribute(
128
+ 'aria-current',
129
+ 'page',
130
+ );
131
+ expect(within(trail).queryByText('Detail')).toBeNull();
132
+ });
@@ -0,0 +1,57 @@
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 { describe, expect, it } from 'vitest';
9
+
10
+ import { DefaultClientServiceProvider } from '../../client/service-provider.ts';
11
+
12
+ describe('application title', () => {
13
+ it('uses the configured title during the application lifecycle', async () => {
14
+ const previousTitle = 'Host title';
15
+ document.title = previousTitle;
16
+ const app = await createTestApp('Configured application');
17
+
18
+ await app.start();
19
+
20
+ expect(app.refineConfig.options?.title).toEqual({
21
+ text: 'Configured application',
22
+ });
23
+ expect(document.title).toBe('Configured application');
24
+
25
+ await app.shutdown();
26
+ expect(document.title).toBe(previousTitle);
27
+ });
28
+
29
+ it('falls back to NocoBase for a blank configured title', async () => {
30
+ const previousTitle = 'Host title';
31
+ document.title = previousTitle;
32
+ const app = await createTestApp(' ');
33
+
34
+ await app.start();
35
+
36
+ expect(app.refineConfig.options?.title).toEqual({ text: 'NocoBase' });
37
+ expect(document.title).toBe('NocoBase');
38
+
39
+ await app.shutdown();
40
+ expect(document.title).toBe(previousTitle);
41
+ });
42
+ });
43
+
44
+ async function createTestApp(title: string) {
45
+ const runtime = await resolveAppRuntime(
46
+ defineAppRuntime({
47
+ packageName: '@example/app',
48
+ createAppConfig: createAppClientConfig,
49
+ serviceProviders: [DefaultClientServiceProvider],
50
+ reactProviders: [],
51
+ routes: [],
52
+ plugins: defineClientPlugins([]),
53
+ }),
54
+ { rawConfig: { app: { title } } },
55
+ );
56
+ return createApp(runtime);
57
+ }
@@ -0,0 +1,87 @@
1
+ import { I18nRuntime } from '@nocobase/i18n';
2
+ import { I18nProvider } from '@nocobase/i18n/client';
3
+ import { act, fireEvent, render, screen } from '@testing-library/react';
4
+ import type { ReactElement } from 'react';
5
+ import { MemoryRouter } from 'react-router';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import locales from '../../client/locales/index.js';
8
+ import ForgotPasswordPage from '../../client/pages/auth/forgot-password.js';
9
+ import LoginPage from '../../client/pages/auth/login.js';
10
+ import RegisterPage from '../../client/pages/auth/register.js';
11
+ import ResetPasswordPage from '../../client/pages/auth/reset-password.js';
12
+ import { Loading } from '../../client/components/loading.js';
13
+
14
+ vi.mock('@nocobase/app-plugin-authentication/client', () => ({
15
+ useSignUpAvailable: () => true,
16
+ }));
17
+
18
+ vi.mock('@nocobase/app-plugin-authentication/client/actions', () => ({
19
+ usePasswordLogin: () => ({ isPending: false, submit: vi.fn() }),
20
+ usePasswordRegistration: () => ({ isPending: false, submit: vi.fn() }),
21
+ usePasswordReset: () => ({ isPending: false, submit: vi.fn() }),
22
+ usePasswordResetRequest: () => ({
23
+ isPending: false,
24
+ isSuccess: true,
25
+ submit: vi.fn(),
26
+ }),
27
+ }));
28
+ async function runtime() {
29
+ const value = new I18nRuntime({
30
+ applicationNamespace: 'app',
31
+ defaultLocale: 'en-US',
32
+ locales: ['en-US', 'zh-CN'],
33
+ });
34
+ value.registerApplicationNamespace('app', locales);
35
+ await value.init('en-US');
36
+ return value;
37
+ }
38
+ function renderPage(value: I18nRuntime, page: ReactElement) {
39
+ return render(
40
+ <I18nProvider runtime={value}>
41
+ <MemoryRouter>{page}</MemoryRouter>
42
+ </I18nProvider>,
43
+ );
44
+ }
45
+ describe('authentication translations', () => {
46
+ it('updates the login page, brand panel and visibility control on language change', async () => {
47
+ const value = await runtime();
48
+ renderPage(value, <LoginPage />);
49
+ await act(() => value.changeLanguage('zh-CN'));
50
+ expect(screen.getByRole('heading', { name: '欢迎回来' })).toBeVisible();
51
+ expect(screen.getByLabelText('用户名或邮箱')).toBeVisible();
52
+ expect(screen.getByRole('button', { name: '显示密码' })).toBeVisible();
53
+ expect(screen.getByRole('link', { name: '注册' })).toBeVisible();
54
+ expect(screen.getByText('AI 原生应用平台')).toBeVisible();
55
+ await act(() => value.changeLanguage('en-US'));
56
+ expect(screen.getByRole('heading', { name: 'Welcome back' })).toBeVisible();
57
+ });
58
+ it('retranslates an existing password mismatch', async () => {
59
+ const value = await runtime();
60
+ const { container } = renderPage(value, <RegisterPage />);
61
+ fireEvent.change(screen.getByLabelText('Password'), {
62
+ target: { value: 'one' },
63
+ });
64
+ fireEvent.change(screen.getByLabelText('Confirm password'), {
65
+ target: { value: 'two' },
66
+ });
67
+ fireEvent.submit(container.querySelector('form')!);
68
+ expect(screen.getByText("Passwords don't match.")).toBeVisible();
69
+ await act(() => value.changeLanguage('zh-CN'));
70
+ expect(screen.getByText('两次输入的密码不一致。')).toBeVisible();
71
+ });
72
+ it('translates reset states and generic loading', async () => {
73
+ const value = await runtime();
74
+ renderPage(
75
+ value,
76
+ <>
77
+ <ResetPasswordPage />
78
+ <ForgotPasswordPage />
79
+ <Loading />
80
+ </>,
81
+ );
82
+ await act(() => value.changeLanguage('zh-CN'));
83
+ expect(screen.getByText('此密码重置链接无效或已过期。')).toBeVisible();
84
+ expect(screen.getByText('如果账户存在,重置链接已发送。')).toBeVisible();
85
+ expect(screen.getByRole('status', { name: '加载中' })).toBeVisible();
86
+ });
87
+ });
@@ -0,0 +1,80 @@
1
+ import {
2
+ TestI18nProvider,
3
+ createTestI18nRuntime,
4
+ } from '@nocobase/i18n/testing';
5
+ import { render, screen } from '@testing-library/react';
6
+ import userEvent from '@testing-library/user-event';
7
+ import type { ReactElement, ReactNode } from 'react';
8
+ import { createMemoryRouter, Outlet, RouterProvider } from 'react-router';
9
+ import { describe, expect, it } from 'vitest';
10
+
11
+ import { BackButton } from '#components/back-button';
12
+
13
+ import enUS from '../../client/locales/en-US.js';
14
+
15
+ const runtime = await createTestI18nRuntime({
16
+ application: { namespace: '@nocobase/app-template-default', resources: enUS },
17
+ });
18
+
19
+ function I18n({ children }: { readonly children: ReactNode }): ReactElement {
20
+ return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
21
+ }
22
+
23
+ function renderAt(url: string, child: ReactElement) {
24
+ const router = createMemoryRouter(
25
+ [
26
+ {
27
+ path: '/projects',
28
+ element: (
29
+ <>
30
+ <h1>List</h1>
31
+ <Outlet />
32
+ </>
33
+ ),
34
+ // A child page with a two-segment path, like a second route to the same overlay: `..` is still the list.
35
+ children: [
36
+ { path: ':projectId', element: child },
37
+ { path: 'import/:batchId', element: child },
38
+ ],
39
+ },
40
+ { path: '/overview', element: <h1>Overview</h1> },
41
+ ],
42
+ { initialEntries: [url] },
43
+ );
44
+ render(<RouterProvider router={router} />, { wrapper: I18n });
45
+ return router;
46
+ }
47
+
48
+ describe('BackButton', () => {
49
+ it('leads to the parent route and keeps the query string', async () => {
50
+ const router = renderAt('/projects/12?q=alpha&page=2', <BackButton />);
51
+
52
+ const link = screen.getByRole('link', { name: enUS.navigation.back });
53
+ expect(link).toHaveAttribute('href', '/projects?q=alpha&page=2');
54
+
55
+ await userEvent.click(link);
56
+ expect(router.state.location.pathname).toBe('/projects');
57
+ expect(router.state.location.search).toBe('?q=alpha&page=2');
58
+ // Leaving the child page replaces its entry, as closing an overlay does.
59
+ expect(router.state.historyAction).toBe('REPLACE');
60
+ });
61
+
62
+ it('resolves the parent by route, not by path segment', () => {
63
+ renderAt('/projects/import/7', <BackButton />);
64
+
65
+ expect(
66
+ screen.getByRole('link', { name: enUS.navigation.back }),
67
+ ).toHaveAttribute('href', '/projects');
68
+ });
69
+
70
+ it('takes a destination and a label of its own', () => {
71
+ renderAt(
72
+ '/projects/12',
73
+ <BackButton to='/overview'>Back to the overview</BackButton>,
74
+ );
75
+
76
+ expect(
77
+ screen.getByRole('link', { name: 'Back to the overview' }),
78
+ ).toHaveAttribute('href', '/overview');
79
+ });
80
+ });
@@ -0,0 +1,101 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
3
+
4
+ import {
5
+ FilePreviewDialog,
6
+ type FileRecord,
7
+ } from '../../client/extensions/nocobase-file-component-ui/index';
8
+
9
+ const viewer = vi.hoisted(() => ({
10
+ load: vi.fn<(data: ArrayBuffer) => Promise<void>>(),
11
+ destroy: vi.fn(),
12
+ }));
13
+
14
+ vi.mock('@silurus/ooxml/docx', () => ({
15
+ DocxScrollViewer: class {
16
+ load = viewer.load;
17
+ destroy = viewer.destroy;
18
+ },
19
+ }));
20
+ vi.mock('@silurus/ooxml/xlsx', () => ({
21
+ XlsxViewer: class {
22
+ load = viewer.load;
23
+ destroy = viewer.destroy;
24
+ },
25
+ }));
26
+ vi.mock('@silurus/ooxml/pptx', () => ({
27
+ PptxScrollViewer: class {
28
+ load = viewer.load;
29
+ destroy = viewer.destroy;
30
+ },
31
+ }));
32
+
33
+ function file(ext: string): FileRecord {
34
+ return {
35
+ id: 'attachment',
36
+ filename: `attachment.${ext}`,
37
+ ext,
38
+ mimeType: 'application/octet-stream',
39
+ size: 3,
40
+ createdAt: '2026-09-20T00:00:00.000Z',
41
+ updatedAt: '2026-09-20T00:00:00.000Z',
42
+ contentUrl: `/main/expense-files/attachment.${ext}`,
43
+ };
44
+ }
45
+
46
+ beforeEach(() => {
47
+ viewer.load.mockReset().mockResolvedValue(undefined);
48
+ viewer.destroy.mockReset();
49
+ });
50
+ afterEach(() => vi.unstubAllGlobals());
51
+
52
+ it.each(['docx', 'xlsx', 'pptx'])(
53
+ 'preinstalled preview fetches private %s content for the local viewer',
54
+ async (ext) => {
55
+ const fetchMock = vi
56
+ .fn<typeof fetch>()
57
+ .mockResolvedValue(new Response(new Uint8Array([1, 2, 3])));
58
+ vi.stubGlobal('fetch', fetchMock);
59
+ const { unmount } = render(
60
+ <FilePreviewDialog files={[file(ext)]} open onOpenChange={vi.fn()} />,
61
+ );
62
+ await waitFor(() => expect(viewer.load).toHaveBeenCalledOnce());
63
+ expect(fetchMock).toHaveBeenCalledWith(
64
+ `/main/expense-files/attachment.${ext}`,
65
+ {
66
+ credentials: 'same-origin',
67
+ signal: expect.any(AbortSignal),
68
+ },
69
+ );
70
+ expect(new Uint8Array(viewer.load.mock.calls[0]![0])).toEqual(
71
+ new Uint8Array([1, 2, 3]),
72
+ );
73
+ expect(
74
+ document.querySelector(`[data-office-open-xml-format="${ext}"]`),
75
+ ).not.toBeNull();
76
+ expect(document.querySelector('iframe')).toBeNull();
77
+ unmount();
78
+ expect(viewer.destroy).toHaveBeenCalledOnce();
79
+ },
80
+ );
81
+
82
+ it('shows the denied content response without trying a third-party viewer', async () => {
83
+ vi.stubGlobal(
84
+ 'fetch',
85
+ vi
86
+ .fn<typeof fetch>()
87
+ .mockResolvedValue(new Response(null, { status: 403 })),
88
+ );
89
+ render(
90
+ <FilePreviewDialog
91
+ files={[file('docx')]}
92
+ open
93
+ onOpenChange={vi.fn()}
94
+ download={false}
95
+ />,
96
+ );
97
+ expect(await screen.findByRole('alert')).toHaveTextContent('403');
98
+ expect(viewer.load).not.toHaveBeenCalled();
99
+ expect(document.querySelector('iframe')).toBeNull();
100
+ expect(screen.queryByRole('button', { name: /Download/ })).toBeNull();
101
+ });