From 224cddd04573eff578ea3fa9ea5419f28b66c613 Mon Sep 17 00:00:00 2001 From: Patrik <35232443+PatrikKozak@users.noreply.github.com> Date: Wed, 29 Nov 2023 16:22:47 -0500 Subject: [PATCH] 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 Co-authored-by: Jarrod Flesch --- docs/fields/relationship.mdx | 37 +++++++++++++++++++ .../forms/field-types/Relationship/index.tsx | 15 ++++++-- packages/payload/src/exports/types.ts | 2 + packages/payload/src/fields/config/schema.ts | 5 +++ packages/payload/src/fields/config/types.ts | 33 ++++++++++++----- .../plugin-nested-docs/src/fields/parent.ts | 6 +-- test/fields/collections/Array/index.ts | 5 +++ test/fields/collections/Array/shared.ts | 29 +++++++++++++++ test/fields/collections/Relationship/index.ts | 11 ++++++ test/fields/collections/Text/index.ts | 1 + test/fields/collections/Text/shared.ts | 4 ++ test/fields/e2e.spec.ts | 24 ++++++++++++ test/fields/lexical.int.spec.ts | 16 ++++---- test/fields/payload-types.ts | 1 + test/fields/seed.ts | 14 +++++-- 15 files changed, 176 insertions(+), 27 deletions(-) diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 9a25b18b2..a37d8526b 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -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. diff --git a/packages/payload/src/admin/components/forms/field-types/Relationship/index.tsx b/packages/payload/src/admin/components/forms/field-types/Relationship/index.tsx index f9e7e294e..e2a8a92e8 100644 --- a/packages/payload/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/packages/payload/src/admin/components/forms/field-types/Relationship/index.tsx @@ -40,13 +40,14 @@ const Relationship: React.FC = (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) => { 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) => { locale, filterOptionsResult, serverURL, + sortOptions, api, i18n, config, @@ -252,7 +261,7 @@ const Relationship: React.FC = (props) => { (searchArg: string, valueArg: Value | Value[]) => { if (search !== searchArg) { setLastLoadedPage({}) - updateSearch(searchArg, valueArg) + updateSearch(searchArg, valueArg, searchArg !== '') } }, [search, updateSearch], diff --git a/packages/payload/src/exports/types.ts b/packages/payload/src/exports/types.ts index 24e9bdc13..b30fc500a 100644 --- a/packages/payload/src/exports/types.ts +++ b/packages/payload/src/exports/types.ts @@ -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, diff --git a/packages/payload/src/fields/config/schema.ts b/packages/payload/src/fields/config/schema.ts index 43a817943..ead01c954 100644 --- a/packages/payload/src/fields/config/schema.ts +++ b/packages/payload/src/fields/config/schema.ts @@ -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()), diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 936e4849e..b9eec3ba8 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -430,19 +430,10 @@ export type SelectField = FieldBase & { type: 'select' } -export type RelationshipField = FieldBase & { - admin?: Admin & { - allowCreate?: boolean - components?: { - Error?: React.ComponentType - Label?: React.ComponentType - } - 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 + Label?: React.ComponentType + } + 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 diff --git a/packages/plugin-nested-docs/src/fields/parent.ts b/packages/plugin-nested-docs/src/fields/parent.ts index 717146420..e39dbc6ce 100644 --- a/packages/plugin-nested-docs/src/fields/parent.ts +++ b/packages/plugin-nested-docs/src/fields/parent.ts @@ -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', diff --git a/test/fields/collections/Array/index.ts b/test/fields/collections/Array/index.ts index 8c8243cb8..7ff8727b3 100644 --- a/test/fields/collections/Array/index.ts +++ b/test/fields/collections/Array/index.ts @@ -10,6 +10,11 @@ const ArrayFields: CollectionConfig = { enableRichTextLink: false, }, fields: [ + { + name: 'title', + type: 'text', + required: false, + }, { name: 'items', defaultValue: arrayDefaultValue, diff --git a/test/fields/collections/Array/shared.ts b/test/fields/collections/Array/shared.ts index 29fdf3767..d78c73ac9 100644 --- a/test/fields/collections/Array/shared.ts +++ b/test/fields/collections/Array/shared.ts @@ -1,6 +1,7 @@ import type { ArrayField } from '../../payload-types' export const arrayDoc: Partial = { + title: 'array doc 1', items: [ { text: 'first row', @@ -35,3 +36,31 @@ export const arrayDoc: Partial = { }, ], } + +export const anotherArrayDoc: Partial = { + 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', + }, + ], +} diff --git a/test/fields/collections/Relationship/index.ts b/test/fields/collections/Relationship/index.ts index 25f419ec0..cecca09b7 100644 --- a/test/fields/collections/Relationship/index.ts +++ b/test/fields/collections/Relationship/index.ts @@ -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', diff --git a/test/fields/collections/Text/index.ts b/test/fields/collections/Text/index.ts index 6fb6ef56f..227b61166 100644 --- a/test/fields/collections/Text/index.ts +++ b/test/fields/collections/Text/index.ts @@ -10,6 +10,7 @@ const TextFields: CollectionConfig = { admin: { useAsTitle: 'text', }, + defaultSort: 'id', fields: [ { name: 'text', diff --git a/test/fields/collections/Text/shared.ts b/test/fields/collections/Text/shared.ts index c7ed92011..a91912ff5 100644 --- a/test/fields/collections/Text/shared.ts +++ b/test/fields/collections/Text/shared.ts @@ -7,3 +7,7 @@ export const textDoc: Partial = { text: 'Seeded text document', localizedText: 'Localized text', } + +export const anotherTextDoc: Partial = { + text: 'Another text document', +} diff --git a/test/fields/e2e.spec.ts b/test/fields/e2e.spec.ts index ec13cbe02..59911a77b 100644 --- a/test/fields/e2e.spec.ts +++ b/test/fields/e2e.spec.ts @@ -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', () => { diff --git a/test/fields/lexical.int.spec.ts b/test/fields/lexical.int.spec.ts index 956404124..2b70bca6b 100644 --- a/test/fields/lexical.int.spec.ts +++ b/test/fields/lexical.int.spec.ts @@ -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', }, }, }) diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index cf0359506..3eecb4c3a 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -166,6 +166,7 @@ export interface User { } export interface ArrayField { id: string + title?: string | null items: { text: string id?: string | null diff --git a/test/fields/seed.ts b/test/fields/seed.ts index 85a4a5c7a..5f9ba7b84 100644 --- a/test/fields/seed.ts +++ b/test/fields/seed.ts @@ -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 }), ])