@nocobase/client-v2 2.4.0-alpha.4 → 2.4.0-alpha.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/components/form/ScanInput/useCodeScanner.d.ts +12 -2
- package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
- package/es/flow/components/filter/VariableFilterItem.d.ts +7 -0
- package/es/index.mjs +18 -18
- package/lib/index.js +111 -111
- package/package.json +9 -8
- package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
- package/src/collection-manager/field-configure.ts +2 -2
- package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
- package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
- package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
- package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +39 -0
- package/src/flow/components/FieldAssignValueInput.tsx +4 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
- package/src/flow/components/filter/VariableFilterItem.tsx +11 -3
- package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
- package/src/flow/models/base/GridModel.tsx +0 -1
- package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -3
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
- package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
- package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
- package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
- package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
- package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
12
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
13
|
+
import type { MetaTreeNode } from '@nocobase/flow-engine';
|
|
14
|
+
import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
|
|
15
|
+
import { VariableFilterItem } from '../VariableFilterItem';
|
|
16
|
+
import { createMockFlowApp, TestCollectionFieldInterface } from '../../../__tests__/helpers/mockFlowApp';
|
|
17
|
+
|
|
18
|
+
const captured = vi.hoisted(() => ({ metaTrees: [] as any[] }));
|
|
19
|
+
|
|
20
|
+
vi.mock('@nocobase/flow-engine', async () => {
|
|
21
|
+
const actual = await vi.importActual<any>('@nocobase/flow-engine');
|
|
22
|
+
const MockVariableInput = ({ onChange, metaTree }: any) => {
|
|
23
|
+
if (metaTree) {
|
|
24
|
+
captured.metaTrees.push(metaTree);
|
|
25
|
+
}
|
|
26
|
+
return (
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
data-testid="variable-input"
|
|
30
|
+
onClick={() =>
|
|
31
|
+
onChange?.('title', {
|
|
32
|
+
interface: 'input',
|
|
33
|
+
uiSchema: { 'x-component': 'Input' },
|
|
34
|
+
paths: ['collection', 'title'],
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
>
|
|
38
|
+
mock-variable-input
|
|
39
|
+
</button>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
return { ...actual, VariableInput: MockVariableInput };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// $context.data (not an association) -> category (m2o) -> book (m2o) -> createdBy (not counted) -> departments (m2m).
|
|
46
|
+
// Mirrors a workflow collection-event trigger that preloads `category.book.createdBy.departments`.
|
|
47
|
+
function buildRightMetaTree(): MetaTreeNode[] {
|
|
48
|
+
const departments: MetaTreeNode = {
|
|
49
|
+
name: 'departments',
|
|
50
|
+
title: 'Departments',
|
|
51
|
+
type: 'object',
|
|
52
|
+
interface: 'm2m',
|
|
53
|
+
paths: ['$context', 'data', 'category', 'book', 'createdBy', 'departments'],
|
|
54
|
+
};
|
|
55
|
+
const createdBy: MetaTreeNode = {
|
|
56
|
+
name: 'createdBy',
|
|
57
|
+
title: 'Created by',
|
|
58
|
+
type: 'object',
|
|
59
|
+
interface: 'createdBy',
|
|
60
|
+
paths: ['$context', 'data', 'category', 'book', 'createdBy'],
|
|
61
|
+
children: async () => [departments],
|
|
62
|
+
};
|
|
63
|
+
const book: MetaTreeNode = {
|
|
64
|
+
name: 'book',
|
|
65
|
+
title: 'Book',
|
|
66
|
+
type: 'object',
|
|
67
|
+
interface: 'obo',
|
|
68
|
+
paths: ['$context', 'data', 'category', 'book'],
|
|
69
|
+
children: async () => [createdBy],
|
|
70
|
+
};
|
|
71
|
+
const category: MetaTreeNode = {
|
|
72
|
+
name: 'category',
|
|
73
|
+
title: 'Category',
|
|
74
|
+
type: 'object',
|
|
75
|
+
interface: 'm2o',
|
|
76
|
+
paths: ['$context', 'data', 'category'],
|
|
77
|
+
children: async () => [book],
|
|
78
|
+
};
|
|
79
|
+
const data: MetaTreeNode = {
|
|
80
|
+
name: 'data',
|
|
81
|
+
title: 'Trigger data',
|
|
82
|
+
type: 'object',
|
|
83
|
+
paths: ['$context', 'data'],
|
|
84
|
+
children: async () => [category],
|
|
85
|
+
};
|
|
86
|
+
return [{ name: '$context', title: 'Trigger variables', type: '', paths: ['$context'], children: [data] }];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function resolveChildren(node: MetaTreeNode | undefined) {
|
|
90
|
+
const children = node?.children;
|
|
91
|
+
if (typeof children === 'function') {
|
|
92
|
+
return (await (children as () => Promise<MetaTreeNode[]>)()) ?? [];
|
|
93
|
+
}
|
|
94
|
+
return Array.isArray(children) ? children : [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function walkToCreatedByChildren(metaTree: any) {
|
|
98
|
+
const roots: MetaTreeNode[] = typeof metaTree === 'function' ? await metaTree() : metaTree;
|
|
99
|
+
const context = roots.find((node) => node.name === '$context');
|
|
100
|
+
const data = (await resolveChildren(context)).find((node) => node.name === 'data');
|
|
101
|
+
const category = (await resolveChildren(data)).find((node) => node.name === 'category');
|
|
102
|
+
const book = (await resolveChildren(category)).find((node) => node.name === 'book');
|
|
103
|
+
const createdBy = (await resolveChildren(book)).find((node) => node.name === 'createdBy');
|
|
104
|
+
return (await resolveChildren(createdBy)).map((node) => node.name);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createModel() {
|
|
108
|
+
const engine = new FlowEngine();
|
|
109
|
+
const model = new FlowModel({ uid: 'm-variable-filter-right', flowEngine: engine });
|
|
110
|
+
const app = createMockFlowApp();
|
|
111
|
+
model.context.defineProperty('app', { value: app });
|
|
112
|
+
|
|
113
|
+
class InputInterface extends TestCollectionFieldInterface {
|
|
114
|
+
name = 'input';
|
|
115
|
+
group = 'basic';
|
|
116
|
+
filterable = { operators: [{ value: '$eq', label: 'Equals' }] };
|
|
117
|
+
}
|
|
118
|
+
app.addFieldInterfaces([InputInterface]);
|
|
119
|
+
|
|
120
|
+
const ds = engine.dataSourceManager.getDataSource('main');
|
|
121
|
+
ds.addCollection({
|
|
122
|
+
name: 'posts',
|
|
123
|
+
fields: [{ name: 'title', type: 'string', interface: 'input', uiSchema: { 'x-component': 'Input' } }],
|
|
124
|
+
});
|
|
125
|
+
model.context.defineProperty('collection', { get: () => ds.getCollection('posts') });
|
|
126
|
+
|
|
127
|
+
return model as any;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderItem(props: Record<string, unknown>) {
|
|
131
|
+
const value = { path: 'title', operator: '$eq', value: '' } as any;
|
|
132
|
+
const model = createModel();
|
|
133
|
+
render(
|
|
134
|
+
<VariableFilterItem value={value} model={model} rightAsVariable rightMetaTree={buildRightMetaTree()} {...props} />,
|
|
135
|
+
);
|
|
136
|
+
fireEvent.click(screen.getAllByTestId('variable-input')[0]);
|
|
137
|
+
return captured.metaTrees.at(-1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
describe('VariableFilterItem right-side association depth', () => {
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
document.body.innerHTML = '';
|
|
143
|
+
captured.metaTrees = [];
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('applies maxAssociationFieldDepth to the right tree by default', async () => {
|
|
147
|
+
const metaTree = renderItem({ maxAssociationFieldDepth: 2 });
|
|
148
|
+
// `departments` is the third association down the path, so the default cap removes it.
|
|
149
|
+
expect(await walkToCreatedByChildren(metaTree)).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// The right side is a variable tree whose depth is decided by its provider (in workflow, by the trigger's
|
|
153
|
+
// "Preload associations" config). Capping it by the left-side field-picker limit hid preloaded variables.
|
|
154
|
+
it('leaves the right tree untouched when rightMaxAssociationFieldDepth is null', async () => {
|
|
155
|
+
const metaTree = renderItem({ maxAssociationFieldDepth: 2, rightMaxAssociationFieldDepth: null });
|
|
156
|
+
expect(await walkToCreatedByChildren(metaTree)).toEqual(['departments']);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -922,7 +922,6 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
|
|
|
922
922
|
const baseLayout = this.context.isMobileLayout
|
|
923
923
|
? normalizeGridLayout({
|
|
924
924
|
rows: transformRowsToSingleColumn(projectLayoutToLegacyRows(rawLayout).rows),
|
|
925
|
-
itemUids: this.getItemUids(),
|
|
926
925
|
})
|
|
927
926
|
: rawLayout;
|
|
928
927
|
const baseProjection = projectLayoutToLegacyRows(baseLayout);
|
|
@@ -115,7 +115,28 @@ export class AssignFormGridModel extends FormGridModel {
|
|
|
115
115
|
(existing as any).assignValue = value;
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
|
-
|
|
118
|
+
if (!collection) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const field = (collection.getFields?.() || []).find((f: any) => f.name === fieldName);
|
|
122
|
+
|
|
123
|
+
if (!field) {
|
|
124
|
+
const created = this.addSubModel('items', {
|
|
125
|
+
use: 'AssignFormItemModel',
|
|
126
|
+
stepParams: {
|
|
127
|
+
fieldSettings: {
|
|
128
|
+
init: {
|
|
129
|
+
dataSourceKey: collection?.dataSourceKey,
|
|
130
|
+
collectionName: collection?.name,
|
|
131
|
+
fieldPath: fieldName,
|
|
132
|
+
},
|
|
133
|
+
assignValue: { value },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
created['assignValue'] = value;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
119
140
|
|
|
120
141
|
const binding = EditableItemModel.getDefaultBindingByField(this.context, field);
|
|
121
142
|
if (!binding) {
|
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { Input } from 'antd';
|
|
12
12
|
import { define, observable } from '@formily/reactive';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
EditableItemModel,
|
|
15
|
+
FieldDeletePlaceholder,
|
|
16
|
+
FlowModelRenderer,
|
|
17
|
+
FormItem,
|
|
18
|
+
jioToJoiSchema,
|
|
19
|
+
tExpr,
|
|
20
|
+
} from '@nocobase/flow-engine';
|
|
14
21
|
// 无需类型导入(避免未使用的类型)
|
|
15
22
|
import { FormItemModel } from '../form/FormItemModel';
|
|
16
23
|
import { EditFormModel } from '../form/EditFormModel';
|
|
@@ -160,11 +167,15 @@ export class AssignFormItemModel extends FormItemModel {
|
|
|
160
167
|
|
|
161
168
|
getAssignedEntry(): [string, any] | null {
|
|
162
169
|
const name = this.fieldPath;
|
|
163
|
-
if (!name) return null;
|
|
170
|
+
if (!name || !this.collectionField) return null;
|
|
164
171
|
return [name, this.assignValue];
|
|
165
172
|
}
|
|
166
173
|
|
|
167
174
|
render() {
|
|
175
|
+
if (!this.collectionField) {
|
|
176
|
+
return <FieldDeletePlaceholder />;
|
|
177
|
+
}
|
|
178
|
+
|
|
168
179
|
// 与 FormItemModel.render 结构保持一致,仅替换内部渲染为 VariableInput + 常量编辑器
|
|
169
180
|
const ctx: any = this.context;
|
|
170
181
|
const collection = ctx.collection;
|
|
@@ -392,7 +403,7 @@ AssignFormItemModel.registerFlow({
|
|
|
392
403
|
},
|
|
393
404
|
defaultParams: (ctx) => {
|
|
394
405
|
return {
|
|
395
|
-
label: (ctx.model as any).collectionField.
|
|
406
|
+
label: (ctx.model as any).collectionField?.title || (ctx.model as any).fieldPath,
|
|
396
407
|
};
|
|
397
408
|
},
|
|
398
409
|
handler(ctx, params) {
|
|
@@ -47,6 +47,146 @@ class MockFlowModelRepository implements IFlowModelRepository {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
describe('assignFieldValuesFlow (editor)', () => {
|
|
50
|
+
it('shows the deleted-field placeholder for a stale assigned value', async () => {
|
|
51
|
+
const engine = new FlowEngine();
|
|
52
|
+
engine.setModelRepository(new MockFlowModelRepository());
|
|
53
|
+
engine.registerModels({
|
|
54
|
+
AssignFormModel,
|
|
55
|
+
AssignFormGridModel,
|
|
56
|
+
AssignFormItemModel,
|
|
57
|
+
InputFieldModel,
|
|
58
|
+
VariableFieldFormModel,
|
|
59
|
+
});
|
|
60
|
+
engine.context.defineProperty('location', { value: { search: '' } });
|
|
61
|
+
engine.context.defineProperty('themeToken', { value: { marginLG: 24 } });
|
|
62
|
+
engine.context.defineProperty('flowSettingsEnabled', { value: true });
|
|
63
|
+
|
|
64
|
+
const main = engine.context.dataSourceManager.getDataSource('main');
|
|
65
|
+
main.addCollection({
|
|
66
|
+
name: 'users',
|
|
67
|
+
fields: [{ name: 'nickname', type: 'string', interface: 'input' }],
|
|
68
|
+
});
|
|
69
|
+
const users = engine.context.dataSourceManager.getCollection('main', 'users');
|
|
70
|
+
|
|
71
|
+
const action = engine.createModel({
|
|
72
|
+
use: 'FlowModel',
|
|
73
|
+
uid: 'act-assign-deleted-field',
|
|
74
|
+
});
|
|
75
|
+
action.setStepParams('assignSettings', 'assignFieldValues', {
|
|
76
|
+
assignedValues: { deletedField: 'stale value' },
|
|
77
|
+
});
|
|
78
|
+
action.context.defineProperty('blockModel', { value: { collection: users } });
|
|
79
|
+
|
|
80
|
+
const step = createAssignFieldValuesStep({ settingsFlowKey: 'assignSettings' });
|
|
81
|
+
const Editor = step.uiSchema().editor?.['x-component'] as React.ComponentType;
|
|
82
|
+
const flowSettingsCtx = new FlowRuntimeContext(action, 'assignSettings', 'settings');
|
|
83
|
+
|
|
84
|
+
render(
|
|
85
|
+
<FlowEngineProvider engine={engine}>
|
|
86
|
+
<ConfigProvider>
|
|
87
|
+
<App>
|
|
88
|
+
<FlowSettingsContextProvider value={flowSettingsCtx}>
|
|
89
|
+
<Editor />
|
|
90
|
+
</FlowSettingsContextProvider>
|
|
91
|
+
</App>
|
|
92
|
+
</ConfigProvider>
|
|
93
|
+
</FlowEngineProvider>,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await waitFor(() => {
|
|
97
|
+
expect(screen.getByRole('button', { name: /Fields/ })).toBeInTheDocument();
|
|
98
|
+
expect(screen.getByText(/deletedField.*may have been deleted/)).toBeInTheDocument();
|
|
99
|
+
const form = engine.findModelByParentId<AssignFormModel>(action.uid, 'assignForm');
|
|
100
|
+
expect(form).toBeDefined();
|
|
101
|
+
expect(form?.subModels.grid.subModels.items || []).toHaveLength(1);
|
|
102
|
+
expect(form?.getAssignedValues()).toEqual({});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('shows the standard deleted-field placeholder for a persisted assigned field model', async () => {
|
|
107
|
+
const engine = new FlowEngine();
|
|
108
|
+
engine.setModelRepository(new MockFlowModelRepository());
|
|
109
|
+
engine.registerModels({
|
|
110
|
+
AssignFormModel,
|
|
111
|
+
AssignFormGridModel,
|
|
112
|
+
AssignFormItemModel,
|
|
113
|
+
InputFieldModel,
|
|
114
|
+
VariableFieldFormModel,
|
|
115
|
+
});
|
|
116
|
+
engine.context.defineProperty('location', { value: { search: '' } });
|
|
117
|
+
engine.context.defineProperty('themeToken', { value: { marginLG: 24 } });
|
|
118
|
+
engine.context.defineProperty('flowSettingsEnabled', { value: true });
|
|
119
|
+
|
|
120
|
+
const main = engine.context.dataSourceManager.getDataSource('main');
|
|
121
|
+
main.addCollection({
|
|
122
|
+
name: 'users',
|
|
123
|
+
fields: [{ name: 'nickname', type: 'string', interface: 'input' }],
|
|
124
|
+
});
|
|
125
|
+
const users = engine.context.dataSourceManager.getCollection('main', 'users');
|
|
126
|
+
|
|
127
|
+
const action = engine.createModel({
|
|
128
|
+
use: 'FlowModel',
|
|
129
|
+
uid: 'act-assign-persisted-deleted-field',
|
|
130
|
+
});
|
|
131
|
+
action.setStepParams('assignSettings', 'assignFieldValues', {
|
|
132
|
+
assignedValues: { deletedField: 'stale value' },
|
|
133
|
+
});
|
|
134
|
+
action.context.defineProperty('blockModel', { value: { collection: users } });
|
|
135
|
+
|
|
136
|
+
const form = engine.createModel<AssignFormModel>({
|
|
137
|
+
use: 'AssignFormModel',
|
|
138
|
+
uid: 'form-assign-persisted-deleted-field',
|
|
139
|
+
parentId: action.uid,
|
|
140
|
+
subKey: 'assignForm',
|
|
141
|
+
stepParams: {
|
|
142
|
+
resourceSettings: {
|
|
143
|
+
init: {
|
|
144
|
+
dataSourceKey: 'main',
|
|
145
|
+
collectionName: 'users',
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
action.setSubModel('assignForm', form);
|
|
151
|
+
form.subModels.grid.addSubModel('items', {
|
|
152
|
+
use: 'AssignFormItemModel',
|
|
153
|
+
uid: 'item-assign-persisted-deleted-field',
|
|
154
|
+
stepParams: {
|
|
155
|
+
fieldSettings: {
|
|
156
|
+
init: {
|
|
157
|
+
dataSourceKey: 'main',
|
|
158
|
+
collectionName: 'users',
|
|
159
|
+
fieldPath: 'deletedField',
|
|
160
|
+
},
|
|
161
|
+
assignValue: { value: 'stale value' },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
form.subModels.grid.resetRows(true);
|
|
166
|
+
|
|
167
|
+
const step = createAssignFieldValuesStep({ settingsFlowKey: 'assignSettings' });
|
|
168
|
+
const Editor = step.uiSchema().editor?.['x-component'] as React.ComponentType;
|
|
169
|
+
const flowSettingsCtx = new FlowRuntimeContext(action, 'assignSettings', 'settings');
|
|
170
|
+
|
|
171
|
+
render(
|
|
172
|
+
<FlowEngineProvider engine={engine}>
|
|
173
|
+
<ConfigProvider>
|
|
174
|
+
<App>
|
|
175
|
+
<FlowSettingsContextProvider value={flowSettingsCtx}>
|
|
176
|
+
<Editor />
|
|
177
|
+
</FlowSettingsContextProvider>
|
|
178
|
+
</App>
|
|
179
|
+
</ConfigProvider>
|
|
180
|
+
</FlowEngineProvider>,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
await waitFor(() => {
|
|
184
|
+
expect(screen.getByText(/deletedField.*may have been deleted/)).toBeInTheDocument();
|
|
185
|
+
expect(form.subModels.grid.subModels.items).toHaveLength(1);
|
|
186
|
+
expect(form.getAssignedValues()).toEqual({});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
50
190
|
it('repairs AssignFormModel resource init and clears cached collection', async () => {
|
|
51
191
|
const engine = new FlowEngine();
|
|
52
192
|
engine.setModelRepository(new MockFlowModelRepository());
|
|
@@ -21,6 +21,35 @@ describe('FilterFormGridModel.toggleFormFieldsCollapse', () => {
|
|
|
21
21
|
engine.registerModels({ FilterFormGridModel });
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
it.each([false, true])('preserves collapsed fields in mobile layout with settings enabled: %s', (settingsEnabled) => {
|
|
25
|
+
engine.flowSettings[settingsEnabled ? 'enable' : 'disable']();
|
|
26
|
+
const rows = {
|
|
27
|
+
first: [['field-1']],
|
|
28
|
+
second: [['field-2']],
|
|
29
|
+
third: [['field-3']],
|
|
30
|
+
};
|
|
31
|
+
const model = engine.createModel<FilterFormGridModel>({
|
|
32
|
+
uid: 'mobile-filter-grid',
|
|
33
|
+
use: 'FilterFormGridModel',
|
|
34
|
+
props: { rows },
|
|
35
|
+
subModels: {
|
|
36
|
+
items: ['field-1', 'field-2', 'field-3'].map((uid) => ({ use: 'FlowModel', uid })),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
model.context.defineProperty('isMobileLayout', { value: true });
|
|
40
|
+
model.setStepParams(GRID_FLOW_KEY, GRID_STEP, { rows });
|
|
41
|
+
type VisibleLayoutReader = { getVisibleLayout(): { rows: Record<string, string[][]> } };
|
|
42
|
+
const renderedItems = () =>
|
|
43
|
+
Object.values((model as unknown as VisibleLayoutReader).getVisibleLayout().rows).flat(2);
|
|
44
|
+
|
|
45
|
+
expect(renderedItems()).toEqual(['field-1', 'field-2', 'field-3']);
|
|
46
|
+
model.toggleFormFieldsCollapse(true, 1);
|
|
47
|
+
expect(renderedItems()).toEqual(['field-1']);
|
|
48
|
+
model.toggleFormFieldsCollapse(false, 1);
|
|
49
|
+
expect(renderedItems()).toEqual(['field-1', 'field-2', 'field-3']);
|
|
50
|
+
expect(model.getStepParams(GRID_FLOW_KEY, GRID_STEP).rows).toEqual(rows);
|
|
51
|
+
});
|
|
52
|
+
|
|
24
53
|
it('uses rowOrder from the full layout when collapsing after reorder', () => {
|
|
25
54
|
const model = engine.createModel<FilterFormGridModel>({
|
|
26
55
|
uid: 'filter-grid-collapse-order',
|
|
@@ -351,7 +351,7 @@ export const FieldComponentProps: React.FC<{ fieldModel: string; source: string[
|
|
|
351
351
|
<FormItem label={t('Options')}>
|
|
352
352
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
|
353
353
|
{options.map((option: any, index: number) => (
|
|
354
|
-
<Space key={
|
|
354
|
+
<Space key={index} style={{ width: '100%' }} size={8} wrap align="start">
|
|
355
355
|
<Input
|
|
356
356
|
style={{ flex: 1, minWidth: 120 }}
|
|
357
357
|
placeholder={t('Option label')}
|
package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { createForm } from '@formily/core';
|
|
13
|
+
import { Field, FormProvider } from '@formily/react';
|
|
14
|
+
import { render, screen } from '@nocobase/test/client';
|
|
15
|
+
import userEvent from '@testing-library/user-event';
|
|
16
|
+
import { FlowEngine, FlowEngineProvider, FlowModel, FlowModelProvider } from '@nocobase/flow-engine';
|
|
17
|
+
import { FieldComponentProps } from '../FieldComponentProps';
|
|
18
|
+
|
|
19
|
+
class HostModel extends FlowModel {
|
|
20
|
+
render() {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe.each(['RadioGroupFieldModel', 'CheckboxGroupFieldModel', 'SelectFieldModel'])('%s options', (fieldModel) => {
|
|
26
|
+
it('keeps focus while editing option values and preserves remaining rows after removal', async () => {
|
|
27
|
+
const form = createForm();
|
|
28
|
+
const engine = new FlowEngine();
|
|
29
|
+
engine.registerModels({ HostModel });
|
|
30
|
+
const model = engine.createModel<HostModel>({ use: 'HostModel' });
|
|
31
|
+
render(
|
|
32
|
+
<FlowEngineProvider engine={engine}>
|
|
33
|
+
<FlowModelProvider model={model}>
|
|
34
|
+
<FormProvider form={form}>
|
|
35
|
+
<Field name="props" component={[FieldComponentProps, { fieldModel, source: [] }]} />
|
|
36
|
+
</FormProvider>
|
|
37
|
+
</FlowModelProvider>
|
|
38
|
+
</FlowEngineProvider>,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
await userEvent.click(screen.getByRole('button', { name: 'plus Add' }));
|
|
42
|
+
const valueInput = screen.getByPlaceholderText('Option value');
|
|
43
|
+
await userEvent.type(valueInput, 'abcdef');
|
|
44
|
+
expect(screen.getByPlaceholderText('Option value')).toHaveValue('abcdef');
|
|
45
|
+
expect(valueInput).toHaveFocus();
|
|
46
|
+
|
|
47
|
+
await userEvent.keyboard('{Backspace}{Backspace}xy');
|
|
48
|
+
expect(valueInput).toHaveValue('abcdxy');
|
|
49
|
+
expect(valueInput).toHaveFocus();
|
|
50
|
+
await userEvent.type(screen.getByPlaceholderText('Option label'), 'First');
|
|
51
|
+
await userEvent.click(screen.getByRole('button', { name: 'plus Add' }));
|
|
52
|
+
await userEvent.type(screen.getAllByPlaceholderText('Option label')[1], 'Second');
|
|
53
|
+
await userEvent.type(screen.getAllByPlaceholderText('Option value')[1], 'second');
|
|
54
|
+
await userEvent.click(screen.getAllByRole('button', { name: 'close' })[0]);
|
|
55
|
+
expect(screen.getByPlaceholderText('Option label')).toHaveValue('Second');
|
|
56
|
+
expect(screen.getByPlaceholderText('Option value')).toHaveValue('second');
|
|
57
|
+
await userEvent.type(screen.getByPlaceholderText('Option value'), '2');
|
|
58
|
+
expect(screen.getByPlaceholderText('Option value')).toHaveFocus();
|
|
59
|
+
expect(form.values.props.options).toEqual([{ label: 'Second', value: 'second2' }]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -399,20 +399,41 @@ export class FormBlockModel<
|
|
|
399
399
|
if (Array.isArray(topValue) && topValue.length === 0) return false;
|
|
400
400
|
|
|
401
401
|
// 本地优先:支持对多关系的 dot 聚合路径(例如 assignees.name)。
|
|
402
|
-
//
|
|
403
|
-
// 因而这里先用 getValuesByPath 做一次前端可解析性检查,命中则直接前端解析。
|
|
402
|
+
// 关联字段只有在本地值包含目标标题字段时才算完整;标量外键或仅含主键的轻量对象仍需服务端补全。
|
|
404
403
|
const formValuesSnapshot = runtime.getFormValuesSnapshot();
|
|
404
|
+
let shouldResolveAssociationValueOnServer = false;
|
|
405
405
|
if (formValuesSnapshot && typeof formValuesSnapshot === 'object') {
|
|
406
|
-
const localResolved = getValuesByPath(formValuesSnapshot as Record<string,
|
|
406
|
+
const localResolved = getValuesByPath(formValuesSnapshot as Record<string, unknown>, subPath);
|
|
407
407
|
if (typeof localResolved !== 'undefined') {
|
|
408
|
-
|
|
408
|
+
const fieldPath = subPath
|
|
409
|
+
.replace(/\[\d+\]/g, '')
|
|
410
|
+
.split('.')
|
|
411
|
+
.filter((segment) => !/^\d+$/.test(segment))
|
|
412
|
+
.join('.');
|
|
413
|
+
const resolvedField = this.collection?.getFieldByPath?.(fieldPath);
|
|
414
|
+
const titleFieldName = resolvedField?.targetCollectionTitleFieldName;
|
|
415
|
+
const isLoadedAssociationRecord = (value: unknown) => {
|
|
416
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
417
|
+
if (!titleFieldName) return true;
|
|
418
|
+
return typeof (value as Record<string, unknown>)[titleFieldName] !== 'undefined';
|
|
419
|
+
};
|
|
420
|
+
const isAssociationValueLoaded =
|
|
421
|
+
localResolved === null ||
|
|
422
|
+
(Array.isArray(localResolved)
|
|
423
|
+
? localResolved.length === 0 ||
|
|
424
|
+
localResolved.every((value) => value === null || isLoadedAssociationRecord(value))
|
|
425
|
+
: isLoadedAssociationRecord(localResolved));
|
|
426
|
+
if (!resolvedField?.isAssociationField?.() || isAssociationValueLoaded) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
shouldResolveAssociationValueOnServer = true;
|
|
409
430
|
}
|
|
410
431
|
}
|
|
411
432
|
|
|
412
433
|
// 已配置字段:仅关联字段的子路径按需服务端补全(保持现有语义)
|
|
413
434
|
const assocResolver = createAssociationSubpathResolver(
|
|
414
435
|
() => this.collection,
|
|
415
|
-
() => runtime.getFormValuesSnapshot(),
|
|
436
|
+
shouldResolveAssociationValueOnServer ? undefined : () => runtime.getFormValuesSnapshot(),
|
|
416
437
|
);
|
|
417
438
|
return assocResolver(subPath);
|
|
418
439
|
}
|