@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.
- package/es/APIClient.d.ts +1 -3
- package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -1
- package/es/flow/actions/index.d.ts +1 -1
- package/es/flow/actions/linkageRules.d.ts +2 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +6 -0
- package/es/flow/components/filter/FilterGroup.d.ts +1 -0
- package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
- package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
- package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
- package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
- package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
- package/es/flow-compat/routeTypes.d.ts +1 -0
- package/es/index.mjs +105 -104
- package/lib/index.js +117 -116
- package/lib/locale/languageCodes.js +2 -1
- package/package.json +7 -7
- package/src/APIClient.ts +1 -10
- package/src/Application.tsx +4 -0
- package/src/BaseApplication.tsx +3 -2
- package/src/__tests__/app.test.tsx +47 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +28 -0
- package/src/collection-manager/field-validation.ts +1 -1
- package/src/components/form/ScanInput/CodeScanner.tsx +23 -6
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +35 -1
- package/src/components/form/ScanInput/useCodeScanner.ts +16 -3
- package/src/components/form/TypedVariableInput.tsx +12 -8
- package/src/components/form/__tests__/TypedVariableInput.test.tsx +44 -3
- package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
- package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
- package/src/flow/actions/index.ts +2 -0
- package/src/flow/actions/linkageRules.tsx +86 -11
- package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
- package/src/flow/components/FieldAssignValueInput.tsx +21 -0
- package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +84 -2
- package/src/flow/components/filter/FilterGroup.tsx +33 -5
- package/src/flow/components/filter/VariableFilterItem.tsx +158 -10
- 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 +70 -1
- package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
- package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
- package/src/flow/models/base/PageModel/PageTabModel.tsx +184 -3
- package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
- package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
- package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
- package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +572 -1
- package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +1 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +1 -1
- package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
- package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
- package/src/flow-compat/fieldValidationConstants.ts +1 -1
- package/src/flow-compat/routeTypes.ts +1 -0
- package/src/locale/languageCodes.ts +2 -1
- 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: "
|
|
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.
|
|
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.
|
|
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.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": "
|
|
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,
|
|
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 {
|
package/src/Application.tsx
CHANGED
|
@@ -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
|
});
|
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,
|
|
@@ -402,7 +403,7 @@ export abstract class BaseApplication<
|
|
|
402
403
|
this.favicon = favicon || '';
|
|
403
404
|
}
|
|
404
405
|
|
|
405
|
-
const iconHref = this.favicon || '
|
|
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:
|
|
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
|
-
|
|
143
|
-
justify-content: center;
|
|
144
|
-
align-items: center;
|
|
159
|
+
overflow: hidden;
|
|
145
160
|
|
|
146
161
|
video {
|
|
147
|
-
|
|
162
|
+
position: absolute !important;
|
|
163
|
+
inset: 0 !important;
|
|
164
|
+
width: 100% !important;
|
|
148
165
|
height: 100% !important;
|
|
149
|
-
|
|
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({
|
|
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
|
-
|
|
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 () => {
|