@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.
- package/es/APIClient.d.ts +12 -0
- package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
- package/es/flow/actions/linkageRules.d.ts +25 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
- package/es/flow/components/FlowRoute.d.ts +2 -2
- package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
- package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
- package/es/flow/components/field-value-variable/index.d.ts +11 -0
- package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
- package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
- package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
- package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
- package/es/flow/models/base/ActionModelCore.d.ts +4 -2
- package/es/flow/models/base/BlockGridModel.d.ts +3 -0
- package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
- package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
- package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
- package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
- package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
- package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
- package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
- package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
- package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
- package/es/flow/models/blocks/table/TableColumnModel.d.ts +12 -0
- package/es/flow/models/blocks/table/utils.d.ts +1 -0
- package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
- package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
- package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
- package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.d.ts +1 -0
- package/es/index.mjs +147 -144
- package/es/ui-operation/index.d.ts +15 -0
- package/es/ui-operation/ui-operation-codec.d.ts +10 -0
- package/lib/index.js +170 -167
- package/lib/locale/languageCodes.js +1 -0
- package/package.json +8 -7
- package/src/APIClient.ts +92 -0
- package/src/Application.tsx +3 -1
- package/src/__tests__/APIClient.test.tsx +58 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
- package/src/__tests__/settings-center.test.tsx +270 -6
- package/src/acl/ACLProvider.tsx +2 -0
- package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
- package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
- package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
- package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
- package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
- package/src/components/form/table/Table.tsx +81 -6
- package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
- package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
- package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
- package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
- package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
- package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
- package/src/flow/actions/linkageRules.tsx +59 -16
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
- package/src/flow/common/Markdown/style.ts +4 -0
- package/src/flow/components/BlockItemCard.tsx +2 -2
- package/src/flow/components/FieldAssignValueInput.tsx +38 -621
- package/src/flow/components/FlowRoute.tsx +71 -29
- package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
- package/src/flow/components/field-value-variable/dateValue.ts +223 -0
- package/src/flow/components/field-value-variable/index.ts +12 -0
- package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
- package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
- package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
- package/src/flow/index.ts +2 -0
- package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
- package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
- package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
- package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
- package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
- package/src/flow/models/base/ActionModelCore.tsx +11 -2
- package/src/flow/models/base/BlockGridModel.tsx +26 -0
- package/src/flow/models/base/CollectionBlockModel.tsx +38 -3
- package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
- package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
- package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
- package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
- package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
- package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
- package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
- package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
- package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
- package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
- package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
- package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
- package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
- package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
- package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
- package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
- package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
- package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +28 -2
- package/src/flow/models/blocks/table/TableColumnModel.tsx +48 -9
- package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
- package/src/flow/models/blocks/table/utils.ts +7 -0
- package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
- package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
- package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
- package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
- package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
- package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
- package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
- package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
- package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
- package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
- package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
- package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
- package/src/index.ts +1 -0
- package/src/layout-manager/LayoutContentRoute.tsx +2 -1
- package/src/layout-manager/LayoutRoute.tsx +2 -1
- package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
- package/src/locale/languageCodes.ts +1 -0
- package/src/settings-center/AdminSettingsLayout.tsx +23 -2
- package/src/ui-operation/index.ts +33 -0
- 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,
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
166
|
-
|
|
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
|
|
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?.(
|
|
192
|
-
expect(getCodeScanBoxSize(
|
|
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('
|
|
196
|
-
render(<ScannerHost
|
|
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
|
-
| {
|
|
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?.(
|
|
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('
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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' });
|