Skip to content

Commit 2b7aa7a

Browse files
authored
feat: add groupBy support to query presets (#14808)
### What? Adds `groupBy` field support to query presets. Presets now save, apply, and clear groupBy state when switching between presets. ### Why? Previously, `groupBy` settings would persist when switching between presets. GroupBy was not being saved as part of the preset configuration, so users couldn't save their preferred grouping with a preset. ### How? Added `groupBy?: string` to the `QueryPreset` type and updated the preset collection config to include the field. Modified `QueryPresetBar` to handle groupBy when selecting, deselecting, resetting, saving, or creating presets. Created custom Field and Cell components to display groupBy values in the admin UI. #### Preset creation with group-by added <img width="711" height="684" alt="Screenshot 2025-12-03 at 3 15 03 PM" src="https://github.com/user-attachments/assets/5fe6ee88-6ed7-40f6-b190-df6b2e89460c" /> #### Preset collection list view with group-by preset <img width="2233" height="537" alt="Screenshot 2025-12-03 at 3 15 26 PM" src="https://github.com/user-attachments/assets/a56645fc-9106-4885-8c54-0e000c429499" /> #### Preset creation without group-by <img width="1250" height="704" alt="Screenshot 2025-12-03 at 3 15 48 PM" src="https://github.com/user-attachments/assets/3438585b-7aa9-4336-9101-2fab59733480" />
1 parent d56796b commit 2b7aa7a

File tree

14 files changed

+561
-7
lines changed

14 files changed

+561
-7
lines changed

packages/payload/src/query-presets/config.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const getQueryPresetsConfig = (config: Config): CollectionConfig => ({
1414
slug: queryPresetsCollectionSlug,
1515
access: getAccess(config),
1616
admin: {
17-
defaultColumns: ['title', 'isShared', 'access', 'where', 'columns'],
17+
defaultColumns: ['title', 'isShared', 'access', 'where', 'columns', 'groupBy'],
1818
hidden: true,
1919
useAsTitle: 'title',
2020
},
@@ -94,6 +94,17 @@ export const getQueryPresetsConfig = (config: Config): CollectionConfig => ({
9494
return true
9595
},
9696
},
97+
{
98+
name: 'groupBy',
99+
type: 'text',
100+
admin: {
101+
components: {
102+
Cell: '@payloadcms/ui#QueryPresetsGroupByCell',
103+
Field: '@payloadcms/ui#QueryPresetsGroupByField',
104+
},
105+
},
106+
label: 'Group By',
107+
},
97108
{
98109
name: 'relatedCollection',
99110
type: 'select',

packages/payload/src/query-presets/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type QueryPreset = {
2020
}
2121
}
2222
columns: CollectionPreferences['columns']
23+
groupBy?: string
2324
id: number | string
2425
isShared: boolean
2526
relatedCollection: CollectionSlug

packages/ui/src/elements/QueryPresets/QueryPresetBar/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export const QueryPresetBar: React.FC<{
8383
await refineListData(
8484
{
8585
columns: preset.columns ? transformColumnsToSearchParams(preset.columns) : undefined,
86+
groupBy: preset.groupBy || '',
8687
preset: preset.id,
8788
where: preset.where,
8889
},
@@ -96,6 +97,7 @@ export const QueryPresetBar: React.FC<{
9697
await refineListData(
9798
{
9899
columns: [],
100+
groupBy: '',
99101
preset: '',
100102
where: {},
101103
},
@@ -141,6 +143,7 @@ export const QueryPresetBar: React.FC<{
141143
await fetch(`${apiRoute}/payload-query-presets/${activePreset.id}`, {
142144
body: JSON.stringify({
143145
columns: transformColumnsToPreferences(query.columns),
146+
groupBy: query.groupBy,
144147
where: query.where,
145148
}),
146149
credentials: 'include',
@@ -178,6 +181,7 @@ export const QueryPresetBar: React.FC<{
178181
apiRoute,
179182
activePreset?.id,
180183
query.columns,
184+
query.groupBy,
181185
query.where,
182186
t,
183187
presetConfig?.labels?.singular,
@@ -216,6 +220,7 @@ export const QueryPresetBar: React.FC<{
216220
await refineListData(
217221
{
218222
columns: transformColumnsToSearchParams(activePreset.columns),
223+
groupBy: activePreset.groupBy || '',
219224
where: activePreset.where,
220225
},
221226
false,
@@ -263,6 +268,7 @@ export const QueryPresetBar: React.FC<{
263268
<CreateNewPresetDrawer
264269
initialData={{
265270
columns: transformColumnsToPreferences(query.columns),
271+
groupBy: query.groupBy,
266272
relatedCollection: collectionSlug,
267273
where: query.where,
268274
}}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { DefaultCellComponentProps } from 'payload'
2+
3+
import { toWords } from 'payload/shared'
4+
import React, { useMemo } from 'react'
5+
6+
import { useAuth } from '../../../../providers/Auth/index.js'
7+
import { useConfig } from '../../../../providers/Config/index.js'
8+
import { useTranslation } from '../../../../providers/Translation/index.js'
9+
import { reduceFieldsToOptions } from '../../../../utilities/reduceFieldsToOptions.js'
10+
11+
export const QueryPresetsGroupByCell: React.FC<DefaultCellComponentProps> = ({
12+
cellData,
13+
rowData,
14+
}) => {
15+
const { i18n } = useTranslation()
16+
const { permissions } = useAuth()
17+
const { config } = useConfig()
18+
19+
// Get the related collection from the row data
20+
const relatedCollection = rowData?.relatedCollection as string
21+
22+
// Get the collection config for the related collection
23+
const collectionConfig = useMemo(() => {
24+
if (!relatedCollection) {
25+
return null
26+
}
27+
28+
return config.collections?.find((col) => col.slug === relatedCollection)
29+
}, [relatedCollection, config.collections])
30+
31+
// Reduce fields to options to get proper labels
32+
const reducedFields = useMemo(() => {
33+
if (!collectionConfig) {
34+
return []
35+
}
36+
37+
const fieldPermissions = permissions?.collections?.[relatedCollection]?.fields
38+
39+
return reduceFieldsToOptions({
40+
fieldPermissions,
41+
fields: collectionConfig.fields,
42+
i18n,
43+
})
44+
}, [collectionConfig, permissions, relatedCollection, i18n])
45+
46+
if (!cellData || typeof cellData !== 'string') {
47+
return <div>No group by selected</div>
48+
}
49+
50+
const isDescending = cellData.startsWith('-')
51+
const fieldName = isDescending ? cellData.slice(1) : cellData
52+
const direction = isDescending ? 'descending' : 'ascending'
53+
54+
// Find the field option to get the proper label
55+
const fieldOption = reducedFields.find((field) => field.value === fieldName)
56+
const displayLabel = fieldOption?.label || toWords(fieldName)
57+
58+
return (
59+
<div>
60+
{displayLabel} ({direction})
61+
</div>
62+
)
63+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
@import '../../../../scss/styles';
2+
3+
@layer payload-default {
4+
.query-preset-group-by-field {
5+
.field-label {
6+
margin-bottom: calc(var(--base) / 2);
7+
}
8+
9+
.value-wrapper {
10+
background-color: var(--theme-elevation-50);
11+
padding: var(--base);
12+
display: flex;
13+
flex-wrap: wrap;
14+
gap: calc(var(--base) / 2);
15+
}
16+
}
17+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
'use client'
2+
import type { TextFieldClientComponent } from 'payload'
3+
4+
import { toWords } from 'payload/shared'
5+
import React, { useMemo } from 'react'
6+
7+
import { FieldLabel } from '../../../../fields/FieldLabel/index.js'
8+
import { useField } from '../../../../forms/useField/index.js'
9+
import { useAuth } from '../../../../providers/Auth/index.js'
10+
import { useConfig } from '../../../../providers/Config/index.js'
11+
import { useTranslation } from '../../../../providers/Translation/index.js'
12+
import { reduceFieldsToOptions } from '../../../../utilities/reduceFieldsToOptions.js'
13+
import { Pill } from '../../../Pill/index.js'
14+
import './index.scss'
15+
16+
export const QueryPresetsGroupByField: TextFieldClientComponent = ({
17+
field: { label, required },
18+
}) => {
19+
const { path, value } = useField()
20+
const { i18n } = useTranslation()
21+
const { permissions } = useAuth()
22+
const { config } = useConfig()
23+
24+
// Get the relatedCollection from the document data
25+
const relatedCollectionField = useField({ path: 'relatedCollection' })
26+
const relatedCollection = relatedCollectionField.value as string
27+
28+
// Get the collection config for the related collection
29+
const collectionConfig = useMemo(() => {
30+
if (!relatedCollection) {
31+
return null
32+
}
33+
34+
return config.collections?.find((col) => col.slug === relatedCollection)
35+
}, [relatedCollection, config.collections])
36+
37+
// Reduce fields to options to get proper labels
38+
const reducedFields = useMemo(() => {
39+
if (!collectionConfig) {
40+
return []
41+
}
42+
43+
const fieldPermissions = permissions?.collections?.[relatedCollection]?.fields
44+
45+
return reduceFieldsToOptions({
46+
fieldPermissions,
47+
fields: collectionConfig.fields,
48+
i18n,
49+
})
50+
}, [collectionConfig, permissions, relatedCollection, i18n])
51+
52+
const renderGroupBy = (groupByValue: string) => {
53+
if (!groupByValue) {
54+
return 'No group by selected'
55+
}
56+
57+
const isDescending = groupByValue.startsWith('-')
58+
const fieldName = isDescending ? groupByValue.slice(1) : groupByValue
59+
const direction = isDescending ? 'descending' : 'ascending'
60+
61+
// Find the field option to get the proper label
62+
const fieldOption = reducedFields.find((field) => field.value === fieldName)
63+
const displayLabel = fieldOption?.label || toWords(fieldName)
64+
65+
return (
66+
<Pill pillStyle="always-white" size="small">
67+
<b>{displayLabel}</b> ({direction})
68+
</Pill>
69+
)
70+
}
71+
72+
return (
73+
<div className="field-type query-preset-group-by-field">
74+
<FieldLabel as="h3" label={label} path={path} required={required} />
75+
<div className="value-wrapper">{renderGroupBy(value as string)}</div>
76+
</div>
77+
)
78+
}

packages/ui/src/exports/client/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ export { OrderableTable } from '../../elements/Table/OrderableTable.js'
3232
export { QueryPresetsColumnsCell } from '../../elements/QueryPresets/cells/ColumnsCell/index.js'
3333
export { QueryPresetsWhereCell } from '../../elements/QueryPresets/cells/WhereCell/index.js'
3434
export { QueryPresetsAccessCell } from '../../elements/QueryPresets/cells/AccessCell/index.js'
35+
export { QueryPresetsGroupByCell } from '../../elements/QueryPresets/cells/GroupByCell/index.js'
3536
export { QueryPresetsColumnField } from '../../elements/QueryPresets/fields/ColumnsField/index.js'
3637
export { QueryPresetsWhereField } from '../../elements/QueryPresets/fields/WhereField/index.js'
38+
export { QueryPresetsGroupByField } from '../../elements/QueryPresets/fields/GroupByField/index.js'
3739

3840
// elements
3941
export { ConfirmationModal } from '../../elements/ConfirmationModal/index.js'

test/group-by/collections/Posts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const PostsCollection: CollectionConfig = {
1111
groupBy: true,
1212
defaultColumns: ['title', 'category', 'createdAt', 'updatedAt'],
1313
},
14+
enableQueryPresets: true,
1415
trash: true,
1516
fields: [
1617
{

0 commit comments

Comments
 (0)