fix(ui): use consistent row ids when duplicating array and block rows (#13679)

Fixes #13653.

Duplicating array rows causes phantom rows to appear. This is because
when duplicate the row locally, we use inconsistent row IDs, e.g. the
`array.rows[0].id` does not match its `array.0.id` counterpart. This
causes form state to lose the reference to the existing row, which the
server interprets as new row as of #13551.

Before:


https://github.com/user-attachments/assets/9f7efc59-ebd9-4fbb-b643-c22d4d3140a3

After:


https://github.com/user-attachments/assets/188db823-4ee5-4757-8b89-751c8d978ad9

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211210023936585
This commit is contained in:
Jacob Fletcher
2025-09-03 14:29:39 -04:00
committed by GitHub
parent be47f65b7c
commit b8d7ccb4dc
14 changed files with 349 additions and 190 deletions

1
next-env.d.ts vendored
View File

@@ -1,6 +1,5 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -134,35 +134,37 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
case 'DUPLICATE_ROW': { case 'DUPLICATE_ROW': {
const { path, rowIndex } = action const { path, rowIndex } = action
const { remainingFields, rows } = separateRows(path, state) const { remainingFields, rows } = separateRows(path, state)
const rowsMetadata = [...(state[path].rows || [])] const rowsWithDuplicate = [...(state[path].rows || [])]
const duplicateRowMetadata = deepCopyObjectSimpleWithoutReactComponents( const newRow = deepCopyObjectSimpleWithoutReactComponents(rowsWithDuplicate[rowIndex])
rowsMetadata[rowIndex],
)
if (duplicateRowMetadata.id) { const newRowID = new ObjectId().toHexString()
duplicateRowMetadata.id = new ObjectId().toHexString()
if (newRow.id) {
newRow.id = newRowID
} }
if (rowsMetadata[rowIndex]?.customComponents?.RowLabel) { if (rowsWithDuplicate[rowIndex]?.customComponents?.RowLabel) {
duplicateRowMetadata.customComponents = { newRow.customComponents = {
RowLabel: rowsMetadata[rowIndex].customComponents.RowLabel, RowLabel: rowsWithDuplicate[rowIndex].customComponents.RowLabel,
} }
} }
const duplicateRowState = deepCopyObjectSimpleWithoutReactComponents(rows[rowIndex]) const duplicateRowState = deepCopyObjectSimpleWithoutReactComponents(rows[rowIndex])
if (duplicateRowState.id) { if (duplicateRowState.id) {
duplicateRowState.id.value = new ObjectId().toHexString() duplicateRowState.id.value = newRowID
duplicateRowState.id.initialValue = new ObjectId().toHexString() duplicateRowState.id.initialValue = newRowID
} }
for (const key of Object.keys(duplicateRowState).filter((key) => key.endsWith('.id'))) { for (const key of Object.keys(duplicateRowState).filter((key) => key.endsWith('.id'))) {
const idState = duplicateRowState[key] const idState = duplicateRowState[key]
const newNestedFieldID = new ObjectId().toHexString()
if (idState && typeof idState.value === 'string' && ObjectId.isValid(idState.value)) { if (idState && typeof idState.value === 'string' && ObjectId.isValid(idState.value)) {
duplicateRowState[key].value = new ObjectId().toHexString() duplicateRowState[key].value = newNestedFieldID
duplicateRowState[key].initialValue = new ObjectId().toHexString() duplicateRowState[key].initialValue = newNestedFieldID
} }
} }
@@ -170,7 +172,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
if (Object.keys(duplicateRowState).length > 0) { if (Object.keys(duplicateRowState).length > 0) {
// Add new object containing subfield names to unflattenedRows array // Add new object containing subfield names to unflattenedRows array
rows.splice(rowIndex + 1, 0, duplicateRowState) rows.splice(rowIndex + 1, 0, duplicateRowState)
rowsMetadata.splice(rowIndex + 1, 0, duplicateRowMetadata) rowsWithDuplicate.splice(rowIndex + 1, 0, newRow)
} }
const newState = { const newState = {
@@ -179,7 +181,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[path]: { [path]: {
...state[path], ...state[path],
disableFormData: true, disableFormData: true,
rows: rowsMetadata, rows: rowsWithDuplicate,
value: rows.length, value: rows.length,
}, },
} }

View File

@@ -1,7 +1,5 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export const postsSlug = 'posts' export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = { export const PostsCollection: CollectionConfig = {
@@ -15,11 +13,14 @@ export const PostsCollection: CollectionConfig = {
type: 'text', type: 'text',
}, },
{ {
name: 'content', name: 'array',
type: 'richText', type: 'array',
editor: lexicalEditor({ fields: [
features: ({ defaultFeatures }) => [...defaultFeatures], {
}), name: 'title',
type: 'text',
},
],
}, },
], ],
} }

View File

