Files
payload/packages/ui/src/utilities/buildTableState.ts
Jacob Fletcher bccf6ab16f 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>
2025-07-24 14:00:52 -04:00

260 lines
6.3 KiB
TypeScript

import type {
BuildTableStateArgs,
ClientCollectionConfig,
ClientConfig,
CollectionPreferences,
Column,
ErrorResult,
PaginatedDocs,
SanitizedCollectionConfig,
ServerFunction,
Where,
} from 'payload'
import { APIError, formatErrors } from 'payload'
import { isNumber } from 'payload/shared'
import { getClientConfig } from './getClientConfig.js'
import { renderFilters, renderTable } from './renderTable.js'
import { upsertPreferences } from './upsertPreferences.js'
type BuildTableStateSuccessResult = {
clientConfig?: ClientConfig
data: PaginatedDocs
errors?: never
preferences: CollectionPreferences
renderedFilters: Map<string, React.ReactNode>
state: Column[]
Table: React.ReactNode
}
type BuildTableStateErrorResult = {
data?: any
renderedFilters?: never
state?: never
Table?: never
} & (
| {
message: string
}
| ErrorResult
)
export type BuildTableStateResult = BuildTableStateErrorResult | BuildTableStateSuccessResult
export const buildTableStateHandler: ServerFunction<
BuildTableStateArgs,
Promise<BuildTableStateResult>
> = async (args) => {
const { req } = args
try {
const res = await buildTableState(args)
return res
} catch (err) {
req.payload.logger.error({ err, msg: `There was an error building form state` })
if (err.message === 'Could not find field schema for given path') {
return {
message: err.message,
}
}
if (err.message === 'Unauthorized') {
return null
}
return formatErrors(err)
}
}
const buildTableState = async (
args: BuildTableStateArgs,
): Promise<BuildTableStateSuccessResult> => {
const {
collectionSlug,
columns,
data: dataFromArgs,
enableRowSelections,
orderableFieldName,
parent,
query,
renderRowTypes,
req,
req: {
i18n,
payload,
payload: { config },
user,
},
tableAppearance,
} = args
const incomingUserSlug = user?.collection
const adminUserSlug = config.admin.user
// If we have a user slug, test it against the functions
if (incomingUserSlug) {
const adminAccessFunction = payload.collections[incomingUserSlug].config.access?.admin
// Run the admin access function from the config if it exists
if (adminAccessFunction) {
const canAccessAdmin = await adminAccessFunction({ req })
if (!canAccessAdmin) {
throw new Error('Unauthorized')
}
// Match the user collection to the global admin config
} else if (adminUserSlug !== incomingUserSlug) {
throw new Error('Unauthorized')
}
} else {
const hasUsers = await payload.find({
collection: adminUserSlug,
depth: 0,
limit: 1,
pagination: false,
})
// If there are users, we should not allow access because of /create-first-user
if (hasUsers.docs.length) {
throw new Error('Unauthorized')
}
}
const clientConfig = getClientConfig({
config,
i18n,
importMap: payload.importMap,
})
let collectionConfig: SanitizedCollectionConfig
let clientCollectionConfig: ClientCollectionConfig
if (!Array.isArray(collectionSlug)) {
if (req.payload.collections[collectionSlug]) {
collectionConfig = req.payload.collections[collectionSlug].config
clientCollectionConfig = clientConfig.collections.find(
(collection) => collection.slug === collectionSlug,
)
}
}
const collectionPreferences = await upsertPreferences<CollectionPreferences>({
key: Array.isArray(collectionSlug)
? `${parent.collectionSlug}-${parent.joinPath}`
: `collection-${collectionSlug}`,
req,
value: {
columns,
limit: isNumber(query?.limit) ? Number(query.limit) : undefined,
sort: query?.sort as string,
},
})
let data: PaginatedDocs = dataFromArgs
// lookup docs, if desired, i.e. within `join` field which initialize with `depth: 0`
if (!data?.docs || query) {
if (Array.isArray(collectionSlug)) {
if (!parent) {
throw new APIError('Unexpected array of collectionSlug, parent must be provided')
}
const select = {}
let currentSelectRef = select
const segments = parent.joinPath.split('.')
for (let i = 0; i < segments.length; i++) {
currentSelectRef[segments[i]] = i === segments.length - 1 ? true : {}
currentSelectRef = currentSelectRef[segments[i]]
}
const joinQuery: { limit?: number; page?: number; sort?: string; where?: Where } = {
sort: query?.sort as string,
where: query?.where,
}
if (query) {
if (!Number.isNaN(Number(query.limit))) {
joinQuery.limit = Number(query.limit)
}
if (!Number.isNaN(Number(query.page))) {
joinQuery.limit = Number(query.limit)
}
}
let parentDoc = await payload.findByID({
id: parent.id,
collection: parent.collectionSlug,
depth: 1,
joins: {
[parent.joinPath]: joinQuery,
},
overrideAccess: false,
select,
user: req.user,
})
for (let i = 0; i < segments.length; i++) {
if (i === segments.length - 1) {
data = parentDoc[segments[i]]
} else {
parentDoc = parentDoc[segments[i]]
}
}
} else {
data = await payload.find({
collection: collectionSlug,
depth: 0,
draft: true,
limit: query?.limit,
locale: req.locale,
overrideAccess: false,
page: query?.page,
sort: query?.sort,
user: req.user,
where: query?.where,
})
}
}
const { columnState, Table } = renderTable({
clientCollectionConfig,
clientConfig,
collectionConfig,
collections: Array.isArray(collectionSlug) ? collectionSlug : undefined,
columns,
data,
enableRowSelections,
i18n: req.i18n,
orderableFieldName,
payload,
query,
renderRowTypes,
tableAppearance,
useAsTitle: Array.isArray(collectionSlug)
? payload.collections[collectionSlug[0]]?.config?.admin?.useAsTitle
: collectionConfig?.admin?.useAsTitle,
})
let renderedFilters
if (collectionConfig) {
renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap)
}
return {
data,
preferences: collectionPreferences,
renderedFilters,
state: columnState,
Table,
}
}