@nocobase/client-v2 2.1.0-beta.47 → 2.1.0-beta.48
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/components/form/ScanInput/CodeScanner.d.ts +18 -0
- package/es/components/form/ScanInput/ScanBox.d.ts +12 -0
- package/es/components/form/ScanInput/ScanInput.d.ts +18 -0
- package/es/components/form/ScanInput/index.d.ts +11 -0
- package/es/components/form/ScanInput/types.d.ts +10 -0
- package/es/components/form/ScanInput/useCodeScanner.d.ts +31 -0
- package/es/components/form/index.d.ts +1 -0
- package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +14 -0
- package/es/index.mjs +76 -36
- package/lib/index.js +111 -71
- package/package.json +8 -7
- package/src/components/form/ScanInput/CodeScanner.tsx +219 -0
- package/src/components/form/ScanInput/ScanBox.tsx +30 -0
- package/src/components/form/ScanInput/ScanInput.tsx +150 -0
- package/src/components/form/ScanInput/__tests__/ScanInput.test.tsx +119 -0
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +123 -0
- package/src/components/form/ScanInput/index.ts +12 -0
- package/src/components/form/ScanInput/types.ts +12 -0
- package/src/components/form/ScanInput/useCodeScanner.ts +136 -0
- package/src/components/form/index.tsx +1 -0
- package/src/flow/actions/__tests__/actionLinkageRules.race.repro.test.ts +45 -0
- package/src/flow/actions/linkageRules.tsx +16 -4
- package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +111 -26
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +61 -0
- package/src/flow/models/fields/InputFieldModel.tsx +56 -1
- package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +116 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.1.0-beta.
|
|
3
|
+
"version": "2.1.0-beta.48",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "es/index.mjs",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"@formily/antd-v5": "1.2.3",
|
|
28
28
|
"@formily/react": "^2.2.27",
|
|
29
29
|
"@formily/shared": "^2.2.27",
|
|
30
|
-
"@nocobase/evaluators": "2.1.0-beta.
|
|
31
|
-
"@nocobase/flow-engine": "2.1.0-beta.
|
|
32
|
-
"@nocobase/sdk": "2.1.0-beta.
|
|
33
|
-
"@nocobase/shared": "2.1.0-beta.
|
|
34
|
-
"@nocobase/utils": "2.1.0-beta.
|
|
30
|
+
"@nocobase/evaluators": "2.1.0-beta.48",
|
|
31
|
+
"@nocobase/flow-engine": "2.1.0-beta.48",
|
|
32
|
+
"@nocobase/sdk": "2.1.0-beta.48",
|
|
33
|
+
"@nocobase/shared": "2.1.0-beta.48",
|
|
34
|
+
"@nocobase/utils": "2.1.0-beta.48",
|
|
35
35
|
"ahooks": "^3.7.2",
|
|
36
36
|
"antd": "5.24.2",
|
|
37
37
|
"antd-style": "3.7.1",
|
|
@@ -39,11 +39,12 @@
|
|
|
39
39
|
"classnames": "^2.3.1",
|
|
40
40
|
"dayjs": "^1.11.10",
|
|
41
41
|
"file-saver": "^2.0.5",
|
|
42
|
+
"html5-qrcode": "^2.3.8",
|
|
42
43
|
"i18next": "^22.4.9",
|
|
43
44
|
"json5": "^2.2.3",
|
|
44
45
|
"lodash": "4.17.21",
|
|
45
46
|
"react-i18next": "^11.15.1",
|
|
46
47
|
"react-router-dom": "^6.30.1"
|
|
47
48
|
},
|
|
48
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "f8c27a286db015c5e433b48241f14c0412e50530"
|
|
49
50
|
}
|
|
@@ -0,0 +1,219 @@
|
|
|
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 { FileImageOutlined, LeftOutlined } from '@ant-design/icons';
|
|
11
|
+
import { css } from '@emotion/css';
|
|
12
|
+
import { Button, message, theme } from 'antd';
|
|
13
|
+
import { Html5Qrcode } from 'html5-qrcode';
|
|
14
|
+
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
15
|
+
import ReactDOM from 'react-dom';
|
|
16
|
+
import { useTranslation } from 'react-i18next';
|
|
17
|
+
import { ScanBox } from './ScanBox';
|
|
18
|
+
import type { CodeFormatsToSupport } from './types';
|
|
19
|
+
import { getCodeScanBoxSize, useCodeScanner } from './useCodeScanner';
|
|
20
|
+
|
|
21
|
+
type CodeScannerProps = {
|
|
22
|
+
visible: boolean;
|
|
23
|
+
formatsToSupport?: CodeFormatsToSupport;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
onScanSuccess: (result: string) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const MAX_CODE_IMAGE_SIZE = 10 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess }: CodeScannerProps) {
|
|
31
|
+
const { t } = useTranslation();
|
|
32
|
+
const { token } = theme.useToken();
|
|
33
|
+
const inputId = useId().replace(/:/g, '');
|
|
34
|
+
const scannerElementId = `code-scanner-${inputId}`;
|
|
35
|
+
const imgUploaderRef = useRef<HTMLInputElement>(null);
|
|
36
|
+
const [cameraAvailable, setCameraAvailable] = useState(false);
|
|
37
|
+
|
|
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
|
+
);
|
|
47
|
+
|
|
48
|
+
const showScanFailure = useCallback(() => {
|
|
49
|
+
message.error(t('Code recognition failed, please scan again'));
|
|
50
|
+
}, [t]);
|
|
51
|
+
|
|
52
|
+
const handleScanSuccess = useCallback(
|
|
53
|
+
(text: string) => {
|
|
54
|
+
onScanSuccess(text);
|
|
55
|
+
onClose();
|
|
56
|
+
},
|
|
57
|
+
[onClose, onScanSuccess],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const { startScanFile } = useCodeScanner({
|
|
61
|
+
enabled: visible && cameraAvailable,
|
|
62
|
+
elementId: scannerElementId,
|
|
63
|
+
formatsToSupport,
|
|
64
|
+
scanBoxSize,
|
|
65
|
+
onScanSuccess: handleScanSuccess,
|
|
66
|
+
onScanFailure: showScanFailure,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!visible) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let canceled = false;
|
|
75
|
+
Html5Qrcode.getCameras()
|
|
76
|
+
.then((cameras) => {
|
|
77
|
+
if (canceled) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (cameras.length > 0) {
|
|
81
|
+
setCameraAvailable(true);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
message.error(t('No camera device detected'));
|
|
85
|
+
onClose();
|
|
86
|
+
})
|
|
87
|
+
.catch((error: Error) => {
|
|
88
|
+
if (canceled) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const errorMap: Record<string, string> = {
|
|
92
|
+
NotFoundError: t('No camera device detected'),
|
|
93
|
+
NotAllowedError: t('You have not granted permission to use the camera'),
|
|
94
|
+
};
|
|
95
|
+
message.error(errorMap[error.name] ?? error.message);
|
|
96
|
+
onClose();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
canceled = true;
|
|
101
|
+
};
|
|
102
|
+
}, [onClose, t, visible]);
|
|
103
|
+
|
|
104
|
+
if (!visible) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const handleImageButtonClick = () => {
|
|
109
|
+
imgUploaderRef.current?.click();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const handleImageUploaded = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
113
|
+
const file = event.target.files?.[0];
|
|
114
|
+
if (!file) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (file.size > MAX_CODE_IMAGE_SIZE) {
|
|
119
|
+
message.error(t('The image size is too large. Please compress it to below 10MB before uploading'));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
startScanFile(file).catch(showScanFailure);
|
|
124
|
+
event.target.value = '';
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const rootClass = css`
|
|
128
|
+
position: fixed;
|
|
129
|
+
inset: 0;
|
|
130
|
+
z-index: ${token.zIndexPopupBase + 1000};
|
|
131
|
+
overflow: hidden;
|
|
132
|
+
background: ${token.colorBgMask};
|
|
133
|
+
`;
|
|
134
|
+
const scannerWrapperClass = css`
|
|
135
|
+
position: absolute;
|
|
136
|
+
inset: 0;
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
`;
|
|
139
|
+
const scannerClass = css`
|
|
140
|
+
width: 100%;
|
|
141
|
+
height: 100%;
|
|
142
|
+
display: flex;
|
|
143
|
+
justify-content: center;
|
|
144
|
+
align-items: center;
|
|
145
|
+
|
|
146
|
+
video {
|
|
147
|
+
width: auto !important;
|
|
148
|
+
height: 100% !important;
|
|
149
|
+
max-width: none !important;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#qr-shaded-region {
|
|
153
|
+
display: none !important;
|
|
154
|
+
}
|
|
155
|
+
`;
|
|
156
|
+
const closeButtonClass = css`
|
|
157
|
+
position: absolute;
|
|
158
|
+
top: ${token.padding}px;
|
|
159
|
+
left: ${token.padding}px;
|
|
160
|
+
z-index: ${token.zIndexPopupBase + 1003};
|
|
161
|
+
color: ${token.colorTextLightSolid};
|
|
162
|
+
`;
|
|
163
|
+
const albumClass = css`
|
|
164
|
+
position: absolute;
|
|
165
|
+
bottom: ${token.paddingLG}px;
|
|
166
|
+
left: ${token.paddingLG}px;
|
|
167
|
+
z-index: ${token.zIndexPopupBase + 1003};
|
|
168
|
+
color: ${token.colorTextLightSolid};
|
|
169
|
+
`;
|
|
170
|
+
const hiddenInputClass = css`
|
|
171
|
+
display: none;
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className={rootClass}>
|
|
176
|
+
<div className={scannerWrapperClass}>
|
|
177
|
+
<div id={scannerElementId} className={scannerClass} />
|
|
178
|
+
</div>
|
|
179
|
+
{cameraAvailable && (
|
|
180
|
+
<>
|
|
181
|
+
<ScanBox
|
|
182
|
+
style={{
|
|
183
|
+
position: 'fixed',
|
|
184
|
+
top: `${(viewport.height - scanBoxSize.height) / 2}px`,
|
|
185
|
+
left: `${(viewport.width - scanBoxSize.width) / 2}px`,
|
|
186
|
+
width: `${scanBoxSize.width}px`,
|
|
187
|
+
height: `${scanBoxSize.height}px`,
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
<Button
|
|
191
|
+
aria-label={t('Close')}
|
|
192
|
+
className={closeButtonClass}
|
|
193
|
+
icon={<LeftOutlined />}
|
|
194
|
+
type="text"
|
|
195
|
+
onClick={onClose}
|
|
196
|
+
/>
|
|
197
|
+
<Button className={albumClass} icon={<FileImageOutlined />} type="text" onClick={handleImageButtonClick}>
|
|
198
|
+
{t('Album')}
|
|
199
|
+
</Button>
|
|
200
|
+
<input
|
|
201
|
+
ref={imgUploaderRef}
|
|
202
|
+
accept="image/*"
|
|
203
|
+
className={hiddenInputClass}
|
|
204
|
+
type="file"
|
|
205
|
+
onChange={handleImageUploaded}
|
|
206
|
+
/>
|
|
207
|
+
</>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function CodeScanner(props: CodeScannerProps) {
|
|
214
|
+
if (!props.visible) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return ReactDOM.createPortal(<CodeScannerContent {...props} />, document.body);
|
|
219
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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 React from 'react';
|
|
11
|
+
|
|
12
|
+
export function ScanBox({ style = {} }: { style?: React.CSSProperties }) {
|
|
13
|
+
const commonStyle: React.CSSProperties = {
|
|
14
|
+
position: 'absolute',
|
|
15
|
+
backgroundColor: 'rgb(255, 255, 255)',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div id="code-scan-box" style={{ boxSizing: 'border-box', inset: '0px', ...style }}>
|
|
20
|
+
<div style={{ width: '40px', height: '5px', top: '-5px', left: '0px', ...commonStyle }} />
|
|
21
|
+
<div style={{ width: '40px', height: '5px', top: '-5px', right: '0px', ...commonStyle }} />
|
|
22
|
+
<div style={{ width: '40px', height: '5px', bottom: '-5px', left: '0px', ...commonStyle }} />
|
|
23
|
+
<div style={{ width: '40px', height: '5px', bottom: '-5px', right: '0px', ...commonStyle }} />
|
|
24
|
+
<div style={{ width: '5px', height: '45px', top: '-5px', left: '-5px', ...commonStyle }} />
|
|
25
|
+
<div style={{ width: '5px', height: '45px', bottom: '-5px', left: '-5px', ...commonStyle }} />
|
|
26
|
+
<div style={{ width: '5px', height: '45px', top: '-5px', right: '-5px', ...commonStyle }} />
|
|
27
|
+
<div style={{ width: '5px', height: '45px', bottom: '-5px', right: '-5px', ...commonStyle }} />
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
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 { ScanOutlined } from '@ant-design/icons';
|
|
11
|
+
import { Button, Input, Tooltip } from 'antd';
|
|
12
|
+
import type { InputProps } from 'antd';
|
|
13
|
+
import type { InputRef } from 'antd/es/input';
|
|
14
|
+
import React, { useRef, useState } from 'react';
|
|
15
|
+
import { useTranslation } from 'react-i18next';
|
|
16
|
+
import { CodeScanner } from './CodeScanner';
|
|
17
|
+
import type { CodeFormatsToSupport } from './types';
|
|
18
|
+
|
|
19
|
+
export type ScanInputProps = Omit<InputProps, 'onChange'> & {
|
|
20
|
+
disableManualInput?: boolean;
|
|
21
|
+
enableScan?: boolean;
|
|
22
|
+
formatsToSupport?: CodeFormatsToSupport;
|
|
23
|
+
onChange?: (value: string | React.ChangeEvent<HTMLInputElement>) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type JsBridgeScanResult = string | { url?: string; text?: string; value?: string };
|
|
27
|
+
|
|
28
|
+
type JsBridge = {
|
|
29
|
+
invoke?: (params: { action: 'scan' }, callback: (data: JsBridgeScanResult) => void) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function getJsBridgeScanText(data: JsBridgeScanResult) {
|
|
33
|
+
if (typeof data === 'string') {
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
return data.url || data.text || data.value || '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ScanInput({
|
|
40
|
+
value,
|
|
41
|
+
onChange,
|
|
42
|
+
disabled,
|
|
43
|
+
disableManualInput,
|
|
44
|
+
enableScan: _enableScan,
|
|
45
|
+
formatsToSupport,
|
|
46
|
+
onFocus,
|
|
47
|
+
suffix,
|
|
48
|
+
...rest
|
|
49
|
+
}: ScanInputProps) {
|
|
50
|
+
const { t } = useTranslation();
|
|
51
|
+
const [scanVisible, setScanVisible] = useState(false);
|
|
52
|
+
const inputRef = useRef<InputRef>(null);
|
|
53
|
+
const suppressInputFocusRef = useRef(false);
|
|
54
|
+
|
|
55
|
+
const handleScanSuccess = (text: string) => {
|
|
56
|
+
if (!text) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
onChange?.(text);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
63
|
+
onChange?.(event);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const openScanner = () => {
|
|
67
|
+
if (disabled) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
suppressInputFocusRef.current = true;
|
|
71
|
+
inputRef.current?.blur();
|
|
72
|
+
const releaseInputFocusSuppression = () => {
|
|
73
|
+
window.setTimeout(() => {
|
|
74
|
+
suppressInputFocusRef.current = false;
|
|
75
|
+
}, 300);
|
|
76
|
+
};
|
|
77
|
+
const jsBridge = (window as Window & { JsBridge?: JsBridge }).JsBridge;
|
|
78
|
+
if (jsBridge?.invoke) {
|
|
79
|
+
jsBridge.invoke({ action: 'scan' }, (data) => {
|
|
80
|
+
handleScanSuccess(getJsBridgeScanText(data));
|
|
81
|
+
});
|
|
82
|
+
releaseInputFocusSuppression();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
setScanVisible(true);
|
|
86
|
+
releaseInputFocusSuppression();
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleInputFocus = (event: React.FocusEvent<HTMLInputElement>) => {
|
|
90
|
+
if (suppressInputFocusRef.current) {
|
|
91
|
+
inputRef.current?.blur();
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
onFocus?.(event);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const handleScanButtonPointerDownCapture = (event: React.PointerEvent<HTMLElement>) => {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
event.stopPropagation();
|
|
101
|
+
openScanner();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const handleScanButtonClick = (event: React.MouseEvent<HTMLElement>) => {
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
event.stopPropagation();
|
|
107
|
+
openScanner();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const scanButton = (
|
|
111
|
+
<Tooltip title={t('Scan to input')}>
|
|
112
|
+
<Button
|
|
113
|
+
aria-label={t('Scan to input')}
|
|
114
|
+
disabled={disabled}
|
|
115
|
+
htmlType="button"
|
|
116
|
+
icon={<ScanOutlined />}
|
|
117
|
+
size="small"
|
|
118
|
+
type="text"
|
|
119
|
+
onClick={handleScanButtonClick}
|
|
120
|
+
onPointerDownCapture={handleScanButtonPointerDownCapture}
|
|
121
|
+
/>
|
|
122
|
+
</Tooltip>
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<>
|
|
127
|
+
<Input
|
|
128
|
+
{...rest}
|
|
129
|
+
ref={inputRef}
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
readOnly={disableManualInput || rest.readOnly}
|
|
132
|
+
suffix={
|
|
133
|
+
<>
|
|
134
|
+
{suffix}
|
|
135
|
+
{scanButton}
|
|
136
|
+
</>
|
|
137
|
+
}
|
|
138
|
+
value={value}
|
|
139
|
+
onChange={handleInputChange}
|
|
140
|
+
onFocus={handleInputFocus}
|
|
141
|
+
/>
|
|
142
|
+
<CodeScanner
|
|
143
|
+
formatsToSupport={formatsToSupport}
|
|
144
|
+
visible={scanVisible}
|
|
145
|
+
onClose={() => setScanVisible(false)}
|
|
146
|
+
onScanSuccess={handleScanSuccess}
|
|
147
|
+
/>
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
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, fireEvent, render, screen } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { ScanInput } from '../ScanInput';
|
|
14
|
+
|
|
15
|
+
vi.mock('react-i18next', () => ({
|
|
16
|
+
useTranslation: () => ({ t: (key: string) => key }),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('../CodeScanner', () => ({
|
|
20
|
+
CodeScanner: ({ visible, onScanSuccess }: { visible: boolean; onScanSuccess: (result: string) => void }) =>
|
|
21
|
+
visible ? <button onClick={() => onScanSuccess('CODE-128')}>mock scan result</button> : null,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
describe('ScanInput', () => {
|
|
25
|
+
it('passes manual input changes through', () => {
|
|
26
|
+
const handleChange = vi.fn();
|
|
27
|
+
|
|
28
|
+
render(<ScanInput value="" onChange={handleChange} />);
|
|
29
|
+
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'manual-code' } });
|
|
30
|
+
|
|
31
|
+
expect(handleChange.mock.calls[0][0].target.value).toBe('manual-code');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('passes scanned text through as the field value', () => {
|
|
35
|
+
const handleChange = vi.fn();
|
|
36
|
+
|
|
37
|
+
render(<ScanInput value="" onChange={handleChange} />);
|
|
38
|
+
fireEvent.pointerDown(screen.getByRole('button', { name: 'Scan to input' }));
|
|
39
|
+
fireEvent.click(screen.getByText('mock scan result'));
|
|
40
|
+
|
|
41
|
+
expect(handleChange).toHaveBeenCalledWith('CODE-128');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('opens scanner on pointer down without focusing the input', () => {
|
|
45
|
+
const handleFocus = vi.fn();
|
|
46
|
+
|
|
47
|
+
render(<ScanInput value="" onChange={() => undefined} onFocus={handleFocus} />);
|
|
48
|
+
|
|
49
|
+
const input = screen.getByRole('textbox');
|
|
50
|
+
const button = screen.getByRole('button', { name: 'Scan to input' });
|
|
51
|
+
|
|
52
|
+
expect(button).not.toHaveAttribute('tabindex', '-1');
|
|
53
|
+
fireEvent.pointerDown(button);
|
|
54
|
+
|
|
55
|
+
act(() => {
|
|
56
|
+
input.focus();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(handleFocus).not.toHaveBeenCalled();
|
|
60
|
+
expect(screen.getByText('mock scan result')).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('opens scanner from the button click path', () => {
|
|
64
|
+
render(<ScanInput value="" onChange={() => undefined} />);
|
|
65
|
+
|
|
66
|
+
fireEvent.click(screen.getByRole('button', { name: 'Scan to input' }));
|
|
67
|
+
|
|
68
|
+
expect(screen.getByText('mock scan result')).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('uses the mobile native scan bridge when available', () => {
|
|
72
|
+
const testWindow = window as Window & {
|
|
73
|
+
JsBridge?: {
|
|
74
|
+
invoke: (params: { action: 'scan' }, callback: (data: { url: string }) => void) => void;
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
const originalJsBridge = testWindow.JsBridge;
|
|
78
|
+
const handleChange = vi.fn();
|
|
79
|
+
const invoke = vi.fn((params: { action: 'scan' }, callback: (data: { url: string }) => void) => {
|
|
80
|
+
callback({ url: 'BRIDGE-CODE' });
|
|
81
|
+
});
|
|
82
|
+
testWindow.JsBridge = { invoke };
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
render(<ScanInput value="" onChange={handleChange} />);
|
|
86
|
+
fireEvent.click(screen.getByRole('button', { name: 'Scan to input' }));
|
|
87
|
+
|
|
88
|
+
expect(invoke).toHaveBeenCalledWith({ action: 'scan' }, expect.any(Function));
|
|
89
|
+
expect(handleChange).toHaveBeenCalledWith('BRIDGE-CODE');
|
|
90
|
+
expect(screen.queryByText('mock scan result')).not.toBeInTheDocument();
|
|
91
|
+
} finally {
|
|
92
|
+
testWindow.JsBridge = originalJsBridge;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('does not call preventDefault from touchstart', () => {
|
|
97
|
+
render(<ScanInput value="" onChange={() => undefined} />);
|
|
98
|
+
|
|
99
|
+
const event = new Event('touchstart', { bubbles: true, cancelable: true });
|
|
100
|
+
const preventDefault = vi.spyOn(event, 'preventDefault');
|
|
101
|
+
|
|
102
|
+
screen.getByRole('button', { name: 'Scan to input' }).dispatchEvent(event);
|
|
103
|
+
|
|
104
|
+
expect(preventDefault).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('keeps scan available when manual input is disabled', () => {
|
|
108
|
+
render(<ScanInput disableManualInput value="" onChange={() => undefined} />);
|
|
109
|
+
|
|
110
|
+
expect(screen.getByRole('textbox')).toHaveAttribute('readonly');
|
|
111
|
+
expect(screen.getByRole('button', { name: 'Scan to input' })).not.toBeDisabled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('disables the scan button when the field is disabled', () => {
|
|
115
|
+
render(<ScanInput disabled value="" onChange={() => undefined} />);
|
|
116
|
+
|
|
117
|
+
expect(screen.getByRole('button', { name: 'Scan to input' })).toBeDisabled();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
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, waitFor } from '@testing-library/react';
|
|
11
|
+
import React, { useCallback } from 'react';
|
|
12
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, useCodeScanner } from '../useCodeScanner';
|
|
14
|
+
|
|
15
|
+
type MockScannerInstance = {
|
|
16
|
+
clear: ReturnType<typeof vi.fn>;
|
|
17
|
+
getState: ReturnType<typeof vi.fn>;
|
|
18
|
+
scanFileV2: ReturnType<typeof vi.fn>;
|
|
19
|
+
start: ReturnType<typeof vi.fn>;
|
|
20
|
+
stop: ReturnType<typeof vi.fn>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const mocks = vi.hoisted(() => {
|
|
24
|
+
const start = vi.fn().mockResolvedValue(null);
|
|
25
|
+
const stop = vi.fn().mockResolvedValue(null);
|
|
26
|
+
const clear = vi.fn();
|
|
27
|
+
const getState = vi.fn(() => 1);
|
|
28
|
+
const scanFileV2 = vi.fn();
|
|
29
|
+
const Html5Qrcode = vi.fn(function MockHtml5Qrcode(this: MockScannerInstance) {
|
|
30
|
+
this.start = start;
|
|
31
|
+
this.stop = stop;
|
|
32
|
+
this.clear = clear;
|
|
33
|
+
this.getState = getState;
|
|
34
|
+
this.scanFileV2 = scanFileV2;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
clear,
|
|
39
|
+
getState,
|
|
40
|
+
Html5Qrcode,
|
|
41
|
+
scanFileV2,
|
|
42
|
+
start,
|
|
43
|
+
stop,
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
vi.mock('html5-qrcode', () => ({
|
|
48
|
+
Html5Qrcode: mocks.Html5Qrcode,
|
|
49
|
+
Html5QrcodeScannerState: {
|
|
50
|
+
PAUSED: 3,
|
|
51
|
+
SCANNING: 2,
|
|
52
|
+
},
|
|
53
|
+
Html5QrcodeSupportedFormats: {
|
|
54
|
+
CODABAR: 2,
|
|
55
|
+
CODE_128: 5,
|
|
56
|
+
CODE_39: 3,
|
|
57
|
+
CODE_93: 4,
|
|
58
|
+
DATA_MATRIX: 6,
|
|
59
|
+
EAN_13: 9,
|
|
60
|
+
EAN_8: 10,
|
|
61
|
+
ITF: 8,
|
|
62
|
+
PDF_417: 11,
|
|
63
|
+
QR_CODE: 0,
|
|
64
|
+
UPC_A: 14,
|
|
65
|
+
UPC_E: 15,
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: number } } = {}) {
|
|
70
|
+
const handleScanSuccess = useCallback(() => undefined, []);
|
|
71
|
+
|
|
72
|
+
useCodeScanner({
|
|
73
|
+
elementId: 'scanner',
|
|
74
|
+
enabled: true,
|
|
75
|
+
scanBoxSize,
|
|
76
|
+
onScanSuccess: handleScanSuccess,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return <div id="scanner" />;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('useCodeScanner', () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
vi.clearAllMocks();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('starts scanning with QR code and barcode formats by default', async () => {
|
|
88
|
+
render(<ScannerHost />);
|
|
89
|
+
|
|
90
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
91
|
+
|
|
92
|
+
expect(mocks.Html5Qrcode).toHaveBeenCalledWith('scanner', {
|
|
93
|
+
formatsToSupport: DEFAULT_CODE_FORMATS,
|
|
94
|
+
verbose: false,
|
|
95
|
+
});
|
|
96
|
+
expect(DEFAULT_CODE_FORMATS).toEqual(expect.arrayContaining([0, 5, 9, 14]));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('uses the visible scan box as the camera scan region', async () => {
|
|
100
|
+
render(<ScannerHost />);
|
|
101
|
+
|
|
102
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
103
|
+
|
|
104
|
+
const config = mocks.start.mock.calls[0]?.[1] as
|
|
105
|
+
| { qrbox?: (width: number, height: number) => { width: number; height: number } }
|
|
106
|
+
| undefined;
|
|
107
|
+
|
|
108
|
+
expect(config?.qrbox?.(390, 844)).toEqual({ width: 319, height: 240 });
|
|
109
|
+
expect(getCodeScanBoxSize(390, 844)).toEqual({ width: 319, height: 240 });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('can use the visible viewport scan box when the camera video is wider than the viewport', async () => {
|
|
113
|
+
render(<ScannerHost scanBoxSize={{ width: 319, height: 240 }} />);
|
|
114
|
+
|
|
115
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
116
|
+
|
|
117
|
+
const config = mocks.start.mock.calls[0]?.[1] as
|
|
118
|
+
| { qrbox?: (width: number, height: number) => { width: number; height: number } }
|
|
119
|
+
| undefined;
|
|
120
|
+
|
|
121
|
+
expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
export * from './CodeScanner';
|
|
11
|
+
export * from './ScanInput';
|
|
12
|
+
export * from './types';
|