@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,151 @@
1
+ import clientPlugins from '../../client/plugins.js';
2
+ // @vitest-environment node
3
+
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ rmSync,
11
+ symlinkSync,
12
+ writeFileSync,
13
+ } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { describe, expect, it } from 'vitest';
18
+
19
+ import tailwindConfig, { contentFilesIn } from '../../tailwind.config.mjs';
20
+
21
+ const appRoot = path.resolve(
22
+ fileURLToPath(new URL('.', import.meta.url)),
23
+ '../..',
24
+ );
25
+
26
+ describe('tailwind content sources', () => {
27
+ const content = tailwindConfig.content as string[];
28
+
29
+ it('scans the client files of every enabled plugin', () => {
30
+ const plugins = clientPlugins.plugins.map((plugin) =>
31
+ plugin.packageName.slice('@nocobase/'.length),
32
+ );
33
+
34
+ expect(plugins.length).toBeGreaterThan(0);
35
+
36
+ // A plugin with no client surface contributes no files, so only those that ship one are required to appear.
37
+ const withClient = plugins.filter((plugin) =>
38
+ ['client', 'dist/client'].some((directory) =>
39
+ existsSync(
40
+ path.join(appRoot, 'node_modules/@nocobase', plugin, directory),
41
+ ),
42
+ ),
43
+ );
44
+ expect(withClient.length).toBeGreaterThan(0);
45
+
46
+ for (const plugin of withClient) {
47
+ expect(
48
+ content.some((file) => file.includes(plugin)),
49
+ `${plugin} contributes no scanned files; its utility classes would be missing from the stylesheet`,
50
+ ).toBe(true);
51
+ }
52
+ });
53
+
54
+ it('resolves past the symlinks pnpm installs dependencies as', () => {
55
+ // The bug this guards: a wildcard `@source` matches nothing through a pnpm symlink, so every path here must be a
56
+ // real one that exists. An empty list would mean the whole mechanism silently stopped working.
57
+ expect(content.length).toBeGreaterThan(0);
58
+ for (const file of content.slice(0, 20)) {
59
+ expect(path.isAbsolute(file)).toBe(true);
60
+ expect(existsSync(file)).toBe(true);
61
+ }
62
+
63
+ // Every file must be its canonical path rather than a path that only reaches the file through a package symlink.
64
+ // pnpm's canonical store path legitimately contains a nested `node_modules/@nocobase` segment.
65
+ for (const file of content) {
66
+ expect(file).toBe(realpathSync(file));
67
+ }
68
+ });
69
+
70
+ it('scans build output the same way, which is the layout an installed plugin has', () => {
71
+ // The original failure was invisible in this repository, where plugins expose TypeScript sources, and only showed
72
+ // up in a generated application, where they ship `dist/client`. `dist` is a build artefact that a clean checkout
73
+ // does not have, so rather than requiring one, this drives the configuration's own resolution over a fixture
74
+ // laid out the way pnpm lays out an installed package: a symlink into a store directory.
75
+ const store = mkdtempSync(path.join(tmpdir(), 'nb3-tailwind-'));
76
+ try {
77
+ const installed = path.join(
78
+ store,
79
+ 'store/app-plugin-fixture/dist/client',
80
+ );
81
+ mkdirSync(installed, { recursive: true });
82
+ writeFileSync(
83
+ path.join(installed, 'page.js'),
84
+ 'export const cls = "mt-[77px]";',
85
+ );
86
+
87
+ const scope = path.join(store, 'node_modules/@nocobase');
88
+ mkdirSync(scope, { recursive: true });
89
+ symlinkSync(
90
+ path.join(store, 'store/app-plugin-fixture'),
91
+ path.join(scope, 'app-plugin-fixture'),
92
+ 'junction',
93
+ );
94
+
95
+ const files = contentFilesIn(store);
96
+
97
+ expect(files).toHaveLength(1);
98
+ expect(files[0]).toBe(path.join(realpathSync(installed), 'page.js'));
99
+ expect(files[0]).toBe(realpathSync(files[0]));
100
+ } finally {
101
+ rmSync(store, { recursive: true, force: true });
102
+ }
103
+ });
104
+
105
+ it('scans canonical Registry source exposed by a plugin', () => {
106
+ const store = mkdtempSync(path.join(tmpdir(), 'nb3-tailwind-registry-'));
107
+ try {
108
+ const registry = path.join(
109
+ store,
110
+ 'store/app-plugin-fixture/registry/example',
111
+ );
112
+ mkdirSync(registry, { recursive: true });
113
+ writeFileSync(
114
+ path.join(registry, 'demo.tsx'),
115
+ 'export const cls = "xl:grid-cols-5";',
116
+ );
117
+
118
+ const scope = path.join(store, 'node_modules/@nocobase');
119
+ mkdirSync(scope, { recursive: true });
120
+ symlinkSync(
121
+ path.join(store, 'store/app-plugin-fixture'),
122
+ path.join(scope, 'app-plugin-fixture'),
123
+ 'junction',
124
+ );
125
+
126
+ const files = contentFilesIn(store);
127
+
128
+ expect(files).toContain(path.join(realpathSync(registry), 'demo.tsx'));
129
+ } finally {
130
+ rmSync(store, { recursive: true, force: true });
131
+ }
132
+ });
133
+
134
+ it('is wired into the stylesheet, without the wildcard sources it replaced', () => {
135
+ const styles = readFileSync(
136
+ path.join(appRoot, 'client/styles.css'),
137
+ 'utf8',
138
+ );
139
+
140
+ expect(styles).toContain('@config "../tailwind.config.mjs"');
141
+ expect(styles).not.toContain('app-plugin-*');
142
+ });
143
+
144
+ it('is published, so a generated application has the file @config points at', () => {
145
+ const packageJson = JSON.parse(
146
+ readFileSync(path.join(appRoot, 'package.json'), 'utf8'),
147
+ ) as { files?: string[] };
148
+
149
+ expect(packageJson.files).toContain('tailwind.config.mjs');
150
+ });
151
+ });
@@ -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
+ });