@nocobase/client-v2 2.3.0-alpha.1 → 2.3.0-beta.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 (148) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
  3. package/es/flow/actions/linkageRules.d.ts +25 -0
  4. package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
  5. package/es/flow/components/FlowRoute.d.ts +2 -2
  6. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  7. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
  8. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  9. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  10. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  11. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  12. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  13. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  14. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  15. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  16. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  17. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  18. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  19. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  20. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  21. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  22. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  23. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  24. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  25. package/es/flow/models/blocks/table/TableColumnModel.d.ts +12 -0
  26. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  27. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  28. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  29. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  30. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  31. package/es/flow-compat/passwordUtils.d.ts +1 -1
  32. package/es/index.d.ts +1 -0
  33. package/es/index.mjs +147 -144
  34. package/es/ui-operation/index.d.ts +15 -0
  35. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  36. package/lib/index.js +170 -167
  37. package/lib/locale/languageCodes.js +1 -0
  38. package/package.json +8 -7
  39. package/src/APIClient.ts +92 -0
  40. package/src/Application.tsx +3 -1
  41. package/src/__tests__/APIClient.test.tsx +58 -0
  42. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
  43. package/src/__tests__/settings-center.test.tsx +270 -6
  44. package/src/acl/ACLProvider.tsx +2 -0
  45. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  46. package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
  47. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  48. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
  49. package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
  50. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  51. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  52. package/src/components/form/table/Table.tsx +81 -6
  53. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  54. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  55. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  56. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  57. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  58. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  59. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  60. package/src/flow/actions/linkageRules.tsx +59 -16
  61. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  62. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  63. package/src/flow/common/Markdown/style.ts +4 -0
  64. package/src/flow/components/BlockItemCard.tsx +2 -2
  65. package/src/flow/components/FieldAssignValueInput.tsx +38 -621
  66. package/src/flow/components/FlowRoute.tsx +71 -29
  67. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  68. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  69. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  70. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  71. package/src/flow/components/field-value-variable/index.ts +12 -0
  72. package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
  73. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  74. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  75. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  76. package/src/flow/index.ts +2 -0
  77. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  78. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  79. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  80. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  81. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  82. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  83. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  84. package/src/flow/models/base/CollectionBlockModel.tsx +38 -3
  85. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  86. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  87. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  88. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  89. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  90. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  91. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  92. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  93. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  94. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  95. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  96. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  97. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  98. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  99. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  100. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  101. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  102. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  103. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  104. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  105. package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
  106. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  107. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  108. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  109. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  110. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  111. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  112. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  113. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  114. package/src/flow/models/blocks/table/TableBlockModel.tsx +28 -2
  115. package/src/flow/models/blocks/table/TableColumnModel.tsx +48 -9
  116. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  117. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  118. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  119. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  120. package/src/flow/models/blocks/table/utils.ts +7 -0
  121. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  122. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  123. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  124. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  125. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  126. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  127. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
  128. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  129. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  130. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  131. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  132. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  133. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  134. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  135. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  136. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  137. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  138. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  139. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  140. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  141. package/src/index.ts +1 -0
  142. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  143. package/src/layout-manager/LayoutRoute.tsx +2 -1
  144. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  145. package/src/locale/languageCodes.ts +1 -0
  146. package/src/settings-center/AdminSettingsLayout.tsx +23 -2
  147. package/src/ui-operation/index.ts +33 -0
  148. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -0,0 +1,92 @@
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 { render, screen, waitFor } from '@testing-library/react';
11
+ import React from 'react';
12
+ import { MemoryRouter } from 'react-router-dom';
13
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import {
15
+ FLOW_SETTINGS_PREFERENCE_STORAGE_KEY,
16
+ writeFlowSettingsPreference,
17
+ } from '../../flow/admin-shell/admin-layout/flowSettingsPreference';
18
+ import { ACLRolesCheckProvider } from '../ACLProvider';
19
+
20
+ const mocks = vi.hoisted(() => ({
21
+ app: undefined as unknown,
22
+ }));
23
+
24
+ vi.mock('../../hooks/useApp', () => ({
25
+ useApp: () => mocks.app,
26
+ }));
27
+
28
+ describe('ACLRolesCheckProvider', () => {
29
+ beforeEach(() => {
30
+ window.localStorage.removeItem(FLOW_SETTINGS_PREFERENCE_STORAGE_KEY);
31
+ });
32
+
33
+ it('exits UI editing mode when the current role cannot configure pages', async () => {
34
+ const disable = vi.fn(async () => undefined);
35
+ const setRole = vi.fn();
36
+ const aclContext: Record<string, unknown> = {};
37
+ const appContext = {
38
+ acl: undefined,
39
+ defineProperty(name: string, descriptor: PropertyDescriptor) {
40
+ Object.defineProperty(this, name, descriptor);
41
+ },
42
+ ...aclContext,
43
+ };
44
+
45
+ mocks.app = {
46
+ apiClient: {
47
+ auth: {
48
+ role: 'member',
49
+ setRole,
50
+ },
51
+ request: vi.fn(async () => ({
52
+ data: {
53
+ data: {
54
+ role: 'viewer',
55
+ snippets: [],
56
+ },
57
+ meta: {},
58
+ },
59
+ })),
60
+ },
61
+ context: appContext,
62
+ flowEngine: {
63
+ flowSettings: {
64
+ disable,
65
+ },
66
+ },
67
+ pluginSettingsManager: {
68
+ setAclSnippets: vi.fn(),
69
+ },
70
+ renderComponent: () => <div>Loading</div>,
71
+ router: {
72
+ isSkippedAuthCheckRoute: () => false,
73
+ },
74
+ };
75
+ writeFlowSettingsPreference(true);
76
+
77
+ render(
78
+ <MemoryRouter initialEntries={['/admin']}>
79
+ <ACLRolesCheckProvider>
80
+ <div>Page content</div>
81
+ </ACLRolesCheckProvider>
82
+ </MemoryRouter>,
83
+ );
84
+
85
+ expect(await screen.findByText('Page content')).toBeInTheDocument();
86
+ await waitFor(() => {
87
+ expect(disable).toHaveBeenCalledTimes(1);
88
+ });
89
+ expect(window.localStorage.getItem(FLOW_SETTINGS_PREFERENCE_STORAGE_KEY)).toBe('0');
90
+ expect(setRole).toHaveBeenCalledWith('viewer');
91
+ });
92
+ });
@@ -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
+ });
@@ -7,13 +7,15 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
10
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
11
11
  import React, { useCallback } from 'react';
