@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,88 @@
1
+ // @vitest-environment node
2
+
3
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ const appRoot = path.resolve(
9
+ fileURLToPath(new URL('.', import.meta.url)),
10
+ '../..',
11
+ );
12
+
13
+ interface AppPackageJson {
14
+ readonly name: string;
15
+ readonly nocobase?: { readonly templatePackage?: string };
16
+ }
17
+
18
+ const appPackage = JSON.parse(
19
+ readFileSync(path.join(appRoot, 'package.json'), 'utf8'),
20
+ ) as AppPackageJson;
21
+ const applicationName = appPackage.name;
22
+ const originalTemplateName =
23
+ appPackage.nocobase?.templatePackage ?? applicationName;
24
+ const isGeneratedApplication = originalTemplateName !== applicationName;
25
+
26
+ /**
27
+ * Sources present in this template that `create-app` rewrites, from `PACKAGE_NAME_SOURCES` in
28
+ * `packages/tools/create-app/src/lib/scaffold.ts`.
29
+ */
30
+ const REWRITTEN_SOURCES = ['client/runtime.ts', 'client/service-provider.ts'];
31
+
32
+ /** Source trees shipped to a generated application, per the `files` field. */
33
+ const SHIPPED_SOURCE_DIRECTORIES = ['client', 'server', 'database', 'cli'];
34
+
35
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mjs', '.js']);
36
+
37
+ function sourceFilesIn(directory: string): string[] {
38
+ const absolute = path.join(appRoot, directory);
39
+
40
+ return readdirSync(absolute, { withFileTypes: true }).flatMap((entry) => {
41
+ if (entry.name === 'node_modules') return [];
42
+
43
+ const relative = path.join(directory, entry.name);
44
+
45
+ if (entry.isDirectory()) return sourceFilesIn(relative);
46
+ if (!SOURCE_EXTENSIONS.has(path.extname(entry.name))) return [];
47
+
48
+ return [relative];
49
+ });
50
+ }
51
+
52
+ describe('template package name', () => {
53
+ /**
54
+ * `create-app` rewrites this name to the application's in a fixed list of files. A source that embeds it but is not
55
+ * on that list ships the template's name into every generated application, where the client would then declare an
56
+ * i18n namespace the server does not share.
57
+ *
58
+ * When this fails, either rewrite the new occurrence to derive the name at runtime, or add the file to
59
+ * `PACKAGE_NAME_SOURCES` in `packages/tools/create-app/src/lib/scaffold.ts` and to `REWRITTEN_SOURCES` above.
60
+ */
61
+ it('keeps the original template name only where create-app rewrites it', () => {
62
+ const offenders = SHIPPED_SOURCE_DIRECTORIES.filter((directory) =>
63
+ statSync(path.join(appRoot, directory), { throwIfNoEntry: false }),
64
+ )
65
+ .flatMap((directory) => sourceFilesIn(directory))
66
+ .filter(
67
+ (relative) =>
68
+ isGeneratedApplication || !REWRITTEN_SOURCES.includes(relative),
69
+ )
70
+ .filter((relative) =>
71
+ readFileSync(path.join(appRoot, relative), 'utf8').includes(
72
+ originalTemplateName,
73
+ ),
74
+ );
75
+
76
+ expect(offenders).toEqual([]);
77
+ });
78
+
79
+ /** The rewrite list is only correct while every file on it exists and carries the current application name. */
80
+ it('uses the application name in every source create-app rewrites', () => {
81
+ for (const relative of REWRITTEN_SOURCES) {
82
+ expect(
83
+ readFileSync(path.join(appRoot, relative), 'utf8'),
84
+ `${relative} does not contain ${applicationName}`,
85
+ ).toContain(applicationName);
86
+ }
87
+ });
88
+ });
@@ -0,0 +1,86 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { themePresets } from '../../client/theme/theme-presets';
3
+ import {
4
+ initializeTheme,
5
+ themeStorageKeys,
6
+ } from '../../client/theme/theme-preferences';
7
+
8
+ describe('application appearance storage', () => {
9
+ it('lists Compact first as the default preset', () => {
10
+ expect(themePresets[0].id).toBe('compact');
11
+ });
12
+ it('restores Compact when the saved Ant Design preset is removed', () => {
13
+ const keys = themeStorageKeys('/crm/');
14
+ localStorage.setItem(keys.preset, 'ant-design');
15
+ localStorage.setItem(keys.mode, 'light');
16
+ initializeTheme(
17
+ '/crm/',
18
+ themePresets.map(({ id }) => id),
19
+ );
20
+ expect(document.documentElement.dataset.theme).toBe('compact');
21
+ expect(document.documentElement).toHaveClass('light');
22
+ });
23
+ afterEach(() => vi.unstubAllGlobals());
24
+ beforeEach(() => {
25
+ localStorage.clear();
26
+ document.documentElement.removeAttribute('class');
27
+ vi.stubGlobal('matchMedia', () => ({ matches: true }));
28
+ });
29
+
30
+ it('uses the first registered preset as the fallback', () => {
31
+ initializeTheme('/crm/', ['custom', 'default']);
32
+ expect(document.documentElement.dataset.theme).toBe('custom');
33
+ });
34
+
35
+ it('separates apps, preserves nested paths and distinguishes the root', () => {
36
+ expect(themeStorageKeys('/crm/').mode).toBe(
37
+ 'nocobase:crm:theme:color-scheme',
38
+ );
39
+ expect(themeStorageKeys('/crm').preset).toBe('nocobase:crm:theme:preset');
40
+ expect(themeStorageKeys('/team/crm/').preset).toBe(
41
+ 'nocobase:team%2Fcrm:theme:preset',
42
+ );
43
+ expect(themeStorageKeys('/').preset).toBe('nocobase:%2F:theme:preset');
44
+ expect(themeStorageKeys('/root/')).not.toEqual(themeStorageKeys('/'));
45
+ });
46
+
47
+ it('restores both preferences before React without reading another app', () => {
48
+ localStorage.setItem('nocobase:crm:theme:color-scheme', 'light');
49
+ localStorage.setItem('nocobase:crm:theme:preset', 'default');
50
+ initializeTheme(
51
+ '/crm/',
52
+ themePresets.map(({ id }) => id),
53
+ );
54
+ expect(document.documentElement).toHaveAttribute('data-theme', 'default');
55
+ expect(document.documentElement).toHaveClass('light');
56
+ initializeTheme(
57
+ '/erp/',
58
+ themePresets.map(({ id }) => id),
59
+ );
60
+ expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
61
+ expect(document.documentElement).toHaveClass('dark');
62
+ });
63
+
64
+ it('falls back on invalid values and unavailable storage', () => {
65
+ localStorage.setItem('nocobase:crm:theme:color-scheme', 'invalid');
66
+ localStorage.setItem('nocobase:crm:theme:preset', 'deleted');
67
+ initializeTheme(
68
+ '/crm/',
69
+ themePresets.map(({ id }) => id),
70
+ );
71
+ expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
72
+ expect(document.documentElement).toHaveClass('dark');
73
+ const spy = vi
74
+ .spyOn(Storage.prototype, 'getItem')
75
+ .mockImplementation(() => {
76
+ throw new Error('blocked');
77
+ });
78
+ expect(() =>
79
+ initializeTheme(
80
+ '/crm/',
81
+ themePresets.map(({ id }) => id),
82
+ ),
83
+ ).not.toThrow();
84
+ spy.mockRestore();
85
+ });
86
+ });
@@ -0,0 +1,248 @@
1
+ // @vitest-environment node
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import tailwind from '@tailwindcss/postcss';
6
+ import postcss, { type Root } from 'postcss';
7
+ import { beforeAll, describe, expect, it } from 'vitest';
8
+
9
+ import { themePresets } from '../../client/theme/theme-presets';
10
+
11
+ const appRoot = fileURLToPath(new URL('../..', import.meta.url));
12
+ const sizes = [
13
+ 'xs',
14
+ 'sm',
15
+ 'base',
16
+ 'lg',
17
+ 'xl',
18
+ '2xl',
19
+ '3xl',
20
+ '4xl',
21
+ '5xl',
22
+ '6xl',
23
+ '7xl',
24
+ '8xl',
25
+ '9xl',
26
+ ];
27
+ const shadows = ['2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl'];
28
+ const colors = [
29
+ 'background',
30
+ 'foreground',
31
+ 'card',
32
+ 'card-foreground',
33
+ 'popover',
34
+ 'popover-foreground',
35
+ 'primary',
36
+ 'primary-foreground',
37
+ 'secondary',
38
+ 'secondary-foreground',
39
+ 'muted',
40
+ 'muted-foreground',
41
+ 'accent',
42
+ 'accent-foreground',
43
+ 'destructive',
44
+ 'border',
45
+ 'input',
46
+ 'ring',
47
+ ...Array.from({ length: 5 }, (_, i) => `chart-${i + 1}`),
48
+ 'sidebar',
49
+ 'sidebar-foreground',
50
+ 'sidebar-primary',
51
+ 'sidebar-primary-foreground',
52
+ 'sidebar-accent',
53
+ 'sidebar-accent-foreground',
54
+ 'sidebar-border',
55
+ 'sidebar-ring',
56
+ ];
57
+ const sharedTokens = [
58
+ 'radius',
59
+ 'spacing',
60
+ 'font-sans',
61
+ 'font-serif',
62
+ 'font-mono',
63
+ 'font-heading',
64
+ ...sizes.flatMap((size) => [`text-${size}`, `text-${size}--line-height`]),
65
+ ...shadows.map((size) => `shadow-${size}`),
66
+ ];
67
+ const radii = {
68
+ sm: 0.6,
69
+ md: 0.8,
70
+ lg: 1,
71
+ xl: 1.4,
72
+ '2xl': 1.8,
73
+ '3xl': 2.2,
74
+ '4xl': 2.6,
75
+ };
76
+
77
+ function declarations(root: Root, selector: string): Record<string, string> {
78
+ const values: Record<string, string> = {};
79
+ root.walkRules((rule) => {
80
+ if (rule.selectors.includes(selector)) {
81
+ rule.walkDecls((decl) => {
82
+ values[decl.prop] = decl.value;
83
+ });
84
+ }
85
+ });
86
+ return values;
87
+ }
88
+
89
+ describe('theme token contract', () => {
90
+ it('keeps compact identical to default except for dimensions', () => {
91
+ const readPreset = (id: string) =>
92
+ postcss.parse(
93
+ readFileSync(
94
+ new URL(`../../client/theme/themes/${id}.css`, import.meta.url),
95
+ 'utf8',
96
+ ),
97
+ );
98
+ const defaultCss = readPreset('default');
99
+ const compactCss = readPreset('compact');
100
+ const withoutDimensions = (values: Record<string, string>) =>
101
+ Object.fromEntries(
102
+ Object.entries(values).filter(
103
+ ([key]) =>
104
+ key !== '--spacing' &&
105
+ key !== '--radius' &&
106
+ !key.startsWith('--text-'),
107
+ ),
108
+ );
109
+ for (const mode of ['', '.dark']) {
110
+ expect(
111
+ withoutDimensions(
112
+ declarations(compactCss, `:root${mode}[data-theme='compact']`),
113
+ ),
114
+ ).toEqual(
115
+ withoutDimensions(
116
+ declarations(defaultCss, `:root${mode}[data-theme='default']`),
117
+ ),
118
+ );
119
+ }
120
+ const compact = declarations(compactCss, ":root[data-theme='compact']");
121
+ const standard = declarations(defaultCss, ":root[data-theme='default']");
122
+ expect(parseFloat(compact['--spacing'])).toBeLessThan(
123
+ parseFloat(standard['--spacing']),
124
+ );
125
+ });
126
+
127
+ for (const { id } of themePresets) {
128
+ it(`${id} defines complete tokens for the page and isolated previews`, () => {
129
+ const root = postcss.parse(
130
+ readFileSync(
131
+ new URL(`../../client/theme/themes/${id}.css`, import.meta.url),
132
+ 'utf8',
133
+ ),
134
+ );
135
+ const light = declarations(root, `:root[data-theme='${id}']`);
136
+ const dark = declarations(root, `:root.dark[data-theme='${id}']`);
137
+ for (const token of [...colors, ...sharedTokens]) {
138
+ expect(light[`--${token}`], `${id} light: ${token}`).toBeTruthy();
139
+ }
140
+ for (const token of colors) {
141
+ expect(dark[`--${token}`], `${id} dark: ${token}`).toBeTruthy();
142
+ }
143
+ expect(declarations(root, `.theme-preview[data-theme='${id}']`)).toEqual(
144
+ light,
145
+ );
146
+ expect(
147
+ declarations(root, `:root.dark .theme-preview[data-theme='${id}']`),
148
+ ).toEqual(dark);
149
+ });
150
+ }
151
+ });
152
+
153
+ describe('compiled theme utilities', () => {
154
+ let compiled: Root;
155
+ beforeAll(async () => {
156
+ const candidates = [
157
+ ...colors.map((color) => `bg-${color}`),
158
+ ...sizes.map((size) => `text-${size}`),
159
+ ...shadows.map((size) => `shadow-${size}`),
160
+ ...Object.keys(radii).map((size) => `rounded-${size}`),
161
+ 'font-sans',
162
+ 'font-serif',
163
+ 'font-mono',
164
+ 'font-heading',
165
+ 'p-4',
166
+ 'gap-2',
167
+ 'h-8',
168
+ 'fill-chart-1',
169
+ 'stroke-chart-5',
170
+ ];
171
+ const source = readFileSync(
172
+ new URL('../../client/styles.css', import.meta.url),
173
+ 'utf8',
174
+ );
175
+ const result = await postcss([
176
+ tailwind({ base: appRoot, optimize: false }),
177
+ ]).process(source + `\n@source inline("${candidates.join(' ')}");`, {
178
+ from: fileURLToPath(new URL('../../client/styles.css', import.meta.url)),
179
+ });
180
+ compiled = result.root;
181
+ }, 30_000);
182
+
183
+ it('maps every semantic color to a runtime variable', () => {
184
+ for (const color of colors) {
185
+ expect(declarations(compiled, `.bg-${color}`)['background-color']).toBe(
186
+ `var(--${color})`,
187
+ );
188
+ }
189
+ expect(declarations(compiled, '.fill-chart-1').fill).toBe('var(--chart-1)');
190
+ expect(declarations(compiled, '.stroke-chart-5').stroke).toBe(
191
+ 'var(--chart-5)',
192
+ );
193
+ });
194
+
195
+ it('keeps fonts, sizes, line heights, spacing and shadows overridable', () => {
196
+ for (const font of ['sans', 'serif', 'mono', 'heading']) {
197
+ expect(declarations(compiled, `.font-${font}`)['font-family']).toBe(
198
+ `var(--font-${font})`,
199
+ );
200
+ }
201
+ for (const size of sizes) {
202
+ const rule = declarations(compiled, `.text-${size}`);
203
+ expect(rule['font-size']).toBe(`var(--text-${size})`);
204
+ expect(rule['line-height']).toContain(`var(--text-${size}--line-height)`);
205
+ }
206
+ for (const [selector, property, multiplier] of [
207
+ ['.p-4', 'padding', '4'],
208
+ ['.gap-2', 'gap', '2'],
209
+ ['.h-8', 'height', '8'],
210
+ ]) {
211
+ expect(declarations(compiled, selector)[property]).toBe(
212
+ `calc(var(--spacing) * ${multiplier})`,
213
+ );
214
+ }
215
+ for (const size of shadows) {
216
+ expect(
217
+ Object.values(declarations(compiled, `.shadow-${size}`)).join(' '),
218
+ ).toContain(`var(--shadow-${size})`);
219
+ }
220
+ });
221
+
222
+ it('derives all seven corner sizes from the current preset', () => {
223
+ for (const [size, multiplier] of Object.entries(radii)) {
224
+ const expected =
225
+ multiplier === 1
226
+ ? 'var(--radius)'
227
+ : `calc(var(--radius) * ${multiplier})`;
228
+ expect(declarations(compiled, `.rounded-${size}`)['border-radius']).toBe(
229
+ expected,
230
+ );
231
+ }
232
+ });
233
+
234
+ it('connects default text, headings and code to font tokens', () => {
235
+ expect(declarations(compiled, 'body')['font-family']).toBe(
236
+ 'var(--font-sans)',
237
+ );
238
+ expect(declarations(compiled, 'body')['font-size']).toBe(
239
+ 'var(--text-base)',
240
+ );
241
+ expect(declarations(compiled, 'h1')['font-family']).toBe(
242
+ 'var(--font-heading)',
243
+ );
244
+ expect(declarations(compiled, 'code')['font-family']).toBe(
245
+ 'var(--font-mono)',
246
+ );
247
+ });
248
+ });
@@ -0,0 +1,108 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import { createToastManager, Toaster } from '#components/ui/toast';
5
+ import { createToaster } from '#lib/toaster';
6
+
7
+ describe('application toaster', () => {
8
+ it('forwards what a toast reports to the Base UI toast manager', () => {
9
+ const manager = createToastManager();
10
+ const add = vi.spyOn(manager, 'add');
11
+ const close = vi.spyOn(manager, 'close');
12
+ const onClose = vi.fn();
13
+ const toaster = createToaster(manager);
14
+
15
+ const id = toaster.show({
16
+ id: 'saved',
17
+ type: 'success',
18
+ title: 'Saved',
19
+ description: 'All changes are stored.',
20
+ duration: 4000,
21
+ onClose,
22
+ });
23
+ toaster.close(id);
24
+
25
+ expect(id).toBe('saved');
26
+ expect(add).toHaveBeenCalledWith({
27
+ id: 'saved',
28
+ type: 'success',
29
+ title: 'Saved',
30
+ description: 'All changes are stored.',
31
+ timeout: 4000,
32
+ priority: 'low',
33
+ onClose,
34
+ });
35
+ expect(close).toHaveBeenCalledWith('saved');
36
+ expect(toaster.show({ title: 'Saved again' })).toEqual(expect.any(String));
37
+ });
38
+
39
+ it('renders an action as a button that leaves the toast open', async () => {
40
+ const manager = createToastManager();
41
+ const onClick = vi.fn();
42
+ render(<Toaster toastManager={manager} />);
43
+
44
+ act(() => {
45
+ createToaster(manager).show({
46
+ type: 'info',
47
+ title: 'Archived',
48
+ action: { label: 'Undo', onClick },
49
+ });
50
+ });
51
+ fireEvent.click(await screen.findByRole('button', { name: 'Undo' }));
52
+
53
+ expect(onClick).toHaveBeenCalledOnce();
54
+ expect(screen.getByText('Archived')).toBeInTheDocument();
55
+ });
56
+
57
+ it('closes one toast, never all of them', () => {
58
+ const manager = createToastManager();
59
+ const close = vi.spyOn(manager, 'close');
60
+
61
+ // A JavaScript caller can pass no id, which Base UI would take as "close every toast".
62
+ createToaster(manager).close(undefined as unknown as string);
63
+
64
+ expect(close).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('announces a plain-text error at once and keeps every other toast at the default priority', () => {
68
+ const manager = createToastManager();
69
+ const add = vi.spyOn(manager, 'add');
70
+ const toaster = createToaster(manager);
71
+
72
+ toaster.show({
73
+ type: 'error',
74
+ title: 'Unable to save',
75
+ description: 'The server is unavailable.',
76
+ });
77
+ toaster.show({
78
+ type: 'error',
79
+ title: 'Unable to deploy',
80
+ description: <button type='button'>Show technical details</button>,
81
+ });
82
+ toaster.show({
83
+ type: 'error',
84
+ title: 'Unable to save',
85
+ action: { label: 'Retry', onClick: vi.fn() },
86
+ });
87
+ toaster.show({ type: 'success', title: 'Saved' });
88
+
89
+ expect(add.mock.calls.map(([options]) => options.priority)).toEqual([
90
+ 'high',
91
+ 'low',
92
+ 'low',
93
+ 'low',
94
+ ]);
95
+ });
96
+
97
+ it('shows what it forwards in the Toaster mounted with its defaults', async () => {
98
+ // Both defaults are the module's toast manager: that shared manager is what connects the registered toaster to the
99
+ // Toaster that client/react-providers.ts mounts.
100
+ render(<Toaster />);
101
+
102
+ act(() => {
103
+ createToaster().show({ type: 'success', title: 'Connected' });
104
+ });
105
+
106
+ expect(await screen.findByText('Connected')).toBeInTheDocument();
107
+ });
108
+ });
@@ -0,0 +1,98 @@
1
+ import {
2
+ act,
3
+ fireEvent,
4
+ render,
5
+ screen,
6
+ waitFor,
7
+ } from '@testing-library/react';
8
+ import userEvent from '@testing-library/user-event';
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import {
11
+ TestI18nProvider,
12
+ createTestI18nRuntime,
13
+ } from '@nocobase/i18n/testing';
14
+ import type { ReactNode } from 'react';
15
+ import { UserMenu } from '../../client/layouts/components/user-menu.tsx';
16
+ import enUS from '../../client/locales/en-US.js';
17
+
18
+ const runtime = await createTestI18nRuntime({
19
+ application: { namespace: '@nocobase/app-template-default', resources: enUS },
20
+ });
21
+
22
+ function I18n({ children }: { readonly children: ReactNode }) {
23
+ return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
24
+ }
25
+
26
+ const { signOut, refresh, showToast } = vi.hoisted(() => ({
27
+ signOut: vi.fn(),
28
+ refresh: vi.fn(),
29
+ showToast: vi.fn(),
30
+ }));
31
+ vi.mock('@nocobase/app-client', () => {
32
+ const toaster = { show: showToast, close: vi.fn() };
33
+ return { useToaster: () => toaster };
34
+ });
35
+ vi.mock('@nocobase/app-plugin-authentication/client', () => ({
36
+ useAuthentication: () => ({
37
+ client: { signOut },
38
+ session: {
39
+ user: { id: 'operator', name: 'Operator', email: 'operator@example.com' },
40
+ },
41
+ isPending: false,
42
+ refresh,
43
+ }),
44
+ }));
45
+ vi.mock('../../client/layouts/components/language-switcher.js', () => ({
46
+ LanguageSwitcher: () => null,
47
+ }));
48
+
49
+ /**
50
+ * Opens the account menu from the keyboard. A pointer opens it on hover and again on the press, one frame later, which
51
+ * can reopen the menu after a test has already chosen an item and closed it.
52
+ */
53
+ async function openAccountMenu(): Promise<void> {
54
+ act(() => screen.getByRole('button', { name: 'Open account menu' }).focus());
55
+ await userEvent.keyboard('{Enter}');
56
+ }
57
+
58
+ describe('account menu sign out', () => {
59
+ beforeEach(() => {
60
+ vi.resetAllMocks();
61
+ refresh.mockResolvedValue(undefined);
62
+ });
63
+ async function signOutFromMenu() {
64
+ render(<UserMenu />, { wrapper: I18n });
65
+ await openAccountMenu();
66
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Sign out' }));
67
+ }
68
+ it('refreshes the server session after successful sign-out', async () => {
69
+ signOut.mockResolvedValue({ data: { success: true }, error: null });
70
+ await signOutFromMenu();
71
+ await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
72
+ expect(showToast).not.toHaveBeenCalled();
73
+ });
74
+ it('reports a rejected origin without pretending the session ended', async () => {
75
+ signOut.mockResolvedValue({
76
+ data: null,
77
+ error: { status: 403, code: 'INVALID_ORIGIN' },
78
+ });
79
+ await signOutFromMenu();
80
+ await waitFor(() =>
81
+ expect(showToast).toHaveBeenCalledWith({
82
+ type: 'error',
83
+ title: 'Unable to sign out. Please try again.',
84
+ }),
85
+ );
86
+ expect(refresh).not.toHaveBeenCalled();
87
+ await openAccountMenu();
88
+ expect(
89
+ await screen.findByRole('menuitem', { name: 'Sign out' }),
90
+ ).not.toHaveAttribute('aria-disabled', 'true');
91
+ });
92
+ it('reports network failures and allows retry', async () => {
93
+ signOut.mockRejectedValue(new Error('Network unavailable'));
94
+ await signOutFromMenu();
95
+ await waitFor(() => expect(showToast).toHaveBeenCalledTimes(1));
96
+ expect(refresh).not.toHaveBeenCalled();
97
+ });
98
+ });
@@ -0,0 +1,27 @@
1
+ import { beforeEach } from 'vitest';
2
+
3
+ const RUNTIME_CONFIG_ELEMENT_ID = 'nocobase-runtime-config';
4
+
5
+ /**
6
+ * The application server renders the client configuration into every page, and the client reads its mount path and
7
+ * API URL from nowhere else. A test page has no server in front of it, so this puts the same block in place, mounted
8
+ * at `/main`. A test that needs other values removes it and renders its own.
9
+ */
10
+ function renderClientConfig(): void {
11
+ // Server-side tests run under `@vitest-environment node`, where there is no page to render into.
12
+ if (typeof document === 'undefined') return;
13
+ if (document.getElementById(RUNTIME_CONFIG_ELEMENT_ID)) return;
14
+ const element = document.createElement('script');
15
+ element.id = RUNTIME_CONFIG_ELEMENT_ID;
16
+ element.type = 'application/json';
17
+ element.textContent = JSON.stringify({
18
+ version: 1,
19
+ config: { app: { basePath: '/main' }, api: { baseURL: '/main/api' } },
20
+ });
21
+ document.head.append(element);
22
+ }
23
+
24
+ // Before the test file is imported, as the server renders it before the entry module runs: some modules resolve
25
+ // URLs at the top level.
26
+ renderClientConfig();
27
+ beforeEach(renderClientConfig);
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "@nocobase/dev-config/tsconfig/client.json",
3
+ "compilerOptions": {
4
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
5
+ },
6
+ // A workflow package's client form is browser code that the application's
7
+ // Vite build resolves through the workflow plugin's virtual module, so it is
8
+ // typechecked here rather than by the server build.
9
+ "include": ["client", "workflows/*/client"],
10
+ "exclude": ["registry"]
11
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "@nocobase/dev-config/tsconfig/node-tooling.json",
3
+ "compilerOptions": {
4
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
5
+ },
6
+ "include": [
7
+ "vite.config.ts",
8
+ "cli/**/*.ts",
9
+ "playwright.config.ts",
10
+ "tests/playwright/**/*.ts"
11
+ ]
12
+ }