feat: document drawer controls (#7679)
## Description Currently, you cannot create, delete, or duplicate documents within the document drawer directly. To create a document within a relationship field, for example, you must first navigate to the parent field and open the "create new" drawer. Similarly (but worse), to duplicate or delete a document, you must _navigate to the parent document to perform these actions_ which is incredibly disruptive to the content editing workflow. This becomes especially apparent within the relationship field where you can edit documents inline, but cannot duplicate or delete them. This PR supports all document-level actions within the document drawer so that these actions can be performed on-the-fly without navigating away. Inline duplication flow on a polymorphic "hasOne" relationship: https://github.com/user-attachments/assets/bb80404a-079d-44a1-b9bc-14eb2ab49a46 Inline deletion flow on a polymorphic "hasOne" relationship: https://github.com/user-attachments/assets/10f3587f-f70a-4cca-83ee-5dbcad32f063 - [x] I have read and understand the [CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md) document in this repository. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## Checklist: - [x] I have added tests that prove my fix is effective or that my feature works - [x] Existing test suite passes locally with my changes
This commit is contained in:
@@ -3,6 +3,7 @@ import type { TypeWithID } from 'payload'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { devUser } from 'credentials.js'
|
||||
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
getRoutes,
|
||||
initPageConsoleErrorCatch,
|
||||
login,
|
||||
openDocControls,
|
||||
openNav,
|
||||
saveDocAndAssert,
|
||||
} from '../helpers.js'
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
exactText,
|
||||
getRoutes,
|
||||
initPageConsoleErrorCatch,
|
||||
openDocControls,
|
||||
openNav,
|
||||
saveDocAndAssert,
|
||||
saveDocHotkeyAndAssert,
|
||||
@@ -61,6 +60,7 @@ const description = 'Description'
|
||||
let payload: PayloadTestSDK<Config>
|
||||
|
||||
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
|
||||
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export const seed = async (_payload) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
_payload.db?.collections[coll.slug]?.ensureIndexes(function (err) {
|
||||
if (err) {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
reject(err)
|
||||
}
|
||||
resolve(true)
|
||||
@@ -42,12 +43,12 @@ export const seed = async (_payload) => {
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
...[...Array(11)].map(() => async () => {
|
||||
...[...Array(11)].map((_, i) => async () => {
|
||||
const postDoc = await _payload.create({
|
||||
collection: postsCollectionSlug,
|
||||
data: {
|
||||
description: 'Description',
|
||||
title: 'Title',
|
||||
title: `Post ${i + 1}`,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
||||
import path from 'path'
|
||||
import { wait } from 'payload/shared'
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
ensureCompilationIsDone,
|
||||
initPageConsoleErrorCatch,
|
||||
openCreateDocDrawer,
|
||||
openDocControls,
|
||||
openDocDrawer,
|
||||
saveDocAndAssert,
|
||||
} from '../helpers.js'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
|
||||
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
||||
import path from 'path'
|
||||
import { wait } from 'payload/shared'
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
exactText,
|
||||
initPageConsoleErrorCatch,
|
||||
openCreateDocDrawer,
|
||||
openDocDrawer,
|
||||
saveDocAndAssert,
|
||||
saveDocHotkeyAndAssert,
|
||||
} from '../../../helpers.js'
|
||||
@@ -238,7 +241,7 @@ describe('relationship', () => {
|
||||
})
|
||||
|
||||
// Related issue: https://github.com/payloadcms/payload/issues/2815
|
||||
test('should modify fields in relationship drawer', async () => {
|
||||
test('should edit document in relationship drawer', async () => {
|
||||
await page.goto(url.create)
|
||||
await page.waitForURL(`**/${url.create}`)
|
||||
// First fill out the relationship field, as it's required
|
||||
@@ -269,9 +272,12 @@ describe('relationship', () => {
|
||||
// Now open the drawer again to edit the `text` field _using the keyboard_
|
||||
// Mimic real user behavior by typing into the field with spaces and backspaces
|
||||
// Explicitly use both `down` and `type` to cover edge cases
|
||||
await page
|
||||
.locator('#field-relationshipHasMany button.relationship--multi-value-label__drawer-toggler')
|
||||
.click()
|
||||
|
||||
await openDocDrawer(
|
||||
page,
|
||||
'#field-relationshipHasMany button.relationship--multi-value-label__drawer-toggler',
|
||||
)
|
||||
|
||||
await page.locator('[id^=doc-drawer_text-fields_1_] #field-text').click()
|
||||
await page.keyboard.down('1')
|
||||
await page.keyboard.type('23')
|
||||
@@ -303,7 +309,7 @@ describe('relationship', () => {
|
||||
// Drawers opened through the edit button are prone to issues due to the use of stopPropagation for certain
|
||||
// events - specifically for drawers opened through the edit button. This test is to ensure that drawers
|
||||
// opened through the edit button can be saved using the hotkey.
|
||||
test('should save using hotkey in edit document drawer', async () => {
|
||||
test('should save using hotkey in document drawer', async () => {
|
||||
await page.goto(url.create)
|
||||
// First fill out the relationship field, as it's required
|
||||
await openCreateDocDrawer(page, '#field-relationship')
|
||||
@@ -333,14 +339,181 @@ describe('relationship', () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const relationshipDocuments = await payload.find({
|
||||
collection: relationshipFieldsSlug,
|
||||
})
|
||||
|
||||
// The Seeded text document should now have a text field with value 'some updated text value',
|
||||
expect(seededTextDocument.docs.length).toEqual(1)
|
||||
|
||||
// but the relationship document should NOT exist, as the hotkey should have saved the drawer and not the parent page
|
||||
expect(relationshipDocuments.docs.length).toEqual(0)
|
||||
// NOTE: the value here represents the number of documents _before_ the test was run
|
||||
expect(relationshipDocuments.docs.length).toEqual(2)
|
||||
})
|
||||
|
||||
describe('should create document within document drawer', () => {
|
||||
test('has one', async () => {
|
||||
await navigateToDoc(page, url)
|
||||
|
||||
const originalValue = await page
|
||||
.locator('#field-relationship .relationship--single-value')
|
||||
.textContent()
|
||||
|
||||
await openDocDrawer(page, '#field-relationship .relationship--single-value__drawer-toggler')
|
||||
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
|
||||
const originalDrawerID = await drawer1Content.locator('.id-label').textContent()
|
||||
await openDocControls(drawer1Content)
|
||||
await drawer1Content.locator('#action-create').click()
|
||||
await wait(1000) // wait for /form-state to return
|
||||
const title = 'Created from drawer'
|
||||
await drawer1Content.locator('#field-text').fill(title)
|
||||
await saveDocAndAssert(page, '[id^=doc-drawer_text-fields_1_] .drawer__content #action-save')
|
||||
const newDrawerID = drawer1Content.locator('.id-label')
|
||||
await expect(newDrawerID).not.toHaveText(originalDrawerID)
|
||||
await page.locator('[id^=doc-drawer_text-fields_1_] .drawer__close').click()
|
||||
await page.locator('#field-relationship').scrollIntoViewIfNeeded()
|
||||
|
||||
await expect(
|
||||
page.locator('#field-relationship .relationship--single-value__text', {
|
||||
hasText: exactText(originalValue),
|
||||
}),
|
||||
).toBeHidden()
|
||||
|
||||
await expect(
|
||||
page.locator('#field-relationship .relationship--single-value__text', {
|
||||
hasText: exactText(title),
|
||||
}),
|
||||
).toBeVisible()
|
||||
|
||||
await page.locator('#field-relationship .rs__control').click()
|
||||
|
||||
await expect(
|
||||
page.locator('.rs__option', {
|
||||
hasText: exactText(title),
|
||||
}),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test.skip('has many', async () => {})
|
||||
})
|
||||
|
||||
describe('should duplicate document within document drawer', () => {
|
||||
test('has one', async () => {
|
||||
await navigateToDoc(page, url)
|
||||
|
||||
await wait(500)
|
||||
const fieldControl = page.locator('#field-relationship .rs__control')
|
||||
const originalValue = await page
|
||||
.locator('#field-relationship .relationship--single-value__text')
|
||||
.textContent()
|
||||
|
||||
await fieldControl.click()
|
||||
|
||||
await expect(
|
||||
page.locator('.rs__option', {
|
||||
hasText: exactText(originalValue),
|
||||
}),
|
||||
).toBeVisible()
|
||||
|
||||
await openDocDrawer(page, '#field-relationship .relationship--single-value__drawer-toggler')
|
||||
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
|
||||
const originalID = await drawer1Content.locator('.id-label').textContent()
|
||||
const originalText = 'Text'
|
||||
await drawer1Content.locator('#field-text').fill(originalText)
|
||||
await saveDocAndAssert(page, '[id^=doc-drawer_text-fields_1_] .drawer__content #action-save')
|
||||
await openDocControls(drawer1Content)
|
||||
await drawer1Content.locator('#action-duplicate').click()
|
||||
const duplicateID = drawer1Content.locator('.id-label')
|
||||
await expect(duplicateID).not.toHaveText(originalID)
|
||||
await page.locator('[id^=doc-drawer_text-fields_1_] .drawer__close').click()
|
||||
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
|
||||
await page.locator('#field-relationship').scrollIntoViewIfNeeded()
|
||||
|
||||
const newValue = `${originalText} - duplicate` // this is added via a `beforeDuplicate` hook
|
||||
|
||||
await expect(
|
||||
page.locator('#field-relationship .relationship--single-value__text', {
|
||||
hasText: exactText(originalValue),
|
||||
}),
|
||||
).toBeHidden()
|
||||
|
||||
await expect(
|
||||
page.locator('#field-relationship .relationship--single-value__text', {
|
||||
hasText: exactText(newValue),
|
||||
}),
|
||||
).toBeVisible()
|
||||
|
||||
await page.locator('#field-relationship .rs__control').click()
|
||||
|
||||
await expect(
|
||||
page.locator('.rs__option', {
|
||||
hasText: exactText(newValue),
|
||||
}),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test.skip('has many', async () => {})
|
||||
})
|
||||
|
||||
describe('should delete document within document drawer', () => {
|
||||
test('has one', async () => {
|
||||
await navigateToDoc(page, url)
|
||||
|
||||
await wait(500)
|
||||
|
||||
const originalValue = await page
|
||||
.locator('#field-relationship .relationship--single-value__text')
|
||||
.textContent()
|
||||
|
||||
await page.locator('#field-relationship .rs__control').click()
|
||||
|
||||
await expect(
|
||||
page.locator('#field-relationship .rs__option', {
|
||||
hasText: exactText(originalValue),
|
||||
}),
|
||||
).toBeVisible()
|
||||
|
||||
await openDocDrawer(
|
||||
page,
|
||||
'#field-relationship button.relationship--single-value__drawer-toggler',
|
||||
)
|
||||
|
||||
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
|
||||
const originalID = await drawer1Content.locator('.id-label').textContent()
|
||||
await openDocControls(drawer1Content)
|
||||
await drawer1Content.locator('#action-delete').click()
|
||||
|
||||
await page
|
||||
.locator('[id^=delete-].payload__modal-item.delete-document[open] button#confirm-delete')
|
||||
.click()
|
||||
|
||||
await expect(drawer1Content).toBeHidden()
|
||||
|
||||
await expect(
|
||||
page.locator('#field-relationship .relationship--single-value__text'),
|
||||
).toBeHidden()
|
||||
|
||||
await expect(page.locator('#field-relationship .rs__placeholder')).toBeVisible()
|
||||
|
||||
await page.locator('#field-relationship .rs__control').click()
|
||||
|
||||
await wait(500)
|
||||
|
||||
await expect(
|
||||
page.locator('#field-relationship .rs__option', {
|
||||
hasText: exactText(originalValue),
|
||||
}),
|
||||
).toBeHidden()
|
||||
|
||||
await expect(
|
||||
page.locator('#field-relationship .rs__option', {
|
||||
hasText: exactText(`Untitled - ${originalID}`),
|
||||
}),
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test.skip('has many', async () => {})
|
||||
})
|
||||
|
||||
// TODO: Fix this. This test flakes due to react select
|
||||
|
||||
@@ -13,6 +13,9 @@ const TextFields: CollectionConfig = {
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
required: true,
|
||||
hooks: {
|
||||
beforeDuplicate: [({ value }) => `${value} - duplicate`],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'localizedText',
|
||||
|
||||
@@ -957,8 +957,15 @@ export interface RowField {
|
||||
title: string;
|
||||
field_with_width_a?: string | null;
|
||||
field_with_width_b?: string | null;
|
||||
field_with_width_30_percent?: string | null;
|
||||
field_with_width_60_percent?: string | null;
|
||||
field_with_width_20_percent?: string | null;
|
||||
field_within_collapsible_a?: string | null;
|
||||
field_within_collapsible_b?: string | null;
|
||||
field_20_percent_width_within_row_a?: string | null;
|
||||
no_set_width_within_row_b?: string | null;
|
||||
no_set_width_within_row_c?: string | null;
|
||||
field_20_percent_width_within_row_d?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
numberFieldsSlug,
|
||||
pointFieldsSlug,
|
||||
radioFieldsSlug,
|
||||
relationshipFieldsSlug,
|
||||
richTextFieldsSlug,
|
||||
selectFieldsSlug,
|
||||
tabsFieldsSlug,
|
||||
@@ -339,6 +340,37 @@ export const seed = async (_payload: Payload) => {
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const relationshipField1 = await _payload.create({
|
||||
collection: relationshipFieldsSlug,
|
||||
data: {
|
||||
text: 'Relationship 1',
|
||||
relationship: {
|
||||
relationTo: textFieldsSlug,
|
||||
value: createdTextDoc.id,
|
||||
},
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
try {
|
||||
await _payload.create({
|
||||
collection: relationshipFieldsSlug,
|
||||
data: {
|
||||
text: 'Relationship 2',
|
||||
relationToSelf: relationshipField1.id,
|
||||
relationship: {
|
||||
relationTo: textFieldsSlug,
|
||||
value: createdAnotherTextDoc.id,
|
||||
},
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
await _payload.create({
|
||||
collection: lexicalFieldsSlug,
|
||||
data: lexicalDocWithRelId,
|
||||
|
||||
@@ -226,11 +226,6 @@ export async function closeNav(page: Page): Promise<void> {
|
||||
await expect(page.locator('.template-default.template-default--nav-open')).toBeHidden()
|
||||
}
|
||||
|
||||
export async function openDocControls(page: Page): Promise<void> {
|
||||
await page.locator('.doc-controls__popup >> .popup-button').click()
|
||||
await expect(page.locator('.doc-controls__popup >> .popup__content')).toBeVisible()
|
||||
}
|
||||
|
||||
export async function changeLocale(page: Page, newLocale: string) {
|
||||
await page.locator('.localizer >> button').first().click()
|
||||
await page
|
||||
|
||||
6
test/helpers/e2e/openDocControls.ts
Normal file
6
test/helpers/e2e/openDocControls.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type Locator, type Page, expect } from '@playwright/test'
|
||||
|
||||
export async function openDocControls(page: Locator | Page): Promise<void> {
|
||||
await page.locator('.doc-controls__popup >> .popup-button').click()
|
||||
await expect(page.locator('.doc-controls__popup >> .popup__content')).toBeVisible()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
||||
import path from 'path'
|
||||
import { wait } from 'payload/shared'
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
changeLocale,
|
||||
ensureCompilationIsDone,
|
||||
initPageConsoleErrorCatch,
|
||||
openDocControls,
|
||||
saveDocAndAssert,
|
||||
} from '../helpers.js'
|
||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||
|
||||
Reference in New Issue
Block a user