test: cleans up fields-relationship test suite (#11003)

The `fields-relationship` test suite is disorganized to the point of
being unusable. This makes it very difficult to digest at a high level
and add new tests.

This PR cleans it up in the following ways:

- Moves collection configs to their own standalone files
- Moves the seed function to its own file
- Consolidates collection slugs in their own file
- Uses generated types instead of defining them statically
- Wraps the `filterOptions` e2e tests within a describe block

Related, there are three distinct test suites where we manage
relationships: `relationships`, `fields-relationship`, and `fields >
relationships`. In the future we ought to consolidate at least two of
these. IMO the `fields > relationship` suite should remain in place for
general _component level_ UI tests for the field itself, whereas the
other suite could run the integration tests and test the more complex UI
patterns that exist outside of the field component.
This commit is contained in:
Jacob Fletcher
2025-02-05 17:03:35 -05:00
committed by GitHub
parent 09721d4c20
commit 694c76d51a
26 changed files with 821 additions and 660 deletions

View File

@@ -2,7 +2,7 @@
import { useField } from '@payloadcms/ui'
import * as React from 'react'
import { collection1Slug } from '../collectionSlugs.js'
import { collection1Slug } from '../slugs.js'
export const PrePopulateFieldUI: React.FC<{
hasMany?: boolean

View File

@@ -0,0 +1,8 @@
import type { CollectionConfig } from 'payload'
export const baseRelationshipFields: CollectionConfig['fields'] = [
{
name: 'name',
type: 'text',
},
]

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
import { collection1Slug } from '../../slugs.js'
export const Collection1: CollectionConfig = {
fields: [
{
name: 'name',
type: 'text',
},
],
slug: collection1Slug,
admin: {
useAsTitle: 'name',
},
}

View File

@@ -0,0 +1,13 @@
import type { CollectionConfig } from 'payload'
import { collection2Slug } from '../../slugs.js'
export const Collection2: CollectionConfig = {
fields: [
{
name: 'name',
type: 'text',
},
],
slug: collection2Slug,
}

View File

@@ -0,0 +1,12 @@
import type { CollectionConfig } from 'payload'
import { baseRelationshipFields } from '../../baseFields.js'
import { relationFalseFilterOptionSlug } from '../../slugs.js'
export const RelationshipFilterFalse: CollectionConfig = {
admin: {
useAsTitle: 'name',
},
fields: baseRelationshipFields,
slug: relationFalseFilterOptionSlug,
}

View File

@@ -0,0 +1,12 @@
import type { CollectionConfig } from 'payload'
import { baseRelationshipFields } from '../../baseFields.js'
import { relationTrueFilterOptionSlug } from '../../slugs.js'
export const RelationshipFilterTrue: CollectionConfig = {
admin: {
useAsTitle: 'name',
},
fields: baseRelationshipFields,
slug: relationTrueFilterOptionSlug,
}

View File

@@ -0,0 +1,19 @@
import type { CollectionConfig } from 'payload'
import {
mixedMediaCollectionSlug,
podcastCollectionSlug,
videoCollectionSlug,
} from '../../slugs.js'
export const MixedMedia: CollectionConfig = {
slug: mixedMediaCollectionSlug,
fields: [
{
type: 'relationship',
name: 'relatedMedia',
relationTo: [videoCollectionSlug, podcastCollectionSlug],
hasMany: true,
},
],
}

View File

@@ -0,0 +1,21 @@
import type { CollectionConfig } from 'payload'
import { podcastCollectionSlug } from '../../slugs.js'
export const Podcast: CollectionConfig = {
slug: podcastCollectionSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'id',
type: 'number',
required: true,
},
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,9 @@
import type { CollectionConfig } from 'payload'
import { baseRelationshipFields } from '../../baseFields.js'
import { relationOneSlug } from '../../slugs.js'
export const Relation1: CollectionConfig = {
fields: baseRelationshipFields,
slug: relationOneSlug,
}

View File

@@ -0,0 +1,9 @@
import type { CollectionConfig } from 'payload'
import { baseRelationshipFields } from '../../baseFields.js'
import { relationTwoSlug } from '../../slugs.js'
export const Relation2: CollectionConfig = {
fields: baseRelationshipFields,
slug: relationTwoSlug,
}

View File

@@ -0,0 +1,25 @@
import type { CollectionConfig } from 'payload'
import { baseRelationshipFields } from '../../baseFields.js'
import { relationWithTitleSlug } from '../../slugs.js'
export const RelationWithTitle: CollectionConfig = {
admin: {
useAsTitle: 'name',
},
fields: [
...baseRelationshipFields,
{
name: 'meta',
fields: [
{
name: 'title',
label: 'Meta Title',
type: 'text',
},
],
type: 'group',
},
],
slug: relationWithTitleSlug,
}

View File

@@ -0,0 +1,130 @@
import type { CollectionConfig, FilterOptionsProps } from 'payload'
import type { FieldsRelationship } from '../../payload-types.js'
import {
relationFalseFilterOptionSlug,
relationOneSlug,
relationRestrictedSlug,
relationTrueFilterOptionSlug,
relationTwoSlug,
relationWithTitleSlug,
slug,
} from '../../slugs.js'
export const Relationship: CollectionConfig = {
admin: {
defaultColumns: [
'id',
'relationship',
'relationshipRestricted',
'relationshipHasManyMultiple',
'relationshipWithTitle',
],
},
fields: [
{
name: 'relationship',
relationTo: relationOneSlug,
type: 'relationship',
},
{
name: 'relationshipHasMany',
hasMany: true,
relationTo: relationOneSlug,
type: 'relationship',
},
{
name: 'relationshipMultiple',
relationTo: [relationOneSlug, relationTwoSlug],
type: 'relationship',
},
{
name: 'relationshipHasManyMultiple',
hasMany: true,
relationTo: [relationOneSlug, relationTwoSlug],
type: 'relationship',
},
{
name: 'relationshipRestricted',
relationTo: relationRestrictedSlug,
type: 'relationship',
},
{
name: 'relationshipWithTitle',
relationTo: relationWithTitleSlug,
type: 'relationship',
},
{
name: 'relationshipFilteredByID',
filterOptions: (args: FilterOptionsProps<FieldsRelationship>) => {
return {
id: {
equals: args.data.relationship,
},
}
},
relationTo: relationOneSlug,
type: 'relationship',
admin: {
description:
'This will filter the relationship options based on id, which is the same as the relationship field in this document',
},
},
{
name: 'relationshipFilteredAsync',
filterOptions: (args: FilterOptionsProps<FieldsRelationship>) => {
return {
id: {
equals: args.data.relationship,
},
}
},
relationTo: relationOneSlug,
type: 'relationship',
},
{
name: 'relationshipManyFiltered',
filterOptions: ({ relationTo, siblingData }) => {
if (relationTo === relationOneSlug) {
return { name: { equals: 'include' } }
}
if (relationTo === relationTrueFilterOptionSlug) {
return true
}
if (relationTo === relationFalseFilterOptionSlug) {
return false
}
if (siblingData.filter) {
return { name: { contains: siblingData.filter } }
}
return { and: [] }
},
hasMany: true,
relationTo: [
relationWithTitleSlug,
relationFalseFilterOptionSlug,
relationTrueFilterOptionSlug,
relationOneSlug,
],
type: 'relationship',
},
{
name: 'filter',
type: 'text',
},
{
name: 'relationshipReadOnly',
admin: {
readOnly: true,
},
relationTo: relationOneSlug,
type: 'relationship',
},
],
slug,
}

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
import { collection1Slug } from '../../slugs.js'
export const Collection1: CollectionConfig = {
fields: [
{
name: 'name',
type: 'text',
},
],
slug: collection1Slug,
admin: {
useAsTitle: 'name',
},
}

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
import { baseRelationshipFields } from '../../baseFields.js'
import { relationRestrictedSlug } from '../../slugs.js'
export const Restricted: CollectionConfig = {
access: {
create: () => false,
read: () => false,
},
admin: {
useAsTitle: 'name',
},
fields: baseRelationshipFields,
slug: relationRestrictedSlug,
}

View File

@@ -0,0 +1,99 @@
import type { CollectionConfig } from 'payload'
import { collection1Slug, collection2Slug, relationUpdatedExternallySlug } from '../../slugs.js'
export const RelationshipUpdatedExternally: CollectionConfig = {
fields: [
{
fields: [
{
name: 'relationPrePopulate',
admin: {
width: '75%',
},
relationTo: collection1Slug,
type: 'relationship',
},
{
name: 'prePopulate',
admin: {
components: {
Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI',
clientProps: {
hasMany: false,
hasMultipleRelations: false,
targetFieldPath: 'relationPrePopulate',
},
},
},
width: '25%',
},
type: 'ui',
},
],
type: 'row',
},
{
fields: [
{
name: 'relationHasMany',
admin: {
width: '75%',
},
hasMany: true,
relationTo: collection1Slug,
type: 'relationship',
},
{
name: 'prePopulateRelationHasMany',
admin: {
components: {
Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI',
clientProps: {
hasMultipleRelations: false,
targetFieldPath: 'relationHasMany',
},
},
},
width: '25%',
},
type: 'ui',
},
],
type: 'row',
},
{
fields: [
{
name: 'relationToManyHasMany',
admin: {
width: '75%',
},
hasMany: true,
relationTo: [collection1Slug, collection2Slug],
type: 'relationship',
},
{
name: 'prePopulateToMany',
admin: {
components: {
Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI',
clientProps: {
hasMultipleRelations: true,
targetFieldPath: 'relationToManyHasMany',
},
},
},
width: '25%',
},
type: 'ui',
},
],
type: 'row',
},
],
slug: relationUpdatedExternallySlug,
}

