fix(next): groupBy for polymorphic relationships (#13781)

### What?

Fixed groupBy functionality for polymorphic relationships, which was
throwing errors.

<img width="1099" height="996" alt="Screenshot 2025-09-11 at 3 10 32 PM"
src="https://github.com/user-attachments/assets/bd11d557-7f21-4e09-8fe6-6a43d777d82c"
/>

### Why?

The groupBy feature failed for polymorphic relationships because:
- `relationshipConfig` was undefined when `relationTo` is an array
(polymorphic)
- ObjectId serialization errors when passing database objects to React
client components
- hasMany relationships weren't properly flattened into individual
groups
- "No Value" groups appeared first instead of populated groups

### How?

- Handle polymorphic relationship structure `{relationTo, value}`
correctly by finding the right collection config using `relationTo`
- Add proper collection config lookup for each relation in polymorphic
relationships during populate
- Flatten hasMany relationship arrays so documents with `[Category1,
Category2]` create separate groups for each

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211331842191589
This commit is contained in:
Patrik
2025-09-23 13:14:35 -04:00
committed by GitHub
parent 5c81342bce
commit 9c20eb34d4
9 changed files with 395 additions and 46 deletions

View File

@@ -0,0 +1,42 @@
import type { CollectionConfig } from 'payload'
import { categoriesSlug } from '../Categories/index.js'
import { postsSlug } from '../Posts/index.js'
export const relationshipsSlug = 'relationships'
export const RelationshipsCollection: CollectionConfig = {
slug: relationshipsSlug,
admin: {
useAsTitle: 'title',
groupBy: true,
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'PolyHasOneRelationship',
type: 'relationship',
relationTo: [categoriesSlug, postsSlug],
},
{
name: 'PolyHasManyRelationship',
type: 'relationship',
relationTo: [categoriesSlug, postsSlug],
hasMany: true,
},
{
name: 'MonoHasOneRelationship',
type: 'relationship',
relationTo: categoriesSlug,
},
{
name: 'MonoHasManyRelationship',
type: 'relationship',
relationTo: categoriesSlug,
hasMany: true,
},
],
}

View File

@@ -6,13 +6,14 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { CategoriesCollection } from './collections/Categories/index.js'
import { MediaCollection } from './collections/Media/index.js'
import { PostsCollection } from './collections/Posts/index.js'
import { RelationshipsCollection } from './collections/Relationships/index.js'
import { seed } from './seed.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [PostsCollection, CategoriesCollection, MediaCollection],
collections: [PostsCollection, CategoriesCollection, MediaCollection, RelationshipsCollection],
admin: {
importMap: {
baseDir: path.resolve(dirname),

View File

@@ -693,6 +693,117 @@ test.describe('Group By', () => {
).toHaveCount(0)
})
test('should group by monomorphic has one relationship field', async () => {
const relationshipsUrl = new AdminUrlUtil(serverURL, 'relationships')
await page.goto(relationshipsUrl.list)
await addGroupBy(page, {
fieldLabel: 'Mono Has One Relationship',
fieldPath: 'MonoHasOneRelationship',
})
// Should show populated values first, then "No value"
await expect(page.locator('.table-wrap')).toHaveCount(2)
await expect(page.locator('.group-by-header')).toHaveCount(2)
// Check that Category 1 appears as a group
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('Category 1') }),
).toBeVisible()
// Check that "No value" appears last
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('No value') }),
).toBeVisible()
})
test('should group by monomorphic has many relationship field', async () => {
const relationshipsUrl = new AdminUrlUtil(serverURL, 'relationships')
await page.goto(relationshipsUrl.list)
await addGroupBy(page, {
fieldLabel: 'Mono Has Many Relationship',
fieldPath: 'MonoHasManyRelationship',
})
// Should flatten hasMany arrays - each category gets its own group
await expect(page.locator('.table-wrap')).toHaveCount(3)
await expect(page.locator('.group-by-header')).toHaveCount(3)
// Both categories should appear as separate groups
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('Category 1') }),
).toBeVisible()
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('Category 2') }),
).toBeVisible()
// "No value" should appear last
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('No value') }),
).toBeVisible()
})
test('should group by polymorphic has one relationship field', async () => {
const relationshipsUrl = new AdminUrlUtil(serverURL, 'relationships')
await page.goto(relationshipsUrl.list)
await addGroupBy(page, {
fieldLabel: 'Poly Has One Relationship',
fieldPath: 'PolyHasOneRelationship',
})
// Should show groups for both collection types plus "No value"
await expect(page.locator('.table-wrap')).toHaveCount(3)
await expect(page.locator('.group-by-header')).toHaveCount(3)
// Check for Category 1 group
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('Category 1') }),
).toBeVisible()
// Check for Post group (should display the post's title as useAsTitle)
await expect(page.locator('.group-by-header__heading', { hasText: 'Find me' })).toBeVisible()
// "No value" should appear last
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('No value') }),
).toBeVisible()
})
test('should group by polymorphic has many relationship field', async () => {
const relationshipsUrl = new AdminUrlUtil(serverURL, 'relationships')
await page.goto(relationshipsUrl.list)
await addGroupBy(page, {
fieldLabel: 'Poly Has Many Relationship',
fieldPath: 'PolyHasManyRelationship',
})
// Should flatten polymorphic hasMany arrays - each relationship gets its own group
// Expecting: Category 1, Category 2, Post, and "No value" = 4 groups
await expect(page.locator('.table-wrap')).toHaveCount(4)
await expect(page.locator('.group-by-header')).toHaveCount(4)
// Check for both category groups
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('Category 1') }),
).toBeVisible()
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('Category 2') }),
).toBeVisible()
// Check for post group
await expect(page.locator('.group-by-header__heading', { hasText: 'Find me' })).toBeVisible()
// "No value" should appear last (documents without any relationships)
await expect(
page.locator('.group-by-header__heading', { hasText: exactText('No value') }),
).toBeVisible()
})
test.describe('Trash', () => {
test('should show trashed docs in trash view when group-by is active', async () => {
await page.goto(url.list)

View File

@@ -70,6 +70,7 @@ export interface Config {
posts: Post;
categories: Category;
media: Media;
relationships: Relationship;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
@@ -80,6 +81,7 @@ export interface Config {
posts: PostsSelect<false> | PostsSelect<true>;
categories: CategoriesSelect<false> | CategoriesSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
relationships: RelationshipsSelect<false> | RelationshipsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -186,6 +188,39 @@ export interface Media {
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relationships".
*/
export interface Relationship {
id: string;
title?: string | null;
PolyHasOneRelationship?:
| ({
relationTo: 'categories';
value: string | Category;
} | null)
| ({
relationTo: 'posts';
value: string | Post;
} | null);
PolyHasManyRelationship?:
| (
| {
relationTo: 'categories';
value: string | Category;
}
| {
relationTo: 'posts';
value: string | Post;
}
)[]
| null;
MonoHasOneRelationship?: (string | null) | Category;
MonoHasManyRelationship?: (string | Category)[] | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -229,6 +264,10 @@ export interface PayloadLockedDocument {
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'relationships';
value: string | Relationship;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -349,6 +388,19 @@ export interface MediaSelect<T extends boolean = true> {
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relationships_select".
*/
export interface RelationshipsSelect<T extends boolean = true> {
title?: T;
PolyHasOneRelationship?: T;
PolyHasManyRelationship?: T;
MonoHasOneRelationship?: T;
MonoHasManyRelationship?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".

View File

@@ -5,6 +5,7 @@ import { executePromises } from '../helpers/executePromises.js'
import { seedDB } from '../helpers/seed.js'
import { categoriesSlug } from './collections/Categories/index.js'
import { postsSlug } from './collections/Posts/index.js'
import { relationshipsSlug } from './collections/Relationships/index.js'
export const seed = async (_payload: Payload) => {
await executePromises(
@@ -62,6 +63,94 @@ export const seed = async (_payload: Payload) => {
category: category2.id,
},
})
// Get the first post for polymorphic relationships
const firstPost = await _payload.find({
collection: postsSlug,
limit: 1,
})
// Create relationship test documents
await Promise.all([
// Document with PolyHasOneRelationship to category
_payload.create({
collection: relationshipsSlug,
data: {
title: 'Poly HasOne (Category)',
PolyHasOneRelationship: {
relationTo: categoriesSlug,
value: category1.id,
},
},
}),
// Document with PolyHasOneRelationship to post
_payload.create({
collection: relationshipsSlug,
data: {
title: 'Poly HasOne (Post)',
PolyHasOneRelationship: {
relationTo: postsSlug,
value: firstPost.docs[0]?.id ?? '',
},
},
}),
// Document with PolyHasManyRelationship to both categories and posts
_payload.create({
collection: relationshipsSlug,
data: {
title: 'Poly HasMany (Mixed)',
PolyHasManyRelationship: [
{
relationTo: categoriesSlug,
value: category1.id,
},
{
relationTo: postsSlug,
value: firstPost.docs[0]?.id ?? '',
},
{
relationTo: categoriesSlug,
value: category2.id,
},
],
},
}),
// Document with MonoHasOneRelationship
_payload.create({
collection: relationshipsSlug,
data: {
title: 'Mono HasOne',
MonoHasOneRelationship: category1.id,
},
}),
// Document with MonoHasManyRelationship
_payload.create({
collection: relationshipsSlug,
data: {
title: 'Mono HasMany',
MonoHasManyRelationship: [category1.id, category2.id],
},
}),
// Documents with no relationships (for "No Value" testing)
_payload.create({
collection: relationshipsSlug,
data: {
title: 'No Relationships 1',
},
}),
_payload.create({
collection: relationshipsSlug,
data: {
title: 'No Relationships 2',
},
}),
])
},
],
false,
@@ -71,7 +160,7 @@ export const seed = async (_payload: Payload) => {
export async function clearAndSeedEverything(_payload: Payload) {
return await seedDB({
_payload,
collectionSlugs: [postsSlug, categoriesSlug, 'users', 'media'],
collectionSlugs: [postsSlug, categoriesSlug, relationshipsSlug, 'users', 'media'],
seedFunction: seed,
snapshotKey: 'groupByTests',
// uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),