Skip to content

Commit 9c20eb3

Browse files
authored
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
1 parent 5c81342 commit 9c20eb3

File tree

9 files changed

+397
-48
lines changed

9 files changed

+397
-48
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Helper function to create serializable value for client components
2+
export const createSerializableValue = (value: any): string => {
3+
if (value === null || value === undefined) {
4+
return 'null'
5+
}
6+
if (typeof value === 'object' && value?.relationTo && value?.value) {
7+
return `${value.relationTo}:${value.value}`
8+
}
9+
if (typeof value === 'object' && value?.id) {
10+
return String(value.id)
11+
}
12+
return String(value)
13+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { ClientCollectionConfig, ClientConfig } from 'payload'
2+
3+
// Helper function to extract display value from relationship
4+
export const extractRelationshipDisplayValue = (
5+
relationship: any,
6+
clientConfig: ClientConfig,
7+
relationshipConfig?: ClientCollectionConfig,
8+
): string => {
9+
if (!relationship) {
10+
return ''
11+
}
12+
13+
// Handle polymorphic relationships
14+
if (typeof relationship === 'object' && relationship?.relationTo && relationship?.value) {
15+
const config = clientConfig.collections.find((c) => c.slug === relationship.relationTo)
16+
return relationship.value?.[config?.admin?.useAsTitle || 'id'] || ''
17+
}
18+
19+
// Handle regular relationships
20+
if (typeof relationship === 'object' && relationship?.id) {
21+
return relationship[relationshipConfig?.admin?.useAsTitle || 'id'] || ''
22+
}
23+
24+
return String(relationship)
25+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Helper function to extract value or relationship ID for database queries
2+
export const extractValueOrRelationshipID = (relationship: any): any => {
3+
if (!relationship || typeof relationship !== 'object') {
4+
return relationship
5+
}
6+
7+
// For polymorphic relationships, preserve structure but ensure IDs are strings
8+
if (relationship?.relationTo && relationship?.value) {
9+
return {
10+
relationTo: relationship.relationTo,
11+
value: String(relationship.value?.id || relationship.value),
12+
}
13+
}
14+
15+
// For regular relationships, extract ID
16+
if (relationship?.id) {
17+
return String(relationship.id)
18+
}
19+
20+
return relationship
21+
}

packages/next/src/views/List/handleGroupBy.ts

Lines changed: 41 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import { renderTable } from '@payloadcms/ui/rsc'
1515
import { formatDate } from '@payloadcms/ui/shared'
1616
import { flattenAllFields } from 'payload'
1717

18+
import { createSerializableValue } from './createSerializableValue.js'
19+
import { extractRelationshipDisplayValue } from './extractRelationshipDisplayValue.js'
20+
import { extractValueOrRelationshipID } from './extractValueOrRelationshipID.js'
21+
1822
export const handleGroupBy = async ({
1923
clientCollectionConfig,
2024
clientConfig,
@@ -64,27 +68,19 @@ export const handleGroupBy = async ({
6468

6569
const groupByField = flattenedFields.find((f) => f.name === groupByFieldPath)
6670

67-
const relationshipConfig =
68-
groupByField?.type === 'relationship'
69-
? clientConfig.collections.find((c) => c.slug === groupByField.relationTo)
70-
: undefined
71-
71+
// Set up population for relationships
7272
let populate
7373

7474
if (groupByField?.type === 'relationship' && groupByField.relationTo) {
75-
const relationTo =
76-
typeof groupByField.relationTo === 'string'
77-
? [groupByField.relationTo]
78-
: groupByField.relationTo
79-
80-
if (Array.isArray(relationTo)) {
81-
relationTo.forEach((rel) => {
82-
if (!populate) {
83-
populate = {}
84-
}
85-
populate[rel] = { [relationshipConfig?.admin.useAsTitle || 'id']: true }
86-
})
87-
}
75+
const relationTo = Array.isArray(groupByField.relationTo)
76+
? groupByField.relationTo
77+
: [groupByField.relationTo]
78+
79+
populate = {}
80+
relationTo.forEach((rel) => {
81+
const config = clientConfig.collections.find((c) => c.slug === rel)
82+
populate[rel] = { [config?.admin?.useAsTitle || 'id']: true }
83+
})
8884
}
8985

9086
const distinct = await req.payload.findDistinct({
@@ -109,16 +105,11 @@ export const handleGroupBy = async ({
109105
}
110106

111107
await Promise.all(
112-
distinct.values.map(async (distinctValue, i) => {
108+
(distinct.values || []).map(async (distinctValue, i) => {
113109
const potentiallyPopulatedRelationship = distinctValue[groupByFieldPath]
114110

115-
const valueOrRelationshipID =
116-
groupByField?.type === 'relationship' &&
117-
potentiallyPopulatedRelationship &&
118-
typeof potentiallyPopulatedRelationship === 'object' &&
119-
'id' in potentiallyPopulatedRelationship
120-
? potentiallyPopulatedRelationship.id
121-
: potentiallyPopulatedRelationship
111+
// Extract value or relationship ID for database query
112+
const valueOrRelationshipID = extractValueOrRelationshipID(potentiallyPopulatedRelationship)
122113

123114
const groupData = await req.payload.find({
124115
collection: collectionSlug,
@@ -149,36 +140,40 @@ export const handleGroupBy = async ({
149140
},
150141
})
151142

152-
let heading = valueOrRelationshipID
153-
154-
if (
155-
groupByField?.type === 'relationship' &&
156-
potentiallyPopulatedRelationship &&
157-
typeof potentiallyPopulatedRelationship === 'object'
158-
) {
159-
heading =
160-
potentiallyPopulatedRelationship[relationshipConfig.admin.useAsTitle || 'id'] ||
161-
valueOrRelationshipID
162-
}
163-
164-
if (groupByField.type === 'date' && valueOrRelationshipID) {
143+
// Extract heading
144+
let heading: string
145+
146+
if (potentiallyPopulatedRelationship === null) {
147+
heading = req.i18n.t('general:noValue')
148+
} else if (groupByField?.type === 'relationship') {
149+
const relationshipConfig = Array.isArray(groupByField.relationTo)
150+
? undefined
151+
: clientConfig.collections.find((c) => c.slug === groupByField.relationTo)
152+
heading = extractRelationshipDisplayValue(
153+
potentiallyPopulatedRelationship,
154+
clientConfig,
155+
relationshipConfig,
156+
)
157+
} else if (groupByField?.type === 'date') {
165158
heading = formatDate({
166159
date: String(valueOrRelationshipID),
167160
i18n: req.i18n,
168161
pattern: clientConfig.admin.dateFormat,
169162
})
170-
}
171-
172-
if (groupByField.type === 'checkbox') {
163+
} else if (groupByField?.type === 'checkbox') {
173164
if (valueOrRelationshipID === true) {
174165
heading = req.i18n.t('general:true')
175166
}
176-
177167
if (valueOrRelationshipID === false) {
178168
heading = req.i18n.t('general:false')
179169
}
170+
} else {
171+
heading = String(valueOrRelationshipID)
180172
}
181173

174+
// Create serializable value for client
175+
const serializableValue = createSerializableValue(valueOrRelationshipID)
176+
182177
if (groupData.docs && groupData.docs.length > 0) {
183178
const { columnState: newColumnState, Table: NewTable } = renderTable({
184179
clientCollectionConfig,
@@ -189,10 +184,10 @@ export const handleGroupBy = async ({
189184
drawerSlug,
190185
enableRowSelections,
191186
groupByFieldPath,
192-
groupByValue: valueOrRelationshipID,
187+
groupByValue: serializableValue,
193188
heading: heading || req.i18n.t('general:noValue'),
194189
i18n: req.i18n,
195-
key: `table-${valueOrRelationshipID}`,
190+
key: `table-${serializableValue}`,
196191
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
197192
payload: req.payload,
198193
query,
@@ -210,7 +205,7 @@ export const handleGroupBy = async ({
210205
Table = []
211206
}
212207

213-
dataByGroup[valueOrRelationshipID] = groupData
208+
dataByGroup[serializableValue] = groupData
214209
;(Table as Array<React.ReactNode>)[i] = NewTable
215210
}
216211
}),
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import { categoriesSlug } from '../Categories/index.js'
4+
import { postsSlug } from '../Posts/index.js'
5+
6+
export const relationshipsSlug = 'relationships'
7+
8+
export const RelationshipsCollection: CollectionConfig = {
9+
slug: relationshipsSlug,
10+
admin: {
11+
useAsTitle: 'title',
12+
groupBy: true,
13+
},
14+
fields: [
15+
{
16+
name: 'title',
17+
type: 'text',
18+
},
19+
{
20+
name: 'PolyHasOneRelationship',
21+
type: 'relationship',
22+
relationTo: [categoriesSlug, postsSlug],
23+
},
24+
{
25+
name: 'PolyHasManyRelationship',
26+
type: 'relationship',
27+
relationTo: [categoriesSlug, postsSlug],
28+
hasMany: true,
29+
},
30+
{
31+
name: 'MonoHasOneRelationship',
32+
type: 'relationship',
33+
relationTo: categoriesSlug,
34+
},
35+
{
36+
name: 'MonoHasManyRelationship',
37+
type: 'relationship',
38+
relationTo: categoriesSlug,
39+
hasMany: true,
40+
},
41+
],
42+
}

test/group-by/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
66
import { CategoriesCollection } from './collections/Categories/index.js'
77
import { MediaCollection } from './collections/Media/index.js'
88
import { PostsCollection } from './collections/Posts/index.js'
9+
import { RelationshipsCollection } from './collections/Relationships/index.js'
910
import { seed } from './seed.js'
1011

1112
const filename = fileURLToPath(import.meta.url)
1213
const dirname = path.dirname(filename)
1314

1415
export default buildConfigWithDefaults({
15-
collections: [PostsCollection, CategoriesCollection, MediaCollection],
16+
collections: [PostsCollection, CategoriesCollection, MediaCollection, RelationshipsCollection],
1617
admin: {
1718
importMap: {
1819
baseDir: path.resolve(dirname),

test/group-by/e2e.spec.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,117 @@ test.describe('Group By', () => {
693693
).toHaveCount(0)
694694
})
695695

696+
test('should group by monomorphic has one relationship field', async () => {
697+
const relationshipsUrl = new AdminUrlUtil(serverURL, 'relationships')
698+
await page.goto(relationshipsUrl.list)
699+
700+
await addGroupBy(page, {
701+
fieldLabel: 'Mono Has One Relationship',
702+
fieldPath: 'MonoHasOneRelationship',
703+
})
704+
705+
// Should show populated values first, then "No value"
706+
await expect(page.locator('.table-wrap')).toHaveCount(2)
707+
await expect(page.locator('.group-by-header')).toHaveCount(2)
708+
709+
// Check that Category 1 appears as a group
710+
await expect(
711+
page.locator('.group-by-header__heading', { hasText: exactText('Category 1') }),
712+
).toBeVisible()
713+
714+
// Check that "No value" appears last
715+
await expect(
716+
page.locator('.group-by-header__heading', { hasText: exactText('No value') }),
717+
).toBeVisible()
718+
})
719+
720+
test('should group by monomorphic has many relationship field', async () => {
721+
const relationshipsUrl = new AdminUrlUtil(serverURL, 'relationships')
722+
await page.goto(relationshipsUrl.list)
723+
724+
await addGroupBy(page, {
725+
fieldLabel: 'Mono Has Many Relationship',
726+
fieldPath: 'MonoHasManyRelationship',
727+
})
728+
729+
// Should flatten hasMany arrays - each category gets its own group
730+
await expect(page.locator('.table-wrap')).toHaveCount(3)
731+
await expect(page.locator('.group-by-header')).toHaveCount(3)
732+
733+
// Both categories should appear as separate groups
734+
await expect(
735+
page.locator('.group-by-header__heading', { hasText: exactText('Category 1') }),
736+
).toBeVisible()
737+
738+
await expect(
739+
page.locator('.group-by-header__heading', { hasText: exactText('Category 2') }),
740+
).toBeVisible()
741+
742+
// "No value" should appear last
743+
await expect(
744+
page.locator('.group-by-header__heading', { hasText: exactText('No value') }),
745+
).toBeVisible()
746+
})
747+
748+
test('should group by polymorphic has one relationship field', async () => {
749+
const relationshipsUrl = new AdminUrlUtil(serverURL, 'relationships')
750+
await page.goto(relationshipsUrl.list)
751+
752+
await addGroupBy(page, {
753+
fieldLabel: 'Poly Has One Relationship',
754+
fieldPath: 'PolyHasOneRelationship',
755+
})
756+
757+
// Should show groups for both collection types plus "No value"
758+
await expect(page.locator('.table-wrap')).toHaveCount(3)
759+
await expect(page.locator('.group-by-header')).toHaveCount(3)
760+
761+
// Check for Category 1 group
762+
await expect(
763+
page.locator('.group-by-header__heading', { hasText: exactText('Category 1') }),
764+
).toBeVisible()
765+
766+
// Check for Post group (should display the post's title as useAsTitle)
767+
await expect(page.locator('.group-by-header__heading', { hasText: 'Find me' })).toBeVisible()
768+
769+
// "No value" should appear last
770+
await expect(
771+
page.locator('.group-by-header__heading', { hasText: exactText('No value') }),
772+
).toBeVisible()
773+
})
774+
775+
test('should group by polymorphic has many relationship field', async () => {
776+
const relationshipsUrl = new AdminUrlUtil(serverURL, 'relationships')
777+
await page.goto(relationshipsUrl.list)
778+
779+
await addGroupBy(page, {
780+
fieldLabel: 'Poly Has Many Relationship',
781+
fieldPath: 'PolyHasManyRelationship',
782+
})
783+
784+
// Should flatten polymorphic hasMany arrays - each relationship gets its own group
785+
// Expecting: Category 1, Category 2, Post, and "No value" = 4 groups
786+
await expect(page.locator('.table-wrap')).toHaveCount(4)
787+
await expect(page.locator('.group-by-header')).toHaveCount(4)
788+
789+
// Check for both category groups
790+
await expect(
791+
page.locator('.group-by-header__heading', { hasText: exactText('Category 1') }),
792+
).toBeVisible()
793+
794+
await expect(
795+
page.locator('.group-by-header__heading', { hasText: exactText('Category 2') }),
796+
).toBeVisible()
797+
798+
// Check for post group
799+
await expect(page.locator('.group-by-header__heading', { hasText: 'Find me' })).toBeVisible()
800+
801+
// "No value" should appear last (documents without any relationships)
802+
await expect(
803+
page.locator('.group-by-header__heading', { hasText: exactText('No value') }),
804+
).toBeVisible()
805+
})
806+
696807
test.describe('Trash', () => {
697808
test('should show trashed docs in trash view when group-by is active', async () => {
698809
await page.goto(url.list)

0 commit comments

Comments
 (0)