import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' import qs from 'qs' import type { Post } from './config' import payload from '../../packages/payload/src' import { mapAsync } from '../../packages/payload/src/utilities/mapAsync' import wait from '../../packages/payload/src/utilities/wait' import { saveDocAndAssert, saveDocHotkeyAndAssert } from '../helpers' import { AdminUrlUtil } from '../helpers/adminUrlUtil' import { initPayloadE2E } from '../helpers/configHelpers' import { globalSlug, slug } from './shared' const { afterEach, beforeAll, beforeEach, describe } = test const title = 'title' const description = 'description' let url: AdminUrlUtil let serverURL: string describe('admin', () => { let page: Page beforeAll(async ({ browser }) => { serverURL = (await initPayloadE2E(__dirname)).serverURL await clearDocs() // Clear any seeded data from onInit url = new AdminUrlUtil(serverURL, slug) const context = await browser.newContext() page = await context.newPage() }) afterEach(async () => { await clearDocs() // clear preferences await payload.db.deleteMany({ collection: 'payload-preferences', where: {}, }) }) describe('Nav', () => { test('should nav to collection - sidebar', async () => { await page.goto(url.admin) const collectionLink = page.locator(`#nav-${slug}`) await collectionLink.click() expect(page.url()).toContain(url.list) }) test('should nav to a global - sidebar', async () => { await page.goto(url.admin) await page.locator(`#nav-global-${globalSlug}`).click() expect(page.url()).toContain(url.global(globalSlug)) }) test('should navigate to collection - card', async () => { await page.goto(url.admin) await page.locator(`#card-${slug}`).click() expect(page.url()).toContain(url.list) }) test('should collapse and expand collection groups', async () => { await page.goto(url.admin) const navGroup = page.locator('#nav-group-One .nav-group__toggle') const link = page.locator('#nav-group-one-collection-ones') await expect(navGroup).toContainText('One') await expect(link).toBeVisible() await navGroup.click() await expect(link).toBeHidden() await navGroup.click() await expect(link).toBeVisible() }) test('should collapse and expand globals groups', async () => { await page.goto(url.admin) const navGroup = page.locator('#nav-group-Group .nav-group__toggle') const link = page.locator('#nav-global-group-globals-one') await expect(navGroup).toContainText('Group') await expect(link).toBeVisible() await navGroup.click() await expect(link).toBeHidden() await navGroup.click() await expect(link).toBeVisible() }) test('should save nav group collapse preferences', async () => { await page.goto(url.admin) const navGroup = page.locator('#nav-group-One .nav-group__toggle') await navGroup.click() await page.goto(url.admin) const link = page.locator('#nav-group-one-collection-ones') await expect(link).toBeHidden() }) test('breadcrumbs - from list to dashboard', async () => { await page.goto(url.list) await page.locator('.step-nav a[href="/admin"]').click() expect(page.url()).toContain(url.admin) }) test('breadcrumbs - from document to collection', async () => { const { id } = await createPost() await page.goto(url.edit(id)) await page.locator(`.step-nav >> text=${slug}`).click() expect(page.url()).toContain(url.list) }) test('should not show hidden collections and globals', async () => { await page.goto(url.admin) // nav menu await expect(page.locator('#nav-hidden-collection')).toBeHidden() await expect(page.locator('#nav-hidden-global')).toBeHidden() // dashboard await expect(page.locator('#card-hidden-collection')).toBeHidden() await expect(page.locator('#card-hidden-global')).toBeHidden() // routing await page.goto(url.collection('hidden-collection')) await expect(page.locator('.not-found')).toContainText('Nothing found') await page.goto(url.global('hidden-global')) await expect(page.locator('.not-found')).toContainText('Nothing found') }) }) describe('CRUD', () => { test('should create', async () => { await page.goto(url.create) await page.locator('#field-title').fill(title) await page.locator('#field-description').fill(description) await saveDocAndAssert(page) await expect(page.locator('#field-title')).toHaveValue(title) await expect(page.locator('#field-description')).toHaveValue(description) }) test('should read existing', async () => { const { id } = await createPost() await page.goto(url.edit(id)) await expect(page.locator('#field-title')).toHaveValue(title) await expect(page.locator('#field-description')).toHaveValue(description) }) test('should update existing', async () => { const { id } = await createPost() await page.goto(url.edit(id)) const newTitle = 'new title' const newDesc = 'new description' await page.locator('#field-title').fill(newTitle) await page.locator('#field-description').fill(newDesc) await saveDocAndAssert(page) await expect(page.locator('#field-title')).toHaveValue(newTitle) await expect(page.locator('#field-description')).toHaveValue(newDesc) }) test('should save using hotkey', async () => { const { id } = await createPost() await page.goto(url.edit(id)) const newTitle = 'new title' await page.locator('#field-title').fill(newTitle) await saveDocHotkeyAndAssert(page) await expect(page.locator('#field-title')).toHaveValue(newTitle) }) test('should delete existing', async () => { const { id, ...post } = await createPost() await page.goto(url.edit(id)) await page.locator('#action-delete').click() await page.locator('#confirm-delete').click() await expect(page.locator(`text=Post en "${post.title}" successfully deleted.`)).toBeVisible() expect(page.url()).toContain(url.list) }) test('should bulk delete', async () => { await createPost() await createPost() await createPost() await page.goto(url.list) await page.locator('input#select-all').check() await page.locator('.delete-documents__toggle').click() await page.locator('#confirm-delete').click() await expect(page.locator('.Toastify__toast--success')).toHaveText( 'Deleted 3 Posts en successfully.', ) await expect(page.locator('.collection-list__no-results')).toBeVisible() }) test('should bulk update', async () => { await createPost() await createPost() await createPost() const bulkTitle = 'Bulk update title' await page.goto(url.list) await page.locator('input#select-all').check() await page.locator('.edit-many__toggle').click() await page.locator('.field-select .rs__control').click() const options = page.locator('.rs__option') const titleOption = options.locator('text=Title en') await expect(titleOption).toHaveText('Title en') await titleOption.click() const titleInput = page.locator('#field-title') await expect(titleInput).toBeVisible() await titleInput.fill(bulkTitle) await page.locator('.form-submit button[type="submit"]').click() await expect(page.locator('.Toastify__toast--success')).toContainText( 'Updated 3 Posts en successfully.', ) await expect(page.locator('.row-1 .cell-title')).toContainText(bulkTitle) await expect(page.locator('.row-2 .cell-title')).toContainText(bulkTitle) await expect(page.locator('.row-3 .cell-title')).toContainText(bulkTitle) }) test('should save globals', async () => { await page.goto(url.global(globalSlug)) await page.locator('#field-title').fill(title) await saveDocAndAssert(page) await expect(page.locator('#field-title')).toHaveValue(title) }) }) describe('i18n', () => { test('should allow changing language', async () => { await page.goto(url.account) const field = page.locator('.account__language .react-select') await field.click() const options = page.locator('.rs__option') await options.locator('text=EspaƱol').click() await expect(page.locator('.step-nav')).toContainText('Tablero') await field.click() await options.locator('text=English').click() await field.click() await expect(page.locator('.form-submit .btn')).toContainText('Save') }) test('should allow custom translation', async () => { await page.goto(url.account) await expect(page.locator('.step-nav')).toContainText('Home') }) }) describe('list view', () => { const tableRowLocator = 'table >> tbody >> tr' beforeEach(async () => { await page.goto(url.list) }) describe('filtering', () => { test('search by id', async () => { const { id } = await createPost() await page.locator('.search-filter__input').fill(id) const tableItems = page.locator(tableRowLocator) await expect(tableItems).toHaveCount(1) }) test('search by title or description', async () => { await createPost({ title: 'find me', description: 'this is fun', }) await page.locator('.search-filter__input').fill('find me') await expect(page.locator(tableRowLocator)).toHaveCount(1) await page.locator('.search-filter__input').fill('this is fun') await expect(page.locator(tableRowLocator)).toHaveCount(1) }) test('toggle columns', async () => { const columnCountLocator = 'table >> thead >> tr >> th' await createPost() await page.locator('.list-controls__toggle-columns').click() // wait until the column toggle UI is visible and fully expanded await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() const numberOfColumns = await page.locator(columnCountLocator).count() await expect(page.locator('table >> thead >> tr >> th:nth-child(2)')).toHaveText('ID') const idButton = page.locator('.column-selector >> text=ID') // Remove ID column await idButton.click() // wait until .cell-id is not present on the page: await page.locator('.cell-id').waitFor({ state: 'detached' }) await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns - 1) await expect(page.locator('table >> thead >> tr >> th:nth-child(2)')).toHaveText('Number') // Add back ID column await idButton.click() await expect(page.locator('.cell-id')).toBeVisible() await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns) await expect(page.locator('table >> thead >> tr >> th:nth-child(2)')).toHaveText('ID') }) test('2nd cell is a link', async () => { const { id } = await createPost() const linkCell = page.locator(`${tableRowLocator} td`).nth(1).locator('a') await expect(linkCell).toHaveAttribute('href', `/admin/collections/posts/${id}`) // open the column controls await page.locator('.list-controls__toggle-columns').click() // wait until the column toggle UI is visible and fully expanded await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() // toggle off the ID column await page.locator('.column-selector >> text=ID').click() // wait until .cell-id is not present on the page: await page.locator('.cell-id').waitFor({ state: 'detached' }) // recheck that the 2nd cell is still a link await expect(linkCell).toHaveAttribute('href', `/admin/collections/posts/${id}`) }) test('filter rows', async () => { const { id } = await createPost({ title: 'post1' }) await createPost({ title: 'post2' }) // open the column controls await page.locator('.list-controls__toggle-columns').click() // wait until the column toggle UI is visible and fully expanded await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() // ensure the ID column is active const idButton = page.locator('.column-selector >> text=ID') const buttonClasses = await idButton.getAttribute('class') if (buttonClasses && !buttonClasses.includes('column-selector__column--active')) { await idButton.click() await expect(page.locator(tableRowLocator).first().locator('.cell-id')).toBeVisible() } await expect(page.locator(tableRowLocator)).toHaveCount(2) await page.locator('.list-controls__toggle-where').click() // wait until the filter UI is visible and fully expanded await expect(page.locator('.list-controls__where.rah-static--height-auto')).toBeVisible() await page.locator('.where-builder__add-first-filter').click() const operatorField = page.locator('.condition__operator') const valueField = page.locator('.condition__value >> input') await operatorField.click() const dropdownOptions = operatorField.locator('.rs__option') await dropdownOptions.locator('text=equals').click() await valueField.fill(id) await expect(page.locator(tableRowLocator)).toHaveCount(1) const firstId = await page.locator(tableRowLocator).first().locator('.cell-id').innerText() expect(firstId).toEqual(id) // Remove filter await page.locator('.condition__actions-remove').click() await expect(page.locator(tableRowLocator)).toHaveCount(2) }) test('should accept where query from valid URL where parameter', async () => { await createPost({ title: 'post1' }) await createPost({ title: 'post2' }) await page.goto(`${url.list}?limit=10&page=1&where[or][0][and][0][title][equals]=post1`) await expect(page.locator('.react-select--single-value').first()).toContainText('Title en') await expect(page.locator(tableRowLocator)).toHaveCount(1) }) test('should accept transformed where query from invalid URL where parameter', async () => { await createPost({ title: 'post1' }) await createPost({ title: 'post2' }) // [title][equals]=post1 should be getting transformed into a valid where[or][0][and][0][title][equals]=post1 await page.goto(`${url.list}?limit=10&page=1&where[title][equals]=post1`) await expect(page.locator('.react-select--single-value').first()).toContainText('Title en') await expect(page.locator(tableRowLocator)).toHaveCount(1) }) test('should accept where query from complex, valid URL where parameter using the near operator', async () => { // We have one point collection with the point [5,-5] and one with [7,-7]. This where query should kick out the [5,-5] point await page.goto( `${new AdminUrlUtil(serverURL, 'geo').list }?limit=10&page=1&where[or][0][and][0][point][near]=6,-7,200000`, ) await expect(page.getByPlaceholder('Enter a value')).toHaveValue('6,-7,200000') await expect(page.locator(tableRowLocator)).toHaveCount(1) }) test('should accept transformed where query from complex, invalid URL where parameter using the near operator', async () => { // We have one point collection with the point [5,-5] and one with [7,-7]. This where query should kick out the [5,-5] point await page.goto( `${new AdminUrlUtil(serverURL, 'geo').list }?limit=10&page=1&where[point][near]=6,-7,200000`, ) await expect(page.getByPlaceholder('Enter a value')).toHaveValue('6,-7,200000') await expect(page.locator(tableRowLocator)).toHaveCount(1) }) test('should accept where query from complex, valid URL where parameter using the within operator', async () => { type Point = [number, number] const polygon: Point[] = [ [3.5, -3.5], // bottom-left [3.5, -6.5], // top-left [6.5, -6.5], // top-right [6.5, -3.5], // bottom-right [3.5, -3.5], // back to starting point to close the polygon ] const whereQueryJSON = { point: { within: { type: 'Polygon', coordinates: [polygon], }, }, } const whereQuery = qs.stringify( { ...{ where: whereQueryJSON }, }, { addQueryPrefix: false, }, ) // We have one point collection with the point [5,-5] and one with [7,-7]. This where query should kick out the [7,-7] point, as it's not within the polygon await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}?limit=10&page=1&${whereQuery}`) await expect(page.getByPlaceholder('Enter a value')).toHaveValue('[object Object]') await expect(page.locator(tableRowLocator)).toHaveCount(1) }) }) describe('table columns', () => { const reorderColumns = async () => { // open the column controls await page.locator('.list-controls__toggle-columns').click() // wait until the column toggle UI is visible and fully expanded await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() const numberBoundingBox = await page .locator('.column-selector >> text=Number') .boundingBox() const idBoundingBox = await page.locator('.column-selector >> text=ID').boundingBox() if (!numberBoundingBox || !idBoundingBox) return // drag the "number" column to the left of the "ID" column await page.mouse.move(numberBoundingBox.x + 2, numberBoundingBox.y + 2, { steps: 10 }) await page.mouse.down() await wait(300) await page.mouse.move(idBoundingBox.x - 2, idBoundingBox.y - 2, { steps: 10 }) await page.mouse.up() // ensure the "number" column is now first await expect( page.locator('.list-controls .column-selector .column-selector__column').first(), ).toHaveText('Number') await expect(page.locator('table thead tr th').nth(1)).toHaveText('Number') // TODO: This wait makes sure the preferences are actually saved. Just waiting for the UI to update is not enough. We should replace this wait await wait(1000) } test('should drag to reorder columns and save to preferences', async () => { await createPost() await reorderColumns() // reload to ensure the preferred order was stored in the database await page.reload() await expect( page.locator('.list-controls .column-selector .column-selector__column').first(), ).toHaveText('Number') await expect(page.locator('table thead tr th').nth(1)).toHaveText('Number') }) test('should render drawer columns in order', async () => { // Re-order columns like done in the previous test await createPost() await reorderColumns() await page.reload() await createPost() await page.goto(url.create) // Open the drawer await page.locator('.rich-text .list-drawer__toggler').click() const listDrawer = page.locator('[id^=list-drawer_1_]') await expect(listDrawer).toBeVisible() const collectionSelector = page.locator( '[id^=list-drawer_1_] .list-drawer__select-collection.react-select', ) // select the "Post en" collection await collectionSelector.click() await page .locator( '[id^=list-drawer_1_] .list-drawer__select-collection.react-select .rs__option >> text="Post en"', ) .click() // open the column controls const columnSelector = page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns') await columnSelector.click() // wait until the column toggle UI is visible and fully expanded await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() // ensure that the columns are in the correct order await expect( page .locator( '[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', ) .first(), ).toHaveText('Number') }) test('should retain preferences when changing drawer collections', async () => { await page.goto(url.create) // Open the drawer await page.locator('.rich-text .list-drawer__toggler').click() const listDrawer = page.locator('[id^=list-drawer_1_]') await expect(listDrawer).toBeVisible() const collectionSelector = page.locator( '[id^=list-drawer_1_] .list-drawer__select-collection.react-select', ) const columnSelector = page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns') // open the column controls await columnSelector.click() // wait until the column toggle UI is visible and fully expanded await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() // deselect the "id" column await page .locator( '[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column >> text=ID', ) .click() // select the "Post en" collection await collectionSelector.click() await page .locator( '[id^=list-drawer_1_] .list-drawer__select-collection.react-select .rs__option >> text="Post en"', ) .click() // deselect the "number" column await page .locator( '[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column >> text=Number', ) .click() // select the "User" collection again await collectionSelector.click() await page .locator( '[id^=list-drawer_1_] .list-drawer__select-collection.react-select .rs__option >> text="User"', ) .click() // ensure that the "id" column is still deselected await expect( page .locator( '[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', ) .first(), ).not.toHaveClass('column-selector__column--active') // select the "Post en" collection again await collectionSelector.click() await page .locator( '[id^=list-drawer_1_] .list-drawer__select-collection.react-select .rs__option >> text="Post en"', ) .click() // ensure that the "number" column is still deselected await expect( page .locator( '[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', ) .first(), ).not.toHaveClass('column-selector__column--active') }) test('should render custom table cell component', async () => { await createPost() await page.goto(url.list) await expect(page.locator('table >> thead >> tr >> th >> text=Demo UI Field')).toBeVisible() }) }) describe('multi-select', () => { beforeEach(async () => { await mapAsync([...Array(3)], async () => { await createPost() }) }) test('should select multiple rows', async () => { const selectAll = page.locator('.custom-checkbox:has(#select-all)') await page.locator('.row-1 .cell-_select input').check() const indeterminateSelectAll = selectAll.locator('.custom-checkbox__icon.partial') expect(indeterminateSelectAll).toBeDefined() await selectAll.locator('input').click() const emptySelectAll = selectAll.locator('.custom-checkbox__icon:not(.check):not(.partial)') await expect(emptySelectAll).toHaveCount(0) await selectAll.locator('input').click() const checkSelectAll = selectAll.locator('.custom-checkbox__icon.check') expect(checkSelectAll).toBeDefined() }) test('should delete many', async () => { // delete should not appear without selection await expect(page.locator('#confirm-delete')).toHaveCount(0) // select one row await page.locator('.row-1 .cell-_select input').check() // delete button should be present await expect(page.locator('#confirm-delete')).toHaveCount(1) await page.locator('.row-2 .cell-_select input').check() await page.locator('.delete-documents__toggle').click() await page.locator('#confirm-delete').click() await expect(page.locator('.cell-_select')).toHaveCount(1) }) }) describe('pagination', () => { beforeAll(async () => { await mapAsync([...Array(11)], async () => { await createPost() }) }) test('should paginate', async () => { const pageInfo = page.locator('.collection-list__page-info') const perPage = page.locator('.per-page') const paginator = page.locator('.paginator') const tableItems = page.locator(tableRowLocator) await expect(tableItems).toHaveCount(10) await expect(pageInfo).toHaveText('1-10 of 11') await expect(perPage).toContainText('Per Page: 10') // Forward one page and back using numbers await paginator.locator('button').nth(1).click() expect(page.url()).toContain('page=2') await expect(tableItems).toHaveCount(1) await paginator.locator('button').nth(0).click() expect(page.url()).toContain('page=1') await expect(tableItems).toHaveCount(10) }) }) describe('custom css', () => { test('should see custom css in admin UI', async () => { await page.goto(url.admin) const navControls = page.locator('.nav__controls') await expect(navControls).toHaveCSS('font-family', 'monospace') }) }) // TODO: Troubleshoot flaky suite describe.skip('sorting', () => { beforeAll(async () => { await createPost() await createPost() }) test('should sort', async () => { const upChevron = page.locator('#heading-id .sort-column__asc') const downChevron = page.locator('#heading-id .sort-column__desc') const firstId = await page.locator('.row-1 .cell-id').innerText() const secondId = await page.locator('.row-2 .cell-id').innerText() await upChevron.click({ delay: 200 }) // Order should have swapped await expect(page.locator('.row-1 .cell-id')).toHaveText(secondId) await expect(page.locator('.row-2 .cell-id')).toHaveText(firstId) await downChevron.click({ delay: 200 }) // Swap back await expect(page.locator('.row-1 .cell-id')).toHaveText(firstId) await expect(page.locator('.row-2 .cell-id')).toHaveText(secondId) }) }) describe('i18n', () => { test('should display translated collections and globals config options', async () => { await page.goto(url.list) // collection label await expect(page.locator('#nav-posts')).toContainText('Posts en') // global label await expect(page.locator('#nav-global-global')).toContainText('Global en') // view description await expect(page.locator('.view-description')).toContainText('Description en') }) test('should display translated field titles', async () => { await createPost() // column controls await page.locator('.list-controls__toggle-columns').click() await expect(page.locator('.column-selector__column >> text=Title en')).toHaveText( 'Title en', ) // filters await page.locator('.list-controls__toggle-where').click() await page.locator('.where-builder__add-first-filter').click() await page.locator('.condition__field .rs__control').click() const options = page.locator('.rs__option') await expect(options.locator('text=Title en')).toHaveText('Title en') // list columns await expect(page.locator('#heading-title .sort-column__label')).toHaveText('Title en') await expect(page.locator('.search-filter input')).toHaveAttribute( 'placeholder', /(Title en)/, ) }) test('should use fallback language on field titles', async () => { // change language German await page.goto(url.account) await page.locator('.account__language .react-select').click() const languageSelect = page.locator('.rs__option') // text field does not have a 'de' label await languageSelect.locator('text=Deutsch').click() await page.goto(url.list) await page.locator('.list-controls__toggle-columns').click() // expecting the label to fall back to english as default fallbackLng await expect(page.locator('.column-selector__column >> text=Title en')).toHaveText( 'Title en', ) }) }) }) }) async function createPost(overrides?: Partial): Promise { return payload.create({ collection: slug, data: { title, description, ...overrides, }, }) } async function clearDocs(): Promise { const allDocs = await payload.find({ collection: slug, limit: 100 }) const ids = allDocs.docs.map((doc) => doc.id) await mapAsync(ids, async (id) => { await payload.delete({ collection: slug, id }) }) }