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

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 (103) hide show
  1. package/es/components/form/JsonTextArea.d.ts +18 -0
  2. package/es/components/index.d.ts +1 -0
  3. package/es/flow/actions/dateRangeLimit.d.ts +9 -0
  4. package/es/flow/actions/index.d.ts +1 -0
  5. package/es/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
  6. package/es/flow/index.d.ts +1 -0
  7. package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
  8. package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
  9. package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
  10. package/es/flow/models/actions/index.d.ts +3 -0
  11. package/es/flow/models/base/GridModel.d.ts +3 -1
  12. package/es/flow/models/base/PageModel/PageModel.d.ts +4 -0
  13. package/es/flow/models/base/PageModel/RootPageModel.d.ts +9 -0
  14. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +15 -6
  15. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +7 -0
  16. package/es/flow/models/blocks/shared/filterOperators.d.ts +9 -0
  17. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +3 -0
  18. package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
  19. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +20 -0
  20. package/es/flow/models/fields/JSEditableFieldModel.d.ts +4 -0
  21. package/es/flow-compat/data.d.ts +9 -2
  22. package/es/flow-compat/index.d.ts +1 -1
  23. package/es/index.d.ts +1 -0
  24. package/es/index.mjs +100 -93
  25. package/lib/index.js +101 -94
  26. package/package.json +6 -5
  27. package/src/BaseApplication.tsx +1 -1
  28. package/src/__tests__/app.test.tsx +23 -6
  29. package/src/__tests__/globalDeps.test.ts +5 -0
  30. package/src/components/form/JsonTextArea.tsx +129 -0
  31. package/src/components/index.ts +1 -0
  32. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +478 -0
  33. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
  34. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
  35. package/src/flow/actions/__tests__/pattern.test.ts +190 -0
  36. package/src/flow/actions/dateRangeLimit.tsx +66 -0
  37. package/src/flow/actions/index.ts +1 -0
  38. package/src/flow/actions/linkageRules.tsx +119 -14
  39. package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
  40. package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
  41. package/src/flow/actions/openView.tsx +2 -1
  42. package/src/flow/actions/pattern.tsx +25 -2
  43. package/src/flow/actions/titleField.tsx +8 -3
  44. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +7 -1
  45. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +117 -0
  46. package/src/flow/components/FieldAssignValueInput.tsx +1 -0
  47. package/src/flow/components/filter/LinkageFilterItem.tsx +6 -5
  48. package/src/flow/components/filter/VariableFilterItem.tsx +14 -13
  49. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +33 -0
  50. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -5
  51. package/src/flow/index.ts +1 -0
  52. package/src/flow/internal/utils/__tests__/titleFieldQuickSync.test.ts +1 -0
  53. package/src/flow/internal/utils/titleFieldQuickSync.ts +2 -2
  54. package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
  55. package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
  56. package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
  57. package/src/flow/models/actions/FilterActionModel.tsx +17 -9
  58. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
  59. package/src/flow/models/actions/index.ts +3 -0
  60. package/src/flow/models/base/GridModel.tsx +21 -1
  61. package/src/flow/models/base/PageModel/PageModel.tsx +15 -3
  62. package/src/flow/models/base/PageModel/RootPageModel.tsx +37 -2
  63. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +73 -0
  64. package/src/flow/models/base/PageModel/__tests__/RootPageModel.test.ts +116 -0
  65. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
  66. package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
  67. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +200 -36
  68. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +270 -1
  69. package/src/flow/models/blocks/filter-form/__tests__/customFieldOperators.test.tsx +23 -0
  70. package/src/flow/models/blocks/filter-form/customFieldOperators.ts +12 -1
  71. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +22 -8
  72. package/src/flow/models/blocks/filter-form/fields/__tests__/FilterFormCustomFieldModel.recordSelect.test.tsx +18 -0
  73. package/src/flow/models/blocks/filter-manager/FilterManager.ts +51 -1
  74. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +75 -0
  75. package/src/flow/models/blocks/form/FormItemModel.tsx +48 -28
  76. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +167 -1
  77. package/src/flow/models/blocks/form/value-runtime/runtime.ts +103 -11
  78. package/src/flow/models/blocks/shared/filterOperators.ts +14 -0
  79. package/src/flow/models/blocks/table/TableBlockModel.tsx +19 -3
  80. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
  81. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +48 -8
  82. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +47 -0
  83. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +42 -0
  84. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.refresh.test.tsx +122 -0
  85. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +2 -0
  86. package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
  87. package/src/flow/models/fields/ClickableFieldModel.tsx +21 -9
  88. package/src/flow/models/fields/DateTimeFieldModel/DateOnlyFieldModel.tsx +9 -0
  89. package/src/flow/models/fields/DateTimeFieldModel/DateTimeFieldModel.tsx +4 -0
  90. package/src/flow/models/fields/DateTimeFieldModel/DateTimeNoTzFieldModel.tsx +9 -0
  91. package/src/flow/models/fields/DateTimeFieldModel/DateTimeTzFieldModel.tsx +9 -0
  92. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +242 -0
  93. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +152 -0
  94. package/src/flow/models/fields/DividerItemModel.tsx +30 -15
  95. package/src/flow/models/fields/JSEditableFieldModel.tsx +110 -14
  96. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +87 -0
  97. package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +210 -0
  98. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
  99. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
  100. package/src/flow-compat/data.ts +25 -3
  101. package/src/flow-compat/index.ts +7 -1
  102. package/src/index.ts +1 -0
  103. package/src/utils/globalDeps.ts +6 -0
