@nocobase/client-v2 3.0.0-alpha.5 → 3.0.0-alpha.7

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.
@@ -0,0 +1,433 @@
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 { fireEvent, render } from '@testing-library/react';
11
+ import { css } from '@emotion/css';
12
+ import { ConfigProvider, Table as AntdTable } from 'antd';
13
+ import type { ColumnsType } from 'antd/es/table/interface';
14
+ import type { RenderedCell } from 'rc-table/lib/interface';
15
+ import React from 'react';
16
+ import { describe, expect, it, vi } from 'vitest';
17
+ import { Table, type TableProps } from '../Table';
18
+
19
+ type Row = {
20
+ id: number;
21
+ name: string;
22
+ note: string;
23
+ };
24
+
25
+ const row: Row = {
26
+ id: 1,
27
+ name: 'Long unbroken content',
28
+ note: 'Secondary content',
29
+ };
30
+
31
+ const defaultColumnMaxWidth = 400;
32
+ const defaultColumnContentClassName = 'nb-table-default-column-content';
33
+
34
+ function getMatchingStyleValues(element: Element, property: string) {
35
+ return Array.from(document.styleSheets).flatMap((styleSheet) => {
36
+ try {
37
+ return Array.from(styleSheet.cssRules).flatMap((rule) => {
38
+ if (!(rule instanceof CSSStyleRule)) return [];
39
+ try {
40
+ return element.matches(rule.selectorText) ? [rule.style.getPropertyValue(property)] : [];
41
+ } catch {
42
+ return [];
43
+ }
44
+ });
45
+ } catch {
46
+ return [];
47
+ }
48
+ });
49
+ }
50
+
51
+ function renderTable(columns: ColumnsType<Row>, props: Partial<TableProps<Row>> = {}) {
52
+ return render(
53
+ <ConfigProvider
54
+ theme={{
55
+ token: {
56
+ screenXS: 500,
57
+ paddingXL: 40,
58
+ padding: 10,
59
+ },
60
+ }}
61
+ >
62
+ <Table<Row> rowKey="id" columns={columns} dataSource={[row]} pagination={false} {...props} />
63
+ </ConfigProvider>,
64
+ );
65
+ }
66
+
67
+ function getResolvedMaxWidth(element: HTMLElement) {
68
+ const style = window.getComputedStyle(element);
69
+ const variableName = style.maxWidth.match(/^var\((--[^)]+)\)$/)?.[1];
70
+ return variableName ? style.getPropertyValue(variableName).trim() : style.maxWidth;
71
+ }
72
+
73
+ function expectDefaultWrappingStyle(element: Element | null) {
74
+ expect(element).not.toBeNull();
75
+ const htmlElement = element as HTMLElement;
76
+ const style = window.getComputedStyle(htmlElement);
77
+ expect(htmlElement.classList).toContain(defaultColumnContentClassName);
78
+ expect(getResolvedMaxWidth(htmlElement)).toBe(`${defaultColumnMaxWidth}px`);
79
+ expect(style.whiteSpace).toBe('normal');
80
+ expect(style.overflowWrap).toBe('break-word');
81
+ expect(style.wordBreak).toBe('break-word');
82
+ }
83
+
84
+ function expectNoDefaultWrappingStyle(element: Element | null) {
85
+ expect(element).not.toBeNull();
86
+ const htmlElement = element as HTMLElement;
87
+ expect(htmlElement.classList).not.toContain(defaultColumnContentClassName);
88
+ expect(getResolvedMaxWidth(htmlElement)).not.toBe(`${defaultColumnMaxWidth}px`);
89
+ }
90
+
91
+ describe('Table default column content width', () => {
92
+ it('adds token-derived wrapping styles to unconstrained leaf headers and cells', () => {
93
+ const { getByRole } = renderTable([{ title: 'Name', dataIndex: 'name' }]);
94
+
95
+ expectDefaultWrappingStyle(getByRole('columnheader', { name: 'Name' }));
96
+ expectDefaultWrappingStyle(getByRole('cell', { name: row.name }));
97
+ });
98
+
99
+ it('leaves columns with explicit width, ellipsis, or fixed positioning unchanged', () => {
100
+ const columns: ColumnsType<Row> = [
101
+ { title: 'Width', dataIndex: 'name', width: 240 },
102
+ { title: 'Ellipsis', dataIndex: 'note', ellipsis: true },
103
+ { title: 'Fixed', dataIndex: 'id', fixed: 'left' },
104
+ ];
105
+ const { getByRole } = renderTable(columns, { scroll: { x: 800 } });
106
+
107
+ for (const title of ['Width', 'Ellipsis', 'Fixed']) {
108
+ expectNoDefaultWrappingStyle(getByRole('columnheader', { name: title }));
109
+ }
110
+
111
+ const widthCell = getByRole('cell', { name: row.name });
112
+ const ellipsisCell = getByRole('cell', { name: row.note });
113
+ const fixedCell = getByRole('cell', { name: String(row.id) });
114
+ for (const cell of [widthCell, ellipsisCell, fixedCell]) {
115
+ expectNoDefaultWrappingStyle(cell);
116
+ }
117
+ expect(ellipsisCell.classList).toContain('ant-table-cell-ellipsis');
118
+ expect(window.getComputedStyle(ellipsisCell).whiteSpace).toBe('nowrap');
119
+ });
120
+
121
+ it('preserves cell callbacks and lets caller styles override the defaults', () => {
122
+ const handleClick = vi.fn();
123
+ const onCell = vi.fn(() => ({
124
+ colSpan: 2,
125
+ className: 'custom-body-cell',
126
+ onClick: handleClick,
127
+ style: {
128
+ maxWidth: 120,
129
+ whiteSpace: 'pre' as const,
130
+ },
131
+ }));
132
+ const onHeaderCell = vi.fn(() => ({
133
+ colSpan: 2,
134
+ className: 'custom-header-cell',
135
+ style: {
136
+ maxWidth: 180,
137
+ overflowWrap: 'normal' as const,
138
+ },
139
+ }));
140
+ const columns: ColumnsType<Row> = [
141
+ { title: 'Name', dataIndex: 'name', onCell, onHeaderCell },
142
+ { title: 'Note', dataIndex: 'note', width: 100 },
143
+ ];
144
+ const { container } = renderTable(columns);
145
+
146
+ const header = container.querySelector<HTMLElement>('th.custom-header-cell');
147
+ expect(header?.getAttribute('colspan')).toBe('2');
148
+ const headerStyle = window.getComputedStyle(header as HTMLElement);
149
+ expect(headerStyle.maxWidth).toBe('180px');
150
+ expect(headerStyle.whiteSpace).toBe('normal');
151
+ expect(headerStyle.overflowWrap).toBe('normal');
152
+ expect(headerStyle.wordBreak).toBe('break-word');
153
+
154
+ const cell = container.querySelector<HTMLElement>('td.custom-body-cell');
155
+ expect(cell?.getAttribute('colspan')).toBe('2');
156
+ const cellStyle = window.getComputedStyle(cell as HTMLElement);
157
+ expect(cellStyle.maxWidth).toBe('120px');
158
+ expect(cellStyle.whiteSpace).toBe('pre');
159
+ expect(cellStyle.overflowWrap).toBe('break-word');
160
+ expect(cellStyle.wordBreak).toBe('break-word');
161
+
162
+ fireEvent.click(cell as HTMLElement);
163
+ expect(handleClick).toHaveBeenCalledTimes(1);
164
+ expect(onCell).toHaveBeenCalledWith(row, 0);
165
+ expect(onHeaderCell).toHaveBeenCalledWith(expect.objectContaining({ title: 'Name' }));
166
+ });
167
+
168
+ it('keeps the default wrapping behavior when caller classNames have no styles', () => {
169
+ const columns: ColumnsType<Row> = [
170
+ {
171
+ title: 'Name',
172
+ dataIndex: 'name',
173
+ className: 'column-marker',
174
+ onCell: () => ({ className: 'body-marker' }),
175
+ onHeaderCell: () => ({ className: 'header-marker' }),
176
+ },
177
+ {
178
+ title: 'Note',
179
+ dataIndex: 'note',
180
+ render: () => ({ children: 'Rendered marker', props: { className: 'render-marker' } }),
181
+ },
182
+ ];
183
+ const { getByRole } = renderTable(columns);
184
+
185
+ const nameHeader = getByRole('columnheader', { name: 'Name' });
186
+ const nameCell = getByRole('cell', { name: row.name });
187
+ const renderedCell = getByRole('cell', { name: 'Rendered marker' });
188
+ expect(nameHeader.classList).toContain('column-marker');
189
+ expect(nameHeader.classList).toContain('header-marker');
190
+ expect(nameCell.classList).toContain('column-marker');
191
+ expect(nameCell.classList).toContain('body-marker');
192
+ expect(renderedCell.classList).toContain('render-marker');
193
+ for (const element of [nameHeader, nameCell, renderedCell]) {
194
+ expectDefaultWrappingStyle(element);
195
+ }
196
+ });
197
+
198
+ it('lets caller className styles override the defaults', () => {
199
+ const callerClassName = css`
200
+ max-width: 135px;
201
+ white-space: pre;
202
+ overflow-wrap: normal;
203
+ word-break: normal;
204
+ `;
205
+ const columns: ColumnsType<Row> = [
206
+ {
207
+ title: 'Name',
208
+ dataIndex: 'name',
209
+ onCell: () => ({ className: callerClassName }),
210
+ onHeaderCell: () => ({ className: callerClassName }),
211
+ },
212
+ { title: 'Note', dataIndex: 'note', className: callerClassName },
213
+ ];
214
+ // Use a distinct token value so this render creates its default Emotion class after the caller class. This
215
+ // catches source-order regressions that would be hidden if a previous test had already inserted the default rule.
216
+ const { getByRole } = render(
217
+ <ConfigProvider theme={{ token: { screenXS: 501, paddingXL: 40, padding: 10 } }}>
218
+ <Table<Row> rowKey="id" columns={columns} dataSource={[row]} pagination={false} />
219
+ </ConfigProvider>,
220
+ );
221
+
222
+ for (const element of [
223
+ getByRole('columnheader', { name: 'Name' }),
224
+ getByRole('columnheader', { name: 'Note' }),
225
+ getByRole('cell', { name: row.name }),
226
+ getByRole('cell', { name: row.note }),
227
+ ]) {
228
+ const style = window.getComputedStyle(element);
229
+ expect(getResolvedMaxWidth(element)).toBe('135px');
230
+ expect(style.whiteSpace).toBe('pre');
231
+ expect(style.overflowWrap).not.toBe('anywhere');
232
+ expect(getMatchingStyleValues(element, 'overflow-wrap')).toContain('normal');
233
+ expect(style.wordBreak).toBe('normal');
234
+ }
235
+ });
236
+
237
+ it('lets className returned from render override the defaults on first mount', () => {
238
+ const callerClassName = css`
239
+ max-width: 136px;
240
+ white-space: pre;
241
+ overflow-wrap: normal;
242
+ word-break: normal;
243
+ `;
244
+ const renderedCell: RenderedCell<Row> = {
245
+ props: { className: callerClassName },
246
+ };
247
+ const { container } = render(
248
+ <ConfigProvider theme={{ token: { screenXS: 502, paddingXL: 40, padding: 10 } }}>
249
+ <Table<Row>
250
+ rowKey="id"
251
+ columns={[{ title: 'Name', dataIndex: 'name', render: () => renderedCell }]}
252
+ dataSource={[row]}
253
+ pagination={false}
254
+ />
255
+ </ConfigProvider>,
256
+ );
257
+
258
+ const cell = container.querySelector<HTMLElement>('.ant-table-tbody td');
259
+ expect(cell).not.toBeNull();
260
+ const htmlCell = cell as HTMLElement;
261
+ const style = window.getComputedStyle(htmlCell);
262
+ expect(getResolvedMaxWidth(htmlCell)).toBe('136px');
263
+ expect(style.whiteSpace).toBe('pre');
264
+ expect(style.overflowWrap).not.toBe('anywhere');
265
+ expect(getMatchingStyleValues(cell as HTMLElement, 'overflow-wrap')).toContain('normal');
266
+ expect(style.wordBreak).toBe('normal');
267
+ });
268
+
269
+ it('lets static caller className styles override defaults inserted later', () => {
270
+ const callerClassName = 'caller-static-table-column';
271
+ const callerStyle = document.createElement('style');
272
+ callerStyle.textContent = `
273
+ .${callerClassName} {
274
+ max-width: 137px;
275
+ white-space: pre;
276
+ overflow-wrap: normal;
277
+ word-break: normal;
278
+ }
279
+ `;
280
+ document.head.prepend(callerStyle);
281
+
282
+ try {
283
+ const columns: ColumnsType<Row> = [
284
+ {
285
+ title: 'Name',
286
+ dataIndex: 'name',
287
+ onCell: () => ({ className: callerClassName }),
288
+ onHeaderCell: () => ({ className: callerClassName }),
289
+ },
290
+ { title: 'Note', dataIndex: 'note', className: callerClassName },
291
+ {
292
+ title: 'Rendered',
293
+ dataIndex: 'id',
294
+ render: () => ({ props: { className: callerClassName } }),
295
+ },
296
+ ];
297
+ const { container, getByRole } = render(
298
+ <ConfigProvider theme={{ token: { screenXS: 503, paddingXL: 40, padding: 10 } }}>
299
+ <Table<Row> rowKey="id" columns={columns} dataSource={[row]} pagination={false} />
300
+ </ConfigProvider>,
301
+ );
302
+ const cells = Array.from(container.querySelectorAll<HTMLElement>('.ant-table-tbody td'));
303
+
304
+ for (const element of [
305
+ getByRole('columnheader', { name: 'Name' }),
306
+ getByRole('columnheader', { name: 'Note' }),
307
+ cells[0],
308
+ cells[1],
309
+ cells[2],
310
+ ]) {
311
+ expect(element).toBeDefined();
312
+ const htmlElement = element as HTMLElement;
313
+ const style = window.getComputedStyle(htmlElement);
314
+ expect(getResolvedMaxWidth(htmlElement)).toBe('137px');
315
+ expect(style.whiteSpace).toBe('pre');
316
+ expect(style.overflowWrap).not.toBe('anywhere');
317
+ expect(getMatchingStyleValues(htmlElement, 'overflow-wrap')).toContain('normal');
318
+ expect(style.wordBreak).toBe('normal');
319
+ }
320
+ } finally {
321
+ callerStyle.remove();
322
+ }
323
+ });
324
+
325
+ it('lets styles returned from render override the defaults', () => {
326
+ const renderedCell: RenderedCell<Row> = {
327
+ children: 'Rendered cell',
328
+ props: {
329
+ className: 'rendered-cell',
330
+ style: {
331
+ maxWidth: 123,
332
+ whiteSpace: 'pre',
333
+ overflowWrap: 'normal',
334
+ wordBreak: 'normal',
335
+ },
336
+ },
337
+ };
338
+ const { getByRole } = renderTable([{ title: 'Name', dataIndex: 'name', render: () => renderedCell }]);
339
+
340
+ const style = window.getComputedStyle(getByRole('cell', { name: 'Rendered cell' }));
341
+ expect(style.maxWidth).toBe('123px');
342
+ expect(style.whiteSpace).toBe('pre');
343
+ expect(style.overflowWrap).toBe('normal');
344
+ expect(style.wordBreak).toBe('normal');
345
+ });
346
+
347
+ it('processes only leaves in nested columns without mutating the input', () => {
348
+ const nameColumn = { title: 'Name', dataIndex: 'name' as const };
349
+ const noteColumn = { title: 'Note', dataIndex: 'note' as const };
350
+ const children = [nameColumn, noteColumn];
351
+ const groupColumn = { title: 'Details', children };
352
+ const columns: ColumnsType<Row> = [groupColumn];
353
+
354
+ const { getByRole } = renderTable(columns);
355
+
356
+ expectNoDefaultWrappingStyle(getByRole('columnheader', { name: 'Details' }));
357
+ expectDefaultWrappingStyle(getByRole('columnheader', { name: 'Name' }));
358
+ expectDefaultWrappingStyle(getByRole('columnheader', { name: 'Note' }));
359
+ expectDefaultWrappingStyle(getByRole('cell', { name: row.name }));
360
+ expectDefaultWrappingStyle(getByRole('cell', { name: row.note }));
361
+
362
+ expect(columns[0]).toBe(groupColumn);
363
+ expect(groupColumn.children).toBe(children);
364
+ expect(children[0]).toBe(nameColumn);
365
+ expect(children[1]).toBe(noteColumn);
366
+ expect(nameColumn).not.toHaveProperty('onCell');
367
+ expect(nameColumn).not.toHaveProperty('onHeaderCell');
368
+ expect(noteColumn).not.toHaveProperty('onCell');
369
+ expect(noteColumn).not.toHaveProperty('onHeaderCell');
370
+ });
371
+
372
+ it('does not apply the default boundary to selection or drag-handle columns', () => {
373
+ const columns: ColumnsType<Row> = [{ title: 'Name', dataIndex: 'name' }];
374
+ const selectionTable = renderTable(columns, { rowSelection: {} });
375
+
376
+ expectNoDefaultWrappingStyle(selectionTable.container.querySelector<HTMLElement>('th.ant-table-selection-column'));
377
+ expectNoDefaultWrappingStyle(selectionTable.container.querySelector<HTMLElement>('td.ant-table-selection-column'));
378
+ expectDefaultWrappingStyle(selectionTable.getByRole('columnheader', { name: 'Name' }));
379
+ selectionTable.unmount();
380
+
381
+ const dragTable = renderTable(columns, { isDraggable: true, onSortEnd: vi.fn() });
382
+ const dragHeader = dragTable.container.querySelector<HTMLElement>('.ant-table-thead th:first-child');
383
+ const dragCell = dragTable.container.querySelector<HTMLElement>('.ant-table-tbody td:first-child');
384
+
385
+ expectNoDefaultWrappingStyle(dragHeader);
386
+ expectNoDefaultWrappingStyle(dragCell);
387
+ expectDefaultWrappingStyle(dragTable.getByRole('columnheader', { name: 'Name' }));
388
+ });
389
+
390
+ it('preserves Ant Design sentinels used to position expand and selection columns', () => {
391
+ const columns: ColumnsType<Row> = [
392
+ { title: 'Name', dataIndex: 'name' },
393
+ AntdTable.EXPAND_COLUMN,
394
+ AntdTable.SELECTION_COLUMN,
395
+ { title: 'Note', dataIndex: 'note' },
396
+ ];
397
+ const { container } = renderTable(columns, {
398
+ expandable: { expandedRowRender: (record) => <span>{record.note}</span> },
399
+ rowSelection: {},
400
+ });
401
+
402
+ const headers = Array.from(container.querySelectorAll<HTMLElement>('.ant-table-thead th'));
403
+ expect(headers).toHaveLength(4);
404
+ expect(headers[0].textContent).toBe('Name');
405
+ expect(headers[1].classList).toContain('ant-table-row-expand-icon-cell');
406
+ expect(headers[2].classList).toContain('ant-table-selection-column');
407
+ expect(headers[3].textContent).toBe('Note');
408
+ expectNoDefaultWrappingStyle(headers[1]);
409
+ expectNoDefaultWrappingStyle(headers[2]);
410
+ });
411
+
412
+ it('treats empty, undefined, or null children as leaves like antd', () => {
413
+ const columns = [
414
+ { title: 'Empty children', dataIndex: 'name', children: [] },
415
+ { title: 'Undefined children', dataIndex: 'note', children: undefined },
416
+ { title: 'Null children', dataIndex: 'id', children: null },
417
+ ] as unknown as ColumnsType<Row>;
418
+ const { getByRole } = renderTable(columns);
419
+
420
+ expectDefaultWrappingStyle(getByRole('columnheader', { name: 'Empty children' }));
421
+ expectDefaultWrappingStyle(getByRole('columnheader', { name: 'Undefined children' }));
422
+ expectDefaultWrappingStyle(getByRole('columnheader', { name: 'Null children' }));
423
+ expectDefaultWrappingStyle(getByRole('cell', { name: row.name }));
424
+ expectDefaultWrappingStyle(getByRole('cell', { name: row.note }));
425
+ expectDefaultWrappingStyle(getByRole('cell', { name: String(row.id) }));
426
+ });
427
+
428
+ it('does not force fixed table layout', () => {
429
+ const { container } = renderTable([{ title: 'Name', dataIndex: 'name' }]);
430
+
431
+ expect(container.querySelector<HTMLTableElement>('table')?.style.tableLayout).not.toBe('fixed');
432
+ });
433
+ });
@@ -22,6 +22,11 @@ import { applyUpdateRecordAction } from './UpdateRecordActionUtils';
22
22
  // import { RemoteFlowModelRenderer } from '../../FlowPage';
