feat(plugin-import-export): use groupBy as SortBy when present and sort is unset (#13491)

### What?

When exporting, if no `sort` parameter is set but a `groupBy` parameter
is present in the list-view query, the export will treat `groupBy` as
the SortBy field and default to ascending order.
Additionally, the SortOrder field in the export UI is now hidden when no
sort is present, reducing visual noise and preventing irrelevant order
selection.

### Why?

Previously, exports ignored `groupBy` entirely when no sort was set,
leading to unsorted output even if the list view was grouped. Also,
SortOrder was always shown, even when no sort field was selected, which
could be confusing. These changes ensure exports reflect the list view’s
grouping and keep the UI focused.

### How?

- Check for `groupBy` in the query only when `sort` is unset.
- If found, set SortBy to `groupBy` and SortOrder to ascending.
- Hide the SortOrder field when `sort` is not set.
- Leave sorting unset if neither `sort` nor `groupBy` are present.
This commit is contained in:
Patrik
2025-08-15 14:56:58 -04:00
committed by GitHub
parent ec5b673aca
commit a7ed88b5fa
5 changed files with 92 additions and 33 deletions

View File

@@ -11,9 +11,9 @@ import {
useField,
useListQuery,
} from '@payloadcms/ui'
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { applySortOrder, stripSortDash } from '../../utilities/sortHelpers.js'
import { applySortOrder, normalizeQueryParam, stripSortDash } from '../../utilities/sortHelpers.js'
import { reduceFields } from '../FieldsToExport/reduceFields.js'
import { useImportExport } from '../ImportExportProvider/index.js'
import './index.scss'
@@ -28,6 +28,8 @@ export const SortBy: SelectFieldClientComponent = (props) => {
// Sibling order field ('asc' | 'desc') used when writing sort on change
const { value: sortOrder = 'asc' } = useField<string>({ path: 'sortOrder' })
// Needed so we can initialize sortOrder when SortOrder component is hidden
const { setValue: setSortOrder } = useField<'asc' | 'desc'>({ path: 'sortOrder' })
const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' })
const { query } = useListQuery()
@@ -61,23 +63,49 @@ export const SortBy: SelectFieldClientComponent = (props) => {
}
}, [sortRaw, fieldOptions, displayedValue])
// Sync the visible select from list-view query sort,
// but no need to write to the "sort" field here — SortOrder owns initial combined value.
// One-time init guard so clearing `sort` doesn't rehydrate from query again
const didInitRef = useRef(false)
// Sync the visible select from list-view query sort (preferred) or groupBy (fallback)
// and initialize both `sort` and `sortOrder` here as SortOrder may be hidden by admin.condition.
useEffect(() => {
if (id || !query?.sort || sortRaw) {
if (didInitRef.current) {
return
}
if (id) {
didInitRef.current = true
return
}
if (typeof sortRaw === 'string' && sortRaw.length > 0) {
// Already initialized elsewhere
didInitRef.current = true
return
}
if (!query.sort) {
const qsSort = normalizeQueryParam(query?.sort)
const qsGroupBy = normalizeQueryParam(query?.groupBy)
const source = qsSort ?? qsGroupBy
if (!source) {
didInitRef.current = true
return
}
const clean = stripSortDash(query.sort as string)
const option = fieldOptions.find((f) => f.value === clean)
const isDesc = !!qsSort && qsSort.startsWith('-')
const base = stripSortDash(source)
const order: 'asc' | 'desc' = isDesc ? 'desc' : 'asc'
// Write BOTH fields so preview/export have the right values even if SortOrder is hidden
setSort(applySortOrder(base, order))
setSortOrder(order)
const option = fieldOptions.find((f) => f.value === base)
if (option) {
setDisplayedValue(option)
}
}, [id, query?.sort, sortRaw, fieldOptions])
didInitRef.current = true
}, [id, query?.groupBy, query?.sort, sortRaw, fieldOptions, setSort, setSortOrder])
// When user selects a different field, store it with the current order applied
const onChange = (option: { id: string; label: ReactNode; value: string } | null) => {

View File

@@ -3,9 +3,9 @@
import type { SelectFieldClientComponent } from 'payload'
import { FieldLabel, ReactSelect, useDocumentInfo, useField, useListQuery } from '@payloadcms/ui'
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { applySortOrder, stripSortDash } from '../../utilities/sortHelpers.js'
import { applySortOrder, normalizeQueryParam, stripSortDash } from '../../utilities/sortHelpers.js'
import './index.scss'
const baseClass = 'sort-order-field'
@@ -20,17 +20,6 @@ const options = [
const defaultOption: OrderOption = options[0]
// Safely coerce query.sort to a string (ignore arrays)
const normalizeSortParam = (v: unknown): string | undefined => {
if (typeof v === 'string') {
return v
}
if (Array.isArray(v) && typeof v[0] === 'string') {
return v[0]
}
return undefined
}
export const SortOrder: SelectFieldClientComponent = (props) => {
const { id } = useDocumentInfo()
const { query } = useListQuery()
@@ -51,23 +40,51 @@ export const SortOrder: SelectFieldClientComponent = (props) => {
)
const [displayed, setDisplayed] = useState<null | OrderOption>(currentOption)
// Derive from list-view query.sort if present
// One-time init guard so clearing `sort` doesn't rehydrate from query again
const didInitRef = useRef(false)
// Derive from list-view query.sort if present; otherwise fall back to groupBy
useEffect(() => {
if (didInitRef.current) {
return
}
// Existing export -> don't initialize here
if (id) {
return
}
const qs = normalizeSortParam(query?.sort)
if (!qs) {
didInitRef.current = true
return
}
const isDesc = qs.startsWith('-')
const base = stripSortDash(qs)
const order: Order = isDesc ? 'desc' : 'asc'
// If sort already has a value, treat as initialized
if (typeof sortRaw === 'string' && sortRaw.length > 0) {
didInitRef.current = true
return
}
setOrder(order)
setSort(applySortOrder(base, order))
}, [id, query?.sort, setOrder, setSort])
const qsSort = normalizeQueryParam(query?.sort)
const qsGroupBy = normalizeQueryParam(query?.groupBy)
if (qsSort) {
const isDesc = qsSort.startsWith('-')
const base = stripSortDash(qsSort)
const order: Order = isDesc ? 'desc' : 'asc'
setOrder(order)
setSort(applySortOrder(base, order)) // combined: 'title' or '-title'
didInitRef.current = true
return
}
// Fallback: groupBy (always ascending)
if (qsGroupBy) {
setOrder('asc')
setSort(applySortOrder(qsGroupBy, 'asc')) // write 'groupByField' (no dash)
didInitRef.current = true
return
}
// Nothing to initialize
didInitRef.current = true
}, [id, query?.sort, query?.groupBy, sortRaw, setOrder, setSort])
// Keep the select's displayed option in sync with the stored order
useEffect(() => {

View File

@@ -125,6 +125,8 @@ export const getFields = (config: Config, pluginConfig?: ImportExportPluginConfi
components: {
Field: '@payloadcms/plugin-import-export/rsc#SortOrder',
},
// Only show when `sort` has a value
condition: ({ sort }) => typeof sort === 'string' && sort.trim().length > 0,
},
// @ts-expect-error - this is not correctly typed in plugins right now
label: ({ t }) => t('plugin-import-export:field-sort-order-label'),

View File

@@ -4,3 +4,14 @@ export const stripSortDash = (v?: null | string): string => (v ? v.replace(/^-/,
/** Apply order to a base field (("title","desc") -> "-title") */
export const applySortOrder = (field: string, order: 'asc' | 'desc'): string =>
order === 'desc' ? `-${field}` : field
// Safely coerce query.sort / query.groupBy to a string (ignore arrays)
export const normalizeQueryParam = (v: unknown): string | undefined => {
if (typeof v === 'string') {
return v
}
if (Array.isArray(v) && typeof v[0] === 'string') {
return v[0]
}
return undefined
}

View File

@@ -10,6 +10,7 @@ export const Pages: CollectionConfig = {
},
admin: {
useAsTitle: 'title',
groupBy: true,
},
versions: {
drafts: true,