@nocobase/client-v2 2.3.0-alpha.1 → 3.0.0-alpha.2

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 (123) hide show
  1. package/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/authRedirect.d.ts +5 -0
  3. package/es/components/AttachmentUpload.d.ts +51 -0
  4. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/actions/linkageRules.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  8. package/es/flow/components/FlowRoute.d.ts +2 -2
  9. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  10. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  11. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  12. package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
  13. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  14. package/es/index.d.ts +4 -1
  15. package/es/index.mjs +139 -125
  16. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  17. package/es/nocobase-buildin-plugin/index.d.ts +9 -0
  18. package/es/settings-app/SettingsApplication.d.ts +18 -0
  19. package/es/settings-app/SettingsBrand.d.ts +16 -0
  20. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  21. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  22. package/es/settings-app/SettingsGroupNav.d.ts +17 -0
  23. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  24. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  25. package/es/settings-app/SettingsSearch.d.ts +17 -0
  26. package/es/settings-app/SettingsShell.d.ts +10 -0
  27. package/es/settings-app/isSettingsApp.d.ts +18 -0
  28. package/es/settings-app/runtimePaths.d.ts +18 -0
  29. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  30. package/es/settings-app/settingsTheme.d.ts +87 -0
  31. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  32. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  33. package/es/settings-center/groups.d.ts +66 -0
  34. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  35. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  36. package/es/settings-center/utils.d.ts +24 -0
  37. package/es/theme/index.d.ts +1 -0
  38. package/es/theme/itemActive.d.ts +24 -0
  39. package/es/theme/type.d.ts +9 -0
  40. package/lib/index.js +141 -127
  41. package/lib/locale/languageCodes.js +1 -0
  42. package/package.json +8 -7
  43. package/src/__tests__/authRedirect.test.ts +137 -0
  44. package/src/__tests__/browserChecker.test.ts +124 -6
  45. package/src/__tests__/mockSettingsApplication.ts +29 -0
  46. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +51 -10
  47. package/src/__tests__/plugin-manager.test.tsx +7 -6
  48. package/src/__tests__/settings-application.test.ts +164 -0
  49. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  50. package/src/__tests__/settings-center.test.tsx +38 -37
  51. package/src/__tests__/settings-layout-root.test.tsx +260 -0
  52. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  53. package/src/__tests__/settings-shell.test.tsx +200 -0
  54. package/src/acl/ACLProvider.tsx +21 -0
  55. package/src/authRedirect.ts +72 -3
  56. package/src/components/AttachmentUpload.tsx +275 -0
  57. package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
  58. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  59. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
  60. package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
  61. package/src/components/index.ts +1 -0
  62. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  63. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  64. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  65. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  66. package/src/flow/actions/linkageRules.tsx +26 -6
  67. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  68. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  69. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  70. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  71. package/src/flow/common/Markdown/style.ts +4 -0
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/FlowRoute.tsx +71 -29
  74. package/src/flow/index.ts +2 -0
  75. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  76. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  77. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  78. package/src/flow/models/base/CollectionBlockModel.tsx +6 -2
  79. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  80. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  81. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  82. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  83. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  84. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  85. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  86. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  87. package/src/flow/models/blocks/table/TableBlockModel.tsx +3 -1
  88. package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
  89. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  90. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  91. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  92. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  93. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  94. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  95. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  96. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  97. package/src/index.ts +10 -1
  98. package/src/locale/languageCodes.ts +1 -0
  99. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  100. package/src/nocobase-buildin-plugin/index.tsx +99 -71
  101. package/src/settings-app/SettingsApplication.ts +31 -0
  102. package/src/settings-app/SettingsBrand.tsx +108 -0
  103. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  104. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  105. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  106. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  107. package/src/settings-app/SettingsRouterManager.ts +92 -0
  108. package/src/settings-app/SettingsSearch.tsx +238 -0
  109. package/src/settings-app/SettingsShell.tsx +167 -0
  110. package/src/settings-app/isSettingsApp.ts +21 -0
  111. package/src/settings-app/runtimePaths.ts +104 -0
  112. package/src/settings-app/settingsDocumentPath.ts +66 -0
  113. package/src/settings-app/settingsTheme.ts +301 -0
  114. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  115. package/src/settings-center/AdminSettingsLayout.tsx +122 -141
  116. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  117. package/src/settings-center/groups.ts +108 -0
  118. package/src/settings-center/useSettingsGroups.ts +105 -0
  119. package/src/settings-center/useSettingsSearch.ts +173 -0
  120. package/src/settings-center/utils.tsx +50 -1
  121. package/src/theme/index.tsx +1 -0
  122. package/src/theme/itemActive.ts +31 -0
  123. package/src/theme/type.ts +10 -0