@@ -0,0 +1,87 @@
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 { describe, expect, it, vi } from 'vitest';
11
+ import { FlowEngine } from '@nocobase/flow-engine';
12
+ import { ClickableFieldModel } from '../ClickableFieldModel';
13
+
14
+ function createRolesFieldModel(sourceRecord: Record<string, any>) {
15
+ const engine = new FlowEngine();
16
+ engine.registerModels({ ClickableFieldModel });
17
+
18
+ const usersCollection = {
19
+ name: 'users',
20
+ filterTargetKey: 'id',
21
+ };
22
+ const rolesCollection = {
23
+ name: 'roles',
24
+ filterTargetKey: 'name',
25
+ };
26
+ const rolesField = {
27
+ name: 'roles',
28
+ target: 'roles',
29
+ targetKey: 'name',
30
+ type: 'belongsToMany',
31
+ interface: 'm2m',
32
+ collection: usersCollection,
33
+ targetCollection: rolesCollection,
34
+ isAssociationField: () => true,
35
+ };
36
+
37
+ const model = engine.createModel<ClickableFieldModel>({
38
+ use: ClickableFieldModel,
39
+ uid: `clickable-roles-${sourceRecord?.id ?? 'new'}`,
40
+ });
41
+ model.context.defineProperty('collectionField', { value: rolesField });
42
+ model.context.defineProperty('blockModel', { value: { collection: usersCollection } });
43
+ model.context.defineProperty('record', { value: sourceRecord });
44
+
45
+ const dispatchEvent = vi.spyOn(model, 'dispatchEvent').mockResolvedValue([]);
46
+ return { model, dispatchEvent };
47
+ }
48
+
49
+ describe('ClickableFieldModel', () => {
50
+ it('opens an association display value as a normal target record when source record has no id', () => {
51
+ const { model, dispatchEvent } = createRolesFieldModel({});
52
+ const event = { type: 'click' };
53
+
54
+ model.onClick(event, { name: 'admin', title: 'Admin' });
55
+
56
+ expect(dispatchEvent).toHaveBeenCalledWith(
57
+ 'click',
58
+ {
59
+ event,
60
+ filterByTk: 'admin',
61
+ collectionName: 'roles',
62
+ associationName: null,
63
+ sourceId: null,
64
+ },
65
+ { debounce: true },
66
+ );
67
+ });
68
+
69
+ it('keeps using the association resource when the source record has an id', () => {
70
+ const { model, dispatchEvent } = createRolesFieldModel({ id: 1 });
71
+ const event = { type: 'click' };
72
+
73
+ model.onClick(event, { name: 'admin', title: 'Admin' });
74
+
75
+ expect(dispatchEvent).toHaveBeenCalledWith(
76
+ 'click',
77
+ {
78
+ event,
79
+ filterByTk: 'admin',
80
+ collectionName: 'users',
81
+ associationName: 'users.roles',
82
+ sourceId: 1,
83
+ },
84
+ { debounce: true },
85
+ );
86
+ });
87
+ });
@@ -0,0 +1,210 @@
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 { act, render, screen, waitFor } from '@testing-library/react';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { FlowEngine, FlowEngineProvider, FlowModel, FlowModelRenderer } from '@nocobase/flow-engine';
14
+ import { JSEditableFieldModel } from '../JSEditableFieldModel';
15
+
16
+ function createField(props?: Record<string, any>, code = '') {
17
+ const engine = new FlowEngine();
18
+ engine.registerModels({ JSEditableFieldModel });
19
+ return engine.createModel<JSEditableFieldModel>({
20
+ use: 'JSEditableFieldModel',
21
+ uid: `js-field-${props?.pattern || 'editable'}`,
22
+ props,
23
+ stepParams: {
24
+ jsSettings: {
25
+ runJs: {
26
+ code,
27
+ },
28
+ },
29
+ },
30
+ });
31
+ }
32
+
33
+ function renderField(props?: Record<string, any>, code?: string) {
34
+ const field = createField(props, code);
35
+
36
+ render(<>{field.render()}</>);
37
+
38
+ return field;
39
+ }
40
+
41
+ const EDITABLE_CODE = `
42
+ function JsEditableField() {
43
+ const React = ctx.React;
44
+ const { Input } = ctx.antd;
45
+ const [value, setValue] = React.useState(ctx.getValue?.() ?? '');
46
+
47
+ React.useEffect(() => {
48
+ const handler = (ev) => setValue(ev?.detail ?? '');
49
+ ctx.element?.addEventListener('js-field:value-change', handler);
50
+ return () => ctx.element?.removeEventListener('js-field:value-change', handler);
51
+ }, []);
52
+
53
+ const onChange = (e) => {
54
+ const v = e?.target?.value ?? '';
55
+ setValue(v);
56
+ ctx.setValue?.(v);
57
+ };
58
+
59
+ return (
60
+ <Input
61
+ {...ctx.model.props}
62
+ value={value}
63
+ onChange={onChange}
64
+ />
65
+ );
66
+ }
67
+
68
+ ctx.render(<JsEditableField />);
69
+ `;
70
+
71
+ const READONLY_AWARE_CODE = `
72
+ const React = ctx.React;
73
+ ctx.render(<span data-testid="js-readonly-state">{String(ctx.readOnly)}</span>);
74
+ `;
75
+
76
+ class ParentModel extends FlowModel<any> {
77
+ render() {
78
+ return <FlowModelRenderer model={this.subModels.field} />;
79
+ }
80
+ }
81
+
82
+ function renderParentFieldWithFlowRenderer(
83
+ fieldProps?: Record<string, any>,
84
+ parentProps?: Record<string, any>,
85
+ code = EDITABLE_CODE,
86
+ ) {
87
+ const engine = new FlowEngine();
88
+ engine.registerModels({ JSEditableFieldModel, ParentModel });
89
+ const parent = engine.createModel<ParentModel>({
90
+ use: ParentModel,
91
+ uid: 'js-field-parent',
92
+ props: parentProps,
93
+ subModels: {
94
+ field: {
95
+ use: 'JSEditableFieldModel',
96
+ uid: 'js-field-with-parent',
97
+ props: fieldProps,
98
+ stepParams: {
99
+ jsSettings: {
100
+ runJs: {
101
+ code,
102
+ },
103
+ },
104
+ },
105
+ },
106
+ },
107
+ });
108
+
109
+ render(
110
+ <FlowEngineProvider engine={engine}>
111
+ <FlowModelRenderer model={parent} />
112
+ </FlowEngineProvider>,
113
+ );
114
+
115
+ return parent;
116
+ }
117
+
118
+ describe('JSEditableFieldModel', () => {
119
+ it('renders configured JavaScript in display only mode', async () => {
120
+ const field = renderParentFieldWithFlowRenderer(
121
+ { pattern: 'readPretty', value: 'hello' },
122
+ undefined,
123
+ READONLY_AWARE_CODE,
124
+ ).subModels.field as JSEditableFieldModel;
125
+
126
+ await waitFor(() => {
127
+ expect(screen.getByTestId('js-readonly-state')).toHaveTextContent('true');
128
+ expect(field.context.ref.current).toBeInstanceOf(HTMLSpanElement);
129
+ });
130
+ });
131
+
132
+ it('renders fallback as text in display only mode when code is empty', () => {
133
+ renderField({ pattern: 'readPretty', value: 'hello' });
134
+
135
+ expect(screen.getByText('hello')).toBeInTheDocument();
136
+ expect(screen.queryByDisplayValue('hello')).not.toBeInTheDocument();
137
+ });
138
+
139
+ it('renders fallback input for editable mode', () => {
140
+ renderField({ value: 'hello' });
141
+
142
+ expect(screen.getByDisplayValue('hello')).toBeInTheDocument();
143
+ });
144
+
145
+ it('rerenders as text when owner form item switches to display only', async () => {
146
+ const parent = renderParentFieldWithFlowRenderer({ value: 'hello' }, undefined, '');
147
+
148
+ await act(async () => {
149
+ parent.setProps({ pattern: 'readPretty' });
150
+ });
151
+
152
+ await waitFor(() => {
153
+ expect(screen.getByText('hello')).toBeInTheDocument();
154
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
155
+ });
156
+ });
157
+
158
+ it('does not rerun JavaScript settings when field value changes', async () => {
159
+ const parent = renderParentFieldWithFlowRenderer({ value: 'hello' });
160
+ const field = parent.subModels.field as JSEditableFieldModel;
161
+ const applyFlowSpy = vi.spyOn(field, 'applyFlow');
162
+
163
+ await waitFor(() => {
164
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
165
+ });
166
+
167
+ applyFlowSpy.mockClear();
168
+
169
+ await act(async () => {
170
+ field.setProps({ value: 'hello1' });
171
+ });
172
+
173
+ expect(applyFlowSpy).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it('coalesces script and pattern changes into one JavaScript settings run', async () => {
177
+ const parent = renderParentFieldWithFlowRenderer({ value: 'hello' }, undefined, '');
178
+ const field = parent.subModels.field as JSEditableFieldModel;
179
+ const applyFlowSpy = vi.spyOn(field, 'applyFlow');
180
+
181
+ await act(async () => {
182
+ field.setStepParams('jsSettings', 'runJs', { code: READONLY_AWARE_CODE });
183
+ parent.setProps({ pattern: 'readPretty' });
184
+ field.scheduleApplyJsSettings();
185
+ });
186
+
187
+ await waitFor(() => {
188
+ expect(screen.getByTestId('js-readonly-state')).toHaveTextContent('true');
189
+ });
190
+
191
+ expect(applyFlowSpy).toHaveBeenCalledTimes(1);
192
+ expect(applyFlowSpy).toHaveBeenCalledWith('jsSettings');
193
+ });
194
+
195
+ it('applies JavaScript settings once on initial render', async () => {
196
+ const applyFlowSpy = vi.spyOn(FlowModel.prototype, 'applyFlow');
197
+ renderParentFieldWithFlowRenderer({ value: 'hello' });
198
+
199
+ try {
200
+ await waitFor(() => {
201
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
202
+ });
203
+
204
+ expect(applyFlowSpy).toHaveBeenCalledTimes(1);
205
+ expect(applyFlowSpy.mock.calls).toEqual([['jsSettings']]);
206
+ } finally {
207
+ applyFlowSpy.mockRestore();
208
+ }
209
+ });
210
+ });
@@ -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
+ });
@@ -7,15 +7,37 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { DataSourceManager } from '@nocobase/flow-engine';
10
+ import { getCollectionFieldInterface } from '@nocobase/flow-engine';
11
+ import type { CollectionFieldInterfaceDataSourceManager } from '@nocobase/flow-engine';
11
12
 
