Compare commits

...

64 Commits

Author SHA1 Message Date
Dan Ribbens
099ec380ec chore: refactor sanitizeOrderable 2025-03-24 12:27:18 -04:00
Germán Jabloñski
01de4efe33 Improve the toggle sort button and extract logic to a custom hook 2025-03-17 09:21:07 -03:00
Germán Jabloñski
743b456e91 improve icons 2025-03-17 08:31:26 -03:00
Germán Jabloñski
4acace2c3f Revert "add dragHandleVertical icon"
This reverts commit 61502532e9.
2025-03-17 07:49:32 -03:00
Germán Jabloñski
950912a4ca fix test 2025-03-13 16:55:58 -03:00
Germán Jabloñski
5d22f939b7 Merge remote-tracking branch 'origin/main' into feat/sortable-collections 2025-03-13 16:18:28 -03:00
Germán Jabloñski
6aae3ad76a create SortableTable component 2025-03-13 16:15:09 -03:00
Germán Jabloñski
c1fde582f3 style drag handle as disabled when table is sorted by another column 2025-03-13 16:04:41 -03:00
Germán Jabloñski
769833a8b2 add slug prop to table 2025-03-13 15:25:28 -03:00
Germán Jabloñski
2f1942e6ee remove comment 2025-03-13 15:01:07 -03:00
Germán Jabloñski
5f594079e8 add sortHeader icon 2 2025-03-13 13:34:08 -03:00
Germán Jabloñski
e1f0773319 rename isSortable to orderable 2025-03-13 13:15:20 -03:00
Germán Jabloñski
bcf7a55399 add sortHeader icon 2025-03-13 13:14:00 -03:00
Germán Jabloñski
61502532e9 add dragHandleVertical icon 2025-03-13 12:46:41 -03:00
Germán Jabloñski
a222a6e407 change header 2025-03-11 18:22:20 -03:00
Germán Jabloñski
d79f5c6128 hide order column from table 2025-03-11 17:39:45 -03:00
Germán Jabloñski
a7dc272ee1 add shimmer effect 2025-03-10 18:21:21 -03:00
Germán Jabloñski
d86a2a601f trying to fix flaky test 2025-03-08 23:26:20 -03:00
Germán Jabloñski
374de055d5 fix ids 2025-03-08 20:39:56 -03:00
Germán Jabloñski
c7ca87647a fix ids 2025-03-08 20:09:25 -03:00
Germán Jabloñski
ac3f87d382 revert columnsToUse 2025-03-08 00:20:37 -03:00
Germán Jabloñski
afec8b5a26 Prevent reordering if user doesn't have editing permissions 2025-03-07 19:12:58 -03:00
Germán Jabloñski
271bb42c6d copy-paste fractional-indexing instead of installing it 2025-03-07 18:22:42 -03:00
Germán Jabloñski
38189f72bf fix type 2025-03-07 13:57:48 -03:00
Germán Jabloñski
0ce26e37d2 fix error 2025-03-07 11:43:30 -03:00
Germán Jabloñski
84d59b39ed Merge remote-tracking branch 'origin/main' into feat/sortable-collections 2025-03-07 11:26:22 -03:00
Germán Jabloñski
92939f6d09 tests done! 2025-03-07 11:12:03 -03:00
Germán Jabloñski
b60aee833d add first test 2025-03-06 17:40:44 -03:00
Germán Jabloñski
946a9b94f2 setup e2e tests 2025-03-06 14:22:04 -03:00
Germán Jabloñski
bfbd5a8b47 The endpoint now receives target and position to operate in any scenario 2025-03-06 13:32:00 -03:00
Germán Jabloñski
ccb9308a4a prevent race conditions 2025-03-06 09:52:17 -03:00
Germán Jabloñski
5df47a86fa improve performance sending keys instead of id (WIP) 2025-03-06 09:36:16 -03:00
Germán Jabloñski
576cfd905c resolve comments 2025-03-06 09:07:25 -03:00
Germán Jabloñski
fe8f30575c last commit 2025-03-05 18:24:05 -03:00
Germán Jabloñski
ee0cf01220 revert to show fractional indexes. Prevent manual reordering if table is not sorted by the "order" column 2025-03-05 17:00:26 -03:00
Germán Jabloñski
364fe87903 missing type 2025-03-04 18:46:41 -03:00
Germán Jabloñski
882d508295 add required and unique to order field 2025-03-04 18:46:00 -03:00
Germán Jabloñski
7e83302a2c show integers as order keys instead of fractional indexes 2025-03-04 18:45:42 -03:00
Germán Jabloñski
de67721850 add fetch with optmistic update again + toast 2025-03-04 18:22:38 -03:00
Germán Jabloñski
895dcd7a91 simplify map 2025-03-04 17:09:05 -03:00
Germán Jabloñski
7aa14d6f18 remove unnecessary code 2025-03-04 16:54:56 -03:00
Germán Jabloñski
7c6327d94e remove onDragEnd prop 2025-03-04 16:43:20 -03:00
Germán Jabloñski
5a1f0b2c2f fix bug with col actives 2025-03-04 16:41:44 -03:00
Germán Jabloñski
168d81c6d0 revert changes in ListQuery 2025-03-04 16:34:53 -03:00
Germán Jabloñski
5bdb56442b revert Cell component 2025-03-04 16:11:29 -03:00
Germán Jabloñski
3f1ea0e849 simplify render cells 2025-03-04 14:53:07 -03:00
Germán Jabloñski
95849b31d2 remove unnecessary changes 2025-03-04 14:17:28 -03:00
Germán Jabloñski
bb4c398177 remove unnecesary logs 2025-03-04 12:36:08 -03:00
Germán Jabloñski
497a8adbb5 Revert "save WIP"
This reverts commit 856cec6017.
2025-03-03 19:03:57 -03:00
Germán Jabloñski
856cec6017 save WIP 2025-03-03 19:03:21 -03:00
Germán Jabloñski
e3eb28027b use useListQuery. Revert use integer as order key 2025-03-03 18:13:46 -03:00
Germán Jabloñski
4ea24c2597 show integers in the UI as order keys 2025-03-03 17:21:27 -03:00
Germán Jabloñski
48698f8165 add docs 2025-03-03 15:59:43 -03:00
Germán Jabloñski
8bc150d2ad add experimental notice to isSortable field documentation 2025-03-03 15:36:58 -03:00
Germán Jabloñski
c51ef571df rename enableCustomOrder to isSortable for consistency 2025-03-03 15:20:37 -03:00
Germán Jabloñski
2fd71c4bdd rename field to payload-order to _order 2025-03-03 15:19:05 -03:00
Germán Jabloñski
5155b620c0 changes in table.tsx 2025-02-28 11:29:27 -03:00
Germán Jabloñski
13577b8cb3 test/sort temporal changes 2025-02-28 11:29:27 -03:00
Germán Jabloñski
606ab59f96 it works 2025-02-28 11:29:27 -03:00
Germán Jabloñski
bab956a9af save WIP 2025-02-28 11:29:27 -03:00
Germán Jabloñski
add4e1dadc add onDragEnd handler (call to endpoint) 2025-02-28 11:29:27 -03:00
Germán Jabloñski
c3c0c13caa make table rows draggable 2025-02-28 11:29:27 -03:00
Germán Jabloñski
e2e88508a6 add endpoint and dragHandle to table 2025-02-28 11:29:27 -03:00
Germán Jabloñski
e83dcf86b3 add order field 2025-02-28 11:29:27 -03:00
22 changed files with 10620 additions and 3262 deletions

View File

@@ -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. |

View File

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

View 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))
}
}

View File

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

View 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',
}
}

View 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),
]
}

View 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;
}
}
}
}

View 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>
)
}

View 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;
}
}
}

View 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>
)
}

View 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>
)
}

View 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);
}
}
}

View 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>
)

View File

@@ -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"
/>
),
}
}

View File

@@ -49,6 +49,12 @@
display: flex;
min-width: unset;
}
#heading-_dragHandle,
.cell-_dragHandle {
width: 20px;
min-width: 0;
}
}
}

12647
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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>
)
}

View 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',
},
],
}

View File

@@ -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
View 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]!)
}
}

View File

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