@@ -0,0 +1,275 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
11
+ import { css, cx } from '@emotion/css';
12
+ import { Upload, message } from 'antd';
13
+ import type { UploadFile } from 'antd';
14
+ import mime from 'mime';
15
+ import match from 'mime-match';
16
+ import type { RcFile, UploadRequestOption } from 'rc-upload/lib/interface';
17
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
18
+ import { useTranslation } from 'react-i18next';
19
+ import { useApp } from '../hooks/useApp';
20
+
21
+ export type UploadedAttachment = {
22
+ id?: number;
23
+ title?: string;
24
+ filename?: string;
25
+ url?: string;
26
+ };
27
+
28
+ export type AttachmentStorageRules = {
29
+ size?: number;
30
+ mimetype?: string | string[];
31
+ };
32
+
33
+ /**
34
+ * 读取默认附件存储的上传限制。
35
+ *
36
+ * 校验交给存储自身的配置(大小、mimetype),而不是在每个调用点各写一套硬编码规则。
37
+ *
38
+ * @returns {AttachmentStorageRules | null} 存储规则,取不到时为 null
39
+ */
40
+ export function useAttachmentStorageRules() {
41
+ const app = useApp();
42
+ const [storageRules, setStorageRules] = useState<AttachmentStorageRules | null>(null);
43
+
44
+ useEffect(() => {
45
+ let mounted = true;
46
+
47
+ const loadStorageRules = async () => {
48
+ try {
49
+ const attachmentsCollection = app.dataSourceManager?.getCollection?.('main', 'attachments');
50
+ const storageName = attachmentsCollection?.getOption?.('storage');
51
+
52
+ if (!storageName) {
53
+ if (mounted) {
54
+ setStorageRules(null);
55
+ }
56
+ return;
57
+ }
58
+
59
+ const response = await app.apiClient.request({
60
+ url: `storages:getBasicInfo/${storageName}`,
61
+ skipNotify: true,
62
+ });
63
+
64
+ if (mounted) {
65
+ setStorageRules(response?.data?.data?.rules || null);
66
+ }
67
+ } catch (error) {
68
+ if (mounted) {
69
+ setStorageRules(null);
70
+ }
71
+ }
72
+ };
73
+
74
+ loadStorageRules().catch(() => {
75
+ if (mounted) {
76
+ setStorageRules(null);
77
+ }
78
+ });
79
+
80
+ return () => {
81
+ mounted = false;
82
+ };
83
+ }, [app]);
84
+
85
+ return storageRules;
86
+ }
87
+
88
+ export type AttachmentUploadProps = {
89
+ value?: UploadedAttachment | null;
90
+ onChange?: (value: UploadedAttachment | null) => void;
91
+ /** 卡片尺寸;`fit` 对应 CSS object-fit */
92
+ preview?: {
93
+ width?: number;
94
+ height?: number;
95
+ fit?: 'contain' | 'cover';
96
+ };
97
+ /** 在存储规则之外再收窄可选文件类型,例如只收图片 */
98
+ accept?: string;
99
+ /** 上传卡片里 `+` 下方的文字 */
100
+ uploadText?: string;
101
+ disabled?: boolean;
102
+ };
103
+
104
+ const DEFAULT_PREVIEW = { width: 104, height: 104, fit: 'contain' as const };
105
+
106
+ /**
107
+ * 附件上传控件。
108
+ *
109
+ * 受控组件,值就是 `attachments:create` 返回的附件对象,可直接挂在 `Form.Item` 上。
110
+ * 外观沿用附件字段的原生形态:一张 picture-card,删除在卡片自身的悬浮操作里,不额外摆按钮。
111
+ * 上传前按默认存储的规则校验大小与 mimetype,与业务表单里的附件字段保持同一套限制。
112
+ */
113
+ export const AttachmentUpload: React.FC<AttachmentUploadProps> = (props) => {
114
+ const { value, onChange, preview, accept, uploadText, disabled } = props;
115
+ const app = useApp();
116
+ const { t } = useTranslation();
117
+ const [uploading, setUploading] = useState(false);
118
+ const storageRules = useAttachmentStorageRules();
119
+ const previewConfig = { ...DEFAULT_PREVIEW, ...preview };
120
+
121
+ const acceptValue = useMemo(() => {
122
+ if (accept) {
123
+ return accept;
124
+ }
125
+ const rule = storageRules?.mimetype;
126
+ if (!rule) {
127
+ return undefined;
128
+ }
129
+ return Array.isArray(rule) ? rule.join(',') : rule;
130
+ }, [accept, storageRules?.mimetype]);
131
+
132
+ const beforeUpload = useCallback(
133
+ async (file: RcFile) => {
134
+ const sizeLimit = storageRules?.size;
135
+
136
+ if (typeof sizeLimit === 'number' && sizeLimit > 0 && file.size > sizeLimit) {
137
+ message.error(t('File size exceeds the limit'));
138
+ return Upload.LIST_IGNORE;
139
+ }
140
+
141
+ const mimetypeRule = storageRules?.mimetype;
142
+ const normalizedRule = Array.isArray(mimetypeRule)
143
+ ? mimetypeRule.join(',').trim()
144
+ : `${mimetypeRule || ''}`.trim();
145
+ const acceptRule = accept?.trim();
146
+ const rules = [normalizedRule, acceptRule].filter((rule) => rule && rule !== '*');
147
+
148
+ if (!rules.length) {
149
+ return file;
150
+ }
151
+
152
+ // 有些浏览器对不常见扩展名给不出 type,这里按扩展名补一次再判断。
153
+ let targetFile = file;
154
+ if (!targetFile.type) {
155
+ const extname = targetFile.name?.match(/\.[^.]+$/)?.[0];
156
+ if (extname) {
157
+ targetFile = new File([targetFile], targetFile.name, {
158
+ type: mime.getType(extname) || 'application/octet-stream',
159
+ lastModified: targetFile.lastModified,
160
+ }) as RcFile;
161
+ }
162
+ }
163
+
164
+ const isAllowed = rules.every((rule) =>
165
+ rule
166
+ .split(',')
167
+ .filter(Boolean)
168
+ .some((item) => match(targetFile.type)(item.trim())),
169
+ );
170
+
171
+ if (!isAllowed) {
172
+ message.error(t('File type is not allowed'));
173
+ return Upload.LIST_IGNORE;
174
+ }
175
+
176
+ return targetFile;
177
+ },
178
+ [accept, storageRules?.mimetype, storageRules?.size, t],
179
+ );
180
+
181
+ const handleUpload = useCallback(
182
+ async (options: UploadRequestOption) => {
183
+ const { file, onError, onSuccess } = options;
184
+ setUploading(true);
185
+
186
+ try {
187
+ const formData = new FormData();
188
+ formData.append('file', file as RcFile);
189
+
190
+ const response = await app.apiClient.request({
191
+ url: 'attachments:create',
192
+ method: 'post',
193
+ data: formData,
194
+ });
195
+
196
+ onChange?.(response?.data?.data || null);
197
+ onSuccess?.(response?.data, file as RcFile);
198
+ } catch (error) {
199
+ onError?.(error as Error);
200
+ } finally {
201
+ setUploading(false);
202
+ }
203
+ },
204
+ [app.apiClient, onChange],
205
+ );
206
+
207
+ const fileList = useMemo<UploadFile[]>(() => {
208
+ if (!value?.url) {
209
+ return [];
210
+ }
211
+
212
+ return [
213
+ {
214
+ uid: `${value.id ?? value.url}`,
215
+ name: value.title || value.filename || '',
216
+ status: 'done',
217
+ url: value.url,
218
+ thumbUrl: value.url,
219
+ },
220
+ ];
221
+ }, [value?.filename, value?.id, value?.title, value?.url]);
222
+
223
+ const handlePreview = useCallback((file: UploadFile) => {
224
+ if (file.url) {
225
+ window.open(file.url, '_blank', 'noopener,noreferrer');
226
+ }
227
+ }, []);
228
+
229
+ const handleRemove = useCallback(() => {
230
+ onChange?.(null);
231
+ return true;
232
+ }, [onChange]);
233
+
234
+ // picture-card 的卡片尺寸写死在 104×104,这里按调用方要的比例覆盖掉。
235
+ const sizeClassName = useMemo(
236
+ () => css`
237
+ .ant-upload.ant-upload-select,
238
+ .ant-upload-list-item-container {
239
+ width: ${previewConfig.width}px !important;
240
+ height: ${previewConfig.height}px !important;
241
+ }
242
+
243
+ .ant-upload-list-item-thumbnail img {
244
+ object-fit: ${previewConfig.fit} !important;
245
+ }
246
+ `,
247
+ [previewConfig.fit, previewConfig.height, previewConfig.width],
248
+ );
249
+
250
+ return (
251
+ <Upload
252
+ accept={acceptValue}
253
+ beforeUpload={beforeUpload}
254
+ className={cx('nb-attachment-upload', sizeClassName)}
255
+ customRequest={handleUpload}
256
+ // 上传期间一并禁用:否则可以在第一个请求返回前再选一个文件,
257
+ // 两个请求谁后返回谁覆盖 onChange,最终留下的不一定是用户最后选的那张。
258
+ disabled={disabled || uploading}
259
+ fileList={fileList}
260
+ listType="picture-card"
261
+ maxCount={1}
262
+ onPreview={handlePreview}
263
+ onRemove={handleRemove}
264
+ >
265
+ {fileList.length ? null : (
266
+ <div>
267
+ {uploading ? <LoadingOutlined /> : <PlusOutlined />}
268
+ <div style={{ marginTop: 8 }}>{uploadText || t('Upload')}</div>
269
+ </div>
270
+ )}
271
+ </Upload>
272
+ );
273
+ };
274
+
275
+ export default AttachmentUpload;
@@ -11,7 +11,7 @@ import { FileImageOutlined, LeftOutlined } from '@ant-design/icons';
11
11
  import { css } from '@emotion/css';
