diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 9c96785b3..f20ea6df8 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -57,26 +57,26 @@ 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. | -| **`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). | -| **`graphQL`** | An object with `singularName` and `pluralName` strings used in schema generation. Auto-generated from slug if not defined. Set to `false` to disable GraphQL. | -| **`hooks`** | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). | -| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | -| **`lockDocuments`** | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). | -| **`slug`** \* | Unique, URL-friendly string that will act as an identifier for this Collection. | -| **`timestamps`** | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. | -| **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. | -| **`upload`** | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. | -| **`versions`** | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). | +| 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. 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). | +| **`graphQL`** | An object with `singularName` and `pluralName` strings used in schema generation. Auto-generated from slug if not defined. Set to `false` to disable GraphQL. | +| **`hooks`** | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). | +| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | +| **`lockDocuments`** | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). | +| **`slug`** \* | Unique, URL-friendly string that will act as an identifier for this Collection. | +| **`timestamps`** | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. | +| **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. | +| **`upload`** | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. | +| **`versions`** | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). | _\* An asterisk denotes that a property is required._ diff --git a/docs/queries/sort.mdx b/docs/queries/sort.mdx index 3340d21f9..60b46c3cd 100644 --- a/docs/queries/sort.mdx +++ b/docs/queries/sort.mdx @@ -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: diff --git a/packages/db-mongodb/src/queries/buildSortParam.ts b/packages/db-mongodb/src/queries/buildSortParam.ts index b539fe481..de4d4860f 100644 --- a/packages/db-mongodb/src/queries/buildSortParam.ts +++ b/packages/db-mongodb/src/queries/buildSortParam.ts @@ -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' - } else { - sortProperty = getLocalizedSortProperty({ + if (typeof sort === 'string') { + sort = [sort] + } + + const sorting = sort.reduce((acc, item) => { + let sortProperty: string + let sortDirection: SortDirection + if (item.indexOf('-') === 0) { + sortProperty = item.substring(1) + sortDirection = 'desc' + } else { + 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 } diff --git a/packages/drizzle/src/find.ts b/packages/drizzle/src/find.ts index 549ed6e13..9ec5bcf69 100644 --- a/packages/drizzle/src/find.ts +++ b/packages/drizzle/src/find.ts @@ -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)) diff --git a/packages/drizzle/src/find/findMany.ts b/packages/drizzle/src/find/findMany.ts index a3a8a42ba..442af7fd7 100644 --- a/packages/drizzle/src/find/findMany.ts +++ b/packages/drizzle/src/find/findMany.ts @@ -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 diff --git a/packages/drizzle/src/find/traverseFields.ts b/packages/drizzle/src/find/traverseFields.ts index 35f7a7875..8f6440957 100644 --- a/packages/drizzle/src/find/traverseFields.ts +++ b/packages/drizzle/src/find/traverseFields.ts @@ -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}` diff --git a/packages/drizzle/src/findGlobalVersions.ts b/packages/drizzle/src/findGlobalVersions.ts index f94095c40..bb55ae360 100644 --- a/packages/drizzle/src/findGlobalVersions.ts +++ b/packages/drizzle/src/findGlobalVersions.ts @@ -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}`, diff --git a/packages/drizzle/src/findVersions.ts b/packages/drizzle/src/findVersions.ts index eac2df63b..ae0e28037 100644 --- a/packages/drizzle/src/findVersions.ts +++ b/packages/drizzle/src/findVersions.ts @@ -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}`, diff --git a/packages/drizzle/src/queries/buildOrderBy.ts b/packages/drizzle/src/queries/buildOrderBy.ts index e90c229a0..30466735b 100644 --- a/packages/drizzle/src/queries/buildOrderBy.ts +++ b/packages/drizzle/src/queries/buildOrderBy.ts @@ -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 - 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) { + const createdAt = adapter.tables[tableName]?.createdAt + if (createdAt) { + sort = '-createdAt' + } else { + sort = '-id' + } } - if (sort) { - let sortPath + if (typeof sort === 'string') { + sort = [sort] + } - if (sort[0] === '-') { - sortPath = sort.substring(1) - orderBy.order = desc + for (const sortItem of sort) { + let sortProperty: string + let sortDirection: 'asc' | 'desc' + if (sortItem[0] === '-') { + sortProperty = sortItem.substring(1) + sortDirection = 'desc' } else { - sortPath = sort - orderBy.order = asc + 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 } diff --git a/packages/drizzle/src/queries/buildQuery.ts b/packages/drizzle/src/queries/buildQuery.ts index 9873b399a..554142796 100644 --- a/packages/drizzle/src/queries/buildQuery.ts +++ b/packages/drizzle/src/queries/buildQuery.ts @@ -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 where: SQL } diff --git a/packages/next/src/routes/rest/collections/find.ts b/packages/next/src/routes/rest/collections/find.ts index 4634629bf..ffa3eb19d 100644 --- a/packages/next/src/routes/rest/collections/find.ts +++ b/packages/next/src/routes/rest/collections/find.ts @@ -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, }) diff --git a/packages/next/src/routes/rest/collections/findVersions.ts b/packages/next/src/routes/rest/collections/findVersions.ts index b58a38045..25cf18d24 100644 --- a/packages/next/src/routes/rest/collections/findVersions.ts +++ b/packages/next/src/routes/rest/collections/findVersions.ts @@ -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, }) diff --git a/packages/next/src/routes/rest/globals/findVersions.ts b/packages/next/src/routes/rest/globals/findVersions.ts index aa5260c3b..d868cd44f 100644 --- a/packages/next/src/routes/rest/globals/findVersions.ts +++ b/packages/next/src/routes/rest/globals/findVersions.ts @@ -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, }) diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index 7ff1bbbef..940a6278f 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -104,7 +104,10 @@ export const ListView: React.FC = 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, diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 5fe12a4f3..b0e1723d5 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -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 = { /** * 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 */ diff --git a/packages/payload/src/collections/operations/find.ts b/packages/payload/src/collections/operations/find.ts index cb90b774e..89ca48035 100644 --- a/packages/payload/src/collections/operations/find.ts +++ b/packages/payload/src/collections/operations/find.ts @@ -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 } diff --git a/packages/payload/src/collections/operations/findVersions.ts b/packages/payload/src/collections/operations/findVersions.ts index 3426a32f1..e0ee8d0b5 100644 --- a/packages/payload/src/collections/operations/findVersions.ts +++ b/packages/payload/src/collections/operations/findVersions.ts @@ -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 } diff --git a/packages/payload/src/collections/operations/local/find.ts b/packages/payload/src/collections/operations/local/find.ts index 09051ca6d..8f5d545f8 100644 --- a/packages/payload/src/collections/operations/local/find.ts +++ b/packages/payload/src/collections/operations/local/find.ts @@ -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 = { pagination?: boolean req?: PayloadRequest showHiddenFields?: boolean - sort?: string + sort?: Sort user?: Document where?: Where } diff --git a/packages/payload/src/collections/operations/local/findVersions.ts b/packages/payload/src/collections/operations/local/findVersions.ts index 9cb1caa86..f5cbbe5a9 100644 --- a/packages/payload/src/collections/operations/local/findVersions.ts +++ b/packages/payload/src/collections/operations/local/findVersions.ts @@ -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 = { page?: number req?: PayloadRequest showHiddenFields?: boolean - sort?: string + sort?: Sort user?: Document where?: Where } diff --git a/packages/payload/src/database/types.ts b/packages/payload/src/database/types.ts index 03b0967dd..d56c3dfd6 100644 --- a/packages/payload/src/database/types.ts +++ b/packages/payload/src/database/types.ts @@ -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 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 } diff --git a/packages/payload/src/globals/operations/findVersions.ts b/packages/payload/src/globals/operations/findVersions.ts index 19d5bd6f4..33383901a 100644 --- a/packages/payload/src/globals/operations/findVersions.ts +++ b/packages/payload/src/globals/operations/findVersions.ts @@ -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 } diff --git a/packages/payload/src/globals/operations/local/findVersions.ts b/packages/payload/src/globals/operations/local/findVersions.ts index 80ab12dc4..67a4e1a1e 100644 --- a/packages/payload/src/globals/operations/local/findVersions.ts +++ b/packages/payload/src/globals/operations/local/findVersions.ts @@ -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 = { req?: PayloadRequest showHiddenFields?: boolean slug: TSlug - sort?: string + sort?: Sort user?: Document where?: Where } diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index 9c673be6a..7890e9e94 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -110,6 +110,8 @@ export type Where = { or?: Where[] } +export type Sort = Array | string + /** * Applies pagination for join fields for including collection relationships */ diff --git a/packages/payload/src/versions/drafts/getQueryDraftsSort.ts b/packages/payload/src/versions/drafts/getQueryDraftsSort.ts index e4ccd3b6f..8ef9d09e2 100644 --- a/packages/payload/src/versions/drafts/getQueryDraftsSort.ts +++ b/packages/payload/src/versions/drafts/getQueryDraftsSort.ts @@ -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,17 +20,24 @@ export const getQueryDraftsSort = ({ } } - let direction = '' - let orderBy = sort - - if (sort[0] === '-') { - direction = '-' - orderBy = sort.substring(1) + if (typeof sort === 'string') { + sort = [sort] } - if (orderBy === 'id') { - return `${direction}parent` - } + return sort.map((field: string) => { + let orderBy: string + let direction = '' + if (field[0] === '-') { + orderBy = field.substring(1) + direction = '-' + } else { + orderBy = field + } - return `${direction}version.${orderBy}` + if (orderBy === 'id') { + return `${direction}parent` + } + + return `${direction}version.${orderBy}` + }) } diff --git a/test/sort/collections/DefaultSort/index.ts b/test/sort/collections/DefaultSort/index.ts new file mode 100644 index 000000000..5254f4b8b --- /dev/null +++ b/test/sort/collections/DefaultSort/index.ts @@ -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', + }, + ], +} diff --git a/test/sort/collections/Drafts/index.ts b/test/sort/collections/Drafts/index.ts new file mode 100644 index 000000000..a6d73ae2d --- /dev/null +++ b/test/sort/collections/Drafts/index.ts @@ -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', + }, + ], +} diff --git a/test/sort/collections/Localized/index.ts b/test/sort/collections/Localized/index.ts new file mode 100644 index 000000000..0f87a5653 --- /dev/null +++ b/test/sort/collections/Localized/index.ts @@ -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, + }, + ], + }, + ], +} diff --git a/test/sort/collections/Posts/index.ts b/test/sort/collections/Posts/index.ts new file mode 100644 index 000000000..b37832f75 --- /dev/null +++ b/test/sort/collections/Posts/index.ts @@ -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', + }, + ], + }, + ], +} diff --git a/test/sort/config.ts b/test/sort/config.ts new file mode 100644 index 000000000..78868504b --- /dev/null +++ b/test/sort/config.ts @@ -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'), + }, +}) diff --git a/test/sort/eslint.config.js b/test/sort/eslint.config.js new file mode 100644 index 000000000..f295df083 --- /dev/null +++ b/test/sort/eslint.config.js @@ -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 diff --git a/test/sort/int.spec.ts b/test/sort/int.spec.ts new file mode 100644 index 000000000..730f7b489 --- /dev/null +++ b/test/sort/int.spec.ts @@ -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[]) { + for (const item of data) { + await payload.create({ collection, data: item }) + } +} diff --git a/test/sort/payload-types.ts b/test/sort/payload-types.ts new file mode 100644 index 000000000..715c5e6c7 --- /dev/null +++ b/test/sort/payload-types.ts @@ -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 {} +} \ No newline at end of file diff --git a/test/sort/tsconfig.eslint.json b/test/sort/tsconfig.eslint.json new file mode 100644 index 000000000..b34cc7afb --- /dev/null +++ b/test/sort/tsconfig.eslint.json @@ -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" + ] +} diff --git a/test/sort/tsconfig.json b/test/sort/tsconfig.json new file mode 100644 index 000000000..3c43903cf --- /dev/null +++ b/test/sort/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +}