@nocobase/client-v2 2.1.0-beta.32 → 2.1.0-beta.34
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/APIClient.d.ts +16 -0
- package/es/Application.d.ts +2 -1
- package/es/authRedirect.d.ts +9 -16
- package/es/components/form/EnvVariableInput.d.ts +8 -6
- package/es/components/form/VariableInput.d.ts +73 -0
- package/es/components/form/index.d.ts +1 -0
- package/es/components/form/table/RowOverlayPreview.d.ts +27 -0
- package/es/components/form/table/SelectionCell.d.ts +36 -0
- package/es/components/form/table/Table.d.ts +82 -0
- package/es/components/form/table/constants.d.ts +15 -0
- package/es/components/form/table/dnd/SortableRow.d.ts +40 -0
- package/es/components/form/table/dnd/index.d.ts +9 -0
- package/es/components/form/table/index.d.ts +9 -0
- package/es/components/form/table/styles.d.ts +41 -0
- package/es/components/form/table/utils.d.ts +44 -0
- package/es/components/index.d.ts +2 -0
- package/es/flow/components/TextAreaWithContextSelector.d.ts +15 -0
- package/es/flow/components/code-editor/index.d.ts +1 -0
- package/es/flow/models/blocks/shared/legacyDefaultValueMigrationBase.d.ts +1 -0
- package/es/flow/models/blocks/table/dragSort/dragSortComponents.d.ts +1 -6
- package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +5 -1
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +4 -0
- package/es/flow/models/fields/ClickableFieldModel.d.ts +3 -0
- package/es/flow/models/fields/DisplayEnumFieldModel.d.ts +6 -0
- package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +2 -2
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.d.ts +1 -0
- package/es/index.mjs +166 -99
- package/es/theme/globalStyles.d.ts +9 -0
- package/es/theme/index.d.ts +1 -0
- package/lib/index.js +171 -104
- package/package.json +10 -5
- package/src/APIClient.ts +68 -0
- package/src/Application.tsx +6 -2
- package/src/__tests__/authRedirect.test.ts +170 -64
- package/src/__tests__/globalDeps.test.ts +3 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +6 -6
- package/src/authRedirect.ts +23 -84
- package/src/components/form/EnvVariableInput.tsx +11 -46
- package/src/components/form/VariableInput.tsx +177 -0
- package/src/components/form/__tests__/EnvVariableInput.test.tsx +175 -0
- package/src/components/form/index.tsx +1 -0
- package/src/components/form/table/RowOverlayPreview.tsx +51 -0
- package/src/components/form/table/SelectionCell.tsx +72 -0
- package/src/components/form/table/Table.tsx +279 -0
- package/src/components/form/table/__tests__/Table.pagination.test.tsx +80 -0
- package/src/components/form/table/constants.ts +16 -0
- package/src/components/form/table/dnd/SortableRow.tsx +106 -0
- package/src/components/form/table/dnd/index.ts +10 -0
- package/src/components/form/table/index.tsx +13 -0
- package/src/components/form/table/styles.ts +110 -0
- package/src/components/form/table/utils.ts +75 -0
- package/src/components/index.ts +2 -0
- package/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx +173 -0
- package/src/flow/actions/__tests__/pattern.test.ts +134 -0
- package/src/flow/actions/__tests__/titleField.test.ts +45 -0
- package/src/flow/actions/filterFormDefaultValues.tsx +30 -9
- package/src/flow/actions/formAssignRules.tsx +24 -9
- package/src/flow/actions/pattern.tsx +41 -6
- package/src/flow/actions/titleField.tsx +4 -2
- package/src/flow/actions/validation.tsx +1 -1
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +2 -4
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +13 -5
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +111 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -1
- package/src/flow/components/DynamicFlowsIcon.tsx +87 -13
- package/src/flow/components/TextAreaWithContextSelector.tsx +30 -6
- package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +195 -8
- package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +81 -0
- package/src/flow/components/code-editor/hooks/useCodeRunner.ts +34 -2
- package/src/flow/components/code-editor/index.tsx +12 -8
- package/src/flow/models/base/PageModel/RootPageModel.tsx +1 -1
- package/src/flow/models/blocks/filter-form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
- package/src/flow/models/blocks/form/FormActionModel.tsx +2 -8
- package/src/flow/models/blocks/form/FormItemModel.tsx +1 -1
- package/src/flow/models/blocks/form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
- package/src/flow/models/blocks/shared/legacyDefaultValueMigrationBase.ts +21 -5
- package/src/flow/models/blocks/table/TableColumnModel.tsx +5 -2
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +36 -0
- package/src/flow/models/blocks/table/dragSort/dragSortComponents.tsx +1 -81
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +144 -3
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +170 -1
- package/src/flow/models/fields/ClickableFieldModel.tsx +46 -2
- package/src/flow/models/fields/DisplayEnumFieldModel.tsx +44 -0
- package/src/flow/models/fields/DisplayTitleFieldModel.tsx +40 -15
- package/src/flow/models/fields/JSEditableFieldModel.tsx +107 -7
- package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +180 -2
- package/src/flow/models/fields/__tests__/DisplayEnumFieldModel.test.tsx +39 -0
- package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +97 -0
- package/src/index.ts +1 -0
- package/src/nocobase-buildin-plugin/index.tsx +4 -4
- package/src/theme/globalStyles.ts +21 -0
- package/src/theme/index.tsx +1 -0
- package/src/utils/globalDeps.ts +14 -1
|
@@ -0,0 +1,75 @@
|
|
|
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 type React from 'react';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Same shape as antd Table's `rowKey` prop — either a record key name or a
|
|
14
|
+
* function. Hoisted here so utilities and `Table.tsx` agree on the contract.
|
|
15
|
+
*/
|
|
16
|
+
export type RowKey<RecordType extends object> =
|
|
17
|
+
| (keyof RecordType & (string | number))
|
|
18
|
+
| ((record: RecordType, index?: number) => React.Key);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read a stable row id off a record. Mirrors antd Table's rowKey resolution
|
|
22
|
+
* but normalises non-primitive ids to strings so `data-row-key` attributes
|
|
23
|
+
* and `useSortable({ id })` agree on equality.
|
|
24
|
+
*/
|
|
25
|
+
export function readRowKey<RecordType extends object>(
|
|
26
|
+
record: RecordType,
|
|
27
|
+
rowKey: RowKey<RecordType>,
|
|
28
|
+
index?: number,
|
|
29
|
+
): React.Key | undefined {
|
|
30
|
+
if (typeof rowKey === 'function') {
|
|
31
|
+
return rowKey(record, index);
|
|
32
|
+
}
|
|
33
|
+
const value = record[rowKey] as unknown;
|
|
34
|
+
if (value == null) return undefined;
|
|
35
|
+
return typeof value === 'string' || typeof value === 'number' ? value : String(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pixel-perfect snapshot of a rendered `<tr>` for the drag overlay clone.
|
|
40
|
+
* Contains everything needed to rebuild a visually identical floating row
|
|
41
|
+
* without re-running antd's column layout pass.
|
|
42
|
+
*/
|
|
43
|
+
export type RowSnapshot = {
|
|
44
|
+
/** outerHTML of the source `<tr>`, captured at drag start. */
|
|
45
|
+
html: string;
|
|
46
|
+
/** Per-cell pixel widths (in DOM order) so the clone can fix them via `<col>`. */
|
|
47
|
+
cellWidths: number[];
|
|
48
|
+
/** Total row width — used as the wrapper width so the clone matches source horizontally. */
|
|
49
|
+
totalWidth: number;
|
|
50
|
+
/** Total row height — applied to the clone so cell padding matches the source row exactly. */
|
|
51
|
+
totalHeight: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Snapshot the source `<tr>` so the drag overlay can render a pixel-accurate
|
|
56
|
+
* floating clone. We can't reliably recompute the layout from `columns` alone
|
|
57
|
+
* — antd auto-sizes columns at runtime based on content + the surrounding
|
|
58
|
+
* container — so we read the rendered widths off the DOM at drag start. The
|
|
59
|
+
* row height is captured too because antd's cell padding rules are scoped to
|
|
60
|
+
* a selector chain we strip in the clone.
|
|
61
|
+
*/
|
|
62
|
+
export function snapshotSourceRow(rowKey: string): RowSnapshot | null {
|
|
63
|
+
if (typeof document === 'undefined') return null;
|
|
64
|
+
// `CSS.escape` is in lib.dom and shipped in every browser we target; the
|
|
65
|
+
// guard is purely a belt-and-suspenders against exotic test environments
|
|
66
|
+
// where `window.CSS` may be absent.
|
|
67
|
+
const cssGlobal: { escape?: (value: string) => string } | undefined =
|
|
68
|
+
typeof window !== 'undefined' ? window.CSS : undefined;
|
|
69
|
+
const escaped = cssGlobal?.escape ? cssGlobal.escape(rowKey) : rowKey;
|
|
70
|
+
const sourceRow = document.querySelector(`tr[data-row-key="${escaped}"]`) as HTMLTableRowElement | null;
|
|
71
|
+
if (!sourceRow) return null;
|
|
72
|
+
const cellWidths = Array.from(sourceRow.cells).map((cell) => cell.getBoundingClientRect().width);
|
|
73
|
+
const rect = sourceRow.getBoundingClientRect();
|
|
74
|
+
return { html: sourceRow.outerHTML, cellWidths, totalWidth: rect.width, totalHeight: rect.height };
|
|
75
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -0,0 +1,173 @@
|
|
|
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 { render, waitFor } from '@testing-library/react';
|
|
12
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { FlowEngine, FlowModel, FlowSettingsContextProvider } from '@nocobase/flow-engine';
|
|
14
|
+
import { formAssignRules } from '../formAssignRules';
|
|
15
|
+
import { filterFormDefaultValues } from '../filterFormDefaultValues';
|
|
16
|
+
|
|
17
|
+
const mockState = vi.hoisted(() => ({
|
|
18
|
+
editorProps: [] as any[],
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('../../components/FieldAssignRulesEditor', () => ({
|
|
22
|
+
FieldAssignRulesEditor: (props: any) => {
|
|
23
|
+
mockState.editorProps.push(props);
|
|
24
|
+
return null;
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('../../components/fieldAssignOptions', () => ({
|
|
29
|
+
collectFieldAssignCascaderOptions: vi.fn(() => []),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock('../../components/useAssociationTitleFieldSync', () => ({
|
|
33
|
+
useAssociationTitleFieldSync: () => ({
|
|
34
|
+
isTitleFieldCandidate: vi.fn(() => false),
|
|
35
|
+
onSyncAssociationTitleField: vi.fn(),
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock('../../internal/utils/modelUtils', () => ({
|
|
40
|
+
findFormItemModelByFieldPath: vi.fn(() => null),
|
|
41
|
+
getCollectionFromModel: vi.fn(() => null),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
function createLegacyField(fieldPath: string, value: any, legacyFlowKey: string) {
|
|
45
|
+
return {
|
|
46
|
+
props: {},
|
|
47
|
+
stepParams: {
|
|
48
|
+
fieldSettings: { init: { fieldPath } },
|
|
49
|
+
[legacyFlowKey]: { initialValue: { defaultValue: value } },
|
|
50
|
+
},
|
|
51
|
+
getProps() {
|
|
52
|
+
return this.props;
|
|
53
|
+
},
|
|
54
|
+
getStepParams(flowKey: string, stepKey: string) {
|
|
55
|
+
return this.stepParams?.[flowKey]?.[stepKey];
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createModel(legacyFlowKey: string, fields = [{ fieldPath: 'description', value: 'Legacy description' }]) {
|
|
61
|
+
const engine = new FlowEngine();
|
|
62
|
+
engine.translate = vi.fn((key: string) => key) as any;
|
|
63
|
+
const model = new FlowModel({ uid: `model-${legacyFlowKey}`, flowEngine: engine }) as any;
|
|
64
|
+
model.subModels.grid = {
|
|
65
|
+
subModels: {
|
|
66
|
+
items: fields.map((field) => createLegacyField(field.fieldPath, field.value, legacyFlowKey)),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
return model;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getActionComponent(action: any) {
|
|
73
|
+
const schema = typeof action.uiSchema === 'function' ? action.uiSchema() : action.uiSchema;
|
|
74
|
+
return schema.value['x-component'];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderAction(action: any, model: any, initialValue: any[] = []) {
|
|
78
|
+
const Comp = getActionComponent(action);
|
|
79
|
+
const onChange = vi.fn();
|
|
80
|
+
|
|
81
|
+
const Harness = () => {
|
|
82
|
+
const [value, setValue] = React.useState(initialValue);
|
|
83
|
+
const handleChange = React.useCallback(
|
|
84
|
+
(next: any[]) => {
|
|
85
|
+
onChange(next);
|
|
86
|
+
setValue(next);
|
|
87
|
+
},
|
|
88
|
+
[onChange],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<FlowSettingsContextProvider value={model.context}>
|
|
93
|
+
<Comp value={value} onChange={handleChange} />
|
|
94
|
+
</FlowSettingsContextProvider>
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
render(<Harness />);
|
|
99
|
+
return { onChange };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function lastEditorValue() {
|
|
103
|
+
return mockState.editorProps.at(-1)?.value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('Field values legacy default migration', () => {
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
mockState.editorProps.length = 0;
|
|
109
|
+
vi.clearAllMocks();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('imports form field legacy defaults before form-level value is persisted', async () => {
|
|
113
|
+
const model = createModel('editItemSettings');
|
|
114
|
+
const { onChange } = renderAction(formAssignRules, model);
|
|
115
|
+
|
|
116
|
+
await waitFor(() => {
|
|
117
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
118
|
+
expect.objectContaining({
|
|
119
|
+
key: 'legacy-default:description',
|
|
120
|
+
targetPath: 'description',
|
|
121
|
+
mode: 'default',
|
|
122
|
+
value: 'Legacy description',
|
|
123
|
+
}),
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
expect(lastEditorValue()).toEqual([
|
|
127
|
+
expect.objectContaining({
|
|
128
|
+
targetPath: 'description',
|
|
129
|
+
value: 'Legacy description',
|
|
130
|
+
}),
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('does not re-import form legacy defaults after an empty form-level value is persisted', async () => {
|
|
135
|
+
const model = createModel('editItemSettings');
|
|
136
|
+
model.setStepParams('formModelSettings', 'assignRules', { value: [] });
|
|
137
|
+
const { onChange } = renderAction(formAssignRules, model, []);
|
|
138
|
+
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
expect(mockState.editorProps.length).toBeGreaterThan(1);
|
|
141
|
+
});
|
|
142
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
143
|
+
expect(lastEditorValue()).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('does not append deleted legacy form targets when another rule is already persisted', async () => {
|
|
147
|
+
const persisted = [{ key: 'kept', targetPath: 'title', mode: 'default', value: 'Kept title' }];
|
|
148
|
+
const model = createModel('editItemSettings', [
|
|
149
|
+
{ fieldPath: 'title', value: 'Kept title' },
|
|
150
|
+
{ fieldPath: 'description', value: 'Legacy description' },
|
|
151
|
+
]);
|
|
152
|
+
model.setStepParams('formModelSettings', 'assignRules', { value: persisted });
|
|
153
|
+
const { onChange } = renderAction(formAssignRules, model, persisted);
|
|
154
|
+
|
|
155
|
+
await waitFor(() => {
|
|
156
|
+
expect(mockState.editorProps.length).toBeGreaterThan(1);
|
|
157
|
+
});
|
|
158
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
159
|
+
expect(lastEditorValue()).toEqual(persisted);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('does not re-import filter form legacy defaults after an empty form-level value is persisted', async () => {
|
|
163
|
+
const model = createModel('filterFormItemSettings');
|
|
164
|
+
model.setStepParams('formFilterBlockModelSettings', 'defaultValues', { value: [] });
|
|
165
|
+
const { onChange } = renderAction(filterFormDefaultValues, model, []);
|
|
166
|
+
|
|
167
|
+
await waitFor(() => {
|
|
168
|
+
expect(mockState.editorProps.length).toBeGreaterThan(1);
|
|
169
|
+
});
|
|
170
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
171
|
+
expect(lastEditorValue()).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -187,4 +187,138 @@ describe('pattern action', () => {
|
|
|
187
187
|
|
|
188
188
|
getDisplayBindingSpy.mockRestore();
|
|
189
189
|
});
|
|
190
|
+
|
|
191
|
+
it('falls back to the target collection title field for association display only mode', async () => {
|
|
192
|
+
const engine = new FlowEngine();
|
|
193
|
+
engine.registerModels({
|
|
194
|
+
DummyFormItemModel,
|
|
195
|
+
FieldModel,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const parent = engine.createModel<DummyFormItemModel>({
|
|
199
|
+
use: DummyFormItemModel,
|
|
200
|
+
uid: 'form-item-association',
|
|
201
|
+
subModels: {
|
|
202
|
+
field: {
|
|
203
|
+
use: FieldModel,
|
|
204
|
+
uid: 'field-association',
|
|
205
|
+
props: {},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
const collectionField = {
|
|
210
|
+
targetCollectionTitleFieldName: 'name',
|
|
211
|
+
isAssociationField: () => true,
|
|
212
|
+
};
|
|
213
|
+
parent.collectionField = collectionField;
|
|
214
|
+
|
|
215
|
+
await pattern.beforeParamsSave?.(
|
|
216
|
+
{
|
|
217
|
+
model: parent,
|
|
218
|
+
collectionField,
|
|
219
|
+
} as any,
|
|
220
|
+
{ pattern: 'readPretty' },
|
|
221
|
+
{ pattern: 'editable' },
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
expect(parent.props).toMatchObject({
|
|
225
|
+
pattern: 'readPretty',
|
|
226
|
+
disabled: false,
|
|
227
|
+
titleField: 'name',
|
|
228
|
+
});
|
|
229
|
+
expect(parent.getStepParams('editItemSettings', 'titleField')).toEqual({
|
|
230
|
+
titleField: 'name',
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('passes the association title field to the rebuilt display field model', async () => {
|
|
235
|
+
const engine = new FlowEngine();
|
|
236
|
+
engine.registerModels({
|
|
237
|
+
DummyFormItemModel,
|
|
238
|
+
FieldModel,
|
|
239
|
+
DummyDisplayFieldModel,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const parent = engine.createModel<DummyFormItemModel>({
|
|
243
|
+
use: DummyFormItemModel,
|
|
244
|
+
uid: 'form-item-association-rebuild',
|
|
245
|
+
subModels: {
|
|
246
|
+
field: {
|
|
247
|
+
use: FieldModel,
|
|
248
|
+
uid: 'field-association-rebuild',
|
|
249
|
+
props: {},
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
const targetTitleField = { name: 'name' };
|
|
254
|
+
const collectionField = {
|
|
255
|
+
targetCollectionTitleFieldName: 'name',
|
|
256
|
+
targetCollection: {
|
|
257
|
+
getField: vi.fn(() => targetTitleField),
|
|
258
|
+
},
|
|
259
|
+
isAssociationField: () => true,
|
|
260
|
+
};
|
|
261
|
+
parent.collectionField = collectionField;
|
|
262
|
+
const getDisplayBindingSpy = vi.spyOn(DetailsItemModel, 'getDefaultBindingByField').mockReturnValue({
|
|
263
|
+
modelName: 'DummyDisplayFieldModel',
|
|
264
|
+
defaultProps: { display: true },
|
|
265
|
+
} as any);
|
|
266
|
+
|
|
267
|
+
await pattern.afterParamsSave?.(
|
|
268
|
+
{
|
|
269
|
+
model: parent,
|
|
270
|
+
collectionField,
|
|
271
|
+
engine,
|
|
272
|
+
} as any,
|
|
273
|
+
{ pattern: 'readPretty' },
|
|
274
|
+
{ pattern: 'editable' },
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(parent.subModels.field).toBeInstanceOf(DummyDisplayFieldModel);
|
|
278
|
+
expect(parent.subModels.field?.props).toMatchObject({
|
|
279
|
+
display: true,
|
|
280
|
+
titleField: 'name',
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
getDisplayBindingSpy.mockRestore();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('refreshes the parent model after leaving display only mode', async () => {
|
|
287
|
+
const engine = new FlowEngine();
|
|
288
|
+
engine.registerModels({
|
|
289
|
+
DummyFormItemModel,
|
|
290
|
+
FieldModel,
|
|
291
|
+
DummyDisplayFieldModel,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const host = engine.createModel<FlowModel>({
|
|
295
|
+
use: FlowModel,
|
|
296
|
+
uid: 'sub-table-host',
|
|
297
|
+
});
|
|
298
|
+
const parent = engine.createModel<DummyFormItemModel>({
|
|
299
|
+
use: DummyFormItemModel,
|
|
300
|
+
uid: 'sub-table-column',
|
|
301
|
+
subModels: {
|
|
302
|
+
field: {
|
|
303
|
+
use: FieldModel,
|
|
304
|
+
uid: 'sub-table-column-field',
|
|
305
|
+
stepParams: {
|
|
306
|
+
fieldBinding: {
|
|
307
|
+
use: 'DummyDisplayFieldModel',
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
parent.setParent(host);
|
|
314
|
+
const hostSetPropsSpy = vi.spyOn(host, 'setProps');
|
|
315
|
+
|
|
316
|
+
await pattern.afterParamsSave?.(makeCtx(parent), { pattern: 'editable' }, { pattern: 'readPretty' });
|
|
317
|
+
|
|
318
|
+
expect(parent.subModels.field).toBeInstanceOf(FieldModel);
|
|
319
|
+
expect(parent.subModels.field?.uid).toBe('sub-table-column-field');
|
|
320
|
+
expect(hostSetPropsSpy).toHaveBeenCalledWith({
|
|
321
|
+
__patternRefreshKey: expect.any(String),
|
|
322
|
+
});
|
|
323
|
+
});
|
|
190
324
|
});
|
|
@@ -0,0 +1,45 @@
|
|
|
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 { titleField } from '../titleField';
|
|
12
|
+
|
|
13
|
+
describe('titleField action', () => {
|
|
14
|
+
it('builds options from target field interface metadata', () => {
|
|
15
|
+
const titleableField = {
|
|
16
|
+
name: 'nickname',
|
|
17
|
+
title: 'Nickname',
|
|
18
|
+
getInterfaceOptions: vi.fn(() => ({ titleUsable: true })),
|
|
19
|
+
};
|
|
20
|
+
const nonTitleableField = {
|
|
21
|
+
name: 'profile',
|
|
22
|
+
title: 'Profile',
|
|
23
|
+
getInterfaceOptions: vi.fn(() => ({ titleUsable: false })),
|
|
24
|
+
};
|
|
25
|
+
const missingContextManager = {
|
|
26
|
+
collectionFieldInterfaceManager: {
|
|
27
|
+
getFieldInterface: vi.fn(() => undefined),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const uiMode = (titleField as any).uiMode({
|
|
32
|
+
dataSourceManager: missingContextManager,
|
|
33
|
+
collectionField: {
|
|
34
|
+
targetCollection: {
|
|
35
|
+
getFields: () => [titleableField, nonTitleableField],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(uiMode.props.options).toEqual([{ value: 'nickname', label: 'Nickname' }]);
|
|
41
|
+
expect(titleableField.getInterfaceOptions).toHaveBeenCalled();
|
|
42
|
+
expect(nonTitleableField.getInterfaceOptions).toHaveBeenCalled();
|
|
43
|
+
expect(missingContextManager.collectionFieldInterfaceManager.getFieldInterface).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
import { defineAction, observer, tExpr, useFlowContext } from '@nocobase/flow-engine';
|
|
11
11
|
import { isEqual } from 'lodash';
|
|
12
12
|
import React from 'react';
|
|
13
|
-
import { FieldAssignRulesEditor
|
|
13
|
+
import { FieldAssignRulesEditor } from '../components/FieldAssignRulesEditor';
|
|
14
|
+
import type { FieldAssignRuleItem } from '../components/FieldAssignRulesEditor';
|
|
14
15
|
import { collectFieldAssignCascaderOptions } from '../components/fieldAssignOptions';
|
|
15
16
|
import { useAssociationTitleFieldSync } from '../components/useAssociationTitleFieldSync';
|
|
16
17
|
import { findFormItemModelByFieldPath, getCollectionFromModel } from '../internal/utils/modelUtils';
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
collectLegacyDefaultValueRulesFromFilterFormModel,
|
|
19
20
|
mergeAssignRulesWithLegacyDefaults,
|
|
20
21
|
} from '../models/blocks/filter-form/legacyDefaultValueMigration';
|
|
22
|
+
import { hasPersistedAssignRulesValue } from '../models/blocks/shared/legacyDefaultValueMigrationBase';
|
|
21
23
|
import { getDefaultOperator } from '../models/blocks/filter-manager/utils';
|
|
22
24
|
import { operators } from '../../flow-compat';
|
|
23
25
|
|
|
@@ -36,6 +38,10 @@ const FilterFormDefaultValuesUI = observer(
|
|
|
36
38
|
return collectLegacyDefaultValueRulesFromFilterFormModel(ctx.model);
|
|
37
39
|
}, [ctx.model]);
|
|
38
40
|
|
|
41
|
+
const hasPersistedValue = React.useMemo(() => {
|
|
42
|
+
return hasPersistedAssignRulesValue(ctx.model, 'formFilterBlockModelSettings', 'defaultValues');
|
|
43
|
+
}, [ctx.model]);
|
|
44
|
+
|
|
39
45
|
const getValueInputProps = React.useCallback(
|
|
40
46
|
(item: FieldAssignRuleItem) => {
|
|
41
47
|
const targetPath = item?.targetPath ? String(item.targetPath) : '';
|
|
@@ -57,7 +63,7 @@ const FilterFormDefaultValuesUI = observer(
|
|
|
57
63
|
);
|
|
58
64
|
|
|
59
65
|
// 兼容:将字段级默认值(filterFormItemSettings.initialValue)合并到表单级 defaultValues 里展示。
|
|
60
|
-
//
|
|
66
|
+
// 仅在表单级 defaultValues.value 尚未持久化时合并;已保存的空数组也代表用户显式删除。
|
|
61
67
|
const hasInitializedMergeRef = React.useRef(false);
|
|
62
68
|
const [hasInitializedMerge, setHasInitializedMerge] = React.useState(false);
|
|
63
69
|
const markInitialized = React.useCallback(() => {
|
|
@@ -66,12 +72,23 @@ const FilterFormDefaultValuesUI = observer(
|
|
|
66
72
|
setHasInitializedMerge(true);
|
|
67
73
|
}, []);
|
|
68
74
|
|
|
75
|
+
const normalizedValue = React.useMemo(() => {
|
|
76
|
+
return Array.isArray(props.value) ? props.value : [];
|
|
77
|
+
}, [props.value]);
|
|
78
|
+
|
|
79
|
+
const legacyAwareValue = React.useMemo(() => {
|
|
80
|
+
if (hasPersistedValue) {
|
|
81
|
+
return normalizedValue;
|
|
82
|
+
}
|
|
83
|
+
return mergeAssignRulesWithLegacyDefaults(props.value, legacyDefaults);
|
|
84
|
+
}, [hasPersistedValue, legacyDefaults, normalizedValue, props.value]);
|
|
85
|
+
|
|
69
86
|
const value = React.useMemo(() => {
|
|
70
87
|
if (!canEdit || !hasInitializedMerge) {
|
|
71
|
-
return
|
|
88
|
+
return legacyAwareValue;
|
|
72
89
|
}
|
|
73
|
-
return
|
|
74
|
-
}, [canEdit, hasInitializedMerge,
|
|
90
|
+
return normalizedValue;
|
|
91
|
+
}, [canEdit, hasInitializedMerge, legacyAwareValue, normalizedValue]);
|
|
75
92
|
|
|
76
93
|
const handleChange = React.useCallback(
|
|
77
94
|
(next: FieldAssignRuleItem[]) => {
|
|
@@ -87,12 +104,16 @@ const FilterFormDefaultValuesUI = observer(
|
|
|
87
104
|
if (hasInitializedMergeRef.current) return;
|
|
88
105
|
if (!canEdit) return;
|
|
89
106
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
107
|
+
if (hasPersistedValue) {
|
|
108
|
+
markInitialized();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!isEqual(normalizedValue, legacyAwareValue)) {
|
|
113
|
+
props.onChange?.(legacyAwareValue);
|
|
93
114
|
}
|
|
94
115
|
markInitialized();
|
|
95
|
-
}, [canEdit,
|
|
116
|
+
}, [canEdit, hasPersistedValue, legacyAwareValue, markInitialized, normalizedValue, props.onChange]);
|
|
96
117
|
|
|
97
118
|
return (
|
|
98
119
|
<FieldAssignRulesEditor
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
import { defineAction, observer, tExpr, useFlowContext } from '@nocobase/flow-engine';
|
|
11
11
|
import { isEqual } from 'lodash';
|
|
12
12
|
import React from 'react';
|
|
13
|
-
import { FieldAssignRulesEditor
|
|
13
|
+
import { FieldAssignRulesEditor } from '../components/FieldAssignRulesEditor';
|
|
14
|
+
import type { FieldAssignRuleItem } from '../components/FieldAssignRulesEditor';
|
|
14
15
|
import { collectFieldAssignCascaderOptions } from '../components/fieldAssignOptions';
|
|
15
16
|
import { useAssociationTitleFieldSync } from '../components/useAssociationTitleFieldSync';
|
|
16
17
|
import { getCollectionFromModel } from '../internal/utils/modelUtils';
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
collectLegacyDefaultValueRulesFromFormModel,
|
|
19
20
|
mergeAssignRulesWithLegacyDefaults,
|
|
20
21
|
} from '../models/blocks/form/legacyDefaultValueMigration';
|
|
22
|
+
import { hasPersistedAssignRulesValue } from '../models/blocks/shared/legacyDefaultValueMigrationBase';
|
|
21
23
|
|
|
22
24
|
const FormAssignRulesUI = observer(
|
|
23
25
|
(props: { value?: FieldAssignRuleItem[]; onChange?: (value: FieldAssignRuleItem[]) => void }) => {
|
|
@@ -34,8 +36,12 @@ const FormAssignRulesUI = observer(
|
|
|
34
36
|
return collectLegacyDefaultValueRulesFromFormModel(ctx.model);
|
|
35
37
|
}, [ctx.model]);
|
|
36
38
|
|
|
39
|
+
const hasPersistedValue = React.useMemo(() => {
|
|
40
|
+
return hasPersistedAssignRulesValue(ctx.model, 'formModelSettings', 'assignRules');
|
|
41
|
+
}, [ctx.model]);
|
|
42
|
+
|
|
37
43
|
// 兼容:将字段级默认值(editItemSettings/formItemSettings.initialValue)合并到表单级 assignRules 里展示。
|
|
38
|
-
//
|
|
44
|
+
// 仅在表单级 assignRules.value 尚未持久化时合并;已保存的空数组也代表用户显式删除。
|
|
39
45
|
const hasInitializedMergeRef = React.useRef(false);
|
|
40
46
|
const [hasInitializedMerge, setHasInitializedMerge] = React.useState(false);
|
|
41
47
|
const markInitialized = React.useCallback(() => {
|
|
@@ -49,12 +55,19 @@ const FormAssignRulesUI = observer(
|
|
|
49
55
|
return base;
|
|
50
56
|
}, [props.value]);
|
|
51
57
|
|
|
58
|
+
const legacyAwareValue = React.useMemo(() => {
|
|
59
|
+
if (hasPersistedValue) {
|
|
60
|
+
return normalizedValue;
|
|
61
|
+
}
|
|
62
|
+
return mergeAssignRulesWithLegacyDefaults(props.value, legacyDefaults);
|
|
63
|
+
}, [hasPersistedValue, legacyDefaults, normalizedValue, props.value]);
|
|
64
|
+
|
|
52
65
|
const value = React.useMemo(() => {
|
|
53
66
|
if (!canEdit || !hasInitializedMerge) {
|
|
54
|
-
return
|
|
67
|
+
return legacyAwareValue;
|
|
55
68
|
}
|
|
56
69
|
return normalizedValue;
|
|
57
|
-
}, [canEdit, hasInitializedMerge,
|
|
70
|
+
}, [canEdit, hasInitializedMerge, legacyAwareValue, normalizedValue]);
|
|
58
71
|
|
|
59
72
|
const handleChange = React.useCallback(
|
|
60
73
|
(next: FieldAssignRuleItem[]) => {
|
|
@@ -70,14 +83,16 @@ const FormAssignRulesUI = observer(
|
|
|
70
83
|
if (hasInitializedMergeRef.current) return;
|
|
71
84
|
if (!canEdit) return;
|
|
72
85
|
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
if (hasPersistedValue) {
|
|
87
|
+
markInitialized();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
75
90
|
|
|
76
|
-
if (!isEqual(
|
|
77
|
-
props.onChange?.(
|
|
91
|
+
if (!isEqual(normalizedValue, legacyAwareValue)) {
|
|
92
|
+
props.onChange?.(legacyAwareValue);
|
|
78
93
|
}
|
|
79
94
|
markInitialized();
|
|
80
|
-
}, [canEdit,
|
|
95
|
+
}, [canEdit, hasPersistedValue, legacyAwareValue, markInitialized, normalizedValue, props.onChange]);
|
|
81
96
|
|
|
82
97
|
return (
|
|
83
98
|
<FieldAssignRulesEditor
|