feat: group by (#13138)

Supports grouping documents by specific fields within the list view.

For example, imagine having a "posts" collection with a "categories"
field. To report on each specific category, you'd traditionally filter
for each category, one at a time. This can be quite inefficient,
especially with large datasets.

Now, you can interact with all categories simultaneously, grouped by
distinct values.

Here is a simple demonstration:


https://github.com/user-attachments/assets/0dcd19d2-e983-47e6-9ea2-cfdd2424d8b5

Enable on any collection by setting the `admin.groupBy` property:

```ts
import type { CollectionConfig } from 'payload'

const MyCollection: CollectionConfig = {
  // ...
  admin: {
    groupBy: true
  }
}
```

This is currently marked as beta to gather feedback while we reach full
stability, and to leave room for API changes and other modifications.
Use at your own risk.

Note: when using `groupBy`, bulk editing is done group-by-group. In the
future we may support cross-group bulk editing.

Dependent on #13102 (merged).

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210774523852467

---------

Co-authored-by: Paul Popus <paul@payloadcms.com>
This commit is contained in:
Jacob Fletcher
2025-07-24 14:00:52 -04:00
committed by GitHub
parent 14322a71bb
commit bccf6ab16f
124 changed files with 7181 additions and 447 deletions

View File

@@ -35,10 +35,12 @@ let payload: PayloadTestSDK<Config>
import { devUser } from 'credentials.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { goToNextPage, goToPreviousPage } from 'helpers/e2e/goToNextPage.js'
import { goToFirstCell } from 'helpers/e2e/navigateToDoc.js'
import { openListColumns } from 'helpers/e2e/openListColumns.js'
import { openListFilters } from 'helpers/e2e/openListFilters.js'
import { deletePreferences } from 'helpers/e2e/preferences.js'
import { sortColumn } from 'helpers/e2e/sortColumn.js'
import { toggleColumn, waitForColumnInURL } from 'helpers/e2e/toggleColumn.js'
import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import { closeListDrawer } from 'helpers/e2e/toggleListDrawer.js'
@@ -630,7 +632,7 @@ describe('List View', () => {
const tableItems = page.locator(tableRowLocator)
await expect(tableItems).toHaveCount(5)
await expect(page.locator('.collection-list__page-info')).toHaveText('1-5 of 6')
await expect(page.locator('.page-controls__page-info')).toHaveText('1-5 of 6')
await expect(page.locator('.per-page')).toContainText('Per Page: 5')
await page.goto(`${postsUrl.list}?limit=5&page=2`)
@@ -642,7 +644,7 @@ describe('List View', () => {
})
await page.waitForURL(new RegExp(`${postsUrl.list}\\?limit=5&page=1`))
await expect(page.locator('.collection-list__page-info')).toHaveText('1-3 of 3')
await expect(page.locator('.page-controls__page-info')).toHaveText('1-3 of 3')
})
test('should reset filter values for every additional filter', async () => {
@@ -1355,13 +1357,13 @@ describe('List View', () => {
await page.reload()
await expect(page.locator(tableRowLocator)).toHaveCount(5)
await expect(page.locator('.collection-list__page-info')).toHaveText('1-5 of 6')
await expect(page.locator('.page-controls__page-info')).toHaveText('1-5 of 6')
await expect(page.locator('.per-page')).toContainText('Per Page: 5')
await page.locator('.paginator button').nth(1).click()
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2')
await goToNextPage(page)
await expect(page.locator(tableRowLocator)).toHaveCount(1)
await page.locator('.paginator button').nth(0).click()
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=1')
await goToPreviousPage(page)
await expect(page.locator(tableRowLocator)).toHaveCount(5)
})
@@ -1375,7 +1377,7 @@ describe('List View', () => {
await page.reload()
const tableItems = page.locator(tableRowLocator)
await expect(tableItems).toHaveCount(5)
await expect(page.locator('.collection-list__page-info')).toHaveText('1-5 of 16')
await expect(page.locator('.page-controls__page-info')).toHaveText('1-5 of 16')
await expect(page.locator('.per-page')).toContainText('Per Page: 5')
await page.locator('.per-page .popup-button').click()
@@ -1387,11 +1389,11 @@ describe('List View', () => {
await expect(tableItems).toHaveCount(15)
await expect(page.locator('.per-page .per-page__base-button')).toContainText('Per Page: 15')
await page.locator('.paginator button').nth(1).click()
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2')
await goToNextPage(page)
await expect(tableItems).toHaveCount(1)
await expect(page.locator('.per-page')).toContainText('Per Page: 15') // ensure this hasn't changed
await expect(page.locator('.collection-list__page-info')).toHaveText('16-16 of 16')
await expect(page.locator('.page-controls__page-info')).toHaveText('16-16 of 16')
})
})
@@ -1410,17 +1412,13 @@ describe('List View', () => {
test('should sort', async () => {
await page.reload()
const upChevron = page.locator('#heading-number .sort-column__asc')
const downChevron = page.locator('#heading-number .sort-column__desc')
await upChevron.click()
await page.waitForURL(/sort=number/)
await sortColumn(page, { fieldPath: 'number', fieldLabel: 'Number', targetState: 'asc' })
await expect(page.locator('.row-1 .cell-number')).toHaveText('1')
await expect(page.locator('.row-2 .cell-number')).toHaveText('2')
await downChevron.click()
await page.waitForURL(/sort=-number/)
await sortColumn(page, { fieldPath: 'number', fieldLabel: 'Number', targetState: 'desc' })
await expect(page.locator('.row-1 .cell-number')).toHaveText('2')
await expect(page.locator('.row-2 .cell-number')).toHaveText('1')
@@ -1434,25 +1432,31 @@ describe('List View', () => {
hasText: exactText('Named Group > Some Text Field'),
})
.click()
const upChevron = page.locator('#heading-namedGroup__someTextField .sort-column__asc')
const downChevron = page.locator('#heading-namedGroup__someTextField .sort-column__desc')
await upChevron.click()
await page.waitForURL(/sort=namedGroup.someTextField/)
await sortColumn(page, {
fieldPath: 'namedGroup.someTextField',
fieldLabel: 'Named Group > Some Text Field',
targetState: 'asc',
})
await expect(page.locator('.row-1 .cell-namedGroup__someTextField')).toHaveText(
'<No Some Text Field>',
)
await expect(page.locator('.row-2 .cell-namedGroup__someTextField')).toHaveText(
'nested group text field',
)
await downChevron.click()
await page.waitForURL(/sort=-namedGroup.someTextField/)
await sortColumn(page, {
fieldPath: 'namedGroup.someTextField',
fieldLabel: 'Named Group > Some Text Field',
targetState: 'desc',
})
await expect(page.locator('.row-1 .cell-namedGroup__someTextField')).toHaveText(
'nested group text field',
)
await expect(page.locator('.row-2 .cell-namedGroup__someTextField')).toHaveText(
'<No Some Text Field>',
)
@@ -1466,29 +1470,31 @@ describe('List View', () => {
hasText: exactText('Named Tab > Nested Text Field In Named Tab'),
})
.click()
const upChevron = page.locator(
'#heading-namedTab__nestedTextFieldInNamedTab .sort-column__asc',
)
const downChevron = page.locator(
'#heading-namedTab__nestedTextFieldInNamedTab .sort-column__desc',
)
await upChevron.click()
await page.waitForURL(/sort=namedTab.nestedTextFieldInNamedTab/)
await sortColumn(page, {
fieldPath: 'namedTab.nestedTextFieldInNamedTab',
fieldLabel: 'Named Tab > Nested Text Field In Named Tab',
targetState: 'asc',
})
await expect(page.locator('.row-1 .cell-namedTab__nestedTextFieldInNamedTab')).toHaveText(
'<No Nested Text Field In Named Tab>',
)
await expect(page.locator('.row-2 .cell-namedTab__nestedTextFieldInNamedTab')).toHaveText(
'nested text in named tab',
)
await downChevron.click()
await page.waitForURL(/sort=-namedTab.nestedTextFieldInNamedTab/)
await sortColumn(page, {
fieldPath: 'namedTab.nestedTextFieldInNamedTab',
fieldLabel: 'Named Tab > Nested Text Field In Named Tab',
targetState: 'desc',
})
await expect(page.locator('.row-1 .cell-namedTab__nestedTextFieldInNamedTab')).toHaveText(
'nested text in named tab',
)
await expect(page.locator('.row-2 .cell-namedTab__nestedTextFieldInNamedTab')).toHaveText(
'<No Nested Text Field In Named Tab>',
)