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/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// 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': {
const { path, rowIndex } = action
const { remainingFields, rows } = separateRows(path, state)
const rowsMetadata = [...(state[path].rows || [])]
const rowsWithDuplicate = [...(state[path].rows || [])]
const duplicateRowMetadata = deepCopyObjectSimpleWithoutReactComponents(
rowsMetadata[rowIndex],
)
const newRow = deepCopyObjectSimpleWithoutReactComponents(rowsWithDuplicate[rowIndex])
if (duplicateRowMetadata.id) {
duplicateRowMetadata.id = new ObjectId().toHexString()
const newRowID = new ObjectId().toHexString()
if (newRow.id) {
newRow.id = newRowID
}
if (rowsMetadata[rowIndex]?.customComponents?.RowLabel) {
duplicateRowMetadata.customComponents = {
RowLabel: rowsMetadata[rowIndex].customComponents.RowLabel,
if (rowsWithDuplicate[rowIndex]?.customComponents?.RowLabel) {
newRow.customComponents = {
RowLabel: rowsWithDuplicate[rowIndex].customComponents.RowLabel,
}
}
const duplicateRowState = deepCopyObjectSimpleWithoutReactComponents(rows[rowIndex])
if (duplicateRowState.id) {
duplicateRowState.id.value = new ObjectId().toHexString()
duplicateRowState.id.initialValue = new ObjectId().toHexString()
duplicateRowState.id.value = newRowID
duplicateRowState.id.initialValue = newRowID
}
for (const key of Object.keys(duplicateRowState).filter((key) => key.endsWith('.id'))) {
const idState = duplicateRowState[key]
const newNestedFieldID = new ObjectId().toHexString()
if (idState && typeof idState.value === 'string' && ObjectId.isValid(idState.value)) {
duplicateRowState[key].value = new ObjectId().toHexString()
duplicateRowState[key].initialValue = new ObjectId().toHexString()
duplicateRowState[key].value = newNestedFieldID
duplicateRowState[key].initialValue = newNestedFieldID
}
}
@@ -170,7 +172,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
if (Object.keys(duplicateRowState).length > 0) {
// Add new object containing subfield names to unflattenedRows array
rows.splice(rowIndex + 1, 0, duplicateRowState)
rowsMetadata.splice(rowIndex + 1, 0, duplicateRowMetadata)
rowsWithDuplicate.splice(rowIndex + 1, 0, newRow)
}
const newState = {
@@ -179,7 +181,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[path]: {
...state[path],
disableFormData: true,
rows: rowsMetadata,
rows: rowsWithDuplicate,
value: rows.length,
},
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,9 @@
import React from 'react'
export const ArrayRowLabel = () => {
return <p id="custom-array-row-label">This is a custom component</p>
export const ArrayRowLabel = (props) => {
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 { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.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 { waitForAutoSaveToRunAndComplete } from 'helpers/e2e/waitForAutoSaveToRunAndComplete.js'
import * as path from 'path'
@@ -454,6 +459,34 @@ test.describe('Form State', () => {
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', () => {
let cdpSession: CDPSession

View File

@@ -1,6 +1,7 @@
import type { Locator, Page } from 'playwright'
import { wait } from 'payload/shared'
import { expect } from 'playwright/test'
import { openArrayRowActions } from './openArrayRowActions.js'
@@ -18,10 +19,12 @@ export const addArrayRow = async (
page: Page,
{ 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)
// TODO: test the array row has appeared
await wait(300)
expect(await rowLocator.count()).toBe(numberOfPrevRows + 1)
}
/**
@@ -31,16 +34,19 @@ export const addArrayRowBelow = async (
page: Page,
{ fieldName, rowIndex = 0 }: Parameters<typeof openArrayRowActions>[1],
): 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, {
fieldName,
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)
return { popupContentLocator, rowActionsButtonLocator }

View File

@@ -1,5 +1,7 @@
import type { Locator, Page } from 'playwright'
import { expect } from 'playwright/test'
import { openArrayRowActions } from './openArrayRowActions.js'
/**
@@ -12,6 +14,9 @@ export const duplicateArrayRow = async (
popupContentLocator: Locator
rowActionsButtonLocator: Locator
}> => {
const rowLocator = page.locator(`#field-${fieldName} .array-field__row`)
const numberOfPrevRows = await rowLocator.count()
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {
fieldName,
rowIndex,
@@ -19,7 +24,9 @@ export const duplicateArrayRow = async (
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 }
}

View File

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

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 { exactText } from 'helpers.js'
import { openArrayRowActions } from '../array/openArrayRowActions.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 ({
page,
fieldName = 'blocks',
@@ -17,15 +37,66 @@ export const addBlock = async ({
fieldName: string
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 blockCard = blocksDrawer.locator('.blocks-drawer__block .thumbnail-card__label', {
hasText: blockToSelect,
await selectBlockFromDrawer({
blocksDrawer,
blockToSelect,
})
await expect(blockCard).toBeVisible()
await blocksDrawer.getByRole('button', { name: exactText(blockToSelect) }).click()
await expect(rowLocator).toHaveCount(numberOfPrevRows + 1)
// 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 { removeAllBlocks } from './removeAllBlocks.js'
export { reorderBlocks } from './reorderBlocks.js'