feat: orderable collections (#11452)
Closes https://github.com/payloadcms/payload/discussions/1413 ### What? Introduces a new `orderable` boolean property on collections that allows dragging and dropping rows to reorder them: https://github.com/user-attachments/assets/8ee85cf0-add1-48e5-a0a2-f73ad66aa24a ### Why? [One of the most requested features](https://github.com/payloadcms/payload/discussions/1413). Additionally, poorly implemented it can be very costly in terms of performance. This can be especially useful for implementing custom views like kanban. ### How? We are using fractional indexing. In its simplest form, it consists of calculating the order of an item to be inserted as the average of its two adjacent elements. There is [a famous article by David Greenspan](https://observablehq.com/@dgreensp/implementing-fractional-indexing) that solves the problem of running out of keys after several partitions. We are using his algorithm, implemented [in this library](https://github.com/rocicorp/fractional-indexing). This means that if you insert, delete or move documents in the collection, you do not have to modify the order of the rest of the documents, making the operation more performant. --------- Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
@@ -73,6 +73,7 @@ The following options are available:
|
|||||||
| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
|
| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
|
||||||
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
|
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
|
||||||
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
|
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
|
||||||
|
| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
|
||||||
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
||||||
| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
|
| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
|
||||||
| `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). |
|
| `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). |
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ powerful Admin UI.
|
|||||||
| **`name`** \* | To be used as the property name when retrieved from the database. [More](./overview#field-names) |
|
| **`name`** \* | To be used as the property name when retrieved from the database. [More](./overview#field-names) |
|
||||||
| **`collection`** \* | The `slug`s having the relationship field or an array of collection slugs. |
|
| **`collection`** \* | The `slug`s having the relationship field or an array of collection slugs. |
|
||||||
| **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. If `collection` is an array, this field must exist for all specified collections |
|
| **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. If `collection` is an array, this field must exist for all specified collections |
|
||||||
|
| **`orderable`** | If true, enables custom ordering and joined documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
|
||||||
| **`where`** | A `Where` query to hide related documents from appearing. Will be merged with any `where` specified in the request. |
|
| **`where`** | A `Where` query to hide related documents from appearing. Will be merged with any `where` specified in the request. |
|
||||||
| **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](../queries/depth#max-depth). |
|
| **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](../queries/depth#max-depth). |
|
||||||
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |
|
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ export const renderListView = async (
|
|||||||
drawerSlug,
|
drawerSlug,
|
||||||
enableRowSelections,
|
enableRowSelections,
|
||||||
i18n: req.i18n,
|
i18n: req.i18n,
|
||||||
|
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
|
||||||
payload,
|
payload,
|
||||||
useAsTitle: collectionConfig.admin.useAsTitle,
|
useAsTitle: collectionConfig.admin.useAsTitle,
|
||||||
})
|
})
|
||||||
@@ -259,6 +260,7 @@ export const renderListView = async (
|
|||||||
defaultSort={sort}
|
defaultSort={sort}
|
||||||
listPreferences={listPreferences}
|
listPreferences={listPreferences}
|
||||||
modifySearchParams={!isInDrawer}
|
modifySearchParams={!isInDrawer}
|
||||||
|
orderableFieldName={collectionConfig.orderable === true ? '_order' : undefined}
|
||||||
>
|
>
|
||||||
{RenderServerComponent({
|
{RenderServerComponent({
|
||||||
clientProps: {
|
clientProps: {
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ export async function VersionsView(props: DocumentViewServerProps) {
|
|||||||
defaultLimit={limitToUse}
|
defaultLimit={limitToUse}
|
||||||
defaultSort={sort as string}
|
defaultSort={sort as string}
|
||||||
modifySearchParams
|
modifySearchParams
|
||||||
|
orderableFieldName={collectionConfig?.orderable === true ? '_order' : undefined}
|
||||||
>
|
>
|
||||||
<VersionsViewClient
|
<VersionsViewClient
|
||||||
baseClass={baseClass}
|
baseClass={baseClass}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type BuildTableStateArgs = {
|
|||||||
columns?: ColumnPreference[]
|
columns?: ColumnPreference[]
|
||||||
docs?: PaginatedDocs['docs']
|
docs?: PaginatedDocs['docs']
|
||||||
enableRowSelections?: boolean
|
enableRowSelections?: boolean
|
||||||
|
orderableFieldName: string
|
||||||
parent?: {
|
parent?: {
|
||||||
collectionSlug: CollectionSlug
|
collectionSlug: CollectionSlug
|
||||||
id: number | string
|
id: number | string
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
|
|
||||||
import type { Config, SanitizedConfig } from '../../config/types.js'
|
import type { Config, SanitizedConfig } from '../../config/types.js'
|
||||||
import type {
|
import type {
|
||||||
CollectionConfig,
|
CollectionConfig,
|
||||||
|
|||||||
@@ -507,6 +507,17 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
|
|||||||
duration: number
|
duration: number
|
||||||
}
|
}
|
||||||
| false
|
| false
|
||||||
|
/**
|
||||||
|
* If true, enables custom ordering for the collection, and documents in the listView can be reordered via drag and drop.
|
||||||
|
* New documents are inserted at the end of the list according to this parameter.
|
||||||
|
*
|
||||||
|
* Under the hood, a field with {@link https://observablehq.com/@dgreensp/implementing-fractional-indexing|fractional indexing} is used to optimize inserts and reorderings.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*
|
||||||
|
* @experimental There may be frequent breaking changes to this API
|
||||||
|
*/
|
||||||
|
orderable?: boolean
|
||||||
slug: string
|
slug: string
|
||||||
/**
|
/**
|
||||||
* Add `createdAt` and `updatedAt` fields
|
* Add `createdAt` and `updatedAt` fields
|
||||||
|
|||||||
318
packages/payload/src/config/orderable/fractional-indexing.js
Normal file
318
packages/payload/src/config/orderable/fractional-indexing.js
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THIS FILE IS COPIED FROM:
|
||||||
|
* https://github.com/rocicorp/fractional-indexing/blob/main/src/index.js
|
||||||
|
*
|
||||||
|
* I AM NOT INSTALLING THAT LIBRARY BECAUSE JEST COMPLAINS ABOUT THE ESM MODULE AND THE TESTS FAIL.
|
||||||
|
* DO NOT MODIFY IT
|
||||||
|
*/
|
||||||
|
|
||||||
|
// License: CC0 (no rights reserved).
|
||||||
|
|
||||||
|
// This is based on https://observablehq.com/@dgreensp/implementing-fractional-indexing
|
||||||
|
|
||||||
|
export const BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||||
|
|
||||||
|
// `a` may be empty string, `b` is null or non-empty string.
|
||||||
|
// `a < b` lexicographically if `b` is non-null.
|
||||||
|
// no trailing zeros allowed.
|
||||||
|
// digits is a string such as '0123456789' for base 10. Digits must be in
|
||||||
|
// ascending character code order!
|
||||||
|
/**
|
||||||
|
* @param {string} a
|
||||||
|
* @param {string | null | undefined} b
|
||||||
|
* @param {string} digits
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function midpoint(a, b, digits) {
|
||||||
|
const zero = digits[0]
|
||||||
|
if (b != null && a >= b) {
|
||||||
|
throw new Error(a + ' >= ' + b)
|
||||||
|
}
|
||||||
|
if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) {
|
||||||
|
throw new Error('trailing zero')
|
||||||
|
}
|
||||||
|
if (b) {
|
||||||
|
// remove longest common prefix. pad `a` with 0s as we
|
||||||
|
// go. note that we don't need to pad `b`, because it can't
|
||||||
|
// end before `a` while traversing the common prefix.
|
||||||
|
let n = 0
|
||||||
|
while ((a[n] || zero) === b[n]) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if (n > 0) {
|
||||||
|
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// first digits (or lack of digit) are different
|
||||||
|
const digitA = a ? digits.indexOf(a[0]) : 0
|
||||||
|
const digitB = b != null ? digits.indexOf(b[0]) : digits.length
|
||||||
|
if (digitB - digitA > 1) {
|
||||||
|
const midDigit = Math.round(0.5 * (digitA + digitB))
|
||||||
|
return digits[midDigit]
|
||||||
|
} else {
|
||||||
|
// first digits are consecutive
|
||||||
|
if (b && b.length > 1) {
|
||||||
|
return b.slice(0, 1)
|
||||||
|
} else {
|
||||||
|
// `b` is null or has length 1 (a single digit).
|
||||||
|
// the first digit of `a` is the previous digit to `b`,
|
||||||
|
// or 9 if `b` is null.
|
||||||
|
// given, for example, midpoint('49', '5'), return
|
||||||
|
// '4' + midpoint('9', null), which will become
|
||||||
|
// '4' + '9' + midpoint('', null), which is '495'
|
||||||
|
return digits[digitA] + midpoint(a.slice(1), null, digits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} int
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function validateInteger(int) {
|
||||||
|
if (int.length !== getIntegerLength(int[0])) {
|
||||||
|
throw new Error('invalid integer part of order key: ' + int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} head
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function getIntegerLength(head) {
|
||||||
|
if (head >= 'a' && head <= 'z') {
|
||||||
|
return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2
|
||||||
|
} else if (head >= 'A' && head <= 'Z') {
|
||||||
|
return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2
|
||||||
|
} else {
|
||||||
|
throw new Error('invalid order key head: ' + head)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function getIntegerPart(key) {
|
||||||
|
const integerPartLength = getIntegerLength(key[0])
|
||||||
|
if (integerPartLength > key.length) {
|
||||||
|
throw new Error('invalid order key: ' + key)
|
||||||
|
}
|
||||||
|
return key.slice(0, integerPartLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {string} digits
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function validateOrderKey(key, digits) {
|
||||||
|
if (key === 'A' + digits[0].repeat(26)) {
|
||||||
|
throw new Error('invalid order key: ' + key)
|
||||||
|
}
|
||||||
|
// getIntegerPart will throw if the first character is bad,
|
||||||
|
// or the key is too short. we'd call it to check these things
|
||||||
|
// even if we didn't need the result
|
||||||
|
const i = getIntegerPart(key)
|
||||||
|
const f = key.slice(i.length)
|
||||||
|
if (f.slice(-1) === digits[0]) {
|
||||||
|
throw new Error('invalid order key: ' + key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// note that this may return null, as there is a largest integer
|
||||||
|
/**
|
||||||
|
* @param {string} x
|
||||||
|
* @param {string} digits
|
||||||
|
* @return {string | null}
|
||||||
|
*/
|
||||||
|
function incrementInteger(x, digits) {
|
||||||
|
validateInteger(x)
|
||||||
|
const [head, ...digs] = x.split('')
|
||||||
|
let carry = true
|
||||||
|
for (let i = digs.length - 1; carry && i >= 0; i--) {
|
||||||
|
const d = digits.indexOf(digs[i]) + 1
|
||||||
|
if (d === digits.length) {
|
||||||
|
digs[i] = digits[0]
|
||||||
|
} else {
|
||||||
|
digs[i] = digits[d]
|
||||||
|
carry = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (carry) {
|
||||||
|
if (head === 'Z') {
|
||||||
|
return 'a' + digits[0]
|
||||||
|
}
|
||||||
|
if (head === 'z') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const h = String.fromCharCode(head.charCodeAt(0) + 1)
|
||||||
|
if (h > 'a') {
|
||||||
|
digs.push(digits[0])
|
||||||
|
} else {
|
||||||
|
digs.pop()
|
||||||
|
}
|
||||||
|
return h + digs.join('')
|
||||||
|
} else {
|
||||||
|
return head + digs.join('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// note that this may return null, as there is a smallest integer
|
||||||
|
/**
|
||||||
|
* @param {string} x
|
||||||
|
* @param {string} digits
|
||||||
|
* @return {string | null}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function decrementInteger(x, digits) {
|
||||||
|
validateInteger(x)
|
||||||
|
const [head, ...digs] = x.split('')
|
||||||
|
let borrow = true
|
||||||
|
for (let i = digs.length - 1; borrow && i >= 0; i--) {
|
||||||
|
const d = digits.indexOf(digs[i]) - 1
|
||||||
|
if (d === -1) {
|
||||||
|
digs[i] = digits.slice(-1)
|
||||||
|
} else {
|
||||||
|
digs[i] = digits[d]
|
||||||
|
borrow = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (borrow) {
|
||||||
|
if (head === 'a') {
|
||||||
|
return 'Z' + digits.slice(-1)
|
||||||
|
}
|
||||||
|
if (head === 'A') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const h = String.fromCharCode(head.charCodeAt(0) - 1)
|
||||||
|
if (h < 'Z') {
|
||||||
|
digs.push(digits.slice(-1))
|
||||||
|
} else {
|
||||||
|
digs.pop()
|
||||||
|
}
|
||||||
|
return h + digs.join('')
|
||||||
|
} else {
|
||||||
|
return head + digs.join('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `a` is an order key or null (START).
|
||||||
|
// `b` is an order key or null (END).
|
||||||
|
// `a < b` lexicographically if both are non-null.
|
||||||
|
// digits is a string such as '0123456789' for base 10. Digits must be in
|
||||||
|
// ascending character code order!
|
||||||
|
/**
|
||||||
|
* @param {string | null | undefined} a
|
||||||
|
* @param {string | null | undefined} b
|
||||||
|
* @param {string=} digits
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
export function generateKeyBetween(a, b, digits = BASE_62_DIGITS) {
|
||||||
|
if (a != null) {
|
||||||
|
validateOrderKey(a, digits)
|
||||||
|
}
|
||||||
|
if (b != null) {
|
||||||
|
validateOrderKey(b, digits)
|
||||||
|
}
|
||||||
|
if (a != null && b != null && a >= b) {
|
||||||
|
throw new Error(a + ' >= ' + b)
|
||||||
|
}
|
||||||
|
if (a == null) {
|
||||||
|
if (b == null) {
|
||||||
|
return 'a' + digits[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ib = getIntegerPart(b)
|
||||||
|
const fb = b.slice(ib.length)
|
||||||
|
if (ib === 'A' + digits[0].repeat(26)) {
|
||||||
|
return ib + midpoint('', fb, digits)
|
||||||
|
}
|
||||||
|
if (ib < b) {
|
||||||
|
return ib
|
||||||
|
}
|
||||||
|
const res = decrementInteger(ib, digits)
|
||||||
|
if (res == null) {
|
||||||
|
throw new Error('cannot decrement any more')
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b == null) {
|
||||||
|
const ia = getIntegerPart(a)
|
||||||
|
const fa = a.slice(ia.length)
|
||||||
|
const i = incrementInteger(ia, digits)
|
||||||
|
return i == null ? ia + midpoint(fa, null, digits) : i
|
||||||
|
}
|
||||||
|
|
||||||
|
const ia = getIntegerPart(a)
|
||||||
|
const fa = a.slice(ia.length)
|
||||||
|
const ib = getIntegerPart(b)
|
||||||
|
const fb = b.slice(ib.length)
|
||||||
|
if (ia === ib) {
|
||||||
|
return ia + midpoint(fa, fb, digits)
|
||||||
|
}
|
||||||
|
const i = incrementInteger(ia, digits)
|
||||||
|
if (i == null) {
|
||||||
|
throw new Error('cannot increment any more')
|
||||||
|
}
|
||||||
|
if (i < b) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return ia + midpoint(fa, null, digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* same preconditions as generateKeysBetween.
|
||||||
|
* n >= 0.
|
||||||
|
* Returns an array of n distinct keys in sorted order.
|
||||||
|
* If a and b are both null, returns [a0, a1, ...]
|
||||||
|
* If one or the other is null, returns consecutive "integer"
|
||||||
|
* keys. Otherwise, returns relatively short keys between
|
||||||
|
* a and b.
|
||||||
|
* @param {string | null | undefined} a
|
||||||
|
* @param {string | null | undefined} b
|
||||||
|
* @param {number} n
|
||||||
|
* @param {string} digits
|
||||||
|
* @return {string[]}
|
||||||
|
*/
|
||||||
|
export function generateNKeysBetween(a, b, n, digits = BASE_62_DIGITS) {
|
||||||
|
if (n === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (n === 1) {
|
||||||
|
return [generateKeyBetween(a, b, digits)]
|
||||||
|
}
|
||||||
|
if (b == null) {
|
||||||
|
let c = generateKeyBetween(a, b, digits)
|
||||||
|
const result = [c]
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
c = generateKeyBetween(c, b, digits)
|
||||||
|
result.push(c)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if (a == null) {
|
||||||
|
let c = generateKeyBetween(a, b, digits)
|
||||||
|
const result = [c]
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
c = generateKeyBetween(a, c, digits)
|
||||||
|
result.push(c)
|
||||||
|
}
|
||||||
|
result.reverse()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
const mid = Math.floor(n / 2)
|
||||||
|
const c = generateKeyBetween(a, b, digits)
|
||||||
|
return [
|
||||||
|
...generateNKeysBetween(a, c, mid, digits),
|
||||||
|
c,
|
||||||
|
...generateNKeysBetween(c, b, n - mid - 1, digits),
|
||||||
|
]
|
||||||
|
}
|
||||||
278
packages/payload/src/config/orderable/index.ts
Normal file
278
packages/payload/src/config/orderable/index.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import type { BeforeChangeHook, CollectionConfig } from '../../collections/config/types.js'
|
||||||
|
import type { Field } from '../../fields/config/types.js'
|
||||||
|
import type { Endpoint, PayloadHandler, SanitizedConfig } from '../types.js'
|
||||||
|
|
||||||
|
import executeAccess from '../../auth/executeAccess.js'
|
||||||
|
import { traverseFields } from '../../utilities/traverseFields.js'
|
||||||
|
import { generateKeyBetween, generateNKeysBetween } from './fractional-indexing.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function creates:
|
||||||
|
* - N fields per collection, named `_order` or `_<collection>_<joinField>_order`
|
||||||
|
* - 1 hook per collection
|
||||||
|
* - 1 endpoint per app
|
||||||
|
*
|
||||||
|
* Also, if collection.defaultSort or joinField.defaultSort is not set, it will be set to the orderable field.
|
||||||
|
*/
|
||||||
|
export const setupOrderable = (config: SanitizedConfig) => {
|
||||||
|
const fieldsToAdd = new Map<CollectionConfig, string[]>()
|
||||||
|
|
||||||
|
config.collections.forEach((collection) => {
|
||||||
|
if (collection.orderable) {
|
||||||
|
const currentFields = fieldsToAdd.get(collection) || []
|
||||||
|
fieldsToAdd.set(collection, [...currentFields, '_order'])
|
||||||
|
collection.defaultSort = collection.defaultSort ?? '_order'
|
||||||
|
}
|
||||||
|
|
||||||
|
traverseFields({
|
||||||
|
callback: ({ field, parentRef, ref }) => {
|
||||||
|
if (field.type === 'array' || field.type === 'blocks') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (field.type === 'group' || field.type === 'tab') {
|
||||||
|
// @ts-expect-error ref is untyped
|
||||||
|
const parentPrefix = parentRef?.prefix ? `${parentRef.prefix}_` : ''
|
||||||
|
// @ts-expect-error ref is untyped
|
||||||
|
ref.prefix = `${parentPrefix}${field.name}`
|
||||||
|
}
|
||||||
|
if (field.type === 'join' && field.orderable === true) {
|
||||||
|
if (Array.isArray(field.collection)) {
|
||||||
|
throw new Error('Orderable joins must target a single collection')
|
||||||
|
}
|
||||||
|
const relationshipCollection = config.collections.find((c) => c.slug === field.collection)
|
||||||
|
if (!relationshipCollection) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
field.defaultSort = field.defaultSort ?? `_${field.collection}_${field.name}_order`
|
||||||
|
const currentFields = fieldsToAdd.get(relationshipCollection) || []
|
||||||
|
// @ts-expect-error ref is untyped
|
||||||
|
const prefix = parentRef?.prefix ? `${parentRef.prefix}_` : ''
|
||||||
|
fieldsToAdd.set(relationshipCollection, [
|
||||||
|
...currentFields,
|
||||||
|
`_${field.collection}_${prefix}${field.name}_order`,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fields: collection.fields,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Array.from(fieldsToAdd.entries()).forEach(([collection, orderableFields]) => {
|
||||||
|
addOrderableFieldsAndHook(collection, orderableFields)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (fieldsToAdd.size > 0) {
|
||||||
|
addOrderableEndpoint(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addOrderableFieldsAndHook = (
|
||||||
|
collection: CollectionConfig,
|
||||||
|
orderableFieldNames: string[],
|
||||||
|
) => {
|
||||||
|
// 1. Add field
|
||||||
|
orderableFieldNames.forEach((orderableFieldName) => {
|
||||||
|
const orderField: Field = {
|
||||||
|
name: orderableFieldName,
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
disableBulkEdit: true,
|
||||||
|
disabled: true,
|
||||||
|
disableListColumn: true,
|
||||||
|
disableListFilter: true,
|
||||||
|
hidden: true,
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
index: true,
|
||||||
|
required: true,
|
||||||
|
// override the schema to make order fields optional for payload.create()
|
||||||
|
typescriptSchema: [
|
||||||
|
() => ({
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
unique: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
collection.fields.unshift(orderField)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Add hook
|
||||||
|
if (!collection.hooks) {
|
||||||
|
collection.hooks = {}
|
||||||
|
}
|
||||||
|
if (!collection.hooks.beforeChange) {
|
||||||
|
collection.hooks.beforeChange = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderBeforeChangeHook: BeforeChangeHook = async ({ data, operation, req }) => {
|
||||||
|
// Only set _order on create, not on update (unless explicitly provided)
|
||||||
|
if (operation === 'create') {
|
||||||
|
for (const orderableFieldName of orderableFieldNames) {
|
||||||
|
if (!data[orderableFieldName]) {
|
||||||
|
const lastDoc = await req.payload.find({
|
||||||
|
collection: collection.slug,
|
||||||
|
depth: 0,
|
||||||
|
limit: 1,
|
||||||
|
pagination: false,
|
||||||
|
req,
|
||||||
|
select: { [orderableFieldName]: true },
|
||||||
|
sort: `-${orderableFieldName}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const lastOrderValue = lastDoc.docs[0]?.[orderableFieldName] || null
|
||||||
|
data[orderableFieldName] = generateKeyBetween(lastOrderValue, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
collection.hooks.beforeChange.push(orderBeforeChangeHook)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The body of the reorder endpoint.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type OrderableEndpointBody = {
|
||||||
|
collectionSlug: string
|
||||||
|
docsToMove: string[]
|
||||||
|
newKeyWillBe: 'greater' | 'less'
|
||||||
|
orderableFieldName: string
|
||||||
|
target: {
|
||||||
|
id: string
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addOrderableEndpoint = (config: SanitizedConfig) => {
|
||||||
|
// 3. Add endpoint
|
||||||
|
const reorderHandler: PayloadHandler = async (req) => {
|
||||||
|
const body = (await req.json?.()) as OrderableEndpointBody
|
||||||
|
|
||||||
|
const { collectionSlug, docsToMove, newKeyWillBe, orderableFieldName, target } = body
|
||||||
|
|
||||||
|
if (!Array.isArray(docsToMove) || docsToMove.length === 0) {
|
||||||
|
return new Response(JSON.stringify({ error: 'docsToMove must be a non-empty array' }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof target !== 'object' ||
|
||||||
|
typeof target.id !== 'string' ||
|
||||||
|
typeof target.key !== 'string'
|
||||||
|
) {
|
||||||
|
return new Response(JSON.stringify({ error: 'target must be an object with id and key' }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (newKeyWillBe !== 'greater' && newKeyWillBe !== 'less') {
|
||||||
|
return new Response(JSON.stringify({ error: 'newKeyWillBe must be "greater" or "less"' }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const collection = config.collections.find((c) => c.slug === collectionSlug)
|
||||||
|
if (!collection) {
|
||||||
|
return new Response(JSON.stringify({ error: `Collection ${collectionSlug} not found` }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (typeof orderableFieldName !== 'string') {
|
||||||
|
return new Response(JSON.stringify({ error: 'orderableFieldName must be a string' }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent reordering if user doesn't have editing permissions
|
||||||
|
if (collection.access?.update) {
|
||||||
|
await executeAccess(
|
||||||
|
{
|
||||||
|
// Currently only one doc can be moved at a time. We should review this if we want to allow
|
||||||
|
// multiple docs to be moved at once in the future.
|
||||||
|
id: docsToMove[0],
|
||||||
|
data: {},
|
||||||
|
req,
|
||||||
|
},
|
||||||
|
collection.access.update,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetId = target.id
|
||||||
|
let targetKey = target.key
|
||||||
|
|
||||||
|
// If targetKey = pending, we need to find its current key.
|
||||||
|
// This can only happen if the user reorders rows quickly with a slow connection.
|
||||||
|
if (targetKey === 'pending') {
|
||||||
|
const beforeDoc = await req.payload.findByID({
|
||||||
|
id: targetId,
|
||||||
|
collection: collection.slug,
|
||||||
|
depth: 0,
|
||||||
|
select: { [orderableFieldName]: true },
|
||||||
|
})
|
||||||
|
targetKey = beforeDoc?.[orderableFieldName] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reason the endpoint does not receive this docId as an argument is that there
|
||||||
|
// are situations where the user may not see or know what the next or previous one is. For
|
||||||
|
// example, access control restrictions, if docBefore is the last one on the page, etc.
|
||||||
|
const adjacentDoc = await req.payload.find({
|
||||||
|
collection: collection.slug,
|
||||||
|
depth: 0,
|
||||||
|
limit: 1,
|
||||||
|
pagination: false,
|
||||||
|
select: { [orderableFieldName]: true },
|
||||||
|
sort: newKeyWillBe === 'greater' ? orderableFieldName : `-${orderableFieldName}`,
|
||||||
|
where: {
|
||||||
|
[orderableFieldName]: {
|
||||||
|
[newKeyWillBe === 'greater' ? 'greater_than' : 'less_than']: targetKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const adjacentDocKey = adjacentDoc.docs?.[0]?.[orderableFieldName] || null
|
||||||
|
|
||||||
|
// Currently N (= docsToMove.length) is always 1. Maybe in the future we will
|
||||||
|
// allow dragging and reordering multiple documents at once via the UI.
|
||||||
|
const orderValues =
|
||||||
|
newKeyWillBe === 'greater'
|
||||||
|
? generateNKeysBetween(targetKey, adjacentDocKey, docsToMove.length)
|
||||||
|
: generateNKeysBetween(adjacentDocKey, targetKey, docsToMove.length)
|
||||||
|
|
||||||
|
// Update each document with its new order value
|
||||||
|
for (const [index, id] of docsToMove.entries()) {
|
||||||
|
await req.payload.update({
|
||||||
|
id,
|
||||||
|
collection: collection.slug,
|
||||||
|
data: {
|
||||||
|
[orderableFieldName]: orderValues[index],
|
||||||
|
},
|
||||||
|
depth: 0,
|
||||||
|
req,
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ orderValues, success: true }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
status: 200,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderEndpoint: Endpoint = {
|
||||||
|
handler: reorderHandler,
|
||||||
|
method: 'post',
|
||||||
|
path: '/reorder',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.endpoints) {
|
||||||
|
config.endpoints = []
|
||||||
|
}
|
||||||
|
config.endpoints.push(reorderEndpoint)
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import { getDefaultJobsCollection, jobsCollectionSlug } from '../queues/config/i
|
|||||||
import { flattenBlock } from '../utilities/flattenAllFields.js'
|
import { flattenBlock } from '../utilities/flattenAllFields.js'
|
||||||
import { getSchedulePublishTask } from '../versions/schedule/job.js'
|
import { getSchedulePublishTask } from '../versions/schedule/job.js'
|
||||||
import { addDefaultsToConfig } from './defaults.js'
|
import { addDefaultsToConfig } from './defaults.js'
|
||||||
|
import { setupOrderable } from './orderable/index.js'
|
||||||
|
|
||||||
const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig> => {
|
const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig> => {
|
||||||
const sanitizedConfig = { ...configToSanitize }
|
const sanitizedConfig = { ...configToSanitize }
|
||||||
@@ -108,6 +109,9 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
|
|||||||
|
|
||||||
const config: Partial<SanitizedConfig> = sanitizeAdminConfig(configWithDefaults)
|
const config: Partial<SanitizedConfig> = sanitizeAdminConfig(configWithDefaults)
|
||||||
|
|
||||||
|
// Add orderable fields
|
||||||
|
setupOrderable(config as SanitizedConfig)
|
||||||
|
|
||||||
if (!config.endpoints) {
|
if (!config.endpoints) {
|
||||||
config.endpoints = []
|
config.endpoints = []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1549,6 +1549,17 @@ export type JoinField = {
|
|||||||
* A string for the field in the collection being joined to.
|
* A string for the field in the collection being joined to.
|
||||||
*/
|
*/
|
||||||
on: string
|
on: string
|
||||||
|
/**
|
||||||
|
* If true, enables custom ordering for the collection with the relationship, and joined documents can be reordered via drag and drop.
|
||||||
|
* New documents are inserted at the end of the list according to this parameter.
|
||||||
|
*
|
||||||
|
* Under the hood, a field with {@link https://observablehq.com/@dgreensp/implementing-fractional-indexing|fractional indexing} is used to optimize inserts and reorderings.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*
|
||||||
|
* @experimental There may be frequent breaking changes to this API
|
||||||
|
*/
|
||||||
|
orderable?: boolean
|
||||||
sanitizedMany?: JoinField[]
|
sanitizedMany?: JoinField[]
|
||||||
type: 'join'
|
type: 'join'
|
||||||
validate?: never
|
validate?: never
|
||||||
@@ -1562,7 +1573,15 @@ export type JoinFieldClient = {
|
|||||||
} & { targetField: Pick<RelationshipFieldClient, 'relationTo'> } & FieldBaseClient &
|
} & { targetField: Pick<RelationshipFieldClient, 'relationTo'> } & FieldBaseClient &
|
||||||
Pick<
|
Pick<
|
||||||
JoinField,
|
JoinField,
|
||||||
'collection' | 'defaultLimit' | 'defaultSort' | 'index' | 'maxDepth' | 'on' | 'type' | 'where'
|
| 'collection'
|
||||||
|
| 'defaultLimit'
|
||||||
|
| 'defaultSort'
|
||||||
|
| 'index'
|
||||||
|
| 'maxDepth'
|
||||||
|
| 'on'
|
||||||
|
| 'orderable'
|
||||||
|
| 'type'
|
||||||
|
| 'where'
|
||||||
>
|
>
|
||||||
|
|
||||||
export type FlattenedBlock = {
|
export type FlattenedBlock = {
|
||||||
|
|||||||
@@ -1090,6 +1090,7 @@ export {
|
|||||||
} from './config/client.js'
|
} from './config/client.js'
|
||||||
|
|
||||||
export { defaults } from './config/defaults.js'
|
export { defaults } from './config/defaults.js'
|
||||||
|
export { type OrderableEndpointBody } from './config/orderable/index.js'
|
||||||
export { sanitizeConfig } from './config/sanitize.js'
|
export { sanitizeConfig } from './config/sanitize.js'
|
||||||
export type * from './config/types.js'
|
export type * from './config/types.js'
|
||||||
export { combineQueries } from './database/combineQueries.js'
|
export { combineQueries } from './database/combineQueries.js'
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const flattenBlock = ({ block }: { block: Block }): FlattenedBlock => {
|
|||||||
|
|
||||||
const flattenedFieldsCache = new Map<Field[], FlattenedField[]>()
|
const flattenedFieldsCache = new Map<Field[], FlattenedField[]>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens all fields in a collection, preserving the nested field structure.
|
||||||
|
* @param cache
|
||||||
|
* @param fields
|
||||||
|
*/
|
||||||
export const flattenAllFields = ({
|
export const flattenAllFields = ({
|
||||||
cache,
|
cache,
|
||||||
fields,
|
fields,
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
|
|||||||
columns: transformColumnsToPreferences(query?.columns) || defaultColumns,
|
columns: transformColumnsToPreferences(query?.columns) || defaultColumns,
|
||||||
docs,
|
docs,
|
||||||
enableRowSelections: false,
|
enableRowSelections: false,
|
||||||
|
orderableFieldName:
|
||||||
|
!field.orderable || Array.isArray(field.collection)
|
||||||
|
? undefined
|
||||||
|
: `_${field.collection}_${field.name}_order`,
|
||||||
parent,
|
parent,
|
||||||
query: newQuery,
|
query: newQuery,
|
||||||
renderRowTypes: true,
|
renderRowTypes: true,
|
||||||
@@ -153,6 +157,10 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
|
|||||||
[
|
[
|
||||||
field.defaultLimit,
|
field.defaultLimit,
|
||||||
field.defaultSort,
|
field.defaultSort,
|
||||||
|
field.admin.defaultColumns,
|
||||||
|
field.collection,
|
||||||
|
field.name,
|
||||||
|
field.orderable,
|
||||||
collectionConfig?.admin?.pagination?.defaultLimit,
|
collectionConfig?.admin?.pagination?.defaultLimit,
|
||||||
collectionConfig?.defaultSort,
|
collectionConfig?.defaultSort,
|
||||||
query,
|
query,
|
||||||
@@ -329,6 +337,11 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
|
|||||||
}
|
}
|
||||||
modifySearchParams={false}
|
modifySearchParams={false}
|
||||||
onQueryChange={setQuery}
|
onQueryChange={setQuery}
|
||||||
|
orderableFieldName={
|
||||||
|
!field.orderable || Array.isArray(field.collection)
|
||||||
|
? undefined
|
||||||
|
: `_${field.collection}_${field.name}_order`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<TableColumnsProvider
|
<TableColumnsProvider
|
||||||
collectionSlug={Array.isArray(relationTo) ? relationTo[0] : relationTo}
|
collectionSlug={Array.isArray(relationTo) ? relationTo[0] : relationTo}
|
||||||
|
|||||||
52
packages/ui/src/elements/SortHeader/index.scss
Normal file
52
packages/ui/src/elements/SortHeader/index.scss
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
@import '../../scss/styles.scss';
|
||||||
|
|
||||||
|
@layer payload-default {
|
||||||
|
.sort-header {
|
||||||
|
display: flex;
|
||||||
|
gap: calc(var(--base) / 2);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&__buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(var(--base) / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__button {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 !important;
|
||||||
|
opacity: 0.3;
|
||||||
|
padding: calc(var(--base) / 4);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.sort-header--active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.btn {
|
||||||
|
opacity: 0.4;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--appearance-condensed {
|
||||||
|
gap: calc(var(--base) / 4);
|
||||||
|
|
||||||
|
.sort-header__buttons {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
packages/ui/src/elements/SortHeader/index.tsx
Normal file
87
packages/ui/src/elements/SortHeader/index.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { SortDownIcon, SortUpIcon } from '../../icons/Sort/index.js'
|
||||||
|
import { useListQuery } from '../../providers/ListQuery/index.js'
|
||||||
|
import './index.scss'
|
||||||
|
import { useTranslation } from '../../providers/Translation/index.js'
|
||||||
|
|
||||||
|
export type SortHeaderProps = {
|
||||||
|
readonly appearance?: 'condensed' | 'default'
|
||||||
|
readonly disable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseClass = 'sort-header'
|
||||||
|
|
||||||
|
function useSort() {
|
||||||
|
const { handleSortChange, orderableFieldName, query } = useListQuery()
|
||||||
|
const querySort = Array.isArray(query.sort) ? query.sort[0] : query.sort
|
||||||
|
const sort = useRef<'asc' | 'desc'>(querySort === `-${orderableFieldName}` ? 'desc' : 'asc')
|
||||||
|
const isActive = querySort === `-${orderableFieldName}` || querySort === orderableFieldName
|
||||||
|
|
||||||
|
// This is necessary if you initialize the page without sort url param
|
||||||
|
// but your preferences are to sort by -_order.
|
||||||
|
// Since sort isn't updated, the arrow would incorrectly point upward.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.current = querySort === `-${orderableFieldName}` ? 'desc' : 'asc'
|
||||||
|
}, [orderableFieldName, querySort, isActive])
|
||||||
|
|
||||||
|
const handleSortPress = () => {
|
||||||
|
// If it's already sorted by the "_order" field, toggle between "asc" and "desc"
|
||||||
|
if (isActive) {
|
||||||
|
void handleSortChange(sort.current === 'asc' ? `-${orderableFieldName}` : orderableFieldName)
|
||||||
|
sort.current = sort.current === 'asc' ? 'desc' : 'asc'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If NOT sorted by the "_order" field, sort by that field but do not toggle the current value of "asc" or "desc".
|
||||||
|
void handleSortChange(sort.current === 'asc' ? orderableFieldName : `-${orderableFieldName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleSortPress, isActive, sort }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SortHeader: React.FC<SortHeaderProps> = (props) => {
|
||||||
|
const { appearance } = props
|
||||||
|
const { handleSortPress, isActive, sort } = useSort()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[baseClass, appearance && `${baseClass}--appearance-${appearance}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
<div className={`${baseClass}__buttons`}>
|
||||||
|
{sort.current === 'desc' ? (
|
||||||
|
<button
|
||||||
|
aria-label={t('general:sortByLabelDirection', {
|
||||||
|
direction: t('general:ascending'),
|
||||||
|
label: 'Order',
|
||||||
|
})}
|
||||||
|
className={`${baseClass}__button ${baseClass}__${sort.current} ${isActive ? `${baseClass}--active` : ''}`}
|
||||||
|
onClick={handleSortPress}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<SortDownIcon />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
aria-label={t('general:sortByLabelDirection', {
|
||||||
|
direction: t('general:descending'),
|
||||||
|
label: 'Order',
|
||||||
|
})}
|
||||||
|
className={`${baseClass}__button ${baseClass}__${sort.current} ${isActive ? `${baseClass}--active` : ''}`}
|
||||||
|
onClick={handleSortPress}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<SortUpIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
packages/ui/src/elements/SortRow/index.scss
Normal file
22
packages/ui/src/elements/SortRow/index.scss
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
@import '../../scss/styles.scss';
|
||||||
|
|
||||||
|
@layer payload-default {
|
||||||
|
.sort-row {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
height: 22px;
|
||||||
|
width: 22px;
|
||||||
|
margin-left: -2px;
|
||||||
|
margin-top: -2px;
|
||||||
|
display: block;
|
||||||
|
width: min-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/ui/src/elements/SortRow/index.tsx
Normal file
20
packages/ui/src/elements/SortRow/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { DragHandleIcon } from '../../icons/DragHandle/index.js'
|
||||||
|
import './index.scss'
|
||||||
|
import { useListQuery } from '../../providers/ListQuery/index.js'
|
||||||
|
|
||||||
|
const baseClass = 'sort-row'
|
||||||
|
|
||||||
|
export const SortRow = () => {
|
||||||
|
const { orderableFieldName, query } = useListQuery()
|
||||||
|
const isActive = query.sort === orderableFieldName || query.sort === `-${orderableFieldName}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${baseClass} ${isActive ? 'active' : ''}`} role="button" tabIndex={0}>
|
||||||
|
<DragHandleIcon className={`${baseClass}__icon`} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
198
packages/ui/src/elements/Table/OrderableTable.tsx
Normal file
198
packages/ui/src/elements/Table/OrderableTable.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ClientCollectionConfig, Column, OrderableEndpointBody } from 'payload'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { useListQuery } from '../../providers/ListQuery/index.js'
|
||||||
|
import { DraggableSortableItem } from '../DraggableSortable/DraggableSortableItem/index.js'
|
||||||
|
import { DraggableSortable } from '../DraggableSortable/index.js'
|
||||||
|
|
||||||
|
const baseClass = 'table'
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
readonly appearance?: 'condensed' | 'default'
|
||||||
|
readonly collection: ClientCollectionConfig
|
||||||
|
readonly columns?: Column[]
|
||||||
|
readonly data: Record<string, unknown>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OrderableTable: React.FC<Props> = ({
|
||||||
|
appearance = 'default',
|
||||||
|
collection,
|
||||||
|
columns,
|
||||||
|
data: initialData,
|
||||||
|
}) => {
|
||||||
|
const { data: listQueryData, handleSortChange, orderableFieldName, query } = useListQuery()
|
||||||
|
// Use the data from ListQueryProvider if available, otherwise use the props
|
||||||
|
const serverData = listQueryData?.docs || initialData
|
||||||
|
|
||||||
|
// Local state to track the current order of rows
|
||||||
|
const [localData, setLocalData] = useState(serverData)
|
||||||
|
|
||||||
|
// id -> index for each column
|
||||||
|
const [cellMap, setCellMap] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
|
// Update local data when server data changes
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalData(serverData)
|
||||||
|
setCellMap(
|
||||||
|
Object.fromEntries(serverData.map((item, index) => [String(item.id ?? item._id), index])),
|
||||||
|
)
|
||||||
|
}, [serverData])
|
||||||
|
|
||||||
|
const activeColumns = columns?.filter((col) => col?.active)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!activeColumns ||
|
||||||
|
activeColumns.filter((col) => !['_dragHandle', '_select'].includes(col.accessor)).length === 0
|
||||||
|
) {
|
||||||
|
return <div>No columns selected</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = async ({ moveFromIndex, moveToIndex }) => {
|
||||||
|
if (query.sort !== orderableFieldName && query.sort !== `-${orderableFieldName}`) {
|
||||||
|
toast.warning('To reorder the rows you must first sort them by the "Order" column')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moveFromIndex === moveToIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedId = localData[moveFromIndex].id ?? localData[moveFromIndex]._id
|
||||||
|
const newBeforeRow =
|
||||||
|
moveToIndex > moveFromIndex ? localData[moveToIndex] : localData[moveToIndex - 1]
|
||||||
|
const newAfterRow =
|
||||||
|
moveToIndex > moveFromIndex ? localData[moveToIndex + 1] : localData[moveToIndex]
|
||||||
|
|
||||||
|
// Store the original data for rollback
|
||||||
|
const previousData = [...localData]
|
||||||
|
|
||||||
|
// Optimisitc update of local state to reorder the rows
|
||||||
|
setLocalData((currentData) => {
|
||||||
|
const newData = [...currentData]
|
||||||
|
// Update the rendered cell for the moved row to show "pending"
|
||||||
|
newData[moveFromIndex][orderableFieldName] = `pending`
|
||||||
|
// Move the item in the array
|
||||||
|
newData.splice(moveToIndex, 0, newData.splice(moveFromIndex, 1)[0])
|
||||||
|
return newData
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target: OrderableEndpointBody['target'] = newBeforeRow
|
||||||
|
? {
|
||||||
|
id: newBeforeRow.id ?? newBeforeRow._id,
|
||||||
|
key: newBeforeRow[orderableFieldName],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: newAfterRow.id ?? newAfterRow._id,
|
||||||
|
key: newAfterRow[orderableFieldName],
|
||||||
|
}
|
||||||
|
|
||||||
|
const newKeyWillBe =
|
||||||
|
(newBeforeRow && query.sort === orderableFieldName) ||
|
||||||
|
(!newBeforeRow && query.sort === `-${orderableFieldName}`)
|
||||||
|
? 'greater'
|
||||||
|
: 'less'
|
||||||
|
|
||||||
|
const jsonBody: OrderableEndpointBody = {
|
||||||
|
collectionSlug: collection.slug,
|
||||||
|
docsToMove: [movedId],
|
||||||
|
newKeyWillBe,
|
||||||
|
orderableFieldName,
|
||||||
|
target,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/reorder`, {
|
||||||
|
body: JSON.stringify(jsonBody),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
throw new Error('You do not have permission to reorder these rows')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
'Failed to reorder. This can happen if you reorder several rows too quickly. Please try again.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err)
|
||||||
|
// Rollback to previous state if the request fails
|
||||||
|
setLocalData(previousData)
|
||||||
|
toast.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowIds = localData.map((row) => row.id ?? row._id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[baseClass, appearance && `${baseClass}--appearance-${appearance}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
<DraggableSortable ids={rowIds} onDragEnd={handleDragEnd}>
|
||||||
|
<table cellPadding="0" cellSpacing="0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{activeColumns.map((col, i) => (
|
||||||
|
<th id={`heading-${col.accessor}`} key={i}>
|
||||||
|
{col.Heading}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{localData.map((row, rowIndex) => (
|
||||||
|
<DraggableSortableItem id={rowIds[rowIndex]} key={rowIds[rowIndex]}>
|
||||||
|
{({ attributes, listeners, setNodeRef, transform, transition }) => (
|
||||||
|
<tr
|
||||||
|
className={`row-${rowIndex + 1}`}
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeColumns.map((col, colIndex) => {
|
||||||
|
const { accessor } = col
|
||||||
|
|
||||||
|
// Use the cellMap to find which index in the renderedCells to use
|
||||||
|
const cell = col.renderedCells[cellMap[row.id ?? row._id]]
|
||||||
|
|
||||||
|
// For drag handles, wrap in div with drag attributes
|
||||||
|
if (accessor === '_dragHandle') {
|
||||||
|
return (
|
||||||
|
<td className={`cell-${accessor}`} key={colIndex}>
|
||||||
|
<div {...attributes} {...listeners}>
|
||||||
|
{cell}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td className={`cell-${accessor}`} key={colIndex}>
|
||||||
|
{cell}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</DraggableSortableItem>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</DraggableSortable>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
packages/ui/src/icons/Sort/index.scss
Normal file
14
packages/ui/src/icons/Sort/index.scss
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@import '../../scss/styles';
|
||||||
|
|
||||||
|
@layer payload-default {
|
||||||
|
.icon--sort {
|
||||||
|
height: $baseline;
|
||||||
|
width: $baseline;
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: $style-stroke-width-s;
|
||||||
|
fill: var(--theme-elevation-800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
packages/ui/src/icons/Sort/index.tsx
Normal file
41
packages/ui/src/icons/Sort/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
export const SortDownIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<svg
|
||||||
|
className="icon icon--sort"
|
||||||
|
fill="none"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
width="20"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
className="fill"
|
||||||
|
d="M2.5 13.3333L5.83333 16.6667M5.83333 16.6667L9.16667 13.3333M5.83333 16.6667V3.33333M9.16667 7.08333H17.5M9.16667 10.4167H15M11.6667 13.75H12.5"
|
||||||
|
stroke="#2F2F2F"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const SortUpIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<svg
|
||||||
|
className="icon icon--sort"
|
||||||
|
fill="none"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
width="20"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
className="fill"
|
||||||
|
d="M2.5 6.66668L5.83333 3.33334M5.83333 3.33334L9.16667 6.66668M5.83333 3.33334V16.6667M11.6667 7.08354H17.5M9.16667 10.4169H15M9.16667 13.7502H12.5"
|
||||||
|
stroke="#2F2F2F"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
@@ -25,6 +25,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
listPreferences,
|
listPreferences,
|
||||||
modifySearchParams,
|
modifySearchParams,
|
||||||
onQueryChange: onQueryChangeFromProps,
|
onQueryChange: onQueryChangeFromProps,
|
||||||
|
orderableFieldName,
|
||||||
}) => {
|
}) => {
|
||||||
'use no memo'
|
'use no memo'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -207,6 +208,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
handleSearchChange,
|
handleSearchChange,
|
||||||
handleSortChange,
|
handleSortChange,
|
||||||
handleWhereChange,
|
handleWhereChange,
|
||||||
|
orderableFieldName,
|
||||||
query: currentQuery,
|
query: currentQuery,
|
||||||
refineListData,
|
refineListData,
|
||||||
setModified,
|
setModified,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type ListQueryProps = {
|
|||||||
readonly listPreferences?: ListPreferences
|
readonly listPreferences?: ListPreferences
|
||||||
readonly modifySearchParams?: boolean
|
readonly modifySearchParams?: boolean
|
||||||
readonly onQueryChange?: OnListQueryChange
|
readonly onQueryChange?: OnListQueryChange
|
||||||
|
readonly orderableFieldName?: string
|
||||||
/**
|
/**
|
||||||
* @deprecated
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
@@ -40,6 +41,7 @@ export type IListQueryContext = {
|
|||||||
data: PaginatedDocs
|
data: PaginatedDocs
|
||||||
defaultLimit?: number
|
defaultLimit?: number
|
||||||
defaultSort?: Sort
|
defaultSort?: Sort
|
||||||
|
orderableFieldName?: string
|
||||||
modified: boolean
|
modified: boolean
|
||||||
query: ListQuery
|
query: ListQuery
|
||||||
refineListData: (args: ListQuery, setModified?: boolean) => Promise<void>
|
refineListData: (args: ListQuery, setModified?: boolean) => Promise<void>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const buildTableStateHandler = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildTableState = async (
|
const buildTableState = async (
|
||||||
args: BuildTableStateArgs,
|
args: BuildTableStateArgs,
|
||||||
): Promise<BuildTableStateSuccessResult> => {
|
): Promise<BuildTableStateSuccessResult> => {
|
||||||
const {
|
const {
|
||||||
@@ -74,6 +74,7 @@ export const buildTableState = async (
|
|||||||
columns,
|
columns,
|
||||||
docs: docsFromArgs,
|
docs: docsFromArgs,
|
||||||
enableRowSelections,
|
enableRowSelections,
|
||||||
|
orderableFieldName,
|
||||||
parent,
|
parent,
|
||||||
query,
|
query,
|
||||||
renderRowTypes,
|
renderRowTypes,
|
||||||
@@ -233,6 +234,7 @@ export const buildTableState = async (
|
|||||||
docs,
|
docs,
|
||||||
enableRowSelections,
|
enableRowSelections,
|
||||||
i18n: req.i18n,
|
i18n: req.i18n,
|
||||||
|
orderableFieldName,
|
||||||
payload,
|
payload,
|
||||||
renderRowTypes,
|
renderRowTypes,
|
||||||
tableAppearance,
|
tableAppearance,
|
||||||
|
|||||||
@@ -13,16 +13,19 @@ import type {
|
|||||||
|
|
||||||
import { getTranslation, type I18nClient } from '@payloadcms/translations'
|
import { getTranslation, type I18nClient } from '@payloadcms/translations'
|
||||||
import { fieldAffectsData, fieldIsHiddenOrDisabled, flattenTopLevelFields } from 'payload/shared'
|
import { fieldAffectsData, fieldIsHiddenOrDisabled, flattenTopLevelFields } from 'payload/shared'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
// eslint-disable-next-line payload/no-imports-from-exports-dir
|
// eslint-disable-next-line payload/no-imports-from-exports-dir
|
||||||
import type { Column } from '../exports/client/index.js'
|
import type { Column } from '../exports/client/index.js'
|
||||||
|
|
||||||
import { RenderServerComponent } from '../elements/RenderServerComponent/index.js'
|
import { RenderServerComponent } from '../elements/RenderServerComponent/index.js'
|
||||||
|
import { SortHeader } from '../elements/SortHeader/index.js'
|
||||||
|
import { SortRow } from '../elements/SortRow/index.js'
|
||||||
|
import { OrderableTable } from '../elements/Table/OrderableTable.js'
|
||||||
import { buildColumnState } from '../providers/TableColumns/buildColumnState.js'
|
import { buildColumnState } from '../providers/TableColumns/buildColumnState.js'
|
||||||
import { buildPolymorphicColumnState } from '../providers/TableColumns/buildPolymorphicColumnState.js'
|
import { buildPolymorphicColumnState } from '../providers/TableColumns/buildPolymorphicColumnState.js'
|
||||||
import { filterFields } from '../providers/TableColumns/filterFields.js'
|
import { filterFields } from '../providers/TableColumns/filterFields.js'
|
||||||
import { getInitialColumns } from '../providers/TableColumns/getInitialColumns.js'
|
import { getInitialColumns } from '../providers/TableColumns/getInitialColumns.js'
|
||||||
|
|
||||||
// eslint-disable-next-line payload/no-imports-from-exports-dir
|
// eslint-disable-next-line payload/no-imports-from-exports-dir
|
||||||
import { Pill, SelectAll, SelectRow, Table } from '../exports/client/index.js'
|
import { Pill, SelectAll, SelectRow, Table } from '../exports/client/index.js'
|
||||||
|
|
||||||
@@ -62,6 +65,7 @@ export const renderTable = ({
|
|||||||
docs,
|
docs,
|
||||||
enableRowSelections,
|
enableRowSelections,
|
||||||
i18n,
|
i18n,
|
||||||
|
orderableFieldName,
|
||||||
payload,
|
payload,
|
||||||
renderRowTypes,
|
renderRowTypes,
|
||||||
tableAppearance,
|
tableAppearance,
|
||||||
@@ -78,6 +82,7 @@ export const renderTable = ({
|
|||||||
drawerSlug?: string
|
drawerSlug?: string
|
||||||
enableRowSelections: boolean
|
enableRowSelections: boolean
|
||||||
i18n: I18nClient
|
i18n: I18nClient
|
||||||
|
orderableFieldName: string
|
||||||
payload: Payload
|
payload: Payload
|
||||||
renderRowTypes?: boolean
|
renderRowTypes?: boolean
|
||||||
tableAppearance?: 'condensed' | 'default'
|
tableAppearance?: 'condensed' | 'default'
|
||||||
@@ -195,9 +200,38 @@ export const renderTable = ({
|
|||||||
} as Column)
|
} as Column)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orderableFieldName) {
|
||||||
return {
|
return {
|
||||||
columnState,
|
columnState,
|
||||||
// key is required since Next.js 15.2.0 to prevent React key error
|
// key is required since Next.js 15.2.0 to prevent React key error
|
||||||
Table: <Table appearance={tableAppearance} columns={columnsToUse} data={docs} key="table" />,
|
Table: <Table appearance={tableAppearance} columns={columnsToUse} data={docs} key="table" />,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
columnsToUse.unshift({
|
||||||
|
accessor: '_dragHandle',
|
||||||
|
active: true,
|
||||||
|
field: {
|
||||||
|
admin: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
Heading: <SortHeader />,
|
||||||
|
renderedCells: docs.map((_, i) => <SortRow key={i} />),
|
||||||
|
} as Column)
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnState,
|
||||||
|
// key is required since Next.js 15.2.0 to prevent React key error
|
||||||
|
Table: (
|
||||||
|
<OrderableTable
|
||||||
|
appearance={tableAppearance}
|
||||||
|
collection={clientCollectionConfig}
|
||||||
|
columns={columnsToUse}
|
||||||
|
data={docs}
|
||||||
|
key="table"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#heading-_dragHandle,
|
||||||
|
.cell-_dragHandle {
|
||||||
|
width: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
pnpm-lock.yaml
generated
51
pnpm-lock.yaml
generated
@@ -45,7 +45,7 @@ importers:
|
|||||||
version: 1.50.0
|
version: 1.50.0
|
||||||
'@sentry/nextjs':
|
'@sentry/nextjs':
|
||||||
specifier: ^8.33.1
|
specifier: ^8.33.1
|
||||||
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
|
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
|
||||||
'@sentry/node':
|
'@sentry/node':
|
||||||
specifier: ^8.33.1
|
specifier: ^8.33.1
|
||||||
version: 8.37.1
|
version: 8.37.1
|
||||||
@@ -135,7 +135,7 @@ importers:
|
|||||||
version: 10.1.3(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)
|
version: 10.1.3(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)
|
||||||
next:
|
next:
|
||||||
specifier: 15.2.3
|
specifier: 15.2.3
|
||||||
version: 15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
|
version: 15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
|
||||||
open:
|
open:
|
||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
@@ -1076,7 +1076,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
next:
|
next:
|
||||||
specifier: ^15.2.3
|
specifier: ^15.2.3
|
||||||
version: 15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
|
version: 15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@payloadcms/eslint-config':
|
'@payloadcms/eslint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
@@ -1141,7 +1141,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@sentry/nextjs':
|
'@sentry/nextjs':
|
||||||
specifier: ^8.33.1
|
specifier: ^8.33.1
|
||||||
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
|
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
|
||||||
'@sentry/types':
|
'@sentry/types':
|
||||||
specifier: ^8.33.1
|
specifier: ^8.33.1
|
||||||
version: 8.37.1
|
version: 8.37.1
|
||||||
@@ -1500,7 +1500,7 @@ importers:
|
|||||||
version: link:../plugin-cloud-storage
|
version: link:../plugin-cloud-storage
|
||||||
uploadthing:
|
uploadthing:
|
||||||
specifier: 7.3.0
|
specifier: 7.3.0
|
||||||
version: 7.3.0(next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))
|
version: 7.3.0(next@15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
payload:
|
payload:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
@@ -1786,7 +1786,7 @@ importers:
|
|||||||
version: link:../packages/ui
|
version: link:../packages/ui
|
||||||
'@sentry/nextjs':
|
'@sentry/nextjs':
|
||||||
specifier: ^8.33.1
|
specifier: ^8.33.1
|
||||||
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
|
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
|
||||||
'@sentry/react':
|
'@sentry/react':
|
||||||
specifier: ^7.77.0
|
specifier: ^7.77.0
|
||||||
version: 7.119.2(react@19.0.0)
|
version: 7.119.2(react@19.0.0)
|
||||||
@@ -1843,7 +1843,7 @@ importers:
|
|||||||
version: 8.9.5(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)
|
version: 8.9.5(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)
|
||||||
next:
|
next:
|
||||||
specifier: 15.2.3
|
specifier: 15.2.3
|
||||||
version: 15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
|
version: 15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
|
||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: 6.9.16
|
specifier: 6.9.16
|
||||||
version: 6.9.16
|
version: 6.9.16
|
||||||
@@ -13719,7 +13719,7 @@ snapshots:
|
|||||||
'@sentry/utils': 7.119.2
|
'@sentry/utils': 7.119.2
|
||||||
localforage: 1.10.0
|
localforage: 1.10.0
|
||||||
|
|
||||||
'@sentry/nextjs@8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))':
|
'@sentry/nextjs@8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@opentelemetry/instrumentation-http': 0.53.0(@opentelemetry/api@1.9.0)
|
'@opentelemetry/instrumentation-http': 0.53.0(@opentelemetry/api@1.9.0)
|
||||||
@@ -13735,7 +13735,7 @@ snapshots:
|
|||||||
'@sentry/vercel-edge': 8.37.1
|
'@sentry/vercel-edge': 8.37.1
|
||||||
'@sentry/webpack-plugin': 2.22.6(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
|
'@sentry/webpack-plugin': 2.22.6(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
|
||||||
chalk: 3.0.0
|
chalk: 3.0.0
|
||||||
next: 15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
|
next: 15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
|
||||||
resolve: 1.22.8
|
resolve: 1.22.8
|
||||||
rollup: 3.29.5
|
rollup: 3.29.5
|
||||||
stacktrace-parser: 0.1.10
|
stacktrace-parser: 0.1.10
|
||||||
@@ -18432,35 +18432,6 @@ snapshots:
|
|||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
|
||||||
next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4):
|
|
||||||
dependencies:
|
|
||||||
'@next/env': 15.2.3
|
|
||||||
'@swc/counter': 0.1.3
|
|
||||||
'@swc/helpers': 0.5.15
|
|
||||||
busboy: 1.6.0
|
|
||||||
caniuse-lite: 1.0.30001678
|
|
||||||
postcss: 8.4.31
|
|
||||||
react: 19.0.0
|
|
||||||
react-dom: 19.0.0(react@19.0.0)
|
|
||||||
styled-jsx: 5.1.6(@babel/core@7.26.7)(babel-plugin-macros@3.1.0)(react@19.0.0)
|
|
||||||
optionalDependencies:
|
|
||||||
'@next/swc-darwin-arm64': 15.2.3
|
|
||||||
'@next/swc-darwin-x64': 15.2.3
|
|
||||||
'@next/swc-linux-arm64-gnu': 15.2.3
|
|
||||||
'@next/swc-linux-arm64-musl': 15.2.3
|
|
||||||
'@next/swc-linux-x64-gnu': 15.2.3
|
|
||||||
'@next/swc-linux-x64-musl': 15.2.3
|
|
||||||
'@next/swc-win32-arm64-msvc': 15.2.3
|
|
||||||
'@next/swc-win32-x64-msvc': 15.2.3
|
|
||||||
'@opentelemetry/api': 1.9.0
|
|
||||||
'@playwright/test': 1.50.0
|
|
||||||
babel-plugin-react-compiler: 19.0.0-beta-714736e-20250131
|
|
||||||
sass: 1.77.4
|
|
||||||
sharp: 0.33.5
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@babel/core'
|
|
||||||
- babel-plugin-macros
|
|
||||||
|
|
||||||
node-abi@3.71.0:
|
node-abi@3.71.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.6.3
|
semver: 7.6.3
|
||||||
@@ -20203,14 +20174,14 @@ snapshots:
|
|||||||
escalade: 3.2.0
|
escalade: 3.2.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
uploadthing@7.3.0(next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)):
|
uploadthing@7.3.0(next@15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@effect/platform': 0.69.8(effect@3.10.3)
|
'@effect/platform': 0.69.8(effect@3.10.3)
|
||||||
'@uploadthing/mime-types': 0.3.2
|
'@uploadthing/mime-types': 0.3.2
|
||||||
'@uploadthing/shared': 7.1.1
|
'@uploadthing/shared': 7.1.1
|
||||||
effect: 3.10.3
|
effect: 3.10.3
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
next: 15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
|
next: 15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -114,7 +114,9 @@ describe('Radio', () => {
|
|||||||
// nested in a group error
|
// nested in a group error
|
||||||
await page.locator('#field-group__unique').fill(uniqueText)
|
await page.locator('#field-group__unique').fill(uniqueText)
|
||||||
|
|
||||||
await wait(1000)
|
// TODO: used because otherwise the toast locator resolves to 2 items
|
||||||
|
// at the same time. Instead we should uniquely identify each toast.
|
||||||
|
await wait(2000)
|
||||||
|
|
||||||
// attempt to save
|
// attempt to save
|
||||||
await page.locator('#action-save').click()
|
await page.locator('#action-save').click()
|
||||||
|
|||||||
19
test/sort/Seed.tsx
Normal file
19
test/sort/Seed.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
export const Seed = () => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/seed', { method: 'POST' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Seed
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
test/sort/collections/Orderable/index.ts
Normal file
27
test/sort/collections/Orderable/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
import { orderableJoinSlug } from '../OrderableJoin/index.js'
|
||||||
|
|
||||||
|
export const orderableSlug = 'orderable'
|
||||||
|
|
||||||
|
export const OrderableCollection: CollectionConfig = {
|
||||||
|
slug: orderableSlug,
|
||||||
|
orderable: true,
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'title',
|
||||||
|
components: {
|
||||||
|
beforeList: ['/Seed.tsx#Seed'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'orderableField',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: orderableJoinSlug,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
39
test/sort/collections/OrderableJoin/index.ts
Normal file
39
test/sort/collections/OrderableJoin/index.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
export const orderableJoinSlug = 'orderable-join'
|
||||||
|
|
||||||
|
export const OrderableJoinCollection: CollectionConfig = {
|
||||||
|
slug: orderableJoinSlug,
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'title',
|
||||||
|
components: {
|
||||||
|
beforeList: ['/Seed.tsx#Seed'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'orderableJoinField1',
|
||||||
|
type: 'join',
|
||||||
|
on: 'orderableField',
|
||||||
|
orderable: true,
|
||||||
|
collection: 'orderable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'orderableJoinField2',
|
||||||
|
type: 'join',
|
||||||
|
on: 'orderableField',
|
||||||
|
orderable: true,
|
||||||
|
collection: 'orderable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'nonOrderableJoinField',
|
||||||
|
type: 'join',
|
||||||
|
on: 'orderableField',
|
||||||
|
collection: 'orderable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { CollectionSlug, Payload } from 'payload'
|
||||||
|
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
@@ -6,17 +8,39 @@ import { devUser } from '../credentials.js'
|
|||||||
import { DefaultSortCollection } from './collections/DefaultSort/index.js'
|
import { DefaultSortCollection } from './collections/DefaultSort/index.js'
|
||||||
import { DraftsCollection } from './collections/Drafts/index.js'
|
import { DraftsCollection } from './collections/Drafts/index.js'
|
||||||
import { LocalizedCollection } from './collections/Localized/index.js'
|
import { LocalizedCollection } from './collections/Localized/index.js'
|
||||||
|
import { OrderableCollection } from './collections/Orderable/index.js'
|
||||||
|
import { OrderableJoinCollection } from './collections/OrderableJoin/index.js'
|
||||||
import { PostsCollection } from './collections/Posts/index.js'
|
import { PostsCollection } from './collections/Posts/index.js'
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
|
|
||||||
export default buildConfigWithDefaults({
|
export default buildConfigWithDefaults({
|
||||||
collections: [PostsCollection, DraftsCollection, DefaultSortCollection, LocalizedCollection],
|
collections: [
|
||||||
|
PostsCollection,
|
||||||
|
DraftsCollection,
|
||||||
|
DefaultSortCollection,
|
||||||
|
LocalizedCollection,
|
||||||
|
OrderableCollection,
|
||||||
|
OrderableJoinCollection,
|
||||||
|
],
|
||||||
admin: {
|
admin: {
|
||||||
importMap: {
|
importMap: {
|
||||||
baseDir: path.resolve(dirname),
|
baseDir: path.resolve(dirname),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
path: '/seed',
|
||||||
|
method: 'post',
|
||||||
|
handler: async (req) => {
|
||||||
|
await seedSortable(req.payload)
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
status: 200,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
cors: ['http://localhost:3000', 'http://localhost:3001'],
|
cors: ['http://localhost:3000', 'http://localhost:3001'],
|
||||||
localization: {
|
localization: {
|
||||||
locales: ['en', 'nb'],
|
locales: ['en', 'nb'],
|
||||||
@@ -30,8 +54,41 @@ export default buildConfigWithDefaults({
|
|||||||
password: devUser.password,
|
password: devUser.password,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
await seedSortable(payload)
|
||||||
},
|
},
|
||||||
typescript: {
|
typescript: {
|
||||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export async function createData(
|
||||||
|
payload: Payload,
|
||||||
|
collection: CollectionSlug,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
data: Record<string, any>[],
|
||||||
|
) {
|
||||||
|
for (const item of data) {
|
||||||
|
await payload.create({ collection, data: item })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedSortable(payload: Payload) {
|
||||||
|
await payload.delete({ collection: 'orderable', where: {} })
|
||||||
|
await payload.delete({ collection: 'orderable-join', where: {} })
|
||||||
|
|
||||||
|
const joinA = await payload.create({ collection: 'orderable-join', data: { title: 'Join A' } })
|
||||||
|
|
||||||
|
await createData(payload, 'orderable', [
|
||||||
|
{ title: 'A', orderableField: joinA.id },
|
||||||
|
{ title: 'B', orderableField: joinA.id },
|
||||||
|
{ title: 'C', orderableField: joinA.id },
|
||||||
|
{ title: 'D', orderableField: joinA.id },
|
||||||
|
])
|
||||||
|
|
||||||
|
await payload.create({ collection: 'orderable-join', data: { title: 'Join B' } })
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
status: 200,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
152
test/sort/e2e.spec.ts
Normal file
152
test/sort/e2e.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import type { BrowserContext, Page } from '@playwright/test'
|
||||||
|
|
||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { RESTClient } from 'helpers/rest.js'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
|
||||||
|
import type { Config } from './payload-types.js'
|
||||||
|
|
||||||
|
import { ensureCompilationIsDone, initPageConsoleErrorCatch } from '../helpers.js'
|
||||||
|
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||||
|
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||||
|
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
||||||
|
import { orderableSlug } from './collections/Orderable/index.js'
|
||||||
|
import { orderableJoinSlug } from './collections/OrderableJoin/index.js'
|
||||||
|
const filename = fileURLToPath(import.meta.url)
|
||||||
|
const dirname = path.dirname(filename)
|
||||||
|
|
||||||
|
const { beforeAll, describe } = test
|
||||||
|
let page: Page
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
let payload: PayloadTestSDK<Config>
|
||||||
|
let client: RESTClient
|
||||||
|
let serverURL: string
|
||||||
|
let context: BrowserContext
|
||||||
|
|
||||||
|
describe('Sort functionality', () => {
|
||||||
|
beforeAll(async ({ browser }, testInfo) => {
|
||||||
|
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
||||||
|
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
|
||||||
|
|
||||||
|
context = await browser.newContext()
|
||||||
|
page = await context.newPage()
|
||||||
|
|
||||||
|
initPageConsoleErrorCatch(page)
|
||||||
|
|
||||||
|
client = new RESTClient({ defaultSlug: 'users', serverURL })
|
||||||
|
await client.login()
|
||||||
|
|
||||||
|
await ensureCompilationIsDone({ page, serverURL })
|
||||||
|
})
|
||||||
|
|
||||||
|
// NOTES: It works for me in headed browser but not in headless, I don't know why.
|
||||||
|
// If you are debugging this test, remember to press the seed button before each attempt.
|
||||||
|
// assertRows contains expect
|
||||||
|
// eslint-disable-next-line playwright/expect-expect
|
||||||
|
test('Orderable collection', async () => {
|
||||||
|
const url = new AdminUrlUtil(serverURL, orderableSlug)
|
||||||
|
await page.goto(`${url.list}?sort=-_order`)
|
||||||
|
// SORT BY ORDER ASCENDING
|
||||||
|
await page.locator('.sort-header button').nth(0).click()
|
||||||
|
await assertRows(0, 'A', 'B', 'C', 'D')
|
||||||
|
await moveRow(2, 3) // move to middle
|
||||||
|
await assertRows(0, 'A', 'C', 'B', 'D')
|
||||||
|
await moveRow(3, 1) // move to top
|
||||||
|
await assertRows(0, 'B', 'A', 'C', 'D')
|
||||||
|
await moveRow(1, 4) // move to bottom
|
||||||
|
await assertRows(0, 'A', 'C', 'D', 'B')
|
||||||
|
|
||||||
|
// SORT BY ORDER DESCENDING
|
||||||
|
await page.locator('.sort-header button').nth(0).click()
|
||||||
|
await page.waitForURL(/sort=-_order/, { timeout: 2000 })
|
||||||
|
await assertRows(0, 'B', 'D', 'C', 'A')
|
||||||
|
await moveRow(1, 3) // move to middle
|
||||||
|
await assertRows(0, 'D', 'C', 'B', 'A')
|
||||||
|
await moveRow(3, 1) // move to top
|
||||||
|
await assertRows(0, 'B', 'D', 'C', 'A')
|
||||||
|
await moveRow(1, 4) // move to bottom
|
||||||
|
await assertRows(0, 'D', 'C', 'A', 'B')
|
||||||
|
|
||||||
|
// SORT BY TITLE
|
||||||
|
await page.getByLabel('Sort by Title Ascending').click()
|
||||||
|
await page.waitForURL(/sort=title/, { timeout: 2000 })
|
||||||
|
await moveRow(1, 3, 'warning') // warning because not sorted by order first
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Orderable join fields', async () => {
|
||||||
|
const url = new AdminUrlUtil(serverURL, orderableJoinSlug)
|
||||||
|
await page.goto(url.list)
|
||||||
|
|
||||||
|
await page.getByText('Join A').click()
|
||||||
|
await expect(page.locator('.sort-header button')).toHaveCount(2)
|
||||||
|
|
||||||
|
await page.locator('.sort-header button').nth(0).click()
|
||||||
|
await assertRows(0, 'A', 'B', 'C', 'D')
|
||||||
|
await moveRow(2, 3, 'success', 0) // move to middle
|
||||||
|
await assertRows(0, 'A', 'C', 'B', 'D')
|
||||||
|
|
||||||
|
await page.locator('.sort-header button').nth(1).click()
|
||||||
|
await assertRows(1, 'A', 'B', 'C', 'D')
|
||||||
|
await moveRow(1, 4, 'success', 1) // move to end
|
||||||
|
await assertRows(1, 'B', 'C', 'D', 'A')
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await page.locator('.sort-header button').nth(0).click()
|
||||||
|
await page.locator('.sort-header button').nth(1).click()
|
||||||
|
await assertRows(0, 'A', 'C', 'B', 'D')
|
||||||
|
await assertRows(1, 'B', 'C', 'D', 'A')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function moveRow(
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
expected: 'success' | 'warning' = 'success',
|
||||||
|
nthTable = 0,
|
||||||
|
) {
|
||||||
|
// counting from 1, zero excluded
|
||||||
|
const table = page.locator(`tbody`).nth(nthTable)
|
||||||
|
const dragHandle = table.locator(`.sort-row`)
|
||||||
|
const source = dragHandle.nth(from - 1)
|
||||||
|
const target = dragHandle.nth(to - 1)
|
||||||
|
|
||||||
|
const sourceBox = await source.boundingBox()
|
||||||
|
const targetBox = await target.boundingBox()
|
||||||
|
if (!sourceBox || !targetBox) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not find elements to DnD. Probably the dndkit animation is not finished. Try increasing the timeout`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// steps is important: move slightly to trigger the drag sensor of DnD-kit
|
||||||
|
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2, {
|
||||||
|
steps: 10,
|
||||||
|
})
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, {
|
||||||
|
steps: 10,
|
||||||
|
})
|
||||||
|
await page.mouse.up()
|
||||||
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||||
|
await page.waitForTimeout(400) // dndkit animation
|
||||||
|
|
||||||
|
if (expected === 'warning') {
|
||||||
|
const toast = page.locator('.payload-toast-item.toast-warning')
|
||||||
|
await expect(toast).toHaveText(
|
||||||
|
'To reorder the rows you must first sort them by the "Order" column',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertRows(nthTable: number, ...expectedRows: Array<string>) {
|
||||||
|
const table = page.locator('tbody').nth(nthTable)
|
||||||
|
const cellTitle = table.locator('.cell-title > :first-child')
|
||||||
|
|
||||||
|
const rows = table.locator('.sort-row')
|
||||||
|
await expect.poll(() => rows.count()).toBe(expectedRows.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < expectedRows.length; i++) {
|
||||||
|
await expect(cellTitle.nth(i)).toHaveText(expectedRows[i]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,11 @@ import path from 'path'
|
|||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
|
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
|
||||||
|
import type { Orderable, OrderableJoin } from './payload-types.js'
|
||||||
|
|
||||||
import { initPayloadInt } from '../helpers/initPayloadInt.js'
|
import { initPayloadInt } from '../helpers/initPayloadInt.js'
|
||||||
|
import { orderableSlug } from './collections/Orderable/index.js'
|
||||||
|
import { orderableJoinSlug } from './collections/OrderableJoin/index.js'
|
||||||
|
|
||||||
let payload: Payload
|
let payload: Payload
|
||||||
let restClient: NextRESTClient
|
let restClient: NextRESTClient
|
||||||
@@ -63,7 +66,7 @@ describe('Sort', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Sinlge sort field', () => {
|
describe('Single sort field', () => {
|
||||||
it('should sort posts by text field', async () => {
|
it('should sort posts by text field', async () => {
|
||||||
const posts = await payload.find({
|
const posts = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
@@ -326,6 +329,84 @@ describe('Sort', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Orderable join', () => {
|
||||||
|
let related: OrderableJoin
|
||||||
|
let orderable1: Orderable
|
||||||
|
let orderable2: Orderable
|
||||||
|
let orderable3: Orderable
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
related = await payload.create({
|
||||||
|
collection: orderableJoinSlug,
|
||||||
|
data: {
|
||||||
|
title: 'test',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
orderable1 = await payload.create({
|
||||||
|
collection: orderableSlug,
|
||||||
|
data: {
|
||||||
|
title: 'test 1',
|
||||||
|
orderableField: related.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
orderable2 = await payload.create({
|
||||||
|
collection: orderableSlug,
|
||||||
|
data: {
|
||||||
|
title: 'test 2',
|
||||||
|
orderableField: related.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
orderable3 = await payload.create({
|
||||||
|
collection: orderableSlug,
|
||||||
|
data: {
|
||||||
|
title: 'test 3',
|
||||||
|
orderableField: related.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set order by default', () => {
|
||||||
|
expect(orderable1._orderable_orderableJoinField1_order).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow setting the order with the local API', async () => {
|
||||||
|
// create two orderableJoinSlug docs
|
||||||
|
orderable2 = await payload.update({
|
||||||
|
collection: orderableSlug,
|
||||||
|
id: orderable2.id,
|
||||||
|
data: {
|
||||||
|
title: 'test',
|
||||||
|
orderableField: related.id,
|
||||||
|
_orderable_orderableJoinField1_order: 'e4',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const orderable4 = await payload.create({
|
||||||
|
collection: orderableSlug,
|
||||||
|
data: {
|
||||||
|
title: 'test',
|
||||||
|
orderableField: related.id,
|
||||||
|
_orderable_orderableJoinField1_order: 'e2',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(orderable2._orderable_orderableJoinField1_order).toBe('e4')
|
||||||
|
expect(orderable4._orderable_orderableJoinField1_order).toBe('e2')
|
||||||
|
})
|
||||||
|
it('should sort join docs in the correct', async () => {
|
||||||
|
related = await payload.findByID({
|
||||||
|
collection: orderableJoinSlug,
|
||||||
|
id: related.id,
|
||||||
|
depth: 1,
|
||||||
|
})
|
||||||
|
const orders = (related.orderableJoinField1 as { docs: Orderable[] }).docs.map((doc) =>
|
||||||
|
parseInt(doc._orderable_orderableJoinField1_order, 16),
|
||||||
|
) as [number, number, number]
|
||||||
|
expect(orders[0]).toBeLessThan(orders[1])
|
||||||
|
expect(orders[1]).toBeLessThan(orders[2])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('REST API', () => {
|
describe('REST API', () => {
|
||||||
@@ -344,7 +425,7 @@ describe('Sort', () => {
|
|||||||
await payload.delete({ collection: 'posts', where: {} })
|
await payload.delete({ collection: 'posts', where: {} })
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Sinlge sort field', () => {
|
describe('Single sort field', () => {
|
||||||
it('should sort posts by text field', async () => {
|
it('should sort posts by text field', async () => {
|
||||||
const res = await restClient
|
const res = await restClient
|
||||||
.GET(`/posts`, {
|
.GET(`/posts`, {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export type SupportedTimezones =
|
|||||||
| 'Asia/Singapore'
|
| 'Asia/Singapore'
|
||||||
| 'Asia/Tokyo'
|
| 'Asia/Tokyo'
|
||||||
| 'Asia/Seoul'
|
| 'Asia/Seoul'
|
||||||
|
| 'Australia/Brisbane'
|
||||||
| 'Australia/Sydney'
|
| 'Australia/Sydney'
|
||||||
| 'Pacific/Guam'
|
| 'Pacific/Guam'
|
||||||
| 'Pacific/Noumea'
|
| 'Pacific/Noumea'
|
||||||
@@ -70,17 +71,27 @@ export interface Config {
|
|||||||
drafts: Draft;
|
drafts: Draft;
|
||||||
'default-sort': DefaultSort;
|
'default-sort': DefaultSort;
|
||||||
localized: Localized;
|
localized: Localized;
|
||||||
|
orderable: Orderable;
|
||||||
|
'orderable-join': OrderableJoin;
|
||||||
users: User;
|
users: User;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
'payload-preferences': PayloadPreference;
|
'payload-preferences': PayloadPreference;
|
||||||
'payload-migrations': PayloadMigration;
|
'payload-migrations': PayloadMigration;
|
||||||
};
|
};
|
||||||
collectionsJoins: {};
|
collectionsJoins: {
|
||||||
|
'orderable-join': {
|
||||||
|
orderableJoinField1: 'orderable';
|
||||||
|
orderableJoinField2: 'orderable';
|
||||||
|
nonOrderableJoinField: 'orderable';
|
||||||
|
};
|
||||||
|
};
|
||||||
collectionsSelect: {
|
collectionsSelect: {
|
||||||
posts: PostsSelect<false> | PostsSelect<true>;
|
posts: PostsSelect<false> | PostsSelect<true>;
|
||||||
drafts: DraftsSelect<false> | DraftsSelect<true>;
|
drafts: DraftsSelect<false> | DraftsSelect<true>;
|
||||||
'default-sort': DefaultSortSelect<false> | DefaultSortSelect<true>;
|
'default-sort': DefaultSortSelect<false> | DefaultSortSelect<true>;
|
||||||
localized: LocalizedSelect<false> | LocalizedSelect<true>;
|
localized: LocalizedSelect<false> | LocalizedSelect<true>;
|
||||||
|
orderable: OrderableSelect<false> | OrderableSelect<true>;
|
||||||
|
'orderable-join': OrderableJoinSelect<false> | OrderableJoinSelect<true>;
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
@@ -174,6 +185,45 @@ export interface Localized {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "orderable".
|
||||||
|
*/
|
||||||
|
export interface Orderable {
|
||||||
|
id: string;
|
||||||
|
_orderable_orderableJoinField2_order?: string;
|
||||||
|
_orderable_orderableJoinField1_order?: string;
|
||||||
|
_order?: string;
|
||||||
|
title?: string | null;
|
||||||
|
orderableField?: (string | null) | OrderableJoin;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "orderable-join".
|
||||||
|
*/
|
||||||
|
export interface OrderableJoin {
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
orderableJoinField1?: {
|
||||||
|
docs?: (string | Orderable)[];
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
totalDocs?: number;
|
||||||
|
};
|
||||||
|
orderableJoinField2?: {
|
||||||
|
docs?: (string | Orderable)[];
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
totalDocs?: number;
|
||||||
|
};
|
||||||
|
nonOrderableJoinField?: {
|
||||||
|
docs?: (string | Orderable)[];
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
totalDocs?: number;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "users".
|
* via the `definition` "users".
|
||||||
@@ -214,6 +264,14 @@ export interface PayloadLockedDocument {
|
|||||||
relationTo: 'localized';
|
relationTo: 'localized';
|
||||||
value: string | Localized;
|
value: string | Localized;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'orderable';
|
||||||
|
value: string | Orderable;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'orderable-join';
|
||||||
|
value: string | OrderableJoin;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: string | User;
|
value: string | User;
|
||||||
@@ -316,6 +374,31 @@ export interface LocalizedSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "orderable_select".
|
||||||
|
*/
|
||||||
|
export interface OrderableSelect<T extends boolean = true> {
|
||||||
|
_orderable_orderableJoinField2_order?: T;
|
||||||
|
_orderable_orderableJoinField1_order?: T;
|
||||||
|
_order?: T;
|
||||||
|
title?: T;
|
||||||
|
orderableField?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "orderable-join_select".
|
||||||
|
*/
|
||||||
|
export interface OrderableJoinSelect<T extends boolean = true> {
|
||||||
|
title?: T;
|
||||||
|
orderableJoinField1?: T;
|
||||||
|
orderableJoinField2?: T;
|
||||||
|
nonOrderableJoinField?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "users_select".
|
* via the `definition` "users_select".
|
||||||
|
|||||||
Reference in New Issue
Block a user