@nocobase/client-v2 2.2.0-alpha.7 → 2.2.0-alpha.9

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 (56) hide show
  1. package/es/APIClient.d.ts +1 -3
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -1
  3. package/es/flow/actions/index.d.ts +1 -1
  4. package/es/flow/actions/linkageRules.d.ts +2 -0
  5. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  6. package/es/flow/components/FieldAssignValueInput.d.ts +6 -0
  7. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  8. package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
  9. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  10. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  11. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  12. package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
  13. package/es/flow-compat/routeTypes.d.ts +1 -0
  14. package/es/index.mjs +105 -104
  15. package/lib/index.js +117 -116
  16. package/lib/locale/languageCodes.js +2 -1
  17. package/package.json +7 -7
  18. package/src/APIClient.ts +1 -10
  19. package/src/Application.tsx +4 -0
  20. package/src/BaseApplication.tsx +3 -2
  21. package/src/__tests__/app.test.tsx +47 -0
  22. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +28 -0
  23. package/src/collection-manager/field-validation.ts +1 -1
  24. package/src/components/form/ScanInput/CodeScanner.tsx +23 -6
  25. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +35 -1
  26. package/src/components/form/ScanInput/useCodeScanner.ts +16 -3
  27. package/src/components/form/TypedVariableInput.tsx +12 -8
  28. package/src/components/form/__tests__/TypedVariableInput.test.tsx +44 -3
  29. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  30. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  31. package/src/flow/actions/index.ts +2 -0
  32. package/src/flow/actions/linkageRules.tsx +86 -11
  33. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  34. package/src/flow/components/FieldAssignValueInput.tsx +21 -0
  35. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +84 -2
  36. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  37. package/src/flow/components/filter/VariableFilterItem.tsx +158 -10
  38. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  39. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  40. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +70 -1
  41. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  42. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  43. package/src/flow/models/base/PageModel/PageTabModel.tsx +184 -3
  44. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
  45. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  46. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  47. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +572 -1
  48. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
  49. package/src/flow/models/blocks/table/TableBlockModel.tsx +1 -1
  50. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +1 -1
  51. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  52. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  53. package/src/flow-compat/fieldValidationConstants.ts +1 -1
  54. package/src/flow-compat/routeTypes.ts +1 -0
  55. package/src/locale/languageCodes.ts +2 -1
  56. package/src/nocobase-buildin-plugin/index.tsx +12 -0
@@ -83,7 +83,8 @@ const languageCodes = {
83
83
  "tk-TK": { label: "Turkmen" },
84
84
  "tr-TR": { label: "T\xFCrk\xE7e" },
85
85
  "uk-UA": { label: "\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430" },
86
- "ur-PK": { label: "O\u02BBzbekcha" },
86
+ "ur-PK": { label: "\u0627\u0631\u062F\u0648" },
87
+ "uz-UZ": { label: "O\u02BBzbekcha" },
87
88
  "vi-VN": { label: "Ti\u1EBFng Vi\u1EC7t" },
88
89
  "zh-CN": { label: "\u7B80\u4F53\u4E2D\u6587" },
89
90
  "zh-HK": { label: "\u7E41\u9AD4\u4E2D\u6587\uFF08\u9999\u6E2F\uFF09" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.2.0-alpha.7",
3
+ "version": "2.2.0-alpha.9",
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.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",
30
+ "@nocobase/evaluators": "2.2.0-alpha.9",
31
+ "@nocobase/flow-engine": "2.2.0-alpha.9",
32
+ "@nocobase/sdk": "2.2.0-alpha.9",
33
+ "@nocobase/shared": "2.2.0-alpha.9",
34
+ "@nocobase/utils": "2.2.0-alpha.9",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -47,5 +47,5 @@
47
47
  "react-i18next": "^11.15.1",
48
48
  "react-router-dom": "^6.30.1"
49
49
  },
50
- "gitHead": "34ba02960fca9ab0b6881d3db7040a8a64067bba"
50
+ "gitHead": "4949bf5ebcd92d77a3a0a718ed578c270e4bd570"
51
51
  }
package/src/APIClient.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { APIClient as APIClientSDK, type APIClientOptions, hasHeaderValue } from '@nocobase/sdk';
10
+ import { APIClient as APIClientSDK, hasHeaderValue } from '@nocobase/sdk';
11
11
 
