feat: relationship sortOptions property (#4301)
* feat: adds sortOptions property to relationship field * chore: fix lexical int tests * feat: simplifies logic & updates joi schema definition * feat: revert to default when searching in relationship select * fix types and joi schema * type adjustments --------- Co-authored-by: Alessio Gravili <alessio@bonfireleads.com> Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
This commit is contained in:
@@ -70,6 +70,43 @@ Set to `true` if you'd like this field to be sortable within the Admin UI using
|
||||
|
||||
Set to `false` if you'd like to disable the ability to create new documents from within the relationship field (hides the "Add new" button in the admin UI).
|
||||
|
||||
**`sortOptions`**
|
||||
|
||||
The `sortOptions` property allows you to define a default sorting order for the options within a Relationship field's dropdown. This can be particularly useful for ensuring that the most relevant options are presented first to the user.
|
||||
|
||||
You can specify `sortOptions` in two ways:
|
||||
|
||||
**As a string:**
|
||||
|
||||
Provide a string to define a global default sort field for all relationship field dropdowns across different collections. You can prefix the field name with a minus symbol ("-") to sort in descending order.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
sortOptions: 'fieldName',
|
||||
```
|
||||
This configuration will sort all relationship field dropdowns by `"fieldName"` in ascending order.
|
||||
|
||||
**As an object :**
|
||||
|
||||
Specify an object where keys are collection slugs and values are strings representing the field names to sort by. This allows for different sorting fields for each collection's relationship dropdown.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
sortOptions: {
|
||||
"pages": "fieldName1",
|
||||
"posts": "-fieldName2",
|
||||
"categories": "fieldName3"
|
||||
}
|
||||
```
|
||||
In this configuration:
|
||||
- Dropdowns related to `pages` will be sorted by `"fieldName1"` in ascending order.
|
||||
- Dropdowns for `posts` will use `"fieldName2"` for sorting in descending order (noted by the "-" prefix).
|
||||
- Dropdowns associated with `categories` will sort based on `"fieldName3"` in ascending order.
|
||||
|
||||
Note: If `sortOptions` is not defined, the default sorting behavior of the Relationship field dropdown will be used.
|
||||
|
||||
### Filtering relationship options
|
||||
|
||||
Options can be dynamically limited by supplying a [query constraint](/docs/queries/overview), which will be used both for validating input and filtering available relationships in the UI.
|
||||
|
||||
@@ -40,13 +40,14 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
admin: {
|
||||
allowCreate = true,
|
||||
className,
|
||||
components: { Error, Label } = {},
|
||||
condition,
|
||||
description,
|
||||
isSortable = true,
|
||||
readOnly,
|
||||
sortOptions,
|
||||
style,
|
||||
width,
|
||||
components: { Error, Label } = {},
|
||||
} = {},
|
||||
filterOptions,
|
||||
hasMany,
|
||||
@@ -139,7 +140,14 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
|
||||
if (resultsFetched < 10) {
|
||||
const collection = collections.find((coll) => coll.slug === relation)
|
||||
const fieldToSearch = collection?.admin?.useAsTitle || 'id'
|
||||
let fieldToSearch = collection?.defaultSort || collection?.admin?.useAsTitle || 'id'
|
||||
if (!searchArg) {
|
||||
if (typeof sortOptions === 'string') {
|
||||
fieldToSearch = sortOptions
|
||||
} else if (sortOptions?.[relation]) {
|
||||
fieldToSearch = sortOptions[relation]
|
||||
}
|
||||
}
|
||||
|
||||
const query: {
|
||||
[key: string]: unknown
|
||||
@@ -236,6 +244,7 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
locale,
|
||||
filterOptionsResult,
|
||||
serverURL,
|
||||
sortOptions,
|
||||
api,
|
||||
i18n,
|
||||
config,
|
||||
@@ -252,7 +261,7 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
(searchArg: string, valueArg: Value | Value[]) => {
|
||||
if (search !== searchArg) {
|
||||
setLastLoadedPage({})
|
||||
updateSearch(searchArg, valueArg)
|
||||
updateSearch(searchArg, valueArg, searchArg !== '')
|
||||
}
|
||||
},
|
||||
[search, updateSearch],
|
||||
|
||||
@@ -80,6 +80,7 @@ export type {
|
||||
Option,
|
||||
OptionObject,
|
||||
PointField,
|
||||
PolymorphicRelationshipField,
|
||||
RadioField,
|
||||
RelationshipField,
|
||||
RelationshipValue,
|
||||
@@ -87,6 +88,7 @@ export type {
|
||||
RowAdmin,
|
||||
RowField,
|
||||
SelectField,
|
||||
SingleRelationshipField,
|
||||
Tab,
|
||||
TabAsField,
|
||||
TabsAdmin,
|
||||
|
||||
@@ -366,6 +366,11 @@ export const relationship = baseField.keys({
|
||||
Label: componentSchema,
|
||||
}),
|
||||
isSortable: joi.boolean().default(false),
|
||||
sortOptions: joi.alternatives().conditional(joi.ref('...relationTo'), {
|
||||
is: joi.string(),
|
||||
otherwise: joi.object().pattern(joi.string(), joi.string()),
|
||||
then: joi.string(),
|
||||
}),
|
||||
}),
|
||||
defaultValue: joi.alternatives().try(joi.func()),
|
||||
filterOptions: joi.alternatives().try(joi.object(), joi.func()),
|
||||
|
||||
@@ -430,19 +430,10 @@ export type SelectField = FieldBase & {
|
||||
type: 'select'
|
||||
}
|
||||
|
||||
export type RelationshipField = FieldBase & {
|
||||
admin?: Admin & {
|
||||
allowCreate?: boolean
|
||||
components?: {
|
||||
Error?: React.ComponentType<ErrorProps>
|
||||
Label?: React.ComponentType<LabelProps>
|
||||
}
|
||||
isSortable?: boolean
|
||||
}
|
||||
type SharedRelationshipProperties = FieldBase & {
|
||||
filterOptions?: FilterOptions
|
||||
hasMany?: boolean
|
||||
maxDepth?: number
|
||||
relationTo: string | string[]
|
||||
type: 'relationship'
|
||||
} & (
|
||||
| {
|
||||
@@ -473,6 +464,28 @@ export type RelationshipField = FieldBase & {
|
||||
}
|
||||
)
|
||||
|
||||
type RelationshipAdmin = Admin & {
|
||||
allowCreate?: boolean
|
||||
components?: {
|
||||
Error?: React.ComponentType<ErrorProps>
|
||||
Label?: React.ComponentType<LabelProps>
|
||||
}
|
||||
isSortable?: boolean
|
||||
}
|
||||
export type PolymorphicRelationshipField = SharedRelationshipProperties & {
|
||||
admin?: RelationshipAdmin & {
|
||||
sortOptions?: { [collectionSlug: string]: string }
|
||||
}
|
||||
relationTo: string[]
|
||||
}
|
||||
export type SingleRelationshipField = SharedRelationshipProperties & {
|
||||
admin?: RelationshipAdmin & {
|
||||
sortOptions?: string
|
||||
}
|
||||
relationTo: string
|
||||
}
|
||||
export type RelationshipField = PolymorphicRelationshipField | SingleRelationshipField
|
||||
|
||||
export type ValueWithRelation = {
|
||||
relationTo: string
|
||||
value: number | string
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { RelationshipField } from 'payload/types'
|
||||
import type { SingleRelationshipField } from 'payload/types'
|
||||
|
||||
const createParentField = (
|
||||
relationTo: string,
|
||||
overrides?: Partial<
|
||||
RelationshipField & {
|
||||
SingleRelationshipField & {
|
||||
hasMany: false
|
||||
}
|
||||
>,
|
||||
): RelationshipField => ({
|
||||
): SingleRelationshipField => ({
|
||||
name: 'parent',
|
||||
relationTo,
|
||||
type: 'relationship',
|
||||
|
||||
@@ -10,6 +10,11 @@ const ArrayFields: CollectionConfig = {
|
||||
enableRichTextLink: false,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'items',
|
||||
defaultValue: arrayDefaultValue,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ArrayField } from '../../payload-types'
|
||||
|
||||
export const arrayDoc: Partial<ArrayField> = {
|
||||
title: 'array doc 1',
|
||||
items: [
|
||||
{
|
||||
text: 'first row',
|
||||
@@ -35,3 +36,31 @@ export const arrayDoc: Partial<ArrayField> = {
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const anotherArrayDoc: Partial<ArrayField> = {
|
||||
title: 'array doc 2',
|
||||
items: [
|
||||
{
|
||||
text: 'first row',
|
||||
},
|
||||
{
|
||||
text: 'second row',
|
||||
},
|
||||
{
|
||||
text: 'third row',
|
||||
},
|
||||
],
|
||||
collapsedArray: [
|
||||
{
|
||||
text: 'initialize collapsed',
|
||||
},
|
||||
],
|
||||
arrayWithMinRows: [
|
||||
{
|
||||
text: 'first row',
|
||||
},
|
||||
{
|
||||
text: 'second row',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -13,12 +13,23 @@ const RelationshipFields: CollectionConfig = {
|
||||
relationTo: ['text-fields', 'array-fields'],
|
||||
required: true,
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
sortOptions: {
|
||||
'text-fields': '-id',
|
||||
'array-fields': '-id',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'relationHasManyPolymorphic',
|
||||
type: 'relationship',
|
||||
relationTo: ['text-fields', 'array-fields'],
|
||||
hasMany: true,
|
||||
admin: {
|
||||
sortOptions: {
|
||||
'text-fields': '-text',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'relationToSelf',
|
||||
|
||||
@@ -10,6 +10,7 @@ const TextFields: CollectionConfig = {
|
||||
admin: {
|
||||
useAsTitle: 'text',
|
||||
},
|
||||
defaultSort: 'id',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
|
||||
@@ -7,3 +7,7 @@ export const textDoc: Partial<TextField> = {
|
||||
text: 'Seeded text document',
|
||||
localizedText: 'Localized text',
|
||||
}
|
||||
|
||||
export const anotherTextDoc: Partial<TextField> = {
|
||||
text: 'Another text document',
|
||||
}
|
||||
|
||||
@@ -1666,6 +1666,30 @@ describe('fields', () => {
|
||||
await page.click('#action-save', { delay: 100 })
|
||||
await expect(page.locator('.Toastify')).toContainText('Please correct invalid fields')
|
||||
})
|
||||
|
||||
test('should sort relationship options by sortOptions property (ID in ascending order)', async () => {
|
||||
await page.goto(url.create)
|
||||
|
||||
const field = page.locator('#field-relationship')
|
||||
await field.click()
|
||||
|
||||
const firstOption = page.locator('.rs__option').first()
|
||||
await expect(firstOption).toBeVisible()
|
||||
const firstOptionText = await firstOption.textContent()
|
||||
expect(firstOptionText.trim()).toBe('Another text document')
|
||||
})
|
||||
|
||||
test('should sort relationHasManyPolymorphic options by sortOptions property: text-fields collection (items in descending order)', async () => {
|
||||
await page.goto(url.create)
|
||||
|
||||
const field = page.locator('#field-relationHasManyPolymorphic')
|
||||
await field.click()
|
||||
|
||||
const firstOption = page.locator('.rs__option').first()
|
||||
await expect(firstOption).toBeVisible()
|
||||
const firstOptionText = await firstOption.textContent()
|
||||
expect(firstOptionText.trim()).toBe('Seeded text document')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload', () => {
|
||||
|
||||
@@ -64,8 +64,8 @@ describe('Lexical', () => {
|
||||
await payload.find({
|
||||
collection: arrayFieldsSlug,
|
||||
where: {
|
||||
id: {
|
||||
exists: true,
|
||||
title: {
|
||||
equals: 'array doc 1',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -75,8 +75,8 @@ describe('Lexical', () => {
|
||||
await payload.find({
|
||||
collection: uploadsSlug,
|
||||
where: {
|
||||
id: {
|
||||
exists: true,
|
||||
filename: {
|
||||
equals: 'payload.jpg',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -86,8 +86,8 @@ describe('Lexical', () => {
|
||||
await payload.find({
|
||||
collection: textFieldsSlug,
|
||||
where: {
|
||||
id: {
|
||||
exists: true,
|
||||
text: {
|
||||
equals: 'Seeded text document',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -97,8 +97,8 @@ describe('Lexical', () => {
|
||||
await payload.find({
|
||||
collection: richTextFieldsSlug,
|
||||
where: {
|
||||
id: {
|
||||
exists: true,
|
||||
title: {
|
||||
equals: 'Rich Text',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -166,6 +166,7 @@ export interface User {
|
||||
}
|
||||
export interface ArrayField {
|
||||
id: string
|
||||
title?: string | null
|
||||
items: {
|
||||
text: string
|
||||
id?: string | null
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type Payload } from '../../packages/payload/src'
|
||||
import getFileByPath from '../../packages/payload/src/uploads/getFileByPath'
|
||||
import { devUser } from '../credentials'
|
||||
import { seedDB } from '../helpers/seed'
|
||||
import { arrayDoc } from './collections/Array/shared'
|
||||
import { anotherArrayDoc, arrayDoc } from './collections/Array/shared'
|
||||
import { blocksDoc } from './collections/Blocks/shared'
|
||||
import { codeDoc } from './collections/Code/shared'
|
||||
import { collapsibleDoc } from './collections/Collapsible/shared'
|
||||
@@ -20,7 +20,7 @@ import { radiosDoc } from './collections/Radio/shared'
|
||||
import { richTextBulletsDocData, richTextDocData } from './collections/RichText/data'
|
||||
import { selectsDoc } from './collections/Select/shared'
|
||||
import { tabsDoc } from './collections/Tabs/shared'
|
||||
import { textDoc } from './collections/Text/shared'
|
||||
import { anotherTextDoc, textDoc } from './collections/Text/shared'
|
||||
import { uploadsDoc } from './collections/Upload/shared'
|
||||
import {
|
||||
arrayFieldsSlug,
|
||||
@@ -56,9 +56,17 @@ export async function clearAndSeedEverything(_payload: Payload) {
|
||||
// Get both files in parallel
|
||||
const [jpgFile, pngFile] = await Promise.all([getFileByPath(jpgPath), getFileByPath(pngPath)])
|
||||
|
||||
const [createdArrayDoc, createdTextDoc, createdPNGDoc] = await Promise.all([
|
||||
const [
|
||||
createdArrayDoc,
|
||||
createdAnotherArrayDoc,
|
||||
createdTextDoc,
|
||||
createdAnotherTextDoc,
|
||||
createdPNGDoc,
|
||||
] = await Promise.all([
|
||||
_payload.create({ collection: arrayFieldsSlug, data: arrayDoc }),
|
||||
_payload.create({ collection: arrayFieldsSlug, data: anotherArrayDoc }),
|
||||
_payload.create({ collection: textFieldsSlug, data: textDoc }),
|
||||
_payload.create({ collection: textFieldsSlug, data: anotherTextDoc }),
|
||||
_payload.create({ collection: uploadsSlug, data: {}, file: pngFile }),
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user