Compare commits
64 Commits
feat/lexic
...
feat/sorta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
099ec380ec | ||
|
|
01de4efe33 | ||
|
|
743b456e91 | ||
|
|
4acace2c3f | ||
|
|
950912a4ca | ||
|
|
5d22f939b7 | ||
|
|
6aae3ad76a | ||
|
|
c1fde582f3 | ||
|
|
769833a8b2 | ||
|
|
2f1942e6ee | ||
|
|
5f594079e8 | ||
|
|
e1f0773319 | ||
|
|
bcf7a55399 | ||
|
|
61502532e9 | ||
|
|
a222a6e407 | ||
|
|
d79f5c6128 | ||
|
|
a7dc272ee1 | ||
|
|
d86a2a601f | ||
|
|
374de055d5 | ||
|
|
c7ca87647a | ||
|
|
ac3f87d382 | ||
|
|
afec8b5a26 | ||
|
|
271bb42c6d | ||
|
|
38189f72bf | ||
|
|
0ce26e37d2 | ||
|
|
84d59b39ed | ||
|
|
92939f6d09 | ||
|
|
b60aee833d | ||
|
|
946a9b94f2 | ||
|
|
bfbd5a8b47 | ||
|
|
ccb9308a4a | ||
|
|
5df47a86fa | ||
|
|
576cfd905c | ||
|
|
fe8f30575c | ||
|
|
ee0cf01220 | ||
|
|
364fe87903 | ||
|
|
882d508295 | ||
|
|
7e83302a2c | ||
|
|
de67721850 | ||
|
|
895dcd7a91 | ||
|
|
7aa14d6f18 | ||
|
|
7c6327d94e | ||
|
|
5a1f0b2c2f | ||
|
|
168d81c6d0 | ||
|
|
5bdb56442b | ||
|
|
3f1ea0e849 | ||
|
|
95849b31d2 | ||
|
|
bb4c398177 | ||
|
|
497a8adbb5 | ||
|
|
856cec6017 | ||
|
|
e3eb28027b | ||
|
|
4ea24c2597 | ||
|
|
48698f8165 | ||
|
|
8bc150d2ad | ||
|
|
c51ef571df | ||
|
|
2fd71c4bdd | ||
|
|
5155b620c0 | ||
|
|
13577b8cb3 | ||
|
|
606ab59f96 | ||
|
|
bab956a9af | ||
|
|
add4e1dadc | ||
|
|
c3c0c13caa | ||
|
|
e2e88508a6 | ||
|
|
e83dcf86b3 |
@@ -70,6 +70,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). |
|
||||
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
|
||||
| `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. |
|
||||
| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
|
||||
| `slug` * | Unique, URL-friendly string that will act as an identifier for this Collection. |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
|
||||
import type { Config, SanitizedConfig } from '../../config/types.js'
|
||||
import type {
|
||||
CollectionConfig,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
} from './defaults.js'
|
||||
import { sanitizeAuthFields, sanitizeUploadFields } from './reservedFieldNames.js'
|
||||
import { sanitizeCompoundIndexes } from './sanitizeCompoundIndexes.js'
|
||||
import { sanitizeOrderable } from './sanitizeOrderable.js'
|
||||
import { validateUseAsTitle } from './useAsTitle.js'
|
||||
|
||||
export const sanitizeCollection = async (
|
||||
@@ -237,6 +239,8 @@ export const sanitizeCollection = async (
|
||||
|
||||
const sanitizedConfig = sanitized as SanitizedCollectionConfig
|
||||
|
||||
sanitizeOrderable(sanitizedConfig)
|
||||
|
||||
sanitizedConfig.joins = joins
|
||||
sanitizedConfig.polymorphicJoins = polymorphicJoins
|
||||
|
||||
|
||||
60
packages/payload/src/collections/config/sanitizeOrderable.ts
Normal file
60
packages/payload/src/collections/config/sanitizeOrderable.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Field } from '../../fields/config/types.js'
|
||||
import type { BeforeChangeHook, SanitizedCollectionConfig } from './types.js'
|
||||
|
||||
import { generateKeyBetween } from '../../utilities/fractional-indexing.js'
|
||||
import { getReorderEndpoint } from '../endpoints/reorder.js'
|
||||
|
||||
export const ORDER_FIELD_NAME = '_order'
|
||||
|
||||
export const sanitizeOrderable = (collection: SanitizedCollectionConfig) => {
|
||||
if (!collection.orderable) {
|
||||
return
|
||||
}
|
||||
// 1. Add field
|
||||
const orderField: Field = {
|
||||
name: ORDER_FIELD_NAME,
|
||||
type: 'text',
|
||||
admin: {
|
||||
disableBulkEdit: true,
|
||||
disabled: true,
|
||||
disableListColumn: true,
|
||||
disableListFilter: true,
|
||||
hidden: true,
|
||||
readOnly: true,
|
||||
},
|
||||
index: true,
|
||||
label: ({ t }) => t('general:order'),
|
||||
required: true,
|
||||
unique: true,
|
||||
}
|
||||
|
||||
collection.fields.unshift(orderField)
|
||||
|
||||
// 2. Add hook
|
||||
const orderBeforeChangeHook: BeforeChangeHook = async ({ data, operation, req }) => {
|
||||
// Only set _order on create, not on update (unless explicitly provided)
|
||||
if (operation === 'create') {
|
||||
// Find the last document to place this one after
|
||||
const lastDoc = await req.payload.find({
|
||||
collection: collection.slug,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: { [ORDER_FIELD_NAME]: true },
|
||||
sort: `-${ORDER_FIELD_NAME}`,
|
||||
})
|
||||
|
||||
const lastOrderValue: null | string = (lastDoc.docs[0]?.[ORDER_FIELD_NAME] as string) || null
|
||||
data[ORDER_FIELD_NAME] = generateKeyBetween(lastOrderValue, null)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
collection.hooks.beforeChange.push(orderBeforeChangeHook)
|
||||
|
||||
// 3. Add endpoint
|
||||
if (collection.endpoints !== false) {
|
||||
collection.endpoints.push(getReorderEndpoint(collection))
|
||||
}
|
||||
}
|
||||
@@ -495,6 +495,17 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
|
||||
duration: number
|
||||
}
|
||||
| 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
|
||||
/**
|
||||
* Add `createdAt` and `updatedAt` fields
|
||||
|
||||
131
packages/payload/src/collections/endpoints/reorder.ts
Normal file
131
packages/payload/src/collections/endpoints/reorder.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { Endpoint, PayloadHandler } from '../../config/types.js'
|
||||
import type { PayloadRequest, SanitizedCollectionConfig } from '../../index.js'
|
||||
|
||||
import { APIError, executeAccess } from '../../index.js'
|
||||
import { generateNKeysBetween } from '../../utilities/fractional-indexing.js'
|
||||
import { ORDER_FIELD_NAME } from '../config/sanitizeOrderable.js'
|
||||
|
||||
export const getReorderEndpoint = (
|
||||
collection: SanitizedCollectionConfig,
|
||||
): Omit<Endpoint, 'root'> => {
|
||||
const reorderHandler: PayloadHandler = async (req: PayloadRequest) => {
|
||||
if (!req.json) {
|
||||
throw new APIError('Unreachable')
|
||||
}
|
||||
const body = await req.json()
|
||||
type KeyAndID = {
|
||||
id: string
|
||||
key: string
|
||||
}
|
||||
const { docsToMove, newKeyWillBe, target } = body as {
|
||||
// array of docs IDs to be moved before or after the target
|
||||
docsToMove: string[]
|
||||
// new key relative to the target. We don't use "after" or "before" as
|
||||
// it can be misleading if the table is sorted in descending order.
|
||||
newKeyWillBe: 'greater' | 'less'
|
||||
target: KeyAndID
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent reordering if user doesn't have editing permissions
|
||||
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: null | string = 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: { [ORDER_FIELD_NAME]: true },
|
||||
})
|
||||
targetKey = (beforeDoc?.[ORDER_FIELD_NAME] as string) || 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: { [ORDER_FIELD_NAME]: true },
|
||||
sort: newKeyWillBe === 'greater' ? ORDER_FIELD_NAME : `-${ORDER_FIELD_NAME}`,
|
||||
where: {
|
||||
[ORDER_FIELD_NAME]: {
|
||||
[newKeyWillBe === 'greater' ? 'greater_than' : 'less_than']: targetKey,
|
||||
},
|
||||
},
|
||||
})
|
||||
const adjacentDocKey: null | string =
|
||||
(adjacentDoc.docs?.[0]?.[ORDER_FIELD_NAME] as string) || 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 id of docsToMove) {
|
||||
await req.payload.update({
|
||||
id,
|
||||
collection: collection.slug,
|
||||
data: {
|
||||
[ORDER_FIELD_NAME]: orderValues.shift(),
|
||||
},
|
||||
depth: 0,
|
||||
req,
|
||||
select: { [ORDER_FIELD_NAME]: true },
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ orderValues, success: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
handler: reorderHandler,
|
||||
method: 'post',
|
||||
path: '/reorder',
|
||||
}
|
||||
}
|
||||
318
packages/payload/src/utilities/fractional-indexing.js
Normal file
318
packages/payload/src/utilities/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),
|
||||
]
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
packages/ui/src/elements/SortHeader/index.tsx
Normal file
76
packages/ui/src/elements/SortHeader/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import React, { 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, query } = useListQuery()
|
||||
const sort = useRef<'asc' | 'desc'>(query.sort === '-_order' ? 'desc' : 'asc')
|
||||
const isActive = query.sort === '-_order' || query.sort === '_order'
|
||||
|
||||
const handleSortPress = () => {
|
||||
// If it's already sorted by the "_order" field, toggle between "asc" and "desc"
|
||||
if (isActive) {
|
||||
void handleSortChange(sort.current === 'asc' ? '-_order' : '_order')
|
||||
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' ? '_order' : '-_order')
|
||||
}
|
||||
|
||||
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 { query } = useListQuery()
|
||||
const isActive = query.sort === '_order' || query.sort === '-_order'
|
||||
|
||||
return (
|
||||
<div className={`${baseClass} ${isActive ? 'active' : ''}`} role="button" tabIndex={0}>
|
||||
<DragHandleIcon className={`${baseClass}__icon`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
203
packages/ui/src/elements/Table/OrderableTable.tsx
Normal file
203
packages/ui/src/elements/Table/OrderableTable.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
import type { ClientCollectionConfig, Column } 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, 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 !== '_order' && query.sort !== '-_order') {
|
||||
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]._order = `pending`
|
||||
// Move the item in the array
|
||||
newData.splice(moveToIndex, 0, newData.splice(moveFromIndex, 1)[0])
|
||||
return newData
|
||||
})
|
||||
|
||||
try {
|
||||
type KeyAndID = {
|
||||
id: string
|
||||
key: string
|
||||
}
|
||||
|
||||
const target: KeyAndID = newBeforeRow
|
||||
? {
|
||||
id: newBeforeRow.id ?? newBeforeRow._id,
|
||||
key: newBeforeRow._order,
|
||||
}
|
||||
: {
|
||||
id: newAfterRow.id ?? newAfterRow._id,
|
||||
key: newAfterRow._order,
|
||||
}
|
||||
|
||||
const newKeyWillBe =
|
||||
(newBeforeRow && query.sort === '_order') || (!newBeforeRow && query.sort === '-_order')
|
||||
? 'greater'
|
||||
: 'less'
|
||||
|
||||
const response = await fetch(`/api/${collection.slug}/reorder`, {
|
||||
body: JSON.stringify({
|
||||
docsToMove: [movedId],
|
||||
newKeyWillBe,
|
||||
target,
|
||||
}),
|
||||
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.',
|
||||
)
|
||||
}
|
||||
|
||||
// This will trigger an update of the data from the server
|
||||
handleSortChange(query.sort as string).catch((error) => {
|
||||
throw error
|
||||
})
|
||||
} 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>
|
||||
)
|
||||
@@ -13,18 +13,22 @@ import type {
|
||||
|
||||
import { getTranslation, type I18nClient } from '@payloadcms/translations'
|
||||
import { fieldAffectsData, fieldIsHiddenOrDisabled, flattenTopLevelFields } from 'payload/shared'
|
||||
import React from 'react'
|
||||
|
||||
// eslint-disable-next-line payload/no-imports-from-exports-dir
|
||||
import type { Column } from '../exports/client/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 '../elements/TableColumns/buildColumnState.js'
|
||||
import { buildPolymorphicColumnState } from '../elements/TableColumns/buildPolymorphicColumnState.js'
|
||||
import { filterFields } from '../elements/TableColumns/filterFields.js'
|
||||
import { getInitialColumns } from '../elements/TableColumns/getInitialColumns.js'
|
||||
|
||||
// eslint-disable-next-line payload/no-imports-from-exports-dir
|
||||
import { Pill, SelectAll, SelectRow, Table } from '../exports/client/index.js'
|
||||
const ORDER_FIELD_NAME = '_order'
|
||||
|
||||
export const renderFilters = (
|
||||
fields: Field[],
|
||||
@@ -195,9 +199,41 @@ export const renderTable = ({
|
||||
} as Column)
|
||||
}
|
||||
|
||||
// Add drag handle if data is sortable
|
||||
const isOrderable = docs.length > 0 && ORDER_FIELD_NAME in docs[0]
|
||||
|
||||
if (!isOrderable) {
|
||||
return {
|
||||
columnState,
|
||||
// key is required since Next.js 15.2.0 to prevent React key error
|
||||
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: <Table appearance={tableAppearance} columns={columnsToUse} data={docs} key="table" />,
|
||||
Table: (
|
||||
<OrderableTable
|
||||
appearance={tableAppearance}
|
||||
collection={clientCollectionConfig}
|
||||
columns={columnsToUse}
|
||||
data={docs}
|
||||
key="table"
|
||||
/>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
display: flex;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
#heading-_dragHandle,
|
||||
.cell-_dragHandle {
|
||||
width: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12647
pnpm-lock.yaml
generated
12647
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -113,7 +113,9 @@ describe('Radio', () => {
|
||||
// nested in a group error
|
||||
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
|
||||
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>
|
||||
)
|
||||
}
|
||||
20
test/sort/collections/Sortable/index.ts
Normal file
20
test/sort/collections/Sortable/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const sortableSlug = 'sortable'
|
||||
|
||||
export const SortableCollection: CollectionConfig = {
|
||||
slug: sortableSlug,
|
||||
orderable: true,
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
components: {
|
||||
beforeList: ['/Seed.tsx#Seed'],
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CollectionSlug, Payload } from 'payload'
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'path'
|
||||
|
||||
@@ -7,16 +9,36 @@ import { DefaultSortCollection } from './collections/DefaultSort/index.js'
|
||||
import { DraftsCollection } from './collections/Drafts/index.js'
|
||||
import { LocalizedCollection } from './collections/Localized/index.js'
|
||||
import { PostsCollection } from './collections/Posts/index.js'
|
||||
import { SortableCollection } from './collections/Sortable/index.js'
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default buildConfigWithDefaults({
|
||||
collections: [PostsCollection, DraftsCollection, DefaultSortCollection, LocalizedCollection],
|
||||
collections: [
|
||||
PostsCollection,
|
||||
DraftsCollection,
|
||||
DefaultSortCollection,
|
||||
LocalizedCollection,
|
||||
SortableCollection,
|
||||
],
|
||||
admin: {
|
||||
importMap: {
|
||||
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'],
|
||||
localization: {
|
||||
locales: ['en', 'nb'],
|
||||
@@ -30,8 +52,34 @@ export default buildConfigWithDefaults({
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
await seedSortable(payload)
|
||||
},
|
||||
typescript: {
|
||||
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: 'sortable', where: {} })
|
||||
await createData(payload, 'sortable', [
|
||||
{ title: 'A' },
|
||||
{ title: 'B' },
|
||||
{ title: 'C' },
|
||||
{ title: 'D' },
|
||||
])
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
|
||||
116
test/sort/e2e.spec.ts
Normal file
116
test/sort/e2e.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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 { sortableSlug } from './collections/Sortable/index.js'
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
const { beforeAll, describe } = test
|
||||
let url: AdminUrlUtil
|
||||
|
||||
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 }))
|
||||
|
||||
url = new AdminUrlUtil(serverURL, sortableSlug)
|
||||
|
||||
context = await browser.newContext()
|
||||
page = await context.newPage()
|
||||
|
||||
initPageConsoleErrorCatch(page)
|
||||
|
||||
client = new RESTClient({ defaultSlug: 'users', serverURL })
|
||||
await client.login()
|
||||
|
||||
await ensureCompilationIsDone({ page, serverURL })
|
||||
})
|
||||
|
||||
// assertRows contains expect
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test('Sortable collection', async () => {
|
||||
await page.goto(url.list)
|
||||
// SORT BY ORDER ASCENDING
|
||||
await page.getByLabel('Sort by Order').click()
|
||||
await assertRows('A', 'B', 'C', 'D')
|
||||
await moveRow(2, 3) // move to middle
|
||||
await assertRows('A', 'C', 'B', 'D')
|
||||
await moveRow(3, 1) // move to top
|
||||
await assertRows('B', 'A', 'C', 'D')
|
||||
await moveRow(1, 4) // move to bottom
|
||||
await assertRows('A', 'C', 'D', 'B')
|
||||
|
||||
// SORT BY ORDER DESCENDING
|
||||
await page.getByLabel('Sort by Order').click()
|
||||
await assertRows('B', 'D', 'C', 'A')
|
||||
await moveRow(1, 3) // move to middle
|
||||
await assertRows('D', 'C', 'B', 'A')
|
||||
await moveRow(3, 1) // move to top
|
||||
await assertRows('B', 'D', 'C', 'A')
|
||||
await moveRow(1, 4) // move to bottom
|
||||
await assertRows('D', 'C', 'A', 'B')
|
||||
|
||||
// SORT BY TITLE
|
||||
await page.getByLabel('Sort by Title Ascending').click()
|
||||
await moveRow(1, 3, 'warning') // warning because not sorted by order first
|
||||
})
|
||||
})
|
||||
|
||||
async function moveRow(from: number, to: number, expected: 'success' | 'warning' = 'success') {
|
||||
// counting from 1, zero excluded
|
||||
const dragHandle = page.locator(`tbody .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 drag and drop')
|
||||
}
|
||||
// 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()
|
||||
|
||||
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(...expectedRows: Array<string>) {
|
||||
const cellTitle = page.locator('td.cell-title a')
|
||||
|
||||
const rows = page.locator('tbody .sort-row')
|
||||
await expect.poll(() => rows.count()).toBe(expectedRows.length)
|
||||
|
||||
for (let i = 0; i < expectedRows.length; i++) {
|
||||
console.log('expectedRows[i]', expectedRows[i], await cellTitle.nth(i).textContent())
|
||||
await expect(cellTitle.nth(i)).toHaveText(expectedRows[i]!)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export interface Config {
|
||||
drafts: Draft;
|
||||
'default-sort': DefaultSort;
|
||||
localized: Localized;
|
||||
sortable: Sortable;
|
||||
users: User;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
@@ -81,6 +82,7 @@ export interface Config {
|
||||
drafts: DraftsSelect<false> | DraftsSelect<true>;
|
||||
'default-sort': DefaultSortSelect<false> | DefaultSortSelect<true>;
|
||||
localized: LocalizedSelect<false> | LocalizedSelect<true>;
|
||||
sortable: SortableSelect<false> | SortableSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
@@ -174,6 +176,17 @@ export interface Localized {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "sortable".
|
||||
*/
|
||||
export interface Sortable {
|
||||
id: string;
|
||||
_order: string;
|
||||
title?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
@@ -214,6 +227,10 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'localized';
|
||||
value: string | Localized;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'sortable';
|
||||
value: string | Sortable;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
@@ -316,6 +333,16 @@ export interface LocalizedSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "sortable_select".
|
||||
*/
|
||||
export interface SortableSelect<T extends boolean = true> {
|
||||
_order?: T;
|
||||
title?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
|
||||
Reference in New Issue
Block a user