12
12
  function offsetToTimeZone(offset: number) {
13
13
  const hours = Math.floor(Math.abs(offset));
@@ -23,15 +23,6 @@ function getCurrentTimezone() {
23
23
  }
24
24
 
25
25
  export class APIClient extends APIClientSDK {
26
- appName?: string;
27
-
28
- constructor(options?: APIClientOptions) {
29
- super(options);
30
- if (options && typeof options !== 'function') {
31
- this.appName = options.appName;
32
- }
33
- }
34
-
35
26
  getHostname() {
36
27
  if (process.env.API_BASE_URL) {
37
28
  try {
@@ -46,6 +46,10 @@ export class Application extends BaseApplication<
46
46
 
47
47
  protected createApiClient(options: ApplicationOptions) {
48
48
  return new APIClient({
49
+ // Cross-origin API deployments rely on cookies for auth (e.g. permanent file
50
+ // URLs); trust is enforced server-side via the CORS origin whitelist and the
51
+ // CSRF middleware, not by omitting credentials.
52
+ withCredentials: true,
49
53
  ...options.apiClient,
50
54
  appName: options.name || getSubAppName(options.publicPath),
51
55
  });
@@ -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,
@@ -402,7 +403,7 @@ export abstract class BaseApplication<
402
403
  this.favicon = favicon || '';
403
404
  }
404
405
 
405
- const iconHref = this.favicon || '/favicon/favicon.ico';
406
+ const iconHref = this.favicon || this.getCdnUrl() + 'favicon/favicon.ico';
406
407
 
407
408
  if (faviconLinkElement) {
408
409
  faviconLinkElement.href = iconHref;
@@ -438,7 +439,7 @@ export abstract class BaseApplication<
438
439
  }
439
440
 
440
441
  getCdnUrl() {
441
- return ensureTrailingSlash(window['__webpack_public_path__'] || this.getPublicPath());
442
+ return ensureTrailingSlash(window['__webpack_public_path__'] || stripModernClientPrefix(this.getPublicPath()));
442
443
  }
443
444
 
444
445
  getPublicPath() {
@@ -35,6 +35,7 @@ describe('app', () => {
35
35
  document.querySelectorAll('link[rel="shortcut icon"]').forEach((node) => node.remove());
36
36
  document.documentElement.removeAttribute('lang');
37
37
  delete window['__webpack_public_path__'];
38
+ delete window['__nocobase_modern_client_prefix__'];
38
39
  vi.restoreAllMocks();
39
40
  });
40
41
 
@@ -51,6 +52,24 @@ describe('app', () => {
51
52
  expect(app.getHref('/test')).toBe('/test');
52
53
  });
53
54
 
55
+ it('should enable credentials for api clients by default', () => {
56
+ const sameOriginApp = new Application({
57
+ router,
58
+ apiClient: {
59
+ baseURL: '/api/',
60
+ },
61
+ });
62
+ const crossOriginApp = new Application({
63
+ router,
64
+ apiClient: {
65
+ baseURL: 'https://api.example.com/api/',
66
+ },
67
+ });
68
+
69
+ expect(sameOriginApp.apiClient.axios.defaults.withCredentials).toBe(true);
70
+ expect(crossOriginApp.apiClient.axios.defaults.withCredentials).toBe(true);
71
+ });
72
+
54
73
  it('should initialize shared jsonLogic operators', () => {
55
74
  const app = new Application({ router });
56
75
 
@@ -84,6 +103,34 @@ describe('app', () => {
84
103
  expect(app.getCdnUrl()).toBe('/cdn/assets/');
85
104
  });
86
105
 
106
+ it('should remove the modern client prefix from the CDN fallback path', () => {
107
+ const app = new Application({
108
+ router,
109
+ publicPath: '/v/',
110
+ });
111
+
112
+ expect(app.getCdnUrl()).toBe('/');
113
+ });
114
+
115
+ it('should preserve APP_PUBLIC_PATH when removing the modern client prefix', () => {
116
+ const app = new Application({
117
+ router,
118
+ publicPath: '/nocobase/v/',
119
+ });
120
+
121
+ expect(app.getCdnUrl()).toBe('/nocobase/');
122
+ });
123
+
124
+ it('should support a custom modern client prefix', () => {
125
+ window['__nocobase_modern_client_prefix__'] = 'modern';
126
+ const app = new Application({
127
+ router,
128
+ publicPath: '/nocobase/modern/',
129
+ });
130
+
131
+ expect(app.getCdnUrl()).toBe('/nocobase/');
132
+ });
133
+
87
134
  it('should apply the provided favicon immediately', () => {
88
135
  const app = new Application({ router });
89
136
 
@@ -55,6 +55,7 @@ describe('nocobase buildin plugin auth redirect', () => {
55
55
  const originalLocation = globalThis.window.location;
56
56
 
57
57
  beforeEach(() => {
58
+ globalThis.window.localStorage.clear();
58
59
  // These fixtures mount the modern client under the `v2` segment; tell the
59
60
  // runtime-prefix helper so v2-runtime detection matches (server injects it
60
61
  // in production).
@@ -75,6 +76,7 @@ describe('nocobase buildin plugin auth redirect', () => {
75
76
  });
76
77
 
77
78
  afterEach(() => {
79
+ globalThis.window.localStorage.clear();
78
80
  delete (globalThis.window as any).__nocobase_modern_client_prefix__;
79
81
  Object.defineProperty(globalThis.window, 'location', {
80
82
  configurable: true,
@@ -116,6 +118,7 @@ describe('nocobase buildin plugin auth redirect', () => {
116
118
  plugins: [NocoBaseBuildInPlugin as any],
117
119
  router: { type: 'memory', initialEntries: ['/v2/admin'] },
118
120
  });
121
+ app.apiClient.auth.setToken('test-token');
119
122
  app.apiMock.onGet('app:getLang').reply(200, {
120
123
  data: { lang: 'en-US', resources: { client: {} }, cron: {} },
121
124
  });
@@ -130,6 +133,7 @@ describe('nocobase buildin plugin auth redirect', () => {
130
133
  await new Promise((resolve) => setTimeout(resolve, 50));
131
134
  expect(app.router.router.state.location.pathname).toBe('/v2/admin');
132
135
  expect(app.router.router.state.location.search).toBe('');
136
+ expect(app.apiMock.history.post.filter((request) => request.url === 'auth:syncCookies')).toHaveLength(0);
133
137
  });
134
138
 
135
139
  it('should redirect unauthenticated v2 root access to v2 signin via <Navigate />', async () => {
@@ -326,6 +330,29 @@ describe('nocobase buildin plugin auth redirect', () => {
326
330
  data: { lang: 'en-US', resources: { client: {} }, cron: {} },
327
331
  });
328
332
  app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
333
+ app.apiMock.onPost('auth:syncCookies').reply(200, { data: { synced: true } });
334
+ const ensureLoaded = vi.spyOn(app.dataSourceManager, 'ensureLoaded').mockResolvedValue(undefined);
335
+
336
+ const Root = app.getRootComponent();
337
+ render(<Root />);
338
+
339
+ expect(await screen.findByText('secure page')).toBeInTheDocument();
340
+ expect(ensureLoaded).toHaveBeenCalledTimes(1);
341
+ expect(app.apiMock.history.post.filter((request) => request.url === 'auth:syncCookies')).toHaveLength(1);
342
+ });
343
+
344
+ it('should keep authenticated v2 startup working when cookie bootstrap fails', async () => {
345
+ const app = createMockClient({
346
+ publicPath: '/v2/',
347
+ plugins: [NocoBaseBuildInPlugin as any, AuthBootstrapRoutePlugin as any],
348
+ router: { type: 'memory', initialEntries: ['/v2/secure'] },
349
+ });
350
+ app.apiClient.auth.setToken('test-token');
351
+ app.apiMock.onGet('app:getLang').reply(200, {
352
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
353
+ });
354
+ app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
355
+ app.apiMock.onPost('auth:syncCookies').reply(500, { errors: [{ message: 'cookie sync failed' }] });
329
356
  const ensureLoaded = vi.spyOn(app.dataSourceManager, 'ensureLoaded').mockResolvedValue(undefined);
330
357
 
331
358
  const Root = app.getRootComponent();
@@ -333,6 +360,7 @@ describe('nocobase buildin plugin auth redirect', () => {
333
360
 
334
361
  expect(await screen.findByText('secure page')).toBeInTheDocument();
335
362
  expect(ensureLoaded).toHaveBeenCalledTimes(1);
363
+ expect(app.apiMock.history.post.filter((request) => request.url === 'auth:syncCookies')).toHaveLength(1);
336
364
  });
337
365
 
338
366
  it('should not auth-check or bootstrap authCheck false routes', async () => {
@@ -132,7 +132,7 @@ fieldValidationConfigureRegistry.registerMany([
132
132
  { key: 'uri', label: 'URI', hasValue: false, params: [], paramsType: 'object' },
133
133
  {
134
134
  key: 'multiple',
135
- label: 'Multiple',
135
+ label: 'Multiple of',
136
136
  hasValue: true,
137
137
  params: [{ key: 'base', label: 'Base', componentType: 'inputNumber', required: true }],
138
138
  },
@@ -49,6 +49,21 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
49
49
  message.error(t('Code recognition failed, please scan again'));
50
50
  }, [t]);
51
51
 
52
+ const showCameraStartFailure = useCallback(
53
+ (error: unknown) => {
54
+ const errorName = error instanceof Error ? error.name : '';
55
+ const errorMessage = error instanceof Error ? error.message : '';
56
+ const errorMap: Record<string, string> = {
57
+ NotFoundError: t('No camera device detected'),
58
+ NotAllowedError: t('You have not granted permission to use the camera'),
59
+ };
60
+
61
+ message.error(errorMap[errorName] || errorMessage || t('You have not granted permission to use the camera'));
62
+ onClose();
63
+ },
64
+ [onClose, t],
65
+ );
66
+
52
67
  const handleScanSuccess = useCallback(
53
68
  (text: string) => {
54
69
  onScanSuccess(text);
@@ -64,6 +79,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
64
79
  scanBoxSize,
65
80
  onScanSuccess: handleScanSuccess,
66
81
  onScanFailure: showScanFailure,
82
+ onCameraStartFailure: showCameraStartFailure,
67
83
  });
68
84
 
69
85
  useEffect(() => {
@@ -129,7 +145,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
129
145
  inset: 0;
130
146
  z-index: ${token.zIndexPopupBase + 1000};
131
147
  overflow: hidden;
132
- background: ${token.colorBgMask};
148
+ background: #000;
133
149
  `;
134
150
  const scannerWrapperClass = css`
135
151
  position: absolute;
@@ -137,16 +153,17 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
137
153
  overflow: hidden;
138
154
  `;
139
155
  const scannerClass = css`
156
+ position: relative;
140
157
  width: 100%;
141
158
  height: 100%;
142
- display: flex;
143
- justify-content: center;
144
- align-items: center;
159
+ overflow: hidden;
145
160
 
146
161
  video {
147
- width: auto !important;
162
+ position: absolute !important;
163
+ inset: 0 !important;
164
+ width: 100% !important;
148
165
  height: 100% !important;
149
- max-width: none !important;
166
+ object-fit: cover !important;
150
167
  }
151
168
 
152
169
  #qr-shaded-region {
@@ -74,13 +74,23 @@ vi.mock('html5-qrcode', () => ({
74
74
 
75
75
  vi.mock('jsqr', () => jsQrMocks);
76
76
 
77
- function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: number } } = {}) {
77
+ function ScannerHost({
78
+ onCameraStartFailure,
79
+ onScanFailure,
80
+ scanBoxSize,
81
+ }: {
82
+ onCameraStartFailure?: (error: unknown) => void;
83
+ onScanFailure?: () => void;
84
+ scanBoxSize?: { width: number; height: number };
85
+ } = {}) {
78
86
  const handleScanSuccess = useCallback(() => undefined, []);
79
87
 
80
88
  useCodeScanner({
81
89
  elementId: 'scanner',
82
90
  enabled: true,
83
91
  scanBoxSize,
92
+ onCameraStartFailure,
93
+ onScanFailure,
84
94
  onScanSuccess: handleScanSuccess,
85
95
  });
86
96
 
@@ -194,6 +204,30 @@ describe('useCodeScanner', () => {
194
204
  expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
195
205
  });
196
206
 
207
+ it('clamps the scan box to the camera viewfinder size', async () => {
208
+ render(<ScannerHost scanBoxSize={{ width: 319, height: 240 }} />);
209
+
210
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
211
+
212
+ const config = mocks.start.mock.calls[0]?.[1] as
213
+ | { qrbox?: (width: number, height: number) => { width: number; height: number } }
214
+ | undefined;
215
+
216
+ expect(config?.qrbox?.(200, 160)).toEqual({ width: 200, height: 160 });
217
+ });
218
+
219
+ it('reports camera start failures through the dedicated handler', async () => {
220
+ const cameraStartError = Object.assign(new Error('Camera failed'), { name: 'NotAllowedError' });
221
+ const handleCameraStartFailure = vi.fn();
222
+ const handleScanFailure = vi.fn();
223
+ mocks.start.mockRejectedValueOnce(cameraStartError);
224
+
225
+ render(<ScannerHost onCameraStartFailure={handleCameraStartFailure} onScanFailure={handleScanFailure} />);
226
+
227
+ await waitFor(() => expect(handleCameraStartFailure).toHaveBeenCalledWith(cameraStartError));
228
+ expect(handleScanFailure).not.toHaveBeenCalled();
229
+ });
230
+
197
231
  it('uses jsQR first for Safari uploaded QR images', async () => {
198
232
  const handleScanSuccess = vi.fn();
199
233
  jsQrMocks.default.mockReturnValueOnce({ data: 'JSQR-CODE' });
@@ -25,6 +25,7 @@ type UseCodeScannerOptions = {
25
25
  onScannerSizeChanged?: (size: ScannerSize) => void;
26
26
  onScanSuccess: (text: string) => void;
27
27
  onScanFailure?: () => void;
28
+ onCameraStartFailure?: (error: unknown) => void;
28
29
  };
29
30
 
30
31
  type ImageDataVariant = {
@@ -70,6 +71,13 @@ export function getCodeScanBoxSize(width: number, height: number) {
70
71
  };
71
72
  }
72
73
 
74
+ function clampCodeScanBoxSize(scanBoxSize: ScannerSize, width: number, height: number) {
75
+ return {
76
+ width: Math.min(scanBoxSize.width, width),
77
+ height: Math.min(scanBoxSize.height, height),
78
+ };
79
+ }
80
+
73
81
  async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } = {}) {
74
82
  if (!scanner) {
75
83
  return;
@@ -212,6 +220,7 @@ export function useCodeScanner({
212
220
  onScannerSizeChanged,
213
221
  onScanSuccess,
214
222
  onScanFailure,
223
+ onCameraStartFailure,
215
224
  }: UseCodeScannerOptions) {
216
225
  const [scanner, setScanner] = useState<Html5Qrcode>();
217
226
 
@@ -223,7 +232,7 @@ export function useCodeScanner({
223
232
  fps: 10,
224
233
  qrbox(width, height) {
225
234
  onScannerSizeChanged?.({ width, height });
226
- return scanBoxSize ?? getCodeScanBoxSize(width, height);
235
+ return clampCodeScanBoxSize(scanBoxSize ?? getCodeScanBoxSize(width, height), width, height);
227
236
  },
228
237
  },
229
238
  (decodedText) => {
@@ -273,14 +282,18 @@ export function useCodeScanner({
273
282
  verbose: false,
274
283
  });
275
284
  setScanner(scannerInstance);
276
- startScanCamera(scannerInstance).catch(() => {
285
+ startScanCamera(scannerInstance).catch((error: unknown) => {
286
+ if (onCameraStartFailure) {
287
+ onCameraStartFailure(error);
288
+ return;
289
+ }
277
290
  onScanFailure?.();
278
291
  });
279
292
 
280
293
  return () => {
281
294
  stopScanner(scannerInstance, { clear: true }).catch(() => undefined);
282
295
  };
283
- }, [elementId, enabled, formatsToSupport, onScanFailure, startScanCamera]);
296
+ }, [elementId, enabled, formatsToSupport, onCameraStartFailure, onScanFailure, startScanCamera]);
284
297
 
285
298
  return {
286
299
  startScanFile,
@@ -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 () => {