12
13
  export interface CollectionFieldOptions {
13
14
  interface?: string;
14
15
  [key: string]: any;
15
16
  }
16
17
 
18
+ type FieldInterfaceOptions = { titleUsable?: boolean } | null | undefined;
19
+
17
20
  export const DEFAULT_DATA_SOURCE_KEY = 'main';
18
21
 
19
- export const isTitleField = (dm: DataSourceManager, field: CollectionFieldOptions) => {
20
- return dm.collectionFieldInterfaceManager?.getFieldInterface(field.interface)?.titleUsable;
22
+ export const getFlowFieldInterfaceOptions = (
23
+ interfaceName: string | undefined,
24
+ ...dataSourceManagers: Array<CollectionFieldInterfaceDataSourceManager | null | undefined>
25
+ ) => getCollectionFieldInterface(interfaceName, ...dataSourceManagers);
26
+
27
+ export const hasFlowFieldInterfaceLookup = (
28
+ ...dataSourceManagers: Array<CollectionFieldInterfaceDataSourceManager | null | undefined>
29
+ ) =>
30
+ dataSourceManagers.some(
31
+ (dataSourceManager) => typeof dataSourceManager?.collectionFieldInterfaceManager?.getFieldInterface === 'function',
32
+ );
33
+
34
+ export const isTitleFieldInterface = (fieldInterfaceOptions: FieldInterfaceOptions) => {
35
+ return fieldInterfaceOptions?.titleUsable;
36
+ };
37
+
38
+ export const isTitleField = (
39
+ dataSourceManager: CollectionFieldInterfaceDataSourceManager | null | undefined,
40
+ field: CollectionFieldOptions | null | undefined,
41
+ ) => {
42
+ return isTitleFieldInterface(getFlowFieldInterfaceOptions(field?.interface, dataSourceManager));
21
43
  };
@@ -11,7 +11,13 @@ export { Plugin } from '../Plugin';
11
11
  export { useApp } from '../hooks/useApp';
12
12
  export { usePlugin } from '../hooks/usePlugin';
13
13
  export { ColorPicker } from './ColorPicker';
14
- export { DEFAULT_DATA_SOURCE_KEY, isTitleField } from './data';
14
+ export {
15
+ DEFAULT_DATA_SOURCE_KEY,
16
+ getFlowFieldInterfaceOptions,
17
+ hasFlowFieldInterfaceLookup,
18
+ isTitleField,
19
+ isTitleFieldInterface,
20
+ } from './data';
15
21
  export { FieldValidation } from './FieldValidation';
16
22
  export { HighPerformanceSpin } from './HighPerformanceSpin';
17
23
  export { Icon, hasIcon, icons, registerIcon, registerIcons } from './Icon';
package/src/index.ts CHANGED
@@ -32,4 +32,5 @@ export * from './collection-field-interface/CollectionFieldInterface';
32
32
  export * from './collection-field-interface/CollectionFieldInterfaceManager';
33
33
  export * from './collection-manager/interfaces';
34
34
  export * from './flow';
35
+ export { DEFAULT_DATA_SOURCE_KEY, isTitleField, isTitleFieldInterface } from './flow-compat';
35
36
  export { default as AntdAppProvider } from './theme/AntdAppProvider';
@@ -9,15 +9,18 @@
9
9
 
10
10
  import * as antdCssinjs from '@ant-design/cssinjs';
11
11
  import * as antdIcons from '@ant-design/icons';
12
+ import * as emotionCss from '@emotion/css';
12
13
  import * as formilyCore from '@formily/core';
13
14
  import * as formilyReact from '@formily/react';
14
15
  import * as formilyReactive from '@formily/reactive';
15
16
  import * as formilyShared from '@formily/shared';
16
17
  import * as nocobaseClientUtils from '@nocobase/utils/client';
18
+ import { dayjs } from '@nocobase/utils/client';
17
19
  import * as nocobaseFlowEngine from '@nocobase/flow-engine';
18
20
  import * as ahooks from 'ahooks';
19
21
  import * as antd from 'antd';
20
22
  import * as i18next from 'i18next';
23
+ import lodash from 'lodash';
21
24
  import React from 'react';
22
25
  import ReactDOM from 'react-dom';
23
26
  import * as reactI18next from 'react-i18next';
@@ -65,4 +68,7 @@ export function defineGlobalDeps(requirejs: RequireJS) {
65
68
 
66
69
  // utils
67
70
  requirejs.define('ahooks', () => ahooks);
71
+ requirejs.define('dayjs', () => dayjs);
72
+ requirejs.define('lodash', () => lodash);
73
+ requirejs.define('@emotion/css', () => emotionCss);
68
74
  }