12
12
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
- import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, useCodeScanner } from '../useCodeScanner';
13
+ import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, scanQrVideoFrame, useCodeScanner } from '../useCodeScanner';
14
14
 
15
15
  type MockScannerInstance = {
16
+ applyVideoConstraints: ReturnType<typeof vi.fn>;
16
17
  clear: ReturnType<typeof vi.fn>;
18
+ getRunningTrackCapabilities: ReturnType<typeof vi.fn>;
17
19
  getState: ReturnType<typeof vi.fn>;
18
20
  scanFileV2: ReturnType<typeof vi.fn>;
19
21
  start: ReturnType<typeof vi.fn>;
@@ -24,18 +26,24 @@ const mocks = vi.hoisted(() => {
24
26
  const start = vi.fn().mockResolvedValue(null);
25
27
  const stop = vi.fn().mockResolvedValue(null);
26
28
  const clear = vi.fn();
29
+ const applyVideoConstraints = vi.fn().mockResolvedValue(undefined);
30
+ const getRunningTrackCapabilities = vi.fn(() => ({}));
27
31
  const getState = vi.fn(() => 1);
28
32
  const scanFileV2 = vi.fn();
29
33
  const Html5Qrcode = vi.fn(function MockHtml5Qrcode(this: MockScannerInstance) {
30
34
  this.start = start;
31
35
  this.stop = stop;
32
36
  this.clear = clear;
37
+ this.applyVideoConstraints = applyVideoConstraints;
38
+ this.getRunningTrackCapabilities = getRunningTrackCapabilities;
33
39
  this.getState = getState;
34
40
  this.scanFileV2 = scanFileV2;
35
41
  });
36
42
 
37
43
  return {
44
+ applyVideoConstraints,
38
45
  clear,
46
+ getRunningTrackCapabilities,
39
47
  getState,
40
48
  Html5Qrcode,
41
49
  scanFileV2,
@@ -77,18 +85,15 @@ vi.mock('jsqr', () => jsQrMocks);
77
85
  function ScannerHost({
78
86
  onCameraStartFailure,
79
87
  onScanFailure,
80
- scanBoxSize,
81
88
  }: {
82
89
  onCameraStartFailure?: (error: unknown) => void;
83
90
  onScanFailure?: () => void;
84
- scanBoxSize?: { width: number; height: number };
85
91
  } = {}) {
86
92
  const handleScanSuccess = useCallback(() => undefined, []);
87
93
 
88
94
  useCodeScanner({
89
95
  elementId: 'scanner',
90
96
  enabled: true,
91
- scanBoxSize,
92
97
  onCameraStartFailure,
93
98
  onScanFailure,
94
99
  onScanSuccess: handleScanSuccess,
@@ -159,6 +164,11 @@ function stubCanvas() {
159
164
  describe('useCodeScanner', () => {
160
165
  beforeEach(() => {
161
166
  vi.clearAllMocks();
167
+ mocks.applyVideoConstraints.mockResolvedValue(undefined);
168
+ mocks.getRunningTrackCapabilities.mockReturnValue({});
169
+ mocks.start.mockResolvedValue(null);
170
+ mocks.stop.mockResolvedValue(null);
171
+ mocks.getState.mockReturnValue(1);
162
172
  jsQrMocks.default.mockReturnValue(undefined);
163
173
  });
164
174
 
@@ -188,32 +198,73 @@ describe('useCodeScanner', () => {
188
198
  | { qrbox?: (width: number, height: number) => { width: number; height: number } }
189
199
  | undefined;
190
200
 
191
- expect(config?.qrbox?.(390, 844)).toEqual({ width: 319, height: 240 });
192
- expect(getCodeScanBoxSize(390, 844)).toEqual({ width: 319, height: 240 });
201
+ expect(config?.qrbox?.(1280, 720)).toEqual({ width: 1152, height: 504 });
202
+ expect(getCodeScanBoxSize(1280, 720)).toEqual({ width: 1152, height: 504 });
193
203
  });
194
204
 
195
- it('can use the visible viewport scan box when the camera video is wider than the viewport', async () => {
196
- render(<ScannerHost scanBoxSize={{ width: 319, height: 240 }} />);
205
+ it('uses optimized camera constraints without requiring a user-selected scan mode', async () => {
206
+ render(<ScannerHost />);
197
207
 
198
208
  await waitFor(() => expect(mocks.start).toHaveBeenCalled());
199
209
 
200
210
  const config = mocks.start.mock.calls[0]?.[1] as
201
- | { qrbox?: (width: number, height: number) => { width: number; height: number } }
211
+ | {
212
+ disableFlip?: boolean;
213
+ fps?: number;
214
+ qrbox?: (width: number, height: number) => { width: number; height: number };
215
+ videoConstraints?: MediaTrackConstraints;
216
+ }
202
217
  | undefined;
203
218
 
204
- expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
219
+ expect(config?.qrbox?.(1280, 720)).toEqual({ width: 1152, height: 504 });
220
+ expect(config).toMatchObject({
221
+ disableFlip: false,
222
+ fps: 8,
223
+ videoConstraints: {
224
+ facingMode: { ideal: 'environment' },
225
+ width: { ideal: 1920 },
226
+ height: { ideal: 1080 },
227
+ frameRate: { ideal: 30 },
228
+ },
229
+ });
230
+ expect(mocks.Html5Qrcode).toHaveBeenCalledWith('scanner', {
231
+ formatsToSupport: DEFAULT_CODE_FORMATS,
232
+ verbose: false,
233
+ });
205
234
  });
206
235
 
207
- it('clamps the scan box to the camera viewfinder size', async () => {
208
- render(<ScannerHost scanBoxSize={{ width: 319, height: 240 }} />);
236
+ it('decodes a centered QR code from the raw camera frame fast path', () => {
237
+ jsQrMocks.default.mockReturnValueOnce({ data: 'FAST-QR' });
238
+ stubCanvas();
239
+ const video = document.createElement('video');
240
+ Object.defineProperties(video, {
241
+ clientHeight: { value: 720 },
242
+ clientWidth: { value: 1280 },
243
+ readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
244
+ videoHeight: { value: 1080 },
245
+ videoWidth: { value: 1920 },
246
+ });
247
+ const canvas = document.createElement('canvas');
248
+
249
+ expect(scanQrVideoFrame(video, canvas)).toBe('FAST-QR');
209
250
 
210
- await waitFor(() => expect(mocks.start).toHaveBeenCalled());
251
+ const context = canvas.getContext('2d');
252
+ expect(context?.drawImage).toHaveBeenCalledWith(video, 96, 162, 1728, 756, 0, 0, 960, 420);
253
+ expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 960, 420, {
254
+ inversionAttempts: 'dontInvert',
255
+ });
256
+ });
211
257
 
212
- const config = mocks.start.mock.calls[0]?.[1] as
213
- | { qrbox?: (width: number, height: number) => { width: number; height: number } }
214
- | undefined;
258
+ it('enables continuous camera focus when the device supports it', async () => {
259
+ mocks.getRunningTrackCapabilities.mockReturnValue({ focusMode: ['manual', 'continuous'] });
260
+
261
+ render(<ScannerHost />);
215
262
 
216
- expect(config?.qrbox?.(200, 160)).toEqual({ width: 200, height: 160 });
263
+ await waitFor(() =>
264
+ expect(mocks.applyVideoConstraints).toHaveBeenCalledWith({
265
+ advanced: [{ focusMode: 'continuous' }],
266
+ }),
267
+ );
217
268
  });
218
269
 
219
270
  it('reports camera start failures through the dedicated handler', async () => {
@@ -228,6 +279,51 @@ describe('useCodeScanner', () => {
228
279
  expect(handleScanFailure).not.toHaveBeenCalled();
229
280
  });
230
281
 
282
+ it('stops a camera that finishes starting after the scanner unmounts', async () => {
283
+ let resolveStart: ((value: null) => void) | undefined;
284
+ mocks.start.mockReturnValueOnce(
285
+ new Promise<null>((resolve) => {
286
+ resolveStart = resolve;
287
+ }),
288
+ );
289
+ mocks.getState.mockReturnValueOnce(1).mockReturnValue(2);
290
+
291
+ const { unmount } = render(<ScannerHost />);
292
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
293
+
294
+ unmount();
295
+ await act(async () => {
296
+ resolveStart?.(null);
297
+ });
298
+
299
+ await waitFor(() => expect(mocks.stop).toHaveBeenCalledTimes(1));
300
+ expect(mocks.clear).toHaveBeenCalled();
301
+ });
302
+
303
+ it('ignores a camera start failure after the scanner unmounts', async () => {
304
+ let rejectStart: ((error: Error) => void) | undefined;
305
+ mocks.start.mockReturnValueOnce(
306
+ new Promise<null>((_resolve, reject) => {
307
+ rejectStart = reject;
308
+ }),
309
+ );
310
+ const handleCameraStartFailure = vi.fn();
311
+ const handleScanFailure = vi.fn();
312
+
313
+ const { unmount } = render(
314
+ <ScannerHost onCameraStartFailure={handleCameraStartFailure} onScanFailure={handleScanFailure} />,
315
+ );
316
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
317
+
318
+ unmount();
319
+ await act(async () => {
320
+ rejectStart?.(new Error('Camera start canceled'));
321
+ });
322
+
323
+ expect(handleCameraStartFailure).not.toHaveBeenCalled();
324
+ expect(handleScanFailure).not.toHaveBeenCalled();
325
+ });
326
+
231
327
  it('uses jsQR first for Safari uploaded QR images', async () => {
232
328
  const handleScanSuccess = vi.fn();
233
329
  jsQrMocks.default.mockReturnValueOnce({ data: 'JSQR-CODE' });