@nocobase/client-v2 2.1.0-beta.42 → 2.1.0-beta.44

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 (33) hide show
  1. package/es/components/form/TypedVariableInput.d.ts +75 -0
  2. package/es/components/form/index.d.ts +1 -0
  3. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  4. package/es/flow/models/blocks/assign-form/assignFieldValuesFlow.d.ts +8 -0
  5. package/es/index.mjs +95 -95
  6. package/lib/index.js +105 -105
  7. package/package.json +7 -7
  8. package/src/components/README.md +48 -0
  9. package/src/components/README.zh-CN.md +48 -0
  10. package/src/components/form/TypedVariableInput.tsx +441 -0
  11. package/src/components/form/__tests__/TypedVariableInput.test.tsx +152 -0
  12. package/src/components/form/index.tsx +1 -0
  13. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +100 -0
  14. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +23 -1
  15. package/src/flow/actions/linkageRules.tsx +5 -4
  16. package/src/flow/actions/linkageRulesRefresh.tsx +2 -3
  17. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +15 -1
  18. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +67 -0
  19. package/src/flow/components/code-editor/__tests__/runjsCompletions.test.ts +272 -27
  20. package/src/flow/components/code-editor/runjsCompletions.ts +490 -9
  21. package/src/flow/models/actions/__tests__/AssignFormRefill.test.ts +90 -0
  22. package/src/flow/models/base/GridModel.tsx +1 -1
  23. package/src/flow/models/base/PageModel/ChildPageModel.tsx +5 -1
  24. package/src/flow/models/base/PageModel/__tests__/ChildPageModel.test.ts +12 -0
  25. package/src/flow/models/base/__tests__/GridModel.render.test.tsx +53 -0
  26. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -3
  27. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +94 -2
  28. package/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx +67 -13
  29. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  30. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +29 -0
  31. package/src/flow/models/blocks/form/value-runtime/runtime.ts +5 -1
  32. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -3
  33. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +70 -0
@@ -0,0 +1,53 @@
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, FlowEngineProvider } from '@nocobase/flow-engine';
11
+ import { render } from '@testing-library/react';
12
+ import React from 'react';
13
+ import { beforeEach, describe, expect, it } from 'vitest';
14
+ import { GridModel } from '../GridModel';
15
+
16
+ describe('GridModel.render', () => {
17
+ let engine: FlowEngine;
18
+
19
+ beforeEach(async () => {
20
+ engine = new FlowEngine();
21
+ engine.registerModels({ GridModel });
22
+ await engine.flowSettings.disable();
23
+ });
24
+
25
+ it('renders the Space wrapper as block-level flex to avoid inline baseline spacing', () => {
26
+ const item = engine.createModel({ use: 'FlowModel', uid: 'item-1' });
27
+ const model = engine.createModel<GridModel>({
28
+ use: 'GridModel',
29
+ uid: 'grid-render',
30
+ props: {
31
+ rows: {
32
+ row1: [['item-1']],
33
+ },
34
+ sizes: {
35
+ row1: [24],
36
+ },
37
+ rowGap: 16,
38
+ },
39
+ structure: {} as any,
40
+ });
41
+ (model as any).subModels = { items: [item] };
42
+
43
+ expect(Object.keys((model as any).getVisibleLayout().rows)).toEqual(['row1']);
44
+
45
+ const { container } = render(<FlowEngineProvider engine={engine}>{model.render()}</FlowEngineProvider>);
46
+ const space = container.querySelector('.ant-space') as HTMLElement;
47
+
48
+ expect(space).toBeTruthy();
49
+ expect(space.style.width).toBe('100%');
50
+ expect(space.style.marginBottom).toBe('16px');
51
+ expect(space.style.display).toBe('flex');
52
+ });
53
+ });
@@ -86,6 +86,14 @@ function getAssignFormTempFieldRefreshKey(originField: AssignFormTempOriginField
86
86
  ].join('|');
87
87
  }
88
88
 
89
+ function normalizeAssignFormInputValue(value: unknown) {
90
+ if (!value || typeof value !== 'object' || !('target' in value)) {
91
+ return value;
92
+ }
93
+ const target = (value as { target?: { value?: unknown } }).target;
94
+ return target && 'value' in target ? target.value : value;
95
+ }
96
+
89
97
  /**
90
98
  * 使用 FormItemModel 的“表单项”包装,内部渲染 VariableInput,并将“常量”映射到临时字段模型。
91
99
  */
