@nocobase/client-v2 2.1.0-beta.24 → 2.1.0-beta.26
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/BaseApplication.d.ts +1 -0
- package/es/flow/actions/dataScopeFilter.d.ts +9 -0
- package/es/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
- package/es/flow/index.d.ts +1 -0
- package/es/flow/internal/utils/rebuildFieldSubModel.d.ts +2 -1
- package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
- package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
- package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
- package/es/flow/models/actions/index.d.ts +3 -0
- package/es/flow/models/base/GridModel.d.ts +3 -1
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +1 -0
- package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
- package/es/flow/models/fields/JSFieldModel.d.ts +5 -0
- package/es/index.d.ts +1 -0
- package/es/index.mjs +101 -101
- package/lib/index.js +99 -99
- package/package.json +6 -5
- package/src/BaseApplication.tsx +4 -0
- package/src/__tests__/globalDeps.test.ts +6 -0
- package/src/__tests__/remotePlugins.test.ts +27 -0
- package/src/flow/actions/__tests__/dataScopeFilter.test.ts +158 -0
- package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
- package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
- package/src/flow/actions/dataScope.tsx +6 -4
- package/src/flow/actions/dataScopeFilter.ts +70 -0
- package/src/flow/actions/linkageRules.tsx +8 -1
- package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
- package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
- package/src/flow/actions/setTargetDataScope.tsx +6 -5
- package/src/flow/index.ts +1 -0
- package/src/flow/internal/utils/__tests__/rebuildFieldSubModel.test.ts +77 -2
- package/src/flow/internal/utils/rebuildFieldSubModel.ts +21 -5
- package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
- package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
- package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
- package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
- package/src/flow/models/actions/index.ts +3 -0
- package/src/flow/models/base/GridModel.tsx +21 -1
- package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
- package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +9 -5
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormBlockModel.cleanup.test.ts +138 -0
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +22 -0
- package/src/flow/models/blocks/table/JSColumnModel.tsx +30 -2
- package/src/flow/models/blocks/table/TableBlockModel.tsx +8 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +1 -0
- package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +51 -0
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +49 -0
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +21 -5
- package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
- package/src/flow/models/fields/JSFieldModel.tsx +54 -14
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
- package/src/index.ts +1 -0
- package/src/utils/globalDeps.ts +10 -0
- package/src/utils/requirejs.ts +1 -1
|
@@ -0,0 +1,250 @@
|
|
|
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 { BaseRecordResource, FlowEngine } from '@nocobase/flow-engine';
|
|
12
|
+
import {
|
|
13
|
+
applyDisassociateAction,
|
|
14
|
+
ActionModel,
|
|
15
|
+
applyAssociateAction,
|
|
16
|
+
AssociateActionModel,
|
|
17
|
+
CollectionActionGroupModel,
|
|
18
|
+
DisassociateActionModel,
|
|
19
|
+
getAssociationTargetResourceSettings,
|
|
20
|
+
PopupActionModel,
|
|
21
|
+
RecordActionGroupModel,
|
|
22
|
+
} from '../../..';
|
|
23
|
+
|
|
24
|
+
class TestAssociationResource<T = any> extends BaseRecordResource<T> {
|
|
25
|
+
async refresh(): Promise<void> {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const createEngine = () => {
|
|
29
|
+
const engine = new FlowEngine();
|
|
30
|
+
engine.registerModels({
|
|
31
|
+
ActionModel,
|
|
32
|
+
AssociateActionModel,
|
|
33
|
+
DisassociateActionModel,
|
|
34
|
+
PopupActionModel,
|
|
35
|
+
CollectionActionGroupModel,
|
|
36
|
+
RecordActionGroupModel,
|
|
37
|
+
});
|
|
38
|
+
return engine;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const createContext = (engine: FlowEngine, resourceSettingsInit: any) => {
|
|
42
|
+
const collection = {
|
|
43
|
+
options: {
|
|
44
|
+
availableActions: ['list', 'update'],
|
|
45
|
+
},
|
|
46
|
+
getFilterByTK: vi.fn((record) => record.id),
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
engine,
|
|
50
|
+
collection,
|
|
51
|
+
blockModel: {
|
|
52
|
+
collection,
|
|
53
|
+
getResourceSettingsInitParams: () => resourceSettingsInit,
|
|
54
|
+
},
|
|
55
|
+
} as any;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
describe('association action models', () => {
|
|
59
|
+
it('uses update permission for association operations', () => {
|
|
60
|
+
expect(AssociateActionModel.prototype.getAclActionName.call({})).toBe('update');
|
|
61
|
+
expect(DisassociateActionModel.prototype.getAclActionName.call({})).toBe('update');
|
|
62
|
+
expect(AssociateActionModel.capabilityActionName).toBe('update');
|
|
63
|
+
expect(DisassociateActionModel.capabilityActionName).toBe('update');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('uses association target collection for selector table blocks', () => {
|
|
67
|
+
const ctx: any = {
|
|
68
|
+
blockModel: {
|
|
69
|
+
association: {
|
|
70
|
+
target: 'transports',
|
|
71
|
+
targetCollection: {
|
|
72
|
+
name: 'transports',
|
|
73
|
+
dataSourceKey: 'main',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
getResourceSettingsInitParams: () => ({
|
|
77
|
+
dataSourceKey: 'main',
|
|
78
|
+
collectionName: 'orders',
|
|
79
|
+
associationName: 'products.transports',
|
|
80
|
+
}),
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
expect(getAssociationTargetResourceSettings(ctx)).toEqual({
|
|
85
|
+
dataSourceKey: 'main',
|
|
86
|
+
collectionName: 'transports',
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('shows Associate only in collection actions of association blocks', async () => {
|
|
91
|
+
const engine = createEngine();
|
|
92
|
+
|
|
93
|
+
const relationItems = await CollectionActionGroupModel.defineChildren(
|
|
94
|
+
createContext(engine, {
|
|
95
|
+
dataSourceKey: 'main',
|
|
96
|
+
collectionName: 'orders',
|
|
97
|
+
associationName: 'products.o2m_orders',
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
const normalItems = await CollectionActionGroupModel.defineChildren(
|
|
101
|
+
createContext(engine, {
|
|
102
|
+
dataSourceKey: 'main',
|
|
103
|
+
collectionName: 'orders',
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(relationItems.map((item: any) => item.useModel)).toContain('AssociateActionModel');
|
|
108
|
+
expect(relationItems.map((item: any) => item.useModel)).not.toContain('DisassociateActionModel');
|
|
109
|
+
expect(normalItems.map((item: any) => item.useModel)).not.toContain('AssociateActionModel');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('shows Disassociate only in record actions of association blocks', async () => {
|
|
113
|
+
const engine = createEngine();
|
|
114
|
+
|
|
115
|
+
const relationItems = await RecordActionGroupModel.defineChildren(
|
|
116
|
+
createContext(engine, {
|
|
117
|
+
dataSourceKey: 'main',
|
|
118
|
+
collectionName: 'orders',
|
|
119
|
+
associationName: 'products.o2m_orders',
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
const normalItems = await RecordActionGroupModel.defineChildren(
|
|
123
|
+
createContext(engine, {
|
|
124
|
+
dataSourceKey: 'main',
|
|
125
|
+
collectionName: 'orders',
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(relationItems.map((item: any) => item.useModel)).toContain('DisassociateActionModel');
|
|
130
|
+
expect(relationItems.map((item: any) => item.useModel)).not.toContain('AssociateActionModel');
|
|
131
|
+
expect(normalItems.map((item: any) => item.useModel)).not.toContain('DisassociateActionModel');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('disassociates the current record through association resource remove action', async () => {
|
|
135
|
+
const resource = {
|
|
136
|
+
runAction: vi.fn(async () => ({})),
|
|
137
|
+
refresh: vi.fn(async () => {}),
|
|
138
|
+
};
|
|
139
|
+
const collection = {
|
|
140
|
+
getFilterByTK: vi.fn(() => 12),
|
|
141
|
+
};
|
|
142
|
+
const ctx: any = {
|
|
143
|
+
blockModel: {
|
|
144
|
+
collection,
|
|
145
|
+
resource,
|
|
146
|
+
getResourceSettingsInitParams: () => ({
|
|
147
|
+
dataSourceKey: 'main',
|
|
148
|
+
collectionName: 'orders',
|
|
149
|
+
associationName: 'products.o2m_orders',
|
|
150
|
+
}),
|
|
151
|
+
},
|
|
152
|
+
record: {
|
|
153
|
+
id: 12,
|
|
154
|
+
},
|
|
155
|
+
message: {
|
|
156
|
+
success: vi.fn(),
|
|
157
|
+
error: vi.fn(),
|
|
158
|
+
},
|
|
159
|
+
t: (value: string) => value,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
await applyDisassociateAction(ctx);
|
|
163
|
+
|
|
164
|
+
expect(collection.getFilterByTK).toHaveBeenCalledWith(ctx.record);
|
|
165
|
+
expect(resource.runAction).toHaveBeenCalledWith('remove', {
|
|
166
|
+
data: [12],
|
|
167
|
+
});
|
|
168
|
+
expect(resource.refresh).toHaveBeenCalled();
|
|
169
|
+
expect(ctx.message.success).toHaveBeenCalledWith('Record disassociated successfully');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('associates selected records through association resource add action', async () => {
|
|
173
|
+
const resource = {
|
|
174
|
+
runAction: vi.fn(async () => ({})),
|
|
175
|
+
refresh: vi.fn(async () => {}),
|
|
176
|
+
};
|
|
177
|
+
const collection = {
|
|
178
|
+
getFilterByTK: vi.fn((record) => record.id),
|
|
179
|
+
};
|
|
180
|
+
const ctx: any = {
|
|
181
|
+
blockModel: {
|
|
182
|
+
collection,
|
|
183
|
+
resource,
|
|
184
|
+
getResourceSettingsInitParams: () => ({
|
|
185
|
+
dataSourceKey: 'main',
|
|
186
|
+
collectionName: 'orders',
|
|
187
|
+
associationName: 'products.o2m_orders',
|
|
188
|
+
}),
|
|
189
|
+
},
|
|
190
|
+
message: {
|
|
191
|
+
success: vi.fn(),
|
|
192
|
+
warning: vi.fn(),
|
|
193
|
+
error: vi.fn(),
|
|
194
|
+
},
|
|
195
|
+
t: (value: string) => value,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
await applyAssociateAction(ctx, [{ id: 11 }, { id: 12 }]);
|
|
199
|
+
|
|
200
|
+
expect(collection.getFilterByTK).toHaveBeenCalledWith({ id: 11 });
|
|
201
|
+
expect(collection.getFilterByTK).toHaveBeenCalledWith({ id: 12 });
|
|
202
|
+
expect(resource.runAction).toHaveBeenCalledWith('add', {
|
|
203
|
+
data: [11, 12],
|
|
204
|
+
});
|
|
205
|
+
expect(resource.refresh).toHaveBeenCalled();
|
|
206
|
+
expect(ctx.message.success).toHaveBeenCalledWith('Record associated successfully');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('uses nested association resource url when adding associated records', async () => {
|
|
210
|
+
const engine = createEngine();
|
|
211
|
+
const resource = engine.createResource(TestAssociationResource);
|
|
212
|
+
const api = {
|
|
213
|
+
request: vi.fn().mockResolvedValue({ data: { data: {} } }),
|
|
214
|
+
};
|
|
215
|
+
resource.setAPIClient(api as any);
|
|
216
|
+
resource.setResourceName('products.o2m_orders');
|
|
217
|
+
resource.setSourceId('362872646860800');
|
|
218
|
+
|
|
219
|
+
const ctx: any = {
|
|
220
|
+
blockModel: {
|
|
221
|
+
collection: {
|
|
222
|
+
getFilterByTK: vi.fn((record) => record.id),
|
|
223
|
+
},
|
|
224
|
+
resource,
|
|
225
|
+
getResourceSettingsInitParams: () => ({
|
|
226
|
+
dataSourceKey: 'main',
|
|
227
|
+
collectionName: 'orders',
|
|
228
|
+
associationName: 'products.o2m_orders',
|
|
229
|
+
sourceId: '362872646860800',
|
|
230
|
+
}),
|
|
231
|
+
},
|
|
232
|
+
message: {
|
|
233
|
+
success: vi.fn(),
|
|
234
|
+
warning: vi.fn(),
|
|
235
|
+
error: vi.fn(),
|
|
236
|
+
},
|
|
237
|
+
t: (value: string) => value,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
await applyAssociateAction(ctx, [{ id: 11 }]);
|
|
241
|
+
|
|
242
|
+
expect(api.request).toHaveBeenCalledWith(
|
|
243
|
+
expect.objectContaining({
|
|
244
|
+
method: 'post',
|
|
245
|
+
url: 'products/362872646860800/o2m_orders:add',
|
|
246
|
+
data: [11],
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export * from './AddNewActionModel';
|
|
11
|
+
export * from './AssociateActionModel';
|
|
12
|
+
export * from './AssociationActionUtils';
|
|
11
13
|
export * from './BulkDeleteActionModel';
|
|
12
14
|
export * from './DeleteActionModel';
|
|
15
|
+
export * from './DisassociateActionModel';
|
|
13
16
|
export * from './EditActionModel';
|
|
14
17
|
export * from './FilterActionModel';
|
|
15
18
|
export * from './JSActionModel';
|
|
@@ -118,6 +118,14 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
|
|
|
118
118
|
private dragState?: DragState;
|
|
119
119
|
private _memoItemFlowSettings?: Exclude<FlowModelRendererProps['showFlowSettings'], boolean>;
|
|
120
120
|
|
|
121
|
+
onInit(options) {
|
|
122
|
+
super.onInit(options);
|
|
123
|
+
// 历史数据里可能残留拖拽高亮框,初始化时立即清理,避免刷新页面后常驻显示。
|
|
124
|
+
if (this.props.dragOverlayRect) {
|
|
125
|
+
this.setProps('dragOverlayRect', null);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
121
129
|
private updateDragPointerPosition = (event: Event) => {
|
|
122
130
|
if (!this.dragState) {
|
|
123
131
|
return;
|
|
@@ -243,6 +251,12 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
|
|
|
243
251
|
return this.normalizeLayoutFromSource();
|
|
244
252
|
}
|
|
245
253
|
|
|
254
|
+
serialize(): Record<string, any> {
|
|
255
|
+
const data = super.serialize();
|
|
256
|
+
data.props = _.omit(data.props, ['dragOverlayRect']);
|
|
257
|
+
return data;
|
|
258
|
+
}
|
|
259
|
+
|
|
246
260
|
syncLayoutProps(layout: GridLayoutV2) {
|
|
247
261
|
const projection = projectLayoutToLegacyRows(layout);
|
|
248
262
|
this.setProps('layout', layout);
|
|
@@ -801,11 +815,17 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
|
|
|
801
815
|
this.setProps('dragOverlayRect', null);
|
|
802
816
|
}
|
|
803
817
|
|
|
804
|
-
handleDragEnd(
|
|
818
|
+
handleDragEnd(event: DragEndEvent) {
|
|
805
819
|
if (!this.dragState) {
|
|
806
820
|
return;
|
|
807
821
|
}
|
|
808
822
|
|
|
823
|
+
const finalPoint = this.computePointerPosition(event);
|
|
824
|
+
if (finalPoint) {
|
|
825
|
+
const finalSlot = this.resolveDragSlot(finalPoint);
|
|
826
|
+
this.applyPreview(finalSlot);
|
|
827
|
+
}
|
|
828
|
+
|
|
809
829
|
const previewLayout = this.dragState.previewLayout;
|
|
810
830
|
if (previewLayout) {
|
|
811
831
|
if (previewLayout.layout) {
|
|
@@ -283,4 +283,102 @@ describe('GridModel drag snapshot container', () => {
|
|
|
283
283
|
type: 'column',
|
|
284
284
|
});
|
|
285
285
|
});
|
|
286
|
+
|
|
287
|
+
it('clears persisted drag overlay on init', () => {
|
|
288
|
+
const model = engine.createModel<GridModel>({
|
|
289
|
+
use: 'GridModel',
|
|
290
|
+
uid: 'grid-hidden-stale-overlay',
|
|
291
|
+
props: {
|
|
292
|
+
dragOverlayRect: {
|
|
293
|
+
top: 10,
|
|
294
|
+
left: 20,
|
|
295
|
+
width: 100,
|
|
296
|
+
height: 40,
|
|
297
|
+
type: 'column',
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
structure: {} as any,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(model.props.dragOverlayRect).toBeNull();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('omits drag overlay rect from serialized props', () => {
|
|
307
|
+
const model = engine.createModel<GridModel>({
|
|
308
|
+
use: 'GridModel',
|
|
309
|
+
uid: 'grid-serialize-overlay',
|
|
310
|
+
props: {
|
|
311
|
+
rowGap: 24,
|
|
312
|
+
dragOverlayRect: {
|
|
313
|
+
top: 10,
|
|
314
|
+
left: 20,
|
|
315
|
+
width: 100,
|
|
316
|
+
height: 40,
|
|
317
|
+
type: 'column',
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
structure: {} as any,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const serialized = model.serialize();
|
|
324
|
+
|
|
325
|
+
expect(serialized.props).toMatchObject({ rowGap: 24 });
|
|
326
|
+
expect(serialized.props).not.toHaveProperty('dragOverlayRect');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('recomputes the final slot from drag end position before saving layout', () => {
|
|
330
|
+
const model = engine.createModel<GridModel>({
|
|
331
|
+
use: 'GridModel',
|
|
332
|
+
uid: 'grid-drag-final-slot',
|
|
333
|
+
props: {},
|
|
334
|
+
structure: {} as any,
|
|
335
|
+
});
|
|
336
|
+
const applyPreview = vi.fn((slot) => {
|
|
337
|
+
(model as any).dragState.previewLayout = slot
|
|
338
|
+
? {
|
|
339
|
+
rows: { 'row-final': [['item-1']] },
|
|
340
|
+
sizes: { 'row-final': [24] },
|
|
341
|
+
}
|
|
342
|
+
: undefined;
|
|
343
|
+
});
|
|
344
|
+
const resolveDragSlot = vi.fn(() => ({
|
|
345
|
+
type: 'row-gap',
|
|
346
|
+
targetRowId: 'row-final',
|
|
347
|
+
position: 'below',
|
|
348
|
+
rect: { top: 200, left: 20, width: 440, height: 32 },
|
|
349
|
+
}));
|
|
350
|
+
const saveGridLayout = vi.fn();
|
|
351
|
+
const syncLayoutProps = vi.fn();
|
|
352
|
+
const finishDrag = vi.fn();
|
|
353
|
+
|
|
354
|
+
(model as any).applyPreview = applyPreview;
|
|
355
|
+
(model as any).resolveDragSlot = resolveDragSlot;
|
|
356
|
+
(model as any).saveGridLayout = saveGridLayout;
|
|
357
|
+
(model as any).syncLayoutProps = syncLayoutProps;
|
|
358
|
+
(model as any).finishDrag = finishDrag;
|
|
359
|
+
(model as any).dragState = {
|
|
360
|
+
sourceUid: 'item-1',
|
|
361
|
+
snapshot: { rows: {}, sizes: {} },
|
|
362
|
+
slots: [],
|
|
363
|
+
containerEl: null,
|
|
364
|
+
containerRect: { top: 0, left: 0, width: 0, height: 0 },
|
|
365
|
+
pointerOrigin: { x: 100, y: 100 },
|
|
366
|
+
activeSlotKey: null,
|
|
367
|
+
previewLayout: undefined,
|
|
368
|
+
refreshTimer: null,
|
|
369
|
+
generatedIds: new Map(),
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
model.handleDragEnd({
|
|
373
|
+
delta: { x: 30, y: 220 },
|
|
374
|
+
} as any);
|
|
375
|
+
|
|
376
|
+
expect(resolveDragSlot).toHaveBeenCalledWith({ x: 130, y: 320 });
|
|
377
|
+
expect(applyPreview).toHaveBeenCalledOnce();
|
|
378
|
+
expect(saveGridLayout).toHaveBeenCalledWith({
|
|
379
|
+
rows: { 'row-final': [['item-1']] },
|
|
380
|
+
sizes: { 'row-final': [24] },
|
|
381
|
+
});
|
|
382
|
+
expect(finishDrag).toHaveBeenCalledOnce();
|
|
383
|
+
});
|
|
286
384
|
});
|
|
@@ -102,15 +102,19 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
102
102
|
// 首次进入页面:等待子模型 beforeRender 完成(例如 name 初始化),再应用表单级默认值并触发筛选
|
|
103
103
|
void this.applyDefaultsAndInitialFilter();
|
|
104
104
|
|
|
105
|
-
//
|
|
105
|
+
// 监听页面区块删除,自动清理已失效的筛选字段。
|
|
106
|
+
// 这里使用 onSubModelDestroyed 而不是 onSubModelRemoved,避免弹窗关闭时
|
|
107
|
+
// 的临时模型卸载被误判成“用户删除了目标区块”。
|
|
106
108
|
const blockGridModel = this.context.blockGridModel;
|
|
107
109
|
if (blockGridModel?.emitter) {
|
|
108
|
-
const
|
|
110
|
+
const handleTargetDestroyed = (model) => {
|
|
109
111
|
if (!model?.uid || model.uid === this.uid) return;
|
|
110
|
-
this.handleTargetBlockRemoved(model.uid)
|
|
112
|
+
void this.handleTargetBlockRemoved(model.uid).catch((error) => {
|
|
113
|
+
console.error('Failed to handle destroyed target block in FilterFormBlockModel:', error);
|
|
114
|
+
});
|
|
111
115
|
};
|
|
112
|
-
blockGridModel.emitter.on('
|
|
113
|
-
this.removeTargetBlockListener = () => blockGridModel.emitter.off('
|
|
116
|
+
blockGridModel.emitter.on('onSubModelDestroyed', handleTargetDestroyed);
|
|
117
|
+
this.removeTargetBlockListener = () => blockGridModel.emitter.off('onSubModelDestroyed', handleTargetDestroyed);
|
|
114
118
|
}
|
|
115
119
|
}
|
|
116
120
|
|
|
@@ -0,0 +1,138 @@
|
|
|
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 '@nocobase/client';
|
|
12
|
+
import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
|
|
13
|
+
import { waitFor } from '@testing-library/react';
|
|
14
|
+
import { TableBlockModel } from '../../table/TableBlockModel';
|
|
15
|
+
import { FilterFormBlockModel } from '../FilterFormBlockModel';
|
|
16
|
+
import { FilterFormGridModel } from '../FilterFormGridModel';
|
|
17
|
+
import { FilterFormItemModel } from '../FilterFormItemModel';
|
|
18
|
+
|
|
19
|
+
describe('FilterFormBlockModel cleanup', () => {
|
|
20
|
+
function createFilterFormSetup() {
|
|
21
|
+
const engine = new FlowEngine();
|
|
22
|
+
engine.registerModels({
|
|
23
|
+
FlowModel,
|
|
24
|
+
TableBlockModel,
|
|
25
|
+
FilterFormBlockModel,
|
|
26
|
+
FilterFormGridModel,
|
|
27
|
+
FilterFormItemModel,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const blockGridModel = engine.createModel<FlowModel>({
|
|
31
|
+
uid: 'block-grid',
|
|
32
|
+
use: 'FlowModel',
|
|
33
|
+
subModels: {
|
|
34
|
+
items: [],
|
|
35
|
+
},
|
|
36
|
+
} as any);
|
|
37
|
+
|
|
38
|
+
const tableBlock = blockGridModel.addSubModel('items', {
|
|
39
|
+
uid: 'target-table',
|
|
40
|
+
use: 'TableBlockModel',
|
|
41
|
+
}) as TableBlockModel;
|
|
42
|
+
|
|
43
|
+
const filterForm = blockGridModel.addSubModel('items', {
|
|
44
|
+
uid: 'filter-form',
|
|
45
|
+
use: 'FilterFormBlockModel',
|
|
46
|
+
subModels: {
|
|
47
|
+
grid: {
|
|
48
|
+
uid: 'filter-grid',
|
|
49
|
+
use: 'FilterFormGridModel',
|
|
50
|
+
subModels: {
|
|
51
|
+
items: [],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}) as FilterFormBlockModel;
|
|
56
|
+
|
|
57
|
+
const filterItem = filterForm.subModels.grid.addSubModel('items', {
|
|
58
|
+
uid: 'filter-item',
|
|
59
|
+
use: 'FilterFormItemModel',
|
|
60
|
+
stepParams: {
|
|
61
|
+
filterFormItemSettings: {
|
|
62
|
+
init: {
|
|
63
|
+
defaultTargetUid: tableBlock.uid,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}) as FilterFormItemModel;
|
|
68
|
+
|
|
69
|
+
const removeFilterConfig = vi.fn(async () => {});
|
|
70
|
+
const saveConnectFieldsConfig = vi.fn(async () => {});
|
|
71
|
+
const getConnectFieldsConfig = vi.fn(() => ({
|
|
72
|
+
targets: [
|
|
73
|
+
{
|
|
74
|
+
targetId: tableBlock.uid,
|
|
75
|
+
filterPaths: ['name'],
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
filterForm.context.defineProperty('blockGridModel', { value: blockGridModel });
|
|
81
|
+
filterForm.context.defineProperty('filterManager', {
|
|
82
|
+
value: {
|
|
83
|
+
removeFilterConfig,
|
|
84
|
+
saveConnectFieldsConfig,
|
|
85
|
+
getConnectFieldsConfig,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
filterForm.subModels.grid.context.defineProperty('filterManager', {
|
|
89
|
+
value: {
|
|
90
|
+
removeFilterConfig,
|
|
91
|
+
saveConnectFieldsConfig,
|
|
92
|
+
getConnectFieldsConfig,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const destroySpy = vi.spyOn(filterItem, 'destroy').mockResolvedValue(true as any);
|
|
97
|
+
vi.spyOn(filterForm as any, 'applyDefaultsAndInitialFilter').mockResolvedValue(undefined);
|
|
98
|
+
|
|
99
|
+
(filterForm as any).onMount();
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
engine,
|
|
103
|
+
blockGridModel,
|
|
104
|
+
tableBlock,
|
|
105
|
+
filterForm,
|
|
106
|
+
filterItem,
|
|
107
|
+
removeFilterConfig,
|
|
108
|
+
saveConnectFieldsConfig,
|
|
109
|
+
getConnectFieldsConfig,
|
|
110
|
+
destroySpy,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
it('does not remove filter items when target block is only removed during popup teardown', async () => {
|
|
115
|
+
const { engine, blockGridModel, tableBlock, filterForm, removeFilterConfig, destroySpy } = createFilterFormSetup();
|
|
116
|
+
|
|
117
|
+
await Promise.resolve(engine.removeModelWithSubModels(blockGridModel.uid));
|
|
118
|
+
|
|
119
|
+
expect(removeFilterConfig).not.toHaveBeenCalled();
|
|
120
|
+
expect(destroySpy).not.toHaveBeenCalled();
|
|
121
|
+
|
|
122
|
+
(filterForm as any).onUnmount();
|
|
123
|
+
expect(engine.getModel(tableBlock.uid)).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('removes filter items when target block is actually destroyed', async () => {
|
|
127
|
+
const { tableBlock, filterForm, removeFilterConfig, destroySpy } = createFilterFormSetup();
|
|
128
|
+
|
|
129
|
+
await tableBlock.destroy();
|
|
130
|
+
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(removeFilterConfig).toHaveBeenCalledWith({ filterId: 'filter-item' });
|
|
133
|
+
expect(destroySpy).toHaveBeenCalledTimes(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
(filterForm as any).onUnmount();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -85,6 +85,7 @@ async function setupFormModel() {
|
|
|
85
85
|
{ name: 'assignees', type: 'belongsToMany', target: 'users', interface: 'm2m' },
|
|
86
86
|
{ name: 'note', type: 'string', interface: 'text' },
|
|
87
87
|
{ name: 'status', type: 'string', interface: 'text' },
|
|
88
|
+
{ name: 'rawPayload', type: 'json', filterable: true },
|
|
88
89
|
],
|
|
89
90
|
});
|
|
90
91
|
|
|
@@ -283,6 +284,27 @@ describe('FormBlockModel (form/formValues injection & server resolve anchors)',
|
|
|
283
284
|
expect(params.note).toBeUndefined();
|
|
284
285
|
});
|
|
285
286
|
|
|
287
|
+
it('keeps interfaced fields in formValues meta even when they are not configured in the form grid', async () => {
|
|
288
|
+
const model = await setupFormModel();
|
|
289
|
+
|
|
290
|
+
function HookCaller() {
|
|
291
|
+
model.useHooksBeforeRender();
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
render(React.createElement(HookCaller));
|
|
295
|
+
mockFormGridEnabledFields(model, ['customer', 'note']);
|
|
296
|
+
|
|
297
|
+
const opt = (model.context as any).getPropertyOptions('formValues');
|
|
298
|
+
const meta = await opt.meta();
|
|
299
|
+
const props = await meta.properties();
|
|
300
|
+
|
|
301
|
+
expect(props).toHaveProperty('customer');
|
|
302
|
+
expect(props).toHaveProperty('note');
|
|
303
|
+
expect(props).toHaveProperty('status');
|
|
304
|
+
expect(props).toHaveProperty('assignees');
|
|
305
|
+
expect(props).not.toHaveProperty('rawPayload');
|
|
306
|
+
});
|
|
307
|
+
|
|
286
308
|
it('registers formValuesChange event and eventSettings flow', async () => {
|
|
287
309
|
const engine = new FlowEngine();
|
|
288
310
|
const TestFormModel = await createTestFormModelSubclass();
|
|
@@ -30,6 +30,27 @@ import { TableCustomColumnModel } from './TableCustomColumnModel';
|
|
|
30
30
|
import { CodeEditor } from '../../../components/code-editor';
|
|
31
31
|
import { resolveRunJsParams } from '../../utils/resolveRunJsParams';
|
|
32
32
|
|
|
33
|
+
function getRecordRenderSignature(record: any) {
|
|
34
|
+
if (!record || typeof record !== 'object') {
|
|
35
|
+
return String(record);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const seen = new WeakSet();
|
|
40
|
+
return JSON.stringify(record, (_key, value) => {
|
|
41
|
+
if (value && typeof value === 'object') {
|
|
42
|
+
if (seen.has(value)) {
|
|
43
|
+
return '[Circular]';
|
|
44
|
+
}
|
|
45
|
+
seen.add(value);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return String(record);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
33
54
|
export class JSColumnModel extends TableCustomColumnModel {
|
|
34
55
|
// Stable per‑instance render component to avoid remounts across re-renders
|
|
35
56
|
private _RenderComponent?: React.ComponentType;
|
|
@@ -113,7 +134,13 @@ export class JSColumnModel extends TableCustomColumnModel {
|
|
|
113
134
|
// 使用记录主键作为 fork key,避免分页后 index 复用导致 fork 复用
|
|
114
135
|
const tk = this.context.collection?.getFilterByTK?.(record);
|
|
115
136
|
const forkKey = tk ?? record?.id ?? index;
|
|
137
|
+
const recordSignature = getRecordRenderSignature(record);
|
|
116
138
|
const fork = this.createFork({}, String(forkKey));
|
|
139
|
+
const previousRecordSignature = (fork as any).__recordRenderSignature;
|
|
140
|
+
if (previousRecordSignature !== recordSignature) {
|
|
141
|
+
(fork as any).__recordRenderSignature = recordSignature;
|
|
142
|
+
fork.invalidateFlowCache('beforeRender');
|
|
143
|
+
}
|
|
117
144
|
const recordMeta: PropertyMetaFactory = createRecordMetaFactory(
|
|
118
145
|
() => fork.context.collection,
|
|
119
146
|
fork.context.t('Current record'),
|
|
@@ -137,7 +164,7 @@ export class JSColumnModel extends TableCustomColumnModel {
|
|
|
137
164
|
fork.context.defineProperty('recordIndex', {
|
|
138
165
|
get: () => index,
|
|
139
166
|
});
|
|
140
|
-
return <MemoFlowModelRenderer key={fork.uid} model={fork} />;
|
|
167
|
+
return <MemoFlowModelRenderer key={`${fork.uid}:${recordSignature}`} model={fork} />;
|
|
141
168
|
},
|
|
142
169
|
};
|
|
143
170
|
}
|
|
@@ -264,7 +291,8 @@ JSColumnModel.registerFlow({
|
|
|
264
291
|
|
|
265
292
|
ctx.onRefReady(ctx.ref, async (element) => {
|
|
266
293
|
ctx.defineProperty('element', {
|
|
267
|
-
get: () => new ElementProxy(element),
|
|
294
|
+
get: () => new ElementProxy((ctx.ref?.current as HTMLElement | null) || element),
|
|
295
|
+
cache: false,
|
|
268
296
|
});
|
|
269
297
|
const navigator = createSafeNavigator();
|
|
270
298
|
await ctx.runjs(
|