@nocobase/client-v2 2.2.0-beta.6 → 2.2.0-beta.8

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.
Files changed (73) hide show
  1. package/es/PluginSettingsManager.d.ts +33 -0
  2. package/es/flow/actions/afterSuccess.d.ts +8 -1
  3. package/es/flow/actions/index.d.ts +1 -1
  4. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +1 -0
  5. package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
  6. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +9 -0
  7. package/es/flow/models/blocks/js-block/JSBlock.d.ts +1 -0
  8. package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
  9. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  10. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
  11. package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
  12. package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
  13. package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
  14. package/es/index.mjs +105 -89
  15. package/lib/index.js +106 -90
  16. package/package.json +7 -7
  17. package/src/PluginSettingsManager.ts +53 -0
  18. package/src/__tests__/PluginSettingsManager.test.ts +13 -0
  19. package/src/__tests__/plugin-manager.test.tsx +44 -2
  20. package/src/__tests__/settings-center.test.tsx +7 -1
  21. package/src/collection-manager/field-configure.ts +1 -1
  22. package/src/collection-manager/interfaces/id.ts +1 -1
  23. package/src/collection-manager/interfaces/m2m.tsx +2 -2
  24. package/src/collection-manager/interfaces/m2o.tsx +2 -2
  25. package/src/collection-manager/interfaces/nanoid.ts +1 -1
  26. package/src/collection-manager/interfaces/o2m.tsx +2 -2
  27. package/src/collection-manager/interfaces/obo.tsx +2 -2
  28. package/src/collection-manager/interfaces/oho.tsx +2 -2
  29. package/src/collection-manager/interfaces/properties/index.ts +2 -2
  30. package/src/collection-manager/interfaces/uuid.ts +1 -1
  31. package/src/flow/__tests__/getKey.test.ts +7 -0
  32. package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
  33. package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
  34. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +58 -0
  35. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +104 -0
  36. package/src/flow/actions/afterSuccess.tsx +142 -3
  37. package/src/flow/actions/dataScopeFilter.ts +10 -0
  38. package/src/flow/actions/index.ts +1 -1
  39. package/src/flow/actions/openView.tsx +38 -4
  40. package/src/flow/admin-shell/BaseLayoutModel.tsx +25 -3
  41. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +5 -2
  42. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +13 -0
  43. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -0
  44. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -4
  45. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +82 -0
  46. package/src/flow/components/FieldAssignRulesEditor.tsx +66 -9
  47. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +427 -0
  48. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +151 -3
  49. package/src/flow/components/fieldAssignOptions.ts +155 -27
  50. package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
  51. package/src/flow/models/base/ActionModelCore.tsx +6 -4
  52. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +37 -0
  53. package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
  54. package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
  55. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
  56. package/src/flow/models/blocks/form/submitHandler.ts +17 -3
  57. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +87 -0
  58. package/src/flow/models/blocks/form/value-runtime/runtime.ts +91 -0
  59. package/src/flow/models/blocks/js-block/JSBlock.tsx +223 -2
  60. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +150 -0
  61. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +35 -12
  62. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -14
  63. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +57 -1
  64. package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
  65. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
  66. package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
  67. package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
  68. package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
  69. package/src/flow/resolveViewParamsToViewList.tsx +11 -3
  70. package/src/nocobase-buildin-plugin/index.tsx +0 -2
  71. package/src/settings-center/SystemSettingsPage.tsx +1 -2
  72. package/src/settings-center/plugin-manager/PluginCard.tsx +2 -2
  73. package/src/settings-center/utils.tsx +0 -6
@@ -14,6 +14,15 @@ import {
14
14
  collectFieldAssignCascaderOptions,
15
15
  } from '../fieldAssignOptions';
16
16
 
