Skip to content

Commit 08f6d99

Browse files
authored
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
1 parent db6ec30 commit 08f6d99

File tree

4 files changed

+162
-63
lines changed

4 files changed

+162
-63
lines changed

packages/ui/src/forms/Form/fieldReducer.ts

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -131,48 +131,75 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
131131
return newState
132132
}
133133

134+
/**
135+
* Duplicates a row in an array or blocks field.
136+
* It needs to manipulate two distinct parts of the form state:
137+
* - The `rows` property of the parent field, e.g. `array.rows`, `blocks.rows`, etc.
138+
* - The row's state, e.g. `array.0.id`, `array.0.text`, etc.
139+
*/
134140
case 'DUPLICATE_ROW': {
135141
const { path, rowIndex } = action
136142
const { remainingFields, rows } = separateRows(path, state)
137-
const rowsWithDuplicate = [...(state[path].rows || [])]
138143

139-
const newRow = deepCopyObjectSimpleWithoutReactComponents(rowsWithDuplicate[rowIndex])
144+
// 1. Duplicate the `rows` property of the parent field, e.g. `array.rows`, `blocks.rows`, etc.
145+
const newRows = [...(state[path].rows || [])]
146+
147+
const newRow = deepCopyObjectSimpleWithoutReactComponents(newRows[rowIndex])
140148

141149
const newRowID = new ObjectId().toHexString()
142150

143151
if (newRow.id) {
144152
newRow.id = newRowID
145153
}
146154

147-
if (rowsWithDuplicate[rowIndex]?.customComponents?.RowLabel) {
155+
if (newRows[rowIndex]?.customComponents?.RowLabel) {
148156
newRow.customComponents = {
149-
RowLabel: rowsWithDuplicate[rowIndex].customComponents.RowLabel,
157+
RowLabel: newRows[rowIndex].customComponents.RowLabel,
150158
}
151159
}
152160

153-
const duplicateRowState = deepCopyObjectSimpleWithoutReactComponents(rows[rowIndex])
161+
// 2. Duplicate the row's state, e.g. `array.0.id`, `array.0.text`, etc.
162+
const newRowState = deepCopyObjectSimpleWithoutReactComponents(rows[rowIndex])
154163

155-
if (duplicateRowState.id) {
156-
duplicateRowState.id.value = newRowID
157-
duplicateRowState.id.initialValue = newRowID
164+
// Ensure that `id` in form state exactly matches the row id on the parent field
165+
if (newRowState.id) {
166+
newRowState.id.value = newRowID
167+
newRowState.id.initialValue = newRowID
158168
}
159169

160-
for (const key of Object.keys(duplicateRowState).filter((key) => key.endsWith('.id'))) {
161-
const idState = duplicateRowState[key]
170+
// Generate new ids for all nested id fields, e.g. `array.0.nestedArray.0.id`
171+
for (const key of Object.keys(newRowState).filter((key) => key.endsWith('.id'))) {
172+
const idState = newRowState[key]
162173

163174
const newNestedFieldID = new ObjectId().toHexString()
164175

165176
if (idState && typeof idState.value === 'string' && ObjectId.isValid(idState.value)) {
166-
duplicateRowState[key].value = newNestedFieldID
167-
duplicateRowState[key].initialValue = newNestedFieldID
177+
newRowState[key].value = newNestedFieldID
178+
newRowState[key].initialValue = newNestedFieldID
179+
180+
// Apply the ID to its corresponding parent field's rows, e.g. `array.0.nestedArray.rows[0].id`
181+
const segments = key.split('.')
182+
const rowIndex = parseInt(segments[segments.length - 2], 10)
183+
const parentFieldPath = segments.slice(0, segments.length - 2).join('.')
184+
const parentFieldRows = newRowState?.[parentFieldPath]?.rows
185+
186+
if (newRowState[parentFieldPath] && Array.isArray(parentFieldRows)) {
187+
if (!parentFieldRows[rowIndex]) {
188+
parentFieldRows[rowIndex] = {
189+
id: newNestedFieldID,
190+
}
191+
} else {
192+
parentFieldRows[rowIndex].id = newNestedFieldID
193+
}
194+
}
168195
}
169196
}
170197

171198
// If there are subfields
172-
if (Object.keys(duplicateRowState).length > 0) {
199+
if (Object.keys(newRowState).length > 0) {
173200
// Add new object containing subfield names to unflattenedRows array
174-
rows.splice(rowIndex + 1, 0, duplicateRowState)
175-
rowsWithDuplicate.splice(rowIndex + 1, 0, newRow)
201+
rows.splice(rowIndex + 1, 0, newRowState)
202+
newRows.splice(rowIndex + 1, 0, newRow)
176203
}
177204

178205
const newState = {
@@ -181,7 +208,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
181208
[path]: {
182209
...state[path],
183210
disableFormData: true,
184-
rows: rowsWithDuplicate,
211+
rows: newRows,
185212
value: rows.length,
186213
},
187214
}

test/fields/collections/Array/e2e.spec.ts

Lines changed: 105 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { expect, test } from '@playwright/test'
55
import { assertToastErrors } from 'helpers/assertToastErrors.js'
66
import { copyPasteField } from 'helpers/e2e/copyPasteField.js'
77
import { addArrayRow, duplicateArrayRow, removeArrayRow } from 'helpers/e2e/fields/array/index.js'
8+
import { scrollEntirePage } from 'helpers/e2e/scrollEntirePage.js'
89
import { toggleBlockOrArrayRow } from 'helpers/e2e/toggleCollapsible.js'
910
import path from 'path'
1011
import { wait } from 'payload/shared'
@@ -191,76 +192,136 @@ describe('Array', () => {
191192
})
192193

193194
describe('row manipulation', () => {
194-
test('should add, remove and duplicate rows', async () => {
195-
const assertText0 = 'array row 1'
196-
const assertGroupText0 = 'text in group in row 1'
197-
const assertText1 = 'array row 2'
198-
const assertText3 = 'array row 3'
199-
const assertGroupText3 = 'text in group in row 3'
195+
test('should add rows', async () => {
196+
const row1Text = 'Array row 1'
197+
const row2Text = 'Array row 2'
198+
const row3Text = 'Array row 3'
199+
200+
const row1GroupText = 'text in group in row 1'
201+
200202
await loadCreatePage()
201-
await page.mouse.wheel(0, 1750)
202-
await page.locator('#field-potentiallyEmptyArray').scrollIntoViewIfNeeded()
203-
await wait(300)
203+
await scrollEntirePage(page)
204204

205-
// Add 3 rows
206205
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
207206
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
208207
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
209208

210-
// Fill out row 1
211-
await page.locator('#field-potentiallyEmptyArray__0__text').fill(assertText0)
209+
await page.locator('#field-potentiallyEmptyArray__0__text').fill(row1Text)
210+
await page.locator('#field-potentiallyEmptyArray__1__text').fill(row2Text)
211+
await page.locator('#field-potentiallyEmptyArray__2__text').fill(row3Text)
212212

213-
await page
214-
.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow')
215-
.fill(assertGroupText0)
213+
await page.locator('#field-potentiallyEmptyArray__0__group__text').fill(row1GroupText)
214+
215+
await saveDocAndAssert(page)
216+
await scrollEntirePage(page)
216217

217-
// Fill out row 2
218-
await page.locator('#field-potentiallyEmptyArray__1__text').fill(assertText1)
218+
await expect(page.locator('#field-potentiallyEmptyArray__0__text')).toHaveValue(row1Text)
219+
await expect(page.locator('#field-potentiallyEmptyArray__1__text')).toHaveValue(row2Text)
220+
await expect(page.locator('#field-potentiallyEmptyArray__2__text')).toHaveValue(row3Text)
219221

220-
// Fill out row 3
221-
await page.locator('#field-potentiallyEmptyArray__2__text').fill(assertText3)
222+
const input = page.locator('#field-potentiallyEmptyArray__0__group__text')
222223

224+
await expect(input).toHaveValue(row1GroupText)
225+
})
226+
227+
test('should duplicate rows', async () => {
228+
const row1Text = 'Array row 1'
229+
const row2Text = 'Array row 2'
230+
const row3Text = 'Array row 3'
231+
232+
await loadCreatePage()
233+
await scrollEntirePage(page)
234+
235+
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
236+
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
237+
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
238+
239+
await page.locator('#field-potentiallyEmptyArray__0__text').fill(row1Text)
240+
await page.locator('#field-potentiallyEmptyArray__1__text').fill(row2Text)
241+
await page.locator('#field-potentiallyEmptyArray__2__text').fill(row3Text)
242+
243+
await page.locator('#field-potentiallyEmptyArray__0__text').fill(row1Text)
244+
245+
// Mark the first row with some unique values to assert against later
223246
await page
224-
.locator('#field-potentiallyEmptyArray__2__groupInRow__textInGroupInRow')
225-
.fill(assertGroupText3)
247+
.locator('#field-potentiallyEmptyArray__0__group__text')
248+
.fill(`${row1Text} duplicate`)
226249

227-
await removeArrayRow(page, { fieldName: 'potentiallyEmptyArray', rowIndex: 1 })
228-
await removeArrayRow(page, { fieldName: 'potentiallyEmptyArray', rowIndex: 0 })
250+
await duplicateArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
229251

230-
// Save document
231252
await saveDocAndAssert(page)
253+
await scrollEntirePage(page)
254+
255+
await page.locator('#field-potentiallyEmptyArray__0__text').fill(row1Text)
256+
await page.locator('#field-potentiallyEmptyArray__1__text').fill(row1Text)
257+
await page.locator('#field-potentiallyEmptyArray__2__text').fill(row2Text)
258+
await page.locator('#field-potentiallyEmptyArray__3__text').fill(row3Text)
259+
260+
await expect(page.locator('#field-potentiallyEmptyArray__1__group__text')).toHaveValue(
261+
`${row1Text} duplicate`,
262+
)
263+
})
264+
265+
test('should duplicate rows with nested arrays', async () => {
266+
await loadCreatePage()
267+
await scrollEntirePage(page)
268+
269+
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
270+
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray__0__array' })
271+
272+
await page.locator('#field-potentiallyEmptyArray__0__array__0__text').fill('Row 1')
232273

233-
// Scroll to array row (fields are not rendered in DOM until on screen)
234-
await page.locator('#field-potentiallyEmptyArray__0__groupInRow').scrollIntoViewIfNeeded()
274+
// There should be 2 fields in the nested array row: the text field and the row id
275+
const fieldsInRow = page
276+
.locator('#field-potentiallyEmptyArray__0__array')
277+
.locator('.render-fields')
278+
.first()
235279

236-
// Expect the remaining row to be the third row
237-
const input = page.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow')
238-
await expect(input).toHaveValue(assertGroupText3)
280+
await expect(fieldsInRow.locator('> *')).toHaveCount(2)
239281

240282
await duplicateArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
241283

242-
// Update duplicated row group field text
243-
await page
244-
.locator('#field-potentiallyEmptyArray__1__groupInRow__textInGroupInRow')
245-
.fill(`${assertGroupText3} duplicate`)
284+
// There should still only be 2 fields in the duplicated row
285+
const fieldsInDuplicatedRow = page
286+
.locator('#field-potentiallyEmptyArray__1__array')
287+
.locator('.render-fields')
288+
.first()
246289

247-
// Save document
248-
await saveDocAndAssert(page)
290+
await expect(fieldsInDuplicatedRow.locator('> *')).toHaveCount(2)
291+
})
249292

250-
// Expect the second row to be a duplicate of the remaining row
251-
await expect(
252-
page.locator('#field-potentiallyEmptyArray__1__groupInRow__textInGroupInRow'),
253-
).toHaveValue(`${assertGroupText3} duplicate`)
293+
test('should remove rows', async () => {
294+
const row1Text = 'Array row 1'
295+
const row2Text = 'Array row 2'
296+
const row3Text = 'Array row 3'
254297

298+
const assertGroupText3 = 'text in group in row 3'
299+
300+
await loadCreatePage()
301+
await scrollEntirePage(page)
302+
303+
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
304+
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
305+
await addArrayRow(page, { fieldName: 'potentiallyEmptyArray' })
306+
307+
await page.locator('#field-potentiallyEmptyArray__0__text').fill(row1Text)
308+
await page.locator('#field-potentiallyEmptyArray__1__text').fill(row2Text)
309+
await page.locator('#field-potentiallyEmptyArray__2__text').fill(row3Text)
310+
311+
// Mark the third row with some unique values to assert against later
312+
await page.locator('#field-potentiallyEmptyArray__2__group__text').fill(assertGroupText3)
313+
314+
// Remove all rows one by one, except the last one
315+
await removeArrayRow(page, { fieldName: 'potentiallyEmptyArray', rowIndex: 1 })
255316
await removeArrayRow(page, { fieldName: 'potentiallyEmptyArray', rowIndex: 0 })
256317

257-
// Save document
258318
await saveDocAndAssert(page)
319+
await scrollEntirePage(page)
259320

260-
// Expect the remaining row to be the copy of the duplicate row
261-
await expect(
262-
page.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow'),
263-
).toHaveValue(`${assertGroupText3} duplicate`)
321+
// Expect the remaining row to be the third row, now first
322+
await expect(page.locator('#field-potentiallyEmptyArray__0__group__text')).toHaveValue(
323+
assertGroupText3,
324+
)
264325
})
265326
})
266327

test/fields/collections/Array/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,15 +136,25 @@ const ArrayFields: CollectionConfig = {
136136
type: 'text',
137137
},
138138
{
139-
name: 'groupInRow',
139+
name: 'group',
140140
fields: [
141141
{
142-
name: 'textInGroupInRow',
142+
name: 'text',
143143
type: 'text',
144144
},
145145
],
146146
type: 'group',
147147
},
148+
{
149+
name: 'array',
150+
fields: [
151+
{
152+
name: 'text',
153+
type: 'text',
154+
},
155+
],
156+
type: 'array',
157+
},
148158
],
149159
type: 'array',
150160
},

test/helpers/e2e/fields/array/duplicateArrayRow.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ export const duplicateArrayRow = async (
1414
popupContentLocator: Locator
1515
rowActionsButtonLocator: Locator
1616
}> => {
17-
const rowLocator = page.locator(`#field-${fieldName} .array-field__row`)
17+
const rowLocator = page.locator(`#field-${fieldName} > .array-field__draggable-rows > *`)
18+
1819
const numberOfPrevRows = await rowLocator.count()
1920

2021
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {

0 commit comments

Comments
 (0)