@nocobase/app-template-default 1.0.0-beta.81 → 1.0.0-beta.82

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 (41) hide show
  1. package/.agents/rules/shell.md +2 -0
  2. package/.agents/rules/workspace.md +9 -0
  3. package/.prettierignore +3 -0
  4. package/AGENTS.md +2 -1
  5. package/README.MD +4 -0
  6. package/client/components/attachment-list.tsx +589 -0
  7. package/client/components/back-button.tsx +15 -4
  8. package/client/components/breadcrumbs.tsx +12 -1
  9. package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +68 -71
  10. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +26 -12
  11. package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +6 -6
  12. package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +3 -3
  13. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +19 -19
  14. package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +27 -19
  15. package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +12 -8
  16. package/client/extensions/nocobase-file-component-ui/index.ts +6 -6
  17. package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +14 -11
  18. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +49 -0
  19. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +47 -0
  20. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +48 -0
  21. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +49 -0
  22. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +46 -0
  23. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +51 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +49 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +47 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +43 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +43 -0
  28. package/client/extensions/nocobase-file-component-ui/types.ts +3 -1
  29. package/client/locales/en-US.ts +2 -0
  30. package/client/locales/zh-CN.ts +2 -0
  31. package/client/pages/settings/role-form.tsx +5 -1
  32. package/client/routing/route-context.tsx +33 -1
  33. package/eslint.config.js +2 -0
  34. package/package.json +19 -17
  35. package/scripts/check-packages.mjs +46 -0
  36. package/tailwind.config.mjs +3 -9
  37. package/tests/client/list-return-state.test.tsx +202 -0
  38. package/tests/client/role-return.test.tsx +104 -0
  39. package/tests/project/internal-packages.test.ts +98 -0
  40. package/tests/project/tailwind-sources.test.ts +2 -2
  41. package/client/extensions/nocobase-file-component-ui/README.md +0 -13