17
+ type TestCollectionField = {
18
+ name?: string;
19
+ title?: string;
20
+ interface?: string;
21
+ target?: string;
22
+ targetCollection?: unknown;
23
+ isAssociationField?: () => boolean;
24
+ };
25
+
17
26
  describe('fieldAssignOptions', () => {
18
27
  it('marks subform node as non-leaf so Cascader can lazy-load target collection fields', () => {
19
28
  const usersCollection = {
@@ -98,8 +107,58 @@ describe('fieldAssignOptions', () => {
98
107
  ).toBe(true);
99
108
  });
100
109
 
110
+ it('preserves configured display association field paths as nested cascader options', () => {
111
+ const orgNameField = {
112
+ name: 'orgname',
113
+ title: '公司名称(单行文本)',
114
+ interface: 'input',
115
+ type: 'string',
116
+ isAssociationField: () => false,
117
+ };
118
+ const orgCollection = {
119
+ getFields: () => [orgNameField],
120
+ getField: (name: string) => (name === 'orgname' ? orgNameField : null),
121
+ };
122
+ const orgAssociationField = {
123
+ name: 'org_m2o',
124
+ title: 'org_m2o',
125
+ interface: 'm2o',
126
+ type: 'belongsTo',
127
+ target: 'org',
128
+ isAssociationField: () => true,
129
+ targetCollection: orgCollection,
130
+ };
131
+ const rootCollection = {
132
+ getField: (name: string) => (name === 'org_m2o' ? orgAssociationField : null),
133
+ getFields: () => [orgAssociationField],
134
+ };
135
+ const displayAssociationItemModel = {
136
+ props: { label: '公司名称(单行文本)' },
137
+ getStepParams: (flowKey: string, stepKey: string) => {
138
+ if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath: 'org_m2o.orgname' };
139
+ return {};
140
+ },
141
+ subModels: {
142
+ field: {
143
+ context: { collectionField: orgNameField },
144
+ },
145
+ },
146
+ };
147
+ const formBlockModel = {
148
+ context: { collection: rootCollection },
149
+ subModels: { grid: { subModels: { items: [displayAssociationItemModel] } } },
150
+ };
151
+
152
+ const options = collectFieldAssignCascaderOptions({ formBlockModel, t: (s) => s });
153
+ const orgOption = options.find((option) => option.value === 'org_m2o');
154
+
155
+ expect(orgOption?.isLeaf).toBe(false);
156
+ expect(orgOption?.children?.some((child) => child.value === 'orgname' && child.isLeaf === true)).toBe(true);
157
+ expect(options.some((option) => option.value === 'orgname')).toBe(false);
158
+ });
159
+
101
160
  it('hides configured association fields deeper than relation / relation / field', () => {
102
- const makeItem = (fieldPath: string, field: any, label: string) => ({
161
+ const makeItem = (fieldPath: string, field: TestCollectionField, label: string) => ({
103
162
  props: { label },
104
163
  getStepParams: (flowKey: string, stepKey: string) => {
105
164
  if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath };
@@ -141,9 +200,98 @@ describe('fieldAssignOptions', () => {
141
200
  };
142
201
 
143
202
  const options = collectFieldAssignCascaderOptions({ formBlockModel, t: (s) => s });
203
+ const usersOption = options.find((item) => item.value === 'users');
204
+ const departmentOption = usersOption?.children?.find((item) => item.value === 'department');
205
+
206
+ expect(departmentOption?.children?.map((item) => item.value)).toContain('name');
207
+ expect(departmentOption?.children?.map((item) => item.value)).not.toContain('company');
208
+ });
209
+
210
+ it('hides configured non-association fields under a third association level', () => {
211
+ const codeField = {
212
+ name: 'code',
213
+ title: 'Code',
214
+ interface: 'input',
215
+ isAssociationField: () => false,
216
+ };
217
+ const companyCollection = {
218
+ getField: (name: string) => (name === 'code' ? codeField : null),
219
+ getFields: () => [codeField],
220
+ };
221
+ const companyField = {
222
+ name: 'company',
223
+ title: 'Company',
224
+ interface: 'm2o',
225
+ target: 'companies',
226
+ isAssociationField: () => true,
227
+ targetCollection: companyCollection,
228
+ };
229
+ const nameField = {
230
+ name: 'name',
231
+ title: 'Name',
232
+ interface: 'input',
233
+ isAssociationField: () => false,
234
+ };
235
+ const departmentCollection = {
236
+ getField: (name: string) => (name === 'name' ? nameField : name === 'company' ? companyField : null),
237
+ getFields: () => [nameField, companyField],
238
+ };
239
+ const departmentField = {
240
+ name: 'department',
241
+ title: 'Department',
242
+ interface: 'm2o',
243
+ target: 'departments',
244
+ isAssociationField: () => true,
245
+ targetCollection: departmentCollection,
246
+ };
247
+ const userCollection = {
248
+ getField: (name: string) => (name === 'department' ? departmentField : null),
249
+ getFields: () => [departmentField],
250
+ };
251
+ const usersField = {
252
+ name: 'users',
253
+ title: 'Users',
254
+ interface: 'm2m',
255
+ target: 'users',
256
+ isAssociationField: () => true,
257
+ targetCollection: userCollection,
258
+ };
259
+ const rootCollection = {
260
+ getField: (name: string) => (name === 'users' ? usersField : null),
261
+ getFields: () => [usersField],
262
+ };
263
+ const makeItem = (fieldPath: string, field: TestCollectionField, label: string) => ({
264
+ props: { label },
265
+ getStepParams: (flowKey: string, stepKey: string) => {
266
+ if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath };
267
+ return {};
268
+ },
269
+ subModels: {
270
+ field: {
271
+ context: { collectionField: field },
272
+ },
273
+ },
274
+ });
275
+ const formBlockModel = {
276
+ context: { collection: rootCollection },
277
+ subModels: {
278
+ grid: {
279
+ subModels: {
280
+ items: [
281
+ makeItem('users.department.name', nameField, 'Department name'),
282
+ makeItem('users.department.company.code', codeField, 'Company code'),
283
+ ],
284
+ },
285
+ },
286
+ },
287
+ };
288
+
289
+ const options = collectFieldAssignCascaderOptions({ formBlockModel, t: (s) => s });
290
+ const usersOption = options.find((item) => item.value === 'users');
291
+ const departmentOption = usersOption?.children?.find((item) => item.value === 'department');
144
292
 
145
- expect(options.map((item) => item.value)).toContain('name');
146
- expect(options.map((item) => item.value)).not.toContain('company');
293
+ expect(departmentOption?.children?.map((item) => item.value)).toContain('name');
294
+ expect(departmentOption?.children?.map((item) => item.value)).not.toContain('company');
147
295
  });
148
296
 
149
297
  it('hides lazy-loaded association selector options after two association levels', () => {
@@ -57,12 +57,11 @@ function getItemLabel(itemModel: any, t: (key: string) => string): string {
57
57
  return '';
58
58
  }
59
59
 
60
- function getLastPathSegment(path: string): string {
61
- const segs = String(path || '')
60
+ function splitFieldPath(path: string): string[] {
61
+ return String(path || '')
62
62
  .split('.')
63
63
  .map((s) => s.trim())
64
64
  .filter(Boolean);
65
- return segs.length ? segs[segs.length - 1] : '';
66
65
  }
67
66
 
68
67
  type CollectionLike = {
@@ -70,6 +69,15 @@ type CollectionLike = {
70
69
  getFields?: () => unknown[];
71
70
  };
72
71
 
72
+ type CollectionFieldLike = {
73
+ name?: unknown;
74
+ title?: unknown;
75
+ interface?: unknown;
76
+ target?: unknown;
77
+ isAssociationField?: () => boolean;
78
+ targetCollection?: CollectionLike | null;
79
+ };
80
+
73
81
  function getCollectionFromFormBlockModel(model: any): CollectionLike | null {
74
82
  if (!model || typeof model !== 'object') return null;
75
83
  const collection = (model as any)?.collection || (model as any)?.context?.collection;
@@ -89,14 +97,7 @@ export function buildFieldAssignCascaderOptionsFromCollection(
89
97
  const out: FieldAssignCascaderOption[] = [];
90
98
  for (const rawField of fields) {
91
99
  if (!rawField) continue;
92
- const f = rawField as {
93
- name?: unknown;
94
- title?: unknown;
95
- interface?: unknown;
96
- target?: unknown;
97
- isAssociationField?: () => boolean;
98
- targetCollection?: any;
99
- };
100
+ const f = rawField as CollectionFieldLike;
100
101
  const fieldInterface = typeof f.interface === 'string' ? f.interface : undefined;
101
102
  if (!fieldInterface) continue;
102
103
  if (fieldInterface === 'formula') continue;
@@ -123,23 +124,143 @@ export function buildFieldAssignCascaderOptionsFromCollection(
123
124
  function mergeRootOptions(
124
125
  configured: FieldAssignCascaderOption[],
125
126
  allFields: FieldAssignCascaderOption[],
127
+ ): FieldAssignCascaderOption[] {
128
+ return mergeCascaderOptions(configured, allFields);
129
+ }
130
+
131
+ function mergeCascaderOptions(
132
+ configured: FieldAssignCascaderOption[],
133
+ allFields: FieldAssignCascaderOption[],
126
134
  ): FieldAssignCascaderOption[] {
127
135
  const configuredList = Array.isArray(configured) ? configured : [];
128
136
  const allList = Array.isArray(allFields) ? allFields : [];
137
+ const result = configuredList.map((item) => cloneCascaderOption(item));
129
138
 
130
- const configuredValues = new Set<string>();
131
- for (const it of configuredList) {
132
- const v = it?.value ? String(it.value) : '';
133
- if (v) configuredValues.add(v);
139
+ for (const item of allList) {
140
+ mergeCascaderOptionInto(result, item);
134
141
  }
135
142
 
136
- const extra = allList.filter((it) => {
137
- const v = it?.value ? String(it.value) : '';
138
- if (!v) return false;
139
- return !configuredValues.has(v);
140
- });
143
+ return result;
144
+ }
145
+
146
+ function cloneCascaderOption(option: FieldAssignCascaderOption): FieldAssignCascaderOption {
147
+ return {
148
+ ...option,
149
+ children: option.children?.map((child) => cloneCascaderOption(child)),
150
+ };
151
+ }
152
+
153
+ function mergeCascaderOptionInto(options: FieldAssignCascaderOption[], source: FieldAssignCascaderOption) {
154
+ const value = source?.value ? String(source.value) : '';
155
+ if (!value) return;
156
+
157
+ const existing = options.find((item) => String(item?.value || '') === value);
158
+ if (!existing) {
159
+ options.push(cloneCascaderOption(source));
160
+ return;
161
+ }
162
+
163
+ if (!existing.label && source.label) {
164
+ existing.label = source.label;
165
+ }
166
+ if (source.isLeaf === false || existing.children?.length || source.children?.length) {
167
+ existing.isLeaf = false;
168
+ }
169
+ if (source.loading) {
170
+ existing.loading = source.loading;
171
+ }
172
+ if (source.children?.length) {
173
+ existing.children = mergeCascaderOptions(existing.children || [], source.children);
174
+ }
175
+ }
176
+
177
+ function getCollectionField(collection: CollectionLike | null, name: string): CollectionFieldLike | undefined {
178
+ const field = typeof collection?.getField === 'function' ? collection.getField(name) : undefined;
179
+ return field && typeof field === 'object' ? (field as CollectionFieldLike) : undefined;
180
+ }
181
+
182
+ function isAssociationFieldLike(field?: CollectionFieldLike | null) {
183
+ return !!(field?.isAssociationField?.() || field?.target || field?.targetCollection);
184
+ }
185
+
186
+ function getFieldLabelFromCollection(collection: CollectionLike | null, name: string, t: (key: string) => string) {
187
+ const field = getCollectionField(collection, name);
188
+ const title = typeof field?.title === 'string' && field.title ? field.title : name;
189
+ return t(title);
190
+ }
191
+
192
+ function getAssociationDepthFromFieldPath(
193
+ rootCollection: CollectionLike | null,
194
+ segments: string[],
195
+ ): number | undefined {
196
+ if (!rootCollection) return undefined;
197
+
198
+ let collection: CollectionLike | null = rootCollection;
199
+ let associationDepth = 0;
200
+ for (let index = 0; index < segments.length; index++) {
201
+ const field = getCollectionField(collection, segments[index]);
202
+ if (!field) return undefined;
203
+
204
+ if (isAssociationFieldLike(field)) {
205
+ associationDepth += 1;
206
+ collection = field.targetCollection || null;
207
+ continue;
208
+ }
209
+
210
+ if (index < segments.length - 1) return undefined;
211
+ }
212
+
213
+ return associationDepth;
214
+ }
215
+
216
+ function shouldIncludeConfiguredFieldPath(options: {
217
+ segments: string[];
218
+ rootCollection: CollectionLike | null;
219
+ leafField?: CollectionFieldLike | null;
220
+ maxAssociationFieldDepth: number;
221
+ }) {
222
+ const resolvedAssociationDepth = getAssociationDepthFromFieldPath(options.rootCollection, options.segments);
223
+ if (typeof resolvedAssociationDepth === 'number') {
224
+ return resolvedAssociationDepth <= options.maxAssociationFieldDepth;
225
+ }
226
+
227
+ const leafIsAssociation = isAssociationFieldLike(options.leafField);
228
+ const maxPathLength = leafIsAssociation ? options.maxAssociationFieldDepth : options.maxAssociationFieldDepth + 1;
229
+ return options.segments.length <= maxPathLength;
230
+ }
231
+
232
+ function buildNestedOptionFromFieldPath(options: {
233
+ targetPath: string;
234
+ leaf: FieldAssignCascaderOption;
235
+ rootCollection: CollectionLike | null;
236
+ t: (key: string) => string;
237
+ }): FieldAssignCascaderOption {
238
+ const segments = splitFieldPath(options.targetPath);
239
+ if (segments.length <= 1) {
240
+ return options.leaf;
241
+ }
242
+
243
+ const build = (index: number, collection: CollectionLike | null): FieldAssignCascaderOption => {
244
+ const value = segments[index];
245
+ const isLast = index === segments.length - 1;
246
+ if (isLast) {
247
+ return {
248
+ ...options.leaf,
249
+ value,
250
+ };
251
+ }
252
+
253
+ const field = getCollectionField(collection, value);
254
+ const nextCollection = field?.targetCollection || null;
255
+ return {
256
+ label: getFieldLabelFromCollection(collection, value, options.t),
257
+ value,
258
+ isLeaf: false,
259
+ children: [build(index + 1, nextCollection)],
260
+ };
261
+ };
141
262
 
142
- return [...configuredList, ...extra];
263
+ return build(0, options.rootCollection);
143
264
  }
144
265
 
145
266
  export function collectFieldAssignCascaderOptions(options: {
@@ -167,7 +288,8 @@ export function collectFieldAssignCascaderOptions(options: {
167
288
  const targetPath = getItemFieldPath(item);
168
289
  if (!targetPath) continue;
169
290
 
170
- const seg = getLastPathSegment(targetPath);
291
+ const segments = splitFieldPath(targetPath);
292
+ const seg = segments[segments.length - 1];
171
293
  if (!seg) continue;
172
294
 
173
295
  const node: FieldAssignCascaderOption = {
@@ -179,10 +301,16 @@ export function collectFieldAssignCascaderOptions(options: {
179
301
  const childItems = fieldModel?.subModels?.grid?.subModels?.items;
180
302
  const cf: (CollectionField & { target?: unknown; targetCollection?: unknown }) | undefined =
181
303
  fieldModel?.context?.collectionField;
182
- const isAssociation = !!(cf?.isAssociationField?.() || cf?.target || cf?.targetCollection);
304
+ const isAssociation = isAssociationFieldLike(cf);
183
305
  const hasTargetCollection = !!cf?.targetCollection;
184
- const associationDepth = isAssociation ? targetPath.split('.').filter(Boolean).length : 0;
185
- if (isAssociation && associationDepth > maxAssociationFieldDepth) {
306
+ if (
307
+ !shouldIncludeConfiguredFieldPath({
308
+ segments,
309
+ rootCollection,
310
+ leafField: cf,
311
+ maxAssociationFieldDepth,
312
+ })
313
+ ) {
186
314
  continue;
187
315
  }
188
316
 
@@ -203,7 +331,7 @@ export function collectFieldAssignCascaderOptions(options: {
203
331
  node.isLeaf = true;
204
332
  }
205
333
  }
206
- out.push(node);
334
+ mergeCascaderOptionInto(out, buildNestedOptionFromFieldPath({ targetPath, leaf: node, rootCollection, t }));
207
335
  continue;
208
336
  }
209
337
 
@@ -215,7 +343,7 @@ export function collectFieldAssignCascaderOptions(options: {
215
343
  node.isLeaf = true;
216
344
  }
217
345
 
218
- out.push(node);
346
+ mergeCascaderOptionInto(out, buildNestedOptionFromFieldPath({ targetPath, leaf: node, rootCollection, t }));
219
347
  }
220
348
 
221
349
  return out;
@@ -33,6 +33,7 @@ export const getKey = (viewItem: ViewItem) => {
33
33
  stableStringify(params.viewUid),
34
34
  stableStringify(params.sourceId),
35
35
  stableStringify(params.filterByTk),
36
+ stableStringify(params.openViewRouteState),
36
37
  String(index),
37
38
  ];
38
39
 
@@ -25,7 +25,7 @@ export function ActionWithoutPermission(props) {
25
25
  const dataSourcePrefix = `${t(dataSource.displayName || dataSource.key)} > `;
26
26
  const collectionPrefix = collection ? `${t(collection.title) || collection.name || collection.tableName} ` : '';
27
27
  return `${dataSourcePrefix}${collectionPrefix}`;
28
- }, []);
28
+ }, [collection, dataSource.displayName, dataSource.key, t]);
29
29
  const { actionName } = props?.forbidden || model.forbidden;
30
30
  const messageValue = useMemo(() => {
31
31
  return t(
@@ -143,10 +143,11 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
143
143
  renderButton() {
144
144
  const { iconOnly, ...props } = this.props;
145
145
  const icon = this.getIcon() ? <Icon type={this.getIcon() as any} /> : undefined;
146
+ const titleContent = iconOnly && icon ? null : props.children || this.getTitle();
146
147
 
147
148
  return (
148
149
  <Button {...props} onClick={this.onClick.bind(this)} icon={icon}>
149
- {iconOnly ? null : props.children || this.getTitle()}
150
+ {titleContent}
150
151
  </Button>
151
152
  );
152
153
  }
@@ -162,11 +163,12 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
162
163
  renderHiddenInConfig(): React.ReactNode | undefined {
163
164
  const { iconOnly, ...props } = this.props;
164
165
  const icon = this.getIcon() ? <Icon type={this.getIcon() as any} /> : undefined;
166
+ const titleContent = iconOnly && icon ? null : props.children || this.getTitle();
165
167
  if (this.forbidden) {
166
168
  return (
167
169
  <ActionWithoutPermission>
168
170
  <Button {...props} onClick={this.onClick.bind(this)} icon={icon} style={{ opacity: '0.3' }}>
169
- {iconOnly ? null : props.children || this.getTitle()}
171
+ {titleContent}
170
172
  </Button>
171
173
  </ActionWithoutPermission>
172
174
  );
@@ -174,7 +176,7 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
174
176
  return (
175
177
  <Tooltip title={this.context.t('The button is hidden and only visible when the UI Editor is active')}>
176
178
  <Button {...props} onClick={this.onClick.bind(this)} icon={icon} style={{ opacity: '0.3' }}>
177
- {iconOnly ? null : props.children || this.getTitle()}
179
+ {titleContent}
178
180
  </Button>
179
181
  </Tooltip>
180
182
  );
@@ -0,0 +1,37 @@
1
+ import { render, screen } from '@nocobase/test/client';
2
+ import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
3
+ import { App, ConfigProvider } from 'antd';
4
+ import React from 'react';
5
+ import { describe, expect, it } from 'vitest';
6
+ import { ActionModel } from '../ActionModel';
7
+
8
+ class NoIconActionModel extends ActionModel {
9
+ defaultProps = {
10
+ type: 'link' as const,
11
+ title: 'Open details',
12
+ };
13
+ }
14
+
15
+ describe('ActionModel rendering', () => {
16
+ it('shows the title when iconOnly is true but no icon is configured', () => {
17
+ const engine = new FlowEngine();
18
+ engine.registerModels({ NoIconActionModel });
19
+ const model = engine.createModel<NoIconActionModel>({
20
+ use: 'NoIconActionModel',
21
+ props: {
22
+ title: 'Open details',
23
+ iconOnly: true,
24
+ },
25
+ });
26
+
27
+ render(
28
+ <FlowEngineProvider engine={engine}>
29
+ <ConfigProvider>
30
+ <App>{model.render()}</App>
31
+ </ConfigProvider>
32
+ </FlowEngineProvider>,
33
+ );
34
+
35
+ expect(screen.getByRole('button', { name: 'Open details' })).toBeInTheDocument();
36
+ });
37
+ });
@@ -210,7 +210,7 @@ FilterFormCustomFieldModel.registerFlow({
210
210
  'x-decorator': 'FormItem',
211
211
  required: true,
212
212
  description:
213
- '{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.")}}',
213
+ '{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.")}}',
214
214
  },
215
215
  source: {
216
216
  type: 'array',
@@ -117,7 +117,7 @@ FormSubmitActionModel.registerFlow({
117
117
  try {
118
118
  ctx.model.setProps('loading', true);
119
119
  const { submitHandler } = await import('./submitHandler');
120
- await submitHandler(ctx, params);
120
+ return await submitHandler(ctx, params);
121
121
  } catch (error) {
122
122
  ctx.model.setProps('loading', false);
123
123
  if (error instanceof FlowExitAllException) {
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { SingleRecordResource } from '@nocobase/flow-engine';
11
11
  import { describe, expect, it, vi } from 'vitest';
12
+ import { EditFormModel } from '../EditFormModel';
12
13
  import { submitHandler } from '../submitHandler';
13
14
 
14
15
  describe('submitHandler', () => {
@@ -52,13 +53,14 @@ describe('submitHandler', () => {
52
53
  t: (value: string) => value,
53
54
  };
54
55
 
55
- await submitHandler(ctx, {
56
+ const responseRecord = await submitHandler(ctx, {
56
57
  assignedValues: {
57
58
  status: 'published',
58
59
  reviewer: 'Alice',
59
60
  },
60
61
  });
61
62
 
63
+ expect(responseRecord).toEqual({ id: 1 });
62
64
  expect(resource.save).toHaveBeenCalledWith(
63
65
  {
64
66
  title: 'Draft title',
@@ -68,4 +70,55 @@ describe('submitHandler', () => {
68
70
  undefined,
69
71
  );
70
72
  });
73
+
74
+ it('returns the refreshed record for edit forms when the save response is empty', async () => {
75
+ const refreshedRecord = { id: 2, title: 'Updated title' };
76
+ const resource = Object.create(SingleRecordResource.prototype);
77
+ resource.getMeta = vi.fn((key: string) => (key === 'currentFilterByTk' ? 2 : undefined));
78
+ resource.setFilterByTk = vi.fn(() => resource);
79
+ resource.save = vi.fn(async () => undefined);
80
+ resource.refresh = vi.fn(async () => undefined);
81
+ resource.getData = vi.fn(() => refreshedRecord);
82
+
83
+ const blockModel = Object.create(EditFormModel.prototype);
84
+ const form = {
85
+ validateFields: vi.fn(async () => undefined),
86
+ getFieldsValue: vi.fn(() => ({
87
+ title: 'Updated title',
88
+ })),
89
+ };
90
+ Object.defineProperty(blockModel, 'context', { value: { form } });
91
+ Object.defineProperty(blockModel, 'collection', {
92
+ value: {
93
+ name: 'posts',
94
+ getFilterByTK: (record: { id?: number }) => record.id,
95
+ },
96
+ });
97
+ blockModel.resetUserModifiedFields = vi.fn();
98
+
99
+ const responseRecord = await submitHandler(
100
+ {
101
+ resource,
102
+ blockModel,
103
+ model: {
104
+ getStepParams: vi.fn(),
105
+ },
106
+ message: {
107
+ error: vi.fn(),
108
+ },
109
+ t: (value: string) => value,
110
+ },
111
+ {},
112
+ );
113
+
114
+ expect(responseRecord).toBe(refreshedRecord);
115
+ expect(resource.setFilterByTk).toHaveBeenCalledWith(2);
116
+ expect(resource.save).toHaveBeenCalledWith(
117
+ {
118
+ title: 'Updated title',
119
+ },
120
+ undefined,
121
+ );
122
+ expect(resource.refresh).toHaveBeenCalled();
123
+ });
71
124
  });
@@ -12,6 +12,16 @@ import { mergeAssignFieldValues, resolveAssignFieldValues } from '../assign-form
12
12
  import type { FormBlockModel } from './FormBlockModel';
13
13
  import { omitHiddenModelValuesFromSubmit, shouldSkipSubmitValidation, validateSubmitForm } from './submitValues';
14
14
 
15
+ function getResponseRecord(response: unknown) {
16
+ if (!response || typeof response !== 'object') {
17
+ return response;
18
+ }
19
+ if ('data' in response) {
20
+ return (response as { data?: unknown }).data;
21
+ }
22
+ return response;
23
+ }
24
+
15
25
  export async function submitHandler(ctx, params, cb?: (values?: any, filterByTk?: any) => void) {
16
26
  const resource = ctx.resource;
17
27
  const blockModel = ctx.blockModel as FormBlockModel;
@@ -41,30 +51,34 @@ export async function submitHandler(ctx, params, cb?: (values?: any, filterByTk?
41
51
  resource.setFilterByTk(currentFilterByTk);
42
52
  }
43
53
  }
44
- const data: any = cb ? await cb(values) : await resource.save(values, params.requestConfig);
54
+ const data: unknown = cb ? await cb(values) : await resource.save(values, params.requestConfig);
55
+ let responseRecord = getResponseRecord(data);
45
56
  if (isEditFormModel) {
46
57
  resource.isNewRecord = false;
47
58
  // 编辑表单保存成功后,表单应回到“已同步”状态:下一次刷新应允许覆盖为服务端值
48
59
  blockModel.resetUserModifiedFields?.();
49
60
  await resource.refresh();
61
+ responseRecord = responseRecord ?? resource.getData?.();
50
62
  } else {
51
63
  blockModel.form.resetFields();
52
64
  blockModel.emitter.emit('onFieldReset');
53
65
  blockModel.resetUserModifiedFields?.();
54
66
  blockModel.formValueRuntime?.resetAfterFormReset?.();
55
67
  if (ctx.view.inputArgs.collectionName === blockModel.collection.name && ctx.view.inputArgs.onChange) {
56
- ctx.view.inputArgs.onChange(data?.data);
68
+ ctx.view.inputArgs.onChange(responseRecord);
57
69
  }
58
70
  }
71
+ return responseRecord;
59
72
  } else if (resource instanceof MultiRecordResource) {
60
73
  const currentFilterByTk = resource.getMeta('currentFilterByTk');
61
74
  if (!currentFilterByTk) {
62
75
  ctx.message.error(ctx.t('No filterByTk found for multi-record resource.'));
63
76
  return;
64
77
  }
65
- (await cb)
78
+ const data = cb
66
79
  ? await cb(values, currentFilterByTk)
67
80
  : await resource.update(currentFilterByTk, values, params.requestConfig);
68
81
  blockModel.resetUserModifiedFields?.();
82
+ return getResponseRecord(data) ?? blockModel.getCurrentRecord?.();
69
83
  }
70
84
  }