From 5c7647f45bc35fab2ffb5513f2b741d8e0526a3d Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 21 May 2024 17:11:55 -0400 Subject: [PATCH] ci: split up test suites (#6415) --- .github/workflows/main.yml | 12 +- test/admin/{ => e2e/1}/e2e.spec.ts | 727 +--------------- test/admin/e2e/2/e2e.spec.ts | 803 ++++++++++++++++++ test/fields/collections/Date/e2e.spec.ts | 187 ++++ .../Lexical/{ => e2e/blocks}/e2e.spec.ts | 374 +------- .../collections/Lexical/e2e/main/e2e.spec.ts | 444 ++++++++++ test/fields/collections/Number/e2e.spec.ts | 158 ++++ test/fields/collections/Point/e2e.spec.ts | 164 ++++ test/fields/collections/Tabs/e2e.spec.ts | 142 ++++ test/fields/collections/Text/e2e.spec.ts | 208 +++++ test/fields/collections/Upload/e2e.spec.ts | 176 ++++ test/fields/e2e.spec.ts | 663 +-------------- tsconfig.json | 2 +- 13 files changed, 2323 insertions(+), 1737 deletions(-) rename test/admin/{ => e2e/1}/e2e.spec.ts (53%) create mode 100644 test/admin/e2e/2/e2e.spec.ts create mode 100644 test/fields/collections/Date/e2e.spec.ts rename test/fields/collections/Lexical/{ => e2e/blocks}/e2e.spec.ts (70%) create mode 100644 test/fields/collections/Lexical/e2e/main/e2e.spec.ts create mode 100644 test/fields/collections/Number/e2e.spec.ts create mode 100644 test/fields/collections/Point/e2e.spec.ts create mode 100644 test/fields/collections/Tabs/e2e.spec.ts create mode 100644 test/fields/collections/Text/e2e.spec.ts create mode 100644 test/fields/collections/Upload/e2e.spec.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 02f6cb325f..5f3a881f72 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -289,7 +289,8 @@ jobs: suite: - _community - access-control - - admin + - admin__e2e__1 + - admin__e2e__2 - auth - field-error-states - fields-relationship @@ -298,7 +299,14 @@ jobs: - fields__collections__Array - fields__collections__Relationship - fields__collections__RichText - - fields__collections__Lexical + - fields__collections__Lexical__e2e__main + - fields__collections__Lexical__e2e__blocks + - fields__collections__Date + - fields__collections__Number + - fields__collections__Point + - fields__collections__Tabs + - fields__collections__Text + - fields__collections__Upload - live-preview - localization - i18n diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e/1/e2e.spec.ts similarity index 53% rename from test/admin/e2e.spec.ts rename to test/admin/e2e/1/e2e.spec.ts index 9c9ff43c24..a8070b0af9 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e/1/e2e.spec.ts @@ -2,11 +2,9 @@ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' import { wait } from 'payload/utilities' -import { mapAsync } from 'payload/utilities' -import qs from 'qs' -import type { Geo, Post } from './payload-types.js' -import type { Config } from './payload-types.js' +import type { Geo, Post } from '../../payload-types.js' +import type { Config } from '../../payload-types.js' import { checkBreadcrumb, @@ -16,13 +14,12 @@ import { getAdminRoutes, initPageConsoleErrorCatch, openDocControls, - openDocDrawer, openNav, saveDocAndAssert, saveDocHotkeyAndAssert, -} from '../helpers.js' -import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' -import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' import { customAdminRoutes, customEditLabel, @@ -36,7 +33,7 @@ import { customViewPath, customViewTitle, slugPluralLabel, -} from './shared.js' +} from '../../shared.js' import { customIdCollectionId, customViews2CollectionSlug, @@ -48,7 +45,7 @@ import { noApiViewCollectionSlug, noApiViewGlobalSlug, postsCollectionSlug, -} from './slugs.js' +} from '../../slugs.js' const { beforeAll, beforeEach, describe } = test @@ -60,14 +57,15 @@ let payload: PayloadTestSDK import path from 'path' import { fileURLToPath } from 'url' -import type { PayloadTestSDK } from '../helpers/sdk/index.js' +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' -import { reInitializeDB } from '../helpers/reInitializeDB.js' -import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.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 dirname = path.dirname(filename) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') -describe('admin', () => { +describe('admin1', () => { let page: Page let geoUrl: AdminUrlUtil let postsUrl: AdminUrlUtil @@ -97,7 +95,7 @@ describe('admin', () => { initPageConsoleErrorCatch(page) await reInitializeDB({ serverURL, - snapshotKey: 'adminTests', + snapshotKey: 'adminTests1', }) await ensureAutoLoginAndCompilationIsDone({ page, serverURL, customAdminRoutes }) @@ -109,7 +107,7 @@ describe('admin', () => { beforeEach(async () => { await reInitializeDB({ serverURL, - snapshotKey: 'adminTests', + snapshotKey: 'adminTests1', }) await ensureAutoLoginAndCompilationIsDone({ page, serverURL, customAdminRoutes }) @@ -834,701 +832,6 @@ describe('admin', () => { await expect(localeListItem1).toContainText('Spanish (es)') }) }) - - 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('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(`${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('should toggle columns', async () => { - const columnCountLocator = 'table > thead > tr > th' - await createPost() - - await page.locator('.list-controls__toggle-columns').click() - - // track the number of columns before manipulating toggling any - const numberOfColumns = await page.locator(columnCountLocator).count() - - // 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') - - const idButton = page.locator(`.column-selector .column-selector__column`, { - hasText: exactText('ID'), - }) - - // Remove ID column - await idButton.click() - - // wait until .cell-id is not present on the page: - await page.locator('.cell-id').waitFor({ state: 'detached' }) - - await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns - 1) - await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('Number') - - // Add back ID column - await idButton.click() - await expect(page.locator('.cell-id').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}`, - ) - - // open the column controls - await page.locator('.list-controls__toggle-columns').click() - // wait until the column toggle UI is visible and fully expanded - await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() - - // toggle off the ID column - await page - .locator('.column-selector .column-selector__column', { - hasText: exactText('ID'), - }) - .click() - - // wait until .cell-id is not present on the page: - await page.locator('.cell-id').waitFor({ state: 'detached' }) - - // recheck that the 2nd cell is still a link - await expect(linkCell).toHaveAttribute( - 'href', - `${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 page.locator('.list-controls__toggle-where').click() - // wait until the filter UI is visible and fully expanded - await expect(page.locator('.list-controls__where.rah-static--height-auto')).toBeVisible() - - await page.locator('.where-builder__add-first-filter').click() - - const operatorField = page.locator('.condition__operator') - const valueField = page.locator('.condition__value input') - - await operatorField.click() - - const dropdownOptions = operatorField.locator('.rs__option') - await dropdownOptions.locator('text=equals').click() - - await valueField.fill(id) - - await expect(page.locator(tableRowLocator)).toHaveCount(1) - const firstId = await page.locator(tableRowLocator).first().locator('.cell-id').innerText() - expect(firstId).toEqual(`ID: ${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 page.locator('.list-controls__toggle-where').click() - await page.waitForSelector('.list-controls__where.rah-static--height-auto') - 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 dropdownFieldOptions = filterField.locator('.rs__option') - await dropdownFieldOptions.locator('text=Number').click() - - // expect operator & value field to reset (be empty) - await expect(operatorField.locator('.rs__placeholder')).toContainText('Select a value') - await expect(valueField).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) - }) - }) - - describe('table columns', () => { - const reorderColumns = async () => { - // open the column controls - await page.locator('.list-controls__toggle-columns').click() - // wait until the column toggle UI is visible and fully expanded - await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() - - const numberBoundingBox = await page - .locator(`.column-selector .column-selector__column`, { - hasText: exactText('Number'), - }) - - .boundingBox() - - const idBoundingBox = await page - .locator(`.column-selector .column-selector__column`, { - hasText: exactText('ID'), - }) - .boundingBox() - - if (!numberBoundingBox || !idBoundingBox) return - - // drag the "number" column to the left of the "ID" column - await page.mouse.move(numberBoundingBox.x + 2, numberBoundingBox.y + 2, { steps: 10 }) - await page.mouse.down() - await wait(300) - - await page.mouse.move(idBoundingBox.x - 2, idBoundingBox.y - 2, { steps: 10 }) - await page.mouse.up() - - // ensure the "number" column is now first - await expect( - page.locator('.list-controls .column-selector .column-selector__column').first(), - ).toHaveText('Number') - await expect(page.locator('table thead tr th').nth(1)).toHaveText('Number') - - // TODO: This wait makes sure the preferences are actually saved. Just waiting for the UI to update is not enough. We should replace this wait - await wait(1000) - } - - test('should drag to reorder columns and save to preferences', async () => { - await createPost() - - await reorderColumns() - - // reload to ensure the preferred order was stored in the database - await page.reload() - await expect( - page.locator('.list-controls .column-selector .column-selector__column').first(), - ).toHaveText('Number') - await expect(page.locator('table thead tr th').nth(1)).toHaveText('Number') - }) - - test('should render drawer columns in order', async () => { - // Re-order columns like done in the previous test - await createPost() - await reorderColumns() - - await page.reload() - - await createPost() - await page.goto(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-drawer__select-collection.react-select', - ) - - // select the "Post" collection - await collectionSelector.click() - await page - .locator( - '[id^=list-drawer_1_] .list-drawer__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-drawer__select-collection.react-select', - ) - const columnSelector = page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns') - - // open the column controls - await columnSelector.click() - // wait until the column toggle UI is visible and fully expanded - await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() - - // deselect the "id" column - await page - .locator( - '[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', - { - hasText: exactText('ID'), - }, - ) - .click() - - // select the "Post" collection - await collectionSelector.click() - await page - .locator( - '[id^=list-drawer_1_] .list-drawer__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-drawer__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-drawer__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(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 pageInfo = page.locator('.collection-list__page-info') - const perPage = page.locator('.per-page') - const paginator = page.locator('.paginator') - const tableItems = page.locator(tableRowLocator) - - await expect(tableItems).toHaveCount(10) - await expect(pageInfo).toHaveText('1-10 of 11') - await expect(perPage).toContainText('Per Page: 10') - - // Forward one page and back using numbers - await paginator.locator('button').nth(1).click() - await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2') - await expect(tableItems).toHaveCount(1) - await paginator.locator('button').nth(0).click() - await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=1') - await expect(tableItems).toHaveCount(10) - }) - }) - - // 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') - }) - }) - - describe('i18n', () => { - test('should display translated collections and globals config options', async () => { - await page.goto(postsUrl.list) - - // collection label - await expect(page.locator('#nav-posts')).toContainText('Posts') - - // global label - await expect(page.locator('#nav-global-global')).toContainText('Global') - - // view description - await expect(page.locator('.view-description')).toContainText('Description') - }) - - 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 page.locator('.list-controls__toggle-where').click() - await page.locator('.where-builder__add-first-filter').click() - await page.locator('.condition__field .rs__control').click() - const options = page.locator('.rs__option') - await expect(options.locator('text=Title')).toHaveText('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('field descriptions', () => { - test('should render static field description', async () => { - await page.goto(postsUrl.create) - await expect(page.locator('.field-description-descriptionAsString')).toContainText( - 'Static field description.', - ) - }) - test('should render functional field description', async () => { - await page.goto(postsUrl.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(postsUrl.create) - await page.locator('#field-descriptionAsComponent').fill('component') - await expect(page.locator('.field-description-descriptionAsComponent')).toContainText( - 'Component description: descriptionAsComponent - component', - ) - }) - }) }) async function createPost(overrides?: Partial): Promise { diff --git a/test/admin/e2e/2/e2e.spec.ts b/test/admin/e2e/2/e2e.spec.ts new file mode 100644 index 0000000000..0d4cfc5da6 --- /dev/null +++ b/test/admin/e2e/2/e2e.spec.ts @@ -0,0 +1,803 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { wait } from 'payload/utilities' +import { mapAsync } from 'payload/utilities' +import qs from 'qs' + +import type { Geo, Post } from '../../payload-types.js' +import type { Config } from '../../payload-types.js' + +import { + ensureAutoLoginAndCompilationIsDone, + exactText, + getAdminRoutes, + initPageConsoleErrorCatch, + openDocDrawer, + openNav, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { customAdminRoutes } from '../../shared.js' +import { geoCollectionSlug, postsCollectionSlug } from '../../slugs.js' + +const { beforeAll, beforeEach, describe } = test + +const title = 'Title' +const description = 'Description' + +let payload: PayloadTestSDK + +import path from 'path' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.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 serverURL: string + let adminRoutes: ReturnType + + beforeAll(async ({ browser }, testInfo) => { + const prebuild = 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) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + await reInitializeDB({ + serverURL, + snapshotKey: 'adminTests2', + }) + + await ensureAutoLoginAndCompilationIsDone({ page, serverURL, customAdminRoutes }) + + adminRoutes = getAdminRoutes({ customAdminRoutes }) + }) + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'adminTests2', + }) + + await ensureAutoLoginAndCompilationIsDone({ page, serverURL, customAdminRoutes }) + }) + + 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('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(`${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('should toggle columns', async () => { + const columnCountLocator = 'table > thead > tr > th' + await createPost() + + await page.locator('.list-controls__toggle-columns').click() + + // track the number of columns before manipulating toggling any + const numberOfColumns = await page.locator(columnCountLocator).count() + + // 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') + + const idButton = page.locator(`.column-selector .column-selector__column`, { + hasText: exactText('ID'), + }) + + // Remove ID column + await idButton.click() + + // wait until .cell-id is not present on the page: + await page.locator('.cell-id').waitFor({ state: 'detached' }) + + await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns - 1) + await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('Number') + + // Add back ID column + await idButton.click() + await expect(page.locator('.cell-id').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}`, + ) + + // open the column controls + await page.locator('.list-controls__toggle-columns').click() + // wait until the column toggle UI is visible and fully expanded + await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() + + // toggle off the ID column + await page + .locator('.column-selector .column-selector__column', { + hasText: exactText('ID'), + }) + .click() + + // wait until .cell-id is not present on the page: + await page.locator('.cell-id').waitFor({ state: 'detached' }) + + // recheck that the 2nd cell is still a link + await expect(linkCell).toHaveAttribute( + 'href', + `${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 page.locator('.list-controls__toggle-where').click() + // wait until the filter UI is visible and fully expanded + await expect(page.locator('.list-controls__where.rah-static--height-auto')).toBeVisible() + + await page.locator('.where-builder__add-first-filter').click() + + const operatorField = page.locator('.condition__operator') + const valueField = page.locator('.condition__value input') + + await operatorField.click() + + const dropdownOptions = operatorField.locator('.rs__option') + await dropdownOptions.locator('text=equals').click() + + await valueField.fill(id) + + await expect(page.locator(tableRowLocator)).toHaveCount(1) + const firstId = await page.locator(tableRowLocator).first().locator('.cell-id').innerText() + expect(firstId).toEqual(`ID: ${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 page.locator('.list-controls__toggle-where').click() + await page.waitForSelector('.list-controls__where.rah-static--height-auto') + 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 dropdownFieldOptions = filterField.locator('.rs__option') + await dropdownFieldOptions.locator('text=Number').click() + + // expect operator & value field to reset (be empty) + await expect(operatorField.locator('.rs__placeholder')).toContainText('Select a value') + await expect(valueField).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) + }) + }) + + describe('table columns', () => { + const reorderColumns = async () => { + // open the column controls + await page.locator('.list-controls__toggle-columns').click() + // wait until the column toggle UI is visible and fully expanded + await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() + + const numberBoundingBox = await page + .locator(`.column-selector .column-selector__column`, { + hasText: exactText('Number'), + }) + + .boundingBox() + + const idBoundingBox = await page + .locator(`.column-selector .column-selector__column`, { + hasText: exactText('ID'), + }) + .boundingBox() + + if (!numberBoundingBox || !idBoundingBox) return + + // drag the "number" column to the left of the "ID" column + await page.mouse.move(numberBoundingBox.x + 2, numberBoundingBox.y + 2, { steps: 10 }) + await page.mouse.down() + await wait(300) + + await page.mouse.move(idBoundingBox.x - 2, idBoundingBox.y - 2, { steps: 10 }) + await page.mouse.up() + + // ensure the "number" column is now first + await expect( + page.locator('.list-controls .column-selector .column-selector__column').first(), + ).toHaveText('Number') + await expect(page.locator('table thead tr th').nth(1)).toHaveText('Number') + + // TODO: This wait makes sure the preferences are actually saved. Just waiting for the UI to update is not enough. We should replace this wait + await wait(1000) + } + + test('should drag to reorder columns and save to preferences', async () => { + await createPost() + + await reorderColumns() + + // reload to ensure the preferred order was stored in the database + await page.reload() + await expect( + page.locator('.list-controls .column-selector .column-selector__column').first(), + ).toHaveText('Number') + await expect(page.locator('table thead tr th').nth(1)).toHaveText('Number') + }) + + test('should render drawer columns in order', async () => { + // Re-order columns like done in the previous test + await createPost() + await reorderColumns() + + await page.reload() + + await createPost() + await page.goto(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-drawer__select-collection.react-select', + ) + + // select the "Post" collection + await collectionSelector.click() + await page + .locator( + '[id^=list-drawer_1_] .list-drawer__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-drawer__select-collection.react-select', + ) + const columnSelector = page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns') + + // open the column controls + await columnSelector.click() + // wait until the column toggle UI is visible and fully expanded + await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() + + // deselect the "id" column + await page + .locator( + '[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', + { + hasText: exactText('ID'), + }, + ) + .click() + + // select the "Post" collection + await collectionSelector.click() + await page + .locator( + '[id^=list-drawer_1_] .list-drawer__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-drawer__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-drawer__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(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 pageInfo = page.locator('.collection-list__page-info') + const perPage = page.locator('.per-page') + const paginator = page.locator('.paginator') + const tableItems = page.locator(tableRowLocator) + + await expect(tableItems).toHaveCount(10) + await expect(pageInfo).toHaveText('1-10 of 11') + await expect(perPage).toContainText('Per Page: 10') + + // Forward one page and back using numbers + await paginator.locator('button').nth(1).click() + await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2') + await expect(tableItems).toHaveCount(1) + await paginator.locator('button').nth(0).click() + await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=1') + await expect(tableItems).toHaveCount(10) + }) + }) + + // 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') + }) + }) + + describe('i18n', () => { + test('should display translated collections and globals config options', async () => { + await page.goto(postsUrl.list) + + // collection label + await expect(page.locator('#nav-posts')).toContainText('Posts') + + // global label + await expect(page.locator('#nav-global-global')).toContainText('Global') + + // view description + await expect(page.locator('.view-description')).toContainText('Description') + }) + + 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 page.locator('.list-controls__toggle-where').click() + await page.locator('.where-builder__add-first-filter').click() + await page.locator('.condition__field .rs__control').click() + const options = page.locator('.rs__option') + await expect(options.locator('text=Title')).toHaveText('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('field descriptions', () => { + test('should render static field description', async () => { + await page.goto(postsUrl.create) + await expect(page.locator('.field-description-descriptionAsString')).toContainText( + 'Static field description.', + ) + }) + test('should render functional field description', async () => { + await page.goto(postsUrl.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(postsUrl.create) + await page.locator('#field-descriptionAsComponent').fill('component') + await expect(page.locator('.field-description-descriptionAsComponent')).toContainText( + 'Component description: descriptionAsComponent - component', + ) + }) + }) +}) + +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/fields/collections/Date/e2e.spec.ts b/test/fields/collections/Date/e2e.spec.ts new file mode 100644 index 0000000000..0f8b223c94 --- /dev/null +++ b/test/fields/collections/Date/e2e.spec.ts @@ -0,0 +1,187 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import path from 'path' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' +import type { Config } from '../../payload-types.js' + +import { + ensureAutoLoginAndCompilationIsDone, + initPageConsoleErrorCatch, + saveDocAndAssert, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../helpers/reInitializeDB.js' +import { RESTClient } from '../../../helpers/rest.js' +import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js' +import { dateFieldsSlug } from '../../slugs.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +const { beforeAll, beforeEach, describe } = test + +let payload: PayloadTestSDK +let client: RESTClient +let page: Page +let serverURL: string +// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' }) +let url: AdminUrlUtil + +describe('Date', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ + dirname, + // prebuild, + })) + url = new AdminUrlUtil(serverURL, dateFieldsSlug) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsDateTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + }) + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsDateTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + + if (client) { + await client.logout() + } + client = new RESTClient(null, { defaultSlug: 'users', serverURL }) + await client.login() + + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + }) + + test('should display formatted date in list view table cell', async () => { + await page.goto(url.list) + const formattedDateCell = page.locator('.row-1 .cell-timeOnly') + await expect(formattedDateCell).toContainText(' Aug ') + + const notFormattedDateCell = page.locator('.row-1 .cell-default') + await expect(notFormattedDateCell).toContainText('August') + }) + + test('should display formatted date in useAsTitle', async () => { + await page.goto(url.list) + await page.locator('.row-1 .cell-default a').click() + await expect(page.locator('.doc-header__title.render-title')).toContainText('August') + }) + + test('should clear date', async () => { + await page.goto(url.create) + const dateField = page.locator('#field-default input') + await expect(dateField).toBeVisible() + await dateField.fill('02/07/2023') + await expect(dateField).toHaveValue('02/07/2023') + await saveDocAndAssert(page) + + const clearButton = page.locator('#field-default .date-time-picker__clear-button') + await expect(clearButton).toBeVisible() + await clearButton.click() + await expect(dateField).toHaveValue('') + }) + + describe('localized dates', () => { + describe('EST', () => { + test.use({ + geolocation: { + latitude: 42.3314, + longitude: -83.0458, + }, + timezoneId: 'America/Detroit', + }) + test('create EST day only date', async () => { + await page.goto(url.create) + await page.waitForURL(`**/${url.create}`) + const dateField = page.locator('#field-default input') + + // enter date in default date field + await dateField.fill('02/07/2023') + await saveDocAndAssert(page) + + // get the ID of the doc + const routeSegments = page.url().split('/') + const id = routeSegments.pop() + + // fetch the doc (need the date string from the DB) + const { doc } = await client.findByID({ id, auth: true, slug: 'date-fields' }) + + expect(doc.default).toEqual('2023-02-07T12:00:00.000Z') + }) + }) + + describe('PST', () => { + test.use({ + geolocation: { + latitude: 37.774929, + longitude: -122.419416, + }, + timezoneId: 'America/Los_Angeles', + }) + + test('create PDT day only date', async () => { + await page.goto(url.create) + await page.waitForURL(`**/${url.create}`) + const dateField = page.locator('#field-default input') + + // enter date in default date field + await dateField.fill('02/07/2023') + await saveDocAndAssert(page) + + // get the ID of the doc + const routeSegments = page.url().split('/') + const id = routeSegments.pop() + + // fetch the doc (need the date string from the DB) + const { doc } = await client.findByID({ id, auth: true, slug: 'date-fields' }) + + expect(doc.default).toEqual('2023-02-07T12:00:00.000Z') + }) + }) + + describe('ST', () => { + test.use({ + geolocation: { + latitude: -14.5994, + longitude: -171.857, + }, + timezoneId: 'Pacific/Apia', + }) + + test('create ST day only date', async () => { + await page.goto(url.create) + await page.waitForURL(`**/${url.create}`) + const dateField = page.locator('#field-default input') + + // enter date in default date field + await dateField.fill('02/07/2023') + await saveDocAndAssert(page) + + // get the ID of the doc + const routeSegments = page.url().split('/') + const id = routeSegments.pop() + + // fetch the doc (need the date string from the DB) + const { doc } = await client.findByID({ id, auth: true, slug: 'date-fields' }) + + expect(doc.default).toEqual('2023-02-07T12:00:00.000Z') + }) + }) + }) +}) diff --git a/test/fields/collections/Lexical/e2e.spec.ts b/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts similarity index 70% rename from test/fields/collections/Lexical/e2e.spec.ts rename to test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts index e347ddcfac..abae686bcf 100644 --- a/test/fields/collections/Lexical/e2e.spec.ts +++ b/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts @@ -1,32 +1,31 @@ import type { SerializedBlockNode, SerializedLinkNode } from '@payloadcms/richtext-lexical' import type { BrowserContext, Page } from '@playwright/test' -import type { PayloadTestSDK } from 'helpers/sdk/index.js' import type { SerializedEditorState, SerializedParagraphNode, SerializedTextNode } from 'lexical' import { expect, test } from '@playwright/test' -import { initPayloadE2ENoConfig } from 'helpers/initPayloadE2ENoConfig.js' -import { reInitializeDB } from 'helpers/reInitializeDB.js' import path from 'path' import { wait } from 'payload/utilities' import { fileURLToPath } from 'url' -import type { Config, LexicalField, Upload } from '../../payload-types.js' +import type { PayloadTestSDK } from '../../../../../helpers/sdk/index.js' +import type { Config, LexicalField, Upload } from '../../../../payload-types.js' import { ensureAutoLoginAndCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert, - throttleTest, -} from '../../../helpers.js' -import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' -import { RESTClient } from '../../../helpers/rest.js' -import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js' -import { lexicalFieldsSlug } from '../../slugs.js' -import { lexicalDocData } from './data.js' +} from '../../../../../helpers.js' +import { AdminUrlUtil } from '../../../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../../../helpers/reInitializeDB.js' +import { RESTClient } from '../../../../../helpers/rest.js' +import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../../../playwright.config.js' +import { lexicalFieldsSlug } from '../../../../slugs.js' +import { lexicalDocData } from '../../data.js' const filename = fileURLToPath(import.meta.url) const currentFolder = path.dirname(filename) -const dirname = path.resolve(currentFolder, '../../') +const dirname = path.resolve(currentFolder, '../../../../') const { beforeAll, beforeEach, describe } = test @@ -60,7 +59,7 @@ async function navigateToLexicalFields( await page.waitForURL(`**${linkDocHref}`) } -describe('lexical', () => { +describe('lexicalBlocks', () => { beforeAll(async ({ browser }, testInfo) => { testInfo.setTimeout(TEST_TIMEOUT_LONG) process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit @@ -72,7 +71,7 @@ describe('lexical', () => { initPageConsoleErrorCatch(page) await reInitializeDB({ serverURL, - snapshotKey: 'fieldsLexicalTest', + snapshotKey: 'fieldsLexicalBlocksTest', uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), }) await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) @@ -85,7 +84,7 @@ describe('lexical', () => { })*/ await reInitializeDB({ serverURL, - snapshotKey: 'fieldsLexicalTest', + snapshotKey: 'fieldsLexicalBlocksTest', uploadsDir: [ path.resolve(dirname, './collections/Upload/uploads'), path.resolve(dirname, './collections/Upload2/uploads2'), @@ -99,345 +98,6 @@ describe('lexical', () => { await client.login() }) - test('should not warn about unsaved changes when navigating to lexical editor with blocks node and then leaving the page without actually changing anything', async () => { - // This used to be an issue in the past, due to the node.setFields function in the blocks node being called unnecessarily when it's initialized after opening the document - // Other than the annoying unsaved changed prompt, this can also cause unnecessary auto-saves, when drafts & autosave is enabled - - await navigateToLexicalFields() - await expect( - page.locator('.rich-text-lexical').nth(1).locator('.lexical-block').first(), - ).toBeVisible() - - // Navigate to some different page, away from the current document - await page.locator('.app-header__step-nav').first().locator('a').first().click() - - // Make sure .leave-without-saving__content (the "Leave without saving") is not visible - await expect(page.locator('.leave-without-saving__content').first()).toBeHidden() - }) - - test('should not warn about unsaved changes when navigating to lexical editor with blocks node and then leaving the page after making a change and saving', async () => { - // Relevant issue: https://github.com/payloadcms/payload/issues/4115 - await navigateToLexicalFields() - const thirdBlock = page.locator('.rich-text-lexical').nth(1).locator('.lexical-block').nth(2) - await thirdBlock.scrollIntoViewIfNeeded() - await expect(thirdBlock).toBeVisible() - - const spanInBlock = thirdBlock - .locator('span') - .getByText('Some text below relationship node 1') - .first() - await spanInBlock.scrollIntoViewIfNeeded() - await expect(spanInBlock).toBeVisible() - - await spanInBlock.click() // Click works better than focus - - await page.keyboard.type('moretext') - const newSpanInBlock = thirdBlock - .locator('span') - .getByText('Some text below rmoretextelationship node 1') - .first() - await expect(newSpanInBlock).toBeVisible() - await expect(newSpanInBlock).toHaveText('Some text below rmoretextelationship node 1') - - // Save - await saveDocAndAssert(page) - await expect(newSpanInBlock).toHaveText('Some text below rmoretextelationship node 1') - - // Navigate to some different page, away from the current document - await page.locator('.app-header__step-nav').first().locator('a').first().click() - - // Make sure .leave-without-saving__content (the "Leave without saving") is not visible - await expect(page.locator('.leave-without-saving__content').first()).toBeHidden() - }) - - test('should type and save typed text', async () => { - await navigateToLexicalFields() - const richTextField = page.locator('.rich-text-lexical').nth(1) // second - await richTextField.scrollIntoViewIfNeeded() - await expect(richTextField).toBeVisible() - - const spanInEditor = richTextField.locator('span').getByText('Upload Node:').first() - await expect(spanInEditor).toBeVisible() - - await spanInEditor.click() // Click works better than focus - // Now go to the END of the span - for (let i = 0; i < 6; i++) { - await page.keyboard.press('ArrowRight') - } - - await page.keyboard.type('moretext') - await expect(spanInEditor).toHaveText('Upload Node:moretext') - - await saveDocAndAssert(page) - - await expect(async () => { - const lexicalDoc: LexicalField = ( - await payload.find({ - collection: lexicalFieldsSlug, - depth: 0, - overrideAccess: true, - where: { - title: { - equals: lexicalDocData.title, - }, - }, - }) - ).docs[0] as never - - const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks - const firstParagraphTextNode: SerializedTextNode = ( - lexicalField.root.children[0] as SerializedParagraphNode - ).children[0] as SerializedTextNode - - expect(firstParagraphTextNode.text).toBe('Upload Node:moretext') - }).toPass({ - timeout: POLL_TOPASS_TIMEOUT, - }) - }) - test('should be able to bold text using floating select toolbar', async () => { - await navigateToLexicalFields() - const richTextField = page.locator('.rich-text-lexical').nth(1) // second - await richTextField.scrollIntoViewIfNeeded() - await expect(richTextField).toBeVisible() - - const spanInEditor = richTextField.locator('span').getByText('Upload Node:').first() - await expect(spanInEditor).toBeVisible() - - await spanInEditor.click() // Click works better than focus - await page.keyboard.press('ArrowRight') - - // Now select the text 'Node' (the .click() makes it click in the middle of the span) - for (let i = 0; i < 4; i++) { - await page.keyboard.press('Shift+ArrowRight') - } - // The following text should now be selected: Node - - const floatingToolbar_formatSection = page.locator('.inline-toolbar-popup__group-format') - - await expect(floatingToolbar_formatSection).toBeVisible() - - await expect(page.locator('.toolbar-popup__button').first()).toBeVisible() - - const boldButton = floatingToolbar_formatSection.locator('.toolbar-popup__button').first() - - await expect(boldButton).toBeVisible() - await boldButton.click() - - /** - * Next test section: check if it worked correctly - */ - - const boldText = richTextField - .locator('.LexicalEditorTheme__paragraph') - .first() - .locator('strong') - await expect(boldText).toBeVisible() - await expect(boldText).toHaveText('Node') - - await saveDocAndAssert(page) - - await expect(async () => { - const lexicalDoc: LexicalField = ( - await payload.find({ - collection: lexicalFieldsSlug, - depth: 0, - overrideAccess: true, - where: { - title: { - equals: lexicalDocData.title, - }, - }, - }) - ).docs[0] as never - - const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks - const firstParagraph: SerializedParagraphNode = lexicalField.root - .children[0] as SerializedParagraphNode - expect(firstParagraph.children).toHaveLength(3) - - const textNode1: SerializedTextNode = firstParagraph.children[0] as SerializedTextNode - const boldNode: SerializedTextNode = firstParagraph.children[1] as SerializedTextNode - const textNode2: SerializedTextNode = firstParagraph.children[2] as SerializedTextNode - - expect(textNode1.text).toBe('Upload ') - expect(textNode1.format).toBe(0) - - expect(boldNode.text).toBe('Node') - expect(boldNode.format).toBe(1) - - expect(textNode2.text).toBe(':') - expect(textNode2.format).toBe(0) - }).toPass({ - timeout: POLL_TOPASS_TIMEOUT, - }) - }) - - test('Make sure highly specific issue does not occur when two richText fields share the same editor prop', async () => { - // Reproduces https://github.com/payloadcms/payload/issues/4282 - const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'tabsWithRichText') - await page.goto(url.global('tabsWithRichText')) - const richTextField = page.locator('.rich-text-lexical').first() - await richTextField.scrollIntoViewIfNeeded() - await expect(richTextField).toBeVisible() - await richTextField.click() // Use click, because focus does not work - await page.keyboard.type('some text') - - await page.locator('.tabs-field__tabs').first().getByText('en tab2').first().click() - await richTextField.scrollIntoViewIfNeeded() - await expect(richTextField).toBeVisible() - - const contentEditable = richTextField.locator('.ContentEditable__root').first() - - await expect - .poll(async () => await contentEditable.textContent(), { timeout: POLL_TOPASS_TIMEOUT }) - .not.toBe('some text') - await expect - .poll(async () => await contentEditable.textContent(), { timeout: POLL_TOPASS_TIMEOUT }) - .toBe('') - }) - - test('ensure blocks content is not hidden behind components outside of the editor', async () => { - // This test expects there to be a TreeView below the editor - - // This test makes sure there are no z-index issues here - await navigateToLexicalFields() - const richTextField = page.locator('.rich-text-lexical').first() - await richTextField.scrollIntoViewIfNeeded() - await expect(richTextField).toBeVisible() - - // Find span in contentEditable with text "Some text below relationship node" - const contentEditable = richTextField.locator('.ContentEditable__root').first() - await expect(contentEditable).toBeVisible() - await contentEditable.click() // Use click, because focus does not work - - await page.keyboard.press('/') - - const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup') - await expect(slashMenuPopover).toBeVisible() - - // Heading 2 should be the last, most bottom popover button element which should be initially visible, if not hidden by something (e.g. another block) - const popoverSelectButton = slashMenuPopover - .locator('button.slash-menu-popup__item-block-select') - .first() - await expect(popoverSelectButton).toBeVisible() - await popoverSelectButton.click() - - const newSelectBlock = richTextField.locator('.lexical-block').first() - await newSelectBlock.scrollIntoViewIfNeeded() - await expect(newSelectBlock).toBeVisible() - - await page.mouse.wheel(0, 300) // Scroll down so that the future react-select menu popover is displayed below and not above - - const reactSelect = newSelectBlock.locator('.rs__control').first() - await reactSelect.click() - - const popover = page.locator('.rs__menu').first() - const popoverOption3 = popover.locator('.rs__option').nth(2) - - await expect(async () => { - const popoverOption3BoundingBox = await popoverOption3.boundingBox() - expect(popoverOption3BoundingBox).not.toBeNull() - expect(popoverOption3BoundingBox).not.toBeUndefined() - expect(popoverOption3BoundingBox.height).toBeGreaterThan(0) - expect(popoverOption3BoundingBox.width).toBeGreaterThan(0) - - // Now click the button to see if it actually works. Simulate an actual mouse click instead of using .click() - // by using page.mouse and the correct coordinates - // .isVisible() and .click() might work fine EVEN if the slash menu is not actually visible by humans - // see: https://github.com/microsoft/playwright/issues/9923 - // This is why we use page.mouse.click() here. It's the most effective way of detecting such a z-index issue - // and usually the only method which works. - - const x = popoverOption3BoundingBox.x - const y = popoverOption3BoundingBox.y - - await page.mouse.click(x, y, { button: 'left' }) - }).toPass({ - timeout: POLL_TOPASS_TIMEOUT, - }) - - await expect(reactSelect.locator('.rs__value-container').first()).toHaveText('Option 3') - }) - - // This reproduces an issue where if you create an upload node, the document drawer opens, you select a collection other than the default one, create a NEW upload document and save, it throws a lexical error - test('ensure creation of new upload document within upload node works', async () => { - await navigateToLexicalFields() - const richTextField = page.locator('.rich-text-lexical').nth(1) // second - await richTextField.scrollIntoViewIfNeeded() - await expect(richTextField).toBeVisible() - - const lastParagraph = richTextField.locator('p').last() - await lastParagraph.scrollIntoViewIfNeeded() - await expect(lastParagraph).toBeVisible() - - /** - * Create new upload node - */ - // type / to open the slash menu - await lastParagraph.click() - await page.keyboard.press('/') - await page.keyboard.type('Upload') - - // Create Upload node - const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup') - await expect(slashMenuPopover).toBeVisible() - - const uploadSelectButton = slashMenuPopover.locator('button').nth(1) - await expect(uploadSelectButton).toBeVisible() - await expect(uploadSelectButton).toContainText('Upload') - await uploadSelectButton.click() - await expect(slashMenuPopover).toBeHidden() - - await wait(500) // wait for drawer form state to initialize (it's a flake) - const uploadListDrawer = page.locator('dialog[id^=list-drawer_1_]').first() // IDs starting with list-drawer_1_ (there's some other symbol after the underscore) - await expect(uploadListDrawer).toBeVisible() - await wait(500) - - await uploadListDrawer.locator('.rs__control .value-container').first().click() - await wait(500) - await expect(uploadListDrawer.locator('.rs__option').nth(1)).toBeVisible() - await expect(uploadListDrawer.locator('.rs__option').nth(1)).toContainText('Upload 2') - await uploadListDrawer.locator('.rs__option').nth(1).click() - - // wait till the text appears in uploadListDrawer: "No Uploads 2 found. Either no Uploads 2 exist yet or none match the filters you've specified above." - await expect( - uploadListDrawer.getByText( - "No Uploads 2 found. Either no Uploads 2 exist yet or none match the filters you've specified above.", - ), - ).toBeVisible() - - await uploadListDrawer.getByText('Create New').first().click() - const createUploadDrawer = page.locator('dialog[id^=doc-drawer_uploads2_]').first() // IDs starting with list-drawer_1_ (there's some other symbol after the underscore) - await expect(createUploadDrawer).toBeVisible() - await wait(500) - - const input = createUploadDrawer.locator('.file-field__upload input[type="file"]').first() - await expect(input).toBeAttached() - - await input.setInputFiles(path.resolve(dirname, './collections/Upload/payload.jpg')) - await expect(createUploadDrawer.locator('.file-field .file-field__filename')).toHaveValue( - 'payload.jpg', - ) - await wait(500) - await createUploadDrawer.getByText('Save').first().click() - await expect(createUploadDrawer).toBeHidden() - await expect(uploadListDrawer).toBeHidden() - await wait(500) - await saveDocAndAssert(page) - - // second one should be the newly created one - const secondUploadNode = richTextField.locator('.lexical-upload').nth(1) - await secondUploadNode.scrollIntoViewIfNeeded() - await expect(secondUploadNode).toBeVisible() - - await expect(secondUploadNode.locator('.lexical-upload__bottomRow')).toContainText( - 'payload.jpg', - ) - await expect(secondUploadNode.locator('.lexical-upload__collectionLabel')).toContainText( - 'Upload 2', - ) - }) - describe('nested lexical editor in block', () => { test('should type and save typed text', async () => { await navigateToLexicalFields() @@ -1248,10 +908,4 @@ describe('lexical', () => { await expect(requiredTooltip).toBeInViewport() // toBeVisible() doesn't work for some reason }) }) - - describe('localization', () => { - test.skip('ensure simple localized lexical field works', async () => { - await navigateToLexicalFields(true, true) - }) - }) }) diff --git a/test/fields/collections/Lexical/e2e/main/e2e.spec.ts b/test/fields/collections/Lexical/e2e/main/e2e.spec.ts new file mode 100644 index 0000000000..a8ee733526 --- /dev/null +++ b/test/fields/collections/Lexical/e2e/main/e2e.spec.ts @@ -0,0 +1,444 @@ +import type { BrowserContext, Page } from '@playwright/test' +import type { SerializedEditorState, SerializedParagraphNode, SerializedTextNode } from 'lexical' + +import { expect, test } from '@playwright/test' +import path from 'path' +import { wait } from 'payload/utilities' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../../../helpers/sdk/index.js' +import type { Config, LexicalField } from '../../../../payload-types.js' + +import { + ensureAutoLoginAndCompilationIsDone, + initPageConsoleErrorCatch, + saveDocAndAssert, +} from '../../../../../helpers.js' +import { AdminUrlUtil } from '../../../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../../../helpers/reInitializeDB.js' +import { RESTClient } from '../../../../../helpers/rest.js' +import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../../../playwright.config.js' +import { lexicalFieldsSlug } from '../../../../slugs.js' +import { lexicalDocData } from '../../data.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../../../') + +const { beforeAll, beforeEach, describe } = test + +let payload: PayloadTestSDK +let client: RESTClient +let page: Page +let context: BrowserContext +let serverURL: string + +/** + * Client-side navigation to the lexical editor from list view + */ +async function navigateToLexicalFields( + navigateToListView: boolean = true, + localized: boolean = false, +) { + if (navigateToListView) { + const url: AdminUrlUtil = new AdminUrlUtil( + serverURL, + localized ? 'lexical-localized-fields' : 'lexical-fields', + ) + await page.goto(url.list) + } + + const linkToDoc = page.locator('tbody tr:first-child .cell-title a').first() + await expect(() => expect(linkToDoc).toBeTruthy()).toPass({ timeout: POLL_TOPASS_TIMEOUT }) + const linkDocHref = await linkToDoc.getAttribute('href') + + await linkToDoc.click() + + await page.waitForURL(`**${linkDocHref}`) +} + +describe('lexicalMain', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) + + context = await browser.newContext() + page = await context.newPage() + + initPageConsoleErrorCatch(page) + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsLexicalMainTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + }) + beforeEach(async () => { + /*await throttleTest({ + page, + context, + delay: 'Slow 4G', + })*/ + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsLexicalMainTest', + uploadsDir: [ + path.resolve(dirname, './collections/Upload/uploads'), + path.resolve(dirname, './collections/Upload2/uploads2'), + ], + }) + + if (client) { + await client.logout() + } + client = new RESTClient(null, { defaultSlug: 'rich-text-fields', serverURL }) + await client.login() + }) + + test('should not warn about unsaved changes when navigating to lexical editor with blocks node and then leaving the page without actually changing anything', async () => { + // This used to be an issue in the past, due to the node.setFields function in the blocks node being called unnecessarily when it's initialized after opening the document + // Other than the annoying unsaved changed prompt, this can also cause unnecessary auto-saves, when drafts & autosave is enabled + + await navigateToLexicalFields() + await expect( + page.locator('.rich-text-lexical').nth(1).locator('.lexical-block').first(), + ).toBeVisible() + + // Navigate to some different page, away from the current document + await page.locator('.app-header__step-nav').first().locator('a').first().click() + + // Make sure .leave-without-saving__content (the "Leave without saving") is not visible + await expect(page.locator('.leave-without-saving__content').first()).toBeHidden() + }) + + test('should not warn about unsaved changes when navigating to lexical editor with blocks node and then leaving the page after making a change and saving', async () => { + // Relevant issue: https://github.com/payloadcms/payload/issues/4115 + await navigateToLexicalFields() + const thirdBlock = page.locator('.rich-text-lexical').nth(1).locator('.lexical-block').nth(2) + await thirdBlock.scrollIntoViewIfNeeded() + await expect(thirdBlock).toBeVisible() + + const spanInBlock = thirdBlock + .locator('span') + .getByText('Some text below relationship node 1') + .first() + await spanInBlock.scrollIntoViewIfNeeded() + await expect(spanInBlock).toBeVisible() + + await spanInBlock.click() // Click works better than focus + + await page.keyboard.type('moretext') + const newSpanInBlock = thirdBlock + .locator('span') + .getByText('Some text below rmoretextelationship node 1') + .first() + await expect(newSpanInBlock).toBeVisible() + await expect(newSpanInBlock).toHaveText('Some text below rmoretextelationship node 1') + + // Save + await saveDocAndAssert(page) + await expect(newSpanInBlock).toHaveText('Some text below rmoretextelationship node 1') + + // Navigate to some different page, away from the current document + await page.locator('.app-header__step-nav').first().locator('a').first().click() + + // Make sure .leave-without-saving__content (the "Leave without saving") is not visible + await expect(page.locator('.leave-without-saving__content').first()).toBeHidden() + }) + + test('should type and save typed text', async () => { + await navigateToLexicalFields() + const richTextField = page.locator('.rich-text-lexical').nth(1) // second + await richTextField.scrollIntoViewIfNeeded() + await expect(richTextField).toBeVisible() + + const spanInEditor = richTextField.locator('span').getByText('Upload Node:').first() + await expect(spanInEditor).toBeVisible() + + await spanInEditor.click() // Click works better than focus + // Now go to the END of the span + for (let i = 0; i < 6; i++) { + await page.keyboard.press('ArrowRight') + } + + await page.keyboard.type('moretext') + await expect(spanInEditor).toHaveText('Upload Node:moretext') + + await saveDocAndAssert(page) + + await expect(async () => { + const lexicalDoc: LexicalField = ( + await payload.find({ + collection: lexicalFieldsSlug, + depth: 0, + overrideAccess: true, + where: { + title: { + equals: lexicalDocData.title, + }, + }, + }) + ).docs[0] as never + + const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks + const firstParagraphTextNode: SerializedTextNode = ( + lexicalField.root.children[0] as SerializedParagraphNode + ).children[0] as SerializedTextNode + + expect(firstParagraphTextNode.text).toBe('Upload Node:moretext') + }).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) + }) + test('should be able to bold text using floating select toolbar', async () => { + await navigateToLexicalFields() + const richTextField = page.locator('.rich-text-lexical').nth(1) // second + await richTextField.scrollIntoViewIfNeeded() + await expect(richTextField).toBeVisible() + + const spanInEditor = richTextField.locator('span').getByText('Upload Node:').first() + await expect(spanInEditor).toBeVisible() + + await spanInEditor.click() // Click works better than focus + await page.keyboard.press('ArrowRight') + + // Now select the text 'Node' (the .click() makes it click in the middle of the span) + for (let i = 0; i < 4; i++) { + await page.keyboard.press('Shift+ArrowRight') + } + // The following text should now be selected: Node + + const floatingToolbar_formatSection = page.locator('.inline-toolbar-popup__group-format') + + await expect(floatingToolbar_formatSection).toBeVisible() + + await expect(page.locator('.toolbar-popup__button').first()).toBeVisible() + + const boldButton = floatingToolbar_formatSection.locator('.toolbar-popup__button').first() + + await expect(boldButton).toBeVisible() + await boldButton.click() + + /** + * Next test section: check if it worked correctly + */ + + const boldText = richTextField + .locator('.LexicalEditorTheme__paragraph') + .first() + .locator('strong') + await expect(boldText).toBeVisible() + await expect(boldText).toHaveText('Node') + + await saveDocAndAssert(page) + + await expect(async () => { + const lexicalDoc: LexicalField = ( + await payload.find({ + collection: lexicalFieldsSlug, + depth: 0, + overrideAccess: true, + where: { + title: { + equals: lexicalDocData.title, + }, + }, + }) + ).docs[0] as never + + const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks + const firstParagraph: SerializedParagraphNode = lexicalField.root + .children[0] as SerializedParagraphNode + expect(firstParagraph.children).toHaveLength(3) + + const textNode1: SerializedTextNode = firstParagraph.children[0] as SerializedTextNode + const boldNode: SerializedTextNode = firstParagraph.children[1] as SerializedTextNode + const textNode2: SerializedTextNode = firstParagraph.children[2] as SerializedTextNode + + expect(textNode1.text).toBe('Upload ') + expect(textNode1.format).toBe(0) + + expect(boldNode.text).toBe('Node') + expect(boldNode.format).toBe(1) + + expect(textNode2.text).toBe(':') + expect(textNode2.format).toBe(0) + }).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) + }) + + test('Make sure highly specific issue does not occur when two richText fields share the same editor prop', async () => { + // Reproduces https://github.com/payloadcms/payload/issues/4282 + const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'tabsWithRichText') + await page.goto(url.global('tabsWithRichText')) + const richTextField = page.locator('.rich-text-lexical').first() + await richTextField.scrollIntoViewIfNeeded() + await expect(richTextField).toBeVisible() + await richTextField.click() // Use click, because focus does not work + await page.keyboard.type('some text') + + await page.locator('.tabs-field__tabs').first().getByText('en tab2').first().click() + await richTextField.scrollIntoViewIfNeeded() + await expect(richTextField).toBeVisible() + + const contentEditable = richTextField.locator('.ContentEditable__root').first() + + await expect + .poll(async () => await contentEditable.textContent(), { timeout: POLL_TOPASS_TIMEOUT }) + .not.toBe('some text') + await expect + .poll(async () => await contentEditable.textContent(), { timeout: POLL_TOPASS_TIMEOUT }) + .toBe('') + }) + + test('ensure blocks content is not hidden behind components outside of the editor', async () => { + // This test expects there to be a TreeView below the editor + + // This test makes sure there are no z-index issues here + await navigateToLexicalFields() + const richTextField = page.locator('.rich-text-lexical').first() + await richTextField.scrollIntoViewIfNeeded() + await expect(richTextField).toBeVisible() + + // Find span in contentEditable with text "Some text below relationship node" + const contentEditable = richTextField.locator('.ContentEditable__root').first() + await expect(contentEditable).toBeVisible() + await contentEditable.click() // Use click, because focus does not work + + await page.keyboard.press('/') + + const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup') + await expect(slashMenuPopover).toBeVisible() + + // Heading 2 should be the last, most bottom popover button element which should be initially visible, if not hidden by something (e.g. another block) + const popoverSelectButton = slashMenuPopover + .locator('button.slash-menu-popup__item-block-select') + .first() + await expect(popoverSelectButton).toBeVisible() + await popoverSelectButton.click() + + const newSelectBlock = richTextField.locator('.lexical-block').first() + await newSelectBlock.scrollIntoViewIfNeeded() + await expect(newSelectBlock).toBeVisible() + + await page.mouse.wheel(0, 300) // Scroll down so that the future react-select menu popover is displayed below and not above + + const reactSelect = newSelectBlock.locator('.rs__control').first() + await reactSelect.click() + + const popover = page.locator('.rs__menu').first() + const popoverOption3 = popover.locator('.rs__option').nth(2) + + await expect(async () => { + const popoverOption3BoundingBox = await popoverOption3.boundingBox() + expect(popoverOption3BoundingBox).not.toBeNull() + expect(popoverOption3BoundingBox).not.toBeUndefined() + expect(popoverOption3BoundingBox.height).toBeGreaterThan(0) + expect(popoverOption3BoundingBox.width).toBeGreaterThan(0) + + // Now click the button to see if it actually works. Simulate an actual mouse click instead of using .click() + // by using page.mouse and the correct coordinates + // .isVisible() and .click() might work fine EVEN if the slash menu is not actually visible by humans + // see: https://github.com/microsoft/playwright/issues/9923 + // This is why we use page.mouse.click() here. It's the most effective way of detecting such a z-index issue + // and usually the only method which works. + + const x = popoverOption3BoundingBox.x + const y = popoverOption3BoundingBox.y + + await page.mouse.click(x, y, { button: 'left' }) + }).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) + + await expect(reactSelect.locator('.rs__value-container').first()).toHaveText('Option 3') + }) + + // This reproduces an issue where if you create an upload node, the document drawer opens, you select a collection other than the default one, create a NEW upload document and save, it throws a lexical error + test('ensure creation of new upload document within upload node works', async () => { + await navigateToLexicalFields() + const richTextField = page.locator('.rich-text-lexical').nth(1) // second + await richTextField.scrollIntoViewIfNeeded() + await expect(richTextField).toBeVisible() + + const lastParagraph = richTextField.locator('p').last() + await lastParagraph.scrollIntoViewIfNeeded() + await expect(lastParagraph).toBeVisible() + + /** + * Create new upload node + */ + // type / to open the slash menu + await lastParagraph.click() + await page.keyboard.press('/') + await page.keyboard.type('Upload') + + // Create Upload node + const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup') + await expect(slashMenuPopover).toBeVisible() + + const uploadSelectButton = slashMenuPopover.locator('button').nth(1) + await expect(uploadSelectButton).toBeVisible() + await expect(uploadSelectButton).toContainText('Upload') + await uploadSelectButton.click() + await expect(slashMenuPopover).toBeHidden() + + await wait(500) // wait for drawer form state to initialize (it's a flake) + const uploadListDrawer = page.locator('dialog[id^=list-drawer_1_]').first() // IDs starting with list-drawer_1_ (there's some other symbol after the underscore) + await expect(uploadListDrawer).toBeVisible() + await wait(500) + + await uploadListDrawer.locator('.rs__control .value-container').first().click() + await wait(500) + await expect(uploadListDrawer.locator('.rs__option').nth(1)).toBeVisible() + await expect(uploadListDrawer.locator('.rs__option').nth(1)).toContainText('Upload 2') + await uploadListDrawer.locator('.rs__option').nth(1).click() + + // wait till the text appears in uploadListDrawer: "No Uploads 2 found. Either no Uploads 2 exist yet or none match the filters you've specified above." + await expect( + uploadListDrawer.getByText( + "No Uploads 2 found. Either no Uploads 2 exist yet or none match the filters you've specified above.", + ), + ).toBeVisible() + + await uploadListDrawer.getByText('Create New').first().click() + const createUploadDrawer = page.locator('dialog[id^=doc-drawer_uploads2_]').first() // IDs starting with list-drawer_1_ (there's some other symbol after the underscore) + await expect(createUploadDrawer).toBeVisible() + await wait(500) + + const input = createUploadDrawer.locator('.file-field__upload input[type="file"]').first() + await expect(input).toBeAttached() + + await input.setInputFiles(path.resolve(dirname, './collections/Upload/payload.jpg')) + await expect(createUploadDrawer.locator('.file-field .file-field__filename')).toHaveValue( + 'payload.jpg', + ) + await wait(500) + await createUploadDrawer.getByText('Save').first().click() + await expect(createUploadDrawer).toBeHidden() + await expect(uploadListDrawer).toBeHidden() + await wait(500) + await saveDocAndAssert(page) + + // second one should be the newly created one + const secondUploadNode = richTextField.locator('.lexical-upload').nth(1) + await secondUploadNode.scrollIntoViewIfNeeded() + await expect(secondUploadNode).toBeVisible() + + await expect(secondUploadNode.locator('.lexical-upload__bottomRow')).toContainText( + 'payload.jpg', + ) + await expect(secondUploadNode.locator('.lexical-upload__collectionLabel')).toContainText( + 'Upload 2', + ) + }) + + describe('localization', () => { + test.skip('ensure simple localized lexical field works', async () => { + await navigateToLexicalFields(true, true) + }) + }) +}) diff --git a/test/fields/collections/Number/e2e.spec.ts b/test/fields/collections/Number/e2e.spec.ts new file mode 100644 index 0000000000..6f6156b1ed --- /dev/null +++ b/test/fields/collections/Number/e2e.spec.ts @@ -0,0 +1,158 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import path from 'path' +import { wait } from 'payload/utilities' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' +import type { Config } from '../../payload-types.js' + +import { + ensureAutoLoginAndCompilationIsDone, + initPageConsoleErrorCatch, + saveDocAndAssert, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../helpers/reInitializeDB.js' +import { RESTClient } from '../../../helpers/rest.js' +import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js' +import { numberDoc } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +const { beforeAll, beforeEach, describe } = test + +let payload: PayloadTestSDK +let client: RESTClient +let page: Page +let serverURL: string +// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' }) +let url: AdminUrlUtil + +describe('Number', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ + dirname, + // prebuild, + })) + url = new AdminUrlUtil(serverURL, 'number-fields') + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsNumberTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + }) + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsNumberTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + + if (client) { + await client.logout() + } + client = new RESTClient(null, { defaultSlug: 'users', serverURL }) + await client.login() + + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + }) + + test('should display field in list view', async () => { + await page.goto(url.list) + const textCell = page.locator('.row-1 .cell-number') + await expect(textCell).toHaveText(String(numberDoc.number)) + }) + + test('should filter Number fields in the collection view - greaterThanOrEqual', async () => { + await page.goto(url.list) + + // should have 3 entries + await expect(page.locator('table >> tbody >> tr')).toHaveCount(3) + + // open the filter options + await page.locator('.list-controls__toggle-where').click() + await expect(page.locator('.list-controls__where.rah-static--height-auto')).toBeVisible() + await page.locator('.where-builder__add-first-filter').click() + + const initialField = page.locator('.condition__field') + const operatorField = page.locator('.condition__operator') + const valueField = page.locator('.condition__value >> input') + + // select Number field to filter on + await initialField.click() + const initialFieldOptions = initialField.locator('.rs__option') + await initialFieldOptions.locator('text=number').first().click() + await expect(initialField.locator('.rs__single-value')).toContainText('Number') + + // select >= operator + await operatorField.click() + const operatorOptions = operatorField.locator('.rs__option') + await operatorOptions.last().click() + await expect(operatorField.locator('.rs__single-value')).toContainText( + 'is greater than or equal to', + ) + + // enter value of 3 + await valueField.fill('3') + await expect(valueField).toHaveValue('3') + await wait(300) + + // should have 2 entries after filtering + await expect(page.locator('table >> tbody >> tr')).toHaveCount(2) + }) + + test('should create', async () => { + const input = 5 + + await page.goto(url.create) + const field = page.locator('#field-number') + await field.fill(String(input)) + await saveDocAndAssert(page) + await expect(field).toHaveValue(String(input)) + }) + + test('should create hasMany', async () => { + const input = 5 + + await page.goto(url.create) + const field = page.locator('.field-hasMany') + await field.click() + await page.keyboard.type(String(input)) + await page.keyboard.press('Enter') + await saveDocAndAssert(page) + await expect(field.locator('.rs__value-container')).toContainText(String(input)) + }) + + test('should bypass min rows validation when no rows present and field is not required', async () => { + await page.goto(url.create) + await saveDocAndAssert(page) + await expect(page.locator('.Toastify')).toContainText('successfully') + }) + + test('should fail min rows validation when rows are present', async () => { + const input = 5 + + await page.goto(url.create) + await page.locator('.field-withMinRows').click() + + await page.keyboard.type(String(input)) + await page.keyboard.press('Enter') + await page.click('#action-save', { delay: 100 }) + + await expect(page.locator('.Toastify')).toContainText( + 'The following field is invalid: withMinRows', + ) + }) +}) diff --git a/test/fields/collections/Point/e2e.spec.ts b/test/fields/collections/Point/e2e.spec.ts new file mode 100644 index 0000000000..598ade5591 --- /dev/null +++ b/test/fields/collections/Point/e2e.spec.ts @@ -0,0 +1,164 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import path from 'path' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' +import type { Config } from '../../payload-types.js' + +import { + ensureAutoLoginAndCompilationIsDone, + initPageConsoleErrorCatch, + saveDocAndAssert, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../helpers/reInitializeDB.js' +import { RESTClient } from '../../../helpers/rest.js' +import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js' +import { pointFieldsSlug } from '../../slugs.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +const { beforeAll, beforeEach, describe } = test + +let payload: PayloadTestSDK +let client: RESTClient +let page: Page +let serverURL: string +// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' }) +let url: AdminUrlUtil +let filledGroupPoint +let emptyGroupPoint +describe('Point', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ + dirname, + // prebuild, + })) + url = new AdminUrlUtil(serverURL, pointFieldsSlug) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsPointTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + }) + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsPointTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + + if (client) { + await client.logout() + } + client = new RESTClient(null, { defaultSlug: 'users', serverURL }) + await client.login() + + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + + filledGroupPoint = await payload.create({ + collection: pointFieldsSlug, + data: { + group: { point: [4, 2] }, + localized: [4, 2], + point: [5, 5], + }, + }) + emptyGroupPoint = await payload.create({ + collection: pointFieldsSlug, + data: { + group: {}, + localized: [3, -2], + point: [5, 5], + }, + }) + }) + + test('should save point', async () => { + await page.goto(url.create) + const longField = page.locator('#field-longitude-point') + await longField.fill('9') + + const latField = page.locator('#field-latitude-point') + await latField.fill('-2') + + const localizedLongField = page.locator('#field-longitude-localized') + await localizedLongField.fill('1') + + const localizedLatField = page.locator('#field-latitude-localized') + await localizedLatField.fill('-1') + + const groupLongitude = page.locator('#field-longitude-group__point') + await groupLongitude.fill('3') + + const groupLatField = page.locator('#field-latitude-group__point') + await groupLatField.fill('-8') + + await saveDocAndAssert(page) + await expect(longField).toHaveAttribute('value', '9') + await expect(latField).toHaveAttribute('value', '-2') + await expect(localizedLongField).toHaveAttribute('value', '1') + await expect(localizedLatField).toHaveAttribute('value', '-1') + await expect(groupLongitude).toHaveAttribute('value', '3') + await expect(groupLatField).toHaveAttribute('value', '-8') + }) + + test('should update point', async () => { + await page.goto(url.edit(emptyGroupPoint.id)) + await page.waitForURL(`**/${emptyGroupPoint.id}`) + const longField = page.locator('#field-longitude-point') + await longField.fill('9') + + const latField = page.locator('#field-latitude-point') + await latField.fill('-2') + + const localizedLongField = page.locator('#field-longitude-localized') + await localizedLongField.fill('2') + + const localizedLatField = page.locator('#field-latitude-localized') + await localizedLatField.fill('-2') + + const groupLongitude = page.locator('#field-longitude-group__point') + await groupLongitude.fill('3') + + const groupLatField = page.locator('#field-latitude-group__point') + await groupLatField.fill('-8') + + await saveDocAndAssert(page) + + await expect(longField).toHaveAttribute('value', '9') + await expect(latField).toHaveAttribute('value', '-2') + await expect(localizedLongField).toHaveAttribute('value', '2') + await expect(localizedLatField).toHaveAttribute('value', '-2') + await expect(groupLongitude).toHaveAttribute('value', '3') + await expect(groupLatField).toHaveAttribute('value', '-8') + }) + + test('should be able to clear a value point', async () => { + await page.goto(url.edit(filledGroupPoint.id)) + await page.waitForURL(`**/${filledGroupPoint.id}`) + + const groupLongitude = page.locator('#field-longitude-group__point') + await groupLongitude.fill('') + + const groupLatField = page.locator('#field-latitude-group__point') + await groupLatField.fill('') + + await saveDocAndAssert(page) + + await expect(groupLongitude).toHaveAttribute('value', '') + await expect(groupLatField).toHaveAttribute('value', '') + }) +}) diff --git a/test/fields/collections/Tabs/e2e.spec.ts b/test/fields/collections/Tabs/e2e.spec.ts new file mode 100644 index 0000000000..8c85231ef2 --- /dev/null +++ b/test/fields/collections/Tabs/e2e.spec.ts @@ -0,0 +1,142 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import path from 'path' +import { wait } from 'payload/utilities' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' +import type { Config } from '../../payload-types.js' + +import { + ensureAutoLoginAndCompilationIsDone, + initPageConsoleErrorCatch, + navigateToListCellLink, + saveDocAndAssert, + switchTab, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../helpers/reInitializeDB.js' +import { RESTClient } from '../../../helpers/rest.js' +import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js' +import { tabsFieldsSlug } from '../../slugs.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +const { beforeAll, beforeEach, describe } = test + +let payload: PayloadTestSDK +let client: RESTClient +let page: Page +let serverURL: string +// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' }) +let url: AdminUrlUtil + +describe('Tabs', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ + dirname, + // prebuild, + })) + url = new AdminUrlUtil(serverURL, tabsFieldsSlug) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsTabsTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + }) + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsTabsTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + + if (client) { + await client.logout() + } + client = new RESTClient(null, { defaultSlug: 'users', serverURL }) + await client.login() + + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + }) + + test('should fill and retain a new value within a tab while switching tabs', async () => { + const textInRowValue = 'hello' + const numberInRowValue = '23' + const jsonValue = '{ "foo": "bar"}' + + await page.goto(url.create) + await page.waitForURL(url.create) + + await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Row")') + await page.locator('#field-textInRow').fill(textInRowValue) + await page.locator('#field-numberInRow').fill(numberInRowValue) + await page.locator('.json-field .inputarea').fill(jsonValue) + + await wait(300) + + await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Array")') + await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Row")') + + await expect(page.locator('#field-textInRow')).toHaveValue(textInRowValue) + await expect(page.locator('#field-numberInRow')).toHaveValue(numberInRowValue) + await expect(page.locator('.json-field .lines-content')).toContainText(jsonValue) + }) + + test('should retain updated values within tabs while switching between tabs', async () => { + const textInRowValue = 'new value' + const jsonValue = '{ "new": "value"}' + await page.goto(url.list) + await navigateToListCellLink(page) + + // Go to Row tab, update the value + await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Row")') + + await page.locator('#field-textInRow').fill(textInRowValue) + await page.locator('.json-field .inputarea').fill(jsonValue) + + await wait(500) + + // Go to Array tab, then back to Row. Make sure new value is still there + await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Array")') + await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Row")') + + await expect(page.locator('#field-textInRow')).toHaveValue(textInRowValue) + await expect(page.locator('.json-field .lines-content')).toContainText(jsonValue) + + // Go to array tab, save the doc + await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Array")') + await saveDocAndAssert(page) + + // Go back to row tab, make sure the new value is still present + await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Row")') + await expect(page.locator('#field-textInRow')).toHaveValue(textInRowValue) + }) + + test('should render array data within unnamed tabs', async () => { + await page.goto(url.list) + await navigateToListCellLink(page) + await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Array")') + await expect(page.locator('#field-array__0__text')).toHaveValue("Hello, I'm the first row") + }) + + test('should render array data within named tabs', async () => { + await page.goto(url.list) + await navigateToListCellLink(page) + await switchTab(page, '.tabs-field__tab-button:nth-child(5)') + await expect(page.locator('#field-tab__array__0__text')).toHaveValue( + "Hello, I'm the first row, in a named tab", + ) + }) +}) diff --git a/test/fields/collections/Text/e2e.spec.ts b/test/fields/collections/Text/e2e.spec.ts new file mode 100644 index 0000000000..dd52569ed2 --- /dev/null +++ b/test/fields/collections/Text/e2e.spec.ts @@ -0,0 +1,208 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import path from 'path' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' +import type { Config } from '../../payload-types.js' + +import { + ensureAutoLoginAndCompilationIsDone, + exactText, + initPageConsoleErrorCatch, + saveDocAndAssert, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../helpers/reInitializeDB.js' +import { RESTClient } from '../../../helpers/rest.js' +import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js' +import { textFieldsSlug } from '../../slugs.js' +import { textDoc } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +const { beforeAll, beforeEach, describe } = test + +let payload: PayloadTestSDK +let client: RESTClient +let page: Page +let serverURL: string +// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' }) +let url: AdminUrlUtil + +describe('Text', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ + dirname, + // prebuild, + })) + url = new AdminUrlUtil(serverURL, textFieldsSlug) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsTextTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + }) + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsTextTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + + if (client) { + await client.logout() + } + client = new RESTClient(null, { defaultSlug: 'users', serverURL }) + await client.login() + + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + }) + + test('should display field in list view', async () => { + await page.goto(url.list) + const textCell = page.locator('.row-1 .cell-text') + await expect(textCell).toHaveText(textDoc.text) + }) + + test('should hide field in column selector when admin.disableListColumn', async () => { + await page.goto(url.list) + await page.locator('.list-controls__toggle-columns').click() + + await expect(page.locator('.column-selector')).toBeVisible() + + // Check if "Disable List Column Text" is not present in the column options + await expect( + page.locator(`.column-selector .column-selector__column`, { + hasText: exactText('Disable List Column Text'), + }), + ).toBeHidden() + }) + + test('should show field in filter when admin.disableListColumn is true', async () => { + await page.goto(url.list) + await page.locator('.list-controls__toggle-where').click() + await page.locator('.where-builder__add-first-filter').click() + + const initialField = page.locator('.condition__field') + await initialField.click() + + await expect( + initialField.locator(`.rs__menu-list:has-text("Disable List Column Text")`), + ).toBeVisible() + }) + + test('should display field in list view column selector if admin.disableListColumn is false and admin.disableListFilter is true', async () => { + await page.goto(url.list) + await page.locator('.list-controls__toggle-columns').click() + + await expect(page.locator('.column-selector')).toBeVisible() + + // Check if "Disable List Filter Text" is present in the column options + await expect( + page.locator(`.column-selector .column-selector__column`, { + hasText: exactText('Disable List Filter Text'), + }), + ).toBeVisible() + }) + + test('should hide field in filter when admin.disableListFilter is true', async () => { + await page.goto(url.list) + await page.locator('.list-controls__toggle-where').click() + await page.locator('.where-builder__add-first-filter').click() + + const initialField = page.locator('.condition__field') + await initialField.click() + + await expect( + initialField.locator(`.rs__option :has-text("Disable List Filter Text")`), + ).toBeHidden() + }) + + test('should display i18n label in cells when missing field data', async () => { + await page.goto(url.list) + const textCell = page.locator('.row-1 .cell-i18nText') + await expect(textCell).toHaveText('') + }) + + test('should show i18n label', async () => { + await page.goto(url.create) + + await expect(page.locator('label[for="field-i18nText"]')).toHaveText('Text en') + }) + + test('should show i18n placeholder', async () => { + await page.goto(url.create) + await expect(page.locator('#field-i18nText')).toHaveAttribute('placeholder', 'en placeholder') + }) + + test('should show i18n descriptions', async () => { + await page.goto(url.create) + const description = page.locator('.field-description-i18nText') + await expect(description).toHaveText('en description') + }) + + test('should render custom label', async () => { + await page.goto(url.create) + const label = page.locator('label.custom-label[for="field-customLabel"]') + await expect(label).toHaveText('#label') + }) + + test('should render custom error', async () => { + await page.goto(url.create) + const input = page.locator('input[id="field-customError"]') + await input.fill('ab') + await expect(input).toHaveValue('ab') + const error = page.locator('.custom-error:near(input[id="field-customError"])') + 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(url.create) + const input = page.locator('input[id="field-beforeAndAfterInput"]') + + 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') + }) + + test('should create hasMany with multiple texts', async () => { + const input = 'five' + const furtherInput = 'six' + + await page.goto(url.create) + const requiredField = page.locator('#field-text') + const field = page.locator('.field-hasMany') + + await requiredField.fill(String(input)) + await field.click() + await page.keyboard.type(input) + await page.keyboard.press('Enter') + await page.keyboard.type(furtherInput) + await page.keyboard.press('Enter') + await saveDocAndAssert(page) + await expect(field.locator('.rs__value-container')).toContainText(input) + await expect(field.locator('.rs__value-container')).toContainText(furtherInput) + }) +}) diff --git a/test/fields/collections/Upload/e2e.spec.ts b/test/fields/collections/Upload/e2e.spec.ts new file mode 100644 index 0000000000..ebd4298468 --- /dev/null +++ b/test/fields/collections/Upload/e2e.spec.ts @@ -0,0 +1,176 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import path from 'path' +import { wait } from 'payload/utilities' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' +import type { Config } from '../../payload-types.js' + +import { + ensureAutoLoginAndCompilationIsDone, + initPageConsoleErrorCatch, + openDocDrawer, + saveDocAndAssert, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../helpers/reInitializeDB.js' +import { RESTClient } from '../../../helpers/rest.js' +import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js' +import { uploadsSlug } from '../../slugs.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +const { beforeAll, beforeEach, describe } = test + +let payload: PayloadTestSDK +let client: RESTClient +let page: Page +let serverURL: string +// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' }) +let url: AdminUrlUtil + +describe('Upload', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ + dirname, + // prebuild, + })) + url = new AdminUrlUtil(serverURL, uploadsSlug) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsUploadTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + }) + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsUploadTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + + if (client) { + await client.logout() + } + client = new RESTClient(null, { defaultSlug: 'users', serverURL }) + await client.login() + + await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) + }) + + async function uploadImage() { + await page.goto(url.create) + + // create a jpg upload + await page + .locator('.file-field__upload input[type="file"]') + .setInputFiles(path.resolve(dirname, './collections/Upload/payload.jpg')) + await expect(page.locator('.file-field .file-field__filename')).toHaveValue('payload.jpg') + await saveDocAndAssert(page) + } + + // eslint-disable-next-line playwright/expect-expect + test('should upload files', async () => { + await uploadImage() + }) + + // test that the image renders + test('should render uploaded image', async () => { + await uploadImage() + await expect(page.locator('.file-field .file-details img')).toHaveAttribute( + 'src', + '/api/uploads/file/payload-1.jpg', + ) + }) + + test('should upload using the document drawer', async () => { + await uploadImage() + await wait(1000) + // Open the media drawer and create a png upload + + await openDocDrawer(page, '.field-type.upload .upload__toggler.doc-drawer__toggler') + + await page + .locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]') + .setInputFiles(path.resolve(dirname, './uploads/payload.png')) + await expect( + page.locator('[id^=doc-drawer_uploads_1_] .file-field__upload .file-field__filename'), + ).toHaveValue('payload.png') + await page.locator('[id^=doc-drawer_uploads_1_] #action-save').click() + await expect(page.locator('.Toastify')).toContainText('successfully') + + // Assert that the media field has the png upload + await expect( + page.locator('.field-type.upload .file-details .file-meta__url a'), + ).toHaveAttribute('href', '/api/uploads/file/payload-1.png') + await expect(page.locator('.field-type.upload .file-details .file-meta__url a')).toContainText( + 'payload-1.png', + ) + await expect(page.locator('.field-type.upload .file-details img')).toHaveAttribute( + 'src', + '/api/uploads/file/payload-1.png', + ) + await saveDocAndAssert(page) + }) + + test('should clear selected upload', async () => { + await uploadImage() + await wait(1000) // TODO: Fix this. Need to wait a bit until the form in the drawer mounted, otherwise values sometimes disappear. This is an issue for all drawers + + await openDocDrawer(page, '.field-type.upload .upload__toggler.doc-drawer__toggler') + + await wait(1000) + + await page + .locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]') + .setInputFiles(path.resolve(dirname, './uploads/payload.png')) + await expect( + page.locator('[id^=doc-drawer_uploads_1_] .file-field__upload .file-field__filename'), + ).toHaveValue('payload.png') + await page.locator('[id^=doc-drawer_uploads_1_] #action-save').click() + await expect(page.locator('.Toastify')).toContainText('successfully') + await page.locator('.field-type.upload .file-details__remove').click() + }) + + test('should select using the list drawer and restrict mimetype based on filterOptions', async () => { + await uploadImage() + + await openDocDrawer(page, '.field-type.upload .upload__toggler.list-drawer__toggler') + + const jpgImages = page.locator('[id^=list-drawer_1_] .upload-gallery img[src$=".jpg"]') + await expect + .poll(async () => await jpgImages.count(), { timeout: POLL_TOPASS_TIMEOUT }) + .toEqual(0) + }) + + test.skip('should show drawer for input field when enableRichText is false', async () => { + const uploads3URL = new AdminUrlUtil(serverURL, 'uploads3') + await page.goto(uploads3URL.create) + + // create file in uploads 3 collection + await page + .locator('.file-field__upload input[type="file"]') + .setInputFiles(path.resolve(dirname, './collections/Upload/payload.jpg')) + await expect(page.locator('.file-field .file-field__filename')).toContainText('payload.jpg') + await page.locator('#action-save').click() + + await wait(200) + + // open drawer + await openDocDrawer(page, '.field-type.upload .list-drawer__toggler') + // check title + await expect(page.locator('.list-drawer__header-text')).toContainText('Uploads 3') + }) +}) diff --git a/test/fields/e2e.spec.ts b/test/fields/e2e.spec.ts index 46e08c8d8b..90d8df697d 100644 --- a/test/fields/e2e.spec.ts +++ b/test/fields/e2e.spec.ts @@ -10,12 +10,8 @@ import type { Config } from './payload-types.js' import { ensureAutoLoginAndCompilationIsDone, - exactText, initPageConsoleErrorCatch, - navigateToListCellLink, - openDocDrawer, saveDocAndAssert, - switchTab, } from '../helpers.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' @@ -23,16 +19,7 @@ import { reInitializeDB } from '../helpers/reInitializeDB.js' import { RESTClient } from '../helpers/rest.js' import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' import { jsonDoc } from './collections/JSON/shared.js' -import { numberDoc } from './collections/Number/shared.js' -import { textDoc } from './collections/Text/shared.js' -import { - arrayFieldsSlug, - blockFieldsSlug, - collapsibleFieldsSlug, - pointFieldsSlug, - tabsFieldsSlug, - textFieldsSlug, -} from './slugs.js' +import { arrayFieldsSlug, blockFieldsSlug, collapsibleFieldsSlug } from './slugs.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -78,242 +65,6 @@ describe('fields', () => { await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) }) - describe('text', () => { - let url: AdminUrlUtil - beforeAll(() => { - url = new AdminUrlUtil(serverURL, textFieldsSlug) - }) - - test('should display field in list view', async () => { - await page.goto(url.list) - const textCell = page.locator('.row-1 .cell-text') - await expect(textCell).toHaveText(textDoc.text) - }) - - test('should hide field in column selector when admin.disableListColumn', async () => { - await page.goto(url.list) - await page.locator('.list-controls__toggle-columns').click() - - await expect(page.locator('.column-selector')).toBeVisible() - - // Check if "Disable List Column Text" is not present in the column options - await expect( - page.locator(`.column-selector .column-selector__column`, { - hasText: exactText('Disable List Column Text'), - }), - ).toBeHidden() - }) - - test('should show field in filter when admin.disableListColumn is true', async () => { - await page.goto(url.list) - await page.locator('.list-controls__toggle-where').click() - await page.locator('.where-builder__add-first-filter').click() - - const initialField = page.locator('.condition__field') - await initialField.click() - - await expect( - initialField.locator(`.rs__menu-list:has-text("Disable List Column Text")`), - ).toBeVisible() - }) - - test('should display field in list view column selector if admin.disableListColumn is false and admin.disableListFilter is true', async () => { - await page.goto(url.list) - await page.locator('.list-controls__toggle-columns').click() - - await expect(page.locator('.column-selector')).toBeVisible() - - // Check if "Disable List Filter Text" is present in the column options - await expect( - page.locator(`.column-selector .column-selector__column`, { - hasText: exactText('Disable List Filter Text'), - }), - ).toBeVisible() - }) - - test('should hide field in filter when admin.disableListFilter is true', async () => { - await page.goto(url.list) - await page.locator('.list-controls__toggle-where').click() - await page.locator('.where-builder__add-first-filter').click() - - const initialField = page.locator('.condition__field') - await initialField.click() - - await expect( - initialField.locator(`.rs__option :has-text("Disable List Filter Text")`), - ).toBeHidden() - }) - - test('should display i18n label in cells when missing field data', async () => { - await page.goto(url.list) - const textCell = page.locator('.row-1 .cell-i18nText') - await expect(textCell).toHaveText('') - }) - - test('should show i18n label', async () => { - await page.goto(url.create) - - await expect(page.locator('label[for="field-i18nText"]')).toHaveText('Text en') - }) - - test('should show i18n placeholder', async () => { - await page.goto(url.create) - await expect(page.locator('#field-i18nText')).toHaveAttribute('placeholder', 'en placeholder') - }) - - test('should show i18n descriptions', async () => { - await page.goto(url.create) - const description = page.locator('.field-description-i18nText') - await expect(description).toHaveText('en description') - }) - - test('should render custom label', async () => { - await page.goto(url.create) - const label = page.locator('label.custom-label[for="field-customLabel"]') - await expect(label).toHaveText('#label') - }) - - test('should render custom error', async () => { - await page.goto(url.create) - const input = page.locator('input[id="field-customError"]') - await input.fill('ab') - await expect(input).toHaveValue('ab') - const error = page.locator('.custom-error:near(input[id="field-customError"])') - 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(url.create) - const input = page.locator('input[id="field-beforeAndAfterInput"]') - - 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') - }) - - test('should create hasMany with multiple texts', async () => { - const input = 'five' - const furtherInput = 'six' - - await page.goto(url.create) - const requiredField = page.locator('#field-text') - const field = page.locator('.field-hasMany') - - await requiredField.fill(String(input)) - await field.click() - await page.keyboard.type(input) - await page.keyboard.press('Enter') - await page.keyboard.type(furtherInput) - await page.keyboard.press('Enter') - await saveDocAndAssert(page) - await expect(field.locator('.rs__value-container')).toContainText(input) - await expect(field.locator('.rs__value-container')).toContainText(furtherInput) - }) - }) - - describe('number', () => { - let url: AdminUrlUtil - beforeAll(() => { - url = new AdminUrlUtil(serverURL, 'number-fields') - }) - - test('should display field in list view', async () => { - await page.goto(url.list) - const textCell = page.locator('.row-1 .cell-number') - await expect(textCell).toHaveText(String(numberDoc.number)) - }) - - test('should filter Number fields in the collection view - greaterThanOrEqual', async () => { - await page.goto(url.list) - - // should have 3 entries - await expect(page.locator('table >> tbody >> tr')).toHaveCount(3) - - // open the filter options - await page.locator('.list-controls__toggle-where').click() - await expect(page.locator('.list-controls__where.rah-static--height-auto')).toBeVisible() - await page.locator('.where-builder__add-first-filter').click() - - const initialField = page.locator('.condition__field') - const operatorField = page.locator('.condition__operator') - const valueField = page.locator('.condition__value >> input') - - // select Number field to filter on - await initialField.click() - const initialFieldOptions = initialField.locator('.rs__option') - await initialFieldOptions.locator('text=number').first().click() - await expect(initialField.locator('.rs__single-value')).toContainText('Number') - - // select >= operator - await operatorField.click() - const operatorOptions = operatorField.locator('.rs__option') - await operatorOptions.last().click() - await expect(operatorField.locator('.rs__single-value')).toContainText( - 'is greater than or equal to', - ) - - // enter value of 3 - await valueField.fill('3') - await expect(valueField).toHaveValue('3') - await wait(300) - - // should have 2 entries after filtering - await expect(page.locator('table >> tbody >> tr')).toHaveCount(2) - }) - - test('should create', async () => { - const input = 5 - - await page.goto(url.create) - const field = page.locator('#field-number') - await field.fill(String(input)) - await saveDocAndAssert(page) - await expect(field).toHaveValue(String(input)) - }) - - test('should create hasMany', async () => { - const input = 5 - - await page.goto(url.create) - const field = page.locator('.field-hasMany') - await field.click() - await page.keyboard.type(String(input)) - await page.keyboard.press('Enter') - await saveDocAndAssert(page) - await expect(field.locator('.rs__value-container')).toContainText(String(input)) - }) - - test('should bypass min rows validation when no rows present and field is not required', async () => { - await page.goto(url.create) - await saveDocAndAssert(page) - await expect(page.locator('.Toastify')).toContainText('successfully') - }) - - test('should fail min rows validation when rows are present', async () => { - const input = 5 - - await page.goto(url.create) - await page.locator('.field-withMinRows').click() - - await page.keyboard.type(String(input)) - await page.keyboard.press('Enter') - await page.click('#action-save', { delay: 100 }) - - await expect(page.locator('.Toastify')).toContainText( - 'The following field is invalid: withMinRows', - ) - }) - }) describe('indexed', () => { let url: AdminUrlUtil @@ -449,107 +200,6 @@ describe('fields', () => { }) }) - describe('point', () => { - let url: AdminUrlUtil - let filledGroupPoint - let emptyGroupPoint - beforeEach(async () => { - url = new AdminUrlUtil(serverURL, pointFieldsSlug) - filledGroupPoint = await payload.create({ - collection: pointFieldsSlug, - data: { - group: { point: [4, 2] }, - localized: [4, 2], - point: [5, 5], - }, - }) - emptyGroupPoint = await payload.create({ - collection: pointFieldsSlug, - data: { - group: {}, - localized: [3, -2], - point: [5, 5], - }, - }) - }) - - test('should save point', async () => { - await page.goto(url.create) - const longField = page.locator('#field-longitude-point') - await longField.fill('9') - - const latField = page.locator('#field-latitude-point') - await latField.fill('-2') - - const localizedLongField = page.locator('#field-longitude-localized') - await localizedLongField.fill('1') - - const localizedLatField = page.locator('#field-latitude-localized') - await localizedLatField.fill('-1') - - const groupLongitude = page.locator('#field-longitude-group__point') - await groupLongitude.fill('3') - - const groupLatField = page.locator('#field-latitude-group__point') - await groupLatField.fill('-8') - - await saveDocAndAssert(page) - await expect(longField).toHaveAttribute('value', '9') - await expect(latField).toHaveAttribute('value', '-2') - await expect(localizedLongField).toHaveAttribute('value', '1') - await expect(localizedLatField).toHaveAttribute('value', '-1') - await expect(groupLongitude).toHaveAttribute('value', '3') - await expect(groupLatField).toHaveAttribute('value', '-8') - }) - - test('should update point', async () => { - await page.goto(url.edit(emptyGroupPoint.id)) - await page.waitForURL(`**/${emptyGroupPoint.id}`) - const longField = page.locator('#field-longitude-point') - await longField.fill('9') - - const latField = page.locator('#field-latitude-point') - await latField.fill('-2') - - const localizedLongField = page.locator('#field-longitude-localized') - await localizedLongField.fill('2') - - const localizedLatField = page.locator('#field-latitude-localized') - await localizedLatField.fill('-2') - - const groupLongitude = page.locator('#field-longitude-group__point') - await groupLongitude.fill('3') - - const groupLatField = page.locator('#field-latitude-group__point') - await groupLatField.fill('-8') - - await saveDocAndAssert(page) - - await expect(longField).toHaveAttribute('value', '9') - await expect(latField).toHaveAttribute('value', '-2') - await expect(localizedLongField).toHaveAttribute('value', '2') - await expect(localizedLatField).toHaveAttribute('value', '-2') - await expect(groupLongitude).toHaveAttribute('value', '3') - await expect(groupLatField).toHaveAttribute('value', '-8') - }) - - test('should be able to clear a value point', async () => { - await page.goto(url.edit(filledGroupPoint.id)) - await page.waitForURL(`**/${filledGroupPoint.id}`) - - const groupLongitude = page.locator('#field-longitude-group__point') - await groupLongitude.fill('') - - const groupLatField = page.locator('#field-latitude-group__point') - await groupLatField.fill('') - - await saveDocAndAssert(page) - - await expect(groupLongitude).toHaveAttribute('value', '') - await expect(groupLatField).toHaveAttribute('value', '') - }) - }) - describe('collapsible', () => { let url: AdminUrlUtil beforeAll(() => { @@ -636,317 +286,6 @@ describe('fields', () => { }) }) - describe('tabs', () => { - let url: AdminUrlUtil - beforeAll(() => { - url = new AdminUrlUtil(serverURL, tabsFieldsSlug) - }) - - test('should fill and retain a new value within a tab while switching tabs', async () => { - const textInRowValue = 'hello' - const numberInRowValue = '23' - const jsonValue = '{ "foo": "bar"}' - - await page.goto(url.create) - await page.waitForURL(url.create) - - await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Row")') - await page.locator('#field-textInRow').fill(textInRowValue) - await page.locator('#field-numberInRow').fill(numberInRowValue) - await page.locator('.json-field .inputarea').fill(jsonValue) - - await wait(300) - - await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Array")') - await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Row")') - - await expect(page.locator('#field-textInRow')).toHaveValue(textInRowValue) - await expect(page.locator('#field-numberInRow')).toHaveValue(numberInRowValue) - await expect(page.locator('.json-field .lines-content')).toContainText(jsonValue) - }) - - test('should retain updated values within tabs while switching between tabs', async () => { - const textInRowValue = 'new value' - const jsonValue = '{ "new": "value"}' - await page.goto(url.list) - await navigateToListCellLink(page) - - // Go to Row tab, update the value - await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Row")') - - await page.locator('#field-textInRow').fill(textInRowValue) - await page.locator('.json-field .inputarea').fill(jsonValue) - - await wait(500) - - // Go to Array tab, then back to Row. Make sure new value is still there - await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Array")') - await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Row")') - - await expect(page.locator('#field-textInRow')).toHaveValue(textInRowValue) - await expect(page.locator('.json-field .lines-content')).toContainText(jsonValue) - - // Go to array tab, save the doc - await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Array")') - await saveDocAndAssert(page) - - // Go back to row tab, make sure the new value is still present - await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Row")') - await expect(page.locator('#field-textInRow')).toHaveValue(textInRowValue) - }) - - test('should render array data within unnamed tabs', async () => { - await page.goto(url.list) - await navigateToListCellLink(page) - await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Array")') - await expect(page.locator('#field-array__0__text')).toHaveValue("Hello, I'm the first row") - }) - - test('should render array data within named tabs', async () => { - await page.goto(url.list) - await navigateToListCellLink(page) - await switchTab(page, '.tabs-field__tab-button:nth-child(5)') - await expect(page.locator('#field-tab__array__0__text')).toHaveValue( - "Hello, I'm the first row, in a named tab", - ) - }) - }) - - describe('date', () => { - let url: AdminUrlUtil - beforeAll(() => { - url = new AdminUrlUtil(serverURL, 'date-fields') - }) - - test('should display formatted date in list view table cell', async () => { - await page.goto(url.list) - const formattedDateCell = page.locator('.row-1 .cell-timeOnly') - await expect(formattedDateCell).toContainText(' Aug ') - - const notFormattedDateCell = page.locator('.row-1 .cell-default') - await expect(notFormattedDateCell).toContainText('August') - }) - - test('should display formatted date in useAsTitle', async () => { - await page.goto(url.list) - await page.locator('.row-1 .cell-default a').click() - await expect(page.locator('.doc-header__title.render-title')).toContainText('August') - }) - - test('should clear date', async () => { - await page.goto(url.create) - const dateField = page.locator('#field-default input') - await expect(dateField).toBeVisible() - await dateField.fill('02/07/2023') - await expect(dateField).toHaveValue('02/07/2023') - await saveDocAndAssert(page) - - const clearButton = page.locator('#field-default .date-time-picker__clear-button') - await expect(clearButton).toBeVisible() - await clearButton.click() - await expect(dateField).toHaveValue('') - }) - - describe('localized dates', () => { - describe('EST', () => { - test.use({ - geolocation: { - latitude: 42.3314, - longitude: -83.0458, - }, - timezoneId: 'America/Detroit', - }) - test('create EST day only date', async () => { - await page.goto(url.create) - await page.waitForURL(`**/${url.create}`) - const dateField = page.locator('#field-default input') - - // enter date in default date field - await dateField.fill('02/07/2023') - await saveDocAndAssert(page) - - // get the ID of the doc - const routeSegments = page.url().split('/') - const id = routeSegments.pop() - - // fetch the doc (need the date string from the DB) - const { doc } = await client.findByID({ id, auth: true, slug: 'date-fields' }) - - expect(doc.default).toEqual('2023-02-07T12:00:00.000Z') - }) - }) - - describe('PST', () => { - test.use({ - geolocation: { - latitude: 37.774929, - longitude: -122.419416, - }, - timezoneId: 'America/Los_Angeles', - }) - - test('create PDT day only date', async () => { - await page.goto(url.create) - await page.waitForURL(`**/${url.create}`) - const dateField = page.locator('#field-default input') - - // enter date in default date field - await dateField.fill('02/07/2023') - await saveDocAndAssert(page) - - // get the ID of the doc - const routeSegments = page.url().split('/') - const id = routeSegments.pop() - - // fetch the doc (need the date string from the DB) - const { doc } = await client.findByID({ id, auth: true, slug: 'date-fields' }) - - expect(doc.default).toEqual('2023-02-07T12:00:00.000Z') - }) - }) - - describe('ST', () => { - test.use({ - geolocation: { - latitude: -14.5994, - longitude: -171.857, - }, - timezoneId: 'Pacific/Apia', - }) - - test('create ST day only date', async () => { - await page.goto(url.create) - await page.waitForURL(`**/${url.create}`) - const dateField = page.locator('#field-default input') - - // enter date in default date field - await dateField.fill('02/07/2023') - await saveDocAndAssert(page) - - // get the ID of the doc - const routeSegments = page.url().split('/') - const id = routeSegments.pop() - - // fetch the doc (need the date string from the DB) - const { doc } = await client.findByID({ id, auth: true, slug: 'date-fields' }) - - expect(doc.default).toEqual('2023-02-07T12:00:00.000Z') - }) - }) - }) - }) - - describe('upload', () => { - let url: AdminUrlUtil - beforeAll(() => { - url = new AdminUrlUtil(serverURL, 'uploads') - }) - - async function uploadImage() { - await page.goto(url.create) - - // create a jpg upload - await page - .locator('.file-field__upload input[type="file"]') - .setInputFiles(path.resolve(dirname, './collections/Upload/payload.jpg')) - await expect(page.locator('.file-field .file-field__filename')).toHaveValue('payload.jpg') - await saveDocAndAssert(page) - } - - // eslint-disable-next-line playwright/expect-expect - test('should upload files', async () => { - await uploadImage() - }) - - // test that the image renders - test('should render uploaded image', async () => { - await uploadImage() - await expect(page.locator('.file-field .file-details img')).toHaveAttribute( - 'src', - '/api/uploads/file/payload-1.jpg', - ) - }) - - test('should upload using the document drawer', async () => { - await uploadImage() - await wait(1000) - // Open the media drawer and create a png upload - - await openDocDrawer(page, '.field-type.upload .upload__toggler.doc-drawer__toggler') - - await page - .locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]') - .setInputFiles(path.resolve(dirname, './uploads/payload.png')) - await expect( - page.locator('[id^=doc-drawer_uploads_1_] .file-field__upload .file-field__filename'), - ).toHaveValue('payload.png') - await page.locator('[id^=doc-drawer_uploads_1_] #action-save').click() - await expect(page.locator('.Toastify')).toContainText('successfully') - - // Assert that the media field has the png upload - await expect( - page.locator('.field-type.upload .file-details .file-meta__url a'), - ).toHaveAttribute('href', '/api/uploads/file/payload-1.png') - await expect( - page.locator('.field-type.upload .file-details .file-meta__url a'), - ).toContainText('payload-1.png') - await expect(page.locator('.field-type.upload .file-details img')).toHaveAttribute( - 'src', - '/api/uploads/file/payload-1.png', - ) - await saveDocAndAssert(page) - }) - - test('should clear selected upload', async () => { - await uploadImage() - await wait(1000) // TODO: Fix this. Need to wait a bit until the form in the drawer mounted, otherwise values sometimes disappear. This is an issue for all drawers - - await openDocDrawer(page, '.field-type.upload .upload__toggler.doc-drawer__toggler') - - await wait(1000) - - await page - .locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]') - .setInputFiles(path.resolve(dirname, './uploads/payload.png')) - await expect( - page.locator('[id^=doc-drawer_uploads_1_] .file-field__upload .file-field__filename'), - ).toHaveValue('payload.png') - await page.locator('[id^=doc-drawer_uploads_1_] #action-save').click() - await expect(page.locator('.Toastify')).toContainText('successfully') - await page.locator('.field-type.upload .file-details__remove').click() - }) - - test('should select using the list drawer and restrict mimetype based on filterOptions', async () => { - await uploadImage() - - await openDocDrawer(page, '.field-type.upload .upload__toggler.list-drawer__toggler') - - const jpgImages = page.locator('[id^=list-drawer_1_] .upload-gallery img[src$=".jpg"]') - await expect - .poll(async () => await jpgImages.count(), { timeout: POLL_TOPASS_TIMEOUT }) - .toEqual(0) - }) - - test.skip('should show drawer for input field when enableRichText is false', async () => { - const uploads3URL = new AdminUrlUtil(serverURL, 'uploads3') - await page.goto(uploads3URL.create) - - // create file in uploads 3 collection - await page - .locator('.file-field__upload input[type="file"]') - .setInputFiles(path.resolve(dirname, './collections/Upload/payload.jpg')) - await expect(page.locator('.file-field .file-field__filename')).toContainText('payload.jpg') - await page.locator('#action-save').click() - - await wait(200) - - // open drawer - await openDocDrawer(page, '.field-type.upload .list-drawer__toggler') - // check title - await expect(page.locator('.list-drawer__header-text')).toContainText('Uploads 3') - }) - }) - describe('row', () => { let url: AdminUrlUtil beforeAll(() => { diff --git a/tsconfig.json b/tsconfig.json index 609ea599b2..d55001935b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,7 +37,7 @@ ], "paths": { "@payload-config": [ - "./test/access-control/config.ts" + "./test/_community/config.ts" ], "@payloadcms/live-preview": [ "./packages/live-preview/src"