@@ -0,0 +1,47 @@
1
+ import type { FileUiResource } from './en-US.js';
2
+ const messages = {
3
+ 'files.ooxmlLoadFailed': 'Không thể hiển thị tệp Office Open XML này.',
4
+ 'files.ooxmlLoadTimedOut':
5
+ 'Bản xem trước không tải xong trong vòng {{minutes}} phút.',
6
+ 'files.previewRequestFailed':
7
+ 'Yêu cầu xem trước không thành công ({{status}}).',
8
+ uploadFailed: 'Tải lên tệp không thành công.',
9
+ fileLimitReached: 'Đã đạt đến số lượng tệp tối đa.',
10
+ fileTooLarge: 'Tệp vượt quá kích thước tối đa.',
11
+ fileTypeNotAllowed: 'Loại tệp không được phép.',
12
+ removalFailed: 'Xóa tệp không thành công.',
13
+ urlNotAllowed: 'Tệp URL không được phép.',
14
+ downloadFailed: 'Tải xuống tệp không thành công.',
15
+ invalidUrl: 'Tệp URL bị thiếu hoặc không được phép.',
16
+ pdfPreviewFailed: 'Không thể tải bản xem trước PDF.',
17
+ pdfPreviewMarkup: 'Tệp URL trả về HTML hoặc XML thay vì PDF.',
18
+ previewFailed: 'Không thể tải bản xem trước tệp.',
19
+ 'files.empty': 'Không có tập tin.',
20
+ 'files.preview': 'Xem trước',
21
+ 'files.download': 'Tải xuống',
22
+ 'files.remove': 'Loại bỏ',
23
+ 'files.chooseMany': 'Chọn tập tin',
24
+ 'files.chooseOne': 'Chọn tập tin',
25
+ 'files.retry': 'Thử lại',
26
+ 'files.done': 'Hoàn thành',
27
+ 'files.uploading': 'Đang tải lên',
28
+ 'files.failed': 'Thất bại',
29
+ 'files.pending': 'Đang chờ',
30
+ 'files.previous': 'Tệp trước đó',
31
+ 'files.next': 'Tập tin tiếp theo',
32
+ 'files.loadingPreview': 'Đang tải bản xem trước...',
33
+ 'files.downloadFile': 'Tải tập tin',
34
+ 'files.previewUnavailable': 'Bản xem trước không khả dụng cho loại tệp này.',
35
+ 'files.imageFailed':
36
+ 'Không thể hiển thị hình ảnh này. Nó có thể bị hỏng hoặc ở định dạng không được hỗ trợ.',
37
+ 'files.officeFailed': 'Office Online không thể tải tệp này.',
38
+ 'files.officeUrl':
39
+ 'Office Online yêu cầu tệp tuyệt đối có thể truy cập internet URL.',
40
+ 'files.cancelFile': 'Hủy {{filename}}',
41
+ 'files.status.uploading': 'Đang tải lên',
42
+ 'files.status.pending': 'Đang chờ',
43
+ 'files.status.error': 'Thất bại',
44
+ };
45
+
46
+ const resource: FileUiResource = { fileUi: messages };
47
+ export default resource;
@@ -0,0 +1,43 @@
1
+ import type { FileUiResource } from './en-US.js';
2
+ const messages = {
3
+ 'files.ooxmlLoadFailed': '无法渲染此 Office Open XML 文件。',
4
+ 'files.ooxmlLoadTimedOut': '预览未能在 {{minutes}} 分钟内加载完成。',
5
+ 'files.previewRequestFailed': '预览请求失败({{status}})。',
6
+ uploadFailed: '文件上传失败。',
7
+ fileLimitReached: '已达到文件数量上限。',
8
+ fileTooLarge: '文件超出大小限制。',
9
+ fileTypeNotAllowed: '不允许上传此文件类型。',
10
+ removalFailed: '文件删除失败。',
11
+ urlNotAllowed: '不允许访问此文件 URL。',
12
+ downloadFailed: '文件下载失败。',
13
+ invalidUrl: '文件 URL 缺失或不允许访问。',
14
+ pdfPreviewFailed: '无法加载 PDF 预览。',
15
+ pdfPreviewMarkup: '文件 URL 返回的是 HTML 或 XML,而不是 PDF。',
16
+ previewFailed: '无法加载文件预览。',
17
+ 'files.empty': '暂无文件。',
18
+ 'files.preview': '预览',
19
+ 'files.download': '下载',
20
+ 'files.remove': '移除',
21
+ 'files.chooseMany': '选择文件',
22
+ 'files.chooseOne': '选择文件',
23
+ 'files.retry': '重试',
24
+ 'files.done': '已完成',
25
+ 'files.uploading': '正在上传',
26
+ 'files.failed': '失败',
27
+ 'files.pending': '等待中',
28
+ 'files.previous': '上一个文件',
29
+ 'files.next': '下一个文件',
30
+ 'files.loadingPreview': '正在加载预览…',
31
+ 'files.downloadFile': '下载文件',
32
+ 'files.previewUnavailable': '此文件类型不支持预览。',
33
+ 'files.imageFailed': '无法显示此图片,文件可能已损坏或格式不受支持。',
34
+ 'files.officeFailed': 'Office Online 无法加载此文件。',
35
+ 'files.officeUrl': 'Office Online 需要可通过互联网访问的文件绝对地址。',
36
+ 'files.cancelFile': '取消 {{filename}}',
37
+ 'files.status.uploading': '正在上传',
38
+ 'files.status.pending': '等待中',
39
+ 'files.status.error': '失败',
40
+ };
41
+
42
+ const resource: FileUiResource = { fileUi: messages };
43
+ export default resource;
@@ -0,0 +1,43 @@
1
+ import type { FileUiResource } from './en-US.js';
2
+ const messages = {
3
+ 'files.ooxmlLoadFailed': '無法呈現此 Office Open XML 檔案。',
4
+ 'files.ooxmlLoadTimedOut': '預覽未在 {{minutes}} 分鐘內完成載入。',
5
+ 'files.previewRequestFailed': '預覽請求失敗 ({{status}})。',
6
+ uploadFailed: '檔案上傳失敗。',
7
+ fileLimitReached: '已達到最大檔案數。',
8
+ fileTooLarge: '檔案超出最大大小。',
9
+ fileTypeNotAllowed: '不允許的檔案型別。',
10
+ removalFailed: '檔案刪除失敗。',
11
+ urlNotAllowed: '不允許使用檔案 URL。',
12
+ downloadFailed: '檔案下載失敗。',
13
+ invalidUrl: '檔案 URL 遺失或不允許。',
14
+ pdfPreviewFailed: '無法載入 PDF 預覽。',
15
+ pdfPreviewMarkup: '檔案 URL 傳回 HTML 或 XML,而不是 PDF。',
16
+ previewFailed: '無法載入檔案預覽。',
17
+ 'files.empty': '沒有檔案。',
18
+ 'files.preview': '預覽',
19
+ 'files.download': '下載',
20
+ 'files.remove': '刪除',
21
+ 'files.chooseMany': '選擇檔案',
22
+ 'files.chooseOne': '選擇檔案',
23
+ 'files.retry': '重試',
24
+ 'files.done': '已完成',
25
+ 'files.uploading': '上傳中',
26
+ 'files.failed': '失敗',
27
+ 'files.pending': '待處理',
28
+ 'files.previous': '上一個檔案',
29
+ 'files.next': '下一個檔案',
30
+ 'files.loadingPreview': '載入預覽...',
31
+ 'files.downloadFile': '下載檔案',
32
+ 'files.previewUnavailable': '此檔案型別無法預覽。',
33
+ 'files.imageFailed': '此影像無法顯示。它可能已損壞或格式不受支援。',
34
+ 'files.officeFailed': 'Office Online 無法載入此檔案。',
35
+ 'files.officeUrl': 'Office Online 需要可透過 Internet 存取的絕對檔案 URL。',
36
+ 'files.cancelFile': '取消 {{filename}}',
37
+ 'files.status.uploading': '上傳中',
38
+ 'files.status.pending': '待處理',
39
+ 'files.status.error': '失敗',
40
+ };
41
+
42
+ const resource: FileUiResource = { fileUi: messages };
43
+ export default resource;
@@ -17,7 +17,7 @@ export interface FileUiLabels {
17
17
  }
