import type { Page } from '@playwright/test' import type { Payload } from 'payload' 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 { FieldsRelationship as CollectionWithRelationships, Config, RelationOne, RelationRestricted, RelationTwo, RelationWithTitle, } from './payload-types.js' import { ensureAutoLoginAndCompilationIsDone, initPageConsoleErrorCatch, openDocControls, openDocDrawer, saveDocAndAssert, throttleTest, } from '../helpers.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { TEST_TIMEOUT_LONG } from '../playwright.config.js' import { relationFalseFilterOptionSlug, relationOneSlug, relationRestrictedSlug, relationTrueFilterOptionSlug, relationTwoSlug, relationUpdatedExternallySlug, relationWithTitleSlug, slug, } from './collectionSlugs.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) const { beforeAll, beforeEach, describe } = test let payload: PayloadTestSDK describe('fields - relationship', () => { let url: AdminUrlUtil let page: Page let relationOneDoc: RelationOne let anotherRelationOneDoc: RelationOne let relationTwoDoc: RelationTwo let docWithExistingRelations: CollectionWithRelationships let restrictedRelation: RelationRestricted let relationWithTitle: RelationWithTitle let serverURL: string beforeAll(async ({ browser }, testInfo) => { testInfo.setTimeout(TEST_TIMEOUT_LONG) ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) url = new AdminUrlUtil(serverURL, slug) const context = await browser.newContext() page = await context.newPage() initPageConsoleErrorCatch(page) await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) }) beforeEach(async () => { await clearAllDocs() // Create docs to relate to relationOneDoc = (await payload.create({ collection: relationOneSlug, data: { name: 'relation', }, })) as any anotherRelationOneDoc = (await payload.create({ collection: relationOneSlug, data: { name: 'relation', }, })) as any relationTwoDoc = (await payload.create({ collection: relationTwoSlug, data: { name: 'second-relation', }, })) as any // Create restricted doc restrictedRelation = (await payload.create({ collection: relationRestrictedSlug, data: { name: 'restricted', }, })) as any // Doc with useAsTitle relationWithTitle = (await payload.create({ collection: relationWithTitleSlug, data: { name: 'relation-title', meta: { title: 'relation-title', }, }, })) as any // Doc with useAsTitle for word boundary test await payload.create({ collection: relationWithTitleSlug, data: { name: 'word boundary search', meta: { title: 'word boundary search', }, }, }) // Add restricted doc as relation docWithExistingRelations = (await payload.create({ collection: slug, data: { name: 'with-existing-relations', relationship: relationOneDoc.id, relationshipReadOnly: relationOneDoc.id, relationshipRestricted: restrictedRelation.id, relationshipWithTitle: relationWithTitle.id, }, })) as any await ensureAutoLoginAndCompilationIsDone({ page, serverURL }) }) test('should create relationship', async () => { await page.goto(url.create) const field = page.locator('#field-relationship') await field.click({ delay: 100 }) const options = page.locator('.rs__option') await expect(options).toHaveCount(2) // two docs // Select a relationship await options.nth(0).click() await expect(field).toContainText(relationOneDoc.id) await saveDocAndAssert(page) }) // TODO: Flaky test in CI - fix this. https://github.com/payloadcms/payload/actions/runs/8559547748/job/23456806365 test.skip('should create relations to multiple collections', async () => { await page.goto(url.create) const field = page.locator('#field-relationshipMultiple') const value = page.locator('#field-relationshipMultiple .relationship--single-value__text') await field.click({ delay: 100 }) const options = page.locator('.rs__option') await expect(options).toHaveCount(3) // 3 docs // Add one relationship await options.locator(`text=${relationOneDoc.id}`).click() await expect(value).toContainText(relationOneDoc.id) // Add relationship of different collection await field.click({ delay: 100 }) await options.locator(`text=${relationTwoDoc.id}`).click() await expect(value).toContainText(relationTwoDoc.id) await saveDocAndAssert(page) await wait(200) await expect(value).toContainText(relationTwoDoc.id) }) test('should create hasMany relationship', async () => { await page.goto(url.create) const field = page.locator('#field-relationshipHasMany') await field.click({ delay: 100 }) const options = page.locator('.rs__option') await expect(options).toHaveCount(2) // Two relationship options const values = page.locator('#field-relationshipHasMany .relationship--multi-value-label__text') // Add one relationship await options.locator(`text=${relationOneDoc.id}`).click() await expect(values).toHaveText([relationOneDoc.id]) await expect(values).not.toHaveText([anotherRelationOneDoc.id]) // Add second relationship await field.click({ delay: 100 }) await options.locator(`text=${anotherRelationOneDoc.id}`).click() await expect(values).toHaveText([relationOneDoc.id, anotherRelationOneDoc.id]) // No options left await field.locator('.rs__input').click({ delay: 100 }) await expect(page.locator('.rs__menu')).toHaveText('No options') await saveDocAndAssert(page) await wait(200) await expect(values).toHaveText([relationOneDoc.id, anotherRelationOneDoc.id]) }) // TODO: Flaky test. Fix this! (This is an actual issue not just an e2e flake) test.skip('should create many relations to multiple collections', async () => { await page.goto(url.create) const field = page.locator('#field-relationshipHasManyMultiple') await field.click({ delay: 100 }) const options = page.locator('.rs__option') await expect(options).toHaveCount(3) const values = page.locator( '#field-relationshipHasManyMultiple .relationship--multi-value-label__text', ) // Add one relationship await options.locator(`text=${relationOneDoc.id}`).click() await expect(values).toHaveText([relationOneDoc.id]) // Add second relationship await field.click({ delay: 100 }) await options.locator(`text=${relationTwoDoc.id}`).click() await expect(values).toHaveText([relationOneDoc.id, relationTwoDoc.id]) await saveDocAndAssert(page) await wait(200) await expect(values).toHaveText([relationOneDoc.id, relationTwoDoc.id]) }) test('should duplicate document with relationships', async () => { await page.goto(url.edit(docWithExistingRelations.id)) await openDocControls(page) await page.locator('#action-duplicate').click() await expect(page.locator('.Toastify')).toContainText('successfully') const field = page.locator('#field-relationship .relationship--single-value__text') await expect(field).toHaveText(relationOneDoc.id) }) async function runFilterOptionsTest(fieldName: string) { await page.reload() await page.goto(url.edit(docWithExistingRelations.id)) // fill the first relation field const field = page.locator('#field-relationship') await field.click({ delay: 100 }) const options = page.locator('.rs__option') await options.nth(0).click() await expect(field).toContainText(relationOneDoc.id) // then verify that the filtered field's options match let filteredField = page.locator(`#field-${fieldName} .react-select`) await filteredField.click({ delay: 100 }) let filteredOptions = filteredField.locator('.rs__option') await expect(filteredOptions).toHaveCount(1) // one doc await filteredOptions.nth(0).click() await expect(filteredField).toContainText(relationOneDoc.id) // change the first relation field await field.click({ delay: 100 }) await options.nth(1).click() await expect(field).toContainText(anotherRelationOneDoc.id) // Need to wait form state to come back // before clicking save await wait(2000) // Now, save the document. This should fail, as the filitered field doesn't match the selected relationship value await page.locator('#action-save').click() await expect(page.locator('.Toastify')).toContainText(`is invalid: ${fieldName}`) // then verify that the filtered field's options match filteredField = page.locator(`#field-${fieldName} .react-select`) await filteredField.click({ delay: 100 }) filteredOptions = filteredField.locator('.rs__option') await expect(filteredOptions).toHaveCount(2) // two options because the currently selected option is still there await filteredOptions.nth(1).click() await expect(filteredField).toContainText(anotherRelationOneDoc.id) // Now, saving the document should succeed await saveDocAndAssert(page) } // TODO: Flaky test. Fix this! (This is an actual issue not just an e2e flake) test('should allow dynamic filterOptions', async () => { await runFilterOptionsTest('relationshipFiltered') }) // TODO: Flaky test. Fix this! (This is an actual issue not just an e2e flake) test('should allow dynamic async filterOptions', async () => { await runFilterOptionsTest('relationshipFilteredAsync') }) test('should allow usage of relationTo in filterOptions', async () => { const { id: include } = (await payload.create({ collection: relationOneSlug, data: { name: 'include', }, })) as any const { id: exclude } = (await payload.create({ collection: relationOneSlug, data: { name: 'exclude', }, })) as any await page.goto(url.create) // select relationshipMany field that relies on siblingData field above await page.locator('#field-relationshipManyFiltered .rs__control').click() const options = page.locator('#field-relationshipManyFiltered .rs__menu') await expect(options).toContainText(include) await expect(options).not.toContainText(exclude) }) test('should allow usage of siblingData in filterOptions', async () => { await payload.create({ collection: relationWithTitleSlug, data: { name: 'exclude', }, }) await page.goto(url.create) // enter a filter for relationshipManyFiltered to use await page.locator('#field-filter').fill('include') // select relationshipMany field that relies on siblingData field above await page.locator('#field-relationshipManyFiltered .rs__control').click() const options = page.locator('#field-relationshipManyFiltered .rs__menu') await expect(options).not.toContainText('exclude') }) // TODO: Flaky test in CI - fix. https://github.com/payloadcms/payload/actions/runs/8559547748/job/23456806365 test.skip('should not query for a relationship when filterOptions returns false', async () => { await payload.create({ collection: relationFalseFilterOptionSlug, data: { name: 'whatever', }, }) await page.goto(url.create) // select relationshipMany field that relies on siblingData field above await page.locator('#field-relationshipManyFiltered .rs__control').click() const options = page.locator('#field-relationshipManyFiltered .rs__menu') await expect(options).toContainText('Relation With Titles') await expect(options).not.toContainText('whatever') }) // TODO: Flaky test in CI - fix. test('should show a relationship when filterOptions returns true', async () => { await payload.create({ collection: relationTrueFilterOptionSlug, data: { name: 'truth', }, }) await page.goto(url.create) // wait for relationship options to load const relationFilterOptionsReq = page.waitForResponse(/api\/relation-filter-true/) // select relationshipMany field that relies on siblingData field above await page.locator('#field-relationshipManyFiltered .rs__control').click() await relationFilterOptionsReq const options = page.locator('#field-relationshipManyFiltered .rs__menu') await expect(options).toContainText('truth') }) // TODO: Flaky test in CI - fix. test.skip('should open document drawer from read-only relationships', async () => { const editURL = url.edit(docWithExistingRelations.id) await page.goto(editURL) await page.waitForURL(editURL) await openDocDrawer( page, '#field-relationshipReadOnly button.relationship--single-value__drawer-toggler.doc-drawer__toggler', ) const documentDrawer = page.locator('[id^=doc-drawer_relation-one_1_]') await expect(documentDrawer).toBeVisible() }) test('should open document drawer and append newly created docs onto the parent field', async () => { await page.goto(url.edit(docWithExistingRelations.id)) const field = page.locator('#field-relationshipHasMany') // open the document drawer const addNewButton = field.locator( 'button.relationship-add-new__add-button.doc-drawer__toggler', ) await addNewButton.click() const documentDrawer = page.locator('[id^=doc-drawer_relation-one_1_]') await expect(documentDrawer).toBeVisible() // fill in the field and save the document, keep the drawer open for further testing const drawerField = documentDrawer.locator('#field-name') await drawerField.fill('Newly created document') const saveButton = documentDrawer.locator('#action-save') await saveButton.click() await expect(page.locator('.Toastify')).toContainText('successfully') // count the number of values in the field to ensure only one was added await expect( page.locator('#field-relationshipHasMany .value-container .rs__multi-value'), ).toHaveCount(1) // save the same document again to ensure the relationship field doesn't receive duplicative values await saveButton.click() await expect(page.locator('.Toastify')).toContainText('successfully') await expect( page.locator('#field-relationshipHasMany .value-container .rs__multi-value'), ).toHaveCount(1) }) describe('existing relationships', () => { test('should highlight existing relationship', async () => { await page.goto(url.edit(docWithExistingRelations.id)) const field = page.locator('#field-relationship') // Check dropdown options await field.click({ delay: 100 }) await expect(page.locator('.rs__option--is-selected')).toHaveCount(1) await expect(page.locator('.rs__option--is-selected')).toHaveText(relationOneDoc.id) }) test('should show untitled ID on restricted relation', async () => { await page.goto(url.edit(docWithExistingRelations.id)) const field = page.locator('#field-relationshipRestricted') // Check existing relationship has untitled ID await expect(field).toContainText(`Untitled - ID: ${restrictedRelation.id}`) // Check dropdown options await field.click({ delay: 100 }) const options = page.locator('.rs__option') await expect(options).toHaveCount(1) // None + 1 Unitled ID }) // test.todo('should paginate within the dropdown'); test('should search within the relationship field', async () => { await page.goto(url.edit(docWithExistingRelations.id)) const input = page.locator('#field-relationshipWithTitle input') await input.fill('title') const options = page.locator('#field-relationshipWithTitle .rs__menu .rs__option') await expect(options).toHaveCount(1) await input.fill('non-occurring-string') await expect(options).toHaveCount(0) }) test('should search using word boundaries within the relationship field', async () => { await page.goto(url.edit(docWithExistingRelations.id)) const input = page.locator('#field-relationshipWithTitle input') await input.fill('word search') const options = page.locator('#field-relationshipWithTitle .rs__menu .rs__option') await expect(options).toHaveCount(1) }) test('should show useAsTitle on relation', async () => { await page.goto(url.edit(docWithExistingRelations.id)) const field = page.locator('#field-relationshipWithTitle') const value = field.locator('.relationship--single-value__text') // Check existing relationship for correct title await expect(value).toHaveText(relationWithTitle.name) await field.click({ delay: 100 }) const options = field.locator('.rs__option') await expect(options).toHaveCount(2) }) test('should show id on relation in list view', async () => { await page.goto(url.list) await wait(110) const relationship = page.locator('.row-1 .cell-relationship') await expect(relationship).toHaveText(relationOneDoc.id) }) test('should show Untitled ID on restricted relation in list view', async () => { await page.goto(url.list) await wait(110) const relationship = page.locator('.row-1 .cell-relationshipRestricted') await expect(relationship).toContainText('Untitled - ID: ') }) test('x in list view', async () => { await page.goto(url.list) await wait(110) const relationship = page.locator('.row-1 .cell-relationshipWithTitle') await expect(relationship).toHaveText(relationWithTitle.name) }) }) describe('externally update relationship field', () => { beforeEach(async () => { const externalRelationURL = new AdminUrlUtil(serverURL, relationUpdatedExternallySlug) await page.goto(externalRelationURL.create) }) test('has many, one collection', async () => { await page.locator('#field-relationHasMany + .pre-populate-field-ui button').click() await wait(300) await expect( page.locator('#field-relationHasMany .rs__value-container > .rs__multi-value'), ).toHaveCount(15) }) test('has many, many collections', async () => { await page.locator('#field-relationToManyHasMany + .pre-populate-field-ui button').click() await wait(300) await expect( page.locator('#field-relationToManyHasMany .rs__value-container > .rs__multi-value'), ).toHaveCount(15) }) }) }) async function clearAllDocs(): Promise { await clearCollectionDocs(slug) await clearCollectionDocs(relationOneSlug) await clearCollectionDocs(relationTwoSlug) await clearCollectionDocs(relationRestrictedSlug) await clearCollectionDocs(relationWithTitleSlug) } async function clearCollectionDocs(collectionSlug: string): Promise { await payload.delete({ collection: collectionSlug, where: { id: { exists: true }, }, }) }