@nocobase/client-v2 2.2.0-alpha.7 → 2.2.0-beta.10
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 -1
- package/es/components/form/TypedVariableInput.d.ts +5 -31
- package/es/components/form/filter/CollectionFilter.d.ts +0 -2
- package/es/components/form/filter/CollectionFilterItem.d.ts +1 -11
- package/es/components/form/filter/CollectionFilterPanel.d.ts +0 -2
- package/es/components/form/filter/index.d.ts +0 -2
- package/es/components/form/filter/useFilterActionProps.d.ts +0 -2
- package/es/components/index.d.ts +0 -1
- package/es/flow/components/FieldAssignExactDatePicker.d.ts +0 -1
- package/es/flow/components/FieldAssignValueInput.d.ts +0 -3
- package/es/flow/components/RunJSValueEditor.d.ts +0 -1
- package/es/flow/index.d.ts +0 -1
- package/es/index.d.ts +0 -4
- package/es/index.mjs +99 -133
- package/es/utils/index.d.ts +0 -1
- package/lib/index.js +106 -140
- package/package.json +7 -8
- package/src/BaseApplication.tsx +6 -6
- package/src/__tests__/app.test.tsx +0 -26
- package/src/__tests__/browserChecker.test.ts +0 -61
- package/src/components/README.md +1 -7
- package/src/components/README.zh-CN.md +1 -6
- package/src/components/form/JsonTextArea.tsx +0 -4
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +2 -145
- package/src/components/form/ScanInput/useCodeScanner.ts +2 -154
- package/src/components/form/TypedVariableInput.tsx +98 -445
- package/src/components/form/__tests__/TypedVariableInput.test.tsx +9 -320
- package/src/components/form/filter/CollectionFilter.tsx +0 -4
- package/src/components/form/filter/CollectionFilterItem.tsx +7 -32
- package/src/components/form/filter/CollectionFilterPanel.tsx +0 -4
- package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +0 -38
- package/src/components/form/filter/index.ts +0 -2
- package/src/components/form/filter/useFilterActionProps.ts +6 -37
- package/src/components/index.ts +0 -1
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -0
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +6 -253
- package/src/flow/components/FieldAssignExactDatePicker.tsx +11 -25
- package/src/flow/components/FieldAssignValueInput.tsx +9 -55
- package/src/flow/components/RunJSValueEditor.tsx +1 -9
- package/src/flow/index.ts +0 -1
- package/src/index.ts +0 -4
- package/src/utils/index.tsx +0 -1
- package/es/components/category-tabs/SortableCategoryTabs.d.ts +0 -55
- package/es/components/category-tabs/index.d.ts +0 -9
- package/es/utils/getRouteRuntimeVersion.d.ts +0 -23
- package/src/__tests__/getRouteRuntimeVersion.test.ts +0 -68
- package/src/components/category-tabs/SortableCategoryTabs.tsx +0 -210
- package/src/components/category-tabs/index.ts +0 -10
- package/src/components/form/filter/__tests__/useFilterActionProps.test.tsx +0 -95
- package/src/utils/getRouteRuntimeVersion.ts +0 -185
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.2.0-
|
|
3
|
+
"version": "2.2.0-beta.10",
|
|
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-
|
|
31
|
-
"@nocobase/flow-engine": "2.2.0-
|
|
32
|
-
"@nocobase/sdk": "2.2.0-
|
|
33
|
-
"@nocobase/shared": "2.2.0-
|
|
34
|
-
"@nocobase/utils": "2.2.0-
|
|
30
|
+
"@nocobase/evaluators": "2.2.0-beta.10",
|
|
31
|
+
"@nocobase/flow-engine": "2.2.0-beta.10",
|
|
32
|
+
"@nocobase/sdk": "2.2.0-beta.10",
|
|
33
|
+
"@nocobase/shared": "2.2.0-beta.10",
|
|
34
|
+
"@nocobase/utils": "2.2.0-beta.10",
|
|
35
35
|
"ahooks": "^3.7.2",
|
|
36
36
|
"antd": "5.24.2",
|
|
37
37
|
"antd-style": "3.7.1",
|
|
@@ -42,10 +42,9 @@
|
|
|
42
42
|
"html5-qrcode": "^2.3.8",
|
|
43
43
|
"i18next": "^22.4.9",
|
|
44
44
|
"json5": "^2.2.3",
|
|
45
|
-
"jsqr": "^1.4.0",
|
|
46
45
|
"lodash": "4.17.21",
|
|
47
46
|
"react-i18next": "^11.15.1",
|
|
48
47
|
"react-router-dom": "^6.30.1"
|
|
49
48
|
},
|
|
50
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "d572beec24de46df948f28db72b25303a63bf62d"
|
|
51
50
|
}
|
package/src/BaseApplication.tsx
CHANGED
|
@@ -81,10 +81,6 @@ const trimTrailingSlashes = (value: string) => {
|
|
|
81
81
|
return match ? value.slice(0, -match[0].length) : value;
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
-
const ensureTrailingSlash = (value: string) => {
|
|
85
|
-
return `${trimTrailingSlashes(value)}/`;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
84
|
const isRenderableComponentType = (value: unknown): value is AnyComponent => isValidElementType(value);
|
|
89
85
|
|
|
90
86
|
export type DevDynamicImport = (packageName: string) => Promise<{ default: PluginClass }>;
|
|
@@ -438,11 +434,15 @@ export abstract class BaseApplication<
|
|
|
438
434
|
}
|
|
439
435
|
|
|
440
436
|
getCdnUrl() {
|
|
441
|
-
return
|
|
437
|
+
return window['__webpack_public_path__'] || this.getPublicPath();
|
|
442
438
|
}
|
|
443
439
|
|
|
444
440
|
getPublicPath() {
|
|
445
|
-
|
|
441
|
+
let publicPath = this.options.publicPath || '/';
|
|
442
|
+
if (!publicPath.endsWith('/')) {
|
|
443
|
+
publicPath += '/';
|
|
444
|
+
}
|
|
445
|
+
return publicPath;
|
|
446
446
|
}
|
|
447
447
|
|
|
448
448
|
getApiUrl(pathname = '') {
|
|
@@ -34,7 +34,6 @@ 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
37
|
vi.restoreAllMocks();
|
|
39
38
|
});
|
|
40
39
|
|
|
@@ -59,31 +58,6 @@ describe('app', () => {
|
|
|
59
58
|
expect(app.jsonLogic.apply({ $testAlwaysTrue: [] })).toBe(true);
|
|
60
59
|
});
|
|
61
60
|
|
|
62
|
-
it('should normalize publicPath and webpack public path with a single trailing slash', () => {
|
|
63
|
-
const app = new Application({
|
|
64
|
-
router,
|
|
65
|
-
publicPath: '/admin//',
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
expect(app.getPublicPath()).toBe('/admin/');
|
|
69
|
-
|
|
70
|
-
window['__webpack_public_path__'] = '/cdn/assets///';
|
|
71
|
-
expect(app.getCdnUrl()).toBe('/cdn/assets/');
|
|
72
|
-
|
|
73
|
-
delete window['__webpack_public_path__'];
|
|
74
|
-
expect(app.getCdnUrl()).toBe('/admin/');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('should normalize webpack public path without a trailing slash', () => {
|
|
78
|
-
const app = new Application({
|
|
79
|
-
router,
|
|
80
|
-
publicPath: '/admin/',
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
window['__webpack_public_path__'] = '/cdn/assets';
|
|
84
|
-
expect(app.getCdnUrl()).toBe('/cdn/assets/');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
61
|
it('should apply the provided favicon immediately', () => {
|
|
88
62
|
const app = new Application({ router });
|
|
89
63
|
|
|
@@ -15,8 +15,6 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
type BrowserCheckerCase = {
|
|
16
16
|
pathname: string;
|
|
17
17
|
publicPath: string;
|
|
18
|
-
modernClientPrefix?: string;
|
|
19
|
-
appClientEntryMode?: string;
|
|
20
18
|
expectedRedirect?: string;
|
|
21
19
|
};
|
|
22
20
|
|
|
@@ -42,8 +40,6 @@ function executeBrowserChecker(scriptPath: string, input: BrowserCheckerCase) {
|
|
|
42
40
|
showLog: false,
|
|
43
41
|
window: {
|
|
44
42
|
__nocobase_public_path__: input.publicPath,
|
|
45
|
-
__nocobase_modern_client_prefix__: input.modernClientPrefix,
|
|
46
|
-
__nocobase_app_client_entry_mode__: input.appClientEntryMode,
|
|
47
43
|
location: {
|
|
48
44
|
origin: 'http://c.local.nocobase.com',
|
|
49
45
|
pathname: input.pathname,
|
|
@@ -113,61 +109,4 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
|
|
|
113
109
|
|
|
114
110
|
expect(replace).not.toHaveBeenCalled();
|
|
115
111
|
});
|
|
116
|
-
|
|
117
|
-
if (scriptPath.includes('/app/client/public/')) {
|
|
118
|
-
it('redirects app root to modern entry for modern-default', () => {
|
|
119
|
-
const replace = executeBrowserChecker(scriptPath, {
|
|
120
|
-
pathname: '/',
|
|
121
|
-
publicPath: '/',
|
|
122
|
-
modernClientPrefix: 'v',
|
|
123
|
-
appClientEntryMode: 'modern-default',
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/');
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('does not redirect legacy deep links for modern-default', () => {
|
|
130
|
-
const replace = executeBrowserChecker(scriptPath, {
|
|
131
|
-
pathname: '/admin',
|
|
132
|
-
publicPath: '/',
|
|
133
|
-
modernClientPrefix: 'v',
|
|
134
|
-
appClientEntryMode: 'modern-default',
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
expect(replace).not.toHaveBeenCalled();
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('rewrites legacy document paths for modern-only', () => {
|
|
141
|
-
const replace = executeBrowserChecker(scriptPath, {
|
|
142
|
-
pathname: '/admin/settings/workflow',
|
|
143
|
-
publicPath: '/',
|
|
144
|
-
modernClientPrefix: 'v',
|
|
145
|
-
appClientEntryMode: 'modern-only',
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/admin/settings/workflow');
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('redirects sub-path site root directly to final modern target', () => {
|
|
152
|
-
const replace = executeBrowserChecker(scriptPath, {
|
|
153
|
-
pathname: '/nocobase/',
|
|
154
|
-
publicPath: '/nocobase/',
|
|
155
|
-
modernClientPrefix: 'v',
|
|
156
|
-
appClientEntryMode: 'modern-default',
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/');
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('rewrites sub-app legacy deep links for modern-only without collapsing the sub-app segment', () => {
|
|
163
|
-
const replace = executeBrowserChecker(scriptPath, {
|
|
164
|
-
pathname: '/nocobase/apps/a_31itq60q4kg/admin/',
|
|
165
|
-
publicPath: '/nocobase/',
|
|
166
|
-
modernClientPrefix: 'v',
|
|
167
|
-
appClientEntryMode: 'modern-only',
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/apps/a_31itq60q4kg/admin');
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
112
|
});
|
package/src/components/README.md
CHANGED
|
@@ -190,10 +190,6 @@ import { TypedVariableInput } from '@nocobase/client-v2';
|
|
|
190
190
|
<Form.Item name={['options', 'secure']} label={t('Secure')} initialValue={true}>
|
|
191
191
|
<TypedVariableInput types={['boolean']} namespaces={['$env']} />
|
|
192
192
|
</Form.Item>
|
|
193
|
-
|
|
194
|
-
// Inject a custom variable tree (e.g. a workflow node's upstream outputs,
|
|
195
|
-
// which are not in the global registry)
|
|
196
|
-
<TypedVariableInput types={['string', 'number']} metaTree={workflowMetaTree} />
|
|
197
193
|
```
|
|
198
194
|
|
|
199
195
|
Key props:
|
|
@@ -201,7 +197,6 @@ Key props:
|
|
|
201
197
|
- `types`: allowed constant types. Shape mirrors v1 `useTypedConstant` — pass bare type names (`['number', 'boolean']`) or `[type, editorProps]` tuples (`[['number', { min, max, step }]]`) to forward props to the underlying antd editor. Defaults to `['string', 'number', 'boolean', 'date']`. **Even when only one type is allowed, the `Constant` entry still expands into a typed submenu** (Number / Boolean / Date / String) — matches v1 so users can see what type the constant is
|
|
202
198
|
- `namespaces`: restrict the variable picker to specific top-level namespaces (e.g. `['$env']`). Omit to expose every namespace registered on `flowEngine.context`
|
|
203
199
|
- `extraNodes`: static leaves appended after the namespace-filtered nodes
|
|
204
|
-
- `metaTree`: **inject the variable tree directly** instead of reading the global `flowEngine.context` meta tree. When set, `namespaces`/`extraNodes` are ignored and this tree is used verbatim — for context-scoped variable sources that are not in the global registry (typically a workflow node's upstream outputs, `$jobsMapByNodeKey`). Nodes whose `children` is a thunk (`() => Promise<MetaTreeNode[]>`) are **lazy-loaded on expand** (via flow-engine's `loadMetaTreeChildren`)
|
|
205
200
|
- `nullable`: whether to expose the `Null` switcher entry. Default `true`. Combined with `Form.Item.rules={[{ required: true }]}`, the user can explicitly clear the field but submission is still blocked by validation — mirrors v1's "Null + required" pairing
|
|
206
201
|
- `delimiters`: variable-token delimiters, default `['{{', '}}']` — same as `VariableInput`
|
|
207
202
|
- `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`: standard controlled-input props
|
|
@@ -217,10 +212,9 @@ When **not** to use it:
|
|
|
217
212
|
- **Pure literal fields** (users will never pass a variable) → use the antd primitive directly (`InputNumber` / `Select` / `DatePicker` / `Input`) and skip the Cascader column overhead
|
|
218
213
|
- **Pure variable fields** (users will never pass a literal) → use `EnvVariableInput` (`$env`-only, with optional password masking) or `VariableInput` (general-purpose)
|
|
219
214
|
|
|
220
|
-
Supported constant types: `string` / `number` / `boolean` / `date` / `object`. The `object` (JSON) type renders an inline monospace textarea (2 rows by default, drag-resizable) — it keeps the raw text as a draft while editing and `JSON.parse`s it back into an object on blur. On a parse failure it shows the raw `JSON.parse` message (e.g. `Expected property name or '}' in JSON at position …`, matching v1) on its own row below the input, and does not emit. Mirrors v1 `useTypedConstant`'s object form (default value `{}`).
|
|
221
|
-
|
|
222
215
|
Capabilities skipped (present in v1, not yet ported to v2):
|
|
223
216
|
|
|
217
|
+
- `object` constant type (JSON editor) — v2 has no inline "JSON editor + Cascader switcher" yet; add when there's a concrete caller
|
|
224
218
|
- Async `loadChildren` cascading — most MetaTree namespaces are already eagerly resolved by `useFilteredMetaTree`, so this hasn't been needed
|
|
225
219
|
|
|
226
220
|
#### FileSizeInput
|
|
@@ -190,9 +190,6 @@ import { TypedVariableInput } from '@nocobase/client-v2';
|
|
|
190
190
|
<Form.Item name={['options', 'secure']} label={t('安全模式')} initialValue={true}>
|
|
191
191
|
<TypedVariableInput types={['boolean']} namespaces={['$env']} />
|
|
192
192
|
</Form.Item>
|
|
193
|
-
|
|
194
|
-
// 注入自定义变量树(如工作流节点的上游输出,不在全局注册表里)
|
|
195
|
-
<TypedVariableInput types={['string', 'number']} metaTree={workflowMetaTree} />
|
|
196
193
|
```
|
|
197
194
|
|
|
198
195
|
主要属性:
|
|
@@ -200,7 +197,6 @@ import { TypedVariableInput } from '@nocobase/client-v2';
|
|
|
200
197
|
- `types`:允许的常量类型。形态对齐 v1 `useTypedConstant`,可以传裸类型名 `['number', 'boolean']`,也可以传 `[type, editorProps]` 元组 `[['number', { min, max, step }]]` 把 props 透传给底层 antd 编辑器。默认 `['string', 'number', 'boolean', 'date']`。**即使只允许一种类型,「常量」入口也会展开二级菜单**(数字 / 逻辑值 / 日期 / 字符串)——跟 v1 一致,让用户能直观看到当前常量是什么类型
|
|
201
198
|
- `namespaces`:限定变量 picker 可选的顶层命名空间(如 `['$env']`)。不传就用 `flowEngine.context` 里所有已注册命名空间
|
|
202
199
|
- `extraNodes`:在命名空间过滤后追加几条静态变量节点
|
|
203
|
-
- `metaTree`:**直接注入变量树**,取代读取全局 `flowEngine.context` 的 MetaTree。传了它就**忽略** `namespaces`/`extraNodes`,原样使用这棵树——用于不在全局注册表里的、上下文相关的变量源(典型如工作流节点的上游节点输出 `$jobsMapByNodeKey`)。树里 `children` 为函数(`() => Promise<MetaTreeNode[]>`)的节点会在用户展开 Cascader 时**按需懒加载**(复用 flow-engine 的 `loadMetaTreeChildren`)
|
|
204
200
|
- `nullable`:是否暴露「空值」入口,默认 `true`。配合 `Form.Item.rules={[{ required: true }]}` 可以让用户能手动清空、但提交时会被校验拦截——跟 v1 的「空值 + required」组合一致
|
|
205
201
|
- `delimiters`:变量 token 开闭分隔符,默认 `['{{', '}}']`,跟 `VariableInput` 一致
|
|
206
202
|
- `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`:标准受控字段属性
|
|
@@ -216,10 +212,9 @@ import { TypedVariableInput } from '@nocobase/client-v2';
|
|
|
216
212
|
- **纯字面量字段**(用户不会想填变量)→ 直接用 antd `InputNumber` / `Select` / `DatePicker` / `Input`,省掉 Cascader 那一格的视觉开销
|
|
217
213
|
- **纯变量字段**(用户不会想填字面量)→ 用 `EnvVariableInput`(`$env` 专用,带 password mask)或 `VariableInput`(更通用)
|
|
218
214
|
|
|
219
|
-
支持的常量类型:`string` / `number` / `boolean` / `date` / `object`。其中 `object`(即 JSON)渲染为一个等宽字体的内联 textarea(默认两行、可拖拽拉伸),编辑时保留原始文本草稿、失焦(blur)时 `JSON.parse` 回写为对象;解析失败则在输入框**下方单独一行**显示原生 `JSON.parse` 的错误信息(如 `Expected property name or '}' in JSON at position …`,对齐 v1)且不回写。对齐 v1 `useTypedConstant` 的 object 形态(默认值 `{}`)。
|
|
220
|
-
|
|
221
215
|
跳过的能力(v1 有但 v2 还没补):
|
|
222
216
|
|
|
217
|
+
- `object` 类型(JSON 编辑器)——v2 还没对应的「内联 JSON 编辑器 + Cascader 切换」组件,等真有需求再补
|
|
223
218
|
- 异步 `loadChildren` 分支——大多数命名空间的 MetaTree 已经由 `useFilteredMetaTree` 提前展平,没遇到刚需
|
|
224
219
|
|
|
225
220
|
#### FileSizeInput
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { render, waitFor } from '@testing-library/react';
|
|
11
11
|
import React, { useCallback } from 'react';
|
|
12
|
-
import {
|
|
12
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
13
|
import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, useCodeScanner } from '../useCodeScanner';
|
|
14
14
|
|
|
15
15
|
type MockScannerInstance = {
|
|
@@ -44,12 +44,6 @@ const mocks = vi.hoisted(() => {
|
|
|
44
44
|
};
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
const jsQrMocks = vi.hoisted(() => {
|
|
48
|
-
return {
|
|
49
|
-
default: vi.fn(),
|
|
50
|
-
};
|
|
51
|
-
});
|
|
52
|
-
|
|
53
47
|
vi.mock('html5-qrcode', () => ({
|
|
54
48
|
Html5Qrcode: mocks.Html5Qrcode,
|
|
55
49
|
Html5QrcodeScannerState: {
|
|
@@ -72,8 +66,6 @@ vi.mock('html5-qrcode', () => ({
|
|
|
72
66
|
},
|
|
73
67
|
}));
|
|
74
68
|
|
|
75
|
-
vi.mock('jsqr', () => jsQrMocks);
|
|
76
|
-
|
|
77
69
|
function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: number } } = {}) {
|
|
78
70
|
const handleScanSuccess = useCallback(() => undefined, []);
|
|
79
71
|
|
|
@@ -87,74 +79,9 @@ function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: n
|
|
|
87
79
|
return <div id="scanner" />;
|
|
88
80
|
}
|
|
89
81
|
|
|
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
|
-
|
|
149
82
|
describe('useCodeScanner', () => {
|
|
150
83
|
beforeEach(() => {
|
|
151
84
|
vi.clearAllMocks();
|
|
152
|
-
jsQrMocks.default.mockReturnValue(undefined);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
afterEach(() => {
|
|
156
|
-
vi.unstubAllGlobals();
|
|
157
|
-
vi.restoreAllMocks();
|
|
158
85
|
});
|
|
159
86
|
|
|
160
87
|
it('starts scanning with QR code and barcode formats by default', async () => {
|
|
@@ -193,74 +120,4 @@ describe('useCodeScanner', () => {
|
|
|
193
120
|
|
|
194
121
|
expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
|
|
195
122
|
});
|
|
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
|
-
});
|
|
266
123
|
});
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
|
11
|
-
import jsQR from 'jsqr';
|
|
12
11
|
import { useCallback, useEffect, useState } from 'react';
|
|
13
12
|
import type { CodeFormatsToSupport } from './types';
|
|
14
13
|
|
|
@@ -27,27 +26,6 @@ type UseCodeScannerOptions = {
|
|
|
27
26
|
onScanFailure?: () => void;
|
|
28
27
|
};
|
|
29
28
|
|
|
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
|
-
|
|
51
29
|
export const DEFAULT_CODE_FORMATS: CodeFormatsToSupport = [
|
|
52
30
|
Html5QrcodeSupportedFormats.QR_CODE,
|
|
53
31
|
Html5QrcodeSupportedFormats.CODE_128,
|
|
@@ -84,126 +62,6 @@ async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } =
|
|
|
84
62
|
}
|
|
85
63
|
}
|
|
86
64
|
|
|
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
|
-
|
|
207
65
|
export function useCodeScanner({
|
|
208
66
|
enabled,
|
|
209
67
|
elementId,
|
|
@@ -237,16 +95,6 @@ export function useCodeScanner({
|
|
|
237
95
|
|
|
238
96
|
const startScanFile = useCallback(
|
|
239
97
|
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
|
-
|
|
250
98
|
if (!scanner) {
|
|
251
99
|
return;
|
|
252
100
|
}
|
|
@@ -255,12 +103,12 @@ export function useCodeScanner({
|
|
|
255
103
|
try {
|
|
256
104
|
const result = await scanner.scanFileV2(file, false);
|
|
257
105
|
onScanSuccess(result.decodedText);
|
|
258
|
-
} catch {
|
|
106
|
+
} catch (error) {
|
|
259
107
|
onScanFailure?.();
|
|
260
108
|
await startScanCamera(scanner);
|
|
261
109
|
}
|
|
262
110
|
},
|
|
263
|
-
[
|
|
111
|
+
[onScanFailure, onScanSuccess, scanner, startScanCamera],
|
|
264
112
|
);
|
|
265
113
|
|
|
266
114
|
useEffect(() => {
|