18
18
 
19
19
  export interface FileUploadFieldProps {
20
- readonly repository: ClientFileRepository;
20
+ readonly repository: Pick<ClientFileRepository, 'uploadOne' | 'deleteOne'>;
21
21
  readonly value: readonly FileRecord[];
22
22
  readonly onChange: (value: readonly FileRecord[]) => void;
23
23
  readonly onError?: (error: Error) => void;
@@ -47,6 +47,8 @@ export interface FilePreviewDialogProps {
47
47
  readonly initialIndex?: number;
48
48
  readonly open: boolean;
49
49
  readonly onOpenChange: (open: boolean) => void;
50
+ /** Override downloads for both the toolbar and preview fallbacks. */
51
+ readonly onDownload?: (file: FileRecord) => void | Promise<void>;
50
52
  readonly onError?: (error: Error) => void;
51
53
  readonly download?: boolean;
52
54
  readonly labels?: FileUiLabels;
@@ -1,3 +1,4 @@
1
+ import fileUi from '../extensions/nocobase-file-component-ui/locales/en-US.js';
1
2
  import type { LocaleResource } from '@nocobase/i18n';
2
3
 
3
4
  import systemEnUS from './system/en-US.js';
@@ -6,6 +7,7 @@ const enUS = {
6
7
  // The template's own copy: the shell, the sign-in pages, shared components and the homepage. Keep it in
7
8
  // `./system/` so a template upgrade can replace it without touching the application's copy below.
8
9
  ...systemEnUS,
10
+ ...fileUi,
9
11
  // The application's copy goes here, one group per feature. A shared group such as `navigation` is extended by
10
12
  // spreading the system one first, `navigation: { ...systemEnUS.navigation, orders: 'Orders' }`, since a group
11
13
  // written here replaces the system group of the same name rather than merging with it.
@@ -1,9 +1,11 @@
1
+ import fileUi from '../extensions/nocobase-file-component-ui/locales/zh-CN.js';
1
2
  import type { AppResource } from './en-US.js';
2
3
  import systemZhCN from './system/zh-CN.js';
3
4
 
4
5
  const zhCN: AppResource = {
5
6
  // The template's own copy; see `./en-US.ts`.
6
7
  ...systemZhCN,
8
+ ...fileUi,
7
9
  };
8
10
 
9
11
  export default zhCN;
@@ -31,6 +31,7 @@ import {
31
31
  useNavigate,
32
32
  useOutletContext,
33
33
  useParams,
34
+ useResolvedPath,
34
35
  } from 'react-router';
35
36
  import { RouteChildPage } from '#components/route-child-page';
36
37
  import { BackButton } from '#components/back-button';
@@ -122,7 +123,10 @@ function RoleEditor(): ReactElement {
122
123
  : canUpdate &&
123
124
  (!request.data?.data.protection ||
124
125
  request.data.data.protection.allow.includes('update'));
125
- const closeTo = { pathname: '/settings/roles', search: location.search };
126
+ const closeTo = {
127
+ pathname: useResolvedPath('..').pathname,
128
+ search: location.search,
129
+ };
126
130
  const save = async (event: React.FormEvent<HTMLFormElement>) => {
127
131
  event.preventDefault();
128
132
  if (options.error || !catalog || (!creating && request.error)) return;
@@ -4,6 +4,7 @@ import {
4
4
  createContext,
5
5
  useContext,
6
6
  useMemo,
7
+ useState,
7
8
  type PropsWithChildren,
8
9
  type ReactElement,
9
10
  } from 'react';
@@ -26,6 +27,15 @@ export interface RouteTrailEntry {
26
27
  readonly pathname: string;
27
28
  }
28
29
 
30
+ const RouteSearchContext = createContext<ReadonlyMap<string, string>>(
31
+ new Map(),
32
+ );
33
+
34
+ /** Queries belong to the page that was visited, never to a child currently covering it. */
35
+ export function useRouteSearches(): ReadonlyMap<string, string> {
36
+ return useContext(RouteSearchContext);
37
+ }
38
+
29
39
  const RouteTreeContext = createContext<readonly AppClientRegisteredRoute[]>([]);
30
40
  export interface RouteTreeProviderProps extends PropsWithChildren {
31
41
  readonly routes: readonly AppClientRegisteredRoute[];
@@ -42,9 +52,31 @@ export function RouteTreeProvider({
42
52
  children,
43
53
  routes,
44
54
  }: RouteTreeProviderProps): ReactElement {
55
+ const location = useLocation();
56
+ const pathname = location.pathname.replace(/\/+$/u, '') || '/';
57
+ const search = location.search;
58
+ const [visited, setVisited] = useState(() => ({
59
+ pathname,
60
+ search,
61
+ queries: new Map([[pathname, search]]),
62
+ }));
63
+ let queries = visited.queries;
64
+ if (visited.pathname !== pathname || visited.search !== search) {
65
+ // Keep only the active ancestry. A fresh visit (including an empty query) replaces its old state.
66
+ // Derive before rendering children so a detail never records its parameters against the parent.
67
+ queries = new Map(
68
+ [...queries].filter(([path]) =>
69
+ pathname.startsWith(`${path.replace(/\/$/u, '')}/`),
70
+ ),
71
+ );
72
+ queries.set(pathname, search);
73
+ setVisited({ pathname, search, queries });
74
+ }
45
75
  return (
46
76
  <RouteTreeContext.Provider value={routes}>
47
- {children}
77
+ <RouteSearchContext.Provider value={queries}>
78
+ {children}
79
+ </RouteSearchContext.Provider>
48
80
  </RouteTreeContext.Provider>
49
81
  );
50
82
  }
package/eslint.config.js CHANGED
@@ -7,5 +7,7 @@ export default createApplicationConfig({
7
7
  'client-old/**',
8
8
  'public/r/**',
9
9
  'storage/**',
10
+ // Internal workspace packages own their lint and TypeScript configurations.
11
+ 'packages/**',
10
12
  ],
11
13
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nocobase/app-template-default",
3
3
  "displayName": "Default Template",
4
- "version": "1.0.0-beta.81",
4
+ "version": "1.0.0-beta.82",
5
5
  "type": "module",
6
6
  "prettier": "@nocobase/dev-config/prettier",
7
7
  "engines": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "nocobase": {
11
11
  "templateKind": "app",
12
- "defaultTemplateVersion": "1.0.0-beta.81"
12
+ "defaultTemplateVersion": "1.0.0-beta.82"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@ai-sdk/react": "4.0.131",
@@ -75,7 +75,7 @@
75
75
  "@types/jsdom": "^30.0.0",
76
76
  "@nocobase/app-client": "^4.0.0-beta.3",
77
77
  "@nocobase/app-plugin-template-print": "0.0.2-beta.2",
78
- "@nocobase/app-skills": "0.1.0-beta.51",
78
+ "@nocobase/app-skills": "0.1.0-beta.52",
79
79
  "@nocobase/app-testing": "0.1.0-beta.6",
80
80
  "@nocobase/app-websocket": "^0.1.0-beta.1",
81
81
  "@nocobase/dev-config": "0.1.0-beta.25",
@@ -113,7 +113,8 @@
113
113
  "AGENTS.md",
114
114
  ".agents/rules",
115
115
  "CLAUDE.md",
116
- "playwright.config.ts"
116
+ "playwright.config.ts",
117
+ "scripts/check-packages.mjs"
117
118
  ],
118
119
  "publishConfig": {
119
120
  "access": "public"
@@ -140,17 +141,17 @@
140
141
  "@oclif/core": "^4.14.0",
141
142
  "better-auth": "1.7.5",
142
143
  "hono": "^4.13.2",
143
- "@nocobase/app-plugin-agents": "^1.0.0-beta.25",
144
- "@nocobase/app-plugin-api-keys": "^1.0.0-beta.18",
144
+ "@nocobase/app-plugin-agents": "^1.0.0-beta.27",
145
+ "@nocobase/app-plugin-api-keys": "^1.0.0-beta.19",
145
146
  "@nocobase/app-plugin-authentication": "^2.0.0-beta.15",
146
147
  "@nocobase/app-plugin-authorization": "^1.0.0-beta.32",
147
- "@nocobase/app-plugin-database-explorer": "^1.0.0-beta.16",
148
- "@nocobase/app-plugin-file": "^1.0.0-beta.26",
149
- "@nocobase/app-plugin-foundation": "^0.1.0-beta.7",
148
+ "@nocobase/app-plugin-database-explorer": "^1.0.0-beta.17",
149
+ "@nocobase/app-plugin-file": "^1.0.0-beta.27",
150
+ "@nocobase/app-plugin-foundation": "^0.1.0-beta.9",
150
151
  "@nocobase/app-plugin-i18n": "^1.0.0-beta.18",
151
- "@nocobase/app-plugin-knowledge": "^0.1.0-beta.12",
152
+ "@nocobase/app-plugin-knowledge": "^0.1.0-beta.13",
152
153
  "@nocobase/app-plugin-mail": "^1.0.0-beta.17",
153
- "@nocobase/app-plugin-notification": "^2.0.0-beta.0",
154
+ "@nocobase/app-plugin-notification": "^2.0.0-beta.1",
154
155
  "@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.0",
155
156
  "@nocobase/app-plugin-notification-providers": "^1.0.0-beta.15",
156
157
  "@nocobase/app-plugin-scheduler": "^1.0.0-beta.20",
@@ -161,9 +162,9 @@
161
162
  "@nocobase/db-sqlite": "^0.1.0-beta.6",
162
163
  "@nocobase/service-provider": "^0.0.2-beta.2",
163
164
  "@nocobase/snowflake": "^1.0.0-beta.4",
164
- "@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.15",
165
- "@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.15",
166
- "@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.14",
165
+ "@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.16",
166
+ "@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.16",
167
+ "@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.15",
167
168
  "@nocobase/authorization": "^1.0.0-beta.15",
168
169
  "@nocobase/queue": "^0.1.0-beta.9",
169
170
  "@nocobase/jobs": "^0.1.0-beta.4",
@@ -171,7 +172,7 @@
171
172
  "@nocobase/i18n": "^2.0.0-beta.1",
172
173
  "@nocobase/caching": "^0.1.0-beta.3",
173
174
  "@nocobase/repository-input": "^0.1.0-beta.3",
174
- "@nocobase/app-cli": "^1.0.0-beta.27",
175
+ "@nocobase/app-cli": "^1.0.0-beta.28",
175
176
  "@nocobase/realtime": "^0.1.0-beta.3"
176
177
  },
177
178
  "description": "NocoBase 3 application template with the standard product capabilities.",
@@ -188,7 +189,7 @@
188
189
  "dev": "nocobase dev",
189
190
  "build": "nocobase build",
190
191
  "start": "nocobase start",
191
- "check": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build",
192
+ "check": "pnpm check:packages && pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build",
192
193
  "fix": "pnpm lint:fix && pnpm format",
193
194
  "typecheck": "tsc --noEmit && tsc -p tsconfig.node.json --noEmit && tsc -p tsconfig.server.json --noEmit",
194
195
  "lint": "eslint . --max-warnings 0",
@@ -198,6 +199,7 @@
198
199
  "test": "vitest run",
199
200
  "test:watch": "vitest",
200
201
  "test:coverage": "vitest run --coverage",
201
- "test:e2e": "playwright test --pass-with-no-tests"
202
+ "test:e2e": "playwright test --pass-with-no-tests",
203
+ "check:packages": "node scripts/check-packages.mjs"
202
204
  }
203
205
  }
@@ -0,0 +1,46 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ // Ask pnpm for exactly the packages the following check selects, respecting workspace exclusions.
6
+ const root = path.resolve(import.meta.dirname, '..');
7
+ const filter = './packages/**';
8
+ const listed = spawnSync(
9
+ 'pnpm',
10
+ ['--filter', filter, 'list', '--depth', '-1', '--json'],
11
+ {
12
+ cwd: root,
13
+ encoding: 'utf8',
14
+ },
15
+ );
16
+ if (listed.error) throw listed.error;
17
+ if (listed.status !== 0) {
18
+ process.stderr.write(listed.stderr);
19
+ process.exit(listed.status ?? 1);
20
+ }
21
+ const packages = JSON.parse(listed.stdout || '[]');
22
+ const missing = packages.filter(({ path: directory }) => {
23
+ const manifest = JSON.parse(
24
+ readFileSync(path.join(directory, 'package.json'), 'utf8'),
25
+ );
26
+ return (
27
+ typeof manifest.scripts?.check !== 'string' ||
28
+ !manifest.scripts.check.trim()
29
+ );
30
+ });
31
+ if (missing.length > 0) {
32
+ console.error(
33
+ 'Every internal package must provide a non-empty check script:',
34
+ );
35
+ for (const entry of missing)
36
+ console.error(` ${path.relative(root, entry.path)}`);
37
+ process.exit(1);
38
+ }
39
+ if (packages.length > 0) {
40
+ const result = spawnSync('pnpm', ['--filter', filter, 'check'], {
41
+ cwd: root,
42
+ stdio: 'inherit',
43
+ });
44
+ if (result.error) throw result.error;
45
+ process.exitCode = result.status ?? 1;
46
+ }
@@ -5,18 +5,12 @@
5
5
  // nothing, in this repository and in a generated application alike. Reading the directory first and resolving each
6
6
  // match to its real path gets past that, because `readdirSync` sees the symlinks and `realpathSync` resolves them.
7
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.
8
+ // Scan plugin runtime source in a workspace and compiled output in installed packages. UI Library items installed
9
+ // into the application are already covered by its own client source. Missing directories are skipped.
10
10
  import { existsSync, globSync, readdirSync, realpathSync } from 'node:fs';
11
11
  import path from 'node:path';
12
12
 
13
- const SOURCE_DIRECTORIES = [
14
- 'client',
15
- 'dist/client',
16
- 'src',
17
- 'dist/src',
18
- 'registry',
19
- ];
13
+ const SOURCE_DIRECTORIES = ['client', 'dist/client', 'src', 'dist/src'];
20
14
  const SCANNED_FILES = '**/*.{js,jsx,ts,tsx}';
21
15
 
22
16
  /** Every scannable directory a workspace or installed `@nocobase` package exposes. */
@@ -0,0 +1,202 @@
1
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
+ import {
3
+ TestI18nProvider,
4
+ createTestI18nRuntime,
5
+ } from '@nocobase/i18n/testing';
6
+ import { act, render, screen } from '@testing-library/react';
7
+ import userEvent from '@testing-library/user-event';
8
+ import {
9
+ createMemoryRouter,
10
+ Link,
11
+ Outlet,
12
+ RouterProvider,
13
+ useLocation,
14
+ useSearchParams,
15
+ } from 'react-router';
16
+ import { describe, expect, it } from 'vitest';
17
+ import { Breadcrumbs } from '../../client/components/breadcrumbs.js';
18
+ import { BackButton } from '../../client/components/back-button.js';
19
+ import { RouteTreeProvider } from '../../client/routing/route-context.js';
20
+ import enUS from '../../client/locales/en-US.js';
21
+
22
+ const runtime = await createTestI18nRuntime({
23
+ application: { namespace: '@nocobase/navigation-test', resources: enUS },
24
+ strict: false,
25
+ });
26
+ const route = (
27
+ path: string,
28
+ title: string,
29
+ children: AppClientRegisteredRoute[] = [],
30
+ ): AppClientRegisteredRoute => ({
31
+ id: path,
32
+ name: path,
33
+ path,
34
+ auth: 'required',
35
+ packageName: 'test',
36
+ source: 'application',
37
+ breadcrumb: { title },
38
+ componentLoader: async () => ({ default: () => null }),
39
+ children,
40
+ });
41
+ const routes = [
42
+ route('/orders', 'Orders', [
43
+ route('/orders/:id', 'Order', [route('/orders/:id/lines', 'Lines')]),
44
+ ]),
45
+ route('/other', 'Other', [route('/other/:id', 'Other detail')]),
46
+ ];
47
+ const initial = '/orders?q=Acme&status=open&page=2';
48
+ function List() {
49
+ const [query, setQuery] = useSearchParams();
50
+ const { pathname } = useLocation();
51
+ return (
52
+ <>
53
+ <input
54
+ aria-label='Search'
55
+ value={query.get('q') ?? ''}
56
+ onChange={(event) => {
57
+ const next = new URLSearchParams(query);
58
+ next.set('q', event.target.value);
59
+ next.delete('page');
60
+ setQuery(next);
61
+ }}
62
+ />
63
+ <output data-testid='page'>{query.get('page') ?? '1'}</output>
64
+ <output data-testid='record'>
65
+ {query.get('q') || 'All'}-{query.get('page') ?? '1'}
66
+ </output>
67
+ <Link to={`${pathname}/42?detailPage=7&q=child`}>Open detail</Link>
68
+ <button onClick={() => setQuery({})}>Clear</button>
69
+ </>
70
+ );
71
+ }
72
+ function Detail() {
73
+ return (
74
+ <>
75
+ <BackButton />
76
+ <Link to='lines?linePage=9'>Open lines</Link>
77
+ </>
78
+ );
79
+ }
80
+ function setup(url = initial) {
81
+ const router = createMemoryRouter(
82
+ [
83
+ {
84
+ element: (
85
+ <TestI18nProvider runtime={runtime}>
86
+ <RouteTreeProvider routes={routes}>
87
+ <Breadcrumbs />
88
+ <Outlet />
89
+ </RouteTreeProvider>
90
+ </TestI18nProvider>
91
+ ),
92
+ children: [
93
+ {
94
+ path: '/orders',
95
+ children: [
96
+ { index: true, element: <List /> },
97
+ {
98
+ path: ':id',
99
+ children: [
100
+ { index: true, element: <Detail /> },
101
+ { path: 'lines', element: <h1>Lines</h1> },
102
+ ],
103
+ },
104
+ ],
105
+ },
106
+ {
107
+ path: '/other',
108
+ children: [
109
+ { index: true, element: <List /> },
110
+ { path: ':id', element: <Detail /> },
111
+ ],
112
+ },
113
+ ],
114
+ },
115
+ ],
116
+ { initialEntries: [url] },
117
+ );
118
+ render(<RouterProvider router={router} />);
119
+ return router;
120
+ }
121
+
122
+ describe('returning to a list', () => {
123
+ it('restores the actual list query through breadcrumbs across repeated visits', async () => {
124
+ const router = setup();
125
+ for (let i = 0; i < 3; i++) {
126
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
127
+ expect(screen.getByRole('link', { name: 'Orders' })).toHaveAttribute(
128
+ 'href',
129
+ initial,
130
+ );
131
+ await userEvent.click(screen.getByRole('link', { name: 'Orders' }));
132
+ expect(router.state.location.search).toBe('?q=Acme&status=open&page=2');
133
+ expect(screen.getByRole('textbox', { name: 'Search' })).toHaveValue(
134
+ 'Acme',
135
+ );
136
+ expect(screen.getByTestId('page')).toHaveTextContent('2');
137
+ expect(screen.getByTestId('record')).toHaveTextContent('Acme-2');
138
+ }
139
+ });
140
+ it('restores list state through browser back and the standard BackButton', async () => {
141
+ const router = setup();
142
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
143
+ await act(() => router.navigate(-1));
144
+ expect(screen.getByTestId('record')).toHaveTextContent('Acme-2');
145
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
146
+ await userEvent.click(screen.getByRole('link', { name: 'Back' }));
147
+ expect(router.state.location.pathname).toBe('/orders');
148
+ expect(screen.getByTestId('record')).toHaveTextContent('Acme-2');
149
+ });
150
+ it('keeps each visited ancestor separate from a nested list query', async () => {
151
+ setup();
152
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
153
+ await userEvent.click(screen.getByRole('link', { name: 'Open lines' }));
154
+ expect(screen.getByRole('link', { name: 'Orders' })).toHaveAttribute(
155
+ 'href',
156
+ initial,
157
+ );
158
+ // Middle levels appear in both the desktop trail and the collapsed menu.
159
+ expect(screen.getByRole('link', { name: 'Order' })).toHaveAttribute(
160
+ 'href',
161
+ '/orders/42?detailPage=7&q=child',
162
+ );
163
+ });
164
+ it('does not revive cleared filters or leak one list into another', async () => {
165
+ const router = setup();
166
+ await userEvent.click(screen.getByRole('button', { name: 'Clear' }));
167
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
168
+ expect(screen.getByRole('link', { name: 'Orders' })).toHaveAttribute(
169
+ 'href',
170
+ '/orders',
171
+ );
172
+ await act(() => router.navigate('/other?q=Beta&page=3'));
173
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
174
+ expect(screen.getByRole('link', { name: 'Other' })).toHaveAttribute(
175
+ 'href',
176
+ '/other?q=Beta&page=3',
177
+ );
178
+ await act(() => router.navigate('/orders/42?detailPage=8'));
179
+ expect(screen.getByRole('link', { name: 'Orders' })).toHaveAttribute(
180
+ 'href',
181
+ '/orders',
182
+ );
183
+ });
184
+ it('does not apply a deep-linked detail query to an unvisited ancestor', () => {
185
+ setup('/orders/42?detailPage=7');
186
+ expect(screen.getByRole('link', { name: 'Orders' })).toHaveAttribute(
187
+ 'href',
188
+ '/orders',
189
+ );
190
+ });
191
+ it('uses the latest search after changing conditions and resetting the page', async () => {
192
+ setup();
193
+ await userEvent.clear(screen.getByRole('textbox', { name: 'Search' }));
194
+ await userEvent.type(
195
+ screen.getByRole('textbox', { name: 'Search' }),
196
+ 'Beta',
197
+ );
198
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
199
+ await userEvent.click(screen.getByRole('link', { name: 'Orders' }));
200
+ expect(screen.getByTestId('record')).toHaveTextContent('Beta-1');
201
+ });
202
+ });