23
23
 
24
24
  const SETTINGS_FLOW_KEY = 'assignSettings';
25
+ const AFTER_SUCCESS_DEFAULT_PARAMS = {
26
+ successMessage: tExpr('Saved successfully'),
27
+ manualClose: false,
28
+ actionAfterSuccess: 'stay',
29
+ };
25
30
 
26
31
  function Info() {
27
32
  const ctx = useFlowSettingsContext();
@@ -101,6 +106,10 @@ UpdateRecordActionModel.registerFlow({
101
106
  tipComponent: Info,
102
107
  validateBeforeSave: true,
103
108
  }),
109
+ afterSuccess: {
110
+ use: 'afterSuccess',
111
+ defaultParams: AFTER_SUCCESS_DEFAULT_PARAMS,
112
+ },
104
113
  },
105
114
  });
106
115
 
@@ -113,7 +122,15 @@ UpdateRecordActionModel.registerFlow({
113
122
  return getAssignFieldValuesDefaultParams(ctx, SETTINGS_FLOW_KEY);
114
123
  },
115
124
  async handler(ctx, params) {
116
- await applyUpdateRecordAction(ctx, params, { settingsFlowKey: SETTINGS_FLOW_KEY });
125
+ const updated = await applyUpdateRecordAction(ctx, params, { settingsFlowKey: SETTINGS_FLOW_KEY });
126
+ if (!updated) {
127
+ return;
128
+ }
129
+ const savedAfterSuccess = ctx.model.getStepParams(SETTINGS_FLOW_KEY, 'afterSuccess') || {};
130
+ await ctx.runAction('afterSuccess', {
131
+ ...AFTER_SUCCESS_DEFAULT_PARAMS,
132
+ ...savedAfterSuccess,
133
+ });
117
134
  },
118
135
  },
