@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.
- package/es/components/form/TypedVariableInput.d.ts +75 -0
- package/es/components/form/index.d.ts +1 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
- package/es/flow/models/blocks/assign-form/assignFieldValuesFlow.d.ts +8 -0
- package/es/index.mjs +95 -95
- package/lib/index.js +105 -105
- package/package.json +7 -7
- package/src/components/README.md +48 -0
- package/src/components/README.zh-CN.md +48 -0
- package/src/components/form/TypedVariableInput.tsx +441 -0
- package/src/components/form/__tests__/TypedVariableInput.test.tsx +152 -0
- package/src/components/form/index.tsx +1 -0
- package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +100 -0
- package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +23 -1
- package/src/flow/actions/linkageRules.tsx +5 -4
- package/src/flow/actions/linkageRulesRefresh.tsx +2 -3
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +15 -1
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +67 -0
- package/src/flow/components/code-editor/__tests__/runjsCompletions.test.ts +272 -27
- package/src/flow/components/code-editor/runjsCompletions.ts +490 -9
- package/src/flow/models/actions/__tests__/AssignFormRefill.test.ts +90 -0
- package/src/flow/models/base/GridModel.tsx +1 -1
- package/src/flow/models/base/PageModel/ChildPageModel.tsx +5 -1
- package/src/flow/models/base/PageModel/__tests__/ChildPageModel.test.ts +12 -0
- package/src/flow/models/base/__tests__/GridModel.render.test.tsx +53 -0
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -3
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +94 -2
- package/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx +67 -13
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +29 -0
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +5 -1
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -3
- 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:
|
|
263
|
-
const
|
|
264
|
-
|
|
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({
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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]) =>
|
|
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(
|
|
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
|
+
});
|