@nocobase/client-v2 2.1.0-beta.46 → 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.
Files changed (44) hide show
  1. package/es/components/form/ScanInput/CodeScanner.d.ts +18 -0
  2. package/es/components/form/ScanInput/ScanBox.d.ts +12 -0
  3. package/es/components/form/ScanInput/ScanInput.d.ts +18 -0
  4. package/es/components/form/ScanInput/index.d.ts +11 -0
  5. package/es/components/form/ScanInput/types.d.ts +10 -0
  6. package/es/components/form/ScanInput/useCodeScanner.d.ts +31 -0
  7. package/es/components/form/filter/CollectionFilter.d.ts +2 -0
  8. package/es/components/form/filter/CollectionFilterPanel.d.ts +38 -0
  9. package/es/components/form/filter/index.d.ts +2 -0
  10. package/es/components/form/filter/useFilterActionProps.d.ts +3 -0
  11. package/es/components/form/index.d.ts +1 -0
  12. package/es/flow/internal/utils/enumOptionsUtils.d.ts +2 -0
  13. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +14 -0
  14. package/es/flow/models/fields/DisplayJSONFieldModel.d.ts +2 -1
  15. package/es/index.mjs +101 -61
  16. package/lib/index.js +112 -72
  17. package/package.json +8 -7
  18. package/src/components/form/DrawerFormLayout.tsx +1 -1
  19. package/src/components/form/ScanInput/CodeScanner.tsx +219 -0
  20. package/src/components/form/ScanInput/ScanBox.tsx +30 -0
  21. package/src/components/form/ScanInput/ScanInput.tsx +150 -0
  22. package/src/components/form/ScanInput/__tests__/ScanInput.test.tsx +119 -0
  23. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +123 -0
  24. package/src/components/form/ScanInput/index.ts +12 -0
  25. package/src/components/form/ScanInput/types.ts +12 -0
  26. package/src/components/form/ScanInput/useCodeScanner.ts +136 -0
  27. package/src/components/form/filter/CollectionFilter.tsx +4 -0
  28. package/src/components/form/filter/CollectionFilterPanel.tsx +97 -0
  29. package/src/components/form/filter/__tests__/compileFilterGroup.test.ts +76 -1
  30. package/src/components/form/filter/index.ts +2 -0
  31. package/src/components/form/filter/useFilterActionProps.ts +66 -2
  32. package/src/components/form/index.tsx +1 -0
  33. package/src/flow/actions/__tests__/actionLinkageRules.race.repro.test.ts +45 -0
  34. package/src/flow/actions/linkageRules.tsx +18 -6
  35. package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -9
  36. package/src/flow/internal/utils/enumOptionsUtils.ts +11 -3
  37. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +111 -26
  38. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +61 -0
  39. package/src/flow/models/fields/DisplayEnumFieldModel.tsx +2 -1
  40. package/src/flow/models/fields/DisplayJSONFieldModel.tsx +15 -4
  41. package/src/flow/models/fields/InputFieldModel.tsx +56 -1
  42. package/src/flow/models/fields/SelectFieldModel.tsx +3 -3
  43. package/src/flow/models/fields/__tests__/DisplayJSONFieldModel.test.tsx +36 -0
  44. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +116 -0
@@ -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 toOne = ['belongsTo', 'hasOne'].includes(ctx.collectionField.type);
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={normalizeValue(props.value, fieldNames, toOne)}
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={toOne ? undefined : 'multiple'}
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 toOne = ['belongsTo', 'hasOne'].includes(ctx.collectionField.type);
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: toOne ? 'radio' : 'checkbox',
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 (toOne) {
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={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 } },
@@ -13,6 +13,7 @@ import { Tag } from 'antd';
13
13
  import React from 'react';
14
14
  import { castArray } from 'lodash';
15
15
  import { ClickableFieldModel } from './ClickableFieldModel';
16
+ import { translateOptionLabel } from '../../internal/utils/enumOptionsUtils';
16
17
 
17
18
  interface FieldNames {
18
19
  value: string;
@@ -70,7 +71,7 @@ export class DisplayEnumFieldModel extends ClickableFieldModel {
70
71
  }
71
72
  return currentOptions.map((option) => (
72
73
  <Tag key={option[fieldNames.value]} color={option[fieldNames.color]} icon={<Icon type={option['icon']} />}>
73
- {this.translate(option[fieldNames.label])}
74
+ {translateOptionLabel(option[fieldNames.label], this.translate)}
74
75
  </Tag>
75
76
  ));
76
77
  }
@@ -9,7 +9,8 @@
9
9
 
10
10
  import { css, cx } from '@emotion/css';
11
11
  import { DisplayItemModel } from '@nocobase/flow-engine';
12
- import React, { useRef, useState, useEffect } from 'react';
12
+ import React, { useEffect, useRef, useState } from 'react';
13
+ import type { CSSProperties } from 'react';
13
14
  import { Tooltip } from 'antd';
14
15
  import { DisplayTitleFieldModel } from './DisplayTitleFieldModel';
15
16
 
@@ -21,8 +22,14 @@ const JSONClassName = css`
21
22
  border: none !important;
22
23
  `;
23
24
 
24
- const EllipsisJSON = ({ content, style, className }) => {
25
- const ref = useRef(null);
25
+ interface EllipsisJSONProps {
26
+ content: string;
27
+ style?: CSSProperties;
28
+ className?: string;
29
+ }
30
+
31
+ const EllipsisJSON = ({ content, style, className }: EllipsisJSONProps) => {
32
+ const ref = useRef<HTMLDivElement>(null);
26
33
  const [overflow, setOverflow] = useState(false);
27
34
 
28
35
  useEffect(() => {
@@ -75,7 +82,7 @@ const EllipsisJSON = ({ content, style, className }) => {
75
82
  };
76
83
 
77
84
  export class DisplayJSONFieldModel extends DisplayTitleFieldModel {
78
- public renderComponent(value) {
85
+ public renderComponent(value: unknown) {
79
86
  const { space, style, className, overflowMode } = this.props;
80
87
  let content = '';
81
88
  if (value !== undefined && value !== null) {
@@ -96,6 +103,10 @@ export class DisplayJSONFieldModel extends DisplayTitleFieldModel {
96
103
  </pre>
97
104
  );
98
105
  }
106
+
107
+ public render() {
108
+ return this.renderComponent(this.props.value);
109
+ }
99
110
  }
100
111
 
101
112
  DisplayItemModel.bindModelToInterface('DisplayJSONFieldModel', ['json'], {
@@ -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
- return <Input {...this.props} />;
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
  });
@@ -12,7 +12,7 @@ import { Select, Tag, Tooltip } from 'antd';
12
12
  import React from 'react';
13
13
  import { FieldModel } from '../base/FieldModel';
14
14
  import { MobileSelect } from './mobile-components/MobileSelect';
15
- import { enumToOptions, getSelectedEnumLabels } from '../../internal/utils/enumOptionsUtils';
15
+ import { enumToOptions, getSelectedEnumLabels, translateOptionLabel } from '../../internal/utils/enumOptionsUtils';
16
16
 
17
17
  const getOriginalEnumOptions = (model: SelectFieldModel) => {
18
18
  const fromEnum = enumToOptions(model.context.collectionField?.uiSchema?.enum, (text) => text) || [];
@@ -29,12 +29,12 @@ export class SelectFieldModel extends FieldModel {
29
29
  const options = this.props.options?.map((v) => {
30
30
  return {
31
31
  ...v,
32
- label: this.translate(v.label),
32
+ label: translateOptionLabel(v.label, this.translate),
33
33
  };
34
34
  });
35
35
  const selectedLabels = getSelectedEnumLabels(this.props.value, fallbackOptions).map((item) => ({
36
36
  ...item,
37
- label: this.translate(item.label),
37
+ label: translateOptionLabel(item.label, this.translate),
38
38
  }));
39
39
  const value = Array.isArray(this.props.value)
40
40
  ? selectedLabels
@@ -0,0 +1,36 @@
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 { FlowEngine } from '@nocobase/flow-engine';
11
+ import { render } from '@testing-library/react';
12
+ import { describe, expect, it } from 'vitest';
13
+ import { DisplayJSONFieldModel } from '../DisplayJSONFieldModel';
14
+
15
+ describe('DisplayJSONFieldModel', () => {
16
+ it('renders object field values as formatted JSON', () => {
17
+ const engine = new FlowEngine();
18
+ engine.registerModels({ DisplayJSONFieldModel });
19
+
20
+ const model = engine.createModel<DisplayJSONFieldModel>({
21
+ use: DisplayJSONFieldModel,
22
+ uid: 'display-json-object-value',
23
+ props: {
24
+ value: {
25
+ enabled: true,
26
+ name: 'NocoBase',
27
+ },
28
+ },
29
+ });
30
+
31
+ const { container } = render(model.render());
32
+
33
+ expect(container.textContent).toContain('"enabled": true');
34
+ expect(container.textContent).toContain('"name": "NocoBase"');
35
+ });
36
+ });
@@ -0,0 +1,116 @@
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 { FlowEngine } from '@nocobase/flow-engine';
11
+ import { render, screen } from '@testing-library/react';
12
+ import React from 'react';
13
+ import { describe, expect, it, vi } from 'vitest';
14
+ import { InputFieldModel } from '../InputFieldModel';
15
+
16
+ vi.mock('react-i18next', () => ({
17
+ useTranslation: () => ({ t: (key: string) => key }),
18
+ }));
19
+
20
+ function createInputFieldModel(props?: Record<string, unknown>) {
21
+ const engine = new FlowEngine();
22
+ return new InputFieldModel({
23
+ uid: 'input-field-model-test',
24
+ flowEngine: engine,
25
+ props,
26
+ });
27
+ }
28
+
29
+ describe('InputFieldModel', () => {
30
+ it('renders the normal input when scan input is disabled', () => {
31
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
32
+ const model = createInputFieldModel({ disableManualInput: true, enableScan: false });
33
+
34
+ render(<>{model.render()}</>);
35
+
36
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
37
+ expect(screen.queryByRole('button', { name: 'Scan to input' })).not.toBeInTheDocument();
38
+ expect(consoleError).not.toHaveBeenCalledWith(
39
+ expect.stringContaining('React does not recognize the `disableManualInput` prop'),
40
+ expect.anything(),
41
+ );
42
+
43
+ consoleError.mockRestore();
44
+ });
45
+
46
+ it('renders ScanInput when scan input is enabled', () => {
47
+ const model = createInputFieldModel({ enableScan: true });
48
+
49
+ render(<>{model.render()}</>);
50
+
51
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
52
+ expect(screen.getByRole('button', { name: 'Scan to input' })).toBeInTheDocument();
53
+ });
54
+
55
+ it('stores enableScan and clears disableManualInput when scan input is turned off', () => {
56
+ const model = createInputFieldModel();
57
+ const step = model.getFlow('scanInputSettings')?.steps.enableScan;
58
+ const setProps = vi.fn();
59
+
60
+ step?.handler(
61
+ {
62
+ model: {
63
+ props: { disableManualInput: true },
64
+ setProps,
65
+ },
66
+ },
67
+ { enableScan: false },
68
+ );
69
+
70
+ expect(setProps).toHaveBeenCalledWith({
71
+ enableScan: false,
72
+ disableManualInput: false,
73
+ });
74
+ });
75
+
76
+ it('does not restore manual input disabling after scan input is turned off', () => {
77
+ const model = createInputFieldModel({ enableScan: true, disableManualInput: true });
78
+ const flow = model.getFlow('scanInputSettings');
79
+ const ctx = { model };
80
+
81
+ flow?.steps.enableScan.handler(ctx, { enableScan: false });
82
+ flow?.steps.disableManualInput.handler(ctx, { disableManualInput: true });
83
+
84
+ expect(model.props).toMatchObject({
85
+ enableScan: false,
86
+ disableManualInput: false,
87
+ });
88
+ });
89
+
90
+ it('hides disableManualInput until scan input is enabled', () => {
91
+ const model = createInputFieldModel();
92
+ const step = model.getFlow('scanInputSettings')?.steps.disableManualInput;
93
+
94
+ const hidden = step?.hideInSettings({
95
+ model: {
96
+ props: { enableScan: false },
97
+ getProps: () => ({}),
98
+ },
99
+ });
100
+
101
+ expect(hidden).toBe(true);
102
+ });
103
+
104
+ it('updates disableManualInput visibility from the current enableScan setting params', () => {
105
+ const model = createInputFieldModel({ enableScan: false });
106
+ const step = model.getFlow('scanInputSettings')?.steps.disableManualInput;
107
+
108
+ model.setStepParams('scanInputSettings', 'enableScan', { enableScan: true });
109
+
110
+ expect(step?.hideInSettings({ model })).toBe(false);
111
+
112
+ model.setStepParams('scanInputSettings', 'enableScan', { enableScan: false });
113
+
114
+ expect(step?.hideInSettings({ model })).toBe(true);
115
+ });
116
+ });