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

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