@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.
Files changed (49) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
  3. package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
  4. package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +7 -0
  6. package/es/flow/components/RunJSValueEditor.d.ts +1 -0
  7. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  8. package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
  9. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
  10. package/es/index.mjs +89 -89
  11. package/lib/index.js +97 -97
  12. package/package.json +8 -7
  13. package/src/BaseApplication.tsx +11 -6
  14. package/src/__tests__/app.test.tsx +55 -0
  15. package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
  16. package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
  17. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +145 -2
  18. package/src/components/form/ScanInput/useCodeScanner.ts +154 -2
  19. package/src/components/form/TypedVariableInput.tsx +12 -8
  20. package/src/components/form/__tests__/TypedVariableInput.test.tsx +44 -3
  21. package/src/flow/FlowPage.tsx +9 -1
  22. package/src/flow/__tests__/FlowPage.test.tsx +50 -3
  23. package/src/flow/__tests__/FlowRoute.test.tsx +2 -2
  24. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  25. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  26. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +0 -1
  27. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +253 -6
  28. package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
  29. package/src/flow/components/FieldAssignValueInput.tsx +81 -15
  30. package/src/flow/components/FlowRoute.tsx +7 -3
  31. package/src/flow/components/RunJSValueEditor.tsx +9 -1
  32. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +216 -0
  33. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  34. package/src/flow/components/filter/VariableFilterItem.tsx +139 -6
  35. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  36. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  37. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +21 -0
  38. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +1 -0
  39. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +27 -12
  40. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +36 -0
  41. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +34 -0
  42. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
  43. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
  44. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
  45. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +1 -0
  46. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  47. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  48. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +20 -10
  49. 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.6",
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.6",
31
- "@nocobase/flow-engine": "2.2.0-alpha.6",
32
- "@nocobase/sdk": "2.2.0-alpha.6",
33
- "@nocobase/shared": "2.2.0-alpha.6",
34
- "@nocobase/utils": "2.2.0-alpha.6",
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": "f55b252811f6af01d08cd3814171867b70c8ef7a"
50
+ "gitHead": "810c81e5963966bda7ec03b6453a3a80fe60e027"
50
51
  }
@@ -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 || '/favicon/favicon.ico';
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
- let publicPath = this.options.publicPath || '/';
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) {
@@ -31,6 +31,7 @@ interface FieldInterfaceLike {
31
31
  title?: ReactNode;
32
32
  group?: string;
33
33
  order?: number;
34
+ creatable?: boolean;
34
35
  isAssociation?: boolean;
35
36
  supportDataSourceType?: string[];
36
37
  notSupportDataSourceType?: string[];
@@ -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 (error) {
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
- onChange?.(defaultValueFor(targetType));
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 default-of-first-type when nullable=false', async () => {
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(0);
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 () => {