@@ -259,9 +267,12 @@ export class AssignFormItemModel extends FormItemModel {
259
267
  if (fm) {
260
268
  fm.setProps?.({
261
269
  value: inputProps?.value,
262
- onChange: (...args: any[]) => {
263
- const next = args && args.length ? args[0] : undefined;
264
- inputProps?.onChange?.(next);
270
+ onChange: (...args: unknown[]) => {
271
+ const nextArg = args && args.length ? args[0] : undefined;
272
+ const nextValue = normalizeAssignFormInputValue(nextArg);
273
+ fm.setProps?.({ value: nextValue });
274
+ this.assignValue = nextValue;
275
+ inputProps?.onChange?.(nextValue);
265
276
  },
266
277
  });
267
278
  }
@@ -10,7 +10,7 @@
10
10
  import React from 'react';
11
11
  import { App, ConfigProvider } from 'antd';
12
12
  import { describe, expect, it } from 'vitest';
13
- import { render, screen, waitFor } from '@nocobase/test/client';
13
+ import { render, screen, userEvent, waitFor } from '@nocobase/test/client';
14
14
  import {
15
15
  FlowEngine,
16
16
  FlowEngineProvider,
@@ -24,6 +24,7 @@ import { AssignFormItemModel } from '../AssignFormItemModel';
24
24
  import { AssignFormModel } from '../AssignFormModel';
25
25
  import { createAssignFieldValuesStep } from '../assignFieldValuesFlow';
26
26
  import { InputFieldModel } from '../../../fields/InputFieldModel';
27
+ import { VariableFieldFormModel } from '../../../fields/VariableFieldFormModel';
27
28
 
28
29
  class MockFlowModelRepository implements IFlowModelRepository {
29
30
  async findOne(): Promise<Record<string, any> | null> {
@@ -49,7 +50,13 @@ describe('assignFieldValuesFlow (editor)', () => {
49
50
  it('repairs AssignFormModel resource init and clears cached collection', async () => {
50
51
  const engine = new FlowEngine();
51
52
  engine.setModelRepository(new MockFlowModelRepository());
52
- engine.registerModels({ AssignFormModel, AssignFormGridModel, AssignFormItemModel, InputFieldModel });
53
+ engine.registerModels({
54
+ AssignFormModel,
55
+ AssignFormGridModel,
56
+ AssignFormItemModel,
57
+ InputFieldModel,
58
+ VariableFieldFormModel,
59
+ });
53
60
  engine.context.defineProperty('location', { value: { search: '' } });
54
61
  engine.context.defineProperty('themeToken', { value: { marginLG: 24 } });
55
62
 
@@ -120,5 +127,90 @@ describe('assignFieldValuesFlow (editor)', () => {
120
127
  const form = engine.findModelByParentId<AssignFormModel>(action.uid, 'assignForm');
121
128
  expect(form?.subModels.grid.subModels.items.length).toBe(1);
122
129
  });
130
+
131
+ await waitFor(() => {
132
+ expect(screen.getByDisplayValue('1111')).toBeInTheDocument();
133
+ });
134
+
135
+ const input = screen.getByDisplayValue('1111') as HTMLInputElement;
136
+ await userEvent.clear(input);
137
+ await userEvent.type(input, '2222');
138
+
139
+ await waitFor(() => {
140
+ const form = engine.findModelByParentId<AssignFormModel>(action.uid, 'assignForm');
141
+ expect(form?.getAssignedValues()).toEqual({ nickname: '2222' });
142
+ });
143
+ });
144
+
145
+ it('does not clear delegated parent resource cache when repairing AssignFormModel init', async () => {
146
+ const engine = new FlowEngine();
147
+ engine.setModelRepository(new MockFlowModelRepository());
148
+ engine.registerModels({ AssignFormModel, AssignFormGridModel, AssignFormItemModel, InputFieldModel });
149
+ engine.context.defineProperty('location', { value: { search: '' } });
150
+ engine.context.defineProperty('themeToken', { value: { marginLG: 24 } });
151
+
152
+ const dsm = engine.context.dataSourceManager;
153
+ const main = dsm.getDataSource('main');
154
+ main.addCollection({
155
+ name: 'users',
156
+ fields: [{ name: 'nickname', type: 'string', interface: 'input' }],
157
+ });
158
+ const users = dsm.getCollection('main', 'users');
159
+
160
+ const action = engine.createModel({
161
+ use: 'FlowModel',
162
+ uid: 'act-assign-parent-resource',
163
+ });
164
+
165
+ let resourceCreateCount = 0;
166
+ action.context.defineProperty('resource', {
167
+ get: () => ({ id: ++resourceCreateCount }),
168
+ });
169
+ const cachedResource = action.context.resource;
170
+
171
+ engine.createModel<AssignFormModel>({
172
+ use: 'AssignFormModel',
173
+ uid: 'form-assign-parent-resource',
174
+ parentId: action.uid,
175
+ subKey: 'assignForm',
176
+ stepParams: {
177
+ resourceSettings: {
178
+ init: {
179
+ dataSourceKey: 'main',
180
+ collectionName: 'missing',
181
+ },
182
+ },
183
+ },
184
+ });
185
+
186
+ action.context.defineProperty('blockModel', { value: { collection: users } });
187
+
188
+ const step = createAssignFieldValuesStep({ settingsFlowKey: 'assignSettings' });
189
+ const schema = step.uiSchema();
190
+ const Editor = schema.editor?.['x-component'] as React.ComponentType;
191
+ const flowSettingsCtx = new FlowRuntimeContext(action as any, 'assignSettings', 'settings');
192
+
193
+ render(
194
+ <FlowEngineProvider engine={engine}>
195
+ <ConfigProvider>
196
+ <App>
197
+ <FlowSettingsContextProvider value={flowSettingsCtx}>
198
+ <Editor />
199
+ </FlowSettingsContextProvider>
200
+ </App>
201
+ </ConfigProvider>
202
+ </FlowEngineProvider>,
203
+ );
204
+
205
+ await waitFor(() => {
206
+ const form = engine.findModelByParentId<AssignFormModel>(action.uid, 'assignForm');
207
+ expect(form?.getStepParams('resourceSettings', 'init')).toEqual({
208
+ dataSourceKey: 'main',
209
+ collectionName: 'users',
210
+ });
211
+ });
212
+
213
+ expect(action.context.resource).toBe(cachedResource);
214
+ expect(resourceCreateCount).toBe(1);
123
215
  });
124
216
  });
@@ -38,6 +38,9 @@ type AssignFieldValuesModel = {
38
38
  uid: string;
39
39
  assignFormUid?: string;
40
40
  context?: AssignFieldValuesContext;
41
+ subModels?: {
42
+ assignForm?: AssignFormModel;
43
+ };
41
44
  getStepParams?: (flowKey: string, stepKey: string) => { assignedValues?: AssignedValues } | undefined;
42
45
  setStepParams?: (flowKey: string, stepKey: string, params: { assignedValues: AssignedValues }) => void;
43
46
  };
@@ -88,11 +91,53 @@ function isValidResourceInit(init: unknown): init is {
88
91
  );
89
92
  }
90
93
 
91
- function clearResourceContextCache(model: { context?: { removeCache?: (key: string) => void } } | undefined) {
92
- model?.context?.removeCache?.('dataSource');
93
- model?.context?.removeCache?.('collection');
94
- model?.context?.removeCache?.('resource');
95
- model?.context?.removeCache?.('association');
94
+ type LocalCacheContext = {
95
+ _observableCache?: Record<string, unknown>;
96
+ _cache?: Record<string, unknown>;
97
+ _pending?: Record<string, unknown>;
98
+ };
99
+
100
+ function toLocalCacheContext(context: unknown): LocalCacheContext | undefined {
101
+ return context && typeof context === 'object' ? (context as LocalCacheContext) : undefined;
102
+ }
103
+
104
+ function clearOwnContextCache(context: unknown, key: string) {
105
+ const localContext = toLocalCacheContext(context);
106
+ if (!localContext) {
107
+ return;
108
+ }
109
+ delete localContext._observableCache?.[key];
110
+ delete localContext._cache?.[key];
111
+ delete localContext._pending?.[key];
112
+ }
113
+
114
+ function clearResourceContextCache(model: { context?: unknown } | undefined) {
115
+ clearOwnContextCache(model?.context, 'dataSource');
116
+ clearOwnContextCache(model?.context, 'collection');
117
+ clearOwnContextCache(model?.context, 'resource');
118
+ clearOwnContextCache(model?.context, 'association');
119
+ }
120
+
121
+ function resolveAssignFormModel(ctx: {
122
+ model: AssignFieldValuesModel;
123
+ engine: {
124
+ getModel?: (uid: string, fromRoot?: boolean) => AssignFormModel | undefined;
125
+ findModelByParentId?: (parentId: string, subKey: string) => AssignFormModel | undefined | null;
126
+ };
127
+ }) {
128
+ if (ctx.model.assignFormUid) {
129
+ const form = ctx.engine.getModel?.(ctx.model.assignFormUid, true);
130
+ if (form) {
131
+ return form;
132
+ }
133
+ }
134
+
135
+ const localForm = ctx.model.subModels?.assignForm;
136
+ if (localForm) {
137
+ return localForm;
138
+ }
139
+
140
+ return ctx.engine.findModelByParentId?.(ctx.model.uid, 'assignForm') || undefined;
96
141
  }
97
142
 
98
143
  export function createAssignFormSubModelOptions(ctx: AssignFieldValuesContext) {
@@ -235,14 +280,23 @@ export function createAssignFieldValuesStep(options: AssignFieldValuesStepOption
235
280
  },
236
281
  };
237
282
  },
238
- async beforeParamsSave(ctx: {
239
- model: AssignFieldValuesModel;
240
- engine: {
241
- getModel?: (uid: string, fromRoot?: boolean) => AssignFormModel | undefined;
242
- };
243
- }) {
244
- const form = ctx.model.assignFormUid ? ctx.engine.getModel?.(ctx.model.assignFormUid, true) : undefined;
245
- if (!form) return;
283
+ async beforeParamsSave(
284
+ ctx: {
285
+ model: AssignFieldValuesModel;
286
+ engine: {
287
+ getModel?: (uid: string, fromRoot?: boolean) => AssignFormModel | undefined;
288
+ findModelByParentId?: (parentId: string, subKey: string) => AssignFormModel | undefined | null;
289
+ };
290
+ },
291
+ params?: { assignedValues?: AssignedValues },
292
+ previousParams?: { assignedValues?: AssignedValues },
293
+ ) {
294
+ const form = resolveAssignFormModel(ctx);
295
+ if (!form) {
296
+ const assignedValues = params?.assignedValues || previousParams?.assignedValues || {};
297
+ ctx.model.setStepParams?.(options.settingsFlowKey, ASSIGN_FIELD_VALUES_STEP_KEY, { assignedValues });
298
+ return;
299
+ }
246
300
  if (options.validateBeforeSave) {
247
301
  await form.form?.validateFields?.();
248
302
  }
@@ -221,6 +221,23 @@ describe('FlowModel core behaviors (collected)', () => {
221
221
  expect(i1).toEqual({ ping: 1 });
222
222
  });
223
223
 
224
+ it('unchanged stepParams do not trigger a beforeRender rerun', async () => {
225
+ vi.useFakeTimers();
226
+ const spy = vi.fn().mockResolvedValue([]);
227
+ (engine as any).executor.dispatchEvent = spy;
228
+ const model = engine.createModel<FlowModel>({
229
+ use: 'FlowModel',
230
+ uid: 'm-flow-3-noop',
231
+ stepParams: { anyFlow: { anyStep: { x: 1 } } },
232
+ });
233
+
234
+ await model.dispatchEvent('beforeRender', { ping: 1 });
235
+ model.setStepParams('anyFlow', 'anyStep', { x: 1 });
236
+ await vi.advanceTimersByTimeAsync(150);
237
+
238
+ expect(spy).toHaveBeenCalledTimes(1);
239
+ });
240
+
224
241
  it('applyFlow delegates to executor.runFlow', async () => {
225
242
  const spyRun = vi.fn().mockResolvedValue('ok');
226
243
  (engine as any).executor.runFlow = spyRun;
@@ -15,6 +15,7 @@ import { observable } from '@formily/reactive';
15
15
  import { get as lodashGet, merge as lodashMerge, set as lodashSet } from 'lodash';
16
16
  import { FlowContext, JSRunner } from '@nocobase/flow-engine';
17
17
  import { FormValueRuntime } from '..';
18
+ import type { FormInstance } from 'antd';
18
19
 
19
20
  function createFormStub(initialValues: any = {}) {
20
21
  const store: any = JSON.parse(JSON.stringify(initialValues || {}));
@@ -121,6 +122,34 @@ function createFieldContext(runtime: FormValueRuntime) {
121
122
  }
122
123
 
123
124
  describe('FormValueRuntime (default rules)', () => {
125
+ it('skips object patches when values are unchanged', async () => {
126
+ const engineEmitter = new EventEmitter();
127
+ const blockEmitter = new EventEmitter();
128
+ const formStub = createFormStub({ name: 'old' });
129
+ const dispatchEvent = vi.fn();
130
+
131
+ const blockModel = {
132
+ uid: 'form-noop-patch',
133
+ flowEngine: { emitter: engineEmitter },
134
+ emitter: blockEmitter,
135
+ dispatchEvent,
136
+ getAclActionName: () => 'create',
137
+ } as ConstructorParameters<typeof FormValueRuntime>[0]['model'];
138
+
139
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as unknown as FormInstance });
140
+ runtime.mount({ sync: true });
141
+
142
+ const blockCtx = createFieldContext(runtime);
143
+ await runtime.setFormValues(blockCtx, { name: 'old' }, { source: 'system' });
144
+
145
+ expect(dispatchEvent).not.toHaveBeenCalled();
146
+
147
+ await runtime.setFormValues(blockCtx, { name: 'new' }, { source: 'system' });
148
+
149
+ expect(formStub.getFieldValue(['name'])).toBe('new');
150
+ expect(dispatchEvent).toHaveBeenCalledTimes(1);
151
+ });
152
+
124
153
  it('recomputes default on dependency change when current equals last default; user change disables default permanently', async () => {
125
154
  const engineEmitter = new EventEmitter();
126
155
  const blockEmitter = new EventEmitter();
@@ -846,7 +846,11 @@ export class FormValueRuntime {
846
846
  const changedPaths: NamePath[] = [];
847
847
 
848
848
  if (!Array.isArray(patch)) {
849
- const patchEntries = Object.entries(patch || {}).filter(([pathKey]) => !shouldSkipByLinkageScope(pathKey));
849
+ const patchEntries = Object.entries(patch || {}).filter(([pathKey, rawValue]) => {
850
+ if (shouldSkipByLinkageScope(pathKey)) return false;
851
+ const value = isObservable(rawValue) ? toJS(rawValue) : rawValue;
852
+ return !_.isEqual(this.getFormValueAtPath([pathKey]), value);
853
+ });
850
854
  const patchToApply = Object.fromEntries(patchEntries);
851
855
  const patchKeys = patchEntries.map(([pathKey]) => pathKey);
852
856
  if (!patchKeys.length) {
@@ -10,7 +10,7 @@
10
10
  import { useFlowModelContext } from '@nocobase/flow-engine';
11
11
  import { Select } from 'antd';
12
12
  import type { CSSProperties } from 'react';
13
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
13
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
14
  import { Button, CheckList, Popup, SearchBar, SpinLoading } from 'antd-mobile';
15
15
  import { css } from '@emotion/css';
16
16
  import {
@@ -51,7 +51,7 @@ function deriveRecordsFromValue(
51
51
  ) {
52
52
  if (isMultiple) {
53
53
  if (Array.isArray(value)) {
54
- return (value.filter(Boolean) as any[]).map((item) => {
54
+ return (value.filter((item) => item !== null && item !== undefined) as any[]).map((item) => {
55
55
  if (valueMode === 'value') {
56
56
  return optionMap.get(item) ?? { [valueKey]: item };
57
57
  }
@@ -113,11 +113,13 @@ export function MobileLazySelect(props: Readonly<LazySelectProps>) {
113
113
  );
114
114
 
115
115
  const [selectedRecords, setSelectedRecords] = useState<AssociationOption[]>(() => derivedRecords);
116
+ const wasVisibleRef = useRef(visible);
116
117
 
117
118
  useEffect(() => {
118
- if (visible) {
119
+ if (visible && !wasVisibleRef.current) {
119
120
  setSelectedRecords(derivedRecords);
120
121
  }
122
+ wasVisibleRef.current = visible;
121
123
  }, [derivedRecords, visible]);
122
124
 
123
125
  useEffect(() => {
@@ -10,6 +10,7 @@
10
10
  import React from 'react';
11
11
  import { beforeEach, describe, it, expect, vi } from 'vitest';
12
12
  import { act, fireEvent, render, screen } from '@nocobase/test/client';
13
+ import { MobileLazySelect } from '../MobileLazySelect';
13
14
  import { MobileSelect } from '../MobileSelect';
14
15
 
15
16
  const DEFAULT_OPTIONS = [
@@ -17,6 +18,11 @@ const DEFAULT_OPTIONS = [
17
18
  { label: 'Option B', value: 'b' },
18
19
  ];
19
20
 
21
+ const RELATION_OPTIONS = [
22
+ { uuid: '05f6a3b4-bfb7-7943-578a-3819e2687a7e' },
23
+ { uuid: 'c7d99828-a1de-9e70-4c2d-b0139abdf02e' },
24
+ ];
25
+
20
26
  const mockState = vi.hoisted(() => ({
21
27
  selectProps: undefined as any,
22
28
  popupProps: undefined as any,
@@ -44,6 +50,13 @@ function openPopup() {
44
50
  expect(screen.getByTestId('popup')).toBeInTheDocument();
45
51
  }
46
52
 
53
+ function openLazyPopup() {
54
+ act(() => {
55
+ mockState.selectProps?.onClick?.();
56
+ });
57
+ expect(screen.getByTestId('popup')).toBeInTheDocument();
58
+ }
59
+
47
60
  function selectValues(values: string[]) {
48
61
  act(() => {
49
62
  mockState.checklistProps?.onChange?.(values);
@@ -73,6 +86,30 @@ function renderMobileSelect(props: Record<string, any> = {}) {
73
86
  return { onChange, onChangeComplete };
74
87
  }
75
88
 
89
+ function renderMobileLazySelect(props: Record<string, any> = {}) {
90
+ const onChange = props.onChange ?? vi.fn();
91
+ const renderComponent = (nextProps: Record<string, any> = {}) => (
92
+ <MobileLazySelect
93
+ fieldNames={{ label: 'uuid', value: 'uuid' }}
94
+ value={[]}
95
+ multiple
96
+ allowMultiple
97
+ options={RELATION_OPTIONS}
98
+ {...props}
99
+ {...nextProps}
100
+ onChange={onChange}
101
+ />
102
+ );
103
+
104
+ const result = render(renderComponent());
105
+
106
+ return {
107
+ ...result,
108
+ onChange,
109
+ rerender: (nextProps: Record<string, any> = {}) => result.rerender(renderComponent(nextProps)),
110
+ };
111
+ }
112
+
76
113
  vi.mock('@nocobase/flow-engine', async () => {
77
114
  const actual = await vi.importActual<any>('@nocobase/flow-engine');
78
115
  return {
@@ -80,6 +117,12 @@ vi.mock('@nocobase/flow-engine', async () => {
80
117
  useFlowModelContext: () => ({
81
118
  t: (value: string) => value,
82
119
  }),
120
+ useFlowModel: () => ({
121
+ context: {
122
+ collectionField: {},
123
+ },
124
+ subModels: {},
125
+ }),
83
126
  };
84
127
  });
85
128
 
@@ -240,3 +283,30 @@ describe('MobileSelect in SubForm/SubTable containers', () => {
240
283
  expect(onCommit).toHaveBeenNthCalledWith(2, ['a', 'b']);
241
284
  });
242
285
  });
286
+
287
+ describe('MobileLazySelect', () => {
288
+ beforeEach(() => {
289
+ resetMockState();
290
+ });
291
+
292
+ it('keeps pending relation records selected until confirm', () => {
293
+ const { onChange, rerender } = renderMobileLazySelect();
294
+
295
+ openLazyPopup();
296
+ expect(mockState.checklistProps?.value).toEqual([]);
297
+
298
+ selectValues(['c7d99828-a1de-9e70-4c2d-b0139abdf02e']);
299
+ expect(mockState.checklistProps?.value).toEqual(['c7d99828-a1de-9e70-4c2d-b0139abdf02e']);
300
+
301
+ rerender({
302
+ options: RELATION_OPTIONS.map((item) => ({ ...item })),
303
+ });
304
+
305
+ expect(mockState.checklistProps?.value).toEqual(['c7d99828-a1de-9e70-4c2d-b0139abdf02e']);
306
+
307
+ confirmSelection();
308
+
309
+ expect(onChange).toHaveBeenCalledTimes(1);
310
+ expect(onChange).toHaveBeenCalledWith([RELATION_OPTIONS[1]]);
311
+ });
312
+ });