@nocobase/client-v2 2.1.0-alpha.30 → 2.1.0-alpha.31

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 (64) hide show
  1. package/es/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
  2. package/es/flow/index.d.ts +1 -0
  3. package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
  4. package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
  5. package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
  6. package/es/flow/models/actions/index.d.ts +3 -0
  7. package/es/flow/models/base/GridModel.d.ts +3 -1
  8. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +15 -6
  9. package/es/flow/models/blocks/shared/filterOperators.d.ts +9 -0
  10. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +1 -0
  11. package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
  12. package/es/flow-compat/data.d.ts +9 -2
  13. package/es/flow-compat/index.d.ts +1 -1
  14. package/es/index.d.ts +1 -0
  15. package/es/index.mjs +90 -90
  16. package/lib/index.js +87 -87
  17. package/package.json +5 -5
  18. package/src/BaseApplication.tsx +1 -1
  19. package/src/__tests__/app.test.tsx +23 -6
  20. package/src/__tests__/globalDeps.test.ts +5 -0
  21. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
  22. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
  23. package/src/flow/actions/linkageRules.tsx +8 -1
  24. package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
  25. package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
  26. package/src/flow/actions/titleField.tsx +8 -3
  27. package/src/flow/components/FieldAssignValueInput.tsx +1 -0
  28. package/src/flow/components/filter/LinkageFilterItem.tsx +6 -5
  29. package/src/flow/components/filter/VariableFilterItem.tsx +14 -13
  30. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +33 -0
  31. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -5
  32. package/src/flow/index.ts +1 -0
  33. package/src/flow/internal/utils/__tests__/titleFieldQuickSync.test.ts +1 -0
  34. package/src/flow/internal/utils/titleFieldQuickSync.ts +2 -2
  35. package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
  36. package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
  37. package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
  38. package/src/flow/models/actions/FilterActionModel.tsx +17 -9
  39. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
  40. package/src/flow/models/actions/index.ts +3 -0
  41. package/src/flow/models/base/GridModel.tsx +21 -1
  42. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
  43. package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
  44. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +200 -36
  45. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +270 -1
  46. package/src/flow/models/blocks/filter-form/__tests__/customFieldOperators.test.tsx +23 -0
  47. package/src/flow/models/blocks/filter-form/customFieldOperators.ts +12 -1
  48. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +22 -8
  49. package/src/flow/models/blocks/filter-form/fields/__tests__/FilterFormCustomFieldModel.recordSelect.test.tsx +18 -0
  50. package/src/flow/models/blocks/filter-manager/FilterManager.ts +51 -1
  51. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +75 -0
  52. package/src/flow/models/blocks/form/FormItemModel.tsx +48 -28
  53. package/src/flow/models/blocks/shared/filterOperators.ts +14 -0
  54. package/src/flow/models/blocks/table/TableBlockModel.tsx +19 -3
  55. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
  56. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +21 -5
  57. package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
  58. package/src/flow/models/fields/DividerItemModel.tsx +30 -15
  59. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
  60. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
  61. package/src/flow-compat/data.ts +25 -3
  62. package/src/flow-compat/index.ts +7 -1
  63. package/src/index.ts +1 -0
  64. package/src/utils/globalDeps.ts +6 -0
@@ -22,6 +22,7 @@ import { FieldModel } from '../../base/FieldModel';
22
22
  import { DetailsItemModel } from '../details/DetailsItemModel';
23
23
  import { EditFormModel } from './EditFormModel';
24
24
  import _ from 'lodash';
25
+ import { Tooltip } from 'antd';
25
26
  import { coerceForToOneField } from '../../../internal/utils/associationValueCoercion';
26
27
  import { buildDynamicNamePath } from './dynamicNamePath';
27
28
 
@@ -99,54 +100,63 @@ export class FormItemModel<T extends DefaultStructure = DefaultStructure> extend
99
100
 
