feat: sort by multiple fields (#8799)

This change adds support for sort with multiple fields in local API and
REST API. Related discussion #2089

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
Anders Semb Hermansen
2024-10-24 21:46:30 +02:00
committed by GitHub
parent 6e919cc83a
commit 4d44c378ed
34 changed files with 1033 additions and 115 deletions

View File

@@ -58,13 +58,13 @@ export const Posts: CollectionConfig = {
The following options are available:
| Option | Description |
|------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`admin`** | The configuration options for the Admin Panel. [More details](../admin/collections). |
| **`access`** | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). |
| **`auth`** | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
| **`disableDuplicate`** | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
| **`defaultSort`** | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. |
| **`defaultSort`** | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. |
| **`dbName`** | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
| **`endpoints`** | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
| **`fields`** \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |

View File

@@ -6,7 +6,7 @@ desc: Payload sort allows you to order your documents by a field in ascending or
keywords: query, documents, pagination, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
Documents in Payload can be easily sorted by a specific [Field](../fields/overview). When querying Documents, you can pass the name of any top-level field, and the response will sort the Documents by that field in _ascending_ order. If prefixed with a minus symbol ("-"), they will be sorted in _descending_ order.
Documents in Payload can be easily sorted by a specific [Field](../fields/overview). When querying Documents, you can pass the name of any top-level field, and the response will sort the Documents by that field in _ascending_ order. If prefixed with a minus symbol ("-"), they will be sorted in _descending_ order. In Local API multiple fields can be specificed by using an array of strings. In REST API multiple fields can be specified by separating fields with comma. The minus symbol can be in front of individual fields.
Because sorting is handled by the database, the field cannot be a [Virtual Field](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges). It must be stored in the database to be searchable.
@@ -30,6 +30,19 @@ const getPosts = async () => {
}
```
To sort by multiple fields, you can use the `sort` option with fields in an array:
```ts
const getPosts = async () => {
const posts = await payload.find({
collection: 'posts',
sort: ['priority', '-createdAt'], // highlight-line
})
return posts
}
```
## REST API
To sort in the [REST API](../rest-api/overview), you can use the `sort` parameter in your query:
@@ -40,6 +53,14 @@ fetch('https://localhost:3000/api/posts?sort=-createdAt') // highlight-line
.then((data) => console.log(data))
```
To sort by multiple fields, you can use the `sort` parameter with fields separated by comma:
```ts
fetch('https://localhost:3000/api/posts?sort=priority,-createdAt') // highlight-line
.then((response) => response.json())
.then((data) => console.log(data))
```
## GraphQL API
To sort in the [GraphQL API](../graphql/overview), you can use the `sort` parameter in your query:

View File

@@ -1,5 +1,5 @@
import type { PaginateOptions } from 'mongoose'
import type { Field, SanitizedConfig } from 'payload'
import type { Field, SanitizedConfig, Sort } from 'payload'
import { getLocalizedSortProperty } from './getLocalizedSortProperty.js'
@@ -7,7 +7,7 @@ type Args = {
config: SanitizedConfig
fields: Field[]
locale: string
sort: string
sort: Sort
timestamps: boolean
}
@@ -25,32 +25,41 @@ export const buildSortParam = ({
sort,
timestamps,
}: Args): PaginateOptions['sort'] => {
let sortProperty: string
let sortDirection: SortDirection = 'desc'
if (!sort) {
if (timestamps) {
sortProperty = 'createdAt'
sort = '-createdAt'
} else {
sortProperty = '_id'
sort = '-id'
}
} else if (sort.indexOf('-') === 0) {
sortProperty = sort.substring(1)
} else {
sortProperty = sort
sortDirection = 'asc'
}
if (sortProperty === 'id') {
sortProperty = '_id'
if (typeof sort === 'string') {
sort = [sort]
}
const sorting = sort.reduce<PaginateOptions['sort']>((acc, item) => {
let sortProperty: string
let sortDirection: SortDirection
if (item.indexOf('-') === 0) {
sortProperty = item.substring(1)
sortDirection = 'desc'
} else {
sortProperty = getLocalizedSortProperty({
sortProperty = item
sortDirection = 'asc'
}
if (sortProperty === 'id') {
acc['_id'] = sortDirection
return acc
}
const localizedProperty = getLocalizedSortProperty({
config,
fields,
locale,
segments: sortProperty.split('.'),
})
}
acc[localizedProperty] = sortDirection
return acc
}, {})
return { [sortProperty]: sortDirection }
return sorting
}

View File

@@ -21,7 +21,7 @@ export const find: Find = async function find(
},
) {
const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config
const sort = typeof sortArg === 'string' ? sortArg : collectionConfig.defaultSort
const sort = sortArg !== undefined && sortArg !== null ? sortArg : collectionConfig.defaultSort
const tableName = this.tableNameMap.get(toSnakeCase(collectionConfig.slug))

View File

@@ -59,9 +59,9 @@ export const findMany = async function find({
const selectDistinctMethods: ChainedMethods = []
if (orderBy?.order && orderBy?.column) {
if (orderBy) {
selectDistinctMethods.push({
args: [orderBy.order(orderBy.column)],
args: [() => orderBy.map(({ column, order }) => order(column))],
method: 'orderBy',
})
}
@@ -114,7 +114,7 @@ export const findMany = async function find({
} else {
findManyArgs.limit = limit
findManyArgs.offset = offset
findManyArgs.orderBy = orderBy.order(orderBy.column)
findManyArgs.orderBy = () => orderBy.map(({ column, order }) => order(column))
if (where) {
findManyArgs.where = where

View File

@@ -373,7 +373,7 @@ export const traverseFields = ({
})
.from(adapter.tables[joinCollectionTableName])
.where(subQueryWhere)
.orderBy(orderBy.order(orderBy.column)),
.orderBy(() => orderBy.map(({ column, order }) => order(column))),
})
const columnName = `${path.replaceAll('.', '_')}${field.name}`

View File

@@ -24,7 +24,7 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV
const globalConfig: SanitizedGlobalConfig = this.payload.globals.config.find(
({ slug }) => slug === global,
)
const sort = typeof sortArg === 'string' ? sortArg : '-createdAt'
const sort = sortArg !== undefined && sortArg !== null ? sortArg : '-createdAt'
const tableName = this.tableNameMap.get(
`_${toSnakeCase(globalConfig.slug)}${this.versionsSuffix}`,

View File

@@ -22,7 +22,7 @@ export const findVersions: FindVersions = async function findVersions(
},
) {
const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config
const sort = typeof sortArg === 'string' ? sortArg : collectionConfig.defaultSort
const sort = sortArg !== undefined && sortArg !== null ? sortArg : collectionConfig.defaultSort
const tableName = this.tableNameMap.get(
`_${toSnakeCase(collectionConfig.slug)}${this.versionsSuffix}`,

View File

@@ -1,4 +1,4 @@
import type { Field } from 'payload'
import type { Field, Sort } from 'payload'
import { asc, desc } from 'drizzle-orm'
@@ -13,7 +13,7 @@ type Args = {
joins: BuildQueryJoinAliases
locale?: string
selectFields: Record<string, GenericColumn>
sort?: string
sort?: Sort
tableName: string
}
@@ -29,54 +29,55 @@ export const buildOrderBy = ({
sort,
tableName,
}: Args): BuildQueryResult['orderBy'] => {
const orderBy: BuildQueryResult['orderBy'] = {
column: null,
order: null,
}
const orderBy: BuildQueryResult['orderBy'] = []
if (sort) {
let sortPath
if (sort[0] === '-') {
sortPath = sort.substring(1)
orderBy.order = desc
if (!sort) {
const createdAt = adapter.tables[tableName]?.createdAt
if (createdAt) {
sort = '-createdAt'
} else {
sortPath = sort
orderBy.order = asc
sort = '-id'
}
}
if (typeof sort === 'string') {
sort = [sort]
}
for (const sortItem of sort) {
let sortProperty: string
let sortDirection: 'asc' | 'desc'
if (sortItem[0] === '-') {
sortProperty = sortItem.substring(1)
sortDirection = 'desc'
} else {
sortProperty = sortItem
sortDirection = 'asc'
}
try {
const { columnName: sortTableColumnName, table: sortTable } = getTableColumnFromPath({
adapter,
collectionPath: sortPath,
collectionPath: sortProperty,
fields,
joins,
locale,
pathSegments: sortPath.replace(/__/g, '.').split('.'),
pathSegments: sortProperty.replace(/__/g, '.').split('.'),
selectFields,
tableName,
value: sortPath,
value: sortProperty,
})
orderBy.column = sortTable?.[sortTableColumnName]
if (sortTable?.[sortTableColumnName]) {
orderBy.push({
column: sortTable[sortTableColumnName],
order: sortDirection === 'asc' ? asc : desc,
})
selectFields[sortTableColumnName] = sortTable[sortTableColumnName]
}
} catch (err) {
// continue
}
}
if (!orderBy?.column) {
orderBy.order = desc
const createdAt = adapter.tables[tableName]?.createdAt
if (createdAt) {
orderBy.column = createdAt
} else {
orderBy.column = adapter.tables[tableName].id
}
}
if (orderBy.column) {
selectFields.sort = orderBy.column
}
return orderBy
}

View File

@@ -1,6 +1,6 @@
import type { asc, desc, SQL } from 'drizzle-orm'
import type { PgTableWithColumns } from 'drizzle-orm/pg-core'
import type { Field, Where } from 'payload'
import type { Field, Sort, Where } from 'payload'
import type { DrizzleAdapter, GenericColumn, GenericTable } from '../types.js'
@@ -18,7 +18,7 @@ type BuildQueryArgs = {
fields: Field[]
joins?: BuildQueryJoinAliases
locale?: string
sort?: string
sort?: Sort
tableName: string
where: Where
}
@@ -28,7 +28,7 @@ export type BuildQueryResult = {
orderBy: {
column: GenericColumn
order: typeof asc | typeof desc
}
}[]
selectFields: Record<string, GenericColumn>
where: SQL
}

View File

@@ -28,7 +28,7 @@ export const find: CollectionRouteHandler = async ({ collection, req }) => {
limit: isNumber(limit) ? Number(limit) : undefined,
page: isNumber(page) ? Number(page) : undefined,
req,
sort,
sort: typeof sort === 'string' ? sort.split(',') : undefined,
where,
})

View File

@@ -23,7 +23,7 @@ export const findVersions: CollectionRouteHandler = async ({ collection, req })
limit: isNumber(limit) ? Number(limit) : undefined,
page: isNumber(page) ? Number(page) : undefined,
req,
sort,
sort: typeof sort === 'string' ? sort.split(',') : undefined,
where,
})

View File

@@ -23,7 +23,7 @@ export const findVersions: GlobalRouteHandler = async ({ globalConfig, req }) =>
limit: isNumber(limit) ? Number(limit) : undefined,
page: isNumber(page) ? Number(page) : undefined,
req,
sort,
sort: typeof sort === 'string' ? sort.split(',') : undefined,
where,
})

View File

@@ -104,7 +104,10 @@ export const ListView: React.FC<AdminViewProps> = async ({
const sort =
query?.sort && typeof query.sort === 'string'
? query.sort
: listPreferences?.sort || collectionConfig.defaultSort || undefined
: listPreferences?.sort ||
(typeof collectionConfig.defaultSort === 'string'
? collectionConfig.defaultSort
: undefined)
const data = await payload.find({
collection: collectionSlug,

View File

@@ -38,7 +38,7 @@ import type {
TypedAuthOperations,
TypedCollection,
} from '../../index.js'
import type { PayloadRequest, RequestContext } from '../../types/index.js'
import type { PayloadRequest, RequestContext, Sort } from '../../types/index.js'
import type { SanitizedUploadConfig, UploadConfig } from '../../uploads/types.js'
import type {
IncomingCollectionVersions,
@@ -375,7 +375,7 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
/**
* Default field to sort by in collection list view
*/
defaultSort?: string
defaultSort?: Sort
/**
* When true, do not show the "Duplicate" button while editing documents within this collection and prevent `duplicate` from all APIs
*/

View File

@@ -1,7 +1,7 @@
import type { AccessResult } from '../../config/types.js'
import type { PaginatedDocs } from '../../database/types.js'
import type { CollectionSlug, JoinQuery } from '../../index.js'
import type { PayloadRequest, Where } from '../../types/index.js'
import type { PayloadRequest, Sort, Where } from '../../types/index.js'
import type { Collection, DataFromCollectionSlug } from '../config/types.js'
import executeAccess from '../../auth/executeAccess.js'
@@ -28,7 +28,7 @@ export type Arguments = {
pagination?: boolean
req?: PayloadRequest
showHiddenFields?: boolean
sort?: string
sort?: Sort
where?: Where
}

View File

@@ -1,5 +1,5 @@
import type { PaginatedDocs } from '../../database/types.js'
import type { PayloadRequest, Where } from '../../types/index.js'
import type { PayloadRequest, Sort, Where } from '../../types/index.js'
import type { TypeWithVersion } from '../../versions/types.js'
import type { Collection } from '../config/types.js'
@@ -20,7 +20,7 @@ export type Arguments = {
pagination?: boolean
req?: PayloadRequest
showHiddenFields?: boolean
sort?: string
sort?: Sort
where?: Where
}

View File

@@ -1,6 +1,6 @@
import type { PaginatedDocs } from '../../../database/types.js'
import type { CollectionSlug, JoinQuery, Payload, TypedLocale } from '../../../index.js'
import type { Document, PayloadRequest, RequestContext, Where } from '../../../types/index.js'
import type { Document, PayloadRequest, RequestContext, Sort, Where } from '../../../types/index.js'
import type { DataFromCollectionSlug } from '../../config/types.js'
import { APIError } from '../../../errors/index.js'
@@ -27,7 +27,7 @@ export type Options<TSlug extends CollectionSlug> = {
pagination?: boolean
req?: PayloadRequest
showHiddenFields?: boolean
sort?: string
sort?: Sort
user?: Document
where?: Where
}

View File

@@ -1,6 +1,6 @@
import type { PaginatedDocs } from '../../../database/types.js'
import type { CollectionSlug, Payload, TypedLocale } from '../../../index.js'
import type { Document, PayloadRequest, RequestContext, Where } from '../../../types/index.js'
import type { Document, PayloadRequest, RequestContext, Sort, Where } from '../../../types/index.js'
import type { TypeWithVersion } from '../../../versions/types.js'
import type { DataFromCollectionSlug } from '../../config/types.js'
@@ -23,7 +23,7 @@ export type Options<TSlug extends CollectionSlug> = {
page?: number
req?: PayloadRequest
showHiddenFields?: boolean
sort?: string
sort?: Sort
user?: Document
where?: Where
}

View File

@@ -1,5 +1,5 @@
import type { TypeWithID } from '../collections/config/types.js'
import type { Document, JoinQuery, Payload, PayloadRequest, Where } from '../types/index.js'
import type { Document, JoinQuery, Payload, PayloadRequest, Sort, Where } from '../types/index.js'
import type { TypeWithVersion } from '../versions/types.js'
export type { TypeWithVersion }
@@ -180,7 +180,7 @@ export type QueryDraftsArgs = {
page?: number
pagination?: boolean
req: PayloadRequest
sort?: string
sort?: Sort
where?: Where
}
@@ -207,7 +207,7 @@ export type FindArgs = {
projection?: Record<string, unknown>
req: PayloadRequest
skip?: number
sort?: string
sort?: Sort
versions?: boolean
where?: Where
}
@@ -230,7 +230,7 @@ type BaseVersionArgs = {
pagination?: boolean
req: PayloadRequest
skip?: number
sort?: string
sort?: Sort
versions?: boolean
where?: Where
}

View File

@@ -1,5 +1,5 @@
import type { PaginatedDocs } from '../../database/types.js'
import type { PayloadRequest, Where } from '../../types/index.js'
import type { PayloadRequest, Sort, Where } from '../../types/index.js'
import type { TypeWithVersion } from '../../versions/types.js'
import type { SanitizedGlobalConfig } from '../config/types.js'
@@ -20,7 +20,7 @@ export type Arguments = {
pagination?: boolean
req?: PayloadRequest
showHiddenFields?: boolean
sort?: string
sort?: Sort
where?: Where
}

View File

@@ -1,6 +1,6 @@
import type { PaginatedDocs } from '../../../database/types.js'
import type { GlobalSlug, Payload, RequestContext, TypedLocale } from '../../../index.js'
import type { Document, PayloadRequest, Where } from '../../../types/index.js'
import type { Document, PayloadRequest, Sort, Where } from '../../../types/index.js'
import type { TypeWithVersion } from '../../../versions/types.js'
import type { DataFromGlobalSlug } from '../../config/types.js'
@@ -19,7 +19,7 @@ export type Options<TSlug extends GlobalSlug> = {
req?: PayloadRequest
showHiddenFields?: boolean
slug: TSlug
sort?: string
sort?: Sort
user?: Document
where?: Where
}

View File

@@ -110,6 +110,8 @@ export type Where = {
or?: Where[]
}
export type Sort = Array<string> | string
/**
* Applies pagination for join fields for including collection relationships
*/

View File

@@ -1,4 +1,5 @@
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { Sort } from '../../types/index.js'
/**
* Takes the incoming sort argument and prefixes it with `versions.` and preserves any `-` prefixes for descending order
@@ -9,8 +10,8 @@ export const getQueryDraftsSort = ({
sort,
}: {
collectionConfig: SanitizedCollectionConfig
sort: string
}): string => {
sort?: Sort
}): Sort => {
if (!sort) {
if (collectionConfig.defaultSort) {
sort = collectionConfig.defaultSort
@@ -19,12 +20,18 @@ export const getQueryDraftsSort = ({
}
}
let direction = ''
let orderBy = sort
if (typeof sort === 'string') {
sort = [sort]
}
if (sort[0] === '-') {
return sort.map((field: string) => {
let orderBy: string
let direction = ''
if (field[0] === '-') {
orderBy = field.substring(1)
direction = '-'
orderBy = sort.substring(1)
} else {
orderBy = field
}
if (orderBy === 'id') {
@@ -32,4 +39,5 @@ export const getQueryDraftsSort = ({
}
return `${direction}version.${orderBy}`
})
}

View File

@@ -0,0 +1,21 @@
import type { CollectionConfig } from 'payload'
export const defaultSortSlug = 'default-sort'
export const DefaultSortCollection: CollectionConfig = {
slug: defaultSortSlug,
admin: {
useAsTitle: 'text',
},
defaultSort: ['number', '-text'],
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'number',
type: 'number',
},
],
}

View File

@@ -0,0 +1,27 @@
import type { CollectionConfig } from 'payload'
export const draftsSlug = 'drafts'
export const DraftsCollection: CollectionConfig = {
slug: draftsSlug,
admin: {
useAsTitle: 'text',
},
versions: {
drafts: true,
},
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'number',
type: 'number',
},
{
name: 'number2',
type: 'number',
},
],
}

View File

@@ -0,0 +1,41 @@
import type { CollectionConfig } from 'payload'
export const localiedSlug = 'localized'
export const LocalizedCollection: CollectionConfig = {
slug: localiedSlug,
admin: {
useAsTitle: 'text',
},
fields: [
{
name: 'text',
type: 'text',
localized: true,
},
{
name: 'number',
type: 'number',
localized: true,
},
{
name: 'number2',
type: 'number',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'number',
type: 'number',
localized: true,
},
],
},
],
}

View File

@@ -0,0 +1,38 @@
import type { CollectionConfig } from 'payload'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
admin: {
useAsTitle: 'text',
},
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'number',
type: 'number',
},
{
name: 'number2',
type: 'number',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'number',
type: 'number',
},
],
},
],
}

37
test/sort/config.ts Normal file
View File

@@ -0,0 +1,37 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { DefaultSortCollection } from './collections/DefaultSort/index.js'
import { DraftsCollection } from './collections/Drafts/index.js'
import { LocalizedCollection } from './collections/Localized/index.js'
import { PostsCollection } from './collections/Posts/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [PostsCollection, DraftsCollection, DefaultSortCollection, LocalizedCollection],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
cors: ['http://localhost:3000', 'http://localhost:3001'],
localization: {
locales: ['en', 'nb'],
defaultLocale: 'en',
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,19 @@
import { rootParserOptions } from '../../eslint.config.js'
import { testEslintConfig } from '../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...testEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

471
test/sort/int.spec.ts Normal file
View File

@@ -0,0 +1,471 @@
import type { CollectionSlug, Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
let payload: Payload
let restClient: NextRESTClient
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Sort', () => {
beforeAll(async () => {
const initialized = await initPayloadInt(dirname)
;({ payload, restClient } = initialized)
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
describe('Local API', () => {
beforeAll(async () => {
await createData('posts', [
{ text: 'Post 1', number: 1, number2: 10, group: { number: 100 } },
{ text: 'Post 2', number: 2, number2: 10, group: { number: 200 } },
{ text: 'Post 3', number: 3, number2: 5, group: { number: 150 } },
{ text: 'Post 10', number: 10, number2: 5, group: { number: 200 } },
{ text: 'Post 11', number: 11, number2: 20, group: { number: 150 } },
{ text: 'Post 12', number: 12, number2: 20, group: { number: 100 } },
])
await createData('default-sort', [
{ text: 'Post default-5 b', number: 5 },
{ text: 'Post default-10', number: 10 },
{ text: 'Post default-5 a', number: 5 },
{ text: 'Post default-1', number: 1 },
])
})
afterAll(async () => {
await payload.delete({ collection: 'posts', where: {} })
await payload.delete({ collection: 'default-sort', where: {} })
})
describe('Default sort', () => {
it('should sort posts by default definition in collection', async () => {
const posts = await payload.find({
collection: 'default-sort', // 'number,-text'
})
expect(posts.docs.map((post) => post.text)).toEqual([
'Post default-1',
'Post default-5 b',
'Post default-5 a',
'Post default-10',
])
})
})
describe('Sinlge sort field', () => {
it('should sort posts by text field', async () => {
const posts = await payload.find({
collection: 'posts',
sort: 'text',
})
expect(posts.docs.map((post) => post.text)).toEqual([
'Post 1',
'Post 10',
'Post 11',
'Post 12',
'Post 2',
'Post 3',
])
})
it('should sort posts by text field desc', async () => {
const posts = await payload.find({
collection: 'posts',
sort: '-text',
})
expect(posts.docs.map((post) => post.text)).toEqual([
'Post 3',
'Post 2',
'Post 12',
'Post 11',
'Post 10',
'Post 1',
])
})
it('should sort posts by number field', async () => {
const posts = await payload.find({
collection: 'posts',
sort: 'number',
})
expect(posts.docs.map((post) => post.text)).toEqual([
'Post 1',
'Post 2',
'Post 3',
'Post 10',
'Post 11',
'Post 12',
])
})
it('should sort posts by number field desc', async () => {
const posts = await payload.find({
collection: 'posts',
sort: '-number',
})
expect(posts.docs.map((post) => post.text)).toEqual([
'Post 12',
'Post 11',
'Post 10',
'Post 3',
'Post 2',
'Post 1',
])
})
})
describe('Sort by multiple fields', () => {
it('should sort posts by multiple fields', async () => {
const posts = await payload.find({
collection: 'posts',
sort: ['number2', 'number'],
})
expect(posts.docs.map((post) => post.text)).toEqual([
'Post 3', // 5, 3
'Post 10', // 5, 10
'Post 1', // 10, 1
'Post 2', // 10, 2
'Post 11', // 20, 11
'Post 12', // 20, 12
])
})
it('should sort posts by multiple fields asc and desc', async () => {
const posts = await payload.find({
collection: 'posts',
sort: ['number2', '-number'],
})
expect(posts.docs.map((post) => post.text)).toEqual([
'Post 10', // 5, 10
'Post 3', // 5, 3
'Post 2', // 10, 2
'Post 1', // 10, 1
'Post 12', // 20, 12
'Post 11', // 20, 11
])
})
it('should sort posts by multiple fields with group', async () => {
const posts = await payload.find({
collection: 'posts',
sort: ['-group.number', '-number'],
})
expect(posts.docs.map((post) => post.text)).toEqual([
'Post 10', // 200, 10
'Post 2', // 200, 2
'Post 11', // 150, 11
'Post 3', // 150, 3
'Post 12', // 100, 12
'Post 1', // 100, 1
])
})
})
describe('Sort with drafts', () => {
beforeAll(async () => {
const testData1 = await payload.create({
collection: 'drafts',
data: { text: 'Post 1 draft', number: 10 },
draft: true,
})
await payload.update({
collection: 'drafts',
id: testData1.id,
data: { text: 'Post 1 draft updated', number: 20 },
draft: true,
})
await payload.update({
collection: 'drafts',
id: testData1.id,
data: { text: 'Post 1 draft updated', number: 30 },
draft: true,
})
await payload.update({
collection: 'drafts',
id: testData1.id,
data: { text: 'Post 1 published', number: 15 },
draft: false,
})
const testData2 = await payload.create({
collection: 'drafts',
data: { text: 'Post 2 draft', number: 1 },
draft: true,
})
await payload.update({
collection: 'drafts',
id: testData2.id,
data: { text: 'Post 2 published', number: 2 },
draft: false,
})
await payload.update({
collection: 'drafts',
id: testData2.id,
data: { text: 'Post 2 newdraft', number: 100 },
draft: true,
})
await payload.create({
collection: 'drafts',
data: { text: 'Post 3 draft', number: 3 },
draft: true,
})
})
it('should sort latest without draft', async () => {
const posts = await payload.find({
collection: 'drafts',
sort: 'number',
draft: false,
})
expect(posts.docs.map((post) => post.text)).toEqual([
'Post 2 published', // 2
'Post 3 draft', // 3
'Post 1 published', // 15
])
})
it('should sort latest with draft', async () => {
const posts = await payload.find({
collection: 'drafts',
sort: 'number',
draft: true,
})
expect(posts.docs.map((post) => post.text)).toEqual([
'Post 3 draft', // 3
'Post 1 published', // 15
'Post 2 newdraft', // 100
])
})
it('should sort versions', async () => {
const posts = await payload.findVersions({
collection: 'drafts',
sort: 'version.number',
draft: false,
})
expect(posts.docs.map((post) => post.version.text)).toEqual([
'Post 2 draft', // 1
'Post 2 published', // 2
'Post 3 draft', // 3
'Post 1 draft', // 10
'Post 1 published', // 15
'Post 1 draft updated', // 20
'Post 1 draft updated', // 30
'Post 2 newdraft', // 100
])
})
})
describe('Localized sort', () => {
beforeAll(async () => {
const testData1 = await payload.create({
collection: 'localized',
data: { text: 'Post 1 english', number: 10 },
locale: 'en',
})
await payload.update({
collection: 'localized',
id: testData1.id,
data: { text: 'Post 1 norsk', number: 20 },
locale: 'nb',
})
const testData2 = await payload.create({
collection: 'localized',
data: { text: 'Post 2 english', number: 25 },
locale: 'en',
})
await payload.update({
collection: 'localized',
id: testData2.id,
data: { text: 'Post 2 norsk', number: 5 },
locale: 'nb',
})
})
it('should sort localized field', async () => {
const englishPosts = await payload.find({
collection: 'localized',
sort: 'number',
locale: 'en',
})
expect(englishPosts.docs.map((post) => post.text)).toEqual([
'Post 1 english', // 10
'Post 2 english', // 20
])
const norwegianPosts = await payload.find({
collection: 'localized',
sort: 'number',
locale: 'nb',
})
expect(norwegianPosts.docs.map((post) => post.text)).toEqual([
'Post 2 norsk', // 5
'Post 1 norsk', // 25
])
})
})
})
describe('REST API', () => {
beforeAll(async () => {
await createData('posts', [
{ text: 'Post 1', number: 1, number2: 10 },
{ text: 'Post 2', number: 2, number2: 10 },
{ text: 'Post 3', number: 3, number2: 5 },
{ text: 'Post 10', number: 10, number2: 5 },
{ text: 'Post 11', number: 11, number2: 20 },
{ text: 'Post 12', number: 12, number2: 20 },
])
})
afterAll(async () => {
await payload.delete({ collection: 'posts', where: {} })
})
describe('Sinlge sort field', () => {
it('should sort posts by text field', async () => {
const res = await restClient
.GET(`/posts`, {
query: {
sort: 'text',
},
})
.then((res) => res.json())
expect(res.docs.map((post) => post.text)).toEqual([
'Post 1',
'Post 10',
'Post 11',
'Post 12',
'Post 2',
'Post 3',
])
})
it('should sort posts by text field desc', async () => {
const res = await restClient
.GET(`/posts`, {
query: {
sort: '-text',
},
})
.then((res) => res.json())
expect(res.docs.map((post) => post.text)).toEqual([
'Post 3',
'Post 2',
'Post 12',
'Post 11',
'Post 10',
'Post 1',
])
})
it('should sort posts by number field', async () => {
const res = await restClient
.GET(`/posts`, {
query: {
sort: 'number',
},
})
.then((res) => res.json())
expect(res.docs.map((post) => post.text)).toEqual([
'Post 1',
'Post 2',
'Post 3',
'Post 10',
'Post 11',
'Post 12',
])
})
it('should sort posts by number field desc', async () => {
const res = await restClient
.GET(`/posts`, {
query: {
sort: '-number',
},
})
.then((res) => res.json())
expect(res.docs.map((post) => post.text)).toEqual([
'Post 12',
'Post 11',
'Post 10',
'Post 3',
'Post 2',
'Post 1',
])
})
})
describe('Sort by multiple fields', () => {
it('should sort posts by multiple fields', async () => {
const res = await restClient
.GET(`/posts`, {
query: {
sort: 'number2,number',
},
})
.then((res) => res.json())
expect(res.docs.map((post) => post.text)).toEqual([
'Post 3', // 5, 3
'Post 10', // 5, 10
'Post 1', // 10, 1
'Post 2', // 10, 2
'Post 11', // 20, 11
'Post 12', // 20, 12
])
})
it('should sort posts by multiple fields asc and desc', async () => {
const res = await restClient
.GET(`/posts`, {
query: {
sort: 'number2,-number',
},
})
.then((res) => res.json())
expect(res.docs.map((post) => post.text)).toEqual([
'Post 10', // 5, 10
'Post 3', // 5, 3
'Post 2', // 10, 2
'Post 1', // 10, 1
'Post 12', // 20, 12
'Post 11', // 20, 11
])
})
})
})
})
async function createData(collection: CollectionSlug, data: Record<string, any>[]) {
for (const item of data) {
await payload.create({ collection, data: item })
}
}

204
test/sort/payload-types.ts Normal file
View File

@@ -0,0 +1,204 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
auth: {
users: UserAuthOperations;
};
collections: {
posts: Post;
drafts: Draft;
'default-sort': DefaultSort;
localized: Localized;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
db: {
defaultIDType: string;
};
globals: {};
locale: 'en' | 'nb';
user: User & {
collection: 'users';
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
text?: string | null;
number?: number | null;
number2?: number | null;
group?: {
text?: string | null;
number?: number | null;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "drafts".
*/
export interface Draft {
id: string;
text?: string | null;
number?: number | null;
number2?: number | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "default-sort".
*/
export interface DefaultSort {
id: string;
text?: string | null;
number?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localized".
*/
export interface Localized {
id: string;
text?: string | null;
number?: number | null;
number2?: number | null;
group?: {
text?: string | null;
number?: number | null;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'drafts';
value: string | Draft;
} | null)
| ({
relationTo: 'default-sort';
value: string | DefaultSort;
} | null)
| ({
relationTo: 'localized';
value: string | Localized;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

View File

@@ -0,0 +1,13 @@
{
// extend your base config to share compilerOptions, etc
//"extends": "./tsconfig.json",
"compilerOptions": {
// ensure that nobody can accidentally use this config for a build
"noEmit": true
},
"include": [
// whatever paths you intend to lint
"./**/*.ts",
"./**/*.tsx"
]
}

3
test/sort/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}