From 0be1a1d880ede12a46cd77f32a68b7db32df89db Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Sat, 28 Dec 2024 00:28:37 -0500 Subject: [PATCH] test: semantically splits admin e2e (#10213) Improves the admin e2e test splitting by grouping them by type with semantic names as opposed to numerically. This will provide much needed clarity to exactly _where_ new admin tests should be written and help to quickly distinguish the areas of failure within the CI overview. --- .github/workflows/main.yml | 6 +- test/_community/e2e.spec.ts | 2 +- test/access-control/e2e.spec.ts | 2 +- test/admin/e2e/2/e2e.spec.ts | 862 ---------------------- test/admin/e2e/3/e2e.spec.ts | 679 ----------------- test/admin/e2e/document-view/e2e.spec.ts | 490 ++++++++++++ test/admin/e2e/{1 => general}/e2e.spec.ts | 581 ++++++++++----- test/admin/e2e/list-view/e2e.spec.ts | 820 ++++++++++++++++++++ test/auth-basic/e2e.spec.ts | 4 +- test/auth/e2e.spec.ts | 3 +- test/field-error-states/e2e.spec.ts | 2 +- test/fields-relationship/e2e.spec.ts | 2 +- test/hooks/e2e.spec.ts | 2 +- test/locked-documents/e2e.spec.ts | 2 +- test/plugin-cloud-storage/e2e.spec.ts | 2 +- test/plugin-form-builder/e2e.spec.ts | 2 +- test/uploads/e2e.spec.ts | 4 +- test/versions/e2e.spec.ts | 10 +- 18 files changed, 1720 insertions(+), 1755 deletions(-) delete mode 100644 test/admin/e2e/2/e2e.spec.ts delete mode 100644 test/admin/e2e/3/e2e.spec.ts create mode 100644 test/admin/e2e/document-view/e2e.spec.ts rename test/admin/e2e/{1 => general}/e2e.spec.ts (57%) create mode 100644 test/admin/e2e/list-view/e2e.spec.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5b326fd920..3abb5de5e5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -278,9 +278,9 @@ jobs: suite: - _community - access-control - - admin__e2e__1 - - admin__e2e__2 - - admin__e2e__3 + - admin__e2e__general + - admin__e2e__list-view + - admin__e2e__document-view - admin-root - auth - auth-basic diff --git a/test/_community/e2e.spec.ts b/test/_community/e2e.spec.ts index a15115e710..a52bdad8ed 100644 --- a/test/_community/e2e.spec.ts +++ b/test/_community/e2e.spec.ts @@ -12,7 +12,7 @@ import { TEST_TIMEOUT_LONG } from '../playwright.config.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) -test.describe('Admin Panel', () => { +test.describe('Community', () => { let page: Page let url: AdminUrlUtil diff --git a/test/access-control/e2e.spec.ts b/test/access-control/e2e.spec.ts index 0009ee1def..d7eeb97b7c 100644 --- a/test/access-control/e2e.spec.ts +++ b/test/access-control/e2e.spec.ts @@ -54,7 +54,7 @@ const dirname = path.dirname(filename) const { beforeAll, describe } = test let payload: PayloadTestSDK -describe('access control', () => { +describe('Access Control', () => { let page: Page let url: AdminUrlUtil let restrictedUrl: AdminUrlUtil diff --git a/test/admin/e2e/2/e2e.spec.ts b/test/admin/e2e/2/e2e.spec.ts deleted file mode 100644 index 849ca8757c..0000000000 --- a/test/admin/e2e/2/e2e.spec.ts +++ /dev/null @@ -1,862 +0,0 @@ -import type { Page } from '@playwright/test' - -import { expect, test } from '@playwright/test' -import { mapAsync } from 'payload' -import * as qs from 'qs-esm' - -import type { Config, Geo, Post } from '../../payload-types.js' - -import { - ensureCompilationIsDone, - exactText, - getRoutes, - initPageConsoleErrorCatch, - openDocDrawer, - openNav, -} from '../../../helpers.js' -import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' -import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' -import { customAdminRoutes } from '../../shared.js' -import { customViews1CollectionSlug, geoCollectionSlug, postsCollectionSlug } from '../../slugs.js' - -const { beforeAll, beforeEach, describe } = test - -const title = 'Title' -const description = 'Description' - -let payload: PayloadTestSDK - -import { goToFirstCell } from 'helpers/e2e/navigateToDoc.js' -import { openListColumns } from 'helpers/e2e/openListColumns.js' -import { openListFilters } from 'helpers/e2e/openListFilters.js' -import { toggleColumn } from 'helpers/e2e/toggleColumn.js' -import path from 'path' -import { wait } from 'payload/shared' -import { fileURLToPath } from 'url' - -import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' - -import { reorderColumns } from '../../../helpers/e2e/reorderColumns.js' -import { reInitializeDB } from '../../../helpers/reInitializeDB.js' -import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js' -const filename = fileURLToPath(import.meta.url) -const currentFolder = path.dirname(filename) -const dirname = path.resolve(currentFolder, '../../') - -describe('admin2', () => { - let page: Page - let geoUrl: AdminUrlUtil - let postsUrl: AdminUrlUtil - let baseListFiltersUrl: AdminUrlUtil - let customViewsUrl: AdminUrlUtil - - let serverURL: string - let adminRoutes: ReturnType - - beforeAll(async ({ browser }, testInfo) => { - const prebuild = false // Boolean(process.env.CI) - - 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({ - dirname, - prebuild, - })) - - geoUrl = new AdminUrlUtil(serverURL, geoCollectionSlug) - postsUrl = new AdminUrlUtil(serverURL, postsCollectionSlug) - baseListFiltersUrl = new AdminUrlUtil(serverURL, 'base-list-filters') - customViewsUrl = new AdminUrlUtil(serverURL, customViews1CollectionSlug) - - const context = await browser.newContext() - page = await context.newPage() - initPageConsoleErrorCatch(page) - - await ensureCompilationIsDone({ customAdminRoutes, page, serverURL }) - - adminRoutes = getRoutes({ customAdminRoutes }) - }) - beforeEach(async () => { - await reInitializeDB({ - serverURL, - snapshotKey: 'adminTests', - }) - - await ensureCompilationIsDone({ customAdminRoutes, page, serverURL }) - }) - - describe('custom CSS', () => { - test('should see custom css in admin UI', async () => { - await page.goto(postsUrl.admin) - await page.waitForURL(postsUrl.admin) - await openNav(page) - const navControls = page.locator('#custom-css') - await expect(navControls).toHaveCSS('font-family', 'monospace') - }) - }) - - describe('list view', () => { - const tableRowLocator = 'table > tbody > tr' - - beforeEach(async () => { - // delete all posts created by the seed - await deleteAllPosts() - await page.goto(postsUrl.list) - await page.waitForURL((url) => url.toString().startsWith(postsUrl.list)) - await expect(page.locator(tableRowLocator)).toBeHidden() - - await createPost({ title: 'post1' }) - await createPost({ title: 'post2' }) - await page.reload() - await expect(page.locator(tableRowLocator)).toHaveCount(2) - }) - - describe('list view descriptions', () => { - test('should render static collection descriptions', async () => { - await page.goto(postsUrl.list) - await expect( - page.locator('.view-description', { - hasText: exactText('This is a custom collection description.'), - }), - ).toBeVisible() - }) - - test('should render dynamic collection description components', async () => { - await page.goto(customViewsUrl.list) - await expect( - page.locator('.view-description', { - hasText: exactText('This is a custom view description component.'), - }), - ).toBeVisible() - }) - }) - - describe('filtering', () => { - test('should prefill search input from query param', async () => { - await createPost({ title: 'dennis' }) - await createPost({ title: 'charlie' }) - - // prefill search with "a" from the query param - await page.goto(`${postsUrl.list}?search=dennis`) - await page.waitForURL(new RegExp(`${postsUrl.list}\\?search=dennis`)) - - // input should be filled out, list should filter - await expect(page.locator('.search-filter__input')).toHaveValue('dennis') - await expect(page.locator(tableRowLocator)).toHaveCount(1) - }) - - test('should search by id with listSearchableFields', async () => { - const { id } = await createPost() - const url = `${postsUrl.list}?limit=10&page=1&search=${id}` - await page.goto(url) - await page.waitForURL(url) - const tableItems = page.locator(tableRowLocator) - await expect(tableItems).toHaveCount(1) - }) - - test('should search by id without listSearchableFields', async () => { - const { id } = await createGeo() - const url = `${geoUrl.list}?limit=10&page=1&search=${id}` - await page.goto(url) - await page.waitForURL(url) - const tableItems = page.locator(tableRowLocator) - await expect(tableItems).toHaveCount(1) - }) - - test('should search by title or description', async () => { - await createPost({ - description: 'this is fun', - title: 'find me', - }) - - 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('search should persist through browser back button', async () => { - const url = `${postsUrl.list}?limit=10&page=1&search=post1` - await page.goto(url) - await page.waitForURL(url) - await expect(page.locator('#search-filter-input')).toHaveValue('post1') - await goToFirstCell(page, postsUrl) - await page.goBack() - await wait(1000) // wait one second to ensure that the new view does not accidentally reset the search - await page.waitForURL(url) - }) - - test('search should not persist between navigation', async () => { - const url = `${postsUrl.list}?limit=10&page=1&search=test` - await page.goto(url) - await page.waitForURL(url) - - await expect(page.locator('#search-filter-input')).toHaveValue('test') - - await page.locator('.nav-toggler.template-default__nav-toggler').click() - await expect(page.locator('#nav-uploads')).toContainText('Uploads') - - const uploadsUrl = await page.locator('#nav-uploads').getAttribute('href') - await page.goto(serverURL + uploadsUrl) - await page.waitForURL(serverURL + uploadsUrl) - - await expect(page.locator('#search-filter-input')).toHaveValue('') - }) - - test('should toggle columns', async () => { - const columnCountLocator = 'table > thead > tr > th' - await createPost() - await openListColumns(page, {}) - const numberOfColumns = await page.locator(columnCountLocator).count() - await expect(page.locator('.column-selector')).toBeVisible() - await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('ID') - await toggleColumn(page, { columnLabel: 'ID', targetState: 'off' }) - await page.locator('#heading-id').waitFor({ state: 'detached' }) - await page.locator('.cell-id').first().waitFor({ state: 'detached' }) - await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns - 1) - await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('Number') - await toggleColumn(page, { columnLabel: 'ID', targetState: 'on' }) - await expect(page.locator('.cell-id').first()).toBeVisible() - await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns) - await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('ID') - }) - - test('should link second cell', async () => { - const { id } = await createPost() - await page.reload() - const linkCell = page.locator(`${tableRowLocator} td`).nth(1).locator('a') - - await expect(linkCell).toHaveAttribute( - 'href', - `${adminRoutes.routes.admin}/collections/posts/${id}`, - ) - - await page.locator('.list-controls__toggle-columns').click() - await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() - - await page - .locator('.column-selector .column-selector__column', { - hasText: exactText('ID'), - }) - .click() - - await page.locator('#heading-id').waitFor({ state: 'detached' }) - await page.locator('.cell-id').first().waitFor({ state: 'detached' }) - - await expect(linkCell).toHaveAttribute( - 'href', - `${adminRoutes.routes.admin}/collections/posts/${id}`, - ) - }) - - test('should filter rows', 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('.column-selector')).toBeVisible() - await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('ID') - - // ensure the ID column is active - const idButton = page.locator('.column-selector .column-selector__column', { - hasText: exactText('ID'), - }) - - const id = (await page.locator('.cell-id').first().innerText()).replace('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 openListFilters(page, {}) - - await page.locator('.where-builder__add-first-filter').click() - - const conditionField = page.locator('.condition__field') - await conditionField.click() - const dropdownFieldOption = conditionField.locator('.rs__option', { - hasText: exactText('ID'), - }) - await dropdownFieldOption.click() - await expect(page.locator('.condition__field')).toContainText('ID') - - 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) - - const tableRows = page.locator(tableRowLocator) - - await expect(tableRows).toHaveCount(1) - const firstId = page.locator(tableRowLocator).first().locator('.cell-id') - await expect(firstId).toHaveText(`ID: ${id}`) - - // Remove filter - await page.locator('.condition__actions-remove').click() - await expect(page.locator(tableRowLocator)).toHaveCount(2) - }) - - test('should reset filter value and operator on field update', async () => { - const id = (await page.locator('.cell-id').first().innerText()).replace('ID: ', '') - - // open the column controls - await page.locator('.list-controls__toggle-columns').click() - await openListFilters(page, {}) - await page.locator('.where-builder__add-first-filter').click() - - const operatorField = page.locator('.condition__operator') - await operatorField.click() - - const dropdownOperatorOptions = operatorField.locator('.rs__option') - await dropdownOperatorOptions.locator('text=equals').click() - - // execute filter (where ID equals id value) - const valueField = page.locator('.condition__value > input') - await valueField.fill(id) - - const filterField = page.locator('.condition__field') - await filterField.click() - - // select new filter field of Number - const dropdownFieldOption = filterField.locator('.rs__option', { - hasText: exactText('Status'), - }) - await dropdownFieldOption.click() - await expect(filterField).toContainText('Status') - - // expect operator & value field to reset (be empty) - await expect(operatorField.locator('.rs__placeholder')).toContainText('Select a value') - await expect(page.locator('.condition__value input')).toHaveValue('') - }) - - test('should accept where query from valid URL where parameter', async () => { - // delete all posts created by the seed - await deleteAllPosts() - await page.goto(postsUrl.list) - await expect(page.locator(tableRowLocator)).toBeHidden() - - await createPost({ title: 'post1' }) - await createPost({ title: 'post2' }) - await page.goto(postsUrl.list) - await expect(page.locator(tableRowLocator)).toHaveCount(2) - - await page.goto( - `${postsUrl.list}?limit=10&page=1&where[or][0][and][0][title][equals]=post1`, - ) - - await expect(page.locator('.react-select--single-value').first()).toContainText('Title') - await expect(page.locator(tableRowLocator)).toHaveCount(1) - }) - - test('should accept transformed where query from invalid URL where parameter', async () => { - // delete all posts created by the seed - await deleteAllPosts() - await page.goto(postsUrl.list) - await expect(page.locator(tableRowLocator)).toBeHidden() - - await createPost({ title: 'post1' }) - await createPost({ title: 'post2' }) - await page.goto(postsUrl.list) - await expect(page.locator(tableRowLocator)).toHaveCount(2) - - // [title][equals]=post1 should be getting transformed into a valid where[or][0][and][0][title][equals]=post1 - await page.goto(`${postsUrl.list}?limit=10&page=1&where[title][equals]=post1`) - - await expect(page.locator('.react-select--single-value').first()).toContainText('Title') - 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) - }) - - test('should reset page when filters are applied', async () => { - await deleteAllPosts() - - await Promise.all( - Array.from({ length: 12 }, async (_, i) => { - if (i < 6) { - await createPost() - } else { - await createPost({ title: 'test' }) - } - }), - ) - - await page.reload() - - const tableItems = page.locator(tableRowLocator) - - await expect(tableItems).toHaveCount(10) - await expect(page.locator('.collection-list__page-info')).toHaveText('1-10 of 12') - await expect(page.locator('.per-page')).toContainText('Per Page: 10') - await page.goto(`${postsUrl.list}?limit=10&page=2`) - await openListFilters(page, {}) - await page.locator('.where-builder__add-first-filter').click() - await page.locator('.condition__field .rs__control').click() - const options = page.locator('.rs__option') - await options.locator('text=Tab 1 > Title').click() - await page.locator('.condition__operator .rs__control').click() - await options.locator('text=equals').click() - await page.locator('.condition__value input').fill('test') - await expect(page.locator('.collection-list__page-info')).toHaveText('1-6 of 6') - }) - }) - - describe('table columns', () => { - test('should drag to reorder columns and save to preferences', async () => { - await createPost() - - await reorderColumns(page, { fromColumn: 'Number', toColumn: 'ID' }) - - // 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(page, { fromColumn: 'Number', toColumn: 'ID' }) - - await page.reload() - - await createPost() - await page.goto(postsUrl.create) - - await openDocDrawer(page, '.rich-text .list-drawer__toggler') - - const listDrawer = page.locator('[id^=list-drawer_1_]') - await expect(listDrawer).toBeVisible() - - const collectionSelector = page.locator( - '[id^=list-drawer_1_] .list-header__select-collection.react-select', - ) - - // select the "Post" collection - await collectionSelector.click() - await page - .locator( - '[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', - { - hasText: exactText('Post'), - }, - ) - .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(postsUrl.create) - - // Open the drawer - await openDocDrawer(page, '.rich-text .list-drawer__toggler') - const listDrawer = page.locator('[id^=list-drawer_1_]') - await expect(listDrawer).toBeVisible() - - const collectionSelector = page.locator( - '[id^=list-drawer_1_] .list-header__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', - { - hasText: exactText('ID'), - }, - ) - .click() - - // select the "Post" collection - await collectionSelector.click() - await page - .locator( - '[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', - { - hasText: exactText('Post'), - }, - ) - .click() - - // deselect the "number" column - await page - .locator( - '[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', - { - hasText: exactText('Number'), - }, - ) - .click() - - // select the "User" collection again - await collectionSelector.click() - await page - .locator( - '[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', - { - hasText: exactText('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" collection again - await collectionSelector.click() - - await page - .locator( - '[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', - { - hasText: exactText('Post'), - }, - ) - .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(postsUrl.list) - await expect( - page.locator('table > thead > tr > th', { - hasText: exactText('Demo UI Field'), - }), - ).toBeVisible() - }) - }) - - describe('multi-select', () => { - beforeEach(async () => { - // delete all posts created by the seed - await deleteAllPosts() - - await createPost() - await createPost() - await createPost() - }) - - test('should select multiple rows', async () => { - await page.reload() - const selectAll = page.locator('.checkbox-input:has(#select-all)') - await page.locator('.row-1 .cell-_select input').check() - - const indeterminateSelectAll = selectAll.locator('.checkbox-input__icon.partial') - expect(indeterminateSelectAll).toBeDefined() - - await selectAll.locator('input').click() - const emptySelectAll = selectAll.locator('.checkbox-input__icon:not(.check):not(.partial)') - await expect(emptySelectAll).toHaveCount(0) - - await selectAll.locator('input').click() - const checkSelectAll = selectAll.locator('.checkbox-input__icon.check') - expect(checkSelectAll).toBeDefined() - }) - - test('should delete many', async () => { - await page.goto(postsUrl.list) - await page.waitForURL(new RegExp(postsUrl.list)) - // 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', () => { - test('should paginate', async () => { - await deleteAllPosts() - - await mapAsync([...Array(11)], async () => { - await createPost() - }) - - await page.reload() - const tableItems = page.locator(tableRowLocator) - await expect(tableItems).toHaveCount(10) - await expect(page.locator('.collection-list__page-info')).toHaveText('1-10 of 11') - await expect(page.locator('.per-page')).toContainText('Per Page: 10') - await page.locator('.paginator button').nth(1).click() - await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2') - await expect(tableItems).toHaveCount(1) - await page.locator('.paginator button').nth(0).click() - await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=1') - await expect(tableItems).toHaveCount(10) - }) - - test('should paginate and maintain perPage', async () => { - await deleteAllPosts() - - await mapAsync([...Array(26)], async () => { - await createPost() - }) - - await page.reload() - const tableItems = page.locator(tableRowLocator) - await expect(tableItems).toHaveCount(10) - await expect(page.locator('.collection-list__page-info')).toHaveText('1-10 of 26') - await expect(page.locator('.per-page')).toContainText('Per Page: 10') - await page.locator('.per-page .popup-button').click() - - await page - .locator('.per-page button.per-page__button', { - hasText: '25', - }) - .click() - - await expect(tableItems).toHaveCount(25) - await expect(page.locator('.per-page .per-page__base-button')).toContainText('Per Page: 25') - await page.locator('.paginator button').nth(1).click() - await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2') - await expect(tableItems).toHaveCount(1) - await expect(page.locator('.per-page')).toContainText('Per Page: 25') - await expect(page.locator('.collection-list__page-info')).toHaveText('26-26 of 26') - }) - }) - - // TODO: Troubleshoot flaky suite - describe('sorting', () => { - beforeEach(async () => { - // delete all posts created by the seed - await deleteAllPosts() - await createPost({ number: 1 }) - await createPost({ number: 2 }) - }) - - test('should sort', async () => { - await page.reload() - const upChevron = page.locator('#heading-number .sort-column__asc') - const downChevron = page.locator('#heading-number .sort-column__desc') - - await upChevron.click() - await page.waitForURL(/sort=number/) - - await expect(page.locator('.row-1 .cell-number')).toHaveText('1') - await expect(page.locator('.row-2 .cell-number')).toHaveText('2') - - await downChevron.click() - await page.waitForURL(/sort=-number/) - - await expect(page.locator('.row-1 .cell-number')).toHaveText('2') - await expect(page.locator('.row-2 .cell-number')).toHaveText('1') - }) - - test('should sort with existing filters', async () => { - await page.goto(postsUrl.list) - await toggleColumn(page, { columnLabel: 'ID', targetState: 'off' }) - await page.locator('#heading-id').waitFor({ state: 'detached' }) - await page.locator('#heading-title button.sort-column__asc').click() - await page.waitForURL(/sort=title/) - - const columnAfterSort = page.locator( - `.list-controls__columns .column-selector .column-selector__column`, - { - hasText: exactText('ID'), - }, - ) - - await expect(columnAfterSort).not.toHaveClass('column-selector__column--active') - await expect(page.locator('#heading-id')).toBeHidden() - await expect(page.locator('.cell-id')).toHaveCount(0) - }) - }) - - describe('i18n', () => { - test('should display translated collections and globals config options', async () => { - await page.goto(postsUrl.list) - await expect(page.locator('#nav-posts')).toContainText('Posts') - await expect(page.locator('#nav-global-global')).toContainText('Global') - }) - - 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', { - hasText: exactText('Title'), - }), - ).toHaveText('Title') - - // filters - await openListFilters(page, {}) - 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=Tab 1 > Title')).toHaveText('Tab 1 > Title') - - // list columns - await expect(page.locator('#heading-title .sort-column__label')).toHaveText('Title') - await expect(page.locator('.search-filter input')).toHaveAttribute('placeholder', /(Title)/) - }) - - test('should use fallback language on field titles', async () => { - // change language German - await page.goto(postsUrl.account) - await page.locator('.payload-settings__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(postsUrl.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', { - hasText: exactText('Title'), - }), - ).toHaveText('Title') - }) - }) - - describe('base list filters', () => { - test('should respect base list filters', async () => { - await page.goto(baseListFiltersUrl.list) - await page.waitForURL((url) => url.toString().startsWith(baseListFiltersUrl.list)) - await expect(page.locator(tableRowLocator)).toHaveCount(1) - }) - }) - }) -}) - -async function createPost(overrides?: Partial): Promise { - return payload.create({ - collection: postsCollectionSlug, - data: { - description, - title, - ...overrides, - }, - }) as unknown as Promise -} - -async function deleteAllPosts() { - await payload.delete({ collection: postsCollectionSlug, where: { id: { exists: true } } }) -} - -async function createGeo(overrides?: Partial): Promise { - return payload.create({ - collection: geoCollectionSlug, - data: { - point: [4, -4], - ...overrides, - }, - }) as unknown as Promise -} diff --git a/test/admin/e2e/3/e2e.spec.ts b/test/admin/e2e/3/e2e.spec.ts deleted file mode 100644 index 4081afd0e8..0000000000 --- a/test/admin/e2e/3/e2e.spec.ts +++ /dev/null @@ -1,679 +0,0 @@ -import type { Page } from '@playwright/test' - -import { expect, test } from '@playwright/test' -import { wait } from 'payload/shared' - -import type { Config, Geo, Post } from '../../payload-types.js' - -import { - checkBreadcrumb, - checkPageTitle, - ensureCompilationIsDone, - exactText, - getRoutes, - initPageConsoleErrorCatch, - openNav, - saveDocAndAssert, - saveDocHotkeyAndAssert, -} from '../../../helpers.js' -import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' -import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' -import { customAdminRoutes } from '../../shared.js' -import { - disableDuplicateSlug, - geoCollectionSlug, - globalSlug, - group1Collection1Slug, - group1GlobalSlug, - noApiViewCollectionSlug, - noApiViewGlobalSlug, - postsCollectionSlug, -} from '../../slugs.js' - -const { beforeAll, beforeEach, describe } = test - -const title = 'Title' -const description = 'Description' - -let payload: PayloadTestSDK - -import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js' -import { openDocControls } from 'helpers/e2e/openDocControls.js' -import path from 'path' -import { fileURLToPath } from 'url' - -import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' - -import { reInitializeDB } from '../../../helpers/reInitializeDB.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, '../../') - -describe('admin3', () => { - let page: Page - let geoUrl: AdminUrlUtil - let postsUrl: AdminUrlUtil - let globalURL: AdminUrlUtil - let disableDuplicateURL: AdminUrlUtil - let serverURL: string - let adminRoutes: ReturnType - - beforeAll(async ({ browser }, testInfo) => { - const prebuild = false // Boolean(process.env.CI) - - 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({ - dirname, - prebuild, - })) - geoUrl = new AdminUrlUtil(serverURL, geoCollectionSlug) - postsUrl = new AdminUrlUtil(serverURL, postsCollectionSlug) - globalURL = new AdminUrlUtil(serverURL, globalSlug) - disableDuplicateURL = new AdminUrlUtil(serverURL, disableDuplicateSlug) - - const context = await browser.newContext() - page = await context.newPage() - initPageConsoleErrorCatch(page) - - await ensureCompilationIsDone({ customAdminRoutes, page, serverURL }) - - adminRoutes = getRoutes({ customAdminRoutes }) - }) - beforeEach(async () => { - await reInitializeDB({ - serverURL, - snapshotKey: 'adminTests', - }) - - await ensureCompilationIsDone({ customAdminRoutes, page, serverURL }) - }) - - describe('API view', () => { - test('collection — should not show API tab when disabled in config', async () => { - await page.goto(postsUrl.collection(noApiViewCollectionSlug)) - await page.locator('.collection-list .table a').click() - await expect(page.locator('.doc-tabs__tabs-container')).not.toContainText('API') - }) - - test('collection — should not enable API route when disabled in config', async () => { - const collectionItems = await payload.find({ - collection: noApiViewCollectionSlug, - limit: 1, - }) - expect(collectionItems.docs.length).toBe(1) - await page.goto( - `${postsUrl.collection(noApiViewGlobalSlug)}/${collectionItems.docs[0].id}/api`, - ) - await expect(page.locator('.not-found')).toHaveCount(1) - }) - - test('collection — sidebar fields should respond to permission', async () => { - const { id } = await createPost() - await page.goto(postsUrl.edit(id)) - - await expect(page.locator('#field-sidebarField')).toBeDisabled() - }) - - test('collection — depth field should have value 0 when empty', async () => { - const { id } = await createPost() - await page.goto(`${postsUrl.edit(id)}/api`) - - const depthField = page.locator('#field-depth') - await depthField.fill('') - await expect(depthField).toHaveValue('0') - }) - - test('global — should not show API tab when disabled in config', async () => { - await page.goto(postsUrl.global(noApiViewGlobalSlug)) - await expect(page.locator('.doc-tabs__tabs-container')).not.toContainText('API') - }) - - test('global — should not enable API route when disabled in config', async () => { - await page.goto(`${postsUrl.global(noApiViewGlobalSlug)}/api`) - await expect(page.locator('.not-found')).toHaveCount(1) - }) - }) - - describe('header actions', () => { - test('should show admin level action in admin panel', async () => { - await page.goto(postsUrl.admin) - // Check if the element with the class .admin-button exists - await expect(page.locator('.app-header .admin-button')).toHaveCount(1) - }) - - test('should show admin level action in collection list view', async () => { - await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}`) - await expect(page.locator('.app-header .admin-button')).toHaveCount(1) - }) - - test('should show admin level action in collection edit view', async () => { - const { id } = await createGeo() - await page.goto(geoUrl.edit(id)) - await expect(page.locator('.app-header .admin-button')).toHaveCount(1) - }) - - test('should show collection list view level action in collection list view', async () => { - await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}`) - await expect(page.locator('.app-header .collection-list-button')).toHaveCount(1) - }) - - test('should show collection edit view level action in collection edit view', async () => { - const { id } = await createGeo() - await page.goto(geoUrl.edit(id)) - await expect(page.locator('.app-header .collection-edit-button')).toHaveCount(1) - }) - - test('should show collection api view level action in collection api view', async () => { - const { id } = await createGeo() - await page.goto(`${geoUrl.edit(id)}/api`) - await expect(page.locator('.app-header .collection-api-button')).toHaveCount(1) - }) - - test('should show global edit view level action in globals edit view', async () => { - const globalWithPreview = new AdminUrlUtil(serverURL, globalSlug) - await page.goto(globalWithPreview.global(globalSlug)) - await expect(page.locator('.app-header .global-edit-button')).toHaveCount(1) - }) - - test('should show global api view level action in globals api view', async () => { - const globalWithPreview = new AdminUrlUtil(serverURL, globalSlug) - await page.goto(`${globalWithPreview.global(globalSlug)}/api`) - await expect(page.locator('.app-header .global-api-button')).toHaveCount(1) - }) - - test('should reset actions array when navigating from view with actions to view without actions', async () => { - await page.goto(geoUrl.list) - await expect(page.locator('.app-header .collection-list-button')).toHaveCount(1) - await page.locator('button.nav-toggler[aria-label="Open Menu"][tabindex="0"]').click() - await page.locator(`#nav-posts`).click() - await expect(page.locator('.app-header .collection-list-button')).toHaveCount(0) - }) - }) - - describe('preview button', () => { - test('collection — should render preview button when `admin.preview` is set', async () => { - const collectionWithPreview = new AdminUrlUtil(serverURL, postsCollectionSlug) - await page.goto(collectionWithPreview.create) - await page.waitForURL(collectionWithPreview.create) - await page.locator('#field-title').fill(title) - await saveDocAndAssert(page) - await expect(page.locator('.btn.preview-btn')).toBeVisible() - }) - - test('collection — should not render preview button when `admin.preview` is not set', async () => { - const collectionWithoutPreview = new AdminUrlUtil(serverURL, group1Collection1Slug) - await page.goto(collectionWithoutPreview.create) - await page.waitForURL(collectionWithoutPreview.create) - await page.locator('#field-title').fill(title) - await saveDocAndAssert(page) - await expect(page.locator('.btn.preview-btn')).toBeHidden() - }) - - test('global — should render preview button when `admin.preview` is set', async () => { - const globalWithPreview = new AdminUrlUtil(serverURL, globalSlug) - await page.goto(globalWithPreview.global(globalSlug)) - await expect(page.locator('.btn.preview-btn')).toBeVisible() - }) - - test('global — should not render preview button when `admin.preview` is not set', async () => { - const globalWithoutPreview = new AdminUrlUtil(serverURL, group1GlobalSlug) - await page.goto(globalWithoutPreview.global(group1GlobalSlug)) - await page.locator('#field-title').fill(title) - await saveDocAndAssert(page) - await expect(page.locator('.btn.preview-btn')).toBeHidden() - }) - }) - - describe('form state', () => { - test('collection — should re-enable fields after save', async () => { - await page.goto(postsUrl.create) - await page.locator('#field-title').fill(title) - await saveDocAndAssert(page) - await expect(page.locator('#field-title')).toBeEnabled() - }) - - test('global — should re-enable fields after save', async () => { - await page.goto(globalURL.global(globalSlug)) - await page.locator('#field-title').fill(title) - await saveDocAndAssert(page) - await expect(page.locator('#field-title')).toBeEnabled() - }) - }) - - describe('document titles', () => { - test('collection — should render fallback titles when creating new', async () => { - await page.goto(postsUrl.create) - await checkPageTitle(page, '[Untitled]') - await checkBreadcrumb(page, 'Create New') - await saveDocAndAssert(page) - expect(true).toBe(true) - }) - - test('collection — should render `useAsTitle` field', async () => { - await page.goto(postsUrl.create) - await page.locator('#field-title')?.fill(title) - await saveDocAndAssert(page) - await wait(500) - await checkPageTitle(page, title) - await checkBreadcrumb(page, title) - expect(true).toBe(true) - }) - - test('collection — should render `id` as `useAsTitle` fallback', async () => { - const { id } = await createPost() - const postURL = postsUrl.edit(id) - await page.goto(postURL) - await page.waitForURL(postURL) - await wait(500) - await page.locator('#field-title')?.fill('') - await expect(page.locator('.doc-header__title.render-title:has-text("ID:")')).toBeVisible() - await saveDocAndAssert(page) - }) - - test('global — should render custom, localized label', async () => { - await page.goto(globalURL.global(globalSlug)) - await page.waitForURL(globalURL.global(globalSlug)) - await openNav(page) - const label = 'My Global Label' - const globalLabel = page.locator(`#nav-global-global`) - await expect(globalLabel).toContainText(label) - await globalLabel.click() - await checkPageTitle(page, label) - await checkBreadcrumb(page, label) - await page.locator('#field-title').fill(title) - await saveDocAndAssert(page) - await checkPageTitle(page, label) - await checkBreadcrumb(page, label) - }) - - test('global — should render simple label strings', async () => { - await page.goto(postsUrl.admin) - await page.waitForURL(postsUrl.admin) - await openNav(page) - const label = 'Group Globals 1' - const globalLabel = page.locator(`#nav-global-group-globals-one`) - await expect(globalLabel).toContainText(label) - await globalLabel.click() - await checkPageTitle(page, label) - await checkBreadcrumb(page, label) - }) - - test('global — should render slug in sentence case as fallback', async () => { - await page.goto(postsUrl.admin) - await page.waitForURL(postsUrl.admin) - await openNav(page) - const label = 'Group Globals Two' - const globalLabel = page.locator(`#nav-global-group-globals-two`) - await expect(globalLabel).toContainText(label) - await globalLabel.click() - await checkPageTitle(page, label) - await checkBreadcrumb(page, label) - }) - }) - - describe('i18n', () => { - test('should allow changing language', async () => { - await page.goto(postsUrl.account) - - const field = page.locator('.payload-settings__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 a').first().locator('span')).toHaveAttribute( - 'title', - '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(postsUrl.account) - await expect(page.locator('.step-nav a').first().locator('span')).toHaveAttribute( - 'title', - 'Home', - ) - }) - - test('should allow custom translation of locale labels', async () => { - const selectOptionClass = '.localizer .popup-button-list__button' - const localizerButton = page.locator('.localizer .popup-button') - const localeListItem1 = page.locator(selectOptionClass).nth(0) - - async function checkLocaleLabels(firstLabel: string, secondLabel: string) { - await localizerButton.click() - await expect(page.locator(selectOptionClass).first()).toContainText(firstLabel) - await expect(page.locator(selectOptionClass).nth(1)).toContainText(secondLabel) - } - - await checkLocaleLabels('Spanish (es)', 'English (en)') - - // Change locale to Spanish - await localizerButton.click() - await expect(localeListItem1).toContainText('Spanish (es)') - await localeListItem1.click() - - // Go to account page - await page.goto(postsUrl.account) - - const languageField = page.locator('.payload-settings__language .react-select') - const options = page.locator('.rs__option') - - // Change language to Spanish - await languageField.click() - await options.locator('text=Español').click() - - await checkLocaleLabels('Español (es)', 'Inglés (en)') - - // Change locale and language back to English - await languageField.click() - await options.locator('text=English').click() - await localizerButton.click() - await expect(localeListItem1).toContainText('Spanish (es)') - }) - }) - - describe('drawers', () => { - test('document drawers are visually stacking', async () => { - await navigateToDoc(page, postsUrl) - await page.locator('#field-title').fill(title) - await saveDocAndAssert(page) - await page - .locator('.field-type.relationship .relationship--single-value__drawer-toggler') - .click() - await wait(500) - const drawer1Content = page.locator('[id^=doc-drawer_posts_1_] .drawer__content') - await expect(drawer1Content).toBeVisible() - const drawerLeft = await drawer1Content.boundingBox().then((box) => box.x) - await drawer1Content - .locator('.field-type.relationship .relationship--single-value__drawer-toggler') - .click() - const drawer2Content = page.locator('[id^=doc-drawer_posts_2_] .drawer__content') - await expect(drawer2Content).toBeVisible() - const drawer2Left = await drawer2Content.boundingBox().then((box) => box.x) - expect(drawer2Left > drawerLeft).toBe(true) - }) - }) - - describe('CRUD', () => { - test('should create', async () => { - await page.goto(postsUrl.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(postsUrl.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(postsUrl.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(postsUrl.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, title } = await createPost() - await page.goto(postsUrl.edit(id)) - await openDocControls(page) - await page.locator('#action-delete').click() - await page.locator('#confirm-delete').click() - await expect(page.locator(`text=Post "${title}" successfully deleted.`)).toBeVisible() - expect(page.url()).toContain(postsUrl.list) - }) - - test('should bulk delete all on page', async () => { - await deleteAllPosts() - await Promise.all([createPost(), createPost(), createPost()]) - await page.goto(postsUrl.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('.payload-toast-container .toast-success')).toHaveText( - 'Deleted 3 Posts successfully.', - ) - - await expect(page.locator('.collection-list__no-results')).toBeVisible() - }) - - test('should bulk delete with filters and across pages', async () => { - await deleteAllPosts() - await Promise.all([createPost({ title: 'Post 1' }), createPost({ title: 'Post 2' })]) - await page.goto(postsUrl.list) - await page.locator('#search-filter-input').fill('Post 1') - await expect(page.locator('.table table > tbody > tr')).toHaveCount(1) - await page.locator('input#select-all').check() - await page.locator('button.list-selection__button').click() - await page.locator('.delete-documents__toggle').click() - await page.locator('#confirm-delete').click() - - await expect(page.locator('.payload-toast-container .toast-success')).toHaveText( - 'Deleted 1 Post successfully.', - ) - - await expect(page.locator('.table table > tbody > tr')).toHaveCount(1) - }) - - test('should bulk update', async () => { - // First, delete all posts created by the seed - await deleteAllPosts() - const post1Title = 'Post' - const updatedPostTitle = `${post1Title} (Updated)` - await Promise.all([createPost({ title: post1Title }), createPost(), createPost()]) - await page.goto(postsUrl.list) - await page.locator('input#select-all').check() - await page.locator('.edit-many__toggle').click() - await page.locator('.field-select .rs__control').click() - - const titleOption = page.locator('.field-select .rs__option', { - hasText: exactText('Title'), - }) - - await expect(titleOption).toBeVisible() - await titleOption.click() - const titleInput = page.locator('#field-title') - await expect(titleInput).toBeVisible() - await titleInput.fill(updatedPostTitle) - await page.locator('.form-submit button[type="submit"].edit-many__publish').click() - - await expect(page.locator('.payload-toast-container .toast-success')).toContainText( - 'Updated 3 Posts successfully.', - ) - - await expect(page.locator('.row-1 .cell-title')).toContainText(updatedPostTitle) - await expect(page.locator('.row-2 .cell-title')).toContainText(updatedPostTitle) - await expect(page.locator('.row-3 .cell-title')).toContainText(updatedPostTitle) - }) - - test('should not override un-edited values in bulk edit if it has a defaultValue', async () => { - await deleteAllPosts() - const post1Title = 'Post' - const postData = { - title: 'Post', - arrayOfFields: [ - { - optional: 'some optional array field', - innerArrayOfFields: [ - { - innerOptional: 'some inner optional array field', - }, - ], - }, - ], - group: { - defaultValueField: 'not the group default value', - title: 'some title', - }, - someBlock: [ - { - textFieldForBlock: 'some text for block text', - blockType: 'textBlock', - }, - ], - defaultValueField: 'not the default value', - } - const updatedPostTitle = `${post1Title} (Updated)` - await createPost(postData) - await page.goto(postsUrl.list) - await page.locator('input#select-all').check() - await page.locator('.edit-many__toggle').click() - await page.locator('.field-select .rs__control').click() - - const titleOption = page.locator('.field-select .rs__option', { - hasText: exactText('Title'), - }) - - await expect(titleOption).toBeVisible() - await titleOption.click() - const titleInput = page.locator('#field-title') - await expect(titleInput).toBeVisible() - await titleInput.fill(updatedPostTitle) - await page.locator('.form-submit button[type="submit"].edit-many__publish').click() - - await expect(page.locator('.payload-toast-container .toast-success')).toContainText( - 'Updated 1 Post successfully.', - ) - - const updatedPost = await payload.find({ - collection: 'posts', - limit: 1, - }) - - expect(updatedPost.docs[0].title).toBe(updatedPostTitle) - expect(updatedPost.docs[0].arrayOfFields.length).toBe(1) - expect(updatedPost.docs[0].arrayOfFields[0].optional).toBe('some optional array field') - expect(updatedPost.docs[0].arrayOfFields[0].innerArrayOfFields.length).toBe(1) - expect(updatedPost.docs[0].someBlock[0].textFieldForBlock).toBe('some text for block text') - expect(updatedPost.docs[0].defaultValueField).toBe('not the default value') - }) - - test('should bulk update with filters and across pages', async () => { - // First, delete all posts created by the seed - await deleteAllPosts() - const post1Title = 'Post 1' - await Promise.all([createPost({ title: post1Title }), createPost({ title: 'Post 2' })]) - const updatedPostTitle = `${post1Title} (Updated)` - await page.goto(postsUrl.list) - await page.locator('#search-filter-input').fill('Post 1') - await expect(page.locator('.table table > tbody > tr')).toHaveCount(1) - await page.locator('input#select-all').check() - await page.locator('button.list-selection__button').click() - await page.locator('.edit-many__toggle').click() - await page.locator('.field-select .rs__control').click() - - const titleOption = page.locator('.field-select .rs__option', { - hasText: exactText('Title'), - }) - - await expect(titleOption).toBeVisible() - await titleOption.click() - const titleInput = page.locator('#field-title') - await expect(titleInput).toBeVisible() - await titleInput.fill(updatedPostTitle) - - await page.locator('.form-submit button[type="submit"].edit-many__publish').click() - await expect(page.locator('.payload-toast-container .toast-success')).toContainText( - 'Updated 1 Post successfully.', - ) - - await expect(page.locator('.table table > tbody > tr')).toHaveCount(1) - await expect(page.locator('.row-1 .cell-title')).toContainText(updatedPostTitle) - }) - - test('should save globals', async () => { - await page.goto(postsUrl.global(globalSlug)) - - await page.locator('#field-title').fill(title) - await saveDocAndAssert(page) - - await expect(page.locator('#field-title')).toHaveValue(title) - }) - - test('should hide duplicate when disableDuplicate: true', async () => { - await page.goto(disableDuplicateURL.create) - await page.locator('#field-title').fill(title) - await saveDocAndAssert(page) - await page.locator('.doc-controls__popup >> .popup-button').click() - await expect(page.locator('#action-duplicate')).toBeHidden() - }) - - test('should properly close leave-without-saving modal after clicking leave-anyway button', async () => { - const { id } = await createPost() - await page.goto(postsUrl.edit(id)) - const title = 'title' - await page.locator('#field-title').fill(title) - await saveDocHotkeyAndAssert(page) - await expect(page.locator('#field-title')).toHaveValue(title) - - const newTitle = 'new title' - await page.locator('#field-title').fill(newTitle) - - await page.locator('header.app-header a[href="/admin/collections/posts"]').click() - - // Locate the modal container - const modalContainer = page.locator('.payload__modal-container') - await expect(modalContainer).toBeVisible() - - // Click the "Leave anyway" button - await page.locator('.leave-without-saving__controls .btn--style-primary').click() - - // Assert that the class on the modal container changes to 'payload__modal-container--exitDone' - await expect(modalContainer).toHaveClass(/payload__modal-container--exitDone/) - }) - }) -}) - -async function createPost(overrides?: Partial): Promise { - return payload.create({ - collection: postsCollectionSlug, - data: { - description, - title, - ...overrides, - }, - }) as unknown as Promise -} - -async function deleteAllPosts() { - await payload.delete({ collection: postsCollectionSlug, where: { id: { exists: true } } }) -} - -async function createGeo(overrides?: Partial): Promise { - return payload.create({ - collection: geoCollectionSlug, - data: { - point: [4, -4], - ...overrides, - }, - }) as unknown as Promise -} diff --git a/test/admin/e2e/document-view/e2e.spec.ts b/test/admin/e2e/document-view/e2e.spec.ts new file mode 100644 index 0000000000..2b5ca795c2 --- /dev/null +++ b/test/admin/e2e/document-view/e2e.spec.ts @@ -0,0 +1,490 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { wait } from 'payload/shared' + +import type { Config, Post } from '../../payload-types.js' + +import { + checkBreadcrumb, + checkPageTitle, + ensureCompilationIsDone, + exactText, + initPageConsoleErrorCatch, + openNav, + saveDocAndAssert, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { + customAdminRoutes, + customEditLabel, + customNestedTabViewPath, + customNestedTabViewTitle, + customTabLabel, + customTabViewPath, + customTabViewTitle, +} from '../../shared.js' +import { + customFieldsSlug, + customGlobalViews2GlobalSlug, + customViews2CollectionSlug, + globalSlug, + group1Collection1Slug, + group1GlobalSlug, + noApiViewCollectionSlug, + noApiViewGlobalSlug, + postsCollectionSlug, +} from '../../slugs.js' + +const { beforeAll, beforeEach, describe } = test + +const title = 'Title' +const description = 'Description' + +let payload: PayloadTestSDK + +import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js' +import path from 'path' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' + +import { reInitializeDB } from '../../../helpers/reInitializeDB.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, '../../') + +describe('Document View', () => { + let page: Page + let postsUrl: AdminUrlUtil + let globalURL: AdminUrlUtil + let serverURL: string + let customViewsURL: AdminUrlUtil + let customFieldsURL: AdminUrlUtil + + beforeAll(async ({ browser }, testInfo) => { + const prebuild = false // Boolean(process.env.CI) + + 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({ + dirname, + prebuild, + })) + postsUrl = new AdminUrlUtil(serverURL, postsCollectionSlug) + globalURL = new AdminUrlUtil(serverURL, globalSlug) + customViewsURL = new AdminUrlUtil(serverURL, customViews2CollectionSlug) + customFieldsURL = new AdminUrlUtil(serverURL, customFieldsSlug) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + + await ensureCompilationIsDone({ customAdminRoutes, page, serverURL }) + }) + + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'adminTests', + }) + + await ensureCompilationIsDone({ customAdminRoutes, page, serverURL }) + }) + + describe('API view', () => { + test('collection — should not show API tab when disabled in config', async () => { + await page.goto(postsUrl.collection(noApiViewCollectionSlug)) + await page.locator('.collection-list .table a').click() + await expect(page.locator('.doc-tabs__tabs-container')).not.toContainText('API') + }) + + test('collection — should not enable API route when disabled in config', async () => { + const collectionItems = await payload.find({ + collection: noApiViewCollectionSlug, + limit: 1, + }) + expect(collectionItems.docs.length).toBe(1) + await page.goto( + `${postsUrl.collection(noApiViewGlobalSlug)}/${collectionItems.docs[0].id}/api`, + ) + await expect(page.locator('.not-found')).toHaveCount(1) + }) + + test('collection — sidebar fields should respond to permission', async () => { + const { id } = await createPost() + await page.goto(postsUrl.edit(id)) + + await expect(page.locator('#field-sidebarField')).toBeDisabled() + }) + + test('collection — depth field should have value 0 when empty', async () => { + const { id } = await createPost() + await page.goto(`${postsUrl.edit(id)}/api`) + + const depthField = page.locator('#field-depth') + await depthField.fill('') + await expect(depthField).toHaveValue('0') + }) + + test('global — should not show API tab when disabled in config', async () => { + await page.goto(postsUrl.global(noApiViewGlobalSlug)) + await expect(page.locator('.doc-tabs__tabs-container')).not.toContainText('API') + }) + + test('global — should not enable API route when disabled in config', async () => { + await page.goto(`${postsUrl.global(noApiViewGlobalSlug)}/api`) + await expect(page.locator('.not-found')).toHaveCount(1) + }) + }) + + describe('preview button', () => { + test('collection — should render preview button when `admin.preview` is set', async () => { + const collectionWithPreview = new AdminUrlUtil(serverURL, postsCollectionSlug) + await page.goto(collectionWithPreview.create) + await page.waitForURL(collectionWithPreview.create) + await page.locator('#field-title').fill(title) + await saveDocAndAssert(page) + await expect(page.locator('.btn.preview-btn')).toBeVisible() + }) + + test('collection — should not render preview button when `admin.preview` is not set', async () => { + const collectionWithoutPreview = new AdminUrlUtil(serverURL, group1Collection1Slug) + await page.goto(collectionWithoutPreview.create) + await page.waitForURL(collectionWithoutPreview.create) + await page.locator('#field-title').fill(title) + await saveDocAndAssert(page) + await expect(page.locator('.btn.preview-btn')).toBeHidden() + }) + + test('global — should render preview button when `admin.preview` is set', async () => { + const globalWithPreview = new AdminUrlUtil(serverURL, globalSlug) + await page.goto(globalWithPreview.global(globalSlug)) + await expect(page.locator('.btn.preview-btn')).toBeVisible() + }) + + test('global — should not render preview button when `admin.preview` is not set', async () => { + const globalWithoutPreview = new AdminUrlUtil(serverURL, group1GlobalSlug) + await page.goto(globalWithoutPreview.global(group1GlobalSlug)) + await page.locator('#field-title').fill(title) + await saveDocAndAssert(page) + await expect(page.locator('.btn.preview-btn')).toBeHidden() + }) + }) + + describe('form state', () => { + test('collection — should re-enable fields after save', async () => { + await page.goto(postsUrl.create) + await page.locator('#field-title').fill(title) + await saveDocAndAssert(page) + await expect(page.locator('#field-title')).toBeEnabled() + }) + + test('global — should re-enable fields after save', async () => { + await page.goto(globalURL.global(globalSlug)) + await page.locator('#field-title').fill(title) + await saveDocAndAssert(page) + await expect(page.locator('#field-title')).toBeEnabled() + }) + }) + + describe('document titles', () => { + test('collection — should render fallback titles when creating new', async () => { + await page.goto(postsUrl.create) + await checkPageTitle(page, '[Untitled]') + await checkBreadcrumb(page, 'Create New') + await saveDocAndAssert(page) + expect(true).toBe(true) + }) + + test('collection — should render `useAsTitle` field', async () => { + await page.goto(postsUrl.create) + await page.locator('#field-title')?.fill(title) + await saveDocAndAssert(page) + await wait(500) + await checkPageTitle(page, title) + await checkBreadcrumb(page, title) + expect(true).toBe(true) + }) + + test('collection — should render `id` as `useAsTitle` fallback', async () => { + const { id } = await createPost() + const postURL = postsUrl.edit(id) + await page.goto(postURL) + await page.waitForURL(postURL) + await wait(500) + await page.locator('#field-title')?.fill('') + await expect(page.locator('.doc-header__title.render-title:has-text("ID:")')).toBeVisible() + await saveDocAndAssert(page) + }) + + test('global — should render custom, localized label', async () => { + await page.goto(globalURL.global(globalSlug)) + await page.waitForURL(globalURL.global(globalSlug)) + await openNav(page) + const label = 'My Global Label' + const globalLabel = page.locator(`#nav-global-global`) + await expect(globalLabel).toContainText(label) + await globalLabel.click() + await checkPageTitle(page, label) + await checkBreadcrumb(page, label) + await page.locator('#field-title').fill(title) + await saveDocAndAssert(page) + await checkPageTitle(page, label) + await checkBreadcrumb(page, label) + }) + + test('global — should render simple label strings', async () => { + await page.goto(postsUrl.admin) + await page.waitForURL(postsUrl.admin) + await openNav(page) + const label = 'Group Globals 1' + const globalLabel = page.locator(`#nav-global-group-globals-one`) + await expect(globalLabel).toContainText(label) + await globalLabel.click() + await checkPageTitle(page, label) + await checkBreadcrumb(page, label) + }) + + test('global — should render slug in sentence case as fallback', async () => { + await page.goto(postsUrl.admin) + await page.waitForURL(postsUrl.admin) + await openNav(page) + const label = 'Group Globals Two' + const globalLabel = page.locator(`#nav-global-group-globals-two`) + await expect(globalLabel).toContainText(label) + await globalLabel.click() + await checkPageTitle(page, label) + await checkBreadcrumb(page, label) + }) + }) + + describe('custom document views', () => { + test('collection — should render custom tab view', async () => { + await page.goto(customViewsURL.create) + await page.locator('#field-title').fill('Test') + await saveDocAndAssert(page) + const pageURL = page.url() + const customViewURL = `${pageURL}${customTabViewPath}` + await page.goto(customViewURL) + expect(page.url()).toEqual(customViewURL) + await expect(page.locator('h1#custom-view-title')).toContainText(customTabViewTitle) + }) + + test('collection — should render custom nested tab view', async () => { + await page.goto(customViewsURL.create) + await page.locator('#field-title').fill('Test') + await saveDocAndAssert(page) + + // wait for the update view to load + await page.waitForURL(/\/(?!create$)[\w-]+$/) + const pageURL = page.url() + + const customNestedTabViewURL = `${pageURL}${customNestedTabViewPath}` + await page.goto(customNestedTabViewURL) + await page.waitForURL(customNestedTabViewURL) + await expect(page.locator('h1#custom-view-title')).toContainText(customNestedTabViewTitle) + }) + + test('collection — should render custom tab label', async () => { + await page.goto(customViewsURL.create) + await page.locator('#field-title').fill('Test') + await saveDocAndAssert(page) + + // wait for the update view to load + await page.waitForURL(/\/(?!create$)[\w-]+$/) + const editTab = page.locator('.doc-tab a[tabindex="-1"]') + + await expect(editTab).toContainText(customEditLabel) + }) + + test('collection — should render custom tab component', async () => { + await page.goto(customViewsURL.create) + await page.locator('#field-title').fill('Test') + await saveDocAndAssert(page) + + const customTab = page.locator(`.doc-tab a:has-text("${customTabLabel}")`) + + await expect(customTab).toBeVisible() + }) + + test('global — should render custom tab label', async () => { + await page.goto(globalURL.global(customGlobalViews2GlobalSlug) + '/custom-tab-view') + + const title = page.locator('#custom-view-title') + + const docTab = page.locator('.doc-tab__link:has-text("Custom")') + + await expect(docTab).toBeVisible() + await expect(title).toContainText('Custom Tab Label View') + }) + + test('global — should render custom tab component', async () => { + await page.goto(globalURL.global(customGlobalViews2GlobalSlug) + '/custom-tab-component') + const title = page.locator('#custom-view-title') + + const docTab = page.locator('.custom-doc-tab').first() + + await expect(docTab).toBeVisible() + await expect(docTab).toContainText('Custom Tab Component') + await expect(title).toContainText('Custom View With Tab Component') + }) + }) + + describe('drawers', () => { + test('document drawers are visually stacking', async () => { + await navigateToDoc(page, postsUrl) + await page.locator('#field-title').fill(title) + await saveDocAndAssert(page) + await page + .locator('.field-type.relationship .relationship--single-value__drawer-toggler') + .click() + await wait(500) + const drawer1Content = page.locator('[id^=doc-drawer_posts_1_] .drawer__content') + await expect(drawer1Content).toBeVisible() + const drawerLeft = await drawer1Content.boundingBox().then((box) => box.x) + await drawer1Content + .locator('.field-type.relationship .relationship--single-value__drawer-toggler') + .click() + const drawer2Content = page.locator('[id^=doc-drawer_posts_2_] .drawer__content') + await expect(drawer2Content).toBeVisible() + const drawer2Left = await drawer2Content.boundingBox().then((box) => box.x) + expect(drawer2Left > drawerLeft).toBe(true) + }) + }) + + describe('custom fields', () => { + test('should render custom field component', async () => { + await page.goto(customFieldsURL.create) + await page.waitForURL(customFieldsURL.create) + await expect(page.locator('#field-customTextClientField')).toBeVisible() + }) + + test('renders custom label component', async () => { + await page.goto(customFieldsURL.create) + await page.waitForURL(customFieldsURL.create) + await expect(page.locator('#custom-client-field-label')).toBeVisible() + await expect(page.locator('#custom-server-field-label')).toBeVisible() + }) + + test('renders custom field description text', async () => { + await page.goto(customFieldsURL.create) + await page.waitForURL(customFieldsURL.create) + await expect(page.locator('#custom-client-field-description')).toBeVisible() + await expect(page.locator('#custom-server-field-description')).toBeVisible() + }) + + test('custom server components should receive field props', async () => { + await page.goto(customFieldsURL.create) + await page.waitForURL(customFieldsURL.create) + await expect( + page.locator('#custom-server-field-label', { + hasText: exactText('Label: the max length of this field is: 100'), + }), + ).toBeVisible() + + await expect( + page.locator('#custom-server-field-description', { + hasText: exactText('Description: the max length of this field is: 100'), + }), + ).toBeVisible() + }) + + test('custom client components should receive field props', async () => { + await page.goto(customFieldsURL.create) + await page.waitForURL(customFieldsURL.create) + await expect( + page.locator('#custom-client-field-label', { + hasText: exactText('Label: the max length of this field is: 100'), + }), + ).toBeVisible() + await expect( + page.locator('#custom-client-field-description', { + hasText: exactText('Description: the max length of this field is: 100'), + }), + ).toBeVisible() + }) + + describe('field descriptions', () => { + test('should render static field description', async () => { + await page.goto(customFieldsURL.create) + await page.waitForURL(customFieldsURL.create) + await expect(page.locator('.field-description-descriptionAsString')).toContainText( + 'Static field description.', + ) + }) + + test('should render functional field description', async () => { + await page.goto(customFieldsURL.create) + await page.waitForURL(customFieldsURL.create) + await page.locator('#field-descriptionAsFunction').fill('functional') + await expect(page.locator('.field-description-descriptionAsFunction')).toContainText( + 'Function description', + ) + }) + }) + + test('should render component field description', async () => { + await page.goto(customFieldsURL.create) + await page.waitForURL(customFieldsURL.create) + await page.locator('#field-descriptionAsComponent').fill('component') + await expect(page.locator('.field-description-descriptionAsComponent')).toContainText( + 'Component description: descriptionAsComponent - component', + ) + }) + + test('should render custom error component', async () => { + await page.goto(customFieldsURL.create) + await page.waitForURL(customFieldsURL.create) + const input = page.locator('input[id="field-customTextClientField"]') + await input.fill('ab') + await expect(input).toHaveValue('ab') + const error = page.locator('.custom-error:near(input[id="field-customTextClientField"])') + const submit = page.locator('button[type="button"][id="action-save"]') + await submit.click() + await expect(error).toHaveText('#custom-error') + }) + + test('should render beforeInput and afterInput', async () => { + await page.goto(customFieldsURL.create) + const input = page.locator('input[id="field-customTextClientField"]') + + const prevSibling = await input.evaluateHandle((el) => { + return el.previousElementSibling + }) + + const prevSiblingText = await page.evaluate((el) => el?.textContent, prevSibling) + expect(prevSiblingText).toEqual('#before-input') + + const nextSibling = await input.evaluateHandle((el) => { + return el.nextElementSibling + }) + + const nextSiblingText = await page.evaluate((el) => el?.textContent, nextSibling) + expect(nextSiblingText).toEqual('#after-input') + }) + + describe('select field', () => { + test('should render custom select options', async () => { + await page.goto(customFieldsURL.create) + await page.waitForURL(customFieldsURL.create) + await page.locator('#field-customSelectField .rs__control').click() + await expect(page.locator('#field-customSelectField .rs__option')).toHaveCount(2) + }) + }) + }) +}) + +async function createPost(overrides?: Partial): Promise { + return payload.create({ + collection: postsCollectionSlug, + data: { + description, + title, + ...overrides, + }, + }) as unknown as Promise +} diff --git a/test/admin/e2e/1/e2e.spec.ts b/test/admin/e2e/general/e2e.spec.ts similarity index 57% rename from test/admin/e2e/1/e2e.spec.ts rename to test/admin/e2e/general/e2e.spec.ts index bf2a2d6ecf..1625171b26 100644 --- a/test/admin/e2e/1/e2e.spec.ts +++ b/test/admin/e2e/general/e2e.spec.ts @@ -2,7 +2,7 @@ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' -import type { Config, Post } from '../../payload-types.js' +import type { Config, Geo, Post } from '../../payload-types.js' import { ensureCompilationIsDone, @@ -11,6 +11,7 @@ import { initPageConsoleErrorCatch, openNav, saveDocAndAssert, + saveDocHotkeyAndAssert, } from '../../../helpers.js' import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' @@ -18,15 +19,10 @@ import { customAdminRoutes, customCollectionMetaTitle, customDefaultTabMetaTitle, - customEditLabel, - customNestedTabViewPath, - customNestedTabViewTitle, customNestedViewPath, customNestedViewTitle, customRootViewMetaTitle, - customTabLabel, customTabViewPath, - customTabViewTitle, customVersionsTabMetaTitle, customViewMetaTitle, customViewPath, @@ -36,9 +32,9 @@ import { slugPluralLabel, } from '../../shared.js' import { - customFieldsSlug, - customGlobalViews2GlobalSlug, customViews2CollectionSlug, + disableDuplicateSlug, + geoCollectionSlug, globalSlug, notInViewCollectionSlug, postsCollectionSlug, @@ -53,6 +49,7 @@ const description = 'Description' let payload: PayloadTestSDK import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js' +import { openDocControls } from 'helpers/e2e/openDocControls.js' import path from 'path' import { fileURLToPath } from 'url' @@ -64,13 +61,14 @@ const filename = fileURLToPath(import.meta.url) const currentFolder = path.dirname(filename) const dirname = path.resolve(currentFolder, '../../') -describe('admin1', () => { +describe('General', () => { let page: Page let postsUrl: AdminUrlUtil + let geoUrl: AdminUrlUtil let notInViewUrl: AdminUrlUtil let globalURL: AdminUrlUtil let customViewsURL: AdminUrlUtil - let customFieldsURL: AdminUrlUtil + let disableDuplicateURL: AdminUrlUtil let serverURL: string let adminRoutes: ReturnType @@ -85,10 +83,11 @@ describe('admin1', () => { prebuild, })) postsUrl = new AdminUrlUtil(serverURL, postsCollectionSlug) + geoUrl = new AdminUrlUtil(serverURL, geoCollectionSlug) notInViewUrl = new AdminUrlUtil(serverURL, notInViewCollectionSlug) globalURL = new AdminUrlUtil(serverURL, globalSlug) customViewsURL = new AdminUrlUtil(serverURL, customViews2CollectionSlug) - customFieldsURL = new AdminUrlUtil(serverURL, customFieldsSlug) + disableDuplicateURL = new AdminUrlUtil(serverURL, disableDuplicateSlug) const context = await browser.newContext() page = await context.newPage() @@ -98,6 +97,7 @@ describe('admin1', () => { adminRoutes = getRoutes({ customAdminRoutes }) }) + beforeEach(async () => { await reInitializeDB({ serverURL, @@ -477,6 +477,16 @@ describe('admin1', () => { }) }) + describe('custom CSS', () => { + test('should see custom css in admin UI', async () => { + await page.goto(postsUrl.admin) + await page.waitForURL(postsUrl.admin) + await openNav(page) + const navControls = page.locator('#custom-css') + await expect(navControls).toHaveCSS('font-family', 'monospace') + }) + }) + describe('custom providers', () => { test('should render custom providers', async () => { await page.goto(`${serverURL}/admin`) @@ -493,14 +503,14 @@ describe('admin1', () => { }) }) - describe('custom views', () => { - test('root — should render custom view', async () => { + describe('custom root views', () => { + test('should render custom view', async () => { await page.goto(`${serverURL}${adminRoutes.routes.admin}${customViewPath}`) await page.waitForURL(`**${adminRoutes.routes.admin}${customViewPath}`) await expect(page.locator('h1#custom-view-title')).toContainText(customViewTitle) }) - test('root — should render custom nested view', async () => { + test('should render custom nested view', async () => { await page.goto(`${serverURL}${adminRoutes.routes.admin}${customNestedViewPath}`) const pageURL = page.url() const pathname = new URL(pageURL).pathname @@ -508,13 +518,13 @@ describe('admin1', () => { await expect(page.locator('h1#custom-view-title')).toContainText(customNestedViewTitle) }) - test('root — should render public custom view', async () => { + test('should render public custom view', async () => { await page.goto(`${serverURL}${adminRoutes.routes.admin}${publicCustomViewPath}`) await page.waitForURL(`**${adminRoutes.routes.admin}${publicCustomViewPath}`) await expect(page.locator('h1#custom-view-title')).toContainText(customViewTitle) }) - test('root — should render protected nested custom view', async () => { + test('should render protected nested custom view', async () => { await page.goto(`${serverURL}${adminRoutes.routes.admin}${protectedCustomNestedViewPath}`) await page.waitForURL(`**${adminRoutes.routes.admin}/unauthorized`) await expect(page.locator('.unauthorized')).toBeVisible() @@ -531,196 +541,61 @@ describe('admin1', () => { await page.waitForURL(`**${adminRoutes.routes.admin}${protectedCustomNestedViewPath}`) await expect(page.locator('h1#custom-view-title')).toContainText(customNestedViewTitle) }) - - test('collection - should render custom tab view', async () => { - await page.goto(customViewsURL.create) - await page.locator('#field-title').fill('Test') - await saveDocAndAssert(page) - const pageURL = page.url() - const customViewURL = `${pageURL}${customTabViewPath}` - await page.goto(customViewURL) - expect(page.url()).toEqual(customViewURL) - await expect(page.locator('h1#custom-view-title')).toContainText(customTabViewTitle) - }) - - test('collection — should render custom nested tab view', async () => { - await page.goto(customViewsURL.create) - await page.locator('#field-title').fill('Test') - await saveDocAndAssert(page) - - // wait for the update view to load - await page.waitForURL(/\/(?!create$)[\w-]+$/) - const pageURL = page.url() - - const customNestedTabViewURL = `${pageURL}${customNestedTabViewPath}` - await page.goto(customNestedTabViewURL) - await page.waitForURL(customNestedTabViewURL) - await expect(page.locator('h1#custom-view-title')).toContainText(customNestedTabViewTitle) - }) - - test('collection — should render custom tab label', async () => { - await page.goto(customViewsURL.create) - await page.locator('#field-title').fill('Test') - await saveDocAndAssert(page) - - // wait for the update view to load - await page.waitForURL(/\/(?!create$)[\w-]+$/) - const editTab = page.locator('.doc-tab a[tabindex="-1"]') - - await expect(editTab).toContainText(customEditLabel) - }) - - test('collection — should render custom tab component', async () => { - await page.goto(customViewsURL.create) - await page.locator('#field-title').fill('Test') - await saveDocAndAssert(page) - - const customTab = page.locator(`.doc-tab a:has-text("${customTabLabel}")`) - - await expect(customTab).toBeVisible() - }) - - test('global — should render custom tab label', async () => { - await page.goto(globalURL.global(customGlobalViews2GlobalSlug) + '/custom-tab-view') - - const title = page.locator('#custom-view-title') - - const docTab = page.locator('.doc-tab__link:has-text("Custom")') - - await expect(docTab).toBeVisible() - await expect(title).toContainText('Custom Tab Label View') - }) - - test('global — should render custom tab component', async () => { - await page.goto(globalURL.global(customGlobalViews2GlobalSlug) + '/custom-tab-component') - const title = page.locator('#custom-view-title') - - const docTab = page.locator('.custom-doc-tab').first() - - await expect(docTab).toBeVisible() - await expect(docTab).toContainText('Custom Tab Component') - await expect(title).toContainText('Custom View With Tab Component') - }) }) - describe('custom fields', () => { - test('should render custom field component', async () => { - await page.goto(customFieldsURL.create) - await page.waitForURL(customFieldsURL.create) - await expect(page.locator('#field-customTextClientField')).toBeVisible() + describe('header actions', () => { + test('should show admin level action in admin panel', async () => { + await page.goto(postsUrl.admin) + // Check if the element with the class .admin-button exists + await expect(page.locator('.app-header .admin-button')).toHaveCount(1) }) - test('renders custom label component', async () => { - await page.goto(customFieldsURL.create) - await page.waitForURL(customFieldsURL.create) - await expect(page.locator('#custom-client-field-label')).toBeVisible() - await expect(page.locator('#custom-server-field-label')).toBeVisible() + test('should show admin level action in collection list view', async () => { + await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}`) + await expect(page.locator('.app-header .admin-button')).toHaveCount(1) }) - test('renders custom field description text', async () => { - await page.goto(customFieldsURL.create) - await page.waitForURL(customFieldsURL.create) - await expect(page.locator('#custom-client-field-description')).toBeVisible() - await expect(page.locator('#custom-server-field-description')).toBeVisible() + test('should show admin level action in collection edit view', async () => { + const { id } = await createGeo() + await page.goto(geoUrl.edit(id)) + await expect(page.locator('.app-header .admin-button')).toHaveCount(1) }) - test('custom server components should receive field props', async () => { - await page.goto(customFieldsURL.create) - await page.waitForURL(customFieldsURL.create) - await expect( - page.locator('#custom-server-field-label', { - hasText: exactText('Label: the max length of this field is: 100'), - }), - ).toBeVisible() - - await expect( - page.locator('#custom-server-field-description', { - hasText: exactText('Description: the max length of this field is: 100'), - }), - ).toBeVisible() + test('should show collection list view level action in collection list view', async () => { + await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}`) + await expect(page.locator('.app-header .collection-list-button')).toHaveCount(1) }) - test('custom client components should receive field props', async () => { - await page.goto(customFieldsURL.create) - await page.waitForURL(customFieldsURL.create) - await expect( - page.locator('#custom-client-field-label', { - hasText: exactText('Label: the max length of this field is: 100'), - }), - ).toBeVisible() - await expect( - page.locator('#custom-client-field-description', { - hasText: exactText('Description: the max length of this field is: 100'), - }), - ).toBeVisible() + test('should show collection edit view level action in collection edit view', async () => { + const { id } = await createGeo() + await page.goto(geoUrl.edit(id)) + await expect(page.locator('.app-header .collection-edit-button')).toHaveCount(1) }) - describe('field descriptions', () => { - test('should render static field description', async () => { - await page.goto(customFieldsURL.create) - await page.waitForURL(customFieldsURL.create) - await expect(page.locator('.field-description-descriptionAsString')).toContainText( - 'Static field description.', - ) - }) - - test('should render functional field description', async () => { - await page.goto(customFieldsURL.create) - await page.waitForURL(customFieldsURL.create) - await page.locator('#field-descriptionAsFunction').fill('functional') - await expect(page.locator('.field-description-descriptionAsFunction')).toContainText( - 'Function description', - ) - }) + test('should show collection api view level action in collection api view', async () => { + const { id } = await createGeo() + await page.goto(`${geoUrl.edit(id)}/api`) + await expect(page.locator('.app-header .collection-api-button')).toHaveCount(1) }) - test('should render component field description', async () => { - await page.goto(customFieldsURL.create) - await page.waitForURL(customFieldsURL.create) - await page.locator('#field-descriptionAsComponent').fill('component') - await expect(page.locator('.field-description-descriptionAsComponent')).toContainText( - 'Component description: descriptionAsComponent - component', - ) + test('should show global edit view level action in globals edit view', async () => { + const globalWithPreview = new AdminUrlUtil(serverURL, globalSlug) + await page.goto(globalWithPreview.global(globalSlug)) + await expect(page.locator('.app-header .global-edit-button')).toHaveCount(1) }) - test('should render custom error component', async () => { - await page.goto(customFieldsURL.create) - await page.waitForURL(customFieldsURL.create) - const input = page.locator('input[id="field-customTextClientField"]') - await input.fill('ab') - await expect(input).toHaveValue('ab') - const error = page.locator('.custom-error:near(input[id="field-customTextClientField"])') - const submit = page.locator('button[type="button"][id="action-save"]') - await submit.click() - await expect(error).toHaveText('#custom-error') + test('should show global api view level action in globals api view', async () => { + const globalWithPreview = new AdminUrlUtil(serverURL, globalSlug) + await page.goto(`${globalWithPreview.global(globalSlug)}/api`) + await expect(page.locator('.app-header .global-api-button')).toHaveCount(1) }) - test('should render beforeInput and afterInput', async () => { - await page.goto(customFieldsURL.create) - const input = page.locator('input[id="field-customTextClientField"]') - - const prevSibling = await input.evaluateHandle((el) => { - return el.previousElementSibling - }) - - const prevSiblingText = await page.evaluate((el) => el?.textContent, prevSibling) - expect(prevSiblingText).toEqual('#before-input') - - const nextSibling = await input.evaluateHandle((el) => { - return el.nextElementSibling - }) - - const nextSiblingText = await page.evaluate((el) => el?.textContent, nextSibling) - expect(nextSiblingText).toEqual('#after-input') - }) - - describe('select field', () => { - test('should render custom select options', async () => { - await page.goto(customFieldsURL.create) - await page.waitForURL(customFieldsURL.create) - await page.locator('#field-customSelectField .rs__control').click() - await expect(page.locator('#field-customSelectField .rs__option')).toHaveCount(2) - }) + test('should reset actions array when navigating from view with actions to view without actions', async () => { + await page.goto(geoUrl.list) + await expect(page.locator('.app-header .collection-list-button')).toHaveCount(1) + await page.locator('button.nav-toggler[aria-label="Open Menu"][tabindex="0"]').click() + await page.locator(`#nav-posts`).click() + await expect(page.locator('.app-header .collection-list-button')).toHaveCount(0) }) }) @@ -731,8 +606,328 @@ describe('admin1', () => { await expect(header).toContainText('Here is a custom header') }) }) + + describe('i18n', () => { + test('should allow changing language', async () => { + await page.goto(postsUrl.account) + + const field = page.locator('.payload-settings__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 a').first().locator('span')).toHaveAttribute( + 'title', + '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(postsUrl.account) + await expect(page.locator('.step-nav a').first().locator('span')).toHaveAttribute( + 'title', + 'Home', + ) + }) + + test('should allow custom translation of locale labels', async () => { + const selectOptionClass = '.localizer .popup-button-list__button' + const localizerButton = page.locator('.localizer .popup-button') + const localeListItem1 = page.locator(selectOptionClass).nth(0) + + async function checkLocaleLabels(firstLabel: string, secondLabel: string) { + await localizerButton.click() + await expect(page.locator(selectOptionClass).first()).toContainText(firstLabel) + await expect(page.locator(selectOptionClass).nth(1)).toContainText(secondLabel) + } + + await checkLocaleLabels('Spanish (es)', 'English (en)') + + // Change locale to Spanish + await localizerButton.click() + await expect(localeListItem1).toContainText('Spanish (es)') + await localeListItem1.click() + + // Go to account page + await page.goto(postsUrl.account) + + const languageField = page.locator('.payload-settings__language .react-select') + const options = page.locator('.rs__option') + + // Change language to Spanish + await languageField.click() + await options.locator('text=Español').click() + + await checkLocaleLabels('Español (es)', 'Inglés (en)') + + // Change locale and language back to English + await languageField.click() + await options.locator('text=English').click() + await localizerButton.click() + await expect(localeListItem1).toContainText('Spanish (es)') + }) + }) + + describe('CRUD', () => { + test('should create', async () => { + await page.goto(postsUrl.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(postsUrl.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(postsUrl.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(postsUrl.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, title } = await createPost() + await page.goto(postsUrl.edit(id)) + await openDocControls(page) + await page.locator('#action-delete').click() + await page.locator('#confirm-delete').click() + await expect(page.locator(`text=Post "${title}" successfully deleted.`)).toBeVisible() + expect(page.url()).toContain(postsUrl.list) + }) + + test('should bulk delete all on page', async () => { + await deleteAllPosts() + await Promise.all([createPost(), createPost(), createPost()]) + await page.goto(postsUrl.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('.payload-toast-container .toast-success')).toHaveText( + 'Deleted 3 Posts successfully.', + ) + + await expect(page.locator('.collection-list__no-results')).toBeVisible() + }) + + test('should bulk delete with filters and across pages', async () => { + await deleteAllPosts() + await Promise.all([createPost({ title: 'Post 1' }), createPost({ title: 'Post 2' })]) + await page.goto(postsUrl.list) + await page.locator('#search-filter-input').fill('Post 1') + await expect(page.locator('.table table > tbody > tr')).toHaveCount(1) + await page.locator('input#select-all').check() + await page.locator('button.list-selection__button').click() + await page.locator('.delete-documents__toggle').click() + await page.locator('#confirm-delete').click() + + await expect(page.locator('.payload-toast-container .toast-success')).toHaveText( + 'Deleted 1 Post successfully.', + ) + + await expect(page.locator('.table table > tbody > tr')).toHaveCount(1) + }) + + test('should bulk update', async () => { + // First, delete all posts created by the seed + await deleteAllPosts() + const post1Title = 'Post' + const updatedPostTitle = `${post1Title} (Updated)` + await Promise.all([createPost({ title: post1Title }), createPost(), createPost()]) + await page.goto(postsUrl.list) + await page.locator('input#select-all').check() + await page.locator('.edit-many__toggle').click() + await page.locator('.field-select .rs__control').click() + + const titleOption = page.locator('.field-select .rs__option', { + hasText: exactText('Title'), + }) + + await expect(titleOption).toBeVisible() + await titleOption.click() + const titleInput = page.locator('#field-title') + await expect(titleInput).toBeVisible() + await titleInput.fill(updatedPostTitle) + await page.locator('.form-submit button[type="submit"].edit-many__publish').click() + + await expect(page.locator('.payload-toast-container .toast-success')).toContainText( + 'Updated 3 Posts successfully.', + ) + + await expect(page.locator('.row-1 .cell-title')).toContainText(updatedPostTitle) + await expect(page.locator('.row-2 .cell-title')).toContainText(updatedPostTitle) + await expect(page.locator('.row-3 .cell-title')).toContainText(updatedPostTitle) + }) + + test('should not override un-edited values in bulk edit if it has a defaultValue', async () => { + await deleteAllPosts() + const post1Title = 'Post' + const postData = { + title: 'Post', + arrayOfFields: [ + { + optional: 'some optional array field', + innerArrayOfFields: [ + { + innerOptional: 'some inner optional array field', + }, + ], + }, + ], + group: { + defaultValueField: 'not the group default value', + title: 'some title', + }, + someBlock: [ + { + textFieldForBlock: 'some text for block text', + blockType: 'textBlock', + }, + ], + defaultValueField: 'not the default value', + } + const updatedPostTitle = `${post1Title} (Updated)` + await createPost(postData) + await page.goto(postsUrl.list) + await page.locator('input#select-all').check() + await page.locator('.edit-many__toggle').click() + await page.locator('.field-select .rs__control').click() + + const titleOption = page.locator('.field-select .rs__option', { + hasText: exactText('Title'), + }) + + await expect(titleOption).toBeVisible() + await titleOption.click() + const titleInput = page.locator('#field-title') + await expect(titleInput).toBeVisible() + await titleInput.fill(updatedPostTitle) + await page.locator('.form-submit button[type="submit"].edit-many__publish').click() + + await expect(page.locator('.payload-toast-container .toast-success')).toContainText( + 'Updated 1 Post successfully.', + ) + + const updatedPost = await payload.find({ + collection: 'posts', + limit: 1, + }) + + expect(updatedPost.docs[0].title).toBe(updatedPostTitle) + expect(updatedPost.docs[0].arrayOfFields.length).toBe(1) + expect(updatedPost.docs[0].arrayOfFields[0].optional).toBe('some optional array field') + expect(updatedPost.docs[0].arrayOfFields[0].innerArrayOfFields.length).toBe(1) + expect(updatedPost.docs[0].someBlock[0].textFieldForBlock).toBe('some text for block text') + expect(updatedPost.docs[0].defaultValueField).toBe('not the default value') + }) + + test('should bulk update with filters and across pages', async () => { + // First, delete all posts created by the seed + await deleteAllPosts() + const post1Title = 'Post 1' + await Promise.all([createPost({ title: post1Title }), createPost({ title: 'Post 2' })]) + const updatedPostTitle = `${post1Title} (Updated)` + await page.goto(postsUrl.list) + await page.locator('#search-filter-input').fill('Post 1') + await expect(page.locator('.table table > tbody > tr')).toHaveCount(1) + await page.locator('input#select-all').check() + await page.locator('button.list-selection__button').click() + await page.locator('.edit-many__toggle').click() + await page.locator('.field-select .rs__control').click() + + const titleOption = page.locator('.field-select .rs__option', { + hasText: exactText('Title'), + }) + + await expect(titleOption).toBeVisible() + await titleOption.click() + const titleInput = page.locator('#field-title') + await expect(titleInput).toBeVisible() + await titleInput.fill(updatedPostTitle) + + await page.locator('.form-submit button[type="submit"].edit-many__publish').click() + await expect(page.locator('.payload-toast-container .toast-success')).toContainText( + 'Updated 1 Post successfully.', + ) + + await expect(page.locator('.table table > tbody > tr')).toHaveCount(1) + await expect(page.locator('.row-1 .cell-title')).toContainText(updatedPostTitle) + }) + + test('should save globals', async () => { + await page.goto(postsUrl.global(globalSlug)) + + await page.locator('#field-title').fill(title) + await saveDocAndAssert(page) + + await expect(page.locator('#field-title')).toHaveValue(title) + }) + + test('should hide duplicate when disableDuplicate: true', async () => { + await page.goto(disableDuplicateURL.create) + await page.locator('#field-title').fill(title) + await saveDocAndAssert(page) + await page.locator('.doc-controls__popup >> .popup-button').click() + await expect(page.locator('#action-duplicate')).toBeHidden() + }) + + test('should properly close leave-without-saving modal after clicking leave-anyway button', async () => { + const { id } = await createPost() + await page.goto(postsUrl.edit(id)) + const title = 'title' + await page.locator('#field-title').fill(title) + await saveDocHotkeyAndAssert(page) + await expect(page.locator('#field-title')).toHaveValue(title) + + const newTitle = 'new title' + await page.locator('#field-title').fill(newTitle) + + await page.locator('header.app-header a[href="/admin/collections/posts"]').click() + + // Locate the modal container + const modalContainer = page.locator('.payload__modal-container') + await expect(modalContainer).toBeVisible() + + // Click the "Leave anyway" button + await page.locator('.leave-without-saving__controls .btn--style-primary').click() + + // Assert that the class on the modal container changes to 'payload__modal-container--exitDone' + await expect(modalContainer).toHaveClass(/payload__modal-container--exitDone/) + }) + }) }) +async function deleteAllPosts() { + await payload.delete({ collection: postsCollectionSlug, where: { id: { exists: true } } }) +} + async function createPost(overrides?: Partial): Promise { return payload.create({ collection: postsCollectionSlug, @@ -743,3 +938,13 @@ async function createPost(overrides?: Partial): Promise { }, }) as unknown as Promise } + +async function createGeo(overrides?: Partial): Promise { + return payload.create({ + collection: geoCollectionSlug, + data: { + point: [4, -4], + ...overrides, + }, + }) as unknown as Promise +} diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts new file mode 100644 index 0000000000..65af4080de --- /dev/null +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -0,0 +1,820 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { mapAsync } from 'payload' +import * as qs from 'qs-esm' + +import type { Config, Geo, Post } from '../../payload-types.js' + +import { + ensureCompilationIsDone, + exactText, + getRoutes, + initPageConsoleErrorCatch, + openDocDrawer, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { customAdminRoutes } from '../../shared.js' +import { customViews1CollectionSlug, geoCollectionSlug, postsCollectionSlug } from '../../slugs.js' + +const { beforeAll, beforeEach, describe } = test + +const title = 'Title' +const description = 'Description' + +let payload: PayloadTestSDK + +import { goToFirstCell } from 'helpers/e2e/navigateToDoc.js' +import { openListColumns } from 'helpers/e2e/openListColumns.js' +import { openListFilters } from 'helpers/e2e/openListFilters.js' +import { toggleColumn } from 'helpers/e2e/toggleColumn.js' +import path from 'path' +import { wait } from 'payload/shared' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' + +import { reorderColumns } from '../../../helpers/e2e/reorderColumns.js' +import { reInitializeDB } from '../../../helpers/reInitializeDB.js' +import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js' +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +describe('List View', () => { + let page: Page + let geoUrl: AdminUrlUtil + let postsUrl: AdminUrlUtil + let baseListFiltersUrl: AdminUrlUtil + let customViewsUrl: AdminUrlUtil + + let serverURL: string + let adminRoutes: ReturnType + + beforeAll(async ({ browser }, testInfo) => { + const prebuild = false // Boolean(process.env.CI) + + 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({ + dirname, + prebuild, + })) + + geoUrl = new AdminUrlUtil(serverURL, geoCollectionSlug) + postsUrl = new AdminUrlUtil(serverURL, postsCollectionSlug) + baseListFiltersUrl = new AdminUrlUtil(serverURL, 'base-list-filters') + customViewsUrl = new AdminUrlUtil(serverURL, customViews1CollectionSlug) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + + await ensureCompilationIsDone({ customAdminRoutes, page, serverURL }) + + adminRoutes = getRoutes({ customAdminRoutes }) + }) + + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'adminTests', + }) + + await ensureCompilationIsDone({ customAdminRoutes, page, serverURL }) + + // delete all posts created by the seed + await deleteAllPosts() + await page.goto(postsUrl.list) + await page.waitForURL((url) => url.toString().startsWith(postsUrl.list)) + await expect(page.locator(tableRowLocator)).toBeHidden() + + await createPost({ title: 'post1' }) + await createPost({ title: 'post2' }) + await page.reload() + await expect(page.locator(tableRowLocator)).toHaveCount(2) + }) + + const tableRowLocator = 'table > tbody > tr' + + describe('list view descriptions', () => { + test('should render static collection descriptions', async () => { + await page.goto(postsUrl.list) + await expect( + page.locator('.view-description', { + hasText: exactText('This is a custom collection description.'), + }), + ).toBeVisible() + }) + + test('should render dynamic collection description components', async () => { + await page.goto(customViewsUrl.list) + await expect( + page.locator('.view-description', { + hasText: exactText('This is a custom view description component.'), + }), + ).toBeVisible() + }) + }) + + describe('filtering', () => { + test('should prefill search input from query param', async () => { + await createPost({ title: 'dennis' }) + await createPost({ title: 'charlie' }) + + // prefill search with "a" from the query param + await page.goto(`${postsUrl.list}?search=dennis`) + await page.waitForURL(new RegExp(`${postsUrl.list}\\?search=dennis`)) + + // input should be filled out, list should filter + await expect(page.locator('.search-filter__input')).toHaveValue('dennis') + await expect(page.locator(tableRowLocator)).toHaveCount(1) + }) + + test('should search by id with listSearchableFields', async () => { + const { id } = await createPost() + const url = `${postsUrl.list}?limit=10&page=1&search=${id}` + await page.goto(url) + await page.waitForURL(url) + const tableItems = page.locator(tableRowLocator) + await expect(tableItems).toHaveCount(1) + }) + + test('should search by id without listSearchableFields', async () => { + const { id } = await createGeo() + const url = `${geoUrl.list}?limit=10&page=1&search=${id}` + await page.goto(url) + await page.waitForURL(url) + const tableItems = page.locator(tableRowLocator) + await expect(tableItems).toHaveCount(1) + }) + + test('should search by title or description', async () => { + await createPost({ + description: 'this is fun', + title: 'find me', + }) + + 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('search should persist through browser back button', async () => { + const url = `${postsUrl.list}?limit=10&page=1&search=post1` + await page.goto(url) + await page.waitForURL(url) + await expect(page.locator('#search-filter-input')).toHaveValue('post1') + await goToFirstCell(page, postsUrl) + await page.goBack() + await wait(1000) // wait one second to ensure that the new view does not accidentally reset the search + await page.waitForURL(url) + }) + + test('search should not persist between navigation', async () => { + const url = `${postsUrl.list}?limit=10&page=1&search=test` + await page.goto(url) + await page.waitForURL(url) + + await expect(page.locator('#search-filter-input')).toHaveValue('test') + + await page.locator('.nav-toggler.template-default__nav-toggler').click() + await expect(page.locator('#nav-uploads')).toContainText('Uploads') + + const uploadsUrl = await page.locator('#nav-uploads').getAttribute('href') + await page.goto(serverURL + uploadsUrl) + await page.waitForURL(serverURL + uploadsUrl) + + await expect(page.locator('#search-filter-input')).toHaveValue('') + }) + + test('should toggle columns', async () => { + const columnCountLocator = 'table > thead > tr > th' + await createPost() + await openListColumns(page, {}) + const numberOfColumns = await page.locator(columnCountLocator).count() + await expect(page.locator('.column-selector')).toBeVisible() + await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('ID') + await toggleColumn(page, { columnLabel: 'ID', targetState: 'off' }) + await page.locator('#heading-id').waitFor({ state: 'detached' }) + await page.locator('.cell-id').first().waitFor({ state: 'detached' }) + await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns - 1) + await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('Number') + await toggleColumn(page, { columnLabel: 'ID', targetState: 'on' }) + await expect(page.locator('.cell-id').first()).toBeVisible() + await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns) + await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('ID') + }) + + test('should link second cell', async () => { + const { id } = await createPost() + await page.reload() + const linkCell = page.locator(`${tableRowLocator} td`).nth(1).locator('a') + + await expect(linkCell).toHaveAttribute( + 'href', + `${adminRoutes.routes.admin}/collections/posts/${id}`, + ) + + await page.locator('.list-controls__toggle-columns').click() + await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() + + await page + .locator('.column-selector .column-selector__column', { + hasText: exactText('ID'), + }) + .click() + + await page.locator('#heading-id').waitFor({ state: 'detached' }) + await page.locator('.cell-id').first().waitFor({ state: 'detached' }) + + await expect(linkCell).toHaveAttribute( + 'href', + `${adminRoutes.routes.admin}/collections/posts/${id}`, + ) + }) + + test('should filter rows', 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('.column-selector')).toBeVisible() + await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('ID') + + // ensure the ID column is active + const idButton = page.locator('.column-selector .column-selector__column', { + hasText: exactText('ID'), + }) + + const id = (await page.locator('.cell-id').first().innerText()).replace('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 openListFilters(page, {}) + + await page.locator('.where-builder__add-first-filter').click() + + const conditionField = page.locator('.condition__field') + await conditionField.click() + const dropdownFieldOption = conditionField.locator('.rs__option', { + hasText: exactText('ID'), + }) + await dropdownFieldOption.click() + await expect(page.locator('.condition__field')).toContainText('ID') + + 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) + + const tableRows = page.locator(tableRowLocator) + + await expect(tableRows).toHaveCount(1) + const firstId = page.locator(tableRowLocator).first().locator('.cell-id') + await expect(firstId).toHaveText(`ID: ${id}`) + + // Remove filter + await page.locator('.condition__actions-remove').click() + await expect(page.locator(tableRowLocator)).toHaveCount(2) + }) + + test('should reset filter value and operator on field update', async () => { + const id = (await page.locator('.cell-id').first().innerText()).replace('ID: ', '') + + // open the column controls + await page.locator('.list-controls__toggle-columns').click() + await openListFilters(page, {}) + await page.locator('.where-builder__add-first-filter').click() + + const operatorField = page.locator('.condition__operator') + await operatorField.click() + + const dropdownOperatorOptions = operatorField.locator('.rs__option') + await dropdownOperatorOptions.locator('text=equals').click() + + // execute filter (where ID equals id value) + const valueField = page.locator('.condition__value > input') + await valueField.fill(id) + + const filterField = page.locator('.condition__field') + await filterField.click() + + // select new filter field of Number + const dropdownFieldOption = filterField.locator('.rs__option', { + hasText: exactText('Status'), + }) + await dropdownFieldOption.click() + await expect(filterField).toContainText('Status') + + // expect operator & value field to reset (be empty) + await expect(operatorField.locator('.rs__placeholder')).toContainText('Select a value') + await expect(page.locator('.condition__value input')).toHaveValue('') + }) + + test('should accept where query from valid URL where parameter', async () => { + // delete all posts created by the seed + await deleteAllPosts() + await page.goto(postsUrl.list) + await expect(page.locator(tableRowLocator)).toBeHidden() + + await createPost({ title: 'post1' }) + await createPost({ title: 'post2' }) + await page.goto(postsUrl.list) + await expect(page.locator(tableRowLocator)).toHaveCount(2) + + await page.goto(`${postsUrl.list}?limit=10&page=1&where[or][0][and][0][title][equals]=post1`) + + await expect(page.locator('.react-select--single-value').first()).toContainText('Title') + await expect(page.locator(tableRowLocator)).toHaveCount(1) + }) + + test('should accept transformed where query from invalid URL where parameter', async () => { + // delete all posts created by the seed + await deleteAllPosts() + await page.goto(postsUrl.list) + await expect(page.locator(tableRowLocator)).toBeHidden() + + await createPost({ title: 'post1' }) + await createPost({ title: 'post2' }) + await page.goto(postsUrl.list) + await expect(page.locator(tableRowLocator)).toHaveCount(2) + + // [title][equals]=post1 should be getting transformed into a valid where[or][0][and][0][title][equals]=post1 + await page.goto(`${postsUrl.list}?limit=10&page=1&where[title][equals]=post1`) + + await expect(page.locator('.react-select--single-value').first()).toContainText('Title') + 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) + }) + + test('should reset page when filters are applied', async () => { + await deleteAllPosts() + + await Promise.all( + Array.from({ length: 12 }, async (_, i) => { + if (i < 6) { + await createPost() + } else { + await createPost({ title: 'test' }) + } + }), + ) + + await page.reload() + + const tableItems = page.locator(tableRowLocator) + + await expect(tableItems).toHaveCount(10) + await expect(page.locator('.collection-list__page-info')).toHaveText('1-10 of 12') + await expect(page.locator('.per-page')).toContainText('Per Page: 10') + await page.goto(`${postsUrl.list}?limit=10&page=2`) + await openListFilters(page, {}) + await page.locator('.where-builder__add-first-filter').click() + await page.locator('.condition__field .rs__control').click() + const options = page.locator('.rs__option') + await options.locator('text=Tab 1 > Title').click() + await page.locator('.condition__operator .rs__control').click() + await options.locator('text=equals').click() + await page.locator('.condition__value input').fill('test') + await expect(page.locator('.collection-list__page-info')).toHaveText('1-6 of 6') + }) + }) + + describe('table columns', () => { + test('should drag to reorder columns and save to preferences', async () => { + await createPost() + + await reorderColumns(page, { fromColumn: 'Number', toColumn: 'ID' }) + + // 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(page, { fromColumn: 'Number', toColumn: 'ID' }) + + await page.reload() + + await createPost() + await page.goto(postsUrl.create) + + await openDocDrawer(page, '.rich-text .list-drawer__toggler') + + const listDrawer = page.locator('[id^=list-drawer_1_]') + await expect(listDrawer).toBeVisible() + + const collectionSelector = page.locator( + '[id^=list-drawer_1_] .list-header__select-collection.react-select', + ) + + // select the "Post" collection + await collectionSelector.click() + await page + .locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', { + hasText: exactText('Post'), + }) + .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(postsUrl.create) + + // Open the drawer + await openDocDrawer(page, '.rich-text .list-drawer__toggler') + const listDrawer = page.locator('[id^=list-drawer_1_]') + await expect(listDrawer).toBeVisible() + + const collectionSelector = page.locator( + '[id^=list-drawer_1_] .list-header__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', { + hasText: exactText('ID'), + }) + .click() + + // select the "Post" collection + await collectionSelector.click() + await page + .locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', { + hasText: exactText('Post'), + }) + .click() + + // deselect the "number" column + await page + .locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', { + hasText: exactText('Number'), + }) + .click() + + // select the "User" collection again + await collectionSelector.click() + await page + .locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', { + hasText: exactText('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" collection again + await collectionSelector.click() + + await page + .locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', { + hasText: exactText('Post'), + }) + .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(postsUrl.list) + await expect( + page.locator('table > thead > tr > th', { + hasText: exactText('Demo UI Field'), + }), + ).toBeVisible() + }) + }) + + describe('multi-select', () => { + beforeEach(async () => { + // delete all posts created by the seed + await deleteAllPosts() + + await createPost() + await createPost() + await createPost() + }) + + test('should select multiple rows', async () => { + await page.reload() + const selectAll = page.locator('.checkbox-input:has(#select-all)') + await page.locator('.row-1 .cell-_select input').check() + + const indeterminateSelectAll = selectAll.locator('.checkbox-input__icon.partial') + expect(indeterminateSelectAll).toBeDefined() + + await selectAll.locator('input').click() + const emptySelectAll = selectAll.locator('.checkbox-input__icon:not(.check):not(.partial)') + await expect(emptySelectAll).toHaveCount(0) + + await selectAll.locator('input').click() + const checkSelectAll = selectAll.locator('.checkbox-input__icon.check') + expect(checkSelectAll).toBeDefined() + }) + + test('should delete many', async () => { + await page.goto(postsUrl.list) + await page.waitForURL(new RegExp(postsUrl.list)) + // 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', () => { + test('should paginate', async () => { + await deleteAllPosts() + + await mapAsync([...Array(11)], async () => { + await createPost() + }) + + await page.reload() + const tableItems = page.locator(tableRowLocator) + await expect(tableItems).toHaveCount(10) + await expect(page.locator('.collection-list__page-info')).toHaveText('1-10 of 11') + await expect(page.locator('.per-page')).toContainText('Per Page: 10') + await page.locator('.paginator button').nth(1).click() + await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2') + await expect(tableItems).toHaveCount(1) + await page.locator('.paginator button').nth(0).click() + await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=1') + await expect(tableItems).toHaveCount(10) + }) + + test('should paginate and maintain perPage', async () => { + await deleteAllPosts() + + await mapAsync([...Array(26)], async () => { + await createPost() + }) + + await page.reload() + const tableItems = page.locator(tableRowLocator) + await expect(tableItems).toHaveCount(10) + await expect(page.locator('.collection-list__page-info')).toHaveText('1-10 of 26') + await expect(page.locator('.per-page')).toContainText('Per Page: 10') + await page.locator('.per-page .popup-button').click() + + await page + .locator('.per-page button.per-page__button', { + hasText: '25', + }) + .click() + + await expect(tableItems).toHaveCount(25) + await expect(page.locator('.per-page .per-page__base-button')).toContainText('Per Page: 25') + await page.locator('.paginator button').nth(1).click() + await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2') + await expect(tableItems).toHaveCount(1) + await expect(page.locator('.per-page')).toContainText('Per Page: 25') + await expect(page.locator('.collection-list__page-info')).toHaveText('26-26 of 26') + }) + }) + + // TODO: Troubleshoot flaky suite + describe('sorting', () => { + beforeEach(async () => { + // delete all posts created by the seed + await deleteAllPosts() + await createPost({ number: 1 }) + await createPost({ number: 2 }) + }) + + test('should sort', async () => { + await page.reload() + const upChevron = page.locator('#heading-number .sort-column__asc') + const downChevron = page.locator('#heading-number .sort-column__desc') + + await upChevron.click() + await page.waitForURL(/sort=number/) + + await expect(page.locator('.row-1 .cell-number')).toHaveText('1') + await expect(page.locator('.row-2 .cell-number')).toHaveText('2') + + await downChevron.click() + await page.waitForURL(/sort=-number/) + + await expect(page.locator('.row-1 .cell-number')).toHaveText('2') + await expect(page.locator('.row-2 .cell-number')).toHaveText('1') + }) + + test('should sort with existing filters', async () => { + await page.goto(postsUrl.list) + await toggleColumn(page, { columnLabel: 'ID', targetState: 'off' }) + await page.locator('#heading-id').waitFor({ state: 'detached' }) + await page.locator('#heading-title button.sort-column__asc').click() + await page.waitForURL(/sort=title/) + + const columnAfterSort = page.locator( + `.list-controls__columns .column-selector .column-selector__column`, + { + hasText: exactText('ID'), + }, + ) + + await expect(columnAfterSort).not.toHaveClass('column-selector__column--active') + await expect(page.locator('#heading-id')).toBeHidden() + await expect(page.locator('.cell-id')).toHaveCount(0) + }) + }) + + describe('i18n', () => { + test('should display translated collections and globals config options', async () => { + await page.goto(postsUrl.list) + await expect(page.locator('#nav-posts')).toContainText('Posts') + await expect(page.locator('#nav-global-global')).toContainText('Global') + }) + + 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', { + hasText: exactText('Title'), + }), + ).toHaveText('Title') + + // filters + await openListFilters(page, {}) + 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=Tab 1 > Title')).toHaveText('Tab 1 > Title') + + // list columns + await expect(page.locator('#heading-title .sort-column__label')).toHaveText('Title') + await expect(page.locator('.search-filter input')).toHaveAttribute('placeholder', /(Title)/) + }) + + test('should use fallback language on field titles', async () => { + // change language German + await page.goto(postsUrl.account) + await page.locator('.payload-settings__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(postsUrl.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', { + hasText: exactText('Title'), + }), + ).toHaveText('Title') + }) + }) + + describe('base list filters', () => { + test('should respect base list filters', async () => { + await page.goto(baseListFiltersUrl.list) + await page.waitForURL((url) => url.toString().startsWith(baseListFiltersUrl.list)) + await expect(page.locator(tableRowLocator)).toHaveCount(1) + }) + }) +}) + +async function createPost(overrides?: Partial): Promise { + return payload.create({ + collection: postsCollectionSlug, + data: { + description, + title, + ...overrides, + }, + }) as unknown as Promise +} + +async function deleteAllPosts() { + await payload.delete({ collection: postsCollectionSlug, where: { id: { exists: true } } }) +} + +async function createGeo(overrides?: Partial): Promise { + return payload.create({ + collection: geoCollectionSlug, + data: { + point: [4, -4], + ...overrides, + }, + }) as unknown as Promise +} diff --git a/test/auth-basic/e2e.spec.ts b/test/auth-basic/e2e.spec.ts index 2235bfaf09..db9783130f 100644 --- a/test/auth-basic/e2e.spec.ts +++ b/test/auth-basic/e2e.spec.ts @@ -68,16 +68,14 @@ const createFirstUser = async ({ .not.toContain('create-first-user') } -describe('auth-basic', () => { +describe('Auth (Basic)', () => { let page: Page let url: AdminUrlUtil let serverURL: string - let apiURL: string beforeAll(async ({ browser }, testInfo) => { testInfo.setTimeout(TEST_TIMEOUT_LONG) ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) - apiURL = `${serverURL}/api` url = new AdminUrlUtil(serverURL, 'users') const context = await browser.newContext() diff --git a/test/auth/e2e.spec.ts b/test/auth/e2e.spec.ts index 46c6a43dd0..c3c2b3ea6f 100644 --- a/test/auth/e2e.spec.ts +++ b/test/auth/e2e.spec.ts @@ -4,7 +4,6 @@ import type { SanitizedConfig } from 'payload' import { expect, test } from '@playwright/test' import { devUser } from 'credentials.js' import path from 'path' -import { wait } from 'payload/shared' import { fileURLToPath } from 'url' import { v4 as uuid } from 'uuid' @@ -82,7 +81,7 @@ const createFirstUser = async ({ .not.toContain('create-first-user') } -describe('auth', () => { +describe('Auth', () => { let page: Page let context: BrowserContext let url: AdminUrlUtil diff --git a/test/field-error-states/e2e.spec.ts b/test/field-error-states/e2e.spec.ts index da04cf0a8b..3398d1b8fd 100644 --- a/test/field-error-states/e2e.spec.ts +++ b/test/field-error-states/e2e.spec.ts @@ -15,7 +15,7 @@ const { beforeAll, describe } = test const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) -describe('field error states', () => { +describe('Field Error States', () => { let serverURL: string let page: Page let validateDraftsOff: AdminUrlUtil diff --git a/test/fields-relationship/e2e.spec.ts b/test/fields-relationship/e2e.spec.ts index 1c8c0e96a8..d6235828e0 100644 --- a/test/fields-relationship/e2e.spec.ts +++ b/test/fields-relationship/e2e.spec.ts @@ -51,7 +51,7 @@ const { beforeAll, beforeEach, describe } = test let payload: PayloadTestSDK -describe('fields - relationship', () => { +describe('Relationship Field', () => { let url: AdminUrlUtil let versionedRelationshipFieldURL: AdminUrlUtil let page: Page diff --git a/test/hooks/e2e.spec.ts b/test/hooks/e2e.spec.ts index 8e6d2d1851..229ce34c63 100644 --- a/test/hooks/e2e.spec.ts +++ b/test/hooks/e2e.spec.ts @@ -21,7 +21,7 @@ const { beforeAll, beforeEach, describe } = test let payload: PayloadTestSDK -describe('hooks', () => { +describe('Hooks', () => { let url: AdminUrlUtil let page: Page let serverURL: string diff --git a/test/locked-documents/e2e.spec.ts b/test/locked-documents/e2e.spec.ts index c5b1d00f59..747c1cf7d5 100644 --- a/test/locked-documents/e2e.spec.ts +++ b/test/locked-documents/e2e.spec.ts @@ -36,7 +36,7 @@ let testsUrl: AdminUrlUtil let payload: PayloadTestSDK let serverURL: string -describe('locked documents', () => { +describe('Locked Documents', () => { beforeAll(async ({ browser }, testInfo) => { testInfo.setTimeout(TEST_TIMEOUT_LONG) ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) diff --git a/test/plugin-cloud-storage/e2e.spec.ts b/test/plugin-cloud-storage/e2e.spec.ts index ecae889a06..50049a28ed 100644 --- a/test/plugin-cloud-storage/e2e.spec.ts +++ b/test/plugin-cloud-storage/e2e.spec.ts @@ -13,7 +13,7 @@ import { mediaSlug } from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) -test.describe('Admin Panel', () => { +test.describe('Cloud Storage Plugin', () => { let page: Page let mediaURL: AdminUrlUtil diff --git a/test/plugin-form-builder/e2e.spec.ts b/test/plugin-form-builder/e2e.spec.ts index 9b3ca7af12..7479d1b7d7 100644 --- a/test/plugin-form-builder/e2e.spec.ts +++ b/test/plugin-form-builder/e2e.spec.ts @@ -15,7 +15,7 @@ import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) -test.describe('Form Builder', () => { +test.describe('Form Builder Plugin', () => { let page: Page let formsUrl: AdminUrlUtil let submissionsUrl: AdminUrlUtil diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 38bb88bc4b..a2f4d8339f 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -6,7 +6,7 @@ import { wait } from 'payload/shared' import { fileURLToPath } from 'url' import type { PayloadTestSDK } from '../helpers/sdk/index.js' -import type { Config, Media, Relation } from './payload-types.js' +import type { Config } from './payload-types.js' import { ensureCompilationIsDone, @@ -55,7 +55,7 @@ let withOnlyJPEGMetadataURL: AdminUrlUtil let relationPreviewURL: AdminUrlUtil let customFileNameURL: AdminUrlUtil -describe('uploads', () => { +describe('Uploads', () => { let page: Page beforeAll(async ({ browser }, testInfo) => { diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index ab9421a28f..b9a32c37a8 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -25,7 +25,6 @@ import type { BrowserContext, Page } from '@playwright/test' import { expect, test } from '@playwright/test' -import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js' import path from 'path' import { wait } from 'payload/shared' import { fileURLToPath } from 'url' @@ -41,7 +40,6 @@ import { initPageConsoleErrorCatch, saveDocAndAssert, selectTableRow, - throttleTest, } from '../helpers.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { trackNetworkRequests } from '../helpers/e2e/trackNetworkRequests.js' @@ -71,12 +69,9 @@ const dirname = path.dirname(filename) const { beforeAll, beforeEach, describe } = test let payload: PayloadTestSDK -let global: AdminUrlUtil -let id: string - let context: BrowserContext -describe('versions', () => { +describe('Versions', () => { let page: Page let url: AdminUrlUtil let serverURL: string @@ -84,7 +79,6 @@ describe('versions', () => { let disablePublishURL: AdminUrlUtil let customIDURL: AdminUrlUtil let postURL: AdminUrlUtil - let global: AdminUrlUtil let id: string beforeAll(async ({ browser }, testInfo) => { @@ -641,8 +635,8 @@ describe('versions', () => { describe('Collections - publish specific locale', () => { beforeAll(() => { url = new AdminUrlUtil(serverURL, localizedCollectionSlug) - global = new AdminUrlUtil(serverURL, localizedGlobalSlug) }) + test('should show publish individual locale dropdown', async () => { await page.goto(url.create) const publishOptions = page.locator('.doc-controls__controls .popup')