12
12
  import { Button, message, theme } from 'antd';
13
13
  import { Html5Qrcode } from 'html5-qrcode';
14
- import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
14
+ import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
15
15
  import ReactDOM from 'react-dom';
16
16
  import { useTranslation } from 'react-i18next';
17
17
  import { ScanBox } from './ScanBox';
@@ -26,6 +26,14 @@ type CodeScannerProps = {
26
26
  };
27
27
 
28
28
  const MAX_CODE_IMAGE_SIZE = 10 * 1024 * 1024;
29
+ const SCANNER_RENDER_WIDTH = 1280;
30
+
31
+ function getViewportSize() {
32
+ return {
33
+ width: Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0),
34
+ height: Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0),
35
+ };
36
+ }
29
37
 
30
38
  function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess }: CodeScannerProps) {
31
39
  const { t } = useTranslation();
@@ -34,16 +42,15 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
34
42
  const scannerElementId = `code-scanner-${inputId}`;
35
43
  const imgUploaderRef = useRef<HTMLInputElement>(null);
36
44
  const [cameraAvailable, setCameraAvailable] = useState(false);
45
+ const [scannerSize, setScannerSize] = useState({ width: 0, height: 0 });
46
+ const [viewport, setViewport] = useState(getViewportSize);
37
47
 
38
- const viewport = useMemo(() => {
39
- const width = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
40
- const height = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
41
- return { width, height };
42
- }, []);
43
- const scanBoxSize = useMemo(
44
- () => getCodeScanBoxSize(viewport.width, viewport.height),
45
- [viewport.height, viewport.width],
46
- );
48
+ const previewScale = scannerSize.width
49
+ ? Math.max(viewport.width / scannerSize.width, viewport.height / scannerSize.height)
50
+ : Math.min(1, viewport.width / SCANNER_RENDER_WIDTH);
51
+ const visibleScanBoxSize = scannerSize.width
52
+ ? getCodeScanBoxSize(viewport.width, viewport.height)
53
+ : { width: 0, height: 0 };
47
54
 
48
55
  const showScanFailure = useCallback(() => {
49
56
  message.error(t('Code recognition failed, please scan again'));
@@ -76,12 +83,18 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
76
83
  enabled: visible && cameraAvailable,
77
84
  elementId: scannerElementId,
78
85
  formatsToSupport,
79
- scanBoxSize,
86
+ onScannerSizeChanged: setScannerSize,
80
87
  onScanSuccess: handleScanSuccess,
81
88
  onScanFailure: showScanFailure,
82
89
  onCameraStartFailure: showCameraStartFailure,
83
90
  });
84
91
 
92
+ useEffect(() => {
93
+ const handleResize = () => setViewport(getViewportSize());
94
+ window.addEventListener('resize', handleResize);
95
+ return () => window.removeEventListener('resize', handleResize);
96
+ }, []);
97
+
85
98
  useEffect(() => {
86
99
  if (!visible) {
87
100
  return;
@@ -152,18 +165,22 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
152
165
  inset: 0;
153
166
  overflow: hidden;
154
167
  `;
168
+ const scannerSurfaceClass = css`
169
+ position: absolute;
170
+ top: 50%;
171
+ left: 50%;
172
+ transform-origin: center;
173
+ `;
155
174
  const scannerClass = css`
156
175
  position: relative;
157
176
  width: 100%;
158
- height: 100%;
159
- overflow: hidden;
177
+ line-height: 0;
160
178
 
161
179
  video {
162
- position: absolute !important;
163
- inset: 0 !important;
164
180
  width: 100% !important;
165
- height: 100% !important;
166
- object-fit: cover !important;
181
+ height: auto !important;
182
+ max-width: none !important;
183
+ display: block !important;
167
184
  }
168
185
 
169
186
  #qr-shaded-region {
@@ -191,26 +208,36 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
191
208
  return (
192
209
  <div className={rootClass}>
193
210
  <div className={scannerWrapperClass}>
194
- <div id={scannerElementId} className={scannerClass} />
211
+ <div
212
+ className={scannerSurfaceClass}
213
+ style={{
214
+ width: `${SCANNER_RENDER_WIDTH}px`,
215
+ transform: `translate(-50%, -50%) scale(${previewScale})`,
216
+ }}
217
+ >
218
+ <div id={scannerElementId} className={scannerClass} />
219
+ </div>
195
220
  </div>
221
+ {cameraAvailable && scannerSize.width > 0 && (
222
+ <ScanBox
223
+ style={{
224
+ position: 'fixed',
225
+ top: `${(viewport.height - visibleScanBoxSize.height) / 2}px`,
226
+ left: `${(viewport.width - visibleScanBoxSize.width) / 2}px`,
227
+ width: `${visibleScanBoxSize.width}px`,
228
+ height: `${visibleScanBoxSize.height}px`,
229
+ }}
230
+ />
231
+ )}
232
+ <Button
233
+ aria-label={t('Close')}
234
+ className={closeButtonClass}
235
+ icon={<LeftOutlined />}
236
+ type="text"
237
+ onClick={onClose}
238
+ />
196
239
  {cameraAvailable && (
197
240
  <>
198
- <ScanBox
199
- style={{
200
- position: 'fixed',
201
- top: `${(viewport.height - scanBoxSize.height) / 2}px`,
202
- left: `${(viewport.width - scanBoxSize.width) / 2}px`,
203
- width: `${scanBoxSize.width}px`,
204
- height: `${scanBoxSize.height}px`,
205
- }}
206
- />
207
- <Button
208
- aria-label={t('Close')}
209
- className={closeButtonClass}
210
- icon={<LeftOutlined />}
211
- type="text"
212
- onClick={onClose}
213
- />
214
241
  <Button className={albumClass} icon={<FileImageOutlined />} type="text" onClick={handleImageButtonClick}>
215
242
  {t('Album')}
216
243
  </Button>
@@ -0,0 +1,101 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { act, render, waitFor } from '@testing-library/react';
11
+ import React from 'react';
12
+ import { afterEach, describe, expect, it, vi } from 'vitest';
13
+ import { CodeScanner } from '../CodeScanner';
14
+
15
+ const mocks = vi.hoisted(() => ({
16
+ getCameras: vi.fn().mockResolvedValue([{ id: 'rear-camera' }]),
17
+ }));
18
+
19
+ vi.mock('html5-qrcode', () => ({
20
+ Html5Qrcode: {
21
+ getCameras: mocks.getCameras,
22
+ },
23
+ Html5QrcodeScannerState: {
24
+ PAUSED: 3,
25
+ SCANNING: 2,
26
+ },
27
+ Html5QrcodeSupportedFormats: {
28
+ CODABAR: 2,
29
+ CODE_128: 5,
30
+ CODE_39: 3,
31
+ CODE_93: 4,
32
+ DATA_MATRIX: 6,
33
+ EAN_13: 9,
34
+ EAN_8: 10,
35
+ ITF: 8,
36
+ PDF_417: 11,
37
+ QR_CODE: 0,
38
+ UPC_A: 14,
39
+ UPC_E: 15,
40
+ },
41
+ }));
42
+
43
+ vi.mock('react-i18next', () => ({
44
+ useTranslation: () => ({ t: (key: string) => key }),
45
+ }));
46
+
47
+ vi.mock('../useCodeScanner', async (importOriginal) => {
48
+ const actual = await importOriginal<typeof import('../useCodeScanner')>();
49
+ const ReactModule = await import('react');
50
+
51
+ return {
52
+ ...actual,
53
+ useCodeScanner: ({
54
+ enabled,
55
+ onScannerSizeChanged,
56
+ }: {
57
+ enabled: boolean;
58
+ onScannerSizeChanged?: (size: { width: number; height: number }) => void;
59
+ }) => {
60
+ ReactModule.useEffect(() => {
61
+ if (enabled) {
62
+ onScannerSizeChanged?.({ width: 1280, height: 720 });
63
+ }
64
+ }, [enabled, onScannerSizeChanged]);
65
+
66
+ return { startScanFile: vi.fn() };
67
+ },
68
+ };
69
+ });
70
+
71
+ describe('CodeScanner', () => {
72
+ afterEach(() => {
73
+ vi.restoreAllMocks();
74
+ });
75
+
76
+ it('keeps the camera preview covering portrait and landscape viewports', async () => {
77
+ let viewportWidth = 390;
78
+ let viewportHeight = 844;
79
+ vi.spyOn(window, 'innerWidth', 'get').mockImplementation(() => viewportWidth);
80
+ vi.spyOn(window, 'innerHeight', 'get').mockImplementation(() => viewportHeight);
81
+
82
+ render(<CodeScanner visible onClose={() => undefined} onScanSuccess={() => undefined} />);
83
+
84
+ await waitFor(() => expect(document.getElementById('code-scan-box')).toBeInTheDocument());
85
+
86
+ const scannerElement = document.querySelector<HTMLElement>('[id^="code-scanner-"]');
87
+ const scannerSurface = scannerElement?.parentElement;
88
+ const scanBox = document.getElementById('code-scan-box');
89
+ expect(scannerSurface?.style.transform).toBe(`translate(-50%, -50%) scale(${844 / 720})`);
90
+ expect(scanBox).toHaveStyle({ width: '351px', height: '540px' });
91
+
92
+ viewportWidth = 844;
93
+ viewportHeight = 390;
94
+ act(() => window.dispatchEvent(new Event('resize')));
95
+
96
+ await waitFor(() => {
97
+ expect(scannerSurface?.style.transform).toBe(`translate(-50%, -50%) scale(${844 / 1280})`);
98
+ expect(scanBox).toHaveStyle({ width: '759px', height: '273px' });
99
+ });
100
+ });
101
+ });