119
136
  },
@@ -8,9 +8,15 @@
8
8
  */
9
9
 
10
10
  import { MultiRecordResource, SingleRecordResource } from '@nocobase/flow-engine';
11
+ import type { AxiosRequestConfig } from 'axios';
11
12
  import { dispatchEventDeep } from '../../utils';
12
13
  import { resolveAssignFieldValues } from '../blocks/assign-form/assignFieldValuesFlow';
13
14
 
15
+ type UpdateRecordActionParams = {
16
+ assignedValues?: unknown;
17
+ requestConfig?: AxiosRequestConfig;
18
+ };
19
+
14
20
  export async function refreshLinkageRulesAfterUpdate(ctx: any) {
15
21
  const blockModel = ctx?.blockModel || ctx?.model?.context?.blockModel || ctx?.model;
16
22
  const actionModel = ctx?.model;
@@ -48,11 +54,11 @@ export async function refreshLinkageRulesAfterUpdate(ctx: any) {
48
54
 
49
55
  export async function applyUpdateRecordAction(
50
56
  ctx: any,
51
- params: any,
57
+ params: UpdateRecordActionParams,
52
58
  options?: {
53
59
  settingsFlowKey?: string;
54
60
  },
55
- ) {
61
+ ): Promise<boolean> {
56
62
  const settingsFlowKey = options?.settingsFlowKey || 'assignSettings';
57
63
 
58
64
  // 统一接入二次确认:如果启用则弹窗;未配置时默认不启用
@@ -62,25 +68,31 @@ export async function applyUpdateRecordAction(
62
68
 
63
69
  const assignedValues = await resolveAssignFieldValues(ctx, params?.assignedValues, 'UpdateRecordAction');
64
70
  if (!assignedValues) {
65
- return;
71
+ return false;
66
72
  }
67
73
 
68
74
  if (!assignedValues || typeof assignedValues !== 'object' || !Object.keys(assignedValues).length) {
69
75
  ctx.message.warning(ctx.t('No assigned fields configured'));
70
- return;
76
+ return false;
71
77
  }
72
78
  const collection = ctx.collection?.name;
73
79
  const filterByTk = ctx.collection?.getFilterByTK?.(ctx.record);
74
80
  if (!collection || typeof filterByTk === 'undefined' || filterByTk === null) {
75
81
  ctx.message.error(ctx.t('Record is required to perform this action'));
76
- return;
82
+ return false;
77
83
  }
84
+ let updated = false;
78
85
  if (ctx.resource instanceof SingleRecordResource) {
79
86
  await ctx.resource.save(assignedValues, params.requestConfig);
87
+ updated = true;
80
88
  } else if (ctx.resource instanceof MultiRecordResource) {
81
89
  await ctx.resource.update(filterByTk, assignedValues, params.requestConfig);
90
+ updated = true;
91
+ }
92
+ if (!updated) {
93
+ return false;
82
94
  }
83
95
 
84
96
  await refreshLinkageRulesAfterUpdate(ctx);
85
- ctx.message.success(ctx.t('Saved successfully'));
97
+ return true;
86
98
  }
@@ -8,8 +8,9 @@
8
8
  */
9
9
 
10
10
  import { beforeEach, describe, expect, it, vi } from 'vitest';
11
- import { MultiRecordResource } from '@nocobase/flow-engine';
11
+ import { FlowEngine, MultiRecordResource } from '@nocobase/flow-engine';
12
12
  import { applyUpdateRecordAction } from '../UpdateRecordActionUtils';
13
+ import { UpdateRecordActionModel } from '../UpdateRecordActionModel';
13
14
  import { dispatchEventDeep } from '../../../utils';
14
15
 
15
16
  vi.mock('../../../utils', () => ({
@@ -21,7 +22,7 @@ describe('UpdateRecordActionModel apply action', () => {
21
22
  vi.clearAllMocks();
22
23
  });
23
24
 
24
- it('dispatches paginationChange for action and block after successful update', async () => {
25
+ it('dispatches paginationChange and returns success after updating the record', async () => {
25
26
  const resource: any = Object.create(MultiRecordResource.prototype);
26
27
  resource.update = vi.fn(async () => ({}));
27
28
  resource.refresh = vi.fn(async () => {});
@@ -52,12 +53,13 @@ describe('UpdateRecordActionModel apply action', () => {
52
53
  t: (value: string) => value,
53
54
  };
54
55
 
55
- await applyUpdateRecordAction(ctx, {
56
+ const updated = await applyUpdateRecordAction(ctx, {
56
57
  assignedValues: {
57
58
  marital_status: '已婚',
58
59
  },
59
60
  });
60
61
 
62
+ expect(updated).toBe(true);
61
63
  expect(resource.update).toHaveBeenCalledWith(1, { marital_status: '已婚' }, undefined);
62
64
  expect(resource.refresh).not.toHaveBeenCalled();
63
65
 
@@ -67,6 +69,76 @@ describe('UpdateRecordActionModel apply action', () => {
67
69
  expect(paginationCalls.length).toBeGreaterThan(0);
68
70
  expect(paginationCalls.some(([model]: [any]) => model === ctx.model)).toBe(true);
69
71
  expect(paginationCalls.some(([model]: [any]) => model === blockModel)).toBe(true);
70
- expect(ctx.message.success).toHaveBeenCalledWith('Saved successfully');
72
+ expect(ctx.message.success).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it('runs the configured after-success action only after a successful update', async () => {
76
+ const engine = new FlowEngine();
77
+ const action = new UpdateRecordActionModel({ uid: 'update-record-action', flowEngine: engine } as any);
78
+ action.setStepParams('assignSettings', 'confirm', { enable: false });
79
+ action.setStepParams('assignSettings', 'afterSuccess', {
80
+ successMessage: 'Record updated',
81
+ });
82
+
83
+ const resource: any = Object.create(MultiRecordResource.prototype);
84
+ resource.update = vi.fn(async () => ({}));
85
+ const blockModel: any = { uid: 'details-block' };
86
+ const runAction = vi.fn(async () => {});
87
+ const ctx: any = {
88
+ model: action,
89
+ blockModel,
90
+ runAction,
91
+ collection: {
92
+ name: 'users',
93
+ getFilterByTK: vi.fn(() => 1),
94
+ },
95
+ record: { id: 1 },
96
+ resource,
97
+ message: {
98
+ success: vi.fn(),
99
+ warning: vi.fn(),
100
+ error: vi.fn(),
101
+ },
102
+ t: (value: string) => value,
103
+ };
104
+ const handler = action.getFlow('apply')?.getStep('apply')?.serialize().handler;
105
+
106
+ await handler(ctx, { assignedValues: { status: 'active' } });
107
+
108
+ expect(runAction).toHaveBeenNthCalledWith(1, 'confirm', { enable: false });
109
+ expect(runAction).toHaveBeenNthCalledWith(2, 'afterSuccess', {
110
+ successMessage: 'Record updated',
111
+ manualClose: false,
112
+ actionAfterSuccess: 'stay',
113
+ });
114
+ });
115
+
116
+ it('does not run the after-success action when no fields are assigned', async () => {
117
+ const engine = new FlowEngine();
118
+ const action = new UpdateRecordActionModel({ uid: 'update-record-action-empty', flowEngine: engine } as any);
119
+ const runAction = vi.fn(async () => {});
120
+ const ctx: any = {
121
+ model: action,
122
+ runAction,
123
+ collection: {
124
+ name: 'users',
125
+ getFilterByTK: vi.fn(() => 1),
126
+ },
127
+ record: { id: 1 },
128
+ resource: Object.create(MultiRecordResource.prototype),
129
+ message: {
130
+ success: vi.fn(),
131
+ warning: vi.fn(),
132
+ error: vi.fn(),
133
+ },
134
+ t: (value: string) => value,
135
+ };
136
+ const handler = action.getFlow('apply')?.getStep('apply')?.serialize().handler;
137
+
138
+ await handler(ctx, { assignedValues: {} });
139
+
140
+ expect(runAction).toHaveBeenCalledTimes(1);
141
+ expect(runAction).toHaveBeenCalledWith('confirm', { enable: false });
142
+ expect(ctx.message.warning).toHaveBeenCalledWith('No assigned fields configured');
71
143
  });
72
144
  });
@@ -465,11 +465,17 @@ describe('FormBlockModel (form/formValues injection & server resolve anchors)',
465
465
 
466
466
  it('builds non-empty contextParams for ctx.formValues.* deep association path', async () => {
467
467
  const model = await setupFormModel();
468
+ const sessionPayload = Buffer.from(JSON.stringify({ userId: 1, signInTime: 'form-record-slots' })).toString(
469
+ 'base64url',
470
+ );
468
471
  // 注入 api mock 到引擎上下文,拦截 variables:resolve 的请求
469
472
  const api = {
473
+ auth: { token: `test.${sessionPayload}.sig` },
470
474
  request: vi.fn(async (config: any) => {
471
- const payload = config?.data?.values || {};
472
- const batch = payload.batch || [];
475
+ const requestValues = config?.data?.values || {};
476
+ const batch = requestValues.batch || [];
477
+ expect(batch[0]?.rd).toEqual(expect.any(String));
478
+ expect(batch[0]?.template).toEqual({ who: '{{ ctx.formValues.assignees.org.name }}' });
473
479
  const cp = batch[0]?.contextParams || {};
474
480
  const keys = Object.keys(cp).sort();
475
481
  // 聚合为单键,不再使用索引键