@nocobase/client-v2 2.3.0-beta.2 → 2.3.0-beta.21
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/RouteRepository.d.ts +6 -0
- package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
- package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
- package/es/flow/admin-shell/admin-layout/MobileTopbarActionsContext.d.ts +10 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
- package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
- package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
- package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
- package/es/flow/models/actions/LinkActionUtils.d.ts +1 -0
- package/es/flow/models/blocks/assign-form/assignFormValue.d.ts +29 -0
- package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
- package/es/index.mjs +100 -100
- package/lib/index.js +102 -102
- package/package.json +9 -8
- package/src/BaseApplication.tsx +2 -0
- package/src/RouteRepository.ts +14 -0
- package/src/__tests__/RouteRepository.test.ts +51 -0
- package/src/__tests__/app.test.tsx +13 -0
- package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
- package/src/collection-manager/field-configure.ts +2 -2
- package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
- package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
- package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
- package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
- package/src/flow/actions/__tests__/validation.test.ts +48 -0
- package/src/flow/admin-shell/admin-layout/MobileTopbarActionsContext.ts +13 -0
- package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +13 -2
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.mobile.test.tsx +205 -0
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
- package/src/flow/components/FieldAssignValueInput.tsx +4 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
- package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
- package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
- package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
- package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
- package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
- package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
- package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
- package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
- package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
- package/src/flow/models/actions/LinkActionModel.tsx +11 -9
- package/src/flow/models/actions/LinkActionUtils.ts +4 -2
- package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
- package/src/flow/models/actions/__tests__/LinkActionModel.test.ts +14 -0
- package/src/flow/models/base/GridModel.tsx +0 -1
- package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +40 -12
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.multipleSelect.test.tsx +216 -0
- package/src/flow/models/blocks/assign-form/__tests__/assignFormValue.test.ts +41 -0
- package/src/flow/models/blocks/assign-form/assignFormValue.ts +64 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
- package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
- package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
- package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
- package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
- package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
- package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
- package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
- package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +11 -2
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +6 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +13 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +47 -17
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.largeField.test.tsx +178 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +26 -2
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
- package/src/flow/models/fields/AssociationFieldModel/__tests__/associationSelect.search.test.ts +113 -0
- package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
- package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
- package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
- package/src/flow/models/fields/InputFieldModel.tsx +1 -1
- package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
- package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
- package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
- package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +16 -0
- package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -0
- package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
- package/src/flow/models/topbar/UserCenterTopbarActionModel.tsx +8 -4
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
|
11
11
|
import jsQR from 'jsqr';
|
|
12
12
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
13
|
+
import type { RefObject } from 'react';
|
|
13
14
|
import type { CodeFormatsToSupport } from './types';
|
|
15
|
+
import { decodeQrCodeWithZxingWasm } from './zxingWasmDecoder';
|
|
14
16
|
|
|
15
17
|
type ScannerSize = {
|
|
16
18
|
width: number;
|
|
@@ -22,6 +24,7 @@ type UseCodeScannerOptions = {
|
|
|
22
24
|
elementId: string;
|
|
23
25
|
formatsToSupport?: CodeFormatsToSupport;
|
|
24
26
|
onScannerSizeChanged?: (size: ScannerSize) => void;
|
|
27
|
+
scanViewportRef?: RefObject<HTMLElement | null>;
|
|
25
28
|
onScanSuccess: (text: string) => void;
|
|
26
29
|
onScanFailure?: () => void;
|
|
27
30
|
onCameraStartFailure?: (error: unknown) => void;
|
|
@@ -37,6 +40,12 @@ type JsQRImageTransform = {
|
|
|
37
40
|
threshold?: number;
|
|
38
41
|
};
|
|
39
42
|
|
|
43
|
+
type QrFrameCaptureLimits = {
|
|
44
|
+
maxHeight: number;
|
|
45
|
+
maxPixels: number;
|
|
46
|
+
maxWidth: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
40
49
|
type FocusMediaTrackCapabilities = MediaTrackCapabilities & {
|
|
41
50
|
focusMode?: string[];
|
|
42
51
|
};
|
|
@@ -47,8 +56,15 @@ type FocusMediaTrackConstraintSet = MediaTrackConstraintSet & {
|
|
|
47
56
|
|
|
48
57
|
const QR_SCAN_IMAGE_SIZES = [3200, 2400, 1600, 1000];
|
|
49
58
|
const LIVE_QR_SCAN_INTERVAL = 120;
|
|
59
|
+
const IOS_ZXING_SCAN_INTERVAL = 200;
|
|
50
60
|
const LIVE_QR_SCAN_MAX_WIDTH = 960;
|
|
51
61
|
const LIVE_QR_SCAN_MAX_HEIGHT = 540;
|
|
62
|
+
const LIVE_QR_SCAN_MAX_PIXELS = LIVE_QR_SCAN_MAX_WIDTH * 420;
|
|
63
|
+
const IOS_ZXING_SCAN_LIMITS: QrFrameCaptureLimits = {
|
|
64
|
+
maxHeight: 720,
|
|
65
|
+
maxPixels: 720 * 960,
|
|
66
|
+
maxWidth: 1280,
|
|
67
|
+
};
|
|
52
68
|
const QR_SCAN_IMAGE_TRANSFORMS: JsQRImageTransform[] = [
|
|
53
69
|
{},
|
|
54
70
|
{ contrast: 3, threshold: 105 },
|
|
@@ -81,7 +97,49 @@ export function getCodeScanBoxSize(width: number, height: number) {
|
|
|
81
97
|
};
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
export function
|
|
100
|
+
export function isIOSBrowser() {
|
|
101
|
+
return (
|
|
102
|
+
/iPad|iPhone|iPod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getVisibleVideoFrameRegion(video: HTMLVideoElement, scanViewport: Element) {
|
|
107
|
+
const rect = video.getBoundingClientRect();
|
|
108
|
+
const viewportRect = scanViewport.getBoundingClientRect();
|
|
109
|
+
const visibleLeft = Math.max(viewportRect.left, rect.left);
|
|
110
|
+
const visibleTop = Math.max(viewportRect.top, rect.top);
|
|
111
|
+
const visibleRight = Math.min(viewportRect.right, rect.right);
|
|
112
|
+
const visibleBottom = Math.min(viewportRect.bottom, rect.bottom);
|
|
113
|
+
if (!rect.width || !rect.height || visibleRight <= visibleLeft || visibleBottom <= visibleTop) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sourceX = Math.max(0, Math.floor(((visibleLeft - rect.left) / rect.width) * video.videoWidth));
|
|
118
|
+
const sourceY = Math.max(0, Math.floor(((visibleTop - rect.top) / rect.height) * video.videoHeight));
|
|
119
|
+
return {
|
|
120
|
+
x: sourceX,
|
|
121
|
+
y: sourceY,
|
|
122
|
+
width: Math.min(
|
|
123
|
+
video.videoWidth - sourceX,
|
|
124
|
+
Math.ceil(((visibleRight - visibleLeft) / rect.width) * video.videoWidth),
|
|
125
|
+
),
|
|
126
|
+
height: Math.min(
|
|
127
|
+
video.videoHeight - sourceY,
|
|
128
|
+
Math.ceil(((visibleBottom - visibleTop) / rect.height) * video.videoHeight),
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getQrVideoFrameImageData(
|
|
134
|
+
video: HTMLVideoElement,
|
|
135
|
+
canvas: HTMLCanvasElement,
|
|
136
|
+
scanViewport?: Element,
|
|
137
|
+
limits: QrFrameCaptureLimits = {
|
|
138
|
+
maxHeight: LIVE_QR_SCAN_MAX_HEIGHT,
|
|
139
|
+
maxPixels: LIVE_QR_SCAN_MAX_PIXELS,
|
|
140
|
+
maxWidth: LIVE_QR_SCAN_MAX_WIDTH,
|
|
141
|
+
},
|
|
142
|
+
) {
|
|
85
143
|
if (!video.videoWidth || !video.videoHeight || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
86
144
|
return;
|
|
87
145
|
}
|
|
@@ -89,16 +147,27 @@ export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElem
|
|
|
89
147
|
const viewfinderWidth = video.clientWidth || video.videoWidth;
|
|
90
148
|
const viewfinderHeight = video.clientHeight || video.videoHeight;
|
|
91
149
|
const scanBoxSize = getCodeScanBoxSize(viewfinderWidth, viewfinderHeight);
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
video.videoHeight,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
150
|
+
const scanBoxRegion = {
|
|
151
|
+
width: Math.min(video.videoWidth, Math.floor(scanBoxSize.width * (video.videoWidth / viewfinderWidth))),
|
|
152
|
+
height: Math.min(video.videoHeight, Math.floor(scanBoxSize.height * (video.videoHeight / viewfinderHeight))),
|
|
153
|
+
};
|
|
154
|
+
const sourceRegion = scanViewport
|
|
155
|
+
? getVisibleVideoFrameRegion(video, scanViewport)
|
|
156
|
+
: {
|
|
157
|
+
x: Math.floor((video.videoWidth - scanBoxRegion.width) / 2),
|
|
158
|
+
y: Math.floor((video.videoHeight - scanBoxRegion.height) / 2),
|
|
159
|
+
...scanBoxRegion,
|
|
160
|
+
};
|
|
161
|
+
if (!sourceRegion) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const maxWidth = scanViewport && sourceRegion.height > sourceRegion.width ? limits.maxHeight : limits.maxWidth;
|
|
166
|
+
const maxHeight = scanViewport && sourceRegion.height > sourceRegion.width ? limits.maxWidth : limits.maxHeight;
|
|
167
|
+
const pixelScale = scanViewport ? Math.sqrt(limits.maxPixels / (sourceRegion.width * sourceRegion.height)) : 1;
|
|
168
|
+
const targetScale = Math.min(1, maxWidth / sourceRegion.width, maxHeight / sourceRegion.height, pixelScale);
|
|
169
|
+
const targetWidth = Math.max(1, Math.floor(sourceRegion.width * targetScale));
|
|
170
|
+
const targetHeight = Math.max(1, Math.floor(sourceRegion.height * targetScale));
|
|
102
171
|
if (canvas.width !== targetWidth) {
|
|
103
172
|
canvas.width = targetWidth;
|
|
104
173
|
}
|
|
@@ -110,30 +179,75 @@ export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElem
|
|
|
110
179
|
return;
|
|
111
180
|
}
|
|
112
181
|
|
|
113
|
-
context.drawImage(
|
|
114
|
-
|
|
182
|
+
context.drawImage(
|
|
183
|
+
video,
|
|
184
|
+
sourceRegion.x,
|
|
185
|
+
sourceRegion.y,
|
|
186
|
+
sourceRegion.width,
|
|
187
|
+
sourceRegion.height,
|
|
188
|
+
0,
|
|
189
|
+
0,
|
|
190
|
+
targetWidth,
|
|
191
|
+
targetHeight,
|
|
192
|
+
);
|
|
193
|
+
return context.getImageData(0, 0, targetWidth, targetHeight);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElement, scanViewport?: Element) {
|
|
197
|
+
const imageData = getQrVideoFrameImageData(video, canvas, scanViewport);
|
|
198
|
+
if (!imageData) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
115
201
|
return jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' })?.data;
|
|
116
202
|
}
|
|
117
203
|
|
|
118
|
-
|
|
204
|
+
export async function scanQrVideoFrameWithZxingWasm(
|
|
205
|
+
video: HTMLVideoElement,
|
|
206
|
+
canvas: HTMLCanvasElement,
|
|
207
|
+
scanViewport: Element,
|
|
208
|
+
) {
|
|
209
|
+
const imageData = getQrVideoFrameImageData(video, canvas, scanViewport, IOS_ZXING_SCAN_LIMITS);
|
|
210
|
+
if (!imageData) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
return decodeQrCodeWithZxingWasm(imageData);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function startLiveQrScan(
|
|
217
|
+
elementId: string,
|
|
218
|
+
onScanSuccess: (text: string) => void,
|
|
219
|
+
scanViewportRef?: RefObject<HTMLElement | null>,
|
|
220
|
+
) {
|
|
119
221
|
const canvas = document.createElement('canvas');
|
|
222
|
+
const useVisiblePreview = isIOSBrowser();
|
|
120
223
|
let timer: number | undefined;
|
|
121
224
|
let stopped = false;
|
|
122
225
|
|
|
123
|
-
const scan = () => {
|
|
226
|
+
const scan = async () => {
|
|
124
227
|
if (stopped) {
|
|
125
228
|
return;
|
|
126
229
|
}
|
|
127
230
|
const video = document.getElementById(elementId)?.querySelector('video');
|
|
231
|
+
const scanViewport = useVisiblePreview ? scanViewportRef?.current : undefined;
|
|
128
232
|
if (video) {
|
|
129
|
-
|
|
130
|
-
|
|
233
|
+
let decodedText: string | undefined;
|
|
234
|
+
try {
|
|
235
|
+
decodedText =
|
|
236
|
+
useVisiblePreview && scanViewport
|
|
237
|
+
? await scanQrVideoFrameWithZxingWasm(video, canvas, scanViewport)
|
|
238
|
+
: scanQrVideoFrame(video, canvas);
|
|
239
|
+
} catch {
|
|
240
|
+
// Keep the existing scanners active when the optional WASM decoder cannot load or decode a frame.
|
|
241
|
+
}
|
|
242
|
+
if (decodedText && !stopped) {
|
|
131
243
|
stopped = true;
|
|
132
244
|
onScanSuccess(decodedText);
|
|
133
245
|
return;
|
|
134
246
|
}
|
|
135
247
|
}
|
|
136
|
-
|
|
248
|
+
if (!stopped) {
|
|
249
|
+
timer = window.setTimeout(scan, useVisiblePreview ? IOS_ZXING_SCAN_INTERVAL : LIVE_QR_SCAN_INTERVAL);
|
|
250
|
+
}
|
|
137
251
|
};
|
|
138
252
|
|
|
139
253
|
timer = window.setTimeout(scan, 0);
|
|
@@ -304,6 +418,7 @@ export function useCodeScanner({
|
|
|
304
418
|
elementId,
|
|
305
419
|
formatsToSupport,
|
|
306
420
|
onScannerSizeChanged,
|
|
421
|
+
scanViewportRef,
|
|
307
422
|
onScanSuccess,
|
|
308
423
|
onScanFailure,
|
|
309
424
|
onCameraStartFailure,
|
|
@@ -377,11 +492,11 @@ export function useCodeScanner({
|
|
|
377
492
|
return;
|
|
378
493
|
}
|
|
379
494
|
if (shouldScanQrWithJsQR(formatsToSupport)) {
|
|
380
|
-
liveQrScanStopRef.current = startLiveQrScan(elementId, reportScanSuccess);
|
|
495
|
+
liveQrScanStopRef.current = startLiveQrScan(elementId, reportScanSuccess, scanViewportRef);
|
|
381
496
|
}
|
|
382
497
|
await enableContinuousFocus(scannerInstance);
|
|
383
498
|
},
|
|
384
|
-
[cancelActiveScan, elementId, formatsToSupport, onScannerSizeChanged, reportScanSuccess],
|
|
499
|
+
[cancelActiveScan, elementId, formatsToSupport, onScannerSizeChanged, reportScanSuccess, scanViewportRef],
|
|
385
500
|
);
|
|
386
501
|
|
|
387
502
|
const startScanFile = useCallback(
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
let decoderPromise: Promise<typeof import('zxing-wasm/reader')> | undefined;
|
|
11
|
+
|
|
12
|
+
async function loadDecoder() {
|
|
13
|
+
if (!decoderPromise) {
|
|
14
|
+
decoderPromise = Promise.all([import('zxing-wasm/reader'), import('zxing-wasm/reader/zxing_reader.wasm')])
|
|
15
|
+
.then(async ([decoder, { default: wasmUrl }]) => {
|
|
16
|
+
try {
|
|
17
|
+
await decoder.prepareZXingModule({
|
|
18
|
+
fireImmediately: true,
|
|
19
|
+
overrides: { locateFile: () => wasmUrl },
|
|
20
|
+
});
|
|
21
|
+
} catch (error) {
|
|
22
|
+
decoder.purgeZXingModule();
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return decoder;
|
|
26
|
+
})
|
|
27
|
+
.catch((error: unknown) => {
|
|
28
|
+
decoderPromise = undefined;
|
|
29
|
+
throw error;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return decoderPromise;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function decodeQrCodeWithZxingWasm(imageData: ImageData) {
|
|
36
|
+
const decoder = await loadDecoder();
|
|
37
|
+
const results = await decoder.readBarcodes(imageData, {
|
|
38
|
+
binarizer: 'GlobalHistogram',
|
|
39
|
+
downscaleThreshold: 300,
|
|
40
|
+
formats: ['QRCode'],
|
|
41
|
+
maxNumberOfSymbols: 1,
|
|
42
|
+
tryDenoise: true,
|
|
43
|
+
tryDownscale: true,
|
|
44
|
+
tryHarder: true,
|
|
45
|
+
tryInvert: true,
|
|
46
|
+
tryRotate: true,
|
|
47
|
+
});
|
|
48
|
+
if (results[0]?.text) {
|
|
49
|
+
return results[0].text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fallbackResults = await decoder.readBarcodes(imageData, {
|
|
53
|
+
binarizer: 'LocalAverage',
|
|
54
|
+
downscaleThreshold: 300,
|
|
55
|
+
formats: ['QRCode'],
|
|
56
|
+
maxNumberOfSymbols: 1,
|
|
57
|
+
tryDenoise: true,
|
|
58
|
+
tryDownscale: true,
|
|
59
|
+
tryHarder: true,
|
|
60
|
+
tryInvert: true,
|
|
61
|
+
tryRotate: true,
|
|
62
|
+
});
|
|
63
|
+
return fallbackResults[0]?.text;
|
|
64
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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 { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { validation } from '../validation';
|
|
12
|
+
|
|
13
|
+
vi.mock('../../../flow-compat', () => ({ FieldValidation: () => null }));
|
|
14
|
+
|
|
15
|
+
describe('validation action', () => {
|
|
16
|
+
it('rejects excess decimal places with a translated error and retains collection rules', async () => {
|
|
17
|
+
const setProps = vi.fn();
|
|
18
|
+
const collectionRule = { validator: vi.fn().mockResolvedValue(undefined) };
|
|
19
|
+
const t = vi.fn((key: string, options?: Record<string, unknown>) => {
|
|
20
|
+
return `${options?.label} 精度不能超过 ${options?.limit} 位小数`;
|
|
21
|
+
});
|
|
22
|
+
const ctx = {
|
|
23
|
+
model: {
|
|
24
|
+
props: { label: '工龄' },
|
|
25
|
+
collectionField: { getComponentProps: () => ({ rules: [collectionRule] }) },
|
|
26
|
+
setProps,
|
|
27
|
+
},
|
|
28
|
+
t,
|
|
29
|
+
} as unknown as Parameters<typeof validation.handler>[0];
|
|
30
|
+
|
|
31
|
+
await validation.handler(ctx, {
|
|
32
|
+
validation: { type: 'number', rules: [{ name: 'precision', args: { limit: 2 } }] },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const { rules } = setProps.mock.calls[0][0] as {
|
|
36
|
+
rules: { validator: (rule: unknown, value: unknown) => Promise<void> }[];
|
|
37
|
+
};
|
|
38
|
+
expect(rules).toHaveLength(2);
|
|
39
|
+
expect(rules[0]).toBe(collectionRule);
|
|
40
|
+
await expect(rules[1].validator({}, 39.2234)).rejects.toBe('工龄 精度不能超过 2 位小数');
|
|
41
|
+
expect(t).toHaveBeenCalledWith(
|
|
42
|
+
'number.precision',
|
|
43
|
+
expect.objectContaining({ ns: 'data-source-main', label: '工龄', limit: 2 }),
|
|
44
|
+
);
|
|
45
|
+
await expect(rules[1].validator({}, 39.22)).resolves.toBeUndefined();
|
|
46
|
+
await expect(rules[1].validator({}, null)).resolves.toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
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 { createContext } from 'react';
|
|
11
|
+
|
|
12
|
+
// Internal callback for dismissing the mobile topbar after a nested menu action completes.
|
|
13
|
+
export const MobileTopbarActionsContext = createContext<(() => void) | undefined>(undefined);
|
|
@@ -11,13 +11,14 @@ import { EllipsisOutlined } from '@ant-design/icons';
|
|
|
11
11
|
import { css } from '@emotion/css';
|
|
12
12
|
import { FlowModelRenderer } from '@nocobase/flow-engine';
|
|
13
13
|
import { ConfigProvider, Divider, Popover, theme } from 'antd';
|
|
14
|
-
import React, { useCallback, useMemo, useState } from 'react';
|
|
14
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
15
15
|
import { ErrorBoundary } from 'react-error-boundary';
|
|
16
16
|
import { useAclSnippets } from '../../../acl/useAclSnippets';
|
|
17
17
|
import type { CustomToken } from '../../../theme';
|
|
18
18
|
import { TopbarActionModel } from '../../models/topbar/TopbarActionModel';
|
|
19
19
|
import { USER_CENTER_ACTION_ID } from '../../models/topbar/UserCenterTopbarActionModel';
|
|
20
20
|
import { HelpLite } from './HelpLite';
|
|
21
|
+
import { MobileTopbarActionsContext } from './MobileTopbarActionsContext';
|
|
21
22
|
|
|
22
23
|
const topbarActionsBarClassName = css`
|
|
23
24
|
display: inline-flex;
|
|
@@ -183,11 +184,21 @@ export const TopbarActionsBar = React.memo((props: { actions: TopbarActionModel[
|
|
|
183
184
|
setOpen(false);
|
|
184
185
|
}, []);
|
|
185
186
|
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!props.isMobile) {
|
|
189
|
+
setOpen(false);
|
|
190
|
+
}
|
|
191
|
+
}, [props.isMobile]);
|
|
192
|
+
|
|
186
193
|
if (props.isMobile) {
|
|
187
194
|
return (
|
|
188
195
|
<Popover
|
|
189
196
|
rootClassName={popoverStyle}
|
|
190
|
-
content={
|
|
197
|
+
content={
|
|
198
|
+
<MobileTopbarActionsContext.Provider value={handleContentClick}>
|
|
199
|
+
<TopbarActionsContent actions={props.actions} onActionClick={handleContentClick} />
|
|
200
|
+
</MobileTopbarActionsContext.Provider>
|
|
201
|
+
}
|
|
191
202
|
color={customToken.colorBgHeader}
|
|
192
203
|
trigger="click"
|
|
193
204
|
open={open}
|
|
@@ -0,0 +1,205 @@
|
|
|
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 { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import { Drawer, Input } from 'antd';
|
|
12
|
+
import React, { useState } from 'react';
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
vi.mock('@nocobase/flow-engine', () => ({
|
|
16
|
+
FlowModel: class {
|
|
17
|
+
uid: string;
|
|
18
|
+
context = { t: (value: string) => value };
|
|
19
|
+
|
|
20
|
+
constructor(options: { uid: string }) {
|
|
21
|
+
this.uid = options.uid;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
FlowModelRenderer: ({ model }: { model: { render: () => React.ReactNode } }) => model.render(),
|
|
25
|
+
observer: (component: React.ComponentType) => component,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('../../../models/topbar/TopbarActionModel', async () => {
|
|
29
|
+
const { FlowModel } = await import('@nocobase/flow-engine');
|
|
30
|
+
return {
|
|
31
|
+
TopbarActionModel: class extends FlowModel {
|
|
32
|
+
isHidden() {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getActionId() {
|
|
37
|
+
return 'user-center';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
renderWrapper(node: React.ReactNode) {
|
|
41
|
+
return node;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
renderButton() {
|
|
45
|
+
return <button data-testid="user-center-button">User center</button>;
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
vi.mock('../../../../acl/useAclSnippets', () => ({
|
|
52
|
+
useAclSnippets: () => ({ allow: () => true }),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
vi.mock('../HelpLite', () => ({ HelpLite: () => null }));
|
|
56
|
+
|
|
57
|
+
import {
|
|
58
|
+
UserCenterActionItemModel,
|
|
59
|
+
UserCenterSelectItemModel,
|
|
60
|
+
UserCenterTopbarActionModel,
|
|
61
|
+
} from '../../../models/topbar/UserCenterTopbarActionModel';
|
|
62
|
+
import { TopbarActionsBar } from '../TopbarActionsBar';
|
|
63
|
+
|
|
64
|
+
afterEach(cleanup);
|
|
65
|
+
|
|
66
|
+
function createUserCenter(onClick: () => Promise<void>, closeOnClick = true) {
|
|
67
|
+
const item = new UserCenterActionItemModel({ uid: 'test-edit-profile', use: 'UserCenterActionItemModel' });
|
|
68
|
+
item.label = 'Edit profile';
|
|
69
|
+
item.onClick = onClick;
|
|
70
|
+
item.closeOnClick = closeOnClick;
|
|
71
|
+
const model = new UserCenterTopbarActionModel({ uid: 'test-user-center', use: 'UserCenterTopbarActionModel' });
|
|
72
|
+
vi.spyOn(model, 'getPreparedItems').mockImplementation(async (closeDropdown) => {
|
|
73
|
+
item.setCloseDropdown(closeDropdown);
|
|
74
|
+
return [item];
|
|
75
|
+
});
|
|
76
|
+
return model;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function openUserMenu(isMobile: boolean) {
|
|
80
|
+
if (isMobile) {
|
|
81
|
+
fireEvent.click(screen.getByRole('img', { name: 'ellipsis' }));
|
|
82
|
+
}
|
|
83
|
+
fireEvent.mouseEnter(screen.getByTestId('user-center-button'));
|
|
84
|
+
const action = await screen.findByRole('button', { name: 'Edit profile' });
|
|
85
|
+
await waitFor(() => expect(action).toBeVisible());
|
|
86
|
+
return action;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('mobile user center popover', () => {
|
|
90
|
+
it('closes both menus after selecting an action and keeps the opened drawer mounted', async () => {
|
|
91
|
+
const model = createUserCenter(vi.fn());
|
|
92
|
+
const Test = () => {
|
|
93
|
+
const [open, setOpen] = useState(false);
|
|
94
|
+
model.getPreparedItems = async (closeDropdown) => {
|
|
95
|
+
const item = new UserCenterActionItemModel({ uid: 'profile', use: 'UserCenterActionItemModel' });
|
|
96
|
+
item.label = 'Edit profile';
|
|
97
|
+
item.onClick = async () => setOpen(true);
|
|
98
|
+
item.setCloseDropdown(closeDropdown);
|
|
99
|
+
return [item];
|
|
100
|
+
};
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
<TopbarActionsBar actions={[model]} isMobile />
|
|
104
|
+
<Drawer open={open} title="Edit profile">
|
|
105
|
+
<Input aria-label="Nickname" />
|
|
106
|
+
</Drawer>
|
|
107
|
+
</>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
render(<Test />);
|
|
111
|
+
|
|
112
|
+
fireEvent.click(await openUserMenu(true));
|
|
113
|
+
|
|
114
|
+
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
|
|
115
|
+
expect(screen.getByRole('textbox', { name: 'Nickname' })).toBeVisible();
|
|
116
|
+
expect(screen.queryByRole('button', { name: 'Edit profile' })).not.toBeInTheDocument();
|
|
117
|
+
|
|
118
|
+
fireEvent.click(screen.getByRole('img', { name: 'ellipsis' }));
|
|
119
|
+
fireEvent.mouseEnter(screen.getByTestId('user-center-button'));
|
|
120
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'Edit profile' })).toBeVisible());
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('respects actions that keep the menu open', async () => {
|
|
124
|
+
const onClick = vi.fn(async () => {});
|
|
125
|
+
render(<TopbarActionsBar actions={[createUserCenter(onClick, false)]} isMobile />);
|
|
126
|
+
|
|
127
|
+
fireEvent.click(await openUserMenu(true));
|
|
128
|
+
|
|
129
|
+
await waitFor(() => expect(onClick).toHaveBeenCalledOnce());
|
|
130
|
+
expect(screen.getByRole('tooltip')).toBeVisible();
|
|
131
|
+
expect(screen.getByRole('button', { name: 'Edit profile' })).toBeVisible();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('keeps the topbar open when the user only leaves the nested menu', async () => {
|
|
135
|
+
render(<TopbarActionsBar actions={[createUserCenter(vi.fn())]} isMobile />);
|
|
136
|
+
const action = await openUserMenu(true);
|
|
137
|
+
|
|
138
|
+
const dropdown = action.closest('.ant-dropdown');
|
|
139
|
+
expect(dropdown).not.toBeNull();
|
|
140
|
+
if (dropdown) {
|
|
141
|
+
fireEvent.mouseLeave(dropdown);
|
|
142
|
+
}
|
|
143
|
+
fireEvent.mouseLeave(screen.getByTestId('user-center-button'));
|
|
144
|
+
|
|
145
|
+
await waitFor(() => expect(screen.queryByRole('button', { name: 'Edit profile' })).not.toBeInTheDocument());
|
|
146
|
+
expect(screen.getByRole('tooltip')).toBeVisible();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('keeps desktop actions working without a mobile popover', async () => {
|
|
150
|
+
const onClick = vi.fn(async () => {});
|
|
151
|
+
render(<TopbarActionsBar actions={[createUserCenter(onClick)]} />);
|
|
152
|
+
|
|
153
|
+
fireEvent.click(await openUserMenu(false));
|
|
154
|
+
|
|
155
|
+
await waitFor(() => expect(screen.queryByRole('button', { name: 'Edit profile' })).not.toBeInTheDocument());
|
|
156
|
+
expect(onClick).toHaveBeenCalledOnce();
|
|
157
|
+
expect(screen.queryByRole('img', { name: 'ellipsis' })).not.toBeInTheDocument();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it.each([true, false])('respects closeOnChange=%s for preference selectors', async (closeOnChange) => {
|
|
161
|
+
const onChange = vi.fn(async () => {});
|
|
162
|
+
const item = new UserCenterSelectItemModel({ uid: 'language', use: 'UserCenterSelectItemModel' });
|
|
163
|
+
item.label = 'Language';
|
|
164
|
+
item.value = 'en';
|
|
165
|
+
item.options = [
|
|
166
|
+
{ label: 'English', value: 'en' },
|
|
167
|
+
{ label: 'Chinese', value: 'zh' },
|
|
168
|
+
];
|
|
169
|
+
item.onChange = onChange;
|
|
170
|
+
item.closeOnChange = closeOnChange;
|
|
171
|
+
const model = createUserCenter(vi.fn());
|
|
172
|
+
model.getPreparedItems = async (closeDropdown) => {
|
|
173
|
+
item.setCloseDropdown(closeDropdown);
|
|
174
|
+
return [item];
|
|
175
|
+
};
|
|
176
|
+
render(<TopbarActionsBar actions={[model]} isMobile />);
|
|
177
|
+
fireEvent.click(screen.getByRole('img', { name: 'ellipsis' }));
|
|
178
|
+
fireEvent.mouseEnter(screen.getByTestId('user-center-button'));
|
|
179
|
+
const select = await screen.findByRole('combobox');
|
|
180
|
+
await waitFor(() => expect(select.closest('.ant-select')).toBeVisible());
|
|
181
|
+
|
|
182
|
+
fireEvent.mouseDown(select);
|
|
183
|
+
fireEvent.click(await screen.findByText('Chinese'));
|
|
184
|
+
|
|
185
|
+
await waitFor(() => expect(onChange).toHaveBeenCalledWith('zh'));
|
|
186
|
+
if (closeOnChange) {
|
|
187
|
+
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
|
|
188
|
+
} else {
|
|
189
|
+
expect(screen.getByRole('tooltip')).toBeVisible();
|
|
190
|
+
expect(select.closest('.ant-select')).toBeVisible();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('does not reopen the previous popover after switching to desktop and back', async () => {
|
|
195
|
+
const actions = [createUserCenter(vi.fn())];
|
|
196
|
+
const { rerender } = render(<TopbarActionsBar actions={actions} isMobile />);
|
|
197
|
+
fireEvent.click(screen.getByRole('img', { name: 'ellipsis' }));
|
|
198
|
+
await waitFor(() => expect(screen.getByRole('tooltip')).toBeVisible());
|
|
199
|
+
|
|
200
|
+
rerender(<TopbarActionsBar actions={actions} isMobile={false} />);
|
|
201
|
+
rerender(<TopbarActionsBar actions={actions} isMobile />);
|
|
202
|
+
|
|
203
|
+
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
|
204
|
+
});
|
|
205
|
+
});
|