Skip to content

Commit 9bcedc8

Browse files
authored
fix(ui): copy & pasting block content duplicates array items in editor UI (#15941)
**What?** When copying a block row that contains an array field and pasting it into a new block of the same type, the array items in the pasted block appeared doubled in the UI (e.g. 3 items → 6 items) immediately after pasting. After saving, the duplicates disappeared since the persisted data was always correct, making this a pure UI state issue. **Why?** When pasting a block row, `mergeFormStateFromClipboard` intentionally skips `.id` fields to avoid overwriting the target block's identity. However, the condition clipboardPath.endsWith('.id') was too broad. It skipped **all** `.id` paths, including nested array item IDs inside the block (e.g. `ctas.0.buttons.0.id`). This left the pasted block's array field with the correct rows metadata (copied from clipboard, containing the source block's IDs), but no corresponding .id form state entries. On the next onChange, the server rebuilt form state from the submitted data, couldn't match any array rows by ID (since the `.id` fields were missing), and marked all of them as `addedByServer: true`. When the server response was merged back via `mergeServerFormState`, those rows were appended to the already-existing client rows doubling them. **How?** Changed the skip condition from matching any `.id` path to matching only the direct block row ID: `- (!pasteIntoField && clipboardPath.endsWith('.id'))` `+ (!pasteIntoField && clipboardPath === `${pathToReplace}.id`)` Nested IDs now fall through to the existing ID-regeneration logic, which assigns fresh IDs and updates the `rows` metadata. The server can then match the rows by their new IDs, so no duplication occurs on merge. The target block's direct ID is still protected from being overwritten. A unit test was added to `mergeFormStateFromClipboard.spec.ts` covering the exact scenario. Fixes #15940
1 parent fac59c8 commit 9bcedc8

File tree

2 files changed

+112
-2
lines changed

2 files changed

+112
-2
lines changed

packages/ui/src/elements/ClipboardAction/mergeFormStateFromClipboard.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,114 @@ describe('mergeFormStateFromClipboard', () => {
233233
})
234234
})
235235

236+
describe('block row paste with nested array', () => {
237+
it('should regenerate nested array item IDs when pasting a block row', () => {
238+
const copiedBlockID = new ObjectId().toHexString()
239+
const copiedArrayItemID1 = new ObjectId().toHexString()
240+
const copiedArrayItemID2 = new ObjectId().toHexString()
241+
const copiedArrayItemID3 = new ObjectId().toHexString()
242+
const targetBlockID = new ObjectId().toHexString()
243+
244+
// Target form state: block at index 1 with empty buttons array
245+
const formState: FormState = {
246+
ctas: {
247+
valid: true,
248+
value: 2,
249+
initialValue: 2,
250+
rows: [
251+
{ id: copiedBlockID, blockType: 'callToAction', isLoading: false },
252+
{ id: targetBlockID, blockType: 'callToAction', isLoading: false },
253+
],
254+
},
255+
'ctas.0': { value: 'callToAction', valid: true },
256+
'ctas.0.id': { value: copiedBlockID, valid: true },
257+
'ctas.0.buttons': {
258+
valid: true,
259+
value: 3,
260+
rows: [
261+
{ id: copiedArrayItemID1, isLoading: false },
262+
{ id: copiedArrayItemID2, isLoading: false },
263+
{ id: copiedArrayItemID3, isLoading: false },
264+
],
265+
},
266+
'ctas.0.buttons.0.id': { value: copiedArrayItemID1, valid: true },
267+
'ctas.0.buttons.1.id': { value: copiedArrayItemID2, valid: true },
268+
'ctas.0.buttons.2.id': { value: copiedArrayItemID3, valid: true },
269+
'ctas.1': { value: 'callToAction', valid: true },
270+
'ctas.1.id': { value: targetBlockID, valid: true },
271+
'ctas.1.buttons': {
272+
valid: true,
273+
value: 0,
274+
rows: [],
275+
},
276+
}
277+
278+
// Clipboard: block row 0 (source) with 3 buttons
279+
const clipboardData: ClipboardPasteData = {
280+
type: 'blocks',
281+
path: 'ctas',
282+
blocks: [],
283+
rowIndex: 0,
284+
data: {
285+
'ctas.0': { value: 'callToAction', valid: true },
286+
'ctas.0.id': { value: copiedBlockID, valid: true },
287+
'ctas.0.buttons': {
288+
valid: true,
289+
value: 3,
290+
rows: [
291+
{ id: copiedArrayItemID1, isLoading: false },
292+
{ id: copiedArrayItemID2, isLoading: false },
293+
{ id: copiedArrayItemID3, isLoading: false },
294+
],
295+
},
296+
'ctas.0.buttons.0.id': { value: copiedArrayItemID1, valid: true },
297+
'ctas.0.buttons.0.label': { value: 'Button 1', valid: true },
298+
'ctas.0.buttons.1.id': { value: copiedArrayItemID2, valid: true },
299+
'ctas.0.buttons.1.label': { value: 'Button 2', valid: true },
300+
'ctas.0.buttons.2.id': { value: copiedArrayItemID3, valid: true },
301+
'ctas.0.buttons.2.label': { value: 'Button 3', valid: true },
302+
},
303+
}
304+
305+
// Paste into block row 1 (target)
306+
const result = mergeFormStateFromClipboard({
307+
dataFromClipboard: clipboardData,
308+
formState,
309+
path: 'ctas',
310+
rowIndex: 1,
311+
})
312+
313+
// Target block ID should NOT be overwritten
314+
expect(result['ctas.1.id'].value).toEqual(targetBlockID)
315+
316+
// Nested array items should have NEW IDs (not the source IDs)
317+
expect(result['ctas.1.buttons.0.id']).toBeDefined()
318+
expect(result['ctas.1.buttons.0.id'].value).not.toEqual(copiedArrayItemID1)
319+
expect(ObjectId.isValid(result['ctas.1.buttons.0.id'].value as string)).toBe(true)
320+
321+
expect(result['ctas.1.buttons.1.id']).toBeDefined()
322+
expect(result['ctas.1.buttons.1.id'].value).not.toEqual(copiedArrayItemID2)
323+
324+
expect(result['ctas.1.buttons.2.id']).toBeDefined()
325+
expect(result['ctas.1.buttons.2.id'].value).not.toEqual(copiedArrayItemID3)
326+
327+
// The rows metadata in ctas.1.buttons should have the new IDs
328+
expect(result['ctas.1.buttons'].rows).toHaveLength(3)
329+
expect(result['ctas.1.buttons'].rows![0].id).toEqual(result['ctas.1.buttons.0.id'].value)
330+
expect(result['ctas.1.buttons'].rows![1].id).toEqual(result['ctas.1.buttons.1.id'].value)
331+
expect(result['ctas.1.buttons'].rows![2].id).toEqual(result['ctas.1.buttons.2.id'].value)
332+
333+
// Field values should be copied
334+
expect(result['ctas.1.buttons.0.label'].value).toEqual('Button 1')
335+
expect(result['ctas.1.buttons.1.label'].value).toEqual('Button 2')
336+
expect(result['ctas.1.buttons.2.label'].value).toEqual('Button 3')
337+
338+
// Source block should be untouched
339+
expect(result['ctas.0.id'].value).toEqual(copiedBlockID)
340+
expect(result['ctas.0.buttons'].rows).toHaveLength(3)
341+
})
342+
})
343+
236344
describe('array ID regeneration', () => {
237345
it('should generate new IDs when pasting arrays to prevent duplicates', () => {
238346
const copiedArrayID = new ObjectId().toHexString()

packages/ui/src/elements/ClipboardAction/mergeFormStateFromClipboard.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,11 @@ export function mergeFormStateFromClipboard({
114114
const idReplacements: Map<string, string> = new Map()
115115

116116
for (const clipboardPath in dataFromClipboard) {
117-
// Pasting a row id, skip overwriting
117+
// When pasting into a specific row, skip only the direct row ID to avoid overwriting
118+
// the target row's identity. Nested IDs (array item IDs within the block) should
119+
// still be processed so they get regenerated below, preventing server-side duplication.
118120
if (
119-
(!pasteIntoField && clipboardPath.endsWith('.id')) ||
121+
(!pasteIntoField && clipboardPath === `${pathToReplace}.id`) ||
120122
!clipboardPath.startsWith(pathToReplace)
121123
) {
122124
continue

0 commit comments

Comments
 (0)