@nocobase/client-v2 2.2.0-alpha.6 → 2.2.0-alpha.7
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/BaseApplication.d.ts +1 -0
- package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
- package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
- package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +1 -0
- package/es/flow/components/RunJSValueEditor.d.ts +1 -0
- package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
- package/es/index.mjs +86 -86
- package/lib/index.js +95 -95
- package/package.json +8 -7
- package/src/BaseApplication.tsx +9 -5
- package/src/__tests__/app.test.tsx +26 -0
- package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
- package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +145 -2
- package/src/components/form/ScanInput/useCodeScanner.ts +154 -2
- package/src/flow/FlowPage.tsx +9 -1
- package/src/flow/__tests__/FlowPage.test.tsx +50 -3
- package/src/flow/__tests__/FlowRoute.test.tsx +2 -2
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +0 -1
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +253 -6
- package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
- package/src/flow/components/FieldAssignValueInput.tsx +60 -15
- package/src/flow/components/FlowRoute.tsx +7 -3
- package/src/flow/components/RunJSValueEditor.tsx +9 -1
- package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +134 -0
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +1 -0
- package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +27 -12
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +36 -0
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +34 -0
- package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
- package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +1 -0
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +20 -10
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +24 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.2.0-alpha.
|
|
3
|
+
"version": "2.2.0-alpha.7",
|
|
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.2.0-alpha.
|
|
31
|
-
"@nocobase/flow-engine": "2.2.0-alpha.
|
|
32
|
-
"@nocobase/sdk": "2.2.0-alpha.
|
|
33
|
-
"@nocobase/shared": "2.2.0-alpha.
|
|
34
|
-
"@nocobase/utils": "2.2.0-alpha.
|
|
30
|
+
"@nocobase/evaluators": "2.2.0-alpha.7",
|
|
31
|
+
"@nocobase/flow-engine": "2.2.0-alpha.7",
|
|
32
|
+
"@nocobase/sdk": "2.2.0-alpha.7",
|
|
33
|
+
"@nocobase/shared": "2.2.0-alpha.7",
|
|
34
|
+
"@nocobase/utils": "2.2.0-alpha.7",
|
|
35
35
|
"ahooks": "^3.7.2",
|
|
36
36
|
"antd": "5.24.2",
|
|
37
37
|
"antd-style": "3.7.1",
|
|
@@ -42,9 +42,10 @@
|
|
|
42
42
|
"html5-qrcode": "^2.3.8",
|
|
43
43
|
"i18next": "^22.4.9",
|
|
44
44
|
"json5": "^2.2.3",
|
|
45
|
+
"jsqr": "^1.4.0",
|
|
45
46
|
"lodash": "4.17.21",
|
|
46
47
|
"react-i18next": "^11.15.1",
|
|
47
48
|
"react-router-dom": "^6.30.1"
|
|
48
49
|
},
|
|
49
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "34ba02960fca9ab0b6881d3db7040a8a64067bba"
|
|
50
51
|
}
|
package/src/BaseApplication.tsx
CHANGED
|
@@ -81,6 +81,10 @@ const trimTrailingSlashes = (value: string) => {
|
|
|
81
81
|
return match ? value.slice(0, -match[0].length) : value;
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
+
const ensureTrailingSlash = (value: string) => {
|
|
85
|
+
return `${trimTrailingSlashes(value)}/`;
|
|
86
|
+
};
|
|
87
|
+
|
|
84
88
|
const isRenderableComponentType = (value: unknown): value is AnyComponent => isValidElementType(value);
|
|
85
89
|
|
|
86
90
|
export type DevDynamicImport = (packageName: string) => Promise<{ default: PluginClass }>;
|
|
@@ -433,12 +437,12 @@ export abstract class BaseApplication<
|
|
|
433
437
|
return getSubAppName(this.getPublicPath()) || null;
|
|
434
438
|
}
|
|
435
439
|
|
|
440
|
+
getCdnUrl() {
|
|
441
|
+
return ensureTrailingSlash(window['__webpack_public_path__'] || this.getPublicPath());
|
|
442
|
+
}
|
|
443
|
+
|
|
436
444
|
getPublicPath() {
|
|
437
|
-
|
|
438
|
-
if (!publicPath.endsWith('/')) {
|
|
439
|
-
publicPath += '/';
|
|
440
|
-
}
|
|
441
|
-
return publicPath;
|
|
445
|
+
return ensureTrailingSlash(this.options.publicPath || '/');
|
|
442
446
|
}
|
|
443
447
|
|
|
444
448
|
getApiUrl(pathname = '') {
|
|
@@ -34,6 +34,7 @@ describe('app', () => {
|
|
|
34
34
|
afterEach(() => {
|
|
35
35
|
document.querySelectorAll('link[rel="shortcut icon"]').forEach((node) => node.remove());
|
|
36
36
|
document.documentElement.removeAttribute('lang');
|
|
37
|
+
delete window['__webpack_public_path__'];
|
|
37
38
|
vi.restoreAllMocks();
|
|
38
39
|
});
|
|
39
40
|
|
|
@@ -58,6 +59,31 @@ describe('app', () => {
|
|
|
58
59
|
expect(app.jsonLogic.apply({ $testAlwaysTrue: [] })).toBe(true);
|
|
59
60
|
});
|
|
60
61
|
|
|
62
|
+
it('should normalize publicPath and webpack public path with a single trailing slash', () => {
|
|
63
|
+
const app = new Application({
|
|
64
|
+
router,
|
|
65
|
+
publicPath: '/admin//',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(app.getPublicPath()).toBe('/admin/');
|
|
69
|
+
|
|
70
|
+
window['__webpack_public_path__'] = '/cdn/assets///';
|
|
71
|
+
expect(app.getCdnUrl()).toBe('/cdn/assets/');
|
|
72
|
+
|
|
73
|
+
delete window['__webpack_public_path__'];
|
|
74
|
+
expect(app.getCdnUrl()).toBe('/admin/');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should normalize webpack public path without a trailing slash', () => {
|
|
78
|
+
const app = new Application({
|
|
79
|
+
router,
|
|
80
|
+
publicPath: '/admin/',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
window['__webpack_public_path__'] = '/cdn/assets';
|
|
84
|
+
expect(app.getCdnUrl()).toBe('/cdn/assets/');
|
|
85
|
+
});
|
|
86
|
+
|
|
61
87
|
it('should apply the provided favicon immediately', () => {
|
|
62
88
|
const app = new Application({ router });
|
|
63
89
|
|
|
@@ -115,6 +115,7 @@ export abstract class CollectionFieldInterface {
|
|
|
115
115
|
titleUsable?: boolean;
|
|
116
116
|
usePathOptions?(field: any): any;
|
|
117
117
|
hidden?: boolean;
|
|
118
|
+
creatable?: boolean;
|
|
118
119
|
|
|
119
120
|
addComponentOption(componentOption: CollectionFieldInterfaceComponentOption) {
|
|
120
121
|
if (!this.componentOptions) {
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { render, waitFor } from '@testing-library/react';
|
|
10
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
11
|
import React, { useCallback } from 'react';
|
|
12
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
13
|
import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, useCodeScanner } from '../useCodeScanner';
|
|
14
14
|
|
|
15
15
|
type MockScannerInstance = {
|
|
@@ -44,6 +44,12 @@ const mocks = vi.hoisted(() => {
|
|
|
44
44
|
};
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
const jsQrMocks = vi.hoisted(() => {
|
|
48
|
+
return {
|
|
49
|
+
default: vi.fn(),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
47
53
|
vi.mock('html5-qrcode', () => ({
|
|
48
54
|
Html5Qrcode: mocks.Html5Qrcode,
|
|
49
55
|
Html5QrcodeScannerState: {
|
|
@@ -66,6 +72,8 @@ vi.mock('html5-qrcode', () => ({
|
|
|
66
72
|
},
|
|
67
73
|
}));
|
|
68
74
|
|
|
75
|
+
vi.mock('jsqr', () => jsQrMocks);
|
|
76
|
+
|
|
69
77
|
function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: number } } = {}) {
|
|
70
78
|
const handleScanSuccess = useCallback(() => undefined, []);
|
|
71
79
|
|
|
@@ -79,9 +87,74 @@ function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: n
|
|
|
79
87
|
return <div id="scanner" />;
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
function FileScannerHost({ onScanSuccess }: { onScanSuccess: (text: string) => void }) {
|
|
91
|
+
const { startScanFile } = useCodeScanner({
|
|
92
|
+
elementId: 'scanner',
|
|
93
|
+
enabled: true,
|
|
94
|
+
onScanSuccess,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<div id="scanner" />
|
|
100
|
+
<button onClick={() => startScanFile(new File(['qr'], 'qr.png', { type: 'image/png' }))}>scan file</button>
|
|
101
|
+
</>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stubSafariBrowser() {
|
|
106
|
+
vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.');
|
|
107
|
+
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
|
|
108
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stubImageElement(size: { width: number; height: number } = { width: 600, height: 400 }) {
|
|
113
|
+
vi.stubGlobal(
|
|
114
|
+
'Image',
|
|
115
|
+
class MockImage {
|
|
116
|
+
onabort: ((event: Event) => void) | null = null;
|
|
117
|
+
onerror: ((event: Event) => void) | null = null;
|
|
118
|
+
onload: (() => void) | null = null;
|
|
119
|
+
height = size.height;
|
|
120
|
+
naturalHeight = size.height;
|
|
121
|
+
naturalWidth = size.width;
|
|
122
|
+
width = size.width;
|
|
123
|
+
|
|
124
|
+
set src(_value: string) {
|
|
125
|
+
this.onload?.();
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
vi.stubGlobal('URL', {
|
|
130
|
+
...URL,
|
|
131
|
+
createObjectURL: vi.fn(() => 'blob:qr'),
|
|
132
|
+
revokeObjectURL: vi.fn(),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function stubCanvas() {
|
|
137
|
+
const canvasContext = {
|
|
138
|
+
drawImage: vi.fn(),
|
|
139
|
+
getImageData: vi.fn((_x: number, _y: number, width: number, height: number) => ({
|
|
140
|
+
data: new Uint8ClampedArray(width * height * 4),
|
|
141
|
+
height,
|
|
142
|
+
width,
|
|
143
|
+
})),
|
|
144
|
+
} as unknown as CanvasRenderingContext2D;
|
|
145
|
+
|
|
146
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvasContext);
|
|
147
|
+
}
|
|
148
|
+
|
|
82
149
|
describe('useCodeScanner', () => {
|
|
83
150
|
beforeEach(() => {
|
|
84
151
|
vi.clearAllMocks();
|
|
152
|
+
jsQrMocks.default.mockReturnValue(undefined);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
afterEach(() => {
|
|
156
|
+
vi.unstubAllGlobals();
|
|
157
|
+
vi.restoreAllMocks();
|
|
85
158
|
});
|
|
86
159
|
|
|
87
160
|
it('starts scanning with QR code and barcode formats by default', async () => {
|
|
@@ -120,4 +193,74 @@ describe('useCodeScanner', () => {
|
|
|
120
193
|
|
|
121
194
|
expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
|
|
122
195
|
});
|
|
196
|
+
|
|
197
|
+
it('uses jsQR first for Safari uploaded QR images', async () => {
|
|
198
|
+
const handleScanSuccess = vi.fn();
|
|
199
|
+
jsQrMocks.default.mockReturnValueOnce({ data: 'JSQR-CODE' });
|
|
200
|
+
stubSafariBrowser();
|
|
201
|
+
stubImageElement();
|
|
202
|
+
stubCanvas();
|
|
203
|
+
|
|
204
|
+
render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
|
|
205
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
206
|
+
|
|
207
|
+
fireEvent.click(screen.getByText('scan file'));
|
|
208
|
+
|
|
209
|
+
await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('JSQR-CODE'));
|
|
210
|
+
expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 600, 400, {
|
|
211
|
+
inversionAttempts: 'attemptBoth',
|
|
212
|
+
});
|
|
213
|
+
expect(mocks.scanFileV2).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('falls back to html5-qrcode for Safari uploaded files when jsQR does not decode a QR code', async () => {
|
|
217
|
+
const handleScanSuccess = vi.fn();
|
|
218
|
+
mocks.scanFileV2.mockResolvedValueOnce({ decodedText: 'BARCODE-CODE' });
|
|
219
|
+
stubSafariBrowser();
|
|
220
|
+
stubImageElement();
|
|
221
|
+
stubCanvas();
|
|
222
|
+
|
|
223
|
+
render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
|
|
224
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
225
|
+
|
|
226
|
+
fireEvent.click(screen.getByText('scan file'));
|
|
227
|
+
|
|
228
|
+
await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('BARCODE-CODE'));
|
|
229
|
+
expect(jsQrMocks.default).toHaveBeenCalled();
|
|
230
|
+
expect(mocks.scanFileV2).toHaveBeenCalled();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('tries multiple image scales before falling back from Safari jsQR scanning', async () => {
|
|
234
|
+
const handleScanSuccess = vi.fn();
|
|
235
|
+
Array.from({ length: 8 }).forEach((_, index) => {
|
|
236
|
+
jsQrMocks.default.mockReturnValueOnce(index === 7 ? { data: 'SCALED-CODE' } : undefined);
|
|
237
|
+
});
|
|
238
|
+
stubSafariBrowser();
|
|
239
|
+
stubImageElement({ width: 4000, height: 3000 });
|
|
240
|
+
stubCanvas();
|
|
241
|
+
|
|
242
|
+
render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
|
|
243
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
244
|
+
|
|
245
|
+
fireEvent.click(screen.getByText('scan file'));
|
|
246
|
+
|
|
247
|
+
await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('SCALED-CODE'));
|
|
248
|
+
expect(jsQrMocks.default).toHaveBeenCalledTimes(8);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('tries enhanced grayscale QR images for blurry Safari uploads', async () => {
|
|
252
|
+
const handleScanSuccess = vi.fn();
|
|
253
|
+
jsQrMocks.default.mockReturnValueOnce(undefined).mockReturnValueOnce({ data: 'ENHANCED-CODE' });
|
|
254
|
+
stubSafariBrowser();
|
|
255
|
+
stubImageElement();
|
|
256
|
+
stubCanvas();
|
|
257
|
+
|
|
258
|
+
render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
|
|
259
|
+
await waitFor(() => expect(mocks.start).toHaveBeenCalled());
|
|
260
|
+
|
|
261
|
+
fireEvent.click(screen.getByText('scan file'));
|
|
262
|
+
|
|
263
|
+
await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('ENHANCED-CODE'));
|
|
264
|
+
expect(jsQrMocks.default).toHaveBeenCalledTimes(2);
|
|
265
|
+
});
|
|
123
266
|
});
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
|
11
|
+
import jsQR from 'jsqr';
|
|
11
12
|
import { useCallback, useEffect, useState } from 'react';
|
|
12
13
|
import type { CodeFormatsToSupport } from './types';
|
|
13
14
|
|
|
@@ -26,6 +27,27 @@ type UseCodeScannerOptions = {
|
|
|
26
27
|
onScanFailure?: () => void;
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
type ImageDataVariant = {
|
|
31
|
+
imageData: ImageData;
|
|
32
|
+
maxSize: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type JsQRImageTransform = {
|
|
36
|
+
contrast?: number;
|
|
37
|
+
threshold?: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const QR_SCAN_IMAGE_SIZES = [3200, 2400, 1600, 1000];
|
|
41
|
+
const QR_SCAN_IMAGE_TRANSFORMS: JsQRImageTransform[] = [
|
|
42
|
+
{},
|
|
43
|
+
{ contrast: 3, threshold: 105 },
|
|
44
|
+
{ contrast: 2, threshold: 105 },
|
|
45
|
+
{ contrast: 3, threshold: 120 },
|
|
46
|
+
{ contrast: 2, threshold: 120 },
|
|
47
|
+
{ contrast: 3, threshold: 90 },
|
|
48
|
+
{ contrast: 4, threshold: 105 },
|
|
49
|
+
];
|
|
50
|
+
|
|
29
51
|
export const DEFAULT_CODE_FORMATS: CodeFormatsToSupport = [
|
|
30
52
|
Html5QrcodeSupportedFormats.QR_CODE,
|
|
31
53
|
Html5QrcodeSupportedFormats.CODE_128,
|
|
@@ -62,6 +84,126 @@ async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } =
|
|
|
62
84
|
}
|
|
63
85
|
}
|
|
64
86
|
|
|
87
|
+
function isSafariBrowser() {
|
|
88
|
+
const { userAgent, vendor } = navigator;
|
|
89
|
+
return /Apple/i.test(vendor) && /Safari/i.test(userAgent) && !/CriOS|FxiOS|EdgiOS|Chrome/i.test(userAgent);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function loadImage(file: File) {
|
|
93
|
+
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
94
|
+
const image = new Image();
|
|
95
|
+
const url = URL.createObjectURL(file);
|
|
96
|
+
const cleanup = () => URL.revokeObjectURL(url);
|
|
97
|
+
|
|
98
|
+
image.onload = () => {
|
|
99
|
+
cleanup();
|
|
100
|
+
resolve(image);
|
|
101
|
+
};
|
|
102
|
+
image.onerror = (event) => {
|
|
103
|
+
cleanup();
|
|
104
|
+
reject(event);
|
|
105
|
+
};
|
|
106
|
+
image.onabort = (event) => {
|
|
107
|
+
cleanup();
|
|
108
|
+
reject(event);
|
|
109
|
+
};
|
|
110
|
+
image.src = url;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getScanImageSizes(naturalWidth: number, naturalHeight: number) {
|
|
115
|
+
const maxSize = Math.max(naturalWidth, naturalHeight);
|
|
116
|
+
const cappedMaxSize = Math.min(maxSize, QR_SCAN_IMAGE_SIZES[0]);
|
|
117
|
+
return Array.from(new Set([cappedMaxSize, ...QR_SCAN_IMAGE_SIZES.filter((size) => size < cappedMaxSize)])).sort(
|
|
118
|
+
(a, b) => b - a,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getImageDataFromImage(image: HTMLImageElement, maxSize: number): ImageDataVariant | undefined {
|
|
123
|
+
const naturalWidth = image.naturalWidth || image.width;
|
|
124
|
+
const naturalHeight = image.naturalHeight || image.height;
|
|
125
|
+
|
|
126
|
+
const scale = Math.min(1, maxSize / Math.max(naturalWidth, naturalHeight));
|
|
127
|
+
const width = Math.max(1, Math.round(naturalWidth * scale));
|
|
128
|
+
const height = Math.max(1, Math.round(naturalHeight * scale));
|
|
129
|
+
const canvas = document.createElement('canvas');
|
|
130
|
+
canvas.width = width;
|
|
131
|
+
canvas.height = height;
|
|
132
|
+
const context = canvas.getContext('2d');
|
|
133
|
+
if (!context) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
context.drawImage(image, 0, 0, width, height);
|
|
138
|
+
return {
|
|
139
|
+
imageData: context.getImageData(0, 0, width, height),
|
|
140
|
+
maxSize,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function getImageDataVariants(file: File) {
|
|
145
|
+
const image = await loadImage(file);
|
|
146
|
+
const naturalWidth = image.naturalWidth || image.width;
|
|
147
|
+
const naturalHeight = image.naturalHeight || image.height;
|
|
148
|
+
|
|
149
|
+
if (!naturalWidth || !naturalHeight) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return getScanImageSizes(naturalWidth, naturalHeight)
|
|
154
|
+
.map((maxSize) => getImageDataFromImage(image, maxSize))
|
|
155
|
+
.filter((variant): variant is ImageDataVariant => !!variant);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getTransformedImageData(imageData: ImageData, transform: JsQRImageTransform) {
|
|
159
|
+
const { contrast = 1, threshold } = transform;
|
|
160
|
+
if (contrast === 1 && threshold == null) {
|
|
161
|
+
return imageData.data;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const data = new Uint8ClampedArray(imageData.data);
|
|
165
|
+
for (let index = 0; index < data.length; index += 4) {
|
|
166
|
+
let luminance = data[index] * 0.299 + data[index + 1] * 0.587 + data[index + 2] * 0.114;
|
|
167
|
+
luminance = Math.max(0, Math.min(255, (luminance - 128) * contrast + 128));
|
|
168
|
+
if (threshold != null) {
|
|
169
|
+
luminance = luminance < threshold ? 0 : 255;
|
|
170
|
+
}
|
|
171
|
+
data[index] = luminance;
|
|
172
|
+
data[index + 1] = luminance;
|
|
173
|
+
data[index + 2] = luminance;
|
|
174
|
+
}
|
|
175
|
+
return data;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function scanFileWithJsQR(file: File, formatsToSupport?: CodeFormatsToSupport) {
|
|
179
|
+
const formats = formatsToSupport?.length ? formatsToSupport : DEFAULT_CODE_FORMATS;
|
|
180
|
+
if (!formats.includes(Html5QrcodeSupportedFormats.QR_CODE)) {
|
|
181
|
+
throw new Error('QR_CODE is not included in the requested formats');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const variants = await getImageDataVariants(file);
|
|
185
|
+
if (!variants?.length) {
|
|
186
|
+
throw new Error('Failed to prepare uploaded image for QR decoding');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const { imageData } of variants) {
|
|
190
|
+
for (const transform of QR_SCAN_IMAGE_TRANSFORMS) {
|
|
191
|
+
const data = getTransformedImageData(imageData, transform);
|
|
192
|
+
const qrCode = jsQR(data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' });
|
|
193
|
+
if (qrCode?.data) {
|
|
194
|
+
return qrCode.data;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
throw new Error('No QR code decoded by jsQR');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function shouldScanQrWithJsQR(formatsToSupport?: CodeFormatsToSupport) {
|
|
203
|
+
const formats = formatsToSupport?.length ? formatsToSupport : DEFAULT_CODE_FORMATS;
|
|
204
|
+
return formats.includes(Html5QrcodeSupportedFormats.QR_CODE);
|
|
205
|
+
}
|
|
206
|
+
|
|
65
207
|
export function useCodeScanner({
|
|
66
208
|
enabled,
|
|
67
209
|
elementId,
|
|
@@ -95,6 +237,16 @@ export function useCodeScanner({
|
|
|
95
237
|
|
|
96
238
|
const startScanFile = useCallback(
|
|
97
239
|
async (file: File) => {
|
|
240
|
+
if (isSafariBrowser() && shouldScanQrWithJsQR(formatsToSupport)) {
|
|
241
|
+
try {
|
|
242
|
+
const decodedText = await scanFileWithJsQR(file, formatsToSupport);
|
|
243
|
+
onScanSuccess(decodedText);
|
|
244
|
+
return;
|
|
245
|
+
} catch {
|
|
246
|
+
// Fall through to html5-qrcode so barcode uploads still work in Safari.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
98
250
|
if (!scanner) {
|
|
99
251
|
return;
|
|
100
252
|
}
|
|
@@ -103,12 +255,12 @@ export function useCodeScanner({
|
|
|
103
255
|
try {
|
|
104
256
|
const result = await scanner.scanFileV2(file, false);
|
|
105
257
|
onScanSuccess(result.decodedText);
|
|
106
|
-
} catch
|
|
258
|
+
} catch {
|
|
107
259
|
onScanFailure?.();
|
|
108
260
|
await startScanCamera(scanner);
|
|
109
261
|
}
|
|
110
262
|
},
|
|
111
|
-
[onScanFailure, onScanSuccess, scanner, startScanCamera],
|
|
263
|
+
[formatsToSupport, onScanFailure, onScanSuccess, scanner, startScanCamera],
|
|
112
264
|
);
|
|
113
265
|
|
|
114
266
|
useEffect(() => {
|
package/src/flow/FlowPage.tsx
CHANGED
|
@@ -60,7 +60,9 @@ type FlowPageProps = {
|
|
|
60
60
|
type FlowPageViewContext = FlowEngineContext & {
|
|
61
61
|
view?: {
|
|
62
62
|
inputArgs?: {
|
|
63
|
+
filterByTk?: unknown;
|
|
63
64
|
isMobileLayout?: unknown;
|
|
65
|
+
sourceId?: unknown;
|
|
64
66
|
};
|
|
65
67
|
};
|
|
66
68
|
};
|
|
@@ -102,6 +104,10 @@ export const FlowPage = React.memo((props: FlowPageProps & Record<string, unknow
|
|
|
102
104
|
subType: 'object',
|
|
103
105
|
use: pageModelClass,
|
|
104
106
|
};
|
|
107
|
+
const isRuntimeRecordScopedPage =
|
|
108
|
+
!flowEngine.context.flowSettingsEnabled &&
|
|
109
|
+
(typeof ctx?.view?.inputArgs?.filterByTk !== 'undefined' ||
|
|
110
|
+
typeof ctx?.view?.inputArgs?.sourceId !== 'undefined');
|
|
105
111
|
if (shouldInjectDefaultChildTab) {
|
|
106
112
|
const tabTitle = defaultTabTitle || flowEngine.translate?.('Details');
|
|
107
113
|
options['subModels'] = {
|
|
@@ -128,7 +134,9 @@ export const FlowPage = React.memo((props: FlowPageProps & Record<string, unknow
|
|
|
128
134
|
},
|
|
129
135
|
};
|
|
130
136
|
}
|
|
131
|
-
const data =
|
|
137
|
+
const data =
|
|
138
|
+
(isRuntimeRecordScopedPage && (await flowEngine.loadModel({ ...options, refresh: true }))) ||
|
|
139
|
+
(await flowEngine.loadOrCreateModel(options, { skipSave: !flowEngine.context.flowSettingsEnabled }));
|
|
132
140
|
if (data?.uid && onModelLoaded) {
|
|
133
141
|
data.context.addDelegate(ctx);
|
|
134
142
|
bindViewLayoutState(data, ctx);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { render, waitFor } from '@testing-library/react';
|
|
11
11
|
import React from 'react';
|
|
12
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
13
|
import { FlowPage } from '../FlowPage';
|
|
14
14
|
|
|
15
15
|
type TestContextProperty = {
|
|
@@ -71,10 +71,15 @@ const createPageModel = (): TestPageModel => {
|
|
|
71
71
|
};
|
|
72
72
|
|
|
73
73
|
const mocks = vi.hoisted(() => ({
|
|
74
|
+
flowSettingsEnabled: false,
|
|
75
|
+
loadModel: vi.fn(),
|
|
76
|
+
loadOrCreateModel: vi.fn(),
|
|
74
77
|
rendererMobileStates: [] as boolean[],
|
|
75
78
|
rendererProps: undefined as RendererProps | undefined,
|
|
76
79
|
model: undefined as TestPageModel | undefined,
|
|
80
|
+
viewFilterByTk: undefined as unknown,
|
|
77
81
|
viewInputMobileLayout: true as boolean | undefined,
|
|
82
|
+
viewSourceId: undefined as unknown,
|
|
78
83
|
contextMobileLayout: undefined as boolean | undefined,
|
|
79
84
|
}));
|
|
80
85
|
|
|
@@ -94,15 +99,20 @@ vi.mock('@nocobase/flow-engine', async () => {
|
|
|
94
99
|
}),
|
|
95
100
|
useFlowEngine: vi.fn(() => ({
|
|
96
101
|
getModelClassAsync: vi.fn(async () => TestPageModelClass),
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
loadModel: mocks.loadModel,
|
|
103
|
+
loadOrCreateModel: mocks.loadOrCreateModel,
|
|
104
|
+
context: {
|
|
105
|
+
flowSettingsEnabled: mocks.flowSettingsEnabled,
|
|
106
|
+
},
|
|
99
107
|
translate: vi.fn((value: string) => value),
|
|
100
108
|
})),
|
|
101
109
|
useFlowModelById: vi.fn(() => mocks.model),
|
|
102
110
|
useFlowViewContext: vi.fn(() => ({
|
|
103
111
|
view: {
|
|
104
112
|
inputArgs: {
|
|
113
|
+
...(typeof mocks.viewFilterByTk !== 'undefined' ? { filterByTk: mocks.viewFilterByTk } : {}),
|
|
105
114
|
...(typeof mocks.viewInputMobileLayout === 'boolean' ? { isMobileLayout: mocks.viewInputMobileLayout } : {}),
|
|
115
|
+
...(typeof mocks.viewSourceId !== 'undefined' ? { sourceId: mocks.viewSourceId } : {}),
|
|
106
116
|
},
|
|
107
117
|
},
|
|
108
118
|
...(typeof mocks.contextMobileLayout === 'boolean' ? { isMobileLayout: mocks.contextMobileLayout } : {}),
|
|
@@ -154,11 +164,21 @@ vi.mock('ahooks', async () => {
|
|
|
154
164
|
});
|
|
155
165
|
|
|
156
166
|
describe('FlowPage', () => {
|
|
167
|
+
beforeEach(() => {
|
|
168
|
+
mocks.loadModel.mockImplementation(async () => null);
|
|
169
|
+
mocks.loadOrCreateModel.mockImplementation(async () => mocks.model);
|
|
170
|
+
});
|
|
171
|
+
|
|
157
172
|
afterEach(() => {
|
|
173
|
+
mocks.flowSettingsEnabled = false;
|
|
174
|
+
mocks.loadModel.mockReset();
|
|
175
|
+
mocks.loadOrCreateModel.mockReset();
|
|
158
176
|
mocks.rendererMobileStates = [];
|
|
159
177
|
mocks.rendererProps = undefined;
|
|
160
178
|
mocks.model = undefined;
|
|
179
|
+
mocks.viewFilterByTk = undefined;
|
|
161
180
|
mocks.viewInputMobileLayout = true;
|
|
181
|
+
mocks.viewSourceId = undefined;
|
|
162
182
|
mocks.contextMobileLayout = undefined;
|
|
163
183
|
});
|
|
164
184
|
|
|
@@ -203,4 +223,31 @@ describe('FlowPage', () => {
|
|
|
203
223
|
expect(mocks.rendererMobileStates[0]).toBe(expected);
|
|
204
224
|
},
|
|
205
225
|
);
|
|
226
|
+
|
|
227
|
+
it('reloads runtime page models for record scoped views', async () => {
|
|
228
|
+
mocks.model = createPageModel();
|
|
229
|
+
mocks.viewFilterByTk = 1;
|
|
230
|
+
mocks.loadModel.mockImplementation(async () => mocks.model);
|
|
231
|
+
|
|
232
|
+
render(<FlowPage onModelLoaded={vi.fn()} />);
|
|
233
|
+
|
|
234
|
+
await waitFor(() => {
|
|
235
|
+
expect(mocks.loadModel).toHaveBeenCalled();
|
|
236
|
+
});
|
|
237
|
+
expect(mocks.loadModel.mock.calls[0]?.[0]).toMatchObject({ refresh: true });
|
|
238
|
+
expect(mocks.loadOrCreateModel).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('keeps configured page model cache behavior in flow settings mode', async () => {
|
|
242
|
+
mocks.flowSettingsEnabled = true;
|
|
243
|
+
mocks.model = createPageModel();
|
|
244
|
+
mocks.viewFilterByTk = 1;
|
|
245
|
+
|
|
246
|
+
render(<FlowPage onModelLoaded={vi.fn()} />);
|
|
247
|
+
|
|
248
|
+
await waitFor(() => {
|
|
249
|
+
expect(mocks.loadOrCreateModel).toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
expect(mocks.loadModel).not.toHaveBeenCalled();
|
|
252
|
+
});
|
|
206
253
|
});
|
|
@@ -1666,7 +1666,7 @@ describe('FlowRoute', () => {
|
|
|
1666
1666
|
expect(engine.getModel('public-form-1')).toBeUndefined();
|
|
1667
1667
|
});
|
|
1668
1668
|
|
|
1669
|
-
it('should
|
|
1669
|
+
it('should skip accessible route loading when layout authCheck is false', async () => {
|
|
1670
1670
|
const engine = new FlowEngine();
|
|
1671
1671
|
const ensureAccessibleLoaded = vi.fn().mockRejectedValue(new Error('cannot load accessible routes'));
|
|
1672
1672
|
const getRouteBySchemaUid = vi.fn();
|
|
@@ -1735,7 +1735,7 @@ describe('FlowRoute', () => {
|
|
|
1735
1735
|
await waitFor(() => {
|
|
1736
1736
|
expect(layoutModel.registerRoutePage).toHaveBeenCalledWith('public-form-1', expect.any(Object));
|
|
1737
1737
|
});
|
|
1738
|
-
expect(ensureAccessibleLoaded).
|
|
1738
|
+
expect(ensureAccessibleLoaded).not.toHaveBeenCalled();
|
|
1739
1739
|
expect(getRouteBySchemaUid).not.toHaveBeenCalled();
|
|
1740
1740
|
});
|
|
1741
1741
|
|
|
@@ -712,7 +712,6 @@ export const AdminLayoutComponent = observer((props: any) => {
|
|
|
712
712
|
<MobileMenuControlContext.Provider value={{ closeMobileMenu }}>
|
|
713
713
|
<DndProvider collisionDetection={menuCollisionDetection} onDragEnd={handleMenuDragEnd}>
|
|
714
714
|
<ProLayout
|
|
715
|
-
key={`admin-layout-menu-${menuRouteRefreshVersion}`}
|
|
716
715
|
{...props}
|
|
717
716
|
contentStyle={contentStyle}
|
|
718
717
|
siderWidth={customToken.siderWidth || 200}
|