@nocobase/client-v2 2.1.0-beta.47 → 2.1.0-beta.48
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/components/form/ScanInput/CodeScanner.d.ts +18 -0
- package/es/components/form/ScanInput/ScanBox.d.ts +12 -0
- package/es/components/form/ScanInput/ScanInput.d.ts +18 -0
- package/es/components/form/ScanInput/index.d.ts +11 -0
- package/es/components/form/ScanInput/types.d.ts +10 -0
- package/es/components/form/ScanInput/useCodeScanner.d.ts +31 -0
- package/es/components/form/index.d.ts +1 -0
- package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +14 -0
- package/es/index.mjs +76 -36
- package/lib/index.js +111 -71
- package/package.json +8 -7
- package/src/components/form/ScanInput/CodeScanner.tsx +219 -0
- package/src/components/form/ScanInput/ScanBox.tsx +30 -0
- package/src/components/form/ScanInput/ScanInput.tsx +150 -0
- package/src/components/form/ScanInput/__tests__/ScanInput.test.tsx +119 -0
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +123 -0
- package/src/components/form/ScanInput/index.ts +12 -0
- package/src/components/form/ScanInput/types.ts +12 -0
- package/src/components/form/ScanInput/useCodeScanner.ts +136 -0
- package/src/components/form/index.tsx +1 -0
- package/src/flow/actions/__tests__/actionLinkageRules.race.repro.test.ts +45 -0
- package/src/flow/actions/linkageRules.tsx +16 -4
- package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +111 -26
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +61 -0
- package/src/flow/models/fields/InputFieldModel.tsx +56 -1
- package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +116 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
|
11
|
+
|
|
12
|
+
export type CodeFormatsToSupport = Html5QrcodeSupportedFormats[];
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
|
11
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
12
|
+
import type { CodeFormatsToSupport } from './types';
|
|
13
|
+
|
|
14
|
+
type ScannerSize = {
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type UseCodeScannerOptions = {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
elementId: string;
|
|
22
|
+
formatsToSupport?: CodeFormatsToSupport;
|
|
23
|
+
scanBoxSize?: ScannerSize;
|
|
24
|
+
onScannerSizeChanged?: (size: ScannerSize) => void;
|
|
25
|
+
onScanSuccess: (text: string) => void;
|
|
26
|
+
onScanFailure?: () => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_CODE_FORMATS: CodeFormatsToSupport = [
|
|
30
|
+
Html5QrcodeSupportedFormats.QR_CODE,
|
|
31
|
+
Html5QrcodeSupportedFormats.CODE_128,
|
|
32
|
+
Html5QrcodeSupportedFormats.CODE_39,
|
|
33
|
+
Html5QrcodeSupportedFormats.CODE_93,
|
|
34
|
+
Html5QrcodeSupportedFormats.CODABAR,
|
|
35
|
+
Html5QrcodeSupportedFormats.EAN_13,
|
|
36
|
+
Html5QrcodeSupportedFormats.EAN_8,
|
|
37
|
+
Html5QrcodeSupportedFormats.ITF,
|
|
38
|
+
Html5QrcodeSupportedFormats.UPC_A,
|
|
39
|
+
Html5QrcodeSupportedFormats.UPC_E,
|
|
40
|
+
Html5QrcodeSupportedFormats.DATA_MATRIX,
|
|
41
|
+
Html5QrcodeSupportedFormats.PDF_417,
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export function getCodeScanBoxSize(width: number, height: number) {
|
|
45
|
+
return {
|
|
46
|
+
width: Math.floor(Math.min(width * 0.82, 520)),
|
|
47
|
+
height: Math.floor(Math.min(height * 0.32, 240)),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } = {}) {
|
|
52
|
+
if (!scanner) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const state = scanner.getState();
|
|
57
|
+
if ([Html5QrcodeScannerState.SCANNING, Html5QrcodeScannerState.PAUSED].includes(state)) {
|
|
58
|
+
await scanner.stop();
|
|
59
|
+
}
|
|
60
|
+
if (options.clear) {
|
|
61
|
+
scanner.clear();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useCodeScanner({
|
|
66
|
+
enabled,
|
|
67
|
+
elementId,
|
|
68
|
+
formatsToSupport,
|
|
69
|
+
scanBoxSize,
|
|
70
|
+
onScannerSizeChanged,
|
|
71
|
+
onScanSuccess,
|
|
72
|
+
onScanFailure,
|
|
73
|
+
}: UseCodeScannerOptions) {
|
|
74
|
+
const [scanner, setScanner] = useState<Html5Qrcode>();
|
|
75
|
+
|
|
76
|
+
const startScanCamera = useCallback(
|
|
77
|
+
async (scannerInstance: Html5Qrcode) => {
|
|
78
|
+
await scannerInstance.start(
|
|
79
|
+
{ facingMode: 'environment' },
|
|
80
|
+
{
|
|
81
|
+
fps: 10,
|
|
82
|
+
qrbox(width, height) {
|
|
83
|
+
onScannerSizeChanged?.({ width, height });
|
|
84
|
+
return scanBoxSize ?? getCodeScanBoxSize(width, height);
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
(decodedText) => {
|
|
88
|
+
onScanSuccess(decodedText);
|
|
89
|
+
},
|
|
90
|
+
undefined,
|
|
91
|
+
);
|
|
92
|
+
},
|
|
93
|
+
[onScanSuccess, onScannerSizeChanged, scanBoxSize],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const startScanFile = useCallback(
|
|
97
|
+
async (file: File) => {
|
|
98
|
+
if (!scanner) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await stopScanner(scanner);
|
|
103
|
+
try {
|
|
104
|
+
const result = await scanner.scanFileV2(file, false);
|
|
105
|
+
onScanSuccess(result.decodedText);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
onScanFailure?.();
|
|
108
|
+
await startScanCamera(scanner);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
[onScanFailure, onScanSuccess, scanner, startScanCamera],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!enabled) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const scannerInstance = new Html5Qrcode(elementId, {
|
|
120
|
+
formatsToSupport: formatsToSupport?.length ? formatsToSupport : DEFAULT_CODE_FORMATS,
|
|
121
|
+
verbose: false,
|
|
122
|
+
});
|
|
123
|
+
setScanner(scannerInstance);
|
|
124
|
+
startScanCamera(scannerInstance).catch(() => {
|
|
125
|
+
onScanFailure?.();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return () => {
|
|
129
|
+
stopScanner(scannerInstance, { clear: true }).catch(() => undefined);
|
|
130
|
+
};
|
|
131
|
+
}, [elementId, enabled, formatsToSupport, onScanFailure, startScanCamera]);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
startScanFile,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
import { describe, expect, it, vi } from 'vitest';
|
|
11
11
|
import { actionLinkageRules } from '../linkageRules';
|
|
12
12
|
|
|
13
|
+
class ActionModel {}
|
|
14
|
+
class PopupSubTableEditActionModel extends ActionModel {}
|
|
15
|
+
|
|
13
16
|
function createActionModel() {
|
|
14
17
|
const model: any = {
|
|
15
18
|
uid: 'edit-action',
|
|
@@ -196,4 +199,46 @@ describe('actionLinkageRules props patch isolation', () => {
|
|
|
196
199
|
|
|
197
200
|
expect(model.hidden).toBe(true);
|
|
198
201
|
});
|
|
202
|
+
|
|
203
|
+
it('does not sync row action hidden state to the popup subtable field path', async () => {
|
|
204
|
+
const model = createActionModel();
|
|
205
|
+
Object.defineProperty(model, 'constructor', {
|
|
206
|
+
value: PopupSubTableEditActionModel,
|
|
207
|
+
});
|
|
208
|
+
model.isFork = true;
|
|
209
|
+
model.context = {
|
|
210
|
+
blockModel: { uid: 'form-block' },
|
|
211
|
+
fieldPathArray: ['org_o2m'],
|
|
212
|
+
};
|
|
213
|
+
const fieldModel: any = {
|
|
214
|
+
uid: 'org-o2m-field',
|
|
215
|
+
hidden: false,
|
|
216
|
+
context: {
|
|
217
|
+
blockModel: { uid: 'form-block' },
|
|
218
|
+
fieldPathArray: ['org_o2m'],
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
const { ctx } = createRuntime(model, { pauseHiddenAction: false });
|
|
222
|
+
ctx.engine = {
|
|
223
|
+
forEachModel: (visitor: (m: any) => void) => {
|
|
224
|
+
visitor(model);
|
|
225
|
+
visitor(fieldModel);
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
await actionLinkageRules.handler(
|
|
230
|
+
ctx,
|
|
231
|
+
createLinkageParams([
|
|
232
|
+
{
|
|
233
|
+
name: 'linkageSetActionProps',
|
|
234
|
+
params: {
|
|
235
|
+
value: 'hidden',
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
]),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
expect(model.hidden).toBe(true);
|
|
242
|
+
expect(fieldModel.hidden).toBe(false);
|
|
243
|
+
});
|
|
199
244
|
});
|
|
@@ -106,6 +106,18 @@ const getLinkageScopeDepthFromContext = (ctx: FlowContext): number => {
|
|
|
106
106
|
return getLinkageScopeDepthFromModel((ctx as any)?.model);
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
+
const isActionFlowModel = (model: any): boolean => {
|
|
110
|
+
if (!model || typeof model !== 'object') return false;
|
|
111
|
+
|
|
112
|
+
let proto = model.constructor?.prototype;
|
|
113
|
+
while (proto) {
|
|
114
|
+
if (proto.constructor?.name === 'ActionModel') return true;
|
|
115
|
+
proto = Object.getPrototypeOf(proto);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return false;
|
|
119
|
+
};
|
|
120
|
+
|
|
109
121
|
// 获取表单中所有字段的 model 实例的通用函数
|
|
110
122
|
const getFormFields = (ctx: any) => {
|
|
111
123
|
try {
|
|
@@ -2142,12 +2154,12 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
|
|
|
2142
2154
|
model.setProps(_.omit(newProps, ['hiddenModel', 'value', 'hiddenText']));
|
|
2143
2155
|
syncFieldOptionsToForks(model, patchProps);
|
|
2144
2156
|
if (typeof model.setHidden === 'function') {
|
|
2145
|
-
model.setHidden(
|
|
2157
|
+
model.setHidden(nextHidden);
|
|
2146
2158
|
} else {
|
|
2147
2159
|
model.hidden = nextHidden;
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
}
|
|
2160
|
+
}
|
|
2161
|
+
if (prevHidden !== nextHidden && !isActionFlowModel(model)) {
|
|
2162
|
+
hiddenStatePatches.push({ model, hidden: nextHidden });
|
|
2151
2163
|
}
|
|
2152
2164
|
|
|
2153
2165
|
if (newProps.required === true) {
|
|
@@ -31,7 +31,90 @@ import {
|
|
|
31
31
|
createRootItemChain,
|
|
32
32
|
type ItemChain,
|
|
33
33
|
} from './itemChain';
|
|
34
|
-
import { buildOpenerUids, LabelByField } from './recordSelectShared';
|
|
34
|
+
import { buildOpenerUids, LabelByField, type AssociationFieldNames } from './recordSelectShared';
|
|
35
|
+
|
|
36
|
+
const MULTIPLE_ASSOCIATION_TYPES = ['belongsToMany', 'hasMany', 'belongsToArray'];
|
|
37
|
+
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function canRecordPickerSelectMultiple(
|
|
43
|
+
collectionField: { type?: string } | null | undefined,
|
|
44
|
+
allowMultiple?: boolean,
|
|
45
|
+
) {
|
|
46
|
+
return (
|
|
47
|
+
!!collectionField?.type && MULTIPLE_ASSOCIATION_TYPES.includes(collectionField.type) && allowMultiple !== false
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function shouldClearRecordPickerValueOnMultipleChange(
|
|
52
|
+
collectionField: { type?: string } | null | undefined,
|
|
53
|
+
previousAllowMultiple?: boolean,
|
|
54
|
+
nextAllowMultiple?: boolean,
|
|
55
|
+
) {
|
|
56
|
+
return (
|
|
57
|
+
canRecordPickerSelectMultiple(collectionField, previousAllowMultiple) !==
|
|
58
|
+
canRecordPickerSelectMultiple(collectionField, nextAllowMultiple)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function normalizeRecordPickerSelectedRows(value: unknown, allowMultiple: boolean) {
|
|
63
|
+
if (!value) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
if (allowMultiple) {
|
|
67
|
+
return Array.isArray(value) ? value : [value];
|
|
68
|
+
}
|
|
69
|
+
return Array.isArray(value) ? value.slice(0, 1) : [value];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getRecordPickerEmptyValue(allowMultiple: boolean) {
|
|
73
|
+
return allowMultiple ? [] : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getRecordPickerClearedValue() {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function normalizeRecordPickerValue(value: unknown, fieldNames: AssociationFieldNames, allowMultiple: boolean) {
|
|
81
|
+
if (!value) {
|
|
82
|
+
return getRecordPickerEmptyValue(allowMultiple);
|
|
83
|
+
}
|
|
84
|
+
const toSelectItem = (item: Record<string, unknown>) => ({
|
|
85
|
+
...item,
|
|
86
|
+
label: item[fieldNames.label],
|
|
87
|
+
value: item[fieldNames.value],
|
|
88
|
+
});
|
|
89
|
+
if (!allowMultiple) {
|
|
90
|
+
const item = Array.isArray(value) ? value[0] : value;
|
|
91
|
+
return isRecord(item) ? toSelectItem(item) : undefined;
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(value)) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
return value.filter(isRecord).map(toSelectItem);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function applyRecordPickerAllowMultiple(ctx: any, params: any, previousParams?: any) {
|
|
100
|
+
const shouldClearValue =
|
|
101
|
+
previousParams &&
|
|
102
|
+
shouldClearRecordPickerValueOnMultipleChange(
|
|
103
|
+
ctx.collectionField,
|
|
104
|
+
previousParams?.allowMultiple,
|
|
105
|
+
params?.allowMultiple,
|
|
106
|
+
);
|
|
107
|
+
const emptyValue = getRecordPickerClearedValue();
|
|
108
|
+
|
|
109
|
+
ctx.model.setProps({
|
|
110
|
+
allowMultiple: params?.allowMultiple,
|
|
111
|
+
...(shouldClearValue ? { value: emptyValue } : {}),
|
|
112
|
+
});
|
|
113
|
+
if (shouldClearValue) {
|
|
114
|
+
ctx.model.selectedRows.value = emptyValue;
|
|
115
|
+
ctx.model.props.onChange?.(emptyValue);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
35
118
|
|
|
36
119
|
export function buildRecordPickerParentItemContext(ctx: any): {
|
|
37
120
|
parentItem: ItemChain;
|
|
@@ -241,25 +324,10 @@ export function RecordPickerContent({ model, toOne = false }) {
|
|
|
241
324
|
function RecordPickerField(props) {
|
|
242
325
|
const { fieldNames, onClick, disabled } = props;
|
|
243
326
|
const ctx = useFlowContext();
|
|
244
|
-
const
|
|
327
|
+
const allowMultiple = canRecordPickerSelectMultiple(ctx.collectionField, props.allowMultiple);
|
|
245
328
|
useEffect(() => {
|
|
246
329
|
ctx.model.selectedRows.value = props.value;
|
|
247
|
-
}, [props.value]);
|
|
248
|
-
const normalizeValue = (value, fieldNames, toOne) => {
|
|
249
|
-
if (!value) return toOne ? undefined : [];
|
|
250
|
-
if (toOne) {
|
|
251
|
-
return {
|
|
252
|
-
...value,
|
|
253
|
-
label: value[fieldNames.label],
|
|
254
|
-
value: value[fieldNames.value],
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
return value.map((v) => ({
|
|
258
|
-
...v,
|
|
259
|
-
label: v[fieldNames.label],
|
|
260
|
-
value: v[fieldNames.value],
|
|
261
|
-
}));
|
|
262
|
-
};
|
|
330
|
+
}, [ctx.model.selectedRows, props.value]);
|
|
263
331
|
|
|
264
332
|
return (
|
|
265
333
|
<Select
|
|
@@ -270,12 +338,12 @@ function RecordPickerField(props) {
|
|
|
270
338
|
onClick(e);
|
|
271
339
|
}
|
|
272
340
|
}}
|
|
273
|
-
value={
|
|
341
|
+
value={normalizeRecordPickerValue(props.value, fieldNames, allowMultiple)}
|
|
274
342
|
labelRender={(item) => {
|
|
275
343
|
return <LabelByField option={item} fieldNames={fieldNames} />;
|
|
276
344
|
}}
|
|
277
345
|
labelInValue
|
|
278
|
-
mode={
|
|
346
|
+
mode={allowMultiple ? 'multiple' : undefined}
|
|
279
347
|
options={props.value}
|
|
280
348
|
allowClear
|
|
281
349
|
onChange={(newValue, option) => {
|
|
@@ -405,7 +473,7 @@ RecordPickerFieldModel.registerFlow({
|
|
|
405
473
|
},
|
|
406
474
|
handler(ctx, params) {
|
|
407
475
|
const { onChange } = ctx.inputArgs;
|
|
408
|
-
const
|
|
476
|
+
const allowMultiple = canRecordPickerSelectMultiple(ctx.collectionField, ctx.model.props.allowMultiple);
|
|
409
477
|
const sizeToWidthMap: Record<string, any> = {
|
|
410
478
|
drawer: {
|
|
411
479
|
small: '30%',
|
|
@@ -441,9 +509,9 @@ RecordPickerFieldModel.registerFlow({
|
|
|
441
509
|
currentItemValue: ctx.inputArgs.currentItemValue ?? {},
|
|
442
510
|
}),
|
|
443
511
|
rowSelectionProps: {
|
|
444
|
-
type:
|
|
512
|
+
type: allowMultiple ? 'checkbox' : 'radio',
|
|
445
513
|
defaultSelectedRows: () => {
|
|
446
|
-
return ctx.model.props.value;
|
|
514
|
+
return normalizeRecordPickerSelectedRows(ctx.model.props.value, allowMultiple);
|
|
447
515
|
},
|
|
448
516
|
renderCell: undefined,
|
|
449
517
|
selectedRowKeys: undefined,
|
|
@@ -452,8 +520,7 @@ RecordPickerFieldModel.registerFlow({
|
|
|
452
520
|
const selectTable = selectBlockModel.findSubModel('items', (m) => {
|
|
453
521
|
return m;
|
|
454
522
|
});
|
|
455
|
-
if (
|
|
456
|
-
// 单选
|
|
523
|
+
if (!allowMultiple) {
|
|
457
524
|
ctx.model.selectedRows.value = selectedRows?.[0];
|
|
458
525
|
onChange(ctx.model.selectedRows.value);
|
|
459
526
|
ctx.model._closeView?.();
|
|
@@ -475,7 +542,7 @@ RecordPickerFieldModel.registerFlow({
|
|
|
475
542
|
},
|
|
476
543
|
},
|
|
477
544
|
},
|
|
478
|
-
content: () => <RecordPickerContent model={ctx.model} toOne={
|
|
545
|
+
content: () => <RecordPickerContent model={ctx.model} toOne={!allowMultiple} />,
|
|
479
546
|
styles: {
|
|
480
547
|
content: {
|
|
481
548
|
padding: 0,
|
|
@@ -500,6 +567,24 @@ RecordPickerFieldModel.registerFlow({
|
|
|
500
567
|
fieldNames: {
|
|
501
568
|
use: 'titleField',
|
|
502
569
|
},
|
|
570
|
+
allowMultiple: {
|
|
571
|
+
title: tExpr('Multiple'),
|
|
572
|
+
uiMode: { type: 'switch', key: 'allowMultiple' },
|
|
573
|
+
hideInSettings(ctx) {
|
|
574
|
+
return !canRecordPickerSelectMultiple(ctx.collectionField, true);
|
|
575
|
+
},
|
|
576
|
+
defaultParams(ctx) {
|
|
577
|
+
return {
|
|
578
|
+
allowMultiple: canRecordPickerSelectMultiple(ctx.collectionField, true),
|
|
579
|
+
};
|
|
580
|
+
},
|
|
581
|
+
afterParamsSave(ctx, params, previousParams) {
|
|
582
|
+
applyRecordPickerAllowMultiple(ctx, params, previousParams);
|
|
583
|
+
},
|
|
584
|
+
handler(ctx, params) {
|
|
585
|
+
applyRecordPickerAllowMultiple(ctx, params);
|
|
586
|
+
},
|
|
587
|
+
},
|
|
503
588
|
},
|
|
504
589
|
});
|
|
505
590
|
|
|
@@ -20,6 +20,14 @@ import {
|
|
|
20
20
|
resolveRecordPersistenceState,
|
|
21
21
|
} from '../itemChain';
|
|
22
22
|
import { injectRecordPickerPopupContext } from '@nocobase/client-v2';
|
|
23
|
+
import {
|
|
24
|
+
canRecordPickerSelectMultiple,
|
|
25
|
+
getRecordPickerClearedValue,
|
|
26
|
+
getRecordPickerEmptyValue,
|
|
27
|
+
normalizeRecordPickerSelectedRows,
|
|
28
|
+
normalizeRecordPickerValue,
|
|
29
|
+
shouldClearRecordPickerValueOnMultipleChange,
|
|
30
|
+
} from '../RecordPickerFieldModel';
|
|
23
31
|
|
|
24
32
|
function createMockCollection() {
|
|
25
33
|
return {
|
|
@@ -32,6 +40,59 @@ function createMockCollection() {
|
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
describe('RecordPickerFieldModel item context', () => {
|
|
43
|
+
it('resolves popup select multiple mode for association fields', () => {
|
|
44
|
+
expect(canRecordPickerSelectMultiple({ type: 'belongsToMany' })).toBe(true);
|
|
45
|
+
expect(canRecordPickerSelectMultiple({ type: 'hasMany' })).toBe(true);
|
|
46
|
+
expect(canRecordPickerSelectMultiple({ type: 'belongsToArray' })).toBe(true);
|
|
47
|
+
expect(canRecordPickerSelectMultiple({ type: 'belongsToMany' }, false)).toBe(false);
|
|
48
|
+
expect(canRecordPickerSelectMultiple({ type: 'belongsTo' })).toBe(false);
|
|
49
|
+
expect(canRecordPickerSelectMultiple({ type: 'hasOne' })).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('normalizes popup select selected rows according to multiple mode', () => {
|
|
53
|
+
const rows = [
|
|
54
|
+
{ id: 1, name: 'A' },
|
|
55
|
+
{ id: 2, name: 'B' },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
expect(normalizeRecordPickerSelectedRows(rows, true)).toEqual(rows);
|
|
59
|
+
expect(normalizeRecordPickerSelectedRows(rows, false)).toEqual([{ id: 1, name: 'A' }]);
|
|
60
|
+
expect(normalizeRecordPickerSelectedRows({ id: 1, name: 'A' }, false)).toEqual([{ id: 1, name: 'A' }]);
|
|
61
|
+
expect(normalizeRecordPickerSelectedRows(undefined, true)).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('normalizes popup select display value according to multiple mode', () => {
|
|
65
|
+
const fieldNames = { label: 'name', value: 'id' };
|
|
66
|
+
const rows = [
|
|
67
|
+
{ id: 1, name: 'A' },
|
|
68
|
+
{ id: 2, name: 'B' },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
expect(normalizeRecordPickerValue(rows, fieldNames, true)).toEqual([
|
|
72
|
+
{ id: 1, name: 'A', label: 'A', value: 1 },
|
|
73
|
+
{ id: 2, name: 'B', label: 'B', value: 2 },
|
|
74
|
+
]);
|
|
75
|
+
expect(normalizeRecordPickerValue(rows, fieldNames, false)).toEqual({ id: 1, name: 'A', label: 'A', value: 1 });
|
|
76
|
+
expect(normalizeRecordPickerValue(undefined, fieldNames, true)).toEqual([]);
|
|
77
|
+
expect(normalizeRecordPickerValue(undefined, fieldNames, false)).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns the empty popup select value for the current multiple mode', () => {
|
|
81
|
+
expect(getRecordPickerEmptyValue(true)).toEqual([]);
|
|
82
|
+
expect(getRecordPickerEmptyValue(false)).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('clears popup select value to undefined when multiple mode changes', () => {
|
|
86
|
+
expect(getRecordPickerClearedValue()).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('detects when changing multiple mode should clear popup select value', () => {
|
|
90
|
+
expect(shouldClearRecordPickerValueOnMultipleChange({ type: 'belongsToMany' }, true, false)).toBe(true);
|
|
91
|
+
expect(shouldClearRecordPickerValueOnMultipleChange({ type: 'belongsToMany' }, false, true)).toBe(true);
|
|
92
|
+
expect(shouldClearRecordPickerValueOnMultipleChange({ type: 'belongsToMany' }, true, true)).toBe(false);
|
|
93
|
+
expect(shouldClearRecordPickerValueOnMultipleChange({ type: 'belongsTo' }, true, false)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
35
96
|
it('itemChain helpers: createParentItemAccessorsFromInputArgs works', () => {
|
|
36
97
|
const inputArgs = {
|
|
37
98
|
parentItem: { value: { id: 1 } },
|
|
@@ -12,10 +12,15 @@ import { Input } from 'antd';
|
|
|
12
12
|
import React from 'react';
|
|
13
13
|
import { customAlphabet as Alphabet } from 'nanoid';
|
|
14
14
|
import { FieldModel } from '../base/FieldModel';
|
|
15
|
+
import { ScanInput } from '../../../components/form/ScanInput';
|
|
15
16
|
|
|
16
17
|
export class InputFieldModel extends FieldModel {
|
|
17
18
|
render() {
|
|
18
|
-
|
|
19
|
+
if (this.props.enableScan) {
|
|
20
|
+
return <ScanInput {...this.props} />;
|
|
21
|
+
}
|
|
22
|
+
const { enableScan, disableManualInput, ...inputProps } = this.props;
|
|
23
|
+
return <Input {...inputProps} />;
|
|
19
24
|
}
|
|
20
25
|
}
|
|
21
26
|
|
|
@@ -36,6 +41,56 @@ InputFieldModel.registerFlow({
|
|
|
36
41
|
},
|
|
37
42
|
});
|
|
38
43
|
|
|
44
|
+
InputFieldModel.registerFlow({
|
|
45
|
+
key: 'scanInputSettings',
|
|
46
|
+
sort: 800,
|
|
47
|
+
title: tExpr('Scan input settings'),
|
|
48
|
+
steps: {
|
|
49
|
+
enableScan: {
|
|
50
|
+
title: tExpr('Enable Scan'),
|
|
51
|
+
uiMode: { type: 'switch', key: 'enableScan' },
|
|
52
|
+
hideInSettings(ctx) {
|
|
53
|
+
return ctx.model.getProps().pattern === 'readPretty' || ctx.model.getProps().disabled;
|
|
54
|
+
},
|
|
55
|
+
defaultParams(ctx) {
|
|
56
|
+
return {
|
|
57
|
+
enableScan: !!ctx.model.props.enableScan,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
handler(ctx, params) {
|
|
61
|
+
ctx.model.setProps({
|
|
62
|
+
enableScan: !!params.enableScan,
|
|
63
|
+
disableManualInput: params.enableScan ? ctx.model.props.disableManualInput : false,
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
disableManualInput: {
|
|
68
|
+
title: tExpr('Disable manual input'),
|
|
69
|
+
uiMode: { type: 'switch', key: 'disableManualInput' },
|
|
70
|
+
hideInSettings(ctx) {
|
|
71
|
+
const enableScanParams = ctx.model.getStepParams?.('scanInputSettings', 'enableScan') as
|
|
72
|
+
| { enableScan?: boolean }
|
|
73
|
+
| undefined;
|
|
74
|
+
const enableScan = Object.prototype.hasOwnProperty.call(enableScanParams || {}, 'enableScan')
|
|
75
|
+
? !!enableScanParams?.enableScan
|
|
76
|
+
: !!ctx.model.props.enableScan;
|
|
77
|
+
|
|
78
|
+
return !enableScan || ctx.model.getProps().pattern === 'readPretty' || !!ctx.model.getProps().disabled;
|
|
79
|
+
},
|
|
80
|
+
defaultParams(ctx) {
|
|
81
|
+
return {
|
|
82
|
+
disableManualInput: !!ctx.model.props.disableManualInput,
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
handler(ctx, params) {
|
|
86
|
+
ctx.model.setProps({
|
|
87
|
+
disableManualInput: !!ctx.model.props.enableScan && !!params.disableManualInput,
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
39
94
|
InputFieldModel.define({
|
|
40
95
|
label: tExpr('Input'),
|
|
41
96
|
});
|