Files
payload/test/fields/collections/Array/e2e.spec.ts
Said Akhrarov 1d6ffcb80e feat(ui): adds support for copy pasting complex fields (#11513)
<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->
### What?
This PR introduces support for copy + pasting complex fields such as
Arrays and Blocks. These changes introduce a new `ClipboardAction`
component that houses logic for copy + pasting to and from the clipboard
to supported fields. I've scoped this PR to include only Blocks &
Arrays, however the structure of the components introduced lend
themselves to be easily extended to other field types. I've limited the
scope because there may be design & functional blockers that make it
unclear how to add actions to particular fields.

Supported fields:
- Arrays
([Demo](https://github.com/user-attachments/assets/523916f6-77d0-43e2-9a11-a6a9d8c1b71c))
- Array Rows
([Demo](https://github.com/user-attachments/assets/0cd01a1f-3e5e-4fea-ac83-8c0bba8d1aac))
- Blocks
([Demo](https://github.com/user-attachments/assets/4c55ac2b-55f4-4793-9b53-309b2e090dd9))
- Block Rows
([Demo](https://github.com/user-attachments/assets/1b4d2bea-981a-485b-a6c4-c59a77a50567))

Fields that may be supported in the future with minimal effort by
adopting the changes introduced here:
- Tabs
- Groups
- Collapsible
- Relationships

This PR also encompasses e2e tests that check both field and row-level
copy/pasting.

### Why?
To make it simpler and faster to copy complex fields over between
documents and rows within those docs.

### How?
Introduces a new `ClipboardAction` component with helper utilities to
aid in copy/pasting and validating field data.

Addresses #2977 & #10703

Notes:
- There seems to be an issue with Blocks & Arrays that contain RichText
fields where the RichText field dissappears from the dom upon replacing
form state. These fields are resurfaced after either saving the data or
dragging/dropping the row containing them.
- Copying a Row and then pasting it at the field-level will overwrite
the field to include only that one row. This is intended however can be
changed if requested.
- Clipboard permissions are required to use this feature. [See Clipboard
API caniuse](https://caniuse.com/async-clipboard).

#### TODO
- [x] ~~I forgot BlockReferences~~
- [x] ~~Fix tests failing due to new buttons causing locator conflicts~~
- [x] ~~Ensure deeply nested structures work~~
- [x] ~~Add missing translations~~
- [x] ~~Implement local storage instead of clipboard api~~
- [x] ~~Improve tests~~

---------

Co-authored-by: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com>
2025-07-09 13:59:22 +00:00

676 lines
22 KiB
TypeScript

/* eslint-disable playwright/no-wait-for-selector */
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { assertToastErrors } from 'helpers/assertToastErrors.js'
import { copyPasteField } from 'helpers/e2e/copyPasteField.js'
import { toggleBlockOrArrayRow } from 'helpers/e2e/toggleCollapsible.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
import type { Config } from '../../payload-types.js'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
saveDocAndAssert,
} from '../../../helpers.js'
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
import { RESTClient } from '../../../helpers/rest.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')
const { beforeAll, beforeEach, describe } = test
let payload: PayloadTestSDK<Config>
let client: RESTClient
let page: Page
let serverURL: string
// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' })
describe('Array', () => {
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname,
}))
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
if (client) {
await client.logout()
}
client = new RESTClient({ defaultSlug: 'users', serverURL })
await client.login()
await ensureCompilationIsDone({ page, serverURL })
})
let url: AdminUrlUtil
beforeAll(() => {
url = new AdminUrlUtil(serverURL, 'array-fields')
})
async function loadCreatePage() {
await page.goto(url.create)
//ensure page is loaded
await expect(page.locator('#field-title')).toBeVisible()
await expect(page.locator('#field-title')).toBeEnabled()
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
}
test('should be readOnly', async () => {
await loadCreatePage()
const field = page.locator('#field-readOnly__0__text')
await expect(field).toBeDisabled()
await expect(page.locator('#field-readOnly .array-field__add-row')).toBeHidden()
})
test('should render RowLabel using a component', async () => {
const label = 'custom row label as component'
await loadCreatePage()
await page.locator('#field-rowLabelAsComponent >> .array-field__add-row').click()
await expect(page.locator('#field-rowLabelAsComponent__0__title')).toBeVisible()
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
// ensure the default label does not blink in before form state returns
const defaultRowLabelWasAttached = await page
.waitForSelector('#field-rowLabelAsComponent .array-field__row-header .row-label', {
state: 'attached',
timeout: 100, // A small timeout to catch any transient rendering
})
.catch(() => false) // If it doesn't appear, this resolves to `false`
expect(defaultRowLabelWasAttached).toBeFalsy()
await expect(page.locator('#field-rowLabelAsComponent #custom-array-row-label')).toBeVisible()
await page.locator('#field-rowLabelAsComponent__0__title').fill(label)
await wait(100)
const customRowLabel = page.locator(
'#rowLabelAsComponent-row-0 >> .array-field__row-header > :text("custom row label")',
)
await expect(customRowLabel).toHaveCSS('text-transform', 'uppercase')
})
test('should render custom RowLabel after duplicating array item', async () => {
const label = 'test custom row label'
const updatedLabel = 'updated custom row label'
await loadCreatePage()
await page.locator('#field-rowLabelAsComponent >> .array-field__add-row').click()
await page.locator('#field-rowLabelAsComponent__0__title').fill(label)
const customRowLabel = page.locator(
'#rowLabelAsComponent-row-0 >> .array-field__row-header > :text("test custom row label")',
)
await expect(customRowLabel).toBeVisible()
await expect(customRowLabel).toHaveCSS('text-transform', 'uppercase')
const rowActionsButton = page.locator('#rowLabelAsComponent-row-0 .array-actions__button')
await rowActionsButton.click()
const duplicateButton = page.locator(
'#rowLabelAsComponent-row-0 .popup__scroll-container .array-actions__duplicate',
)
await expect(duplicateButton).toBeVisible()
await duplicateButton.click()
await expect(page.locator('#rowLabelAsComponent-row-1')).toBeVisible()
await expect(
page.locator(
'#rowLabelAsComponent-row-1 >> .array-field__row-header > :text("test custom row label")',
),
).toBeVisible()
await page.locator('#field-rowLabelAsComponent__1__title').fill(updatedLabel)
const duplicatedRowLabel = page.locator(
'#rowLabelAsComponent-row-1 >> .array-field__row-header > :text("updated custom row label")',
)
await expect(duplicatedRowLabel).toBeVisible()
await expect(duplicatedRowLabel).toHaveCSS('text-transform', 'uppercase')
})
test('should render default array field within custom component', async () => {
await loadCreatePage()
await page.locator('#field-customArrayField >> .array-field__add-row').click()
await expect(page.locator('#field-customArrayField__0__text')).toBeVisible()
})
test('should bypass min rows validation when no rows present and field is not required', async () => {
await loadCreatePage()
await saveDocAndAssert(page)
})
test('should fail min rows validation when rows are present', async () => {
await loadCreatePage()
await page.locator('#field-arrayWithMinRows >> .array-field__add-row').click()
// Ensure new array row is visible and fields are rendered
await expect(page.locator('#arrayWithMinRows-row-0')).toBeVisible()
await expect(
page.locator('#arrayWithMinRows-row-0 #field-arrayWithMinRows__0__text'),
).toBeVisible()
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
await page.click('#action-save', { delay: 100 })
await assertToastErrors({
page,
errors: ['Array With Min Rows'],
})
})
test('should show singular label for array rows', async () => {
await loadCreatePage()
await expect(page.locator('#field-items #items-row-0 .row-label')).toContainText('Item 01')
})
test('ensure functions passed to array field labels property are respected', async () => {
await loadCreatePage()
const arrayWithLabelsField = page.locator('#field-arrayWithLabels')
await expect(arrayWithLabelsField.locator('.array-field__add-row')).toHaveText('Add Account')
})
describe('row manipulation', () => {
test('should add, remove and duplicate rows', async () => {
const assertText0 = 'array row 1'
const assertGroupText0 = 'text in group in row 1'
const assertText1 = 'array row 2'
const assertText3 = 'array row 3'
const assertGroupText3 = 'text in group in row 3'
await loadCreatePage()
await page.mouse.wheel(0, 1750)
await page.locator('#field-potentiallyEmptyArray').scrollIntoViewIfNeeded()
await wait(300)
// Add 3 rows
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
await wait(300)
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
await wait(300)
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
await wait(300)
// Fill out row 1
await page.locator('#field-potentiallyEmptyArray__0__text').fill(assertText0)
await page
.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow')
.fill(assertGroupText0)
// Fill out row 2
await page.locator('#field-potentiallyEmptyArray__1__text').fill(assertText1)
// Fill out row 3
await page.locator('#field-potentiallyEmptyArray__2__text').fill(assertText3)
await page
.locator('#field-potentiallyEmptyArray__2__groupInRow__textInGroupInRow')
.fill(assertGroupText3)
// Remove row 1
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
await page
.locator('#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__remove')
.click()
// Remove row 2
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
await page
.locator('#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__remove')
.click()
// Save document
await saveDocAndAssert(page)
// Scroll to array row (fields are not rendered in DOM until on screen)
await page.locator('#field-potentiallyEmptyArray__0__groupInRow').scrollIntoViewIfNeeded()
// Expect the remaining row to be the third row
const input = page.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow')
await expect(input).toHaveValue(assertGroupText3)
// Duplicate row
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
await page
.locator('#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__duplicate')
.click()
// Update duplicated row group field text
await page
.locator('#field-potentiallyEmptyArray__1__groupInRow__textInGroupInRow')
.fill(`${assertGroupText3} duplicate`)
// Save document
await saveDocAndAssert(page)
// Expect the second row to be a duplicate of the remaining row
await expect(
page.locator('#field-potentiallyEmptyArray__1__groupInRow__textInGroupInRow'),
).toHaveValue(`${assertGroupText3} duplicate`)
// Remove row 1
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
await page
.locator('#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__remove')
.click()
// Save document
await saveDocAndAssert(page)
// Expect the remaining row to be the copy of the duplicate row
await expect(
page.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow'),
).toHaveValue(`${assertGroupText3} duplicate`)
})
})
// TODO: re-enable this test
test.skip('should bulk update', async () => {
await payload.create({
collection: 'array-fields',
data: {
title: 'for test 1',
items: [
{
text: 'test 1',
},
{
text: 'test 2',
},
],
},
})
await payload.create({
collection: 'array-fields',
data: {
title: 'for test 2',
items: [
{
text: 'test 3',
},
],
},
})
await payload.create({
collection: 'array-fields',
data: {
title: 'for test 3',
items: [
{
text: 'test 4',
},
{
text: 'test 5',
},
{
text: 'test 6',
},
],
},
})
const bulkText = 'Bulk update text'
await page.goto(url.list)
await page.waitForSelector('.table > table > tbody > tr td.cell-title')
const rows = page.locator('.table > table > tbody > tr', {
has: page.locator('td.cell-title a', {
hasText: 'for test',
}),
})
const count = await rows.count()
for (let i = 0; i < count; i++) {
await rows
.nth(i)
.locator('td.cell-_select .checkbox-input__input > input[type="checkbox"]')
.click()
}
await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const arrayOption = page.locator('.rs__option', {
hasText: 'Items',
})
await expect(arrayOption).toBeVisible()
await arrayOption.click()
await wait(200)
const addRowButton = page.locator('#field-items > .array-field__add-row')
await expect(addRowButton).toBeVisible()
await addRowButton.click()
await wait(200)
const targetInput = page.locator('#field-items__0__text')
await expect(targetInput).toBeVisible()
await targetInput.fill(bulkText)
await page.locator('#edit-array-fields .form-submit .edit-many__save').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 3 Array Fields successfully.',
)
})
test('should externally update array rows and render custom fields', async () => {
await loadCreatePage()
await page.locator('#updateArrayExternally').click()
await expect(page.locator('#custom-text-field')).toBeVisible()
})
test('should initialize array rows with collapsed state', async () => {
await page.goto(url.create)
await page.locator('#field-collapsedArray >> .array-field__add-row').click()
const row = page.locator(`#collapsedArray-row-0`)
const toggler = row.locator('button.collapsible__toggle')
await expect(toggler).toHaveClass(/collapsible__toggle--collapsed/)
await expect(page.locator(`#field-collapsedArray__0__text`)).toBeHidden()
})
test('should not collapse array rows on input change', async () => {
await page.goto(url.create)
await page.locator('#field-collapsedArray >> .array-field__add-row').click()
const row = page.locator(`#collapsedArray-row-0`)
const toggler = row.locator('button.collapsible__toggle')
await expect(toggler).toHaveClass(/collapsible__toggle--collapsed/)
await expect(page.locator(`#field-collapsedArray__0__text`)).toBeHidden()
await toggleBlockOrArrayRow({
page,
rowIndex: 0,
fieldName: 'collapsedArray',
targetState: 'open',
})
await page.locator('input#field-collapsedArray__0__text').fill('Hello, world!')
// wait for form state to return, in the future can wire this into watch network requests (if needed)
await wait(1000)
await expect(toggler).toHaveClass(/collapsible__toggle--open/)
await expect(page.locator(`#field-collapsedArray__0__text`)).toBeVisible()
})
describe('sortable arrays', () => {
test('should have disabled admin sorting', async () => {
await loadCreatePage()
const field = page.locator('#field-disableSort > div > div > .array-actions__action-chevron')
expect(await field.count()).toEqual(0)
})
test('the drag handle should be hidden', async () => {
await loadCreatePage()
const field = page.locator(
'#field-disableSort > .blocks-field__rows > div > div > .collapsible__drag',
)
expect(await field.count()).toEqual(0)
})
})
describe('copy paste', () => {
test('should prevent copying an empty array field', async () => {
await page.goto(url.create)
const arrayFieldPopupBtn = page.locator(
'#field-collapsedArray .popup.clipboard-action__popup button.popup-button',
)
await arrayFieldPopupBtn.click()
const disabledCopyBtn = page.locator(
'#field-collapsedArray .popup.clipboard-action__popup .popup__content div.popup-button-list__disabled:has-text("Copy Field")',
)
await expect(disabledCopyBtn).toBeVisible()
})
test('should prevent pasting into readonly array field', async () => {
await page.goto(url.create)
await copyPasteField({
fieldName: 'readOnly',
page,
})
const popupBtn = page.locator(
'#field-readOnly .popup.clipboard-action__popup button.popup-button',
)
await expect(popupBtn).toBeVisible()
await popupBtn.click()
const disabledPasteBtn = page.locator(
'#field-readOnly .popup.clipboard-action__popup .popup__content div.popup-button-list__disabled:has-text("Paste Field")',
)
await expect(disabledPasteBtn).toBeVisible()
})
test('should prevent pasting into array field with different schema', async () => {
await page.goto(url.create)
await copyPasteField({
fieldName: 'readOnly',
page,
})
await copyPasteField({
fieldName: 'items',
page,
action: 'paste',
})
const pasteErrorToast = page
.locator('.payload-toast-item.toast-error')
.filter({ hasText: 'Invalid clipboard data.' })
await expect(pasteErrorToast).toBeVisible()
})
test('should copy and paste array fields', async () => {
await page.goto(url.create)
const arrayField = page.locator('#field-items')
const row = arrayField.locator('#items-row-0')
const rowTextInput = row.locator('#field-items__0__text')
const textVal = 'row one copy'
await rowTextInput.fill(textVal)
await copyPasteField({
page,
fieldName: 'items',
})
await page.reload()
await expect(rowTextInput).toHaveValue('row one')
await copyPasteField({
page,
action: 'paste',
fieldName: 'items',
})
await expect(rowTextInput).toHaveValue(textVal)
})
test('should copy and paste array rows', async () => {
await page.goto(url.create)
const arrayField = page.locator('#field-items')
const row = arrayField.locator('#items-row-0')
const rowTextInput = row.locator('#field-items__0__text')
const textVal = 'row one copy'
await rowTextInput.fill(textVal)
await copyPasteField({
page,
fieldName: 'items',
rowIndex: 0,
})
await page.reload()
await expect(rowTextInput).toHaveValue('row one')
await copyPasteField({
page,
action: 'paste',
fieldName: 'items',
rowIndex: 0,
})
await expect(rowTextInput).toHaveValue(textVal)
})
test('should copy an array row and paste into a field with the same schema', async () => {
await page.goto(url.create)
await copyPasteField({
page,
fieldName: 'localized',
rowIndex: 0,
})
await copyPasteField({
page,
fieldName: 'disableSort',
action: 'paste',
})
const rowsContainer = page
.locator('#field-disableSort > div.array-field__draggable-rows')
.first()
await expect(rowsContainer).toBeVisible()
const rowTextInput = rowsContainer.locator('#field-disableSort__0__text')
await expect(rowTextInput).toHaveValue('row one')
})
test('should copy an array field and paste into a row with the same schema', async () => {
await page.goto(url.create)
await copyPasteField({
page,
fieldName: 'localized',
})
const field = page.locator('#field-disableSort')
const addArrayBtn = field
.locator('button.array-field__add-row')
.filter({ hasText: 'Add Disable Sort' })
await expect(addArrayBtn).toBeVisible()
await addArrayBtn.click()
const row = field.locator('#disableSort-row-0')
await expect(row).toBeVisible()
await copyPasteField({ page, action: 'paste', fieldName: 'disableSort' })
const rowsContainer = page
.locator('#field-disableSort > div.array-field__draggable-rows')
.first()
await expect(rowsContainer).toBeVisible()
const rowTextInput = rowsContainer.locator('#field-disableSort__0__text')
await expect(rowTextInput).toHaveValue('row one')
})
test('should correctly paste a row with nested arrays into a row with no children', async () => {
await page.goto(url.create)
const field = page.locator('#field-items')
const addSubArrayBtn = field.locator(
'#field-items__0__subArray > button.array-field__add-row',
)
await addSubArrayBtn.click()
const textInputRowOne = field.locator('#field-items__0__subArray__0__text')
await expect(textInputRowOne).toBeVisible()
const textInputRowOneValue = 'sub array row one'
await textInputRowOne.fill(textInputRowOneValue)
await copyPasteField({
page,
fieldName: 'items',
rowIndex: 0,
})
await copyPasteField({
page,
fieldName: 'items',
rowIndex: 1,
action: 'paste',
})
const textInputRowTwo = field.locator('#field-items__1__subArray__0__text')
await expect(textInputRowTwo).toBeVisible()
await expect(textInputRowTwo).toHaveValue(textInputRowOneValue)
})
test('should replace the rows of a nested array field with those of its paste counterpart', async () => {
await page.goto(url.create)
const field = page.locator('#field-items')
const addSubArrayBtn = field.locator(
'#field-items__0__subArray > button.array-field__add-row',
)
await expect(addSubArrayBtn).toBeVisible()
await addSubArrayBtn.click()
await addSubArrayBtn.click()
const addSubArrayBtn2 = field.locator(
'#field-items__1__subArray > button.array-field__add-row',
)
await expect(addSubArrayBtn2).toBeVisible()
await addSubArrayBtn2.click()
const subArrayContainer = field.locator(
'#field-items__0__subArray > div.array-field__draggable-rows > div',
)
const subArrayContainer2 = field.locator(
'#field-items__1__subArray > div.array-field__draggable-rows > div',
)
await expect(subArrayContainer).toHaveCount(2)
await expect(subArrayContainer2).toHaveCount(1)
await copyPasteField({
page,
fieldName: 'items',
rowIndex: 1,
})
await copyPasteField({
page,
fieldName: 'items',
rowIndex: 0,
action: 'paste',
})
await expect(subArrayContainer).toHaveCount(1)
await expect(subArrayContainer2).toHaveCount(1)
})
})
})