@@ -126,21 +126,12 @@ export interface UserAuthOperations {
export interface Post { export interface Post {
id: string; id: string;
title?: string | null; title?: string | null;
content?: { array?:
root: { | {
type: string; title?: string | null;
children: { id?: string | null;
type: string; }[]
version: number; | null;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -279,7 +270,12 @@ export interface PayloadMigration {
*/ */
export interface PostsSelect<T extends boolean = true> { export interface PostsSelect<T extends boolean = true> {
title?: T; title?: T;
content?: T; array?:
| T
| {
title?: T;
id?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }

View File

@@ -2,8 +2,14 @@ import type { BrowserContext, Page } from '@playwright/test'
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import { copyPasteField } from 'helpers/e2e/copyPasteField.js' import { copyPasteField } from 'helpers/e2e/copyPasteField.js'
import { addArrayRowBelow, duplicateArrayRow } from 'helpers/e2e/fields/array/index.js' import { duplicateArrayRow } from 'helpers/e2e/fields/array/index.js'
import { addBlock, openBlocksDrawer, reorderBlocks } from 'helpers/e2e/fields/blocks/index.js' import {
addBlock,
addBlockBelow,
duplicateBlock,
openBlocksDrawer,
reorderBlocks,
} from 'helpers/e2e/fields/blocks/index.js'
import { scrollEntirePage } from 'helpers/e2e/scrollEntirePage.js' import { scrollEntirePage } from 'helpers/e2e/scrollEntirePage.js'
import { toggleBlockOrArrayRow } from 'helpers/e2e/toggleCollapsible.js' import { toggleBlockOrArrayRow } from 'helpers/e2e/toggleCollapsible.js'
import path from 'path' import path from 'path'
@@ -127,22 +133,13 @@ describe('Block fields', () => {
test('should open blocks drawer from block row and add below', async () => { test('should open blocks drawer from block row and add below', async () => {
await page.goto(url.create) await page.goto(url.create)
await addArrayRowBelow(page, { fieldName: 'blocks' }) await addBlockBelow(page, { fieldName: 'blocks', blockToSelect: 'Content' })
const blocksDrawer = page.locator('[id^=drawer_1_blocks-drawer-]')
await expect(blocksDrawer).toBeVisible()
// select the first block in the drawer
const firstBlockSelector = blocksDrawer
.locator('.blocks-drawer__blocks .blocks-drawer__block')
.first()
await expect(firstBlockSelector).toContainText('Content')
await firstBlockSelector.click()
// ensure the block was inserted beneath the first in the rows // ensure the block was inserted beneath the first in the rows
const addedRow = page.locator('#field-blocks #blocks-row-1') const addedRow = page.locator('#field-blocks #blocks-row-1')
await expect(addedRow).toBeVisible() await expect(addedRow).toBeVisible()
await expect(addedRow.locator('.blocks-field__block-header')).toHaveText( await expect(addedRow.locator('.blocks-field__block-header')).toHaveText(
'Custom Block Label: Content 02', 'Custom Block Label: Content 02',
) // went from `Number` to `Content` ) // went from `Number` to `Content`
@@ -151,19 +148,17 @@ describe('Block fields', () => {
test('should duplicate block', async () => { test('should duplicate block', async () => {
await page.goto(url.create) await page.goto(url.create)
await duplicateArrayRow(page, { fieldName: 'blocks' }) const { rowCount } = await duplicateBlock(page, { fieldName: 'blocks' })
const blocks = page.locator('#field-blocks > .blocks-field__rows > div') expect(rowCount).toEqual(5)
expect(await blocks.count()).toEqual(5)
}) })
test('should save when duplicating subblocks', async () => { test('should save when duplicating subblocks', async () => {
await page.goto(url.create) await page.goto(url.create)
await duplicateArrayRow(page, { fieldName: 'blocks', rowIndex: 2 }) const { rowCount } = await duplicateBlock(page, { fieldName: 'blocks', rowIndex: 2 })
const blocks = page.locator('#field-blocks > .blocks-field__rows > div') expect(rowCount).toEqual(5)
expect(await blocks.count()).toEqual(5)
await page.click('#action-save') await page.click('#action-save')
await expect(page.locator('.payload-toast-container')).toContainText('successfully') await expect(page.locator('.payload-toast-container')).toContainText('successfully')

View File

@@ -149,7 +149,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
}; };
db: { db: {
defaultIDType: number; defaultIDType: string;
}; };
globals: {}; globals: {};
globalsSelect: {}; globalsSelect: {};
@@ -215,7 +215,7 @@ export interface LocalizedTextReference2 {
* via the `definition` "users". * via the `definition` "users".
*/ */
export interface User { export interface User {
id: number; id: string;
canViewConditionalField?: boolean | null; canViewConditionalField?: boolean | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -240,7 +240,7 @@ export interface User {
* via the `definition` "select-versions-fields". * via the `definition` "select-versions-fields".
*/ */
export interface SelectVersionsField { export interface SelectVersionsField {
id: number; id: string;
hasMany?: ('a' | 'b' | 'c' | 'd')[] | null; hasMany?: ('a' | 'b' | 'c' | 'd')[] | null;
array?: array?:
| { | {
@@ -265,7 +265,7 @@ export interface SelectVersionsField {
* via the `definition` "array-fields". * via the `definition` "array-fields".
*/ */
export interface ArrayField { export interface ArrayField {
id: number; id: string;
title?: string | null; title?: string | null;
items: { items: {
text: string; text: string;
@@ -369,7 +369,7 @@ export interface ArrayField {
* via the `definition` "block-fields". * via the `definition` "block-fields".
*/ */
export interface BlockField { export interface BlockField {
id: number; id: string;
blocks: (ContentBlock | NoBlockname | NumberBlock | SubBlocksBlock | TabsBlock)[]; blocks: (ContentBlock | NoBlockname | NumberBlock | SubBlocksBlock | TabsBlock)[];
duplicate: (ContentBlock | NoBlockname | NumberBlock | SubBlocksBlock | TabsBlock)[]; duplicate: (ContentBlock | NoBlockname | NumberBlock | SubBlocksBlock | TabsBlock)[];
collapsedByDefaultBlocks: ( collapsedByDefaultBlocks: (
@@ -500,7 +500,7 @@ export interface BlockField {
| null; | null;
relationshipBlocks?: relationshipBlocks?:
| { | {
relationship?: (number | null) | TextField; relationship?: (string | null) | TextField;
id?: string | null; id?: string | null;
blockName?: string | null; blockName?: string | null;
blockType: 'relationships'; blockType: 'relationships';
@@ -697,7 +697,7 @@ export interface LocalizedTabsBlock {
* via the `definition` "text-fields". * via the `definition` "text-fields".
*/ */
export interface TextField { export interface TextField {
id: number; id: string;
text: string; text: string;
hiddenTextField?: string | null; hiddenTextField?: string | null;
/** /**
@@ -749,7 +749,7 @@ export interface TextField {
* via the `definition` "checkbox-fields". * via the `definition` "checkbox-fields".
*/ */
export interface CheckboxField { export interface CheckboxField {
id: number; id: string;
checkbox: boolean; checkbox: boolean;
checkboxNotRequired?: boolean | null; checkboxNotRequired?: boolean | null;
updatedAt: string; updatedAt: string;
@@ -760,7 +760,7 @@ export interface CheckboxField {
* via the `definition` "code-fields". * via the `definition` "code-fields".
*/ */
export interface CodeField { export interface CodeField {
id: number; id: string;
javascript?: string | null; javascript?: string | null;
typescript?: string | null; typescript?: string | null;
json?: string | null; json?: string | null;
@@ -775,7 +775,7 @@ export interface CodeField {
* via the `definition` "collapsible-fields". * via the `definition` "collapsible-fields".
*/ */
export interface CollapsibleField { export interface CollapsibleField {
id: number; id: string;
text: string; text: string;
group: { group: {
textWithinGroup?: string | null; textWithinGroup?: string | null;
@@ -808,7 +808,7 @@ export interface CollapsibleField {
* via the `definition` "conditional-logic". * via the `definition` "conditional-logic".
*/ */
export interface ConditionalLogic { export interface ConditionalLogic {
id: number; id: string;
text: string; text: string;
toggleField?: boolean | null; toggleField?: boolean | null;
fieldWithDocIDCondition?: string | null; fieldWithDocIDCondition?: string | null;
@@ -922,7 +922,7 @@ export interface CustomRowId {
* via the `definition` "date-fields". * via the `definition` "date-fields".
*/ */
export interface DateField { export interface DateField {
id: number; id: string;
default: string; default: string;
timeOnly?: string | null; timeOnly?: string | null;
timeOnlyWithMiliseconds?: string | null; timeOnlyWithMiliseconds?: string | null;
@@ -967,7 +967,7 @@ export interface DateField {
* via the `definition` "email-fields". * via the `definition` "email-fields".
*/ */
export interface EmailField { export interface EmailField {
id: number; id: string;
email: string; email: string;
localizedEmail?: string | null; localizedEmail?: string | null;
emailWithAutocomplete?: string | null; emailWithAutocomplete?: string | null;
@@ -992,7 +992,7 @@ export interface EmailField {
* via the `definition` "radio-fields". * via the `definition` "radio-fields".
*/ */
export interface RadioField { export interface RadioField {
id: number; id: string;
radio?: ('one' | 'two' | 'three') | null; radio?: ('one' | 'two' | 'three') | null;
radioWithJsxLabelOption?: ('one' | 'two' | 'three') | null; radioWithJsxLabelOption?: ('one' | 'two' | 'three') | null;
updatedAt: string; updatedAt: string;
@@ -1003,7 +1003,7 @@ export interface RadioField {
* via the `definition` "group-fields". * via the `definition` "group-fields".
*/ */
export interface GroupField { export interface GroupField {
id: number; id: string;
/** /**
* This is a group. * This is a group.
*/ */
@@ -1085,22 +1085,22 @@ export interface GroupField {
select?: ('one' | 'two')[] | null; select?: ('one' | 'two')[] | null;
}; };
localizedGroupRel?: { localizedGroupRel?: {
email?: (number | null) | EmailField; email?: (string | null) | EmailField;
}; };
localizedGroupManyRel?: { localizedGroupManyRel?: {
email?: (number | EmailField)[] | null; email?: (string | EmailField)[] | null;
}; };
localizedGroupPolyRel?: { localizedGroupPolyRel?: {
email?: { email?: {
relationTo: 'email-fields'; relationTo: 'email-fields';
value: number | EmailField; value: string | EmailField;
} | null; } | null;
}; };
localizedGroupPolyHasManyRel?: { localizedGroupPolyHasManyRel?: {
email?: email?:
| { | {
relationTo: 'email-fields'; relationTo: 'email-fields';
value: number | EmailField; value: string | EmailField;
}[] }[]
| null; | null;
}; };
@@ -1154,30 +1154,30 @@ export interface RowField {
* via the `definition` "indexed-fields". * via the `definition` "indexed-fields".
*/ */
export interface IndexedField { export interface IndexedField {
id: number; id: string;
text: string; text: string;
uniqueText?: string | null; uniqueText?: string | null;
uniqueRelationship?: (number | null) | TextField; uniqueRelationship?: (string | null) | TextField;
uniqueHasManyRelationship?: (number | TextField)[] | null; uniqueHasManyRelationship?: (string | TextField)[] | null;
uniqueHasManyRelationship_2?: (number | TextField)[] | null; uniqueHasManyRelationship_2?: (string | TextField)[] | null;
uniquePolymorphicRelationship?: { uniquePolymorphicRelationship?: {
relationTo: 'text-fields'; relationTo: 'text-fields';
value: number | TextField; value: string | TextField;
} | null; } | null;
uniquePolymorphicRelationship_2?: { uniquePolymorphicRelationship_2?: {
relationTo: 'text-fields'; relationTo: 'text-fields';
value: number | TextField; value: string | TextField;
} | null; } | null;
uniqueHasManyPolymorphicRelationship?: uniqueHasManyPolymorphicRelationship?:
| { | {
relationTo: 'text-fields'; relationTo: 'text-fields';
value: number | TextField; value: string | TextField;
}[] }[]
| null; | null;
uniqueHasManyPolymorphicRelationship_2?: uniqueHasManyPolymorphicRelationship_2?:
| { | {
relationTo: 'text-fields'; relationTo: 'text-fields';
value: number | TextField; value: string | TextField;
}[] }[]
| null; | null;
uniqueRequiredText: string; uniqueRequiredText: string;
@@ -1213,7 +1213,7 @@ export interface IndexedField {
* via the `definition` "json-fields". * via the `definition` "json-fields".
*/ */
export interface JsonField { export interface JsonField {
id: number; id: string;
json?: { json?: {
array?: { array?: {
object?: { object?: {
@@ -1254,7 +1254,7 @@ export interface JsonField {
* via the `definition` "number-fields". * via the `definition` "number-fields".
*/ */
export interface NumberField { export interface NumberField {
id: number; id: string;
number?: number | null; number?: number | null;
min?: number | null; min?: number | null;
max?: number | null; max?: number | null;
@@ -1289,7 +1289,7 @@ export interface NumberField {
* via the `definition` "point-fields". * via the `definition` "point-fields".
*/ */
export interface PointField { export interface PointField {
id: number; id: string;
/** /**
* @minItems 2 * @minItems 2
* @maxItems 2 * @maxItems 2
@@ -1320,83 +1320,83 @@ export interface PointField {
* via the `definition` "relationship-fields". * via the `definition` "relationship-fields".
*/ */
export interface RelationshipField { export interface RelationshipField {
id: number; id: string;
text?: string | null; text?: string | null;
relationship: relationship:
| { | {
relationTo: 'text-fields'; relationTo: 'text-fields';
value: number | TextField; value: string | TextField;
} }
| { | {
relationTo: 'array-fields'; relationTo: 'array-fields';
value: number | ArrayField; value: string | ArrayField;
}; };
relationHasManyPolymorphic?: relationHasManyPolymorphic?:
| ( | (
| { | {
relationTo: 'text-fields'; relationTo: 'text-fields';
value: number | TextField; value: string | TextField;
} }
| { | {
relationTo: 'array-fields'; relationTo: 'array-fields';
value: number | ArrayField; value: string | ArrayField;
} }
)[] )[]
| null; | null;
relationToSelf?: (number | null) | RelationshipField; relationToSelf?: (string | null) | RelationshipField;
relationToSelfSelectOnly?: (number | null) | RelationshipField; relationToSelfSelectOnly?: (string | null) | RelationshipField;
relationWithAllowCreateToFalse?: (number | null) | User; relationWithAllowCreateToFalse?: (string | null) | User;
relationWithAllowEditToFalse?: (number | null) | User; relationWithAllowEditToFalse?: (string | null) | User;
relationWithDynamicDefault?: (number | null) | User; relationWithDynamicDefault?: (string | null) | User;
relationHasManyWithDynamicDefault?: { relationHasManyWithDynamicDefault?: {
relationTo: 'users'; relationTo: 'users';
value: number | User; value: string | User;
} | null; } | null;
relationshipWithMin?: (number | TextField)[] | null; relationshipWithMin?: (string | TextField)[] | null;
relationshipWithMax?: (number | TextField)[] | null; relationshipWithMax?: (string | TextField)[] | null;
relationshipHasMany?: (number | TextField)[] | null; relationshipHasMany?: (string | TextField)[] | null;
array?: array?:
| { | {
relationship?: (number | null) | TextField; relationship?: (string | null) | TextField;
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
relationshipWithMinRows?: relationshipWithMinRows?:
| { | {
relationTo: 'text-fields'; relationTo: 'text-fields';
value: number | TextField; value: string | TextField;
}[] }[]
| null; | null;
relationToRow?: (string | null) | RowField; relationToRow?: (string | null) | RowField;
relationToRowMany?: (string | RowField)[] | null; relationToRowMany?: (string | RowField)[] | null;
relationshipDrawer?: (number | null) | TextField; relationshipDrawer?: (string | null) | TextField;
relationshipDrawerReadOnly?: (number | null) | TextField; relationshipDrawerReadOnly?: (string | null) | TextField;
polymorphicRelationshipDrawer?: polymorphicRelationshipDrawer?:
| ({ | ({
relationTo: 'text-fields'; relationTo: 'text-fields';
value: number | TextField; value: string | TextField;
} | null) } | null)
| ({ | ({
relationTo: 'array-fields'; relationTo: 'array-fields';
value: number | ArrayField; value: string | ArrayField;
} | null); } | null);
relationshipDrawerHasMany?: (number | TextField)[] | null; relationshipDrawerHasMany?: (string | TextField)[] | null;
relationshipDrawerHasManyPolymorphic?: relationshipDrawerHasManyPolymorphic?:
| ( | (
| { | {
relationTo: 'text-fields'; relationTo: 'text-fields';
value: number | TextField; value: string | TextField;
} }
| { | {
relationTo: 'array-fields'; relationTo: 'array-fields';
value: number | ArrayField; value: string | ArrayField;
} }
)[] )[]
| null; | null;
relationshipDrawerWithAllowCreateFalse?: (number | null) | TextField; relationshipDrawerWithAllowCreateFalse?: (string | null) | TextField;
relationshipDrawerWithFilterOptions?: { relationshipDrawerWithFilterOptions?: {
relationTo: 'text-fields'; relationTo: 'text-fields';
value: number | TextField; value: string | TextField;
} | null; } | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -1406,7 +1406,7 @@ export interface RelationshipField {
* via the `definition` "select-fields". * via the `definition` "select-fields".
*/ */
export interface SelectField { export interface SelectField {
id: number; id: string;
select?: ('one' | 'two' | 'three') | null; select?: ('one' | 'two' | 'three') | null;
selectReadOnly?: ('one' | 'two' | 'three') | null; selectReadOnly?: ('one' | 'two' | 'three') | null;
selectHasMany?: ('one' | 'two' | 'three' | 'four' | 'five' | 'six')[] | null; selectHasMany?: ('one' | 'two' | 'three' | 'four' | 'five' | 'six')[] | null;
@@ -1436,7 +1436,7 @@ export interface SelectField {
* via the `definition` "tabs-fields-2". * via the `definition` "tabs-fields-2".
*/ */
export interface TabsFields2 { export interface TabsFields2 {
id: number; id: string;
tabsInArray?: tabsInArray?:
| { | {
text?: string | null; text?: string | null;
@@ -1454,7 +1454,7 @@ export interface TabsFields2 {
* via the `definition` "tabs-fields". * via the `definition` "tabs-fields".
*/ */
export interface TabsField { export interface TabsField {
id: number; id: string;
/** /**
* This should not collapse despite there being many tabs pushing the main fields open. * This should not collapse despite there being many tabs pushing the main fields open.
*/ */
@@ -1556,9 +1556,9 @@ export interface TabWithName {
* via the `definition` "uploads". * via the `definition` "uploads".
*/ */
export interface Upload { export interface Upload {
id: number; id: string;
text?: string | null; text?: string | null;
media?: (number | null) | Upload; media?: (string | null) | Upload;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -1576,9 +1576,9 @@ export interface Upload {
* via the `definition` "uploads2". * via the `definition` "uploads2".
*/ */
export interface Uploads2 { export interface Uploads2 {
id: number; id: string;
text?: string | null; text?: string | null;
media?: (number | null) | Uploads2; media?: (string | null) | Uploads2;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -1596,8 +1596,8 @@ export interface Uploads2 {
* via the `definition` "uploads3". * via the `definition` "uploads3".
*/ */
export interface Uploads3 { export interface Uploads3 {
id: number; id: string;
media?: (number | null) | Uploads3; media?: (string | null) | Uploads3;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -1615,9 +1615,9 @@ export interface Uploads3 {
* via the `definition` "uploads-multi". * via the `definition` "uploads-multi".
*/ */
export interface UploadsMulti { export interface UploadsMulti {
id: number; id: string;
text?: string | null; text?: string | null;
media?: (number | Upload)[] | null; media?: (string | Upload)[] | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -1626,16 +1626,16 @@ export interface UploadsMulti {
* via the `definition` "uploads-poly". * via the `definition` "uploads-poly".
*/ */
export interface UploadsPoly { export interface UploadsPoly {
id: number; id: string;
text?: string | null; text?: string | null;
media?: media?:
| ({ | ({
relationTo: 'uploads'; relationTo: 'uploads';
value: number | Upload; value: string | Upload;
} | null) } | null)
| ({ | ({
relationTo: 'uploads2'; relationTo: 'uploads2';
value: number | Uploads2; value: string | Uploads2;
} | null); } | null);
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -1645,17 +1645,17 @@ export interface UploadsPoly {
* via the `definition` "uploads-multi-poly". * via the `definition` "uploads-multi-poly".
*/ */
export interface UploadsMultiPoly { export interface UploadsMultiPoly {
id: number; id: string;
text?: string | null; text?: string | null;
media?: media?:
| ( | (
| { | {
relationTo: 'uploads'; relationTo: 'uploads';
value: number | Upload; value: string | Upload;
} }
| { | {
relationTo: 'uploads2'; relationTo: 'uploads2';
value: number | Uploads2; value: string | Uploads2;
} }
)[] )[]
| null; | null;
@@ -1667,11 +1667,11 @@ export interface UploadsMultiPoly {
* via the `definition` "uploads-restricted". * via the `definition` "uploads-restricted".
*/ */
export interface UploadsRestricted { export interface UploadsRestricted {
id: number; id: string;
text?: string | null; text?: string | null;
uploadWithoutRestriction?: (number | null) | Upload; uploadWithoutRestriction?: (string | null) | Upload;
uploadWithAllowCreateFalse?: (number | null) | Upload; uploadWithAllowCreateFalse?: (string | null) | Upload;
uploadMultipleWithAllowCreateFalse?: (number | Upload)[] | null; uploadMultipleWithAllowCreateFalse?: (string | Upload)[] | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -1680,7 +1680,7 @@ export interface UploadsRestricted {
* via the `definition` "ui-fields". * via the `definition` "ui-fields".
*/ */
export interface UiField { export interface UiField {
id: number; id: string;
text: string; text: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -1690,39 +1690,39 @@ export interface UiField {
* via the `definition` "payload-locked-documents". * via the `definition` "payload-locked-documents".
*/ */
export interface PayloadLockedDocument { export interface PayloadLockedDocument {
id: number; id: string;
document?: document?:
| ({ | ({
relationTo: 'users'; relationTo: 'users';
value: number | User; value: string | User;
} | null) } | null)
| ({ | ({
relationTo: 'select-versions-fields'; relationTo: 'select-versions-fields';
value: number | SelectVersionsField; value: string | SelectVersionsField;
} | null) } | null)
| ({ | ({
relationTo: 'array-fields'; relationTo: 'array-fields';
value: number | ArrayField; value: string | ArrayField;
} | null) } | null)
| ({ | ({
relationTo: 'block-fields'; relationTo: 'block-fields';
value: number | BlockField; value: string | BlockField;
} | null) } | null)
| ({ | ({
relationTo: 'checkbox-fields'; relationTo: 'checkbox-fields';
value: number | CheckboxField; value: string | CheckboxField;
} | null) } | null)
| ({ | ({
relationTo: 'code-fields'; relationTo: 'code-fields';
value: number | CodeField; value: string | CodeField;
} | null) } | null)
| ({ | ({
relationTo: 'collapsible-fields'; relationTo: 'collapsible-fields';
value: number | CollapsibleField; value: string | CollapsibleField;
} | null) } | null)
| ({ | ({
relationTo: 'conditional-logic'; relationTo: 'conditional-logic';
value: number | ConditionalLogic; value: string | ConditionalLogic;
} | null) } | null)
| ({ | ({
relationTo: 'custom-id'; relationTo: 'custom-id';
@@ -1730,27 +1730,27 @@ export interface PayloadLockedDocument {
} | null) } | null)
| ({ | ({
relationTo: 'custom-tab-id'; relationTo: 'custom-tab-id';
value: number | CustomTabId; value: string | CustomTabId;
} | null) } | null)
| ({ | ({
relationTo: 'custom-row-id'; relationTo: 'custom-row-id';
value: number | CustomRowId; value: string | CustomRowId;
} | null) } | null)
| ({ | ({
relationTo: 'date-fields'; relationTo: 'date-fields';
value: number | DateField; value: string | DateField;
} | null) } | null)
| ({ | ({
relationTo: 'email-fields'; relationTo: 'email-fields';
value: number | EmailField; value: string | EmailField;
} | null) } | null)
| ({ | ({
relationTo: 'radio-fields'; relationTo: 'radio-fields';
value: number | RadioField; value: string | RadioField;
} | null) } | null)
| ({ | ({
relationTo: 'group-fields'; relationTo: 'group-fields';
value: number | GroupField; value: string | GroupField;
} | null) } | null)
| ({ | ({
relationTo: 'row-fields'; relationTo: 'row-fields';
@@ -1758,76 +1758,76 @@ export interface PayloadLockedDocument {
} | null) } | null)
| ({ | ({
relationTo: 'indexed-fields'; relationTo: 'indexed-fields';
value: number | IndexedField; value: string | IndexedField;
} | null) } | null)
| ({ | ({
relationTo: 'json-fields'; relationTo: 'json-fields';
value: number | JsonField; value: string | JsonField;
} | null) } | null)
| ({ | ({
relationTo: 'number-fields'; relationTo: 'number-fields';
value: number | NumberField; value: string | NumberField;
} | null) } | null)
| ({ | ({
relationTo: 'point-fields'; relationTo: 'point-fields';
value: number | PointField; value: string | PointField;
} | null) } | null)
| ({ | ({
relationTo: 'relationship-fields'; relationTo: 'relationship-fields';
value: number | RelationshipField; value: string | RelationshipField;
} | null) } | null)
| ({ | ({
relationTo: 'select-fields'; relationTo: 'select-fields';
value: number | SelectField; value: string | SelectField;
} | null) } | null)
| ({ | ({
relationTo: 'tabs-fields-2'; relationTo: 'tabs-fields-2';
value: number | TabsFields2; value: string | TabsFields2;
} | null) } | null)
| ({ | ({
relationTo: 'tabs-fields'; relationTo: 'tabs-fields';
value: number | TabsField; value: string | TabsField;
} | null) } | null)
| ({ | ({
relationTo: 'text-fields'; relationTo: 'text-fields';
value: number | TextField; value: string | TextField;
} | null) } | null)
| ({ | ({
relationTo: 'uploads'; relationTo: 'uploads';
value: number | Upload; value: string | Upload;
} | null) } | null)
| ({ | ({
relationTo: 'uploads2'; relationTo: 'uploads2';
value: number | Uploads2; value: string | Uploads2;
} | null) } | null)
| ({ | ({
relationTo: 'uploads3'; relationTo: 'uploads3';
value: number | Uploads3; value: string | Uploads3;
} | null) } | null)
| ({ | ({
relationTo: 'uploads-multi'; relationTo: 'uploads-multi';
value: number | UploadsMulti; value: string | UploadsMulti;
} | null) } | null)
| ({ | ({
relationTo: 'uploads-poly'; relationTo: 'uploads-poly';
value: number | UploadsPoly; value: string | UploadsPoly;
} | null) } | null)
| ({ | ({
relationTo: 'uploads-multi-poly'; relationTo: 'uploads-multi-poly';
value: number | UploadsMultiPoly; value: string | UploadsMultiPoly;
} | null) } | null)
| ({ | ({
relationTo: 'uploads-restricted'; relationTo: 'uploads-restricted';
value: number | UploadsRestricted; value: string | UploadsRestricted;
} | null) } | null)
| ({ | ({
relationTo: 'ui-fields'; relationTo: 'ui-fields';
value: number | UiField; value: string | UiField;
} | null); } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: { user: {
relationTo: 'users'; relationTo: 'users';
value: number | User; value: string | User;
}; };
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -1837,10 +1837,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences". * via the `definition` "payload-preferences".
*/ */
export interface PayloadPreference { export interface PayloadPreference {
id: number; id: string;
user: { user: {
relationTo: 'users'; relationTo: 'users';
value: number | User; value: string | User;
}; };
key?: string | null; key?: string | null;
value?: value?:
@@ -1860,7 +1860,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations". * via the `definition` "payload-migrations".
*/ */
export interface PayloadMigration { export interface PayloadMigration {
id: number; id: string;
name?: string | null; name?: string | null;
batch?: number | null; batch?: number | null;
updatedAt: string; updatedAt: string;

View File

@@ -1,5 +1,9 @@
import React from 'react' import React from 'react'
export const ArrayRowLabel = () => { export const ArrayRowLabel = (props) => {
return <p id="custom-array-row-label">This is a custom component</p> return (
<p data-id={props.value[props?.rowNumber - 1]?.id} id="custom-array-row-label">
This is a custom component
</p>
)
} }

View File

@@ -6,7 +6,12 @@ import { expect, test } from '@playwright/test'
import { assertElementStaysVisible } from 'helpers/e2e/assertElementStaysVisible.js' import { assertElementStaysVisible } from 'helpers/e2e/assertElementStaysVisible.js'
import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js' import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js'
import { assertRequestBody } from 'helpers/e2e/assertRequestBody.js' import { assertRequestBody } from 'helpers/e2e/assertRequestBody.js'
import { addArrayRowAsync, removeArrayRow } from 'helpers/e2e/fields/array/index.js' import {
addArrayRow,
addArrayRowAsync,
duplicateArrayRow,
removeArrayRow,
} from 'helpers/e2e/fields/array/index.js'
import { addBlock } from 'helpers/e2e/fields/blocks/index.js' import { addBlock } from 'helpers/e2e/fields/blocks/index.js'
import { waitForAutoSaveToRunAndComplete } from 'helpers/e2e/waitForAutoSaveToRunAndComplete.js' import { waitForAutoSaveToRunAndComplete } from 'helpers/e2e/waitForAutoSaveToRunAndComplete.js'
import * as path from 'path' import * as path from 'path'
@@ -454,6 +459,34 @@ test.describe('Form State', () => {
await expect(computedTitleField).toHaveValue('Test Title 2') await expect(computedTitleField).toHaveValue('Test Title 2')
}) })
test('array and block rows and maintain consistent row IDs across duplication', async () => {
await page.goto(postsUrl.create)
await addArrayRow(page, { fieldName: 'array' })
const row0 = page.locator('#field-array #array-row-0')
await expect(row0.locator('#custom-array-row-label')).toHaveAttribute('data-id')
await expect(row0.locator('#field-array__0__id')).toHaveValue(
(await row0.locator('#custom-array-row-label').getAttribute('data-id'))!,
)
await duplicateArrayRow(page, { fieldName: 'array' })
const row1 = page.locator('#field-array #array-row-1')
await expect(row1.locator('#custom-array-row-label')).toHaveAttribute('data-id')
await expect(row1.locator('#custom-array-row-label')).not.toHaveAttribute(
'data-id',
(await row0.locator('#custom-array-row-label').getAttribute('data-id'))!,
)
await expect(row1.locator('#field-array__1__id')).toHaveValue(
(await row1.locator('#custom-array-row-label').getAttribute('data-id'))!,
)
})
describe('Throttled tests', () => { describe('Throttled tests', () => {
let cdpSession: CDPSession let cdpSession: CDPSession

View File

@@ -1,6 +1,7 @@
import type { Locator, Page } from 'playwright' import type { Locator, Page } from 'playwright'
import { wait } from 'payload/shared' import { wait } from 'payload/shared'
import { expect } from 'playwright/test'
import { openArrayRowActions } from './openArrayRowActions.js' import { openArrayRowActions } from './openArrayRowActions.js'
@@ -18,10 +19,12 @@ export const addArrayRow = async (
page: Page, page: Page,
{ fieldName }: Omit<Parameters<typeof openArrayRowActions>[1], 'rowIndex'>, { fieldName }: Omit<Parameters<typeof openArrayRowActions>[1], 'rowIndex'>,
) => { ) => {
const rowLocator = page.locator(`#field-${fieldName} .array-field__row`)
const numberOfPrevRows = await rowLocator.count()
await addArrayRowAsync(page, fieldName) await addArrayRowAsync(page, fieldName)
// TODO: test the array row has appeared expect(await rowLocator.count()).toBe(numberOfPrevRows + 1)
await wait(300)
} }
/** /**
@@ -31,16 +34,19 @@ export const addArrayRowBelow = async (
page: Page, page: Page,
{ fieldName, rowIndex = 0 }: Parameters<typeof openArrayRowActions>[1], { fieldName, rowIndex = 0 }: Parameters<typeof openArrayRowActions>[1],
): Promise<{ popupContentLocator: Locator; rowActionsButtonLocator: Locator }> => { ): Promise<{ popupContentLocator: Locator; rowActionsButtonLocator: Locator }> => {
const rowLocator = page.locator(`#field-${fieldName} .array-field__row`)
const numberOfPrevRows = await rowLocator.count()
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, { const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {
fieldName, fieldName,
rowIndex, rowIndex,
}) })
const addBelowButton = popupContentLocator.locator('.array-actions__action.array-actions__add') await popupContentLocator.locator('.array-actions__action.array-actions__add').click()
await addBelowButton.click() await expect(rowLocator).toHaveCount(numberOfPrevRows + 1)
// TODO: test the array row has appeared // TODO: test the array row has appeared in the _correct position_ (immediately below the original row)
await wait(300) await wait(300)
return { popupContentLocator, rowActionsButtonLocator } return { popupContentLocator, rowActionsButtonLocator }

View File

@@ -1,5 +1,7 @@
import type { Locator, Page } from 'playwright' import type { Locator, Page } from 'playwright'
import { expect } from 'playwright/test'
import { openArrayRowActions } from './openArrayRowActions.js' import { openArrayRowActions } from './openArrayRowActions.js'
/** /**
@@ -12,6 +14,9 @@ export const duplicateArrayRow = async (
popupContentLocator: Locator popupContentLocator: Locator
rowActionsButtonLocator: Locator rowActionsButtonLocator: Locator
}> => { }> => {
const rowLocator = page.locator(`#field-${fieldName} .array-field__row`)
const numberOfPrevRows = await rowLocator.count()
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, { const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {
fieldName, fieldName,
rowIndex, rowIndex,
@@ -19,7 +24,9 @@ export const duplicateArrayRow = async (
await popupContentLocator.locator('.array-actions__action.array-actions__duplicate').click() await popupContentLocator.locator('.array-actions__action.array-actions__duplicate').click()
// TODO: test the array row has been duplicated expect(await rowLocator.count()).toBe(numberOfPrevRows + 1)
// TODO: test the array row's field input values have been duplicated as well
return { popupContentLocator, rowActionsButtonLocator } return { popupContentLocator, rowActionsButtonLocator }
} }

View File

@@ -1,5 +1,7 @@
import type { Locator, Page } from 'playwright' import type { Locator, Page } from 'playwright'
import { expect } from 'playwright/test'
import { openArrayRowActions } from './openArrayRowActions.js' import { openArrayRowActions } from './openArrayRowActions.js'
/** /**
@@ -12,6 +14,9 @@ export const removeArrayRow = async (
popupContentLocator: Locator popupContentLocator: Locator
rowActionsButtonLocator: Locator rowActionsButtonLocator: Locator
}> => { }> => {
const rowLocator = page.locator(`#field-${fieldName} .array-field__row`)
const numberOfPrevRows = await rowLocator.count()
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, { const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {
fieldName, fieldName,
rowIndex, rowIndex,
@@ -19,8 +24,10 @@ export const removeArrayRow = async (
await popupContentLocator.locator('.array-actions__action.array-actions__remove').click() await popupContentLocator.locator('.array-actions__action.array-actions__remove').click()
// TODO: test the array row has been removed expect(await rowLocator.count()).toBe(numberOfPrevRows - 1)
// another row may have been moved into its place, though
// TODO: test the array row has been removed in the _correct position_ (original row index)
// another row may have been moved into its place, need to ensure the test accounts for this fact
return { popupContentLocator, rowActionsButtonLocator } return { popupContentLocator, rowActionsButtonLocator }
} }

View File

@@ -1,10 +1,30 @@
import type { Page } from '@playwright/test' import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test' import { expect } from '@playwright/test'
import { exactText } from 'helpers.js' import { exactText } from 'helpers.js'
import { openArrayRowActions } from '../array/openArrayRowActions.js'
import { openBlocksDrawer } from './openBlocksDrawer.js' import { openBlocksDrawer } from './openBlocksDrawer.js'
const selectBlockFromDrawer = async ({
blocksDrawer,
blockToSelect,
}: {
blocksDrawer: Locator
blockToSelect: string
}) => {
const blockCard = blocksDrawer.locator('.blocks-drawer__block .thumbnail-card__label', {
hasText: blockToSelect,
})
await expect(blockCard).toBeVisible()
await blocksDrawer.getByRole('button', { name: exactText(blockToSelect) }).click()
}
/**
* Adds a block to the end of the blocks array using the primary "Add Block" button.
*/
export const addBlock = async ({ export const addBlock = async ({
page, page,
fieldName = 'blocks', fieldName = 'blocks',
@@ -17,15 +37,66 @@ export const addBlock = async ({
fieldName: string fieldName: string
page: Page page: Page
}) => { }) => {
const rowLocator = page.locator(
`#field-${fieldName} > .blocks-field__rows > div > .blocks-field__row`,
)
const numberOfPrevRows = await rowLocator.count()
const blocksDrawer = await openBlocksDrawer({ page, fieldName }) const blocksDrawer = await openBlocksDrawer({ page, fieldName })
const blockCard = blocksDrawer.locator('.blocks-drawer__block .thumbnail-card__label', { await selectBlockFromDrawer({
hasText: blockToSelect, blocksDrawer,
blockToSelect,
}) })
await expect(blockCard).toBeVisible() await expect(rowLocator).toHaveCount(numberOfPrevRows + 1)
await blocksDrawer.getByRole('button', { name: exactText(blockToSelect) }).click()
// expect to see the block on the page // expect to see the block on the page
} }
/**
* Like `addBlock`, but inserts the block at the specified index using the row actions menu.
*/
export const addBlockBelow = async (
page: Page,
{
fieldName = 'blocks',
blockToSelect = 'Block',
rowIndex = 0,
}: {
/**
* The name of the block to select from the blocks drawer.
*/
blockToSelect: string
fieldName: string
/**
* The index at which to insert the block.
*/
rowIndex?: number
},
) => {
const rowLocator = page.locator(
`#field-${fieldName} > .blocks-field__rows > div > .blocks-field__row`,
)
const numberOfPrevRows = await rowLocator.count()
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {
fieldName,
rowIndex,
})
await popupContentLocator.locator('.array-actions__action.array-actions__add').click()
const blocksDrawer = page.locator('[id^=drawer_1_blocks-drawer-]')
await selectBlockFromDrawer({
blocksDrawer,
blockToSelect,
})
await expect(rowLocator).toHaveCount(numberOfPrevRows + 1)
return { popupContentLocator, rowActionsButtonLocator }
}

View File

@@ -0,0 +1,37 @@
import type { Locator, Page } from 'playwright'
import { expect } from 'playwright/test'
import { openArrayRowActions } from '../array/openArrayRowActions.js'
/**
* Duplicates the block row at the specified index.
*/
export const duplicateBlock = async (
page: Page,
{ fieldName, rowIndex = 0 }: Parameters<typeof openArrayRowActions>[1],
): Promise<{
popupContentLocator: Locator
rowActionsButtonLocator: Locator
rowCount: number
}> => {
const rowLocator = page.locator(
`#field-${fieldName} > .blocks-field__rows > div > .blocks-field__row`,
)
const numberOfPrevRows = await rowLocator.count()
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {
fieldName,
rowIndex,
})
await popupContentLocator.locator('.array-actions__action.array-actions__duplicate').click()
const numberOfCurrentRows = await rowLocator.count()
expect(numberOfCurrentRows).toBe(numberOfPrevRows + 1)
// TODO: test the array row's field input values have been duplicated as well
return { popupContentLocator, rowActionsButtonLocator, rowCount: numberOfCurrentRows }
}

View File

@@ -1,4 +1,5 @@
export { addBlock } from './addBlock.js' export { addBlock, addBlockBelow } from './addBlock.js'
export { duplicateBlock } from './duplicateBlock.js'
export { openBlocksDrawer } from './openBlocksDrawer.js' export { openBlocksDrawer } from './openBlocksDrawer.js'
export { removeAllBlocks } from './removeAllBlocks.js' export { removeAllBlocks } from './removeAllBlocks.js'
export { reorderBlocks } from './reorderBlocks.js' export { reorderBlocks } from './reorderBlocks.js'