Skip to content

Commit efdf002

Browse files
authored
feat(plugin-import-export): adds sort order control and sync with sort-by field (#13478)
### What? This PR adds a dedicated `sortOrder` select field (Ascending / Descending) to the import-export plugin, alongside updates to the existing `SortBy` component. The new field and component logic keep the sort field (`sort`) in sync with the selected sort direction. ### Why? Previously, descending sorting did not work. While the `SortBy` field could read the list view’s `query.sort` param, if the value contained a leading dash (e.g. `-title`), it would not be handled correctly. Only ascending sorts such as `sort=title` worked, and the preview table would not reflect a descending order. ### How? - Added a new `sortOrder` select field to the export options schema. - Implemented a `SortOrder` custom component using ReactSelect: - On new exports, reads `query.sort` from the list view and sets both `sortOrder` and `sort` (combined value with or without a leading dash). - Handles external changes to `sort` that include a leading dash. - Updated `SortBy`: - No longer writes to `sort` during initial hydration—`SortOrder` owns initial value setting. - On user field changes, writes the combined value using the current `sortOrder`.
1 parent 217606a commit efdf002

File tree

49 files changed

+271
-14
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+271
-14
lines changed

packages/plugin-import-export/src/components/SortBy/index.tsx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import {
1111
useField,
1212
useListQuery,
1313
} from '@payloadcms/ui'
14-
import React, { useEffect, useState } from 'react'
14+
import React, { useEffect, useMemo, useState } from 'react'
1515

16+
import { applySortOrder, stripSortDash } from '../../utilities/sortHelpers.js'
1617
import { reduceFields } from '../FieldsToExport/reduceFields.js'
1718
import { useImportExport } from '../ImportExportProvider/index.js'
1819
import './index.scss'
@@ -21,53 +22,72 @@ const baseClass = 'sort-by-fields'
2122

2223
export const SortBy: SelectFieldClientComponent = (props) => {
2324
const { id } = useDocumentInfo()
24-
const { setValue, value } = useField<string>()
25+
26+
// The "sort" text field that stores 'title' or '-title'
27+
const { setValue: setSort, value: sortRaw } = useField<string>()
28+
29+
// Sibling order field ('asc' | 'desc') used when writing sort on change
30+
const { value: sortOrder = 'asc' } = useField<string>({ path: 'sortOrder' })
31+
2532
const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' })
2633
const { query } = useListQuery()
2734
const { getEntityConfig } = useConfig()
2835
const { collection } = useImportExport()
2936

37+
// ReactSelect's displayed option
3038
const [displayedValue, setDisplayedValue] = useState<{
3139
id: string
3240
label: ReactNode
3341
value: string
3442
} | null>(null)
3543

3644
const collectionConfig = getEntityConfig({ collectionSlug: collectionSlug ?? collection })
37-
const fieldOptions = reduceFields({ fields: collectionConfig?.fields })
45+
const fieldOptions = useMemo(
46+
() => reduceFields({ fields: collectionConfig?.fields }),
47+
[collectionConfig?.fields],
48+
)
3849

39-
// Sync displayedValue with value from useField
50+
// Normalize the stored value for display (strip the '-') and pick the option
4051
useEffect(() => {
41-
if (!value) {
52+
const clean = stripSortDash(sortRaw)
53+
if (!clean) {
4254
setDisplayedValue(null)
4355
return
4456
}
4557

46-
const option = fieldOptions.find((field) => field.value === value)
47-
if (option && (!displayedValue || displayedValue.value !== value)) {
58+
const option = fieldOptions.find((f) => f.value === clean)
59+
if (option && (!displayedValue || displayedValue.value !== clean)) {
4860
setDisplayedValue(option)
4961
}
50-
}, [displayedValue, fieldOptions, value])
62+
}, [sortRaw, fieldOptions, displayedValue])
5163

64+
// Sync the visible select from list-view query sort,
65+
// but no need to write to the "sort" field here — SortOrder owns initial combined value.
5266
useEffect(() => {
53-
if (id || !query?.sort || value) {
67+
if (id || !query?.sort || sortRaw) {
68+
return
69+
}
70+
71+
if (!query.sort) {
5472
return
5573
}
5674

57-
const option = fieldOptions.find((field) => field.value === query.sort)
75+
const clean = stripSortDash(query.sort as string)
76+
const option = fieldOptions.find((f) => f.value === clean)
5877
if (option) {
59-
setValue(option.value)
6078
setDisplayedValue(option)
6179
}
62-
}, [fieldOptions, id, query?.sort, value, setValue])
80+
}, [id, query?.sort, sortRaw, fieldOptions])
6381

82+
// When user selects a different field, store it with the current order applied
6483
const onChange = (option: { id: string; label: ReactNode; value: string } | null) => {
6584
if (!option) {
66-
setValue('')
85+
setSort('')
6786
setDisplayedValue(null)
6887
} else {
69-
setValue(option.value)
7088
setDisplayedValue(option)
89+
const next = applySortOrder(option.value, String(sortOrder) as 'asc' | 'desc')
90+
setSort(next)
7191
}
7292
}
7393

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.sort-order-field {
2+
--field-width: 25%;
3+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'use client'
2+
3+
import type { SelectFieldClientComponent } from 'payload'
4+
5+
import { FieldLabel, ReactSelect, useDocumentInfo, useField, useListQuery } from '@payloadcms/ui'
6+
import React, { useEffect, useMemo, useState } from 'react'
7+
8+
import { applySortOrder, stripSortDash } from '../../utilities/sortHelpers.js'
9+
import './index.scss'
10+
11+
const baseClass = 'sort-order-field'
12+
13+
type Order = 'asc' | 'desc'
14+
type OrderOption = { label: string; value: Order }
15+
16+
const options = [
17+
{ label: 'Ascending', value: 'asc' as const },
18+
{ label: 'Descending', value: 'desc' as const },
19+
] as const
20+
21+
const defaultOption: OrderOption = options[0]
22+
23+
// Safely coerce query.sort to a string (ignore arrays)
24+
const normalizeSortParam = (v: unknown): string | undefined => {
25+
if (typeof v === 'string') {
26+
return v
27+
}
28+
if (Array.isArray(v) && typeof v[0] === 'string') {
29+
return v[0]
30+
}
31+
return undefined
32+
}
33+
34+
export const SortOrder: SelectFieldClientComponent = (props) => {
35+
const { id } = useDocumentInfo()
36+
const { query } = useListQuery()
37+
38+
// 'sortOrder' select field: 'asc' | 'desc'
39+
const { setValue: setOrder, value: orderValueRaw } = useField<Order>()
40+
41+
// 'sort' text field: 'title' | '-title'
42+
const { setValue: setSort, value: sortRaw } = useField<string>({ path: 'sort' })
43+
44+
// The current order value, defaulting to 'asc' for UI
45+
const orderValue: Order = orderValueRaw || 'asc'
46+
47+
// Map 'asc' | 'desc' to the option object for ReactSelect
48+
const currentOption = useMemo<OrderOption>(
49+
() => options.find((o) => o.value === orderValue) ?? defaultOption,
50+
[orderValue],
51+
)
52+
const [displayed, setDisplayed] = useState<null | OrderOption>(currentOption)
53+
54+
// Derive from list-view query.sort if present
55+
useEffect(() => {
56+
if (id) {
57+
return
58+
}
59+
const qs = normalizeSortParam(query?.sort)
60+
if (!qs) {
61+
return
62+
}
63+
64+
const isDesc = qs.startsWith('-')
65+
const base = stripSortDash(qs)
66+
const order: Order = isDesc ? 'desc' : 'asc'
67+
68+
setOrder(order)
69+
setSort(applySortOrder(base, order))
70+
}, [id, query?.sort, setOrder, setSort])
71+
72+
// Keep the select's displayed option in sync with the stored order
73+
useEffect(() => {
74+
setDisplayed(currentOption ?? defaultOption)
75+
}, [currentOption])
76+
77+
// Handle manual order changes via ReactSelect:
78+
// - update the order field
79+
// - rewrite the combined "sort" string to add/remove the leading '-'
80+
const onChange = (option: null | OrderOption) => {
81+
const next = option?.value ?? 'asc'
82+
setOrder(next)
83+
84+
const base = stripSortDash(sortRaw)
85+
if (base) {
86+
setSort(applySortOrder(base, next))
87+
}
88+
89+
setDisplayed(option ?? defaultOption)
90+
}
91+
92+
return (
93+
<div className={baseClass}>
94+
<FieldLabel label={props.field.label} path={props.path} />
95+
<ReactSelect
96+
className={baseClass}
97+
disabled={props.readOnly}
98+
inputId={`field-${props.path.replace(/\./g, '__')}`}
99+
isClearable={false}
100+
isSearchable={false}
101+
// @ts-expect-error react-select option typing differs from our local type
102+
onChange={onChange}
103+
options={options as unknown as OrderOption[]}
104+
// @ts-expect-error react-select option typing differs from our local type
105+
value={displayed}
106+
/>
107+
</div>
108+
)
109+
}

packages/plugin-import-export/src/export/getFields.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ export const getFields = (config: Config, pluginConfig?: ImportExportPluginConfi
118118
// @ts-expect-error - this is not correctly typed in plugins right now
119119
label: ({ t }) => t('plugin-import-export:field-sort-label'),
120120
},
121+
{
122+
name: 'sortOrder',
123+
type: 'select',
124+
admin: {
125+
components: {
126+
Field: '@payloadcms/plugin-import-export/rsc#SortOrder',
127+
},
128+
},
129+
// @ts-expect-error - this is not correctly typed in plugins right now
130+
label: ({ t }) => t('plugin-import-export:field-sort-order-label'),
131+
options: [
132+
{ label: 'Ascending', value: 'asc' },
133+
{ label: 'Descending', value: 'desc' },
134+
],
135+
},
121136
...(localeField ? [localeField] : []),
122137
{
123138
name: 'drafts',

packages/plugin-import-export/src/exports/rsc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export { Page } from '../components/Page/index.js'
77
export { Preview } from '../components/Preview/index.js'
88
export { SelectionToUseField } from '../components/SelectionToUseField/index.js'
99
export { SortBy } from '../components/SortBy/index.js'
10+
export { SortOrder } from '../components/SortOrder/index.js'

packages/plugin-import-export/src/translations/languages/ar.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const arTranslations: PluginDefaultTranslationsObject = {
1515
'field-page-label': 'صفحة',
1616
'field-selectionToUse-label': 'اختيار للاستخدام',
1717
'field-sort-label': 'ترتيب حسب',
18+
'field-sort-order-label': 'ترتيب',
1819
'selectionToUse-allDocuments': 'استخدم جميع الوثائق',
1920
'selectionToUse-currentFilters': 'استخدم الفلاتر الحالية',
2021
'selectionToUse-currentSelection': 'استخدم الاختيار الحالي',

packages/plugin-import-export/src/translations/languages/az.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const azTranslations: PluginDefaultTranslationsObject = {
1515
'field-page-label': 'Səhifə',
1616
'field-selectionToUse-label': 'İstifadə etmək üçün seçim',
1717
'field-sort-label': 'Sırala',
18+
'field-sort-order-label': 'Sıralama',
1819
'selectionToUse-allDocuments': 'Bütün sənədlərdən istifadə edin',
1920
'selectionToUse-currentFilters': 'Cari filtrlərdən istifadə edin',
2021
'selectionToUse-currentSelection': 'Cari seçimi istifadə edin',

packages/plugin-import-export/src/translations/languages/bg.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const bgTranslations: PluginDefaultTranslationsObject = {
1515
'field-page-label': 'Страница',
1616
'field-selectionToUse-label': 'Избор за използване',
1717
'field-sort-label': 'Сортирай по',
18+
'field-sort-order-label': 'Ред на сортиране',
1819
'selectionToUse-allDocuments': 'Използвайте всички документи',
1920
'selectionToUse-currentFilters': 'Използвайте текущите филтри',
2021
'selectionToUse-currentSelection': 'Използвайте текущия избор',

packages/plugin-import-export/src/translations/languages/ca.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const caTranslations: PluginDefaultTranslationsObject = {
1515
'field-page-label': 'Pàgina',
1616
'field-selectionToUse-label': 'Selecció per utilitzar',
1717
'field-sort-label': 'Ordena per',
18+
'field-sort-order-label': 'Ordre de classificació',
1819
'selectionToUse-allDocuments': 'Utilitzeu tots els documents',
1920
'selectionToUse-currentFilters': 'Utilitza els filtres actuals',
2021
'selectionToUse-currentSelection': 'Utilitza la selecció actual',

packages/plugin-import-export/src/translations/languages/cs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const csTranslations: PluginDefaultTranslationsObject = {
1515
'field-page-label': 'Stránka',
1616
'field-selectionToUse-label': 'Výběr k použití',
1717
'field-sort-label': 'Seřadit podle',
18+
'field-sort-order-label': 'Řazení',
1819
'selectionToUse-allDocuments': 'Použijte všechny dokumenty',
1920
'selectionToUse-currentFilters': 'Použijte aktuální filtry',
2021
'selectionToUse-currentSelection': 'Použijte aktuální výběr',

0 commit comments

Comments
 (0)