fix(ui): phantom fields when duplicating rows with rows (#14068)

Fixes #14032.

When duplicating arrays that contains nested arrays, the newly
duplicated row will include twice as many fields within the nested array
as the original.

This is because duplicated row ids don't match 1:1 with their parent's
`rows` property.

For example: `array.0.nestedArray.0.id` should match
`array.0.nestedArray.rows[0].id`.

The problem is that when we duplicate the row, we regenerate all nested
id fields, but we fail to sync them its corresponding row in its parent
field. When we go to build form state on the server, we build new fields
for this stale row. Then when we merge server form state back into local
state, those new fields are simply _merged_ with the local state without
replacing them.

This is how we determine whether local changes to a row took place while
a form state request was pending. It is important to prevent the merge
strategy from overriding your active changes.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211543194775558
This commit is contained in:
Jacob Fletcher
2025-10-06 12:11:12 -04:00
committed by GitHub
parent db6ec3026c
commit 08f6d99e4b
4 changed files with 167 additions and 68 deletions

View File

@@ -5,6 +5,7 @@ import { expect, test } from '@playwright/test'
import { assertToastErrors } from 'helpers/assertToastErrors.js'
import { copyPasteField } from 'helpers/e2e/copyPasteField.js'
import { addArrayRow, duplicateArrayRow, removeArrayRow } from 'helpers/e2e/fields/array/index.js'
import { scrollEntirePage } from 'helpers/e2e/scrollEntirePage.js'
import { toggleBlockOrArrayRow } from 'helpers/e2e/toggleCollapsible.js'
import path from 'path'
import { wait } from 'payload/shared'
@@ -191,76 +192,136 @@ describe('Array', () => {
})
describe('row manipulation', () => {
test('should add, remove and duplicate rows', async () => {
const assertText0 = 'array row 1'
const assertGroupText0 = 'text in group in row 1'
const assertText1 = 'array row 2'
const assertText3 = 'array row 3'
const assertGroupText3 = 'text in group in row 3'
test('should add rows', async () => {
const row1Text = 'Array row 1'
const row2Text = 'Array row 2'
const row3Text = 'Array row 3'
const row1GroupText = 'text in group in row 1'
await loadCreatePage()
await page.mouse.wheel(0, 1750)
await page.locator('#field-potentiallyEmptyArray').scrollIntoViewIfNeeded()
await wait(300)
await scrollEntirePage(page)
// Add 3 rows
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
// Fill out row 1
await page.locator('#field-potentiallyEmptyArray__0__text').fill(assertText0)
await page.locator('#field-potentiallyEmptyArray__0__text').fill(row1Text)
await page.locator('#field-potentiallyEmptyArray__1__text').fill(row2Text)
await page.locator('#field-potentiallyEmptyArray__2__text').fill(row3Text)
await page
.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow')
.fill(assertGroupText0)
await page.locator('#field-potentiallyEmptyArray__0__group__text').fill(row1GroupText)
// Fill out row 2
await page.locator('#field-potentiallyEmptyArray__1__text').fill(assertText1)
// Fill out row 3
await page.locator('#field-potentiallyEmptyArray__2__text').fill(assertText3)
await page
.locator('#field-potentiallyEmptyArray__2__groupInRow__textInGroupInRow')
.fill(assertGroupText3)
await removeArrayRow(page, { fieldName: 'potentiallyEmptyArray', rowIndex: 1 })
await removeArrayRow(page, { fieldName: 'potentiallyEmptyArray', rowIndex: 0 })
// Save document
await saveDocAndAssert(page)
await scrollEntirePage(page)
// Scroll to array row (fields are not rendered in DOM until on screen)
await page.locator('#field-potentiallyEmptyArray__0__groupInRow').scrollIntoViewIfNeeded()
await expect(page.locator('#field-potentiallyEmptyArray__0__text')).toHaveValue(row1Text)
await expect(page.locator('#field-potentiallyEmptyArray__1__text')).toHaveValue(row2Text)
await expect(page.locator('#field-potentiallyEmptyArray__2__text')).toHaveValue(row3Text)
// Expect the remaining row to be the third row
const input = page.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow')
await expect(input).toHaveValue(assertGroupText3)
const input = page.locator('#field-potentiallyEmptyArray__0__group__text')
await expect(input).toHaveValue(row1GroupText)
})
test('should duplicate rows', async () => {
const row1Text = 'Array row 1'
const row2Text = 'Array row 2'
const row3Text = 'Array row 3'
await loadCreatePage()
await scrollEntirePage(page)
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
await page.locator('#field-potentiallyEmptyArray__0__text').fill(row1Text)
await page.locator('#field-potentiallyEmptyArray__1__text').fill(row2Text)
await page.locator('#field-potentiallyEmptyArray__2__text').fill(row3Text)
await page.locator('#field-potentiallyEmptyArray__0__text').fill(row1Text)
// Mark the first row with some unique values to assert against later
await page
.locator('#field-potentiallyEmptyArray__0__group__text')
.fill(`${row1Text} duplicate`)
await duplicateArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
// Update duplicated row group field text
await page
.locator('#field-potentiallyEmptyArray__1__groupInRow__textInGroupInRow')
.fill(`${assertGroupText3} duplicate`)
// Save document
await saveDocAndAssert(page)
await scrollEntirePage(page)
// Expect the second row to be a duplicate of the remaining row
await expect(
page.locator('#field-potentiallyEmptyArray__1__groupInRow__textInGroupInRow'),
).toHaveValue(`${assertGroupText3} duplicate`)
await page.locator('#field-potentiallyEmptyArray__0__text').fill(row1Text)
await page.locator('#field-potentiallyEmptyArray__1__text').fill(row1Text)
await page.locator('#field-potentiallyEmptyArray__2__text').fill(row2Text)
await page.locator('#field-potentiallyEmptyArray__3__text').fill(row3Text)
await expect(page.locator('#field-potentiallyEmptyArray__1__group__text')).toHaveValue(
`${row1Text} duplicate`,
)
})
test('should duplicate rows with nested arrays', async () => {
await loadCreatePage()
await scrollEntirePage(page)
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray__0__array' })
await page.locator('#field-potentiallyEmptyArray__0__array__0__text').fill('Row 1')
// There should be 2 fields in the nested array row: the text field and the row id
const fieldsInRow = page
.locator('#field-potentiallyEmptyArray__0__array')
.locator('.render-fields')
.first()
await expect(fieldsInRow.locator('> *')).toHaveCount(2)
await duplicateArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
// There should still only be 2 fields in the duplicated row
const fieldsInDuplicatedRow = page
.locator('#field-potentiallyEmptyArray__1__array')
.locator('.render-fields')
.first()
await expect(fieldsInDuplicatedRow.locator('> *')).toHaveCount(2)
})
test('should remove rows', async () => {
const row1Text = 'Array row 1'
const row2Text = 'Array row 2'
const row3Text = 'Array row 3'
const assertGroupText3 = 'text in group in row 3'
await loadCreatePage()
await scrollEntirePage(page)
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
await page.locator('#field-potentiallyEmptyArray__0__text').fill(row1Text)
await page.locator('#field-potentiallyEmptyArray__1__text').fill(row2Text)
await page.locator('#field-potentiallyEmptyArray__2__text').fill(row3Text)
// Mark the third row with some unique values to assert against later
await page.locator('#field-potentiallyEmptyArray__2__group__text').fill(assertGroupText3)
// Remove all rows one by one, except the last one
await removeArrayRow(page, { fieldName: 'potentiallyEmptyArray', rowIndex: 1 })
await removeArrayRow(page, { fieldName: 'potentiallyEmptyArray', rowIndex: 0 })
// Save document
await saveDocAndAssert(page)
await scrollEntirePage(page)
// Expect the remaining row to be the copy of the duplicate row
await expect(
page.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow'),
).toHaveValue(`${assertGroupText3} duplicate`)
// Expect the remaining row to be the third row, now first
await expect(page.locator('#field-potentiallyEmptyArray__0__group__text')).toHaveValue(
assertGroupText3,
)
})
})

View File

@@ -136,15 +136,25 @@ const ArrayFields: CollectionConfig = {
type: 'text',
},
{
name: 'groupInRow',
name: 'group',
fields: [
{
name: 'textInGroupInRow',
name: 'text',
type: 'text',
},
],
type: 'group',
},
{
name: 'array',
fields: [
{
name: 'text',
type: 'text',
},
],
type: 'array',
},
],
type: 'array',
},

View File

@@ -14,7 +14,8 @@ export const duplicateArrayRow = async (
popupContentLocator: Locator
rowActionsButtonLocator: Locator
}> => {
const rowLocator = page.locator(`#field-${fieldName} .array-field__row`)
const rowLocator = page.locator(`#field-${fieldName} > .array-field__draggable-rows > *`)
const numberOfPrevRows = await rowLocator.count()
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {