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:
42
test/group-by/collections/Relationships/index.ts
Normal file
42
test/group-by/collections/Relationships/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user