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,13 @@
// Helper function to create serializable value for client components
export const createSerializableValue = (value: any): string => {
if (value === null || value === undefined) {
return 'null'
}
if (typeof value === 'object' && value?.relationTo && value?.value) {
return `${value.relationTo}:${value.value}`
}
if (typeof value === 'object' && value?.id) {
return String(value.id)
}
return String(value)
}

View File

@@ -0,0 +1,25 @@
import type { ClientCollectionConfig, ClientConfig } from 'payload'
// Helper function to extract display value from relationship
export const extractRelationshipDisplayValue = (
relationship: any,
clientConfig: ClientConfig,
relationshipConfig?: ClientCollectionConfig,
): string => {
if (!relationship) {
return ''
}
// Handle polymorphic relationships
if (typeof relationship === 'object' && relationship?.relationTo && relationship?.value) {
const config = clientConfig.collections.find((c) => c.slug === relationship.relationTo)
return relationship.value?.[config?.admin?.useAsTitle || 'id'] || ''
}
// Handle regular relationships
if (typeof relationship === 'object' && relationship?.id) {
return relationship[relationshipConfig?.admin?.useAsTitle || 'id'] || ''
}
return String(relationship)
}

View File

@@ -0,0 +1,21 @@
// Helper function to extract value or relationship ID for database queries
export const extractValueOrRelationshipID = (relationship: any): any => {
if (!relationship || typeof relationship !== 'object') {
return relationship
}
// For polymorphic relationships, preserve structure but ensure IDs are strings
if (relationship?.relationTo && relationship?.value) {
return {
relationTo: relationship.relationTo,
value: String(relationship.value?.id || relationship.value),
}
}
// For regular relationships, extract ID
if (relationship?.id) {
return String(relationship.id)
}
return relationship
}

View File

@@ -15,6 +15,10 @@ import { renderTable } from '@payloadcms/ui/rsc'
import { formatDate } from '@payloadcms/ui/shared'
import { flattenAllFields } from 'payload'
import { createSerializableValue } from './createSerializableValue.js'
import { extractRelationshipDisplayValue } from './extractRelationshipDisplayValue.js'
import { extractValueOrRelationshipID } from './extractValueOrRelationshipID.js'
export const handleGroupBy = async ({
clientCollectionConfig,
clientConfig,
@@ -64,27 +68,19 @@ export const handleGroupBy = async ({
const groupByField = flattenedFields.find((f) => f.name === groupByFieldPath)
const relationshipConfig =
groupByField?.type === 'relationship'
? clientConfig.collections.find((c) => c.slug === groupByField.relationTo)
: undefined
// Set up population for relationships
let populate
if (groupByField?.type === 'relationship' && groupByField.relationTo) {
const relationTo =
typeof groupByField.relationTo === 'string'
? [groupByField.relationTo]
: groupByField.relationTo
const relationTo = Array.isArray(groupByField.relationTo)
? groupByField.relationTo
: [groupByField.relationTo]
if (Array.isArray(relationTo)) {
relationTo.forEach((rel) => {
if (!populate) {
populate = {}
}
populate[rel] = { [relationshipConfig?.admin.useAsTitle || 'id']: true }
})
}
populate = {}
relationTo.forEach((rel) => {
const config = clientConfig.collections.find((c) => c.slug === rel)
populate[rel] = { [config?.admin?.useAsTitle || 'id']: true }
})
}
const distinct = await req.payload.findDistinct({
@@ -109,16 +105,11 @@ export const handleGroupBy = async ({
}
await Promise.all(
distinct.values.map(async (distinctValue, i) => {
(distinct.values || []).map(async (distinctValue, i) => {
const potentiallyPopulatedRelationship = distinctValue[groupByFieldPath]
const valueOrRelationshipID =
groupByField?.type === 'relationship' &&
potentiallyPopulatedRelationship &&
typeof potentiallyPopulatedRelationship === 'object' &&
'id' in potentiallyPopulatedRelationship
? potentiallyPopulatedRelationship.id
: potentiallyPopulatedRelationship
// Extract value or relationship ID for database query
const valueOrRelationshipID = extractValueOrRelationshipID(potentiallyPopulatedRelationship)
const groupData = await req.payload.find({
collection: collectionSlug,
@@ -149,36 +140,40 @@ export const handleGroupBy = async ({
},
})
let heading = valueOrRelationshipID
// Extract heading
let heading: string
if (
groupByField?.type === 'relationship' &&
potentiallyPopulatedRelationship &&
typeof potentiallyPopulatedRelationship === 'object'
) {
heading =
potentiallyPopulatedRelationship[relationshipConfig.admin.useAsTitle || 'id'] ||
valueOrRelationshipID
}
if (groupByField.type === 'date' && valueOrRelationshipID) {
if (potentiallyPopulatedRelationship === null) {
heading = req.i18n.t('general:noValue')
} else if (groupByField?.type === 'relationship') {
const relationshipConfig = Array.isArray(groupByField.relationTo)
? undefined
: clientConfig.collections.find((c) => c.slug === groupByField.relationTo)
heading = extractRelationshipDisplayValue(
potentiallyPopulatedRelationship,
clientConfig,
relationshipConfig,
)
} else if (groupByField?.type === 'date') {
heading = formatDate({
date: String(valueOrRelationshipID),
i18n: req.i18n,
pattern: clientConfig.admin.dateFormat,
})
}
if (groupByField.type === 'checkbox') {
} else if (groupByField?.type === 'checkbox') {
if (valueOrRelationshipID === true) {
heading = req.i18n.t('general:true')
}
if (valueOrRelationshipID === false) {
heading = req.i18n.t('general:false')
}
} else {
heading = String(valueOrRelationshipID)
}
// Create serializable value for client
const serializableValue = createSerializableValue(valueOrRelationshipID)
if (groupData.docs && groupData.docs.length > 0) {
const { columnState: newColumnState, Table: NewTable } = renderTable({
clientCollectionConfig,
@@ -189,10 +184,10 @@ export const handleGroupBy = async ({
drawerSlug,
enableRowSelections,
groupByFieldPath,
groupByValue: valueOrRelationshipID,
groupByValue: serializableValue,
heading: heading || req.i18n.t('general:noValue'),
i18n: req.i18n,
key: `table-${valueOrRelationshipID}`,
key: `table-${serializableValue}`,
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
payload: req.payload,
query,
@@ -210,7 +205,7 @@ export const handleGroupBy = async ({
Table = []
}
dataByGroup[valueOrRelationshipID] = groupData
dataByGroup[serializableValue] = groupData
;(Table as Array<React.ReactNode>)[i] = NewTable
}
}),

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'),