@nocobase/client-v2 2.4.0-alpha.1 → 2.4.0-alpha.10
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/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/blocks/form/QuickEditFormModel.d.ts +1 -0
- package/es/index.mjs +77 -77
- package/lib/index.js +131 -131
- 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/__tests__/TopbarActionsBar.test.tsx +39 -0
- 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/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/__tests__/JSItemActionModel.test.tsx +47 -1
- 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 +14 -3
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -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 +7 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +17 -10
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -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/SelectFieldModel.tsx +5 -0
- package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.4.0-alpha.
|
|
3
|
+
"version": "2.4.0-alpha.10",
|
|
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.4.0-alpha.
|
|
31
|
-
"@nocobase/flow-engine": "2.4.0-alpha.
|
|
32
|
-
"@nocobase/sdk": "2.4.0-alpha.
|
|
33
|
-
"@nocobase/shared": "2.4.0-alpha.
|
|
34
|
-
"@nocobase/utils": "2.4.0-alpha.
|
|
30
|
+
"@nocobase/evaluators": "2.4.0-alpha.10",
|
|
31
|
+
"@nocobase/flow-engine": "2.4.0-alpha.10",
|
|
32
|
+
"@nocobase/sdk": "2.4.0-alpha.10",
|
|
33
|
+
"@nocobase/shared": "2.4.0-alpha.10",
|
|
34
|
+
"@nocobase/utils": "2.4.0-alpha.10",
|
|
35
35
|
"ahooks": "^3.7.2",
|
|
36
36
|
"antd": "5.24.2",
|
|
37
37
|
"antd-style": "3.7.1",
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"lodash": "4.17.21",
|
|
47
47
|
"react-device-detect": "2.2.3",
|
|
48
48
|
"react-i18next": "^11.15.1",
|
|
49
|
-
"react-router-dom": "^6.30.1"
|
|
49
|
+
"react-router-dom": "^6.30.1",
|
|
50
|
+
"zxing-wasm": "^3.1.3"
|
|
50
51
|
},
|
|
51
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "c869865c0413fbe4ef85b26be1f2b8d7078c38df"
|
|
52
53
|
}
|
package/src/BaseApplication.tsx
CHANGED
|
@@ -274,6 +274,8 @@ export abstract class BaseApplication<
|
|
|
274
274
|
this.eventBus.addEventListener('auth:tokenChanged', (event: Event) => {
|
|
275
275
|
const detail = (event as CustomEvent<AuthTokenPayload>).detail;
|
|
276
276
|
if (!detail?.token) {
|
|
277
|
+
// 登录态已结束(退出登录或 token 失效),清空路由缓存,避免下一个用户沿用上一个用户的可访问路由。
|
|
278
|
+
this.context.routeRepository?.clear();
|
|
277
279
|
return;
|
|
278
280
|
}
|
|
279
281
|
this.setTokenInWebSocket(detail);
|
package/src/RouteRepository.ts
CHANGED
|
@@ -153,6 +153,20 @@ export class RouteRepository {
|
|
|
153
153
|
return loadingPromise;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* 清空所有布局的路由缓存,并丢弃清空前发起的请求结果。
|
|
158
|
+
*
|
|
159
|
+
* 用于登录态失效(如退出登录)时,避免下一个用户沿用上一个用户的可访问路由。
|
|
160
|
+
*/
|
|
161
|
+
clear() {
|
|
162
|
+
this.routeCaches.clear();
|
|
163
|
+
this.accessibleLoadingPromises.clear();
|
|
164
|
+
this.refreshRequestIds.forEach((requestId, layoutUid) => {
|
|
165
|
+
this.refreshRequestIds.set(layoutUid, requestId + 1);
|
|
166
|
+
});
|
|
167
|
+
this.syncRoutesProperty();
|
|
168
|
+
}
|
|
169
|
+
|
|
156
170
|
/**
|
|
157
171
|
* 订阅路由缓存变化,用于驱动 React 上下文刷新。
|
|
158
172
|
*
|
|
@@ -193,6 +193,57 @@ describe('RouteRepository', () => {
|
|
|
193
193
|
unsubscribeCustom();
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
+
it('should drop cached routes of every layout when cleared', () => {
|
|
197
|
+
const { repository } = createRouteRepository();
|
|
198
|
+
|
|
199
|
+
repository.setRoutes([route('admin-page')], 'admin-layout-model');
|
|
200
|
+
repository.setRoutes([route('custom-page')], 'custom-desktop-layout-model');
|
|
201
|
+
|
|
202
|
+
repository.clear();
|
|
203
|
+
|
|
204
|
+
expect(repository.isAccessibleLoaded()).toBe(false);
|
|
205
|
+
expect(repository.listAccessible()).toEqual([]);
|
|
206
|
+
expect(repository.routes).toEqual([]);
|
|
207
|
+
const deactivateLayout = repository.activateLayout({
|
|
208
|
+
uid: 'custom-desktop-layout-model',
|
|
209
|
+
});
|
|
210
|
+
expect(repository.isAccessibleLoaded()).toBe(false);
|
|
211
|
+
expect(repository.listAccessible()).toEqual([]);
|
|
212
|
+
deactivateLayout();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should reload accessible routes after being cleared', async () => {
|
|
216
|
+
const { repository, request } = createRouteRepository();
|
|
217
|
+
request.mockResolvedValueOnce({ data: { data: [route('previous-user-page')] } });
|
|
218
|
+
await repository.ensureAccessibleLoaded();
|
|
219
|
+
|
|
220
|
+
repository.clear();
|
|
221
|
+
request.mockResolvedValueOnce({ data: { data: [route('next-user-page')] } });
|
|
222
|
+
const routes = await repository.ensureAccessibleLoaded();
|
|
223
|
+
|
|
224
|
+
expect(request).toHaveBeenCalledTimes(2);
|
|
225
|
+
expect(routes.map((item) => item.schemaUid)).toEqual(['next-user-page']);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should ignore refresh results requested before being cleared', async () => {
|
|
229
|
+
const { repository, request } = createRouteRepository();
|
|
230
|
+
let resolveStaleRequest: (value: unknown) => void = () => {};
|
|
231
|
+
request.mockImplementationOnce(
|
|
232
|
+
() =>
|
|
233
|
+
new Promise((resolve) => {
|
|
234
|
+
resolveStaleRequest = resolve;
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const staleRefresh = repository.refreshAccessible();
|
|
239
|
+
repository.clear();
|
|
240
|
+
resolveStaleRequest({ data: { data: [route('previous-user-page')] } });
|
|
241
|
+
await staleRefresh;
|
|
242
|
+
|
|
243
|
+
expect(repository.isAccessibleLoaded()).toBe(false);
|
|
244
|
+
expect(repository.listAccessible()).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
|
|
196
247
|
it('should pass the default admin layout when creating a route', async () => {
|
|
197
248
|
const { repository, create } = createRouteRepository();
|
|
198
249
|
|
|
@@ -171,6 +171,19 @@ describe('app', () => {
|
|
|
171
171
|
expect(document.documentElement.lang).toBe('ja-JP');
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
it('should clear cached accessible routes when the auth token is cleared', () => {
|
|
175
|
+
const app = new Application({ router });
|
|
176
|
+
const routeRepository = app.flowEngine.context.routeRepository;
|
|
177
|
+
routeRepository.setRoutes([{ schemaUid: 'previous-user-page' }]);
|
|
178
|
+
|
|
179
|
+
app.apiClient.auth.setToken('renewed-token');
|
|
180
|
+
expect(routeRepository.listAccessible().map((route) => route.schemaUid)).toEqual(['previous-user-page']);
|
|
181
|
+
|
|
182
|
+
app.apiClient.auth.setToken(null);
|
|
183
|
+
expect(routeRepository.isAccessibleLoaded()).toBe(false);
|
|
184
|
+
expect(routeRepository.listAccessible()).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
174
187
|
it('should escape app version html placeholder content', () => {
|
|
175
188
|
expect(getAppVersionHTML('<script>alert(1)</script>&"')).toBe(
|
|
176
189
|
'<span class="nb-app-version">v<script>alert(1)</script>&"</span>',
|
|
@@ -0,0 +1,52 @@
|
|
|
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 } from 'vitest';
|
|
11
|
+
import { getCoreFieldConfigureState, reverseFieldConfigureItems } from '../field-configure';
|
|
12
|
+
|
|
13
|
+
describe('inverse relationship configuration', () => {
|
|
14
|
+
it.each(['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'])(
|
|
15
|
+
'keeps the %s inverse relationship type disabled when creating or editing a field',
|
|
16
|
+
(type) => {
|
|
17
|
+
for (const createOnly of [true, false]) {
|
|
18
|
+
for (const showReverseFieldConfig of [true, false]) {
|
|
19
|
+
const values = {
|
|
20
|
+
autoCreateReverseField: true,
|
|
21
|
+
reverseField: { type, ...(createOnly ? {} : { key: 'existing-reverse-field' }) },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
expect(
|
|
25
|
+
getCoreFieldConfigureState('reverseField.type', values, { createOnly, showReverseFieldConfig }),
|
|
26
|
+
).toEqual({
|
|
27
|
+
disabled: true,
|
|
28
|
+
hidden: !showReverseFieldConfig,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
it.each(['reverseField.name', 'reverseField.uiSchema.title'])(
|
|
36
|
+
'allows editing %s only when the inverse configuration is visible',
|
|
37
|
+
(name) => {
|
|
38
|
+
for (const showReverseFieldConfig of [true, false]) {
|
|
39
|
+
expect(getCoreFieldConfigureState(name, {}, { showReverseFieldConfig })).toEqual({
|
|
40
|
+
disabled: !showReverseFieldConfig,
|
|
41
|
+
hidden: !showReverseFieldConfig,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
it('also disables the inverse relationship type in explicit configure items', () => {
|
|
48
|
+
const item = reverseFieldConfigureItems().find(({ name }) => name === 'reverseField.type');
|
|
49
|
+
|
|
50
|
+
expect(item?.disabled).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -271,7 +271,7 @@ export function getCoreFieldConfigureState(
|
|
|
271
271
|
|
|
272
272
|
if (name.startsWith('reverseField.')) {
|
|
273
273
|
return {
|
|
274
|
-
disabled: !context.showReverseFieldConfig,
|
|
274
|
+
disabled: name === 'reverseField.type' || !context.showReverseFieldConfig,
|
|
275
275
|
hidden: !context.showReverseFieldConfig,
|
|
276
276
|
};
|
|
277
277
|
}
|
|
@@ -514,7 +514,7 @@ export function reverseFieldConfigureItems(): FieldConfigureItem[] {
|
|
|
514
514
|
{ label: "{{t('BelongsToMany')}}", value: 'belongsToMany' },
|
|
515
515
|
],
|
|
516
516
|
hidden: ({ context, values }) => !context.showReverseFieldConfig && !get(values, 'autoCreateReverseField'),
|
|
517
|
-
disabled:
|
|
517
|
+
disabled: true,
|
|
518
518
|
},
|
|
519
519
|
{
|
|
520
520
|
name: 'reverseField.uiSchema.title',
|
|
@@ -41,6 +41,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
|
|
|
41
41
|
const inputId = useId().replace(/:/g, '');
|
|
42
42
|
const scannerElementId = `code-scanner-${inputId}`;
|
|
43
43
|
const imgUploaderRef = useRef<HTMLInputElement>(null);
|
|
44
|
+
const scannerViewportRef = useRef<HTMLDivElement>(null);
|
|
44
45
|
const [cameraAvailable, setCameraAvailable] = useState(false);
|
|
45
46
|
const [scannerSize, setScannerSize] = useState({ width: 0, height: 0 });
|
|
46
47
|
const [viewport, setViewport] = useState(getViewportSize);
|
|
@@ -84,6 +85,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
|
|
|
84
85
|
elementId: scannerElementId,
|
|
85
86
|
formatsToSupport,
|
|
86
87
|
onScannerSizeChanged: setScannerSize,
|
|
88
|
+
scanViewportRef: scannerViewportRef,
|
|
87
89
|
onScanSuccess: handleScanSuccess,
|
|
88
90
|
onScanFailure: showScanFailure,
|
|
89
91
|
onCameraStartFailure: showCameraStartFailure,
|
|
@@ -207,7 +209,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
|
|
|
207
209
|
|
|
208
210
|
return (
|
|
209
211
|
<div className={rootClass}>
|
|
210
|
-
<div className={scannerWrapperClass}>
|
|
212
|
+
<div ref={scannerViewportRef} className={scannerWrapperClass}>
|
|
211
213
|
<div
|
|
212
214
|
className={scannerSurfaceClass}
|
|
213
215
|
style={{
|
|
@@ -14,6 +14,7 @@ import { CodeScanner } from '../CodeScanner';
|
|
|
14
14
|
|
|
15
15
|
const mocks = vi.hoisted(() => ({
|
|
16
16
|
getCameras: vi.fn().mockResolvedValue([{ id: 'rear-camera' }]),
|
|
17
|
+
scanViewport: null as HTMLElement | null,
|
|
17
18
|
}));
|
|
18
19
|
|
|
19
20
|
vi.mock('html5-qrcode', () => ({
|
|
@@ -53,15 +54,18 @@ vi.mock('../useCodeScanner', async (importOriginal) => {
|
|
|
53
54
|
useCodeScanner: ({
|
|
54
55
|
enabled,
|
|
55
56
|
onScannerSizeChanged,
|
|
57
|
+
scanViewportRef,
|
|
56
58
|
}: {
|
|
57
59
|
enabled: boolean;
|
|
58
60
|
onScannerSizeChanged?: (size: { width: number; height: number }) => void;
|
|
61
|
+
scanViewportRef?: { current: HTMLElement | null };
|
|
59
62
|
}) => {
|
|
60
63
|
ReactModule.useEffect(() => {
|
|
61
64
|
if (enabled) {
|
|
65
|
+
mocks.scanViewport = scanViewportRef?.current || null;
|
|
62
66
|
onScannerSizeChanged?.({ width: 1280, height: 720 });
|
|
63
67
|
}
|
|
64
|
-
}, [enabled, onScannerSizeChanged]);
|
|
68
|
+
}, [enabled, onScannerSizeChanged, scanViewportRef]);
|
|
65
69
|
|
|
66
70
|
return { startScanFile: vi.fn() };
|
|
67
71
|
},
|
|
@@ -70,6 +74,7 @@ vi.mock('../useCodeScanner', async (importOriginal) => {
|
|
|
70
74
|
|
|
71
75
|
describe('CodeScanner', () => {
|
|
72
76
|
afterEach(() => {
|
|
77
|
+
mocks.scanViewport = null;
|
|
73
78
|
vi.restoreAllMocks();
|
|
74
79
|
});
|
|
75
80
|
|
|
@@ -82,6 +87,7 @@ describe('CodeScanner', () => {
|
|
|
82
87
|
render(<CodeScanner visible onClose={() => undefined} onScanSuccess={() => undefined} />);
|
|
83
88
|
|
|
84
89
|
await waitFor(() => expect(document.getElementById('code-scan-box')).toBeInTheDocument());
|
|
90
|
+
expect(mocks.scanViewport).toBeInstanceOf(HTMLElement);
|
|
85
91
|
|
|
86
92
|
const scannerElement = document.querySelector<HTMLElement>('[id^="code-scanner-"]');
|
|
87
93
|
const scannerSurface = scannerElement?.parentElement;
|
|
@@ -10,7 +10,14 @@
|
|
|
10
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 {
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_CODE_FORMATS,
|
|
15
|
+
getCodeScanBoxSize,
|
|
16
|
+
isIOSBrowser,
|
|
17
|
+
scanQrVideoFrame,
|
|
18
|
+
scanQrVideoFrameWithZxingWasm,
|
|
19
|
+
useCodeScanner,
|
|
20
|
+
} from '../useCodeScanner';
|
|
14
21
|
|
|
15
22
|
type MockScannerInstance = {
|
|
16
23
|
applyVideoConstraints: ReturnType<typeof vi.fn>;
|
|
@@ -58,6 +65,10 @@ const jsQrMocks = vi.hoisted(() => {
|
|
|
58
65
|
};
|
|
59
66
|
});
|
|
60
67
|
|
|
68
|
+
const zxingWasmMocks = vi.hoisted(() => ({
|
|
69
|
+
decodeQrCodeWithZxingWasm: vi.fn(),
|
|
70
|
+
}));
|
|
71
|
+
|
|
61
72
|
vi.mock('html5-qrcode', () => ({
|
|
62
73
|
Html5Qrcode: mocks.Html5Qrcode,
|
|
63
74
|
Html5QrcodeScannerState: {
|
|
@@ -81,6 +92,7 @@ vi.mock('html5-qrcode', () => ({
|
|
|
81
92
|
}));
|
|
82
93
|
|
|
83
94
|
vi.mock('jsqr', () => jsQrMocks);
|
|
95
|
+
vi.mock('../zxingWasmDecoder', () => zxingWasmMocks);
|
|
84
96
|
|
|
85
97
|
function ScannerHost({
|
|
86
98
|
onCameraStartFailure,
|
|
@@ -148,19 +160,71 @@ function stubImageElement(size: { width: number; height: number } = { width: 600
|
|
|
148
160
|
});
|
|
149
161
|
}
|
|
150
162
|
|
|
151
|
-
function stubCanvas() {
|
|
163
|
+
function stubCanvas(imageData?: ImageData) {
|
|
152
164
|
const canvasContext = {
|
|
153
165
|
drawImage: vi.fn(),
|
|
154
|
-
getImageData: vi.fn(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
getImageData: vi.fn(
|
|
167
|
+
(_x: number, _y: number, width: number, height: number) =>
|
|
168
|
+
imageData || {
|
|
169
|
+
data: new Uint8ClampedArray(width * height * 4),
|
|
170
|
+
height,
|
|
171
|
+
width,
|
|
172
|
+
},
|
|
173
|
+
),
|
|
159
174
|
} as unknown as CanvasRenderingContext2D;
|
|
160
175
|
|
|
161
176
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvasContext);
|
|
162
177
|
}
|
|
163
178
|
|
|
179
|
+
function createCustomerQrFrame(width: number, height: number) {
|
|
180
|
+
const modules = [
|
|
181
|
+
'111111101101001111111',
|
|
182
|
+
'100000100101101000001',
|
|
183
|
+
'101110100111101011101',
|
|
184
|
+
'101110100111101011101',
|
|
185
|
+
'101110101100101011101',
|
|
186
|
+
'100000101000101000001',
|
|
187
|
+
'111111101010101111111',
|
|
188
|
+
'000000000111100000000',
|
|
189
|
+
'011111110001100110001',
|
|
190
|
+
'011010001101101111110',
|
|
191
|
+
'101101110010011000110',
|
|
192
|
+
'011000001011010110101',
|
|
193
|
+
'011110111011000100100',
|
|
194
|
+
'000000001100100001010',
|
|
195
|
+
'111111101001100101110',
|
|
196
|
+
'100000101001100001101',
|
|
197
|
+
'101110101010000101110',
|
|
198
|
+
'101110101011111001000',
|
|
199
|
+
'101110101110101011000',
|
|
200
|
+
'100000101101111010001',
|
|
201
|
+
'111111100110100010000',
|
|
202
|
+
];
|
|
203
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
204
|
+
data.fill(255);
|
|
205
|
+
const moduleSize = 12;
|
|
206
|
+
const left = Math.floor((width - modules.length * moduleSize) / 2);
|
|
207
|
+
const top = 60;
|
|
208
|
+
|
|
209
|
+
modules.forEach((row, rowIndex) => {
|
|
210
|
+
Array.from(row).forEach((module, columnIndex) => {
|
|
211
|
+
if (module !== '1') {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (let y = 0; y < moduleSize; y += 1) {
|
|
215
|
+
for (let x = 0; x < moduleSize; x += 1) {
|
|
216
|
+
const pixelIndex = ((top + rowIndex * moduleSize + y) * width + left + columnIndex * moduleSize + x) * 4;
|
|
217
|
+
data[pixelIndex] = 0;
|
|
218
|
+
data[pixelIndex + 1] = 0;
|
|
219
|
+
data[pixelIndex + 2] = 0;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return { data, height, width } as ImageData;
|
|
226
|
+
}
|
|
227
|
+
|
|
164
228
|
describe('useCodeScanner', () => {
|
|
165
229
|
beforeEach(() => {
|
|
166
230
|
vi.clearAllMocks();
|
|
@@ -170,6 +234,7 @@ describe('useCodeScanner', () => {
|
|
|
170
234
|
mocks.stop.mockResolvedValue(null);
|
|
171
235
|
mocks.getState.mockReturnValue(1);
|
|
172
236
|
jsQrMocks.default.mockReturnValue(undefined);
|
|
237
|
+
zxingWasmMocks.decodeQrCodeWithZxingWasm.mockResolvedValue(undefined);
|
|
173
238
|
});
|
|
174
239
|
|
|
175
240
|
afterEach(() => {
|
|
@@ -233,7 +298,7 @@ describe('useCodeScanner', () => {
|
|
|
233
298
|
});
|
|
234
299
|
});
|
|
235
300
|
|
|
236
|
-
it('
|
|
301
|
+
it('keeps the existing centered scan region outside iOS', () => {
|
|
237
302
|
jsQrMocks.default.mockReturnValueOnce({ data: 'FAST-QR' });
|
|
238
303
|
stubCanvas();
|
|
239
304
|
const video = document.createElement('video');
|
|
@@ -255,6 +320,115 @@ describe('useCodeScanner', () => {
|
|
|
255
320
|
});
|
|
256
321
|
});
|
|
257
322
|
|
|
323
|
+
it('decodes the camera preview visible on iOS without scanning offscreen content', async () => {
|
|
324
|
+
const actualJsQr = await vi.importActual<typeof import('jsqr')>('jsqr');
|
|
325
|
+
jsQrMocks.default.mockImplementationOnce(actualJsQr.default);
|
|
326
|
+
stubCanvas(createCustomerQrFrame(431, 933));
|
|
327
|
+
const video = document.createElement('video');
|
|
328
|
+
Object.defineProperties(video, {
|
|
329
|
+
getBoundingClientRect: {
|
|
330
|
+
value: () => ({ bottom: 864, height: 864, left: -573, right: 963, top: 0, width: 1536 }),
|
|
331
|
+
},
|
|
332
|
+
readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
|
|
333
|
+
videoHeight: { value: 1080 },
|
|
334
|
+
videoWidth: { value: 1920 },
|
|
335
|
+
});
|
|
336
|
+
const scanViewport = document.createElement('div');
|
|
337
|
+
vi.spyOn(scanViewport, 'getBoundingClientRect').mockReturnValue({
|
|
338
|
+
bottom: 844,
|
|
339
|
+
height: 844,
|
|
340
|
+
left: 0,
|
|
341
|
+
right: 390,
|
|
342
|
+
top: 0,
|
|
343
|
+
width: 390,
|
|
344
|
+
x: 0,
|
|
345
|
+
y: 0,
|
|
346
|
+
toJSON: () => undefined,
|
|
347
|
+
});
|
|
348
|
+
const canvas = document.createElement('canvas');
|
|
349
|
+
|
|
350
|
+
expect(scanQrVideoFrame(video, canvas, scanViewport)).toBe('TCBNVBY064J3');
|
|
351
|
+
|
|
352
|
+
const context = canvas.getContext('2d');
|
|
353
|
+
expect(context?.drawImage).toHaveBeenCalledWith(video, 716, 0, 488, 1055, 0, 0, 431, 933);
|
|
354
|
+
expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 431, 933, {
|
|
355
|
+
inversionAttempts: 'dontInvert',
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('uses ZXing WASM for high-resolution iOS camera frames', async () => {
|
|
360
|
+
const imageData = createCustomerQrFrame(431, 933);
|
|
361
|
+
stubCanvas(imageData);
|
|
362
|
+
zxingWasmMocks.decodeQrCodeWithZxingWasm.mockResolvedValueOnce('CUSTOMER-QR');
|
|
363
|
+
const video = document.createElement('video');
|
|
364
|
+
Object.defineProperties(video, {
|
|
365
|
+
getBoundingClientRect: {
|
|
366
|
+
value: () => ({ bottom: 864, height: 864, left: -573, right: 963, top: 0, width: 1536 }),
|
|
367
|
+
},
|
|
368
|
+
readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
|
|
369
|
+
videoHeight: { value: 1080 },
|
|
370
|
+
videoWidth: { value: 1920 },
|
|
371
|
+
});
|
|
372
|
+
const scanViewport = document.createElement('div');
|
|
373
|
+
vi.spyOn(scanViewport, 'getBoundingClientRect').mockReturnValue({
|
|
374
|
+
bottom: 844,
|
|
375
|
+
height: 844,
|
|
376
|
+
left: 0,
|
|
377
|
+
right: 390,
|
|
378
|
+
top: 0,
|
|
379
|
+
width: 390,
|
|
380
|
+
x: 0,
|
|
381
|
+
y: 0,
|
|
382
|
+
toJSON: () => undefined,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await expect(scanQrVideoFrameWithZxingWasm(video, document.createElement('canvas'), scanViewport)).resolves.toBe(
|
|
386
|
+
'CUSTOMER-QR',
|
|
387
|
+
);
|
|
388
|
+
const context = document.createElement('canvas').getContext('2d');
|
|
389
|
+
expect(context?.drawImage).toHaveBeenCalledWith(video, 716, 0, 488, 1055, 0, 0, 488, 1055);
|
|
390
|
+
expect(jsQrMocks.default).not.toHaveBeenCalled();
|
|
391
|
+
expect(zxingWasmMocks.decodeQrCodeWithZxingWasm).toHaveBeenCalledWith(imageData);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('detects iPhone browsers', () => {
|
|
395
|
+
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)');
|
|
396
|
+
expect(isIOSBrowser()).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('detects iPad browsers using desktop mode', () => {
|
|
400
|
+
vi.stubGlobal('navigator', {
|
|
401
|
+
maxTouchPoints: 5,
|
|
402
|
+
platform: 'MacIntel',
|
|
403
|
+
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
|
404
|
+
});
|
|
405
|
+
expect(isIOSBrowser()).toBe(true);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('keeps the iOS preview path disabled on other platforms', () => {
|
|
409
|
+
vi.stubGlobal('navigator', {
|
|
410
|
+
maxTouchPoints: 0,
|
|
411
|
+
platform: 'Linux x86_64',
|
|
412
|
+
userAgent: 'Mozilla/5.0 (X11; Linux x86_64)',
|
|
413
|
+
});
|
|
414
|
+
expect(isIOSBrowser()).toBe(false);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('waits for a measurable iOS preview instead of scanning hidden camera content', () => {
|
|
418
|
+
stubCanvas();
|
|
419
|
+
const video = document.createElement('video');
|
|
420
|
+
Object.defineProperties(video, {
|
|
421
|
+
readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
|
|
422
|
+
videoHeight: { value: 1080 },
|
|
423
|
+
videoWidth: { value: 1920 },
|
|
424
|
+
});
|
|
425
|
+
const canvas = document.createElement('canvas');
|
|
426
|
+
const scanViewport = document.createElement('div');
|
|
427
|
+
|
|
428
|
+
expect(scanQrVideoFrame(video, canvas, scanViewport)).toBeUndefined();
|
|
429
|
+
expect(jsQrMocks.default).not.toHaveBeenCalled();
|
|
430
|
+
});
|
|
431
|
+
|
|
258
432
|
it('enables continuous camera focus when the device supports it', async () => {
|
|
259
433
|
mocks.getRunningTrackCapabilities.mockReturnValue({ focusMode: ['manual', 'continuous'] });
|
|
260
434
|
|
|
@@ -0,0 +1,78 @@
|
|
|
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 { beforeEach, expect, it, vi } from 'vitest';
|
|
11
|
+
import { decodeQrCodeWithZxingWasm } from '../zxingWasmDecoder';
|
|
12
|
+
|
|
13
|
+
const mocks = vi.hoisted(() => ({
|
|
14
|
+
prepareZXingModule: vi.fn(),
|
|
15
|
+
purgeZXingModule: vi.fn(),
|
|
16
|
+
readBarcodes: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('zxing-wasm/reader', () => mocks);
|
|
20
|
+
vi.mock('zxing-wasm/reader/zxing_reader.wasm', () => ({ default: '/assets/zxing_reader.wasm' }));
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
mocks.prepareZXingModule.mockResolvedValue(undefined);
|
|
25
|
+
mocks.readBarcodes.mockResolvedValue([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('retries decoder loading and uses downscaled QR detection', async () => {
|
|
29
|
+
const imageData = { data: new Uint8ClampedArray(16), height: 2, width: 2 } as ImageData;
|
|
30
|
+
mocks.prepareZXingModule.mockRejectedValueOnce(new Error('decoder loading failed'));
|
|
31
|
+
|
|
32
|
+
await expect(decodeQrCodeWithZxingWasm(imageData)).rejects.toThrow('decoder loading failed');
|
|
33
|
+
expect(mocks.purgeZXingModule).toHaveBeenCalledTimes(1);
|
|
34
|
+
|
|
35
|
+
mocks.readBarcodes.mockResolvedValueOnce([{ text: 'CUSTOMER-QR' }]);
|
|
36
|
+
|
|
37
|
+
await expect(decodeQrCodeWithZxingWasm(imageData)).resolves.toBe('CUSTOMER-QR');
|
|
38
|
+
expect(mocks.prepareZXingModule).toHaveBeenCalledTimes(2);
|
|
39
|
+
const { overrides } = mocks.prepareZXingModule.mock.calls[1][0] as {
|
|
40
|
+
overrides: { locateFile: () => string };
|
|
41
|
+
};
|
|
42
|
+
const { locateFile } = overrides;
|
|
43
|
+
expect(locateFile()).toBe('/assets/zxing_reader.wasm');
|
|
44
|
+
expect(mocks.prepareZXingModule).toHaveBeenLastCalledWith({
|
|
45
|
+
fireImmediately: true,
|
|
46
|
+
overrides: { locateFile: expect.any(Function) },
|
|
47
|
+
});
|
|
48
|
+
expect(mocks.readBarcodes).toHaveBeenCalledWith(imageData, {
|
|
49
|
+
binarizer: 'GlobalHistogram',
|
|
50
|
+
downscaleThreshold: 300,
|
|
51
|
+
formats: ['QRCode'],
|
|
52
|
+
maxNumberOfSymbols: 1,
|
|
53
|
+
tryDenoise: true,
|
|
54
|
+
tryDownscale: true,
|
|
55
|
+
tryHarder: true,
|
|
56
|
+
tryInvert: true,
|
|
57
|
+
tryRotate: true,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('retries reflective frames with local adaptive binarization', async () => {
|
|
62
|
+
const imageData = { data: new Uint8ClampedArray(16), height: 2, width: 2 } as ImageData;
|
|
63
|
+
mocks.readBarcodes.mockResolvedValueOnce([]).mockResolvedValueOnce([{ text: 'REFLECTIVE-QR' }]);
|
|
64
|
+
|
|
65
|
+
await expect(decodeQrCodeWithZxingWasm(imageData)).resolves.toBe('REFLECTIVE-QR');
|
|
66
|
+
expect(mocks.readBarcodes).toHaveBeenCalledTimes(2);
|
|
67
|
+
expect(mocks.readBarcodes).toHaveBeenNthCalledWith(2, imageData, {
|
|
68
|
+
binarizer: 'LocalAverage',
|
|
69
|
+
downscaleThreshold: 300,
|
|
70
|
+
formats: ['QRCode'],
|
|
71
|
+
maxNumberOfSymbols: 1,
|
|
72
|
+
tryDenoise: true,
|
|
73
|
+
tryDownscale: true,
|
|
74
|
+
tryHarder: true,
|
|
75
|
+
tryInvert: true,
|
|
76
|
+
tryRotate: true,
|
|
77
|
+
});
|
|
78
|
+
});
|