View File

@@ -1,8 +1,8 @@
import type { CollectionConfig } from 'payload'
import { collection1Slug, versionedRelationshipFieldSlug } from '../../collectionSlugs.js'
import { collection1Slug, versionedRelationshipFieldSlug } from '../../slugs.js'
export const VersionedRelationshipFieldCollection: CollectionConfig = {
export const Versions: CollectionConfig = {
slug: versionedRelationshipFieldSlug,
fields: [
{

View File

@@ -0,0 +1,21 @@
import type { CollectionConfig } from 'payload'
import { videoCollectionSlug } from '../../slugs.js'
export const Video: CollectionConfig = {
slug: videoCollectionSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'id',
type: 'number',
required: true,
},
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -2,54 +2,23 @@ import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import type { CollectionConfig, FilterOptionsProps } from 'payload'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { VersionedRelationshipFieldCollection } from './collections/VersionedRelationshipField/index.js'
import {
collection1Slug,
collection2Slug,
mixedMediaCollectionSlug,
podcastCollectionSlug,
relationFalseFilterOptionSlug,
relationOneSlug,
relationRestrictedSlug,
relationTrueFilterOptionSlug,
relationTwoSlug,
relationUpdatedExternallySlug,
relationWithTitleSlug,
slug,
videoCollectionSlug,
} from './collectionSlugs.js'
export interface FieldsRelationship {
createdAt: Date
id: string
relationship: RelationOne
relationshipHasMany: RelationOne[]
relationshipHasManyMultiple: Array<{ relationTo: string; value: string } | RelationOne>
relationshipMultiple: Array<RelationOne>
relationshipRestricted: RelationRestricted
relationshipWithTitle: RelationWithTitle
updatedAt: Date
}
export interface RelationOne {
id: string
name: string
}
export type RelationTwo = RelationOne
export type RelationRestricted = RelationOne
export type RelationWithTitle = RelationOne
const baseRelationshipFields: CollectionConfig['fields'] = [
{
name: 'name',
type: 'text',
},
]
import { Collection1 } from './collections/Collection1/index.js'
import { Collection2 } from './collections/Collection2/index.js'
import { RelationshipFilterFalse } from './collections/FilterFalse/index.js'
import { RelationshipFilterTrue } from './collections/FilterTrue/index.js'
import { MixedMedia } from './collections/MixedMedia/index.js'
import { Podcast } from './collections/Podcast/index.js'
import { Relation1 } from './collections/Relation1/index.js'
import { Relation2 } from './collections/Relation2/index.js'
import { Relationship } from './collections/Relationship/index.js'
import { RelationWithTitle } from './collections/RelationWithTitle/index.js'
import { Restricted } from './collections/Restricted/index.js'
import { RelationshipUpdatedExternally } from './collections/UpdatedExternally/index.js'
import { Versions } from './collections/Versions/index.js'
import { Video } from './collections/Video/index.js'
import { clearAndSeedEverything } from './seed.js'
export default buildConfigWithDefaults({
admin: {
@@ -58,329 +27,20 @@ export default buildConfigWithDefaults({
},
},
collections: [
{
admin: {
defaultColumns: [
'id',
'relationship',
'relationshipRestricted',
'relationshipHasManyMultiple',
'relationshipWithTitle',
],
},
fields: [
{
name: 'relationship',
relationTo: relationOneSlug,
type: 'relationship',
},
{
name: 'relationshipHasMany',
hasMany: true,
relationTo: relationOneSlug,
type: 'relationship',
},
{
name: 'relationshipMultiple',
relationTo: [relationOneSlug, relationTwoSlug],
type: 'relationship',
},
{
name: 'relationshipHasManyMultiple',
hasMany: true,
relationTo: [relationOneSlug, relationTwoSlug],
type: 'relationship',
},
{
name: 'relationshipRestricted',
relationTo: relationRestrictedSlug,
type: 'relationship',
},
{
name: 'relationshipWithTitle',
relationTo: relationWithTitleSlug,
type: 'relationship',
},
{
name: 'relationshipFiltered',
filterOptions: (args: FilterOptionsProps<FieldsRelationship>) => {
return {
id: {
equals: args.data.relationship,
},
}
},
relationTo: relationOneSlug,
type: 'relationship',
},
{
name: 'relationshipFilteredAsync',
filterOptions: (args: FilterOptionsProps<FieldsRelationship>) => {
return {
id: {
equals: args.data.relationship,
},
}
},
relationTo: relationOneSlug,
type: 'relationship',
},
{
name: 'relationshipManyFiltered',
filterOptions: ({ relationTo, siblingData }: any) => {
if (relationTo === relationOneSlug) {
return { name: { equals: 'include' } }
}
if (relationTo === relationTrueFilterOptionSlug) {
return true
}
if (relationTo === relationFalseFilterOptionSlug) {
return false
}
if (siblingData.filter) {
return { name: { contains: siblingData.filter } }
}
return { and: [] }
},
hasMany: true,
relationTo: [
relationWithTitleSlug,
relationFalseFilterOptionSlug,
relationTrueFilterOptionSlug,
relationOneSlug,
],
type: 'relationship',
},
{
name: 'filter',
type: 'text',
},
{
name: 'relationshipReadOnly',
admin: {
readOnly: true,
},
relationTo: relationOneSlug,
type: 'relationship',
},
],
slug,
},
{
admin: {
useAsTitle: 'name',
},
fields: baseRelationshipFields,
slug: relationFalseFilterOptionSlug,
},
{
admin: {
useAsTitle: 'name',
},
fields: baseRelationshipFields,
slug: relationTrueFilterOptionSlug,
},
{
fields: baseRelationshipFields,
slug: relationOneSlug,
},
{
fields: baseRelationshipFields,
slug: relationTwoSlug,
},
{
access: {
create: () => false,
read: () => false,
},
admin: {
useAsTitle: 'name',
},
fields: baseRelationshipFields,
slug: relationRestrictedSlug,
},
{
admin: {
useAsTitle: 'name',
},
fields: [
...baseRelationshipFields,
{
name: 'meta',
fields: [
{
name: 'title',
label: 'Meta Title',
type: 'text',
},
],
type: 'group',
},
],
slug: relationWithTitleSlug,
},
{
fields: [
{
fields: [
{
name: 'relationPrePopulate',
admin: {
width: '75%',
},
relationTo: collection1Slug,
type: 'relationship',
},
{
name: 'prePopulate',
admin: {
components: {
Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI',
clientProps: {
hasMany: false,
hasMultipleRelations: false,
targetFieldPath: 'relationPrePopulate',
},
},
},
width: '25%',
},
type: 'ui',
},
],
type: 'row',
},
{
fields: [
{
name: 'relationHasMany',
admin: {
width: '75%',
},
hasMany: true,
relationTo: collection1Slug,
type: 'relationship',
},
{
name: 'prePopulateRelationHasMany',
admin: {
components: {
Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI',
clientProps: {
hasMultipleRelations: false,
targetFieldPath: 'relationHasMany',
},
},
},
width: '25%',
},
type: 'ui',
},
],
type: 'row',
},
{
fields: [
{
name: 'relationToManyHasMany',
admin: {
width: '75%',
},
hasMany: true,
relationTo: [collection1Slug, collection2Slug],
type: 'relationship',
},
{
name: 'prePopulateToMany',
admin: {
components: {
Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI',
clientProps: {
hasMultipleRelations: true,
targetFieldPath: 'relationToManyHasMany',
},
},
},
width: '25%',
},
type: 'ui',
},
],
type: 'row',
},
],
slug: relationUpdatedExternallySlug,
},
{
fields: [
{
name: 'name',
type: 'text',
},
],
slug: collection1Slug,
admin: {
useAsTitle: 'name',
},
},
{
fields: [
{
name: 'name',
type: 'text',
},
],
slug: collection2Slug,
},
{
slug: videoCollectionSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'id',
type: 'number',
required: true,
},
{
name: 'title',
type: 'text',
},
],
},
{
slug: podcastCollectionSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'id',
type: 'number',
required: true,
},
{
name: 'title',
type: 'text',
},
],
},
{
slug: mixedMediaCollectionSlug,
fields: [
{
type: 'relationship',
name: 'relatedMedia',
relationTo: [videoCollectionSlug, podcastCollectionSlug],
hasMany: true,
},
],
},
VersionedRelationshipFieldCollection,
Relationship,
RelationshipFilterFalse,
RelationshipFilterTrue,
Relation1,
Relation2,
Restricted,
RelationWithTitle,
RelationshipUpdatedExternally,
Collection1,
Collection2,
Video,
Podcast,
MixedMedia,
Versions,
],
localization: {
locales: ['en'],
@@ -388,156 +48,8 @@ export default buildConfigWithDefaults({
fallback: true,
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
depth: 0,
overrideAccess: true,
})
// Create docs to relate to
const { id: relationOneDocId } = await payload.create({
collection: relationOneSlug,
data: {
name: relationOneSlug,
},
depth: 0,
overrideAccess: true,
})
const relationOneIDs: string[] = []
for (let i = 0; i < 11; i++) {
const doc = await payload.create({
collection: relationOneSlug,
data: {
name: relationOneSlug,
},
depth: 0,
overrideAccess: true,
})
relationOneIDs.push(doc.id)
}
const relationTwoIDs: string[] = []
for (let i = 0; i < 11; i++) {
const doc = await payload.create({
collection: relationTwoSlug,
data: {
name: relationTwoSlug,
},
depth: 0,
overrideAccess: true,
})
relationTwoIDs.push(doc.id)
}
// Existing relationships
const { id: restrictedDocId } = await payload.create({
collection: relationRestrictedSlug,
data: {
name: 'relation-restricted',
},
depth: 0,
overrideAccess: true,
})
const relationsWithTitle: string[] = []
for (const title of ['relation-title', 'word boundary search']) {
const { id } = await payload.create({
collection: relationWithTitleSlug,
depth: 0,
overrideAccess: true,
data: {
name: title,
meta: {
title,
},
},
})
relationsWithTitle.push(id)
}
await payload.create({
collection: slug,
depth: 0,
overrideAccess: true,
data: {
relationship: relationOneDocId,
relationshipRestricted: restrictedDocId,
relationshipWithTitle: relationsWithTitle[0],
},
})
for (let i = 0; i < 11; i++) {
await payload.create({
collection: slug,
depth: 0,
overrideAccess: true,
data: {
relationship: relationOneDocId,
relationshipHasManyMultiple: relationOneIDs.map((id) => ({
relationTo: relationOneSlug,
value: id,
})),
relationshipRestricted: restrictedDocId,
},
})
}
for (let i = 0; i < 15; i++) {
const relationOneID = relationOneIDs[Math.floor(Math.random() * 10)]
const relationTwoID = relationTwoIDs[Math.floor(Math.random() * 10)]
await payload.create({
collection: slug,
depth: 0,
overrideAccess: true,
data: {
relationship: relationOneDocId,
relationshipHasMany: [relationOneID],
relationshipHasManyMultiple: [{ relationTo: relationTwoSlug, value: relationTwoID }],
relationshipReadOnly: relationOneID,
relationshipRestricted: restrictedDocId,
},
})
}
for (let i = 0; i < 15; i++) {
await payload.create({
collection: collection1Slug,
depth: 0,
overrideAccess: true,
data: {
name: `relationship-test ${i}`,
},
})
await payload.create({
collection: collection2Slug,
depth: 0,
overrideAccess: true,
data: {
name: `relationship-test ${i}`,
},
})
}
for (let i = 0; i < 2; i++) {
await payload.create({
collection: videoCollectionSlug,
data: {
id: i,
title: `Video ${i}`,
},
})
await payload.create({
collection: podcastCollectionSlug,
data: {
id: i,
title: `Podcast ${i}`,
},
})
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await clearAndSeedEverything(payload)
}
},
typescript: {

View File

@@ -42,7 +42,7 @@ import {
relationWithTitleSlug,
slug,
versionedRelationshipFieldSlug,
} from './collectionSlugs.js'
} from './slugs.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -305,97 +305,99 @@ describe('Relationship Field', () => {
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', 'Relationship Filtered')
})
// 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', 'Relationship Filtered Async')
})
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',
},
describe('filterOptions', () => {
// TODO: Flaky test. Fix this! (This is an actual issue not just an e2e flake)
test('should allow dynamic filterOptions', async () => {
await runFilterOptionsTest('relationshipFilteredByID', 'Relationship Filtered')
})
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',
},
// 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', 'Relationship Filtered Async')
})
await page.goto(url.create)
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
// select relationshipMany field that relies on siblingData field above
await page.locator('#field-relationshipManyFiltered .rs__control').click()
await page.goto(url.create)
const options = page.locator('#field-relationshipManyFiltered .rs__menu')
await expect(options).toContainText('Relation With Titles')
await expect(options).not.toContainText('whatever')
})
// select relationshipMany field that relies on siblingData field above
await page.locator('#field-relationshipManyFiltered .rs__control').click()
// TODO: Flaky test in CI - fix.
test('should show a relationship when filterOptions returns true', async () => {
await payload.create({
collection: relationTrueFilterOptionSlug,
data: {
name: 'truth',
},
const options = page.locator('#field-relationshipManyFiltered .rs__menu')
await expect(options).toContainText(include)
await expect(options).not.toContainText(exclude)
})
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
test('should allow usage of siblingData in filterOptions', async () => {
await payload.create({
collection: relationWithTitleSlug,
data: {
name: 'exclude',
},
})
const options = page.locator('#field-relationshipManyFiltered .rs__menu')
await expect(options).toContainText('truth')
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')
})
})
test('should allow docs with same ID but different collections to be selectable', async () => {

View File

@@ -8,7 +8,7 @@ import type { Collection1 } from './payload-types.js'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { collection1Slug, versionedRelationshipFieldSlug } from './collectionSlugs.js'
import { collection1Slug, versionedRelationshipFieldSlug } from './slugs.js'
let payload: Payload
let restClient: NextRESTClient

View File

@@ -114,7 +114,14 @@ export interface FieldsRelationship {
| null;
relationshipRestricted?: (string | null) | RelationRestricted;
relationshipWithTitle?: (string | null) | RelationWithTitle;
relationshipFiltered?: (string | null) | RelationOne;
/**
* This will filter the relationship options based on id, which is the same as the relationship field in this document
*/
relationshipFilteredByID?: (string | null) | RelationOne;
/**
* This will filter the relationship options if the filter field in this document is set to "Include me"
*/
relationshipFilteredByField?: (string | null) | RelationOne;
relationshipFilteredAsync?: (string | null) | RelationOne;
relationshipManyFiltered?:
| (
@@ -442,7 +449,8 @@ export interface FieldsRelationshipSelect<T extends boolean = true> {
relationshipHasManyMultiple?: T;
relationshipRestricted?: T;
relationshipWithTitle?: T;
relationshipFiltered?: T;
relationshipFilteredByID?: T;
relationshipFilteredByField?: T;
relationshipFilteredAsync?: T;
relationshipManyFiltered?: T;
filter?: T;

View File

@@ -0,0 +1,191 @@
import type { Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import { devUser } from '../credentials.js'
import { seedDB } from '../helpers/seed.js'
import {
collection1Slug,
collection2Slug,
collectionSlugs,
podcastCollectionSlug,
relationOneSlug,
relationRestrictedSlug,
relationTwoSlug,
relationWithTitleSlug,
slug,
videoCollectionSlug,
} from './slugs.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const seed = async (_payload: Payload) => {
await _payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
depth: 0,
overrideAccess: true,
})
// Create docs to relate to
const { id: relationOneDocId } = await _payload.create({
collection: relationOneSlug,
data: {
name: relationOneSlug,
},
depth: 0,
overrideAccess: true,
})
const relationOneIDs: string[] = []
for (let i = 0; i < 11; i++) {
const doc = await _payload.create({
collection: relationOneSlug,
data: {
name: relationOneSlug,
},
depth: 0,
overrideAccess: true,
})
relationOneIDs.push(doc.id)
}
const relationTwoIDs: string[] = []
for (let i = 0; i < 11; i++) {
const doc = await _payload.create({
collection: relationTwoSlug,
data: {
name: relationTwoSlug,
},
depth: 0,
overrideAccess: true,
})
relationTwoIDs.push(doc.id)
}
// Existing relationships
const { id: restrictedDocId } = await _payload.create({
collection: relationRestrictedSlug,
data: {
name: 'relation-restricted',
},
depth: 0,
overrideAccess: true,
})
const relationsWithTitle: string[] = []
for (const title of ['relation-title', 'word boundary search']) {
const { id } = await _payload.create({
collection: relationWithTitleSlug,
depth: 0,
overrideAccess: true,
data: {
name: title,
meta: {
title,
},
},
})
relationsWithTitle.push(id)
}
await _payload.create({
collection: slug,
depth: 0,
overrideAccess: true,
data: {
relationship: relationOneDocId,
relationshipRestricted: restrictedDocId,
relationshipWithTitle: relationsWithTitle[0],
},
})
for (let i = 0; i < 11; i++) {
await _payload.create({
collection: slug,
depth: 0,
overrideAccess: true,
data: {
relationship: relationOneDocId,
relationshipHasManyMultiple: relationOneIDs.map((id) => ({
relationTo: relationOneSlug,
value: id,
})),
relationshipRestricted: restrictedDocId,
},
})
}
for (let i = 0; i < 15; i++) {
const relationOneID = relationOneIDs[Math.floor(Math.random() * 10)]
const relationTwoID = relationTwoIDs[Math.floor(Math.random() * 10)]
await _payload.create({
collection: slug,
depth: 0,
overrideAccess: true,
data: {
relationship: relationOneDocId,
relationshipHasMany: [relationOneID],
relationshipHasManyMultiple: [{ relationTo: relationTwoSlug, value: relationTwoID }],
relationshipReadOnly: relationOneID,
relationshipRestricted: restrictedDocId,
},
})
}
for (let i = 0; i < 15; i++) {
await _payload.create({
collection: collection1Slug,
depth: 0,
overrideAccess: true,
data: {
name: `relationship-test ${i}`,
},
})
await _payload.create({
collection: collection2Slug,
depth: 0,
overrideAccess: true,
data: {
name: `relationship-test ${i}`,
},
})
}
for (let i = 0; i < 2; i++) {
await _payload.create({
collection: videoCollectionSlug,
data: {
id: i,
title: `Video ${i}`,
},
})
await _payload.create({
collection: podcastCollectionSlug,
data: {
id: i,
title: `Podcast ${i}`,
},
})
}
}
export async function clearAndSeedEverything(_payload: Payload) {
return await seedDB({
_payload,
collectionSlugs,
seedFunction: seed,
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
}

View File

@@ -13,3 +13,19 @@ export const videoCollectionSlug = 'videos'
export const podcastCollectionSlug = 'podcasts'
export const mixedMediaCollectionSlug = 'mixed-media'
export const versionedRelationshipFieldSlug = 'versioned-relationship-field'
export const collectionSlugs = [
relationOneSlug,
relationTrueFilterOptionSlug,
relationFalseFilterOptionSlug,
relationTwoSlug,
relationRestrictedSlug,
relationWithTitleSlug,
relationUpdatedExternallySlug,
collection1Slug,
collection2Slug,
videoCollectionSlug,
podcastCollectionSlug,
mixedMediaCollectionSlug,
versionedRelationshipFieldSlug,
]

View File

@@ -1439,6 +1439,8 @@ export interface RelationshipField {
| null;
relationToRow?: (string | null) | RowField;
relationToRowMany?: (string | RowField)[] | null;
disableRelation?: boolean | null;
filteredRelationship?: (string | null) | RelationshipField;
updatedAt: string;
createdAt: string;
}
@@ -3028,6 +3030,8 @@ export interface RelationshipFieldsSelect<T extends boolean = true> {
relationshipWithMinRows?: T;
relationToRow?: T;
relationToRowMany?: T;
disableRelation?: T;
filteredRelationship?: T;
updatedAt?: T;
createdAt?: T;
}

View File

@@ -374,71 +374,73 @@ describe('Uploads', () => {
await expect(page.locator('.row-3 .cell-title')).toContainText('draft')
})
test('should restrict mimetype based on filterOptions', async () => {
const audioDoc = (
await payload.find({
collection: audioSlug,
depth: 0,
pagination: false,
})
).docs[0]
describe('filterOptions', () => {
test('should restrict mimetype based on filterOptions', async () => {
const audioDoc = (
await payload.find({
collection: audioSlug,
depth: 0,
pagination: false,
})
).docs[0]
await page.goto(audioURL.edit(audioDoc.id))
await page.waitForURL(audioURL.edit(audioDoc.id))
await page.goto(audioURL.edit(audioDoc.id))
await page.waitForURL(audioURL.edit(audioDoc.id))
// remove the selection and open the list drawer
await wait(500) // flake workaround
await page.locator('#field-audio .upload-relationship-details__remove').click()
// remove the selection and open the list drawer
await wait(500) // flake workaround
await page.locator('#field-audio .upload-relationship-details__remove').click()
await openDocDrawer(page, '#field-audio .upload__listToggler')
await openDocDrawer(page, '#field-audio .upload__listToggler')
const listDrawer = page.locator('[id^=list-drawer_1_]')
await expect(listDrawer).toBeVisible()
const listDrawer = page.locator('[id^=list-drawer_1_]')
await expect(listDrawer).toBeVisible()
await openDocDrawer(page, 'button.list-drawer__create-new-button.doc-drawer__toggler')
await expect(page.locator('[id^=doc-drawer_media_1_]')).toBeVisible()
await openDocDrawer(page, 'button.list-drawer__create-new-button.doc-drawer__toggler')
await expect(page.locator('[id^=doc-drawer_media_1_]')).toBeVisible()
// upload an image and try to select it
await page
.locator('[id^=doc-drawer_media_1_] .file-field__upload input[type="file"]')
.setInputFiles(path.resolve(dirname, './image.png'))
await page.locator('[id^=doc-drawer_media_1_] button#action-save').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'successfully',
)
await page
.locator('.payload-toast-container .toast-success .payload-toast-close-button')
.click()
// upload an image and try to select it
await page
.locator('[id^=doc-drawer_media_1_] .file-field__upload input[type="file"]')
.setInputFiles(path.resolve(dirname, './image.png'))
await page.locator('[id^=doc-drawer_media_1_] button#action-save').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'successfully',
)
await page
.locator('.payload-toast-container .toast-success .payload-toast-close-button')
.click()
// save the document and expect an error
await page.locator('button#action-save').click()
await expect(page.locator('.payload-toast-container .toast-error')).toContainText(
'The following field is invalid: Audio',
)
})
// save the document and expect an error
await page.locator('button#action-save').click()
await expect(page.locator('.payload-toast-container .toast-error')).toContainText(
'The following field is invalid: Audio',
)
})
test('should restrict uploads in drawer based on filterOptions', async () => {
const audioDoc = (
await payload.find({
collection: audioSlug,
depth: 0,
pagination: false,
})
).docs[0]
test('should restrict uploads in drawer based on filterOptions', async () => {
const audioDoc = (
await payload.find({
collection: audioSlug,
depth: 0,
pagination: false,
})
).docs[0]
await page.goto(audioURL.edit(audioDoc.id))
await page.waitForURL(audioURL.edit(audioDoc.id))
await page.goto(audioURL.edit(audioDoc.id))
await page.waitForURL(audioURL.edit(audioDoc.id))
// remove the selection and open the list drawer
await wait(500) // flake workaround
await page.locator('#field-audio .upload-relationship-details__remove').click()
// remove the selection and open the list drawer
await wait(500) // flake workaround
await page.locator('#field-audio .upload-relationship-details__remove').click()
await openDocDrawer(page, '.upload__listToggler')
await openDocDrawer(page, '.upload__listToggler')
const listDrawer = page.locator('[id^=list-drawer_1_]')
await expect(listDrawer).toBeVisible()
const listDrawer = page.locator('[id^=list-drawer_1_]')
await expect(listDrawer).toBeVisible()
await expect(listDrawer.locator('tbody tr')).toHaveCount(1)
await expect(listDrawer.locator('tbody tr')).toHaveCount(1)
})
})
test('should throw error when file is larger than the limit and abortOnLimit is true', async () => {