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:
Patrik
2023-11-29 16:22:47 -05:00
committed by GitHub
parent 3502ce720b
commit 224cddd045
15 changed files with 176 additions and 27 deletions

View File

@@ -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.

View File

@@ -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],

View File

@@ -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,

View File

@@ -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()),

View File

@@ -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

View File

@@ -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',

View File

@@ -10,6 +10,11 @@ const ArrayFields: CollectionConfig = {
enableRichTextLink: false,
},
fields: [
{
name: 'title',
type: 'text',
required: false,
},
{
name: 'items',
defaultValue: arrayDefaultValue,

View File

@@ -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',
},
],
}

View File

@@ -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',

View File

@@ -10,6 +10,7 @@ const TextFields: CollectionConfig = {
admin: {
useAsTitle: 'text',
},
defaultSort: 'id',
fields: [
{
name: 'text',

View File

@@ -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',
}

View File

@@ -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', () => {

View File

@@ -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',
},
},
})

View File

@@ -166,6 +166,7 @@ export interface User {
}
export interface ArrayField {
id: string
title?: string | null
items: {
text: string
id?: string | null

View File

@@ -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 }),
])