@nocobase/client-v2 2.2.0-alpha.6 → 2.2.0-alpha.8
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 +7 -0
- package/es/flow/components/RunJSValueEditor.d.ts +1 -0
- package/es/flow/components/filter/FilterGroup.d.ts +1 -0
- package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
- package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
- package/es/index.mjs +89 -89
- package/lib/index.js +97 -97
- package/package.json +8 -7
- package/src/BaseApplication.tsx +11 -6
- package/src/__tests__/app.test.tsx +55 -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/components/form/TypedVariableInput.tsx +12 -8
- package/src/components/form/__tests__/TypedVariableInput.test.tsx +44 -3
- 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/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
- package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
- 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 +81 -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 +216 -0
- package/src/flow/components/filter/FilterGroup.tsx +33 -5
- package/src/flow/components/filter/VariableFilterItem.tsx +139 -6
- package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
- package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +21 -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/blocks/table/TableActionsColumnModel.tsx +2 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +1 -0
- package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
- package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -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.8",
|
|
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.8",
|
|
31
|
+
"@nocobase/flow-engine": "2.2.0-alpha.8",
|
|
32
|
+
"@nocobase/sdk": "2.2.0-alpha.8",
|
|
33
|
+
"@nocobase/shared": "2.2.0-alpha.8",
|
|
34
|
+
"@nocobase/utils": "2.2.0-alpha.8",
|
|
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": "810c81e5963966bda7ec03b6453a3a80fe60e027"
|
|
50
51
|
}
|
package/src/BaseApplication.tsx
CHANGED
|
@@ -35,6 +35,7 @@ import { SystemSettingsSource } from './flow/system-settings';
|
|
|
35
35
|
import { LayoutManager } from './layout-manager/LayoutManager';
|
|
36
36
|
import type { PluginClass, PluginManager, PluginType } from './PluginManager';
|
|
37
37
|
import { RouteRepository } from './RouteRepository';
|
|
38
|
+
import { stripModernClientPrefix } from './authRedirect';
|
|
38
39
|
import type {
|
|
39
40
|
ComponentTypeAndString,
|
|
40
41
|
RenderableComponentType,
|
|
@@ -81,6 +82,10 @@ const trimTrailingSlashes = (value: string) => {
|
|
|
81
82
|
return match ? value.slice(0, -match[0].length) : value;
|
|
82
83
|
};
|
|
83
84
|
|
|
85
|
+
const ensureTrailingSlash = (value: string) => {
|
|
86
|
+
return `${trimTrailingSlashes(value)}/`;
|
|
87
|
+
};
|
|
88
|
+
|
|
84
89
|
const isRenderableComponentType = (value: unknown): value is AnyComponent => isValidElementType(value);
|
|
85
90
|
|
|
86
91
|
export type DevDynamicImport = (packageName: string) => Promise<{ default: PluginClass }>;
|
|
@@ -398,7 +403,7 @@ export abstract class BaseApplication<
|
|
|
398
403
|
this.favicon = favicon || '';
|
|
399
404
|
}
|
|
400
405
|
|
|
401
|
-
const iconHref = this.favicon || '
|
|
406
|
+
const iconHref = this.favicon || this.getCdnUrl() + 'favicon/favicon.ico';
|
|
402
407
|
|
|
403
408
|
if (faviconLinkElement) {
|
|
404
409
|
faviconLinkElement.href = iconHref;
|
|
@@ -433,12 +438,12 @@ export abstract class BaseApplication<
|
|
|
433
438
|
return getSubAppName(this.getPublicPath()) || null;
|
|
434
439
|
}
|
|
435
440
|
|
|
441
|
+
getCdnUrl() {
|
|
442
|
+
return ensureTrailingSlash(window['__webpack_public_path__'] || stripModernClientPrefix(this.getPublicPath()));
|
|
443
|
+
}
|
|
444
|
+
|
|
436
445
|
getPublicPath() {
|
|
437
|
-
|
|
438
|
-
if (!publicPath.endsWith('/')) {
|
|
439
|
-
publicPath += '/';
|
|
440
|
-
}
|
|
441
|
-
return publicPath;
|
|
446
|
+
return ensureTrailingSlash(this.options.publicPath || '/');
|
|
442
447
|
}
|
|
443
448
|
|
|
444
449
|
getApiUrl(pathname = '') {
|
|
@@ -34,6 +34,8 @@ 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__'];
|
|
38
|
+
delete window['__nocobase_modern_client_prefix__'];
|
|
37
39
|
vi.restoreAllMocks();
|
|
38
40
|
});
|
|
39
41
|
|
|
@@ -58,6 +60,59 @@ describe('app', () => {
|
|
|
58
60
|
expect(app.jsonLogic.apply({ $testAlwaysTrue: [] })).toBe(true);
|
|
59
61
|
});
|
|
60
62
|
|
|
63
|
+
it('should normalize publicPath and webpack public path with a single trailing slash', () => {
|
|
64
|
+
const app = new Application({
|
|
65
|
+
router,
|
|
66
|
+
publicPath: '/admin//',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(app.getPublicPath()).toBe('/admin/');
|
|
70
|
+
|
|
71
|
+
window['__webpack_public_path__'] = '/cdn/assets///';
|
|
72
|
+
expect(app.getCdnUrl()).toBe('/cdn/assets/');
|
|
73
|
+
|
|
74
|
+
delete window['__webpack_public_path__'];
|
|
75
|
+
expect(app.getCdnUrl()).toBe('/admin/');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should normalize webpack public path without a trailing slash', () => {
|
|
79
|
+
const app = new Application({
|
|
80
|
+
router,
|
|
81
|
+
publicPath: '/admin/',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
window['__webpack_public_path__'] = '/cdn/assets';
|
|
85
|
+
expect(app.getCdnUrl()).toBe('/cdn/assets/');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should remove the modern client prefix from the CDN fallback path', () => {
|
|
89
|
+
const app = new Application({
|
|
90
|
+
router,
|
|
91
|
+
publicPath: '/v/',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(app.getCdnUrl()).toBe('/');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should preserve APP_PUBLIC_PATH when removing the modern client prefix', () => {
|
|
98
|
+
const app = new Application({
|
|
99
|
+
router,
|
|
100
|
+
publicPath: '/nocobase/v/',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(app.getCdnUrl()).toBe('/nocobase/');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should support a custom modern client prefix', () => {
|
|
107
|
+
window['__nocobase_modern_client_prefix__'] = 'modern';
|
|
108
|
+
const app = new Application({
|
|
109
|
+
router,
|
|
110
|
+
publicPath: '/nocobase/modern/',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(app.getCdnUrl()).toBe('/nocobase/');
|
|
114
|
+
});
|
|
115
|
+
|
|
61
116
|
it('should apply the provided favicon immediately', () => {
|
|
62
117
|
const app = new Application({ router });
|
|
63
118
|
|
|
@@ -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(() => {
|
|
@@ -121,12 +121,15 @@ function normalizeTypes(types: TypedConstantSpec[]): NormalizedType[] {
|
|
|
121
121
|
);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
function defaultValueFor(type: TypedConstantType): unknown {
|
|
124
|
+
function defaultValueFor(type: TypedConstantType, typedProps: Record<string, unknown> = {}): unknown {
|
|
125
125
|
switch (type) {
|
|
126
126
|
case 'string':
|
|
127
127
|
return '';
|
|
128
|
-
case 'number':
|
|
128
|
+
case 'number': {
|
|
129
|
+
const min = typeof typedProps.min === 'number' ? typedProps.min : undefined;
|
|
130
|
+
if (min !== undefined && min > 0) return min;
|
|
129
131
|
return 0;
|
|
132
|
+
}
|
|
130
133
|
case 'boolean':
|
|
131
134
|
return false;
|
|
132
135
|
case 'date': {
|
|
@@ -433,7 +436,7 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
|
|
|
433
436
|
return undefined;
|
|
434
437
|
}
|
|
435
438
|
const firstType = normalizedTypes[0];
|
|
436
|
-
return firstType ? defaultValueFor(firstType.type) : undefined;
|
|
439
|
+
return firstType ? defaultValueFor(firstType.type, firstType.props) : undefined;
|
|
437
440
|
}, [defaultToFirstConstantTypeWhenUndefined, normalizedTypes, value, variableOnly]);
|
|
438
441
|
const effectiveValue = value === undefined && defaultedValue !== undefined ? defaultedValue : value;
|
|
439
442
|
const detected = useMemo(() => detectMode(effectiveValue, parseVariablePath), [effectiveValue, parseVariablePath]);
|
|
@@ -529,7 +532,8 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
|
|
|
529
532
|
const targetType = (path[1] as TypedConstantType | undefined) ?? normalizedTypes[0]?.type;
|
|
530
533
|
if (!targetType) return;
|
|
531
534
|
if (detected.mode === targetType) return;
|
|
532
|
-
|
|
535
|
+
const target = normalizedTypes.find(({ type }) => type === targetType);
|
|
536
|
+
onChange?.(defaultValueFor(targetType, target?.props));
|
|
533
537
|
return;
|
|
534
538
|
}
|
|
535
539
|
const leaf = selectedOptions?.[selectedOptions.length - 1] as SwitcherOption | undefined;
|
|
@@ -548,7 +552,7 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
|
|
|
548
552
|
}
|
|
549
553
|
const first = normalizedTypes[0];
|
|
550
554
|
if (first) {
|
|
551
|
-
onChange?.(defaultValueFor(first.type));
|
|
555
|
+
onChange?.(defaultValueFor(first.type, first.props));
|
|
552
556
|
return;
|
|
553
557
|
}
|
|
554
558
|
if (nullable) {
|
|
@@ -585,11 +589,11 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
|
|
|
585
589
|
if (variableOnly) {
|
|
586
590
|
return undefined;
|
|
587
591
|
}
|
|
588
|
-
if (isNull) {
|
|
592
|
+
if (isNull && nullable) {
|
|
589
593
|
return [NULL_KEY];
|
|
590
594
|
}
|
|
591
595
|
return [CONST_KEY, constantTypeForRendering];
|
|
592
|
-
}, [constantTypeForRendering, detected.variablePath, isNull, isVariable, variableOnly]);
|
|
596
|
+
}, [constantTypeForRendering, detected.variablePath, isNull, isVariable, nullable, variableOnly]);
|
|
593
597
|
|
|
594
598
|
// Preload a saved variable's label path across lazy levels. `resolveVariableLabels` can only read already-loaded
|
|
595
599
|
// `children`; when a saved reference points below a node whose children are still a lazy thunk (e.g. a relation field
|
|
@@ -734,7 +738,7 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
|
|
|
734
738
|
</div>
|
|
735
739
|
) : variableOnly ? (
|
|
736
740
|
<Input placeholder={placeholder} readOnly disabled={disabled} style={{ width: '100%' }} />
|
|
737
|
-
) : isNull ? (
|
|
741
|
+
) : isNull && nullable ? (
|
|
738
742
|
// v1 used the `placeholder` slot (not `value`) so the antd default placeholder colour applies — keeps the
|
|
739
743
|
// field looking visibly empty/inactive rather than holding a real text value.
|
|
740
744
|
<Input placeholder={`<${t('Null')}>`} readOnly disabled={disabled} style={{ width: '100%' }} />
|
|
@@ -81,6 +81,27 @@ describe('TypedVariableInput - constant rendering', () => {
|
|
|
81
81
|
expect(screen.getByRole('button', { name: 'variable-switcher' }).className).not.toContain('ant-btn-primary');
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
it('keeps the number editor usable when value=null and nullable=false', async () => {
|
|
85
|
+
const ctx = createContextWithEnv();
|
|
86
|
+
const handleChange = vi.fn();
|
|
87
|
+
renderWithCtx(
|
|
88
|
+
ctx,
|
|
89
|
+
<TypedVariableInput
|
|
90
|
+
value={null}
|
|
91
|
+
types={[['number', { min: 1 }]]}
|
|
92
|
+
namespaces={['$env']}
|
|
93
|
+
nullable={false}
|
|
94
|
+
onChange={handleChange}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const numberInput = await screen.findByRole('spinbutton');
|
|
99
|
+
expect(screen.queryByPlaceholderText('<Null>')).toBeNull();
|
|
100
|
+
fireEvent.change(numberInput, { target: { value: '2' } });
|
|
101
|
+
fireEvent.blur(numberInput);
|
|
102
|
+
expect(handleChange).toHaveBeenCalledWith(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
84
105
|
it('defaults undefined to the first constant type', async () => {
|
|
85
106
|
const ctx = createContextWithEnv();
|
|
86
107
|
const handleChange = vi.fn();
|
|
@@ -102,6 +123,26 @@ describe('TypedVariableInput - constant rendering', () => {
|
|
|
102
123
|
});
|
|
103
124
|
});
|
|
104
125
|
|
|
126
|
+
it('uses a positive numeric minimum as the default value', async () => {
|
|
127
|
+
const ctx = createContextWithEnv();
|
|
128
|
+
const handleChange = vi.fn();
|
|
129
|
+
renderWithCtx(
|
|
130
|
+
ctx,
|
|
131
|
+
<TypedVariableInput
|
|
132
|
+
value={undefined}
|
|
133
|
+
types={[['number', { min: 1 }]]}
|
|
134
|
+
namespaces={['$env']}
|
|
135
|
+
nullable={false}
|
|
136
|
+
onChange={handleChange}
|
|
137
|
+
/>,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(await screen.findByDisplayValue('1')).toBeInTheDocument();
|
|
141
|
+
await waitFor(() => {
|
|
142
|
+
expect(handleChange).toHaveBeenCalledWith(1);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
105
146
|
it('can still opt out to keep the null placeholder for undefined', async () => {
|
|
106
147
|
const ctx = createContextWithEnv();
|
|
107
148
|
renderWithCtx(
|
|
@@ -183,14 +224,14 @@ describe('TypedVariableInput - variable rendering', () => {
|
|
|
183
224
|
expect(handleChange).toHaveBeenCalledWith(0);
|
|
184
225
|
});
|
|
185
226
|
|
|
186
|
-
it('clears back to
|
|
227
|
+
it('clears back to the valid minimum of the first type when nullable=false', async () => {
|
|
187
228
|
const ctx = createContextWithEnv();
|
|
188
229
|
const handleChange = vi.fn();
|
|
189
230
|
const { container } = renderWithCtx(
|
|
190
231
|
ctx,
|
|
191
232
|
<TypedVariableInput
|
|
192
233
|
value="{{$env.SMTP_PORT}}"
|
|
193
|
-
types={['number']}
|
|
234
|
+
types={[['number', { min: 1 }]]}
|
|
194
235
|
namespaces={['$env']}
|
|
195
236
|
nullable={false}
|
|
196
237
|
onChange={handleChange}
|
|
@@ -199,7 +240,7 @@ describe('TypedVariableInput - variable rendering', () => {
|
|
|
199
240
|
const clear = container.querySelector('button.clear-button') as HTMLButtonElement | null;
|
|
200
241
|
expect(clear).not.toBeNull();
|
|
201
242
|
fireEvent.click(clear as HTMLButtonElement);
|
|
202
|
-
expect(handleChange).toHaveBeenCalledWith(
|
|
243
|
+
expect(handleChange).toHaveBeenCalledWith(1);
|
|
203
244
|
});
|
|
204
245
|
|
|
205
246
|
it('treats types=[] as variable-only mode with a readonly placeholder before selection', async () => {
|