100
101
  renderItem() {
101
102
  const fieldModel = this.subModels.field as FieldModel;
102
- // 行索引(来自数组子表单)
103
103
  const idx = this.context.fieldIndex;
104
104
  const fieldKey = this.context.fieldKey;
105
105
  const parentFieldPathArray = this.parent?.context.fieldPathArray || [];
106
+ const isHiddenReservedValuePreview =
107
+ !!this.context.flowSettingsEnabled && !!this.props.hidden && !this.hidden && !this.forbidden;
106
108
 
107
- // 嵌套场景下继续传透,为字段子模型创建 fork
108
109
  const modelForRender =
109
- idx != null
110
+ idx != null || isHiddenReservedValuePreview
110
111
  ? (() => {
111
- const fork = fieldModel.createFork({}, `${fieldKey}`);
112
- fork.context.defineProperty('fieldIndex', {
113
- get: () => idx,
114
- });
115
- fork.context.defineProperty('fieldKey', {
116
- get: () => fieldKey,
117
- });
118
- if (this.context.currentObject) {
119
- fork.context.defineProperty('currentObject', {
120
- get: () => this.context.currentObject,
112
+ const forkKey = isHiddenReservedValuePreview ? `${this.uid}:config-visible` : `${fieldKey}`;
113
+ const fork = fieldModel.createFork({}, forkKey);
114
+ if (idx != null) {
115
+ fork.context.defineProperty('fieldIndex', {
116
+ get: () => idx,
121
117
  });
122
- }
123
- const itemOptions = this.context.getPropertyOptions('item');
124
- if (this.context.item) {
125
- const { value: _value, ...rest } = (itemOptions || {}) as any;
126
- fork.context.defineProperty('item', {
127
- ...rest,
128
- get: () => this.context.item,
129
- cache: false,
118
+ fork.context.defineProperty('fieldKey', {
119
+ get: () => fieldKey,
130
120
  });
121
+ if (this.context.currentObject) {
122
+ fork.context.defineProperty('currentObject', {
123
+ get: () => this.context.currentObject,
124
+ });
125
+ }
126
+ const itemOptions = this.context.getPropertyOptions('item');
127
+ if (this.context.item) {
128
+ const { value: _value, ...rest } = (itemOptions || {}) as any;
129
+ fork.context.defineProperty('item', {
130
+ ...rest,
131
+ get: () => this.context.item,
132
+ cache: false,
133
+ });
134
+ }
135
+ if (this.context.pattern) {
136
+ fork.context.defineProperty('pattern', {
137
+ get: () => this.context.pattern,
138
+ });
139
+ }
131
140
  }
132
- if (this.context.pattern) {
133
- fork.context.defineProperty('pattern', {
134
- get: () => this.context.pattern,
135
- });
141
+ if (isHiddenReservedValuePreview) {
142
+ fork.setProps({ hidden: false });
136
143
  }
137
144
  return fork;
138
145
  })()
139
146
  : fieldModel;
140
147
  const mergedProps = this.context.pattern ? { ...this.props, pattern: this.context.pattern } : this.props;
141
- const { initialValue, ...mergedPropsWithoutInitial } = mergedProps as any;
148
+ const { initialValue, hidden, ...mergedPropsWithoutInitial } = mergedProps as any;
149
+ const formItemProps = isHiddenReservedValuePreview
150
+ ? mergedPropsWithoutInitial
151
+ : { hidden, ...mergedPropsWithoutInitial };
142
152
  const fieldPath = buildDynamicNamePath(this.props.name, idx);
143
153
  this.context.defineProperty('fieldPathArray', {
144
154
  value: [...parentFieldPathArray, ..._.castArray(fieldPath)],
145
155
  });
146
156
  const record = this.context.item?.value || this.context.record;
147
- return (
157
+ const content = (
148
158
  <FormItem
149
- {...mergedPropsWithoutInitial}
159
+ {...formItemProps}
150
160
  name={fieldPath}
151
161
  validateFirst={true}
152
162
  disabled={
@@ -158,6 +168,16 @@ export class FormItemModel<T extends DefaultStructure = DefaultStructure> extend
158
168
  <FieldModelRenderer model={modelForRender} name={fieldPath} />
159
169
  </FormItem>
160
170
  );
171
+
172
+ if (isHiddenReservedValuePreview) {
173
+ return (
174
+ <Tooltip title={this.context.t('The field is hidden and only visible when the UI Editor is active')}>
175
+ <div style={{ opacity: 0.3 }}>{content}</div>
176
+ </Tooltip>
177
+ );
178
+ }
179
+
180
+ return content;
161
181
  }
162
182
  }
163
183
 
@@ -0,0 +1,14 @@
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
+ export function isArrayLikeField(field: any) {
11
+ return (
12
+ ['multipleSelect', 'checkboxGroup'].includes(field?.interface) || ['array', 'json', 'jsonb'].includes(field?.type)
13
+ );
14
+ }
@@ -563,6 +563,9 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
563
563
  columns={this.columns.value}
564
564
  pagination={this.pagination()}
565
565
  highlightedRowKey={highlightedRowKey}
566
+ selectedRowKeysFromResource={this.resource
567
+ .getSelectedRows()
568
+ .map((row) => getRowKey(row, this.collection.filterTargetKey))}
566
569
  defaultExpandAllRows={this.props.defaultExpandAllRows}
567
570
  expandedRowKeys={this.props.expandedRowKeys}
568
571
  heightMode={heightMode}
@@ -581,6 +584,7 @@ const TableBlockContent = (props: {
581
584
  columns: any;
582
585
  pagination: any;
583
586
  highlightedRowKey: string;
587
+ selectedRowKeysFromResource: string[];
584
588
  defaultExpandAllRows?: boolean;
585
589
  expandedRowKeys?: any[];
586
590
  heightMode?: string;
@@ -594,6 +598,7 @@ const TableBlockContent = (props: {
594
598
  columns,
595
599
  pagination,
596
600
  highlightedRowKey,
601
+ selectedRowKeysFromResource,
597
602
  defaultExpandAllRows,
598
603
  expandedRowKeys,
599
604
  heightMode,
@@ -620,6 +625,7 @@ const TableBlockContent = (props: {
620
625
  columns={columns}
621
626
  pagination={pagination}
622
627
  highlightedRowKey={highlightedRowKey}
628
+ selectedRowKeysFromResource={selectedRowKeysFromResource}
623
629
  defaultExpandAllRows={defaultExpandAllRows}
624
630
  expandedRowKeys={expandedRowKeys}
625
631
  tableScroll={tableScroll}
@@ -829,6 +835,7 @@ const HighPerformanceTable = React.memo(
829
835
  columns: any;
830
836
  pagination: any;
831
837
  highlightedRowKey: string;
838
+ selectedRowKeysFromResource: string[];
832
839
  defaultExpandAllRows?: boolean;
833
840
  expandedRowKeys?: any[];
834
841
  tableScroll;
@@ -841,6 +848,7 @@ const HighPerformanceTable = React.memo(
841
848
  columns,
842
849
  pagination: _pagination,
843
850
  highlightedRowKey,
851
+ selectedRowKeysFromResource,
844
852
  defaultExpandAllRows,
845
853
  expandedRowKeys,
846
854
  tableScroll,
@@ -848,9 +856,17 @@ const HighPerformanceTable = React.memo(
848
856
  const dataSourceRef = useRef(dataSource);
849
857
  dataSourceRef.current = dataSource;
850
858
 
851
- const [selectedRowKeys, setSelectedRowKeys] = React.useState<string[]>(() =>
852
- model.resource.getSelectedRows().map((row) => getRowKey(row, model.collection.filterTargetKey)),
853
- );
859
+ const [selectedRowKeys, setSelectedRowKeys] = React.useState<string[]>(selectedRowKeysFromResource || []);
860
+
861
+ useEffect(() => {
862
+ const nextSelectedRowKeys = selectedRowKeysFromResource || [];
863
+ setSelectedRowKeys((prev) => {
864
+ if (_.isEqual(prev, nextSelectedRowKeys)) {
865
+ return prev;
866
+ }
867
+ return nextSelectedRowKeys;
868
+ });
869
+ }, [selectedRowKeysFromResource]);
854
870
 
855
871
  const getRowKeyFunc = useCallback(
856
872
  (record) => {
@@ -38,7 +38,8 @@ import {
38
38
  import { MobileLazySelect } from '../mobile-components/MobileLazySelect';
39
39
  import { BlockSceneEnum } from '../../base/BlockModel';
40
40
  import { ActionWithoutPermission } from '../../base/ActionModel';
41
- import { EditFormModel } from '../../blocks/form/EditFormModel';
41
+ import { EditFormModel } from '../../blocks';
42
+ import { hasAncestorModel } from './recordSelectSettingsUtils';
42
43
 
43
44
  function isPlainObject(val: unknown): val is Record<string, any> {
44
45
  return !!val && typeof val === 'object' && !Array.isArray(val);
@@ -818,6 +819,9 @@ RecordSelectFieldModel.registerFlow({
818
819
  if (ctx?.blockModel?.constructor?.scene === BlockSceneEnum.filter) {
819
820
  return true;
820
821
  }
822
+ if (hasAncestorModel(ctx?.model, ['SubTableColumnModel', 'SubTableFieldModel'])) {
823
+ return true;
824
+ }
821
825
  return false;
822
826
  },
823
827
  defaultParams: {
@@ -194,7 +194,7 @@ function shouldCommitImmediately(value: any) {
194
194
  }
195
195
 
196
196
  const FieldModelRendererOptimize = React.memo((props: any) => {
197
- const { model, onChange, value, ...rest } = props;
197
+ const { model, onChange, value, commitOnChange, ...rest } = props;
198
198
  const pendingValueRef = React.useRef<any>(props?.value);
199
199
 
200
200
  useEffect(() => {
@@ -204,11 +204,11 @@ const FieldModelRendererOptimize = React.memo((props: any) => {
204
204
  const handleChange = React.useCallback(
205
205
  (value: any) => {
206
206
  pendingValueRef.current = value;
207
- if (shouldCommitImmediately(value)) {
207
+ if (commitOnChange || shouldCommitImmediately(value)) {
208
208
  onChange?.(value);
209
209
  }
210
210
  },
211
- [onChange],
211
+ [commitOnChange, onChange],
212
212
  );
213
213
 
214
214
  const handleCommit = React.useCallback(() => {
@@ -241,10 +241,11 @@ interface CellProps {
241
241
  rowFork?: any;
242
242
  memoKey?: string;
243
243
  width?: number;
244
+ commitOnChange?: boolean;
244
245
  }
245
246
 
246
247
  const MemoCell: React.FC<CellProps> = React.memo(
247
- ({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width }) => {
248
+ ({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width, commitOnChange }) => {
248
249
  const isNew = record?.__is_new__;
249
250
  return (
250
251
  <div
@@ -346,7 +347,11 @@ const MemoCell: React.FC<CellProps> = React.memo(
346
347
  }
347
348
  />
348
349
  ) : (
349
- <FieldModelRendererOptimize model={fork} id={[(parent as any).context.fieldPath, rowIdx]} />
350
+ <FieldModelRendererOptimize
351
+ model={fork}
352
+ id={[(parent as any).context.fieldPath, rowIdx]}
353
+ commitOnChange={commitOnChange}
354
+ />
350
355
  )}
351
356
  </FormItem>
352
357
  );
@@ -360,6 +365,7 @@ const MemoCell: React.FC<CellProps> = React.memo(
360
365
  prev.id === next.id &&
361
366
  prev.memoKey === next.memoKey &&
362
367
  prev.width === next.width &&
368
+ prev.commitOnChange === next.commitOnChange &&
363
369
  prev.rowIdx === next.rowIdx
364
370
  );
365
371
  },
@@ -428,6 +434,15 @@ export class SubTableColumnModel<
428
434
  return this.parent.collection;
429
435
  }
430
436
 
437
+ get hasFormulaColumn() {
438
+ return (
439
+ this.parent?.mapSubModels('columns', (column: SubTableColumnModel) => {
440
+ const field = column.collectionField;
441
+ return field?.interface === 'formula' || field?.type === 'formula';
442
+ }) || []
443
+ ).some(Boolean);
444
+ }
445
+
431
446
  onInit(options: any): void {
432
447
  super.onInit(options);
433
448
  this.context.defineProperty('resourceName', {
@@ -607,6 +622,7 @@ export class SubTableColumnModel<
607
622
  rowFork={rowFork}
608
623
  memoKey={rowForkKey}
609
624
  width={this.props.width}
625
+ commitOnChange={this.hasFormulaColumn}
610
626
  />
611
627
  );
612
628
  };
@@ -0,0 +1,20 @@
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
+ export function hasAncestorModel(model: any, modelNames: string[]) {
11
+ let cursor = model;
12
+ while (cursor) {
13
+ const modelName = cursor?.constructor?.name;
14
+ if (modelName && modelNames.includes(modelName)) {
15
+ return true;
16
+ }
17
+ cursor = cursor?.parent;
18
+ }
19
+ return false;
20
+ }
@@ -8,25 +8,40 @@
8
8
  */
9
9
 
10
10
  import { FormItem } from '@nocobase/flow-engine';
11
- import { Divider } from 'antd';
11
+ import { Divider, theme } from 'antd';
12
12
  import React from 'react';
13
13
  import { CommonItemModel } from '../base/CommonItemModel';
14
14
  import { NBColorPicker } from './ColorFieldModel';
15
15
 
16
+ const resolveThemeColor = (value: string | undefined, fallback: string) => {
17
+ return value ? value : fallback;
18
+ };
19
+
20
+ const DividerItem = (props: any) => {
21
+ const { token } = theme.useToken();
22
+ const { color, borderColor, label, orientation, dashed } = props;
23
+
24
+ return (
25
+ <Divider
26
+ type="horizontal"
27
+ style={{
28
+ color: resolveThemeColor(color, token.colorText),
29
+ borderColor: resolveThemeColor(borderColor, token.colorSplit),
30
+ }}
31
+ orientationMargin="0"
32
+ orientation={orientation}
33
+ dashed={dashed}
34
+ >
35
+ {label}
36
+ </Divider>
37
+ );
38
+ };
39
+
16
40
  export class DividerItemModel extends CommonItemModel {
17
41
  render() {
18
- const { color, borderColor, label, orientation, dashed } = this.props;
19
42
  return (
20
43
  <FormItem shouldUpdate showLabel={false}>
21
- <Divider
22
- type="horizontal"
23
- style={{ color, borderColor }}
24
- orientationMargin="0"
25
- orientation={orientation}
26
- dashed={dashed}
27
- >
28
- {label}
29
- </Divider>
44
+ <DividerItem {...this.props} />
30
45
  </FormItem>
31
46
  );
32
47
  }
@@ -38,12 +53,12 @@ DividerItemModel.registerFlow({
38
53
  steps: {
39
54
  title: {
40
55
  title: '{{t("Edit divider")}}',
41
- defaultParams: {
56
+ defaultParams: (ctx) => ({
42
57
  label: '{{t("Text")}}',
43
58
  orientation: 'left',
44
- color: 'rgba(0, 0, 0, 0.88)',
45
- borderColor: 'rgba(5, 5, 5, 0.06)',
46
- },
59
+ color: ctx.themeToken?.colorText,
60
+ borderColor: ctx.themeToken?.colorSplit,
61
+ }),
47
62
  uiSchema(ctx) {
48
63
  return {
49
64
  label: {
@@ -13,7 +13,7 @@ import { Button, CheckList, Popup, SearchBar } from 'antd-mobile';
13
13
  import React, { useEffect, useMemo, useState } from 'react';
14
14
 
15
15
  export function MobileSelect(props) {
16
- const { value, onChange, disabled, options = [], mode } = props;
16
+ const { value, onChange, onChangeComplete, disabled, options = [], mode } = props;
17
17
  const ctx = useFlowModelContext();
18
18
  const t = ctx.t;
19
19
  const [visible, setVisible] = useState(false);
@@ -28,6 +28,7 @@ export function MobileSelect(props) {
28
28
 
29
29
  const handleConfirm = () => {
30
30
  onChange(selected);
31
+ onChangeComplete?.();
31
32
  setVisible(false);
32
33
  };
33
34
  useEffect(() => {
@@ -36,12 +37,18 @@ export function MobileSelect(props) {
36
37
  } else {
37
38
  setSearchText(null);
38
39
  }
39
- }, [visible]);
40
+ }, [visible, value]);
40
41
 
41
42
  return (
42
43
  <>
43
44
  <div onClick={() => !disabled && setVisible(true)}>
44
- <Select {...props} dropdownStyle={{ display: 'none' }} showSearch={false} />
45
+ <Select
46
+ {...props}
47
+ open={false}
48
+ dropdownStyle={{ display: 'none' }}
49
+ showSearch={false}
50
+ style={{ pointerEvents: 'none', width: '100%' }}
51
+ />
45
52
  </div>
46
53
  <Popup
47
54
  visible={visible}
@@ -71,6 +78,7 @@ export function MobileSelect(props) {
71
78
  } else {
72
79
  setSelected(val[0]);
73
80
  onChange(val[0]);
81
+ onChangeComplete?.();
74
82
  setVisible(false);
75
83
  }
76
84
  }}
@@ -0,0 +1,235 @@
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 React from 'react';
11
+ import { beforeEach, describe, it, expect, vi } from 'vitest';
12
+ import { act, fireEvent, render, screen } from '@nocobase/test/client';
13
+ import { MobileSelect } from '../MobileSelect';
14
+
15
+ const DEFAULT_OPTIONS = [
16
+ { label: 'Option A', value: 'a' },
17
+ { label: 'Option B', value: 'b' },
18
+ ];
19
+
20
+ const mockState = vi.hoisted(() => ({
21
+ selectProps: undefined as any,
22
+ popupProps: undefined as any,
23
+ checklistProps: undefined as any,
24
+ confirmButtonProps: undefined as any,
25
+ }));
26
+
27
+ function resetMockState() {
28
+ mockState.selectProps = undefined;
29
+ mockState.popupProps = undefined;
30
+ mockState.checklistProps = undefined;
31
+ mockState.confirmButtonProps = undefined;
32
+ }
33
+
34
+ function clickTrigger() {
35
+ const trigger = screen.getByTestId('antd-select').parentElement as HTMLElement | null;
36
+ expect(trigger).toBeTruthy();
37
+ act(() => {
38
+ fireEvent.click(trigger as HTMLElement);
39
+ });
40
+ }
41
+
42
+ function openPopup() {
43
+ clickTrigger();
44
+ expect(screen.getByTestId('popup')).toBeInTheDocument();
45
+ }
46
+
47
+ function selectValues(values: string[]) {
48
+ act(() => {
49
+ mockState.checklistProps?.onChange?.(values);
50
+ });
51
+ }
52
+
53
+ function confirmSelection() {
54
+ act(() => {
55
+ mockState.confirmButtonProps?.onClick?.();
56
+ });
57
+ }
58
+
59
+ function renderMobileSelect(props: Record<string, any> = {}) {
60
+ const onChange = props.onChange ?? vi.fn();
61
+ const onChangeComplete = props.onChangeComplete ?? vi.fn();
62
+
63
+ render(
64
+ <MobileSelect
65
+ value={undefined}
66
+ options={DEFAULT_OPTIONS}
67
+ onChange={onChange}
68
+ onChangeComplete={onChangeComplete}
69
+ {...props}
70
+ />,
71
+ );
72
+
73
+ return { onChange, onChangeComplete };
74
+ }
75
+
76
+ vi.mock('@nocobase/flow-engine', async () => {
77
+ const actual = await vi.importActual<any>('@nocobase/flow-engine');
78
+ return {
79
+ ...actual,
80
+ useFlowModelContext: () => ({
81
+ t: (value: string) => value,
82
+ }),
83
+ };
84
+ });
85
+
86
+ vi.mock('antd', async () => {
87
+ const actual = await vi.importActual<any>('antd');
88
+ return {
89
+ ...actual,
90
+ Select: (props: any) => {
91
+ mockState.selectProps = props;
92
+ return <div data-testid="antd-select" />;
93
+ },
94
+ };
95
+ });
96
+
97
+ vi.mock('antd-mobile', () => {
98
+ const MockCheckList: any = (props: any) => {
99
+ mockState.checklistProps = props;
100
+ return <div data-testid="checklist">{props.children}</div>;
101
+ };
102
+
103
+ MockCheckList.Item = ({ value, children }: any) => <div data-testid={`item-${value}`}>{children}</div>;
104
+
105
+ return {
106
+ Button: (props: any) => {
107
+ mockState.confirmButtonProps = props;
108
+ return (
109
+ <button type="button" data-testid="confirm" onClick={props.onClick}>
110
+ {props.children}
111
+ </button>
112
+ );
113
+ },
114
+ Popup: (props: any) => {
115
+ mockState.popupProps = props;
116
+ return props.visible ? <div data-testid="popup">{props.children}</div> : null;
117
+ },
118
+ SearchBar: ({ value, onChange }: any) => (
119
+ <input data-testid="search" value={value ?? ''} onChange={(e) => onChange?.(e.target.value)} />
120
+ ),
121
+ CheckList: MockCheckList,
122
+ };
123
+ });
124
+
125
+ describe('MobileSelect', () => {
126
+ beforeEach(() => {
127
+ resetMockState();
128
+ });
129
+
130
+ it('commits the selected value immediately in single mode', () => {
131
+ const { onChange, onChangeComplete } = renderMobileSelect();
132
+
133
+ openPopup();
134
+ selectValues(['a']);
135
+
136
+ expect(onChange).toHaveBeenCalledTimes(1);
137
+ expect(onChange).toHaveBeenCalledWith('a');
138
+ expect(onChangeComplete).toHaveBeenCalledTimes(1);
139
+ expect(screen.queryByTestId('popup')).not.toBeInTheDocument();
140
+ });
141
+
142
+ it('renders filtered options based on search text', () => {
143
+ const { onChange, onChangeComplete } = renderMobileSelect();
144
+ openPopup();
145
+ act(() => {
146
+ fireEvent.change(screen.getByTestId('search'), { target: { value: 'Option A' } });
147
+ });
148
+
149
+ expect(screen.getByTestId('item-a')).toBeInTheDocument();
150
+ expect(screen.queryByTestId('item-b')).not.toBeInTheDocument();
151
+
152
+ selectValues(['a']);
153
+
154
+ expect(onChange).toHaveBeenCalledTimes(1);
155
+ expect(onChange).toHaveBeenCalledWith('a');
156
+ expect(onChangeComplete).toHaveBeenCalledTimes(1);
157
+ });
158
+
159
+ it('defers commit until confirm in multiple mode', () => {
160
+ const { onChange, onChangeComplete } = renderMobileSelect({ value: [], mode: 'multiple' });
161
+ openPopup();
162
+
163
+ selectValues(['a', 'b']);
164
+ expect(onChange).not.toHaveBeenCalled();
165
+ expect(onChangeComplete).not.toHaveBeenCalled();
166
+
167
+ confirmSelection();
168
+
169
+ expect(onChange).toHaveBeenCalledTimes(1);
170
+ expect(onChange).toHaveBeenCalledWith(['a', 'b']);
171
+ expect(onChangeComplete).toHaveBeenCalledTimes(1);
172
+ expect(screen.queryByTestId('popup')).not.toBeInTheDocument();
173
+ });
174
+
175
+ it('does not open popup when disabled', () => {
176
+ renderMobileSelect({ disabled: true });
177
+
178
+ clickTrigger();
179
+ expect(screen.queryByTestId('popup')).not.toBeInTheDocument();
180
+ });
181
+ });
182
+
183
+ function SubTableCellHarness({ value, onCommit, mode }: { value: any; onCommit: (value: any) => void; mode?: string }) {
184
+ const pendingValueRef = React.useRef<any>(value);
185
+ return (
186
+ <div>
187
+ <MobileSelect
188
+ value={value}
189
+ mode={mode}
190
+ options={DEFAULT_OPTIONS}
191
+ onChange={(next) => {
192
+ pendingValueRef.current = next;
193
+ if (Array.isArray(next)) {
194
+ onCommit(next);
195
+ }
196
+ }}
197
+ onChangeComplete={() => {
198
+ onCommit(pendingValueRef.current);
199
+ }}
200
+ />
201
+ </div>
202
+ );
203
+ }
204
+
205
+ describe('MobileSelect in SubForm/SubTable containers', () => {
206
+ beforeEach(() => {
207
+ resetMockState();
208
+ });
209
+
210
+ it('SubTable: single selection commits final value via onChangeComplete', () => {
211
+ const onCommit = vi.fn();
212
+
213
+ render(<SubTableCellHarness value={undefined} onCommit={onCommit} />);
214
+
215
+ openPopup();
216
+ selectValues(['b']);
217
+
218
+ expect(onCommit).toHaveBeenCalledTimes(1);
219
+ expect(onCommit).toHaveBeenCalledWith('b');
220
+ });
221
+
222
+ it('SubTable: multiple mode only commits after confirm, and commit receives the full array', () => {
223
+ const onCommit = vi.fn();
224
+
225
+ render(<SubTableCellHarness value={[]} onCommit={onCommit} mode="multiple" />);
226
+
227
+ openPopup();
228
+ selectValues(['a', 'b']);
229
+ confirmSelection();
230
+
231
+ expect(onCommit).toHaveBeenCalledTimes(2);
232
+ expect(onCommit).toHaveBeenNthCalledWith(1, ['a', 'b']);
233
+ expect(onCommit).toHaveBeenNthCalledWith(2, ['a', 'b']);
234
+ });
235
+ });