@nocobase/client-v2 2.2.0-beta.5 → 2.2.0-beta.6
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/RouteRepository.d.ts +21 -6
- package/es/components/form/JsonTextArea.d.ts +2 -1
- package/es/components/form/VariableJsonTextArea.d.ts +19 -0
- package/es/components/form/index.d.ts +1 -0
- package/es/components/index.d.ts +1 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
- package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +4 -0
- package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
- package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
- package/es/flow/models/base/GridModel.d.ts +1 -1
- package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
- package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
- package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
- package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
- package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
- package/es/flow-compat/FieldValidation.d.ts +2 -1
- package/es/index.d.ts +2 -1
- package/es/index.mjs +143 -117
- package/lib/index.js +148 -122
- package/package.json +7 -7
- package/src/RouteRepository.ts +126 -24
- package/src/__tests__/RouteRepository.test.ts +216 -0
- package/src/__tests__/exports.test.ts +16 -0
- package/src/__tests__/settings-center.test.tsx +33 -0
- package/src/components/form/JsonTextArea.tsx +21 -11
- package/src/components/form/VariableJsonTextArea.tsx +175 -0
- package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
- package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
- package/src/components/form/index.tsx +1 -0
- package/src/components/index.ts +1 -0
- package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
- package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
- package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
- package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
- package/src/flow/actions/customVariable.tsx +1 -2
- package/src/flow/actions/linkageRules.tsx +122 -34
- package/src/flow/actions/runjs.tsx +2 -14
- package/src/flow/actions/validation.tsx +62 -30
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +33 -10
- package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
- package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
- package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +21 -8
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
- package/src/flow/components/DefaultValue.tsx +1 -2
- package/src/flow/components/FieldAssignRulesEditor.tsx +62 -12
- package/src/flow/components/FlowRoute.tsx +56 -11
- package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
- package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
- package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
- package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
- package/src/flow/models/actions/JSActionModel.tsx +2 -7
- package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
- package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
- package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
- package/src/flow/models/base/ActionModel.tsx +12 -1
- package/src/flow/models/base/GridModel.tsx +38 -7
- package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
- package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
- package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
- package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
- package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +31 -3
- package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
- package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
- package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
- package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
- package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
- package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
- package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
- package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +1 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +13 -3
- package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +54 -0
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
- package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
- package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
- package/src/flow/models/fields/InputFieldModel.tsx +14 -22
- package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
- package/src/flow/models/fields/JSFieldModel.tsx +2 -7
- package/src/flow/models/fields/JSItemModel.tsx +2 -14
- package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
- package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
- package/src/flow-compat/FieldValidation.tsx +122 -60
- package/src/flow-compat/Popover.tsx +43 -4
- package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
- package/src/index.ts +8 -1
- package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
- package/src/settings-center/AdminSettingsLayout.tsx +3 -0
- package/src/settings-center/plugin-manager/index.tsx +3 -0
|
@@ -0,0 +1,83 @@
|
|
|
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, FlowRuntimeContext } from '@nocobase/flow-engine';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { ActionModel } from '../ActionModel';
|
|
13
|
+
|
|
14
|
+
class IconActionModel extends ActionModel {
|
|
15
|
+
defaultProps = {
|
|
16
|
+
type: 'default' as const,
|
|
17
|
+
title: 'Icon action',
|
|
18
|
+
icon: 'PlusOutlined',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class PlainActionModel extends ActionModel {
|
|
23
|
+
defaultProps = {
|
|
24
|
+
type: 'default' as const,
|
|
25
|
+
title: 'Plain action',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getButtonSettingsDefaultParams(model: ActionModel) {
|
|
30
|
+
const defaultParams = model.getFlow('buttonSettings')?.getStep('general')?.defaultParams;
|
|
31
|
+
if (typeof defaultParams !== 'function') {
|
|
32
|
+
throw new Error('buttonSettings.general.defaultParams is unavailable.');
|
|
33
|
+
}
|
|
34
|
+
return defaultParams(new FlowRuntimeContext(model, 'buttonSettings', 'settings'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('ActionModel button settings', () => {
|
|
38
|
+
it('uses icon-only buttons by default for mobile icon actions', () => {
|
|
39
|
+
const engine = new FlowEngine();
|
|
40
|
+
engine.registerModels({ IconActionModel });
|
|
41
|
+
const model = engine.createModel<IconActionModel>({
|
|
42
|
+
use: 'IconActionModel',
|
|
43
|
+
uid: 'mobile-icon-action',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
model.context.defineProperty('isMobileLayout', { value: true });
|
|
47
|
+
|
|
48
|
+
expect(getButtonSettingsDefaultParams(model)).toMatchObject({
|
|
49
|
+
icon: 'PlusOutlined',
|
|
50
|
+
iconOnly: true,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keeps button titles visible by default outside mobile layouts', () => {
|
|
55
|
+
const engine = new FlowEngine();
|
|
56
|
+
engine.registerModels({ IconActionModel });
|
|
57
|
+
const model = engine.createModel<IconActionModel>({
|
|
58
|
+
use: 'IconActionModel',
|
|
59
|
+
uid: 'desktop-icon-action',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(getButtonSettingsDefaultParams(model)).toMatchObject({
|
|
63
|
+
icon: 'PlusOutlined',
|
|
64
|
+
});
|
|
65
|
+
expect(getButtonSettingsDefaultParams(model)).not.toHaveProperty('iconOnly', true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not force icon-only buttons for mobile actions without icons', () => {
|
|
69
|
+
const engine = new FlowEngine();
|
|
70
|
+
engine.registerModels({ PlainActionModel });
|
|
71
|
+
const model = engine.createModel<PlainActionModel>({
|
|
72
|
+
use: 'PlainActionModel',
|
|
73
|
+
uid: 'mobile-plain-action',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
model.context.defineProperty('isMobileLayout', { value: true });
|
|
77
|
+
|
|
78
|
+
expect(getButtonSettingsDefaultParams(model)).toMatchObject({
|
|
79
|
+
title: 'Plain action',
|
|
80
|
+
});
|
|
81
|
+
expect(getButtonSettingsDefaultParams(model)).not.toHaveProperty('iconOnly', true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { FlowEngine } from '@nocobase/flow-engine';
|
|
11
11
|
import React from 'react';
|
|
12
12
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
-
import { GridModel } from '../GridModel';
|
|
13
|
+
import { GridModel, transformRowsToSingleColumn } from '../GridModel';
|
|
14
14
|
|
|
15
15
|
const createMockRect = ({ top, left, width, height }: { top: number; left: number; width: number; height: number }) => {
|
|
16
16
|
return {
|
|
@@ -66,6 +66,83 @@ const createGridContainer = () => {
|
|
|
66
66
|
return container;
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
+
const createGridContainerWithEmptyColumn = () => {
|
|
70
|
+
const container = document.createElement('div');
|
|
71
|
+
container.setAttribute('data-grid-root', '');
|
|
72
|
+
const row = document.createElement('div');
|
|
73
|
+
row.setAttribute('data-grid-row-id', 'row-1');
|
|
74
|
+
container.appendChild(row);
|
|
75
|
+
|
|
76
|
+
const firstColumn = document.createElement('div');
|
|
77
|
+
firstColumn.setAttribute('data-grid-column-row-id', 'row-1');
|
|
78
|
+
firstColumn.setAttribute('data-grid-column-index', '0');
|
|
79
|
+
row.appendChild(firstColumn);
|
|
80
|
+
|
|
81
|
+
const item = document.createElement('div');
|
|
82
|
+
item.setAttribute('data-grid-item-row-id', 'row-1');
|
|
83
|
+
item.setAttribute('data-grid-column-index', '0');
|
|
84
|
+
item.setAttribute('data-grid-item-index', '0');
|
|
85
|
+
item.setAttribute('data-grid-item-uid', 'item-1');
|
|
86
|
+
firstColumn.appendChild(item);
|
|
87
|
+
|
|
88
|
+
const secondColumn = document.createElement('div');
|
|
89
|
+
secondColumn.setAttribute('data-grid-column-row-id', 'row-1');
|
|
90
|
+
secondColumn.setAttribute('data-grid-column-index', '1');
|
|
91
|
+
row.appendChild(secondColumn);
|
|
92
|
+
|
|
93
|
+
mockRect(container, { top: 0, left: 0, width: 480, height: 280 });
|
|
94
|
+
mockRect(row, { top: 20, left: 20, width: 440, height: 120 });
|
|
95
|
+
mockRect(firstColumn, { top: 20, left: 20, width: 220, height: 120 });
|
|
96
|
+
mockRect(item, { top: 30, left: 30, width: 200, height: 100 });
|
|
97
|
+
mockRect(secondColumn, { top: 20, left: 260, width: 200, height: 120 });
|
|
98
|
+
return container;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const createProjectedMobileGridContainer = (rows: Record<string, string[][]>) => {
|
|
102
|
+
const container = document.createElement('div');
|
|
103
|
+
container.setAttribute('data-grid-root', '');
|
|
104
|
+
mockRect(container, { top: 0, left: 0, width: 480, height: 320 });
|
|
105
|
+
|
|
106
|
+
let top = 20;
|
|
107
|
+
Object.entries(transformRowsToSingleColumn(rows)).forEach(([rowId, columns]) => {
|
|
108
|
+
const row = document.createElement('div');
|
|
109
|
+
row.setAttribute('data-grid-row-id', rowId);
|
|
110
|
+
container.appendChild(row);
|
|
111
|
+
|
|
112
|
+
const column = document.createElement('div');
|
|
113
|
+
column.setAttribute('data-grid-column-row-id', rowId);
|
|
114
|
+
column.setAttribute('data-grid-column-index', '0');
|
|
115
|
+
row.appendChild(column);
|
|
116
|
+
|
|
117
|
+
const items = columns[0] || [];
|
|
118
|
+
items.forEach((itemUid, itemIndex) => {
|
|
119
|
+
const item = document.createElement('div');
|
|
120
|
+
item.setAttribute('data-grid-item-row-id', rowId);
|
|
121
|
+
item.setAttribute('data-grid-column-index', '0');
|
|
122
|
+
item.setAttribute('data-grid-item-index', String(itemIndex));
|
|
123
|
+
item.setAttribute('data-grid-item-uid', itemUid);
|
|
124
|
+
column.appendChild(item);
|
|
125
|
+
mockRect(item, { top: top + 10 + itemIndex * 36, left: 30, width: 420, height: 32 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
mockRect(row, { top, left: 20, width: 440, height: 52 });
|
|
129
|
+
mockRect(column, { top, left: 20, width: 440, height: 52 });
|
|
130
|
+
top += 72;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return container;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const setMobileGridItems = (engine: FlowEngine, model: GridModel, itemUids: string[]) => {
|
|
137
|
+
(model as any).subModels = {
|
|
138
|
+
items: itemUids.map((itemUid) => engine.createModel({ use: 'FlowModel', uid: itemUid })),
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const getSavedTopLevelItemOrder = (model: GridModel) => {
|
|
143
|
+
return Object.values(model.props.rows || {}).map((columns: string[][]) => columns[0]?.[0]);
|
|
144
|
+
};
|
|
145
|
+
|
|
69
146
|
describe('GridModel drag snapshot container', () => {
|
|
70
147
|
let engine: FlowEngine;
|
|
71
148
|
|
|
@@ -106,6 +183,167 @@ describe('GridModel drag snapshot container', () => {
|
|
|
106
183
|
model.handleDragCancel({} as any);
|
|
107
184
|
});
|
|
108
185
|
|
|
186
|
+
it('filters multi-column drop slots from mobile drag snapshots', () => {
|
|
187
|
+
const model = engine.createModel<GridModel>({
|
|
188
|
+
use: 'GridModel',
|
|
189
|
+
uid: 'grid-mobile-drag-slots',
|
|
190
|
+
props: {
|
|
191
|
+
rows: {
|
|
192
|
+
'row-1': [['item-1'], []],
|
|
193
|
+
},
|
|
194
|
+
sizes: {
|
|
195
|
+
'row-1': [12, 12],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
structure: {} as any,
|
|
199
|
+
});
|
|
200
|
+
model.context.defineProperty('isMobileLayout', { value: true });
|
|
201
|
+
|
|
202
|
+
const container = createGridContainerWithEmptyColumn();
|
|
203
|
+
(model.gridContainerRef as any).current = container;
|
|
204
|
+
|
|
205
|
+
model.handleDragStart({
|
|
206
|
+
active: { id: 'item-1' },
|
|
207
|
+
activatorEvent: { clientX: 80, clientY: 80 },
|
|
208
|
+
} as any);
|
|
209
|
+
|
|
210
|
+
const slotTypes = ((model as any).dragState?.slots || []).map((slot) => slot.type);
|
|
211
|
+
expect(slotTypes).toContain('column');
|
|
212
|
+
expect(slotTypes).toContain('row-gap');
|
|
213
|
+
expect(slotTypes).not.toContain('column-edge');
|
|
214
|
+
expect(slotTypes).not.toContain('item-edge');
|
|
215
|
+
expect(slotTypes).not.toContain('empty-column');
|
|
216
|
+
model.handleDragCancel({} as any);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('keeps empty-row slot available for the first mobile grid item', () => {
|
|
220
|
+
const model = engine.createModel<GridModel>({
|
|
221
|
+
use: 'GridModel',
|
|
222
|
+
uid: 'grid-mobile-empty-drag-slots',
|
|
223
|
+
props: {},
|
|
224
|
+
structure: {} as any,
|
|
225
|
+
});
|
|
226
|
+
model.context.defineProperty('isMobileLayout', { value: true });
|
|
227
|
+
|
|
228
|
+
const container = document.createElement('div');
|
|
229
|
+
mockRect(container, { top: 0, left: 0, width: 480, height: 280 });
|
|
230
|
+
(model.gridContainerRef as any).current = container;
|
|
231
|
+
|
|
232
|
+
model.handleDragStart({
|
|
233
|
+
active: { id: 'item-1' },
|
|
234
|
+
activatorEvent: { clientX: 80, clientY: 80 },
|
|
235
|
+
} as any);
|
|
236
|
+
|
|
237
|
+
expect(((model as any).dragState?.slots || []).map((slot) => slot.type)).toEqual(['empty-row']);
|
|
238
|
+
model.handleDragCancel({} as any);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('saves mobile vertical sorting when moving the second item above the first one', () => {
|
|
242
|
+
const rows = {
|
|
243
|
+
'row-a': [['item-a']],
|
|
244
|
+
'row-b': [['item-b']],
|
|
245
|
+
};
|
|
246
|
+
const model = engine.createModel<GridModel>({
|
|
247
|
+
use: 'GridModel',
|
|
248
|
+
uid: 'grid-mobile-sort-second-above-first',
|
|
249
|
+
props: {
|
|
250
|
+
rows,
|
|
251
|
+
sizes: {
|
|
252
|
+
'row-a': [24],
|
|
253
|
+
'row-b': [24],
|
|
254
|
+
},
|
|
255
|
+
rowOrder: ['row-a', 'row-b'],
|
|
256
|
+
},
|
|
257
|
+
structure: {} as any,
|
|
258
|
+
});
|
|
259
|
+
model.context.defineProperty('isMobileLayout', { value: true });
|
|
260
|
+
setMobileGridItems(engine, model, ['item-a', 'item-b']);
|
|
261
|
+
(model as any).saveStepParams = vi.fn();
|
|
262
|
+
|
|
263
|
+
const container = createProjectedMobileGridContainer(rows);
|
|
264
|
+
(model.gridContainerRef as any).current = container;
|
|
265
|
+
|
|
266
|
+
model.handleDragStart({
|
|
267
|
+
active: { id: 'item-b' },
|
|
268
|
+
activatorEvent: { clientX: 240, clientY: 118 },
|
|
269
|
+
} as any);
|
|
270
|
+
model.handleDragEnd({
|
|
271
|
+
delta: { x: 0, y: -112 },
|
|
272
|
+
} as any);
|
|
273
|
+
|
|
274
|
+
expect(getSavedTopLevelItemOrder(model)).toEqual(['item-b', 'item-a']);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('saves mobile vertical sorting when moving the first item below the last one', () => {
|
|
278
|
+
const rows = {
|
|
279
|
+
'row-a': [['item-a']],
|
|
280
|
+
'row-b': [['item-b']],
|
|
281
|
+
'row-c': [['item-c']],
|
|
282
|
+
};
|
|
283
|
+
const model = engine.createModel<GridModel>({
|
|
284
|
+
use: 'GridModel',
|
|
285
|
+
uid: 'grid-mobile-sort-first-below-last',
|
|
286
|
+
props: {
|
|
287
|
+
rows,
|
|
288
|
+
sizes: {
|
|
289
|
+
'row-a': [24],
|
|
290
|
+
'row-b': [24],
|
|
291
|
+
'row-c': [24],
|
|
292
|
+
},
|
|
293
|
+
rowOrder: ['row-a', 'row-b', 'row-c'],
|
|
294
|
+
},
|
|
295
|
+
structure: {} as any,
|
|
296
|
+
});
|
|
297
|
+
model.context.defineProperty('isMobileLayout', { value: true });
|
|
298
|
+
setMobileGridItems(engine, model, ['item-a', 'item-b', 'item-c']);
|
|
299
|
+
(model as any).saveStepParams = vi.fn();
|
|
300
|
+
|
|
301
|
+
const container = createProjectedMobileGridContainer(rows);
|
|
302
|
+
(model.gridContainerRef as any).current = container;
|
|
303
|
+
|
|
304
|
+
model.handleDragStart({
|
|
305
|
+
active: { id: 'item-a' },
|
|
306
|
+
activatorEvent: { clientX: 240, clientY: 46 },
|
|
307
|
+
} as any);
|
|
308
|
+
model.handleDragEnd({
|
|
309
|
+
delta: { x: 0, y: 184 },
|
|
310
|
+
} as any);
|
|
311
|
+
|
|
312
|
+
expect(getSavedTopLevelItemOrder(model)).toEqual(['item-b', 'item-c', 'item-a']);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('does not filter multi-column drop slots from desktop drag snapshots', () => {
|
|
316
|
+
const model = engine.createModel<GridModel>({
|
|
317
|
+
use: 'GridModel',
|
|
318
|
+
uid: 'grid-desktop-drag-slots',
|
|
319
|
+
props: {
|
|
320
|
+
rows: {
|
|
321
|
+
'row-1': [['item-1'], []],
|
|
322
|
+
},
|
|
323
|
+
sizes: {
|
|
324
|
+
'row-1': [12, 12],
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
structure: {} as any,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const container = createGridContainerWithEmptyColumn();
|
|
331
|
+
(model.gridContainerRef as any).current = container;
|
|
332
|
+
|
|
333
|
+
model.handleDragStart({
|
|
334
|
+
active: { id: 'item-1' },
|
|
335
|
+
activatorEvent: { clientX: 80, clientY: 80 },
|
|
336
|
+
} as any);
|
|
337
|
+
|
|
338
|
+
const slotTypes = ((model as any).dragState?.slots || []).map((slot) => slot.type);
|
|
339
|
+
expect(slotTypes).toContain('column');
|
|
340
|
+
expect(slotTypes).toContain('row-gap');
|
|
341
|
+
expect(slotTypes).toContain('column-edge');
|
|
342
|
+
expect(slotTypes).toContain('item-edge');
|
|
343
|
+
expect(slotTypes).toContain('empty-column');
|
|
344
|
+
model.handleDragCancel({} as any);
|
|
345
|
+
});
|
|
346
|
+
|
|
109
347
|
it('uses native pointer position instead of scroll-adjusted drag delta', () => {
|
|
110
348
|
const model = engine.createModel<GridModel>({
|
|
111
349
|
use: 'GridModel',
|
|
@@ -35,6 +35,36 @@ describe('transformRowsToSingleColumn', () => {
|
|
|
35
35
|
expect(all).toEqual([['a'], ['b', 'c'], ['d'], ['e']]);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
it('should preserve row ids for ordinary single-column mobile rows', () => {
|
|
39
|
+
const rows = {
|
|
40
|
+
row_a: [['a']],
|
|
41
|
+
row_b: [['b']],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
expect(transformRowsToSingleColumn(rows)).toEqual(rows);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should avoid adding another mobile prefix to ordinary single-column projected rows', () => {
|
|
48
|
+
const rows = {
|
|
49
|
+
'mobile:row_a:0': [['a']],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
expect(Object.keys(transformRowsToSingleColumn(rows))).toEqual(['mobile:row_a:0']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should keep split multi-column row keys stable and unique', () => {
|
|
56
|
+
const rows = {
|
|
57
|
+
row_a: [['a'], ['b']],
|
|
58
|
+
};
|
|
59
|
+
const result = transformRowsToSingleColumn(rows);
|
|
60
|
+
const keys = Object.keys(result);
|
|
61
|
+
|
|
62
|
+
expect(keys).toHaveLength(2);
|
|
63
|
+
expect(new Set(keys).size).toBe(2);
|
|
64
|
+
expect(keys.every((key) => key.startsWith('mobile:'))).toBe(true);
|
|
65
|
+
expect(Object.values(result).map((row) => row[0])).toEqual([['a'], ['b']]);
|
|
66
|
+
});
|
|
67
|
+
|
|
38
68
|
it('should skip columns that only contain EMPTY_COLUMN_UID', () => {
|
|
39
69
|
const rows = buildRows([[[EMPTY_COLUMN_UID], ['a']]]);
|
|
40
70
|
const result = transformRowsToSingleColumn(rows);
|
|
@@ -55,4 +85,22 @@ describe('transformRowsToSingleColumn', () => {
|
|
|
55
85
|
const all = Object.values(result).map((r) => r[0]);
|
|
56
86
|
expect(all).toEqual([['x'], ['y']]);
|
|
57
87
|
});
|
|
88
|
+
|
|
89
|
+
it('should keep generated row keys stable for the same input', () => {
|
|
90
|
+
const rows = buildRows([
|
|
91
|
+
[['a'], ['b']],
|
|
92
|
+
[['c'], ['d']],
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
expect(Object.keys(transformRowsToSingleColumn(rows))).toEqual(Object.keys(transformRowsToSingleColumn(rows)));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should keep existing row keys stable when appending new mobile rows', () => {
|
|
99
|
+
const before = buildRows([[['a'], ['b']]]);
|
|
100
|
+
const after = buildRows([[['a'], ['b']], [['c']]]);
|
|
101
|
+
const beforeKeys = Object.keys(transformRowsToSingleColumn(before));
|
|
102
|
+
const afterKeys = Object.keys(transformRowsToSingleColumn(after));
|
|
103
|
+
|
|
104
|
+
expect(afterKeys.slice(0, beforeKeys.length)).toEqual(beforeKeys);
|
|
105
|
+
});
|
|
58
106
|
});
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
Droppable,
|
|
18
18
|
isRunJSValue,
|
|
19
19
|
normalizeRunJSValue,
|
|
20
|
-
runjsWithSafeGlobals,
|
|
21
20
|
tExpr,
|
|
22
21
|
FlowModelRenderer,
|
|
23
22
|
FlowSettingsButton,
|
|
@@ -231,6 +230,7 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
231
230
|
private initialDefaultsPromise?: Promise<void>;
|
|
232
231
|
private initialRefreshHandledTargetIds = new Set<string>();
|
|
233
232
|
private lastDefaultValueByFieldName = new Map<string, any>();
|
|
233
|
+
private userEditedFieldNames = new Set<string>();
|
|
234
234
|
private defaultValuesRefreshSeq = 0;
|
|
235
235
|
|
|
236
236
|
get form() {
|
|
@@ -436,6 +436,28 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
436
436
|
return isEqual(current, this.lastDefaultValueByFieldName.get(name));
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
+
private canApplyFormOverrideValue(name: string, force?: boolean) {
|
|
440
|
+
if (force) return true;
|
|
441
|
+
return !this.userEditedFieldNames.has(name);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private normalizeFieldValueMode(mode: unknown): 'default' | 'assign' | 'override' {
|
|
445
|
+
if (mode === 'assign') return 'assign';
|
|
446
|
+
if (mode === 'override') return 'override';
|
|
447
|
+
return 'default';
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private markFilterFormUserEditedFields(changedValues: any) {
|
|
451
|
+
if (!changedValues || typeof changedValues !== 'object' || Array.isArray(changedValues)) return;
|
|
452
|
+
for (const name of Object.keys(changedValues)) {
|
|
453
|
+
this.userEditedFieldNames.add(String(name));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private resetFilterFormUserEditedFields() {
|
|
458
|
+
this.userEditedFieldNames.clear();
|
|
459
|
+
}
|
|
460
|
+
|
|
439
461
|
private async matchDefaultValueCondition(condition: any) {
|
|
440
462
|
if (!condition) return true;
|
|
441
463
|
|
|
@@ -458,6 +480,10 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
458
480
|
if (!form) return appliedValues;
|
|
459
481
|
|
|
460
482
|
const force = options?.force === true;
|
|
483
|
+
if (force) {
|
|
484
|
+
this.resetFilterFormUserEditedFields();
|
|
485
|
+
}
|
|
486
|
+
|
|
461
487
|
const params = this.getStepParams?.('formFilterBlockModelSettings', 'defaultValues');
|
|
462
488
|
const rules = (params?.value || []) as any[];
|
|
463
489
|
if (!Array.isArray(rules) || rules.length === 0) return appliedValues;
|
|
@@ -466,7 +492,7 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
466
492
|
// RunJS support
|
|
467
493
|
if (isRunJSValue(raw)) {
|
|
468
494
|
const { code, version } = normalizeRunJSValue(raw);
|
|
469
|
-
const ret = await
|
|
495
|
+
const ret = await this.context.runjs(code, undefined, { version });
|
|
470
496
|
return ret?.success ? ret.value : undefined;
|
|
471
497
|
}
|
|
472
498
|
|
|
@@ -499,8 +525,9 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
499
525
|
|
|
500
526
|
const operator = getDefaultOperator(itemModel as any);
|
|
501
527
|
const normalized = normalizeFilterValueByOperator(operator, resolved);
|
|
502
|
-
const mode =
|
|
528
|
+
const mode = this.normalizeFieldValueMode(rule.mode);
|
|
503
529
|
if (mode === 'default' && !this.canApplyFormDefaultValue(String(name), current, force)) continue;
|
|
530
|
+
if (mode === 'override' && !this.canApplyFormOverrideValue(String(name), force)) continue;
|
|
504
531
|
if (isEqual(current, normalized)) {
|
|
505
532
|
if (mode === 'default') {
|
|
506
533
|
this.lastDefaultValueByFieldName.set(String(name), normalized);
|
|
@@ -527,6 +554,7 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
527
554
|
}
|
|
528
555
|
|
|
529
556
|
private handleFilterFormValuesChange(changedValues: any, allValues: any) {
|
|
557
|
+
this.markFilterFormUserEditedFields(changedValues);
|
|
530
558
|
const refreshSeq = ++this.defaultValuesRefreshSeq;
|
|
531
559
|
void (async () => {
|
|
532
560
|
const appliedValues = await this.applyFormDefaultValues({ refreshSeq });
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { tExpr } from '@nocobase/flow-engine';
|
|
11
11
|
import { CodeEditor } from '../../../components/code-editor';
|
|
12
12
|
import { FilterFormActionModel } from './FilterFormActionModel';
|
|
13
13
|
import { resolveRunJsParams } from '../../utils/resolveRunJsParams';
|
|
@@ -60,12 +60,7 @@ FilterFormJSActionModel.registerFlow({
|
|
|
60
60
|
},
|
|
61
61
|
async handler(ctx, params) {
|
|
62
62
|
const { code, version } = resolveRunJsParams(ctx, params);
|
|
63
|
-
|
|
64
|
-
await ctx.runjs(
|
|
65
|
-
code,
|
|
66
|
-
{ window: createSafeWindow({ navigator }), document: createSafeDocument(), navigator },
|
|
67
|
-
{ version },
|
|
68
|
-
);
|
|
63
|
+
await ctx.runjs(code, undefined, { version });
|
|
69
64
|
},
|
|
70
65
|
},
|
|
71
66
|
},
|
|
@@ -50,6 +50,7 @@ function createFilterFormDefaultValuesModel(rules: any[], initialValues: Record<
|
|
|
50
50
|
const model = {
|
|
51
51
|
defaultValuesRefreshSeq: 0,
|
|
52
52
|
lastDefaultValueByFieldName: new Map<string, any>(),
|
|
53
|
+
userEditedFieldNames: new Set<string>(),
|
|
53
54
|
form: {
|
|
54
55
|
getFieldsValue: () => ({ ...values }),
|
|
55
56
|
getFieldValue: (name: string) => values[name],
|
|
@@ -86,6 +87,10 @@ function createFilterFormDefaultValuesModel(rules: any[], initialValues: Record<
|
|
|
86
87
|
return undefined;
|
|
87
88
|
}),
|
|
88
89
|
canApplyFormDefaultValue: (FilterFormBlockModel.prototype as any).canApplyFormDefaultValue,
|
|
90
|
+
canApplyFormOverrideValue: (FilterFormBlockModel.prototype as any).canApplyFormOverrideValue,
|
|
91
|
+
normalizeFieldValueMode: (FilterFormBlockModel.prototype as any).normalizeFieldValueMode,
|
|
92
|
+
markFilterFormUserEditedFields: (FilterFormBlockModel.prototype as any).markFilterFormUserEditedFields,
|
|
93
|
+
resetFilterFormUserEditedFields: (FilterFormBlockModel.prototype as any).resetFilterFormUserEditedFields,
|
|
89
94
|
matchDefaultValueCondition: (FilterFormBlockModel.prototype as any).matchDefaultValueCondition,
|
|
90
95
|
applyFormDefaultValues: FilterFormBlockModel.prototype.applyFormDefaultValues,
|
|
91
96
|
handleFilterFormValuesChange: (FilterFormBlockModel.prototype as any).handleFilterFormValuesChange,
|
|
@@ -315,6 +320,78 @@ describe('filter-form defaultValues wiring', () => {
|
|
|
315
320
|
expect(values.username_user).toBe('Bob');
|
|
316
321
|
});
|
|
317
322
|
|
|
323
|
+
it('applies override values until the target filter field is changed by user', async () => {
|
|
324
|
+
const { model, values } = createFilterFormDefaultValuesModel(
|
|
325
|
+
[
|
|
326
|
+
{
|
|
327
|
+
key: 'username-override',
|
|
328
|
+
enable: true,
|
|
329
|
+
targetPath: 'username',
|
|
330
|
+
mode: 'override',
|
|
331
|
+
value: '{{ ctx.formValues.nickname_nick }}',
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
{ nickname_nick: 'Bob', username_user: 'Manual' },
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any);
|
|
338
|
+
expect(values.username_user).toBe('Bob');
|
|
339
|
+
|
|
340
|
+
values.nickname_nick = 'Carol';
|
|
341
|
+
model.defaultValuesRefreshSeq += 1;
|
|
342
|
+
await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any, {
|
|
343
|
+
refreshSeq: model.defaultValuesRefreshSeq,
|
|
344
|
+
});
|
|
345
|
+
expect(values.username_user).toBe('Carol');
|
|
346
|
+
|
|
347
|
+
values.username_user = 'User value';
|
|
348
|
+
(model as any).handleFilterFormValuesChange(
|
|
349
|
+
{ username_user: 'User value' },
|
|
350
|
+
{ nickname_nick: 'Carol', username_user: 'User value' },
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await waitFor(() => {
|
|
354
|
+
expect(values.username_user).toBe('User value');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
values.nickname_nick = 'Dora';
|
|
358
|
+
model.defaultValuesRefreshSeq += 1;
|
|
359
|
+
await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any, {
|
|
360
|
+
refreshSeq: model.defaultValuesRefreshSeq,
|
|
361
|
+
});
|
|
362
|
+
expect(values.username_user).toBe('User value');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('clears filter form user-edited override state on forced apply', async () => {
|
|
366
|
+
const { model, values } = createFilterFormDefaultValuesModel(
|
|
367
|
+
[
|
|
368
|
+
{
|
|
369
|
+
key: 'username-override',
|
|
370
|
+
enable: true,
|
|
371
|
+
targetPath: 'username',
|
|
372
|
+
mode: 'override',
|
|
373
|
+
condition: {
|
|
374
|
+
logic: '$and',
|
|
375
|
+
items: [{ path: '{{ ctx.formValues.nickname_nick }}', operator: '$eq', value: 'allow' }],
|
|
376
|
+
},
|
|
377
|
+
value: '{{ ctx.formValues.nickname_nick }}',
|
|
378
|
+
},
|
|
379
|
+
],
|
|
380
|
+
{ nickname_nick: 'deny', username_user: 'User value' },
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
model.userEditedFieldNames.add('username_user');
|
|
384
|
+
|
|
385
|
+
await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any, { force: true });
|
|
386
|
+
expect(values.username_user).toBe('User value');
|
|
387
|
+
expect(model.userEditedFieldNames.has('username_user')).toBe(false);
|
|
388
|
+
|
|
389
|
+
values.nickname_nick = 'allow';
|
|
390
|
+
await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any);
|
|
391
|
+
|
|
392
|
+
expect(values.username_user).toBe('allow');
|
|
393
|
+
});
|
|
394
|
+
|
|
318
395
|
it('skips filter form field values when the rule condition does not match', async () => {
|
|
319
396
|
const { model, values } = createFilterFormDefaultValuesModel(
|
|
320
397
|
[
|
|
@@ -166,6 +166,13 @@ export class FormBlockModel<
|
|
|
166
166
|
this.userModifiedTopLevelFields.clear();
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* @internal
|
|
171
|
+
*/
|
|
172
|
+
resetRuntimeUserEditedState(): void {
|
|
173
|
+
this.formValueRuntime?.resetUserEditedState?.();
|
|
174
|
+
}
|
|
175
|
+
|
|
169
176
|
public async onDispatchEventStart(eventName: string, options?: any, inputArgs?: Record<string, any>): Promise<void> {
|
|
170
177
|
if (eventName === 'beforeRender') {
|
|
171
178
|
const grid = this.subModels.grid;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
/**
|
|
11
11
|
* JS Form Action:表单工具栏按钮点击执行 JS。
|
|
12
12
|
*/
|
|
13
|
-
import { tExpr
|
|
13
|
+
import { tExpr } from '@nocobase/flow-engine';
|
|
14
14
|
import { CodeEditor } from '../../../components/code-editor';
|
|
15
15
|
import { FormActionModel } from './FormActionModel';
|
|
16
16
|
import { resolveRunJsParams } from '../../utils/resolveRunJsParams';
|
|
@@ -73,12 +73,7 @@ ctx.message.success('Current form values: ' + JSON.stringify(values));
|
|
|
73
73
|
await ctx.resource.refresh();
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
|
-
|
|
77
|
-
await ctx.runjs(
|
|
78
|
-
code,
|
|
79
|
-
{ window: createSafeWindow({ navigator }), document: createSafeDocument(), navigator },
|
|
80
|
-
{ version },
|
|
81
|
-
);
|
|
76
|
+
await ctx.runjs(code, undefined, { version });
|
|
82
77
|
},
|
|
83
78
|
},
|
|
84
79
|
},
|
|
@@ -420,6 +420,23 @@ describe('FormBlockModel (form/formValues injection & server resolve anchors)',
|
|
|
420
420
|
expect(savedUids).toContain('grid-1');
|
|
421
421
|
});
|
|
422
422
|
|
|
423
|
+
it('keeps dirty reset separate from runtime user-edited reset', async () => {
|
|
424
|
+
const model = await setupFormModel();
|
|
425
|
+
const resetUserEditedState = vi.fn();
|
|
426
|
+
model.formValueRuntime = { resetUserEditedState } as any;
|
|
427
|
+
|
|
428
|
+
model.markUserModifiedFields({ status: 'draft' });
|
|
429
|
+
expect(model.getUserModifiedFields().has('status')).toBe(true);
|
|
430
|
+
|
|
431
|
+
model.resetUserModifiedFields();
|
|
432
|
+
|
|
433
|
+
expect(model.getUserModifiedFields().size).toBe(0);
|
|
434
|
+
expect(resetUserEditedState).not.toHaveBeenCalled();
|
|
435
|
+
|
|
436
|
+
model.resetRuntimeUserEditedState();
|
|
437
|
+
expect(resetUserEditedState).toHaveBeenCalledTimes(1);
|
|
438
|
+
});
|
|
439
|
+
|
|
423
440
|
it('re-syncs delegated assignRules when grid submodel is added after block init', async () => {
|
|
424
441
|
const model = await setupFormModel();
|
|
425
442
|
const syncAssignRules = vi.fn();
|