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:
Germán Jabloñski
2025-04-01 15:11:11 -03:00
committed by GitHub
parent 968a066f45
commit d963e6a54c
35 changed files with 1616 additions and 49 deletions

View File

@@ -195,6 +195,7 @@ export const renderListView = async (
drawerSlug,
enableRowSelections,
i18n: req.i18n,
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
payload,
useAsTitle: collectionConfig.admin.useAsTitle,
})
@@ -259,6 +260,7 @@ export const renderListView = async (
defaultSort={sort}
listPreferences={listPreferences}
modifySearchParams={!isInDrawer}
orderableFieldName={collectionConfig.orderable === true ? '_order' : undefined}
>
{RenderServerComponent({
clientProps: {

View File

@@ -193,6 +193,7 @@ export async function VersionsView(props: DocumentViewServerProps) {
defaultLimit={limitToUse}
defaultSort={sort as string}
modifySearchParams
orderableFieldName={collectionConfig?.orderable === true ? '_order' : undefined}
>
<VersionsViewClient
baseClass={baseClass}

View File

@@ -60,6 +60,7 @@ export type BuildTableStateArgs = {
columns?: ColumnPreference[]
docs?: PaginatedDocs['docs']
enableRowSelections?: boolean
orderableFieldName: string
parent?: {
collectionSlug: CollectionSlug
id: number | string

View File

@@ -1,4 +1,5 @@
// @ts-strict-ignore
import type { Config, SanitizedConfig } from '../../config/types.js'
import type {
CollectionConfig,

View File

@@ -507,6 +507,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,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,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)
}

View File

@@ -36,6 +36,7 @@ import { getDefaultJobsCollection, jobsCollectionSlug } from '../queues/config/i
import { flattenBlock } from '../utilities/flattenAllFields.js'
import { getSchedulePublishTask } from '../versions/schedule/job.js'
import { addDefaultsToConfig } from './defaults.js'
import { setupOrderable } from './orderable/index.js'
const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig> => {
const sanitizedConfig = { ...configToSanitize }
@@ -108,6 +109,9 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
const config: Partial<SanitizedConfig> = sanitizeAdminConfig(configWithDefaults)
// Add orderable fields
setupOrderable(config as SanitizedConfig)
if (!config.endpoints) {
config.endpoints = []
}

View File

@@ -1549,6 +1549,17 @@ export type JoinField = {
* A string for the field in the collection being joined to.
*/
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[]
type: 'join'
validate?: never
@@ -1562,7 +1573,15 @@ export type JoinFieldClient = {
} & { targetField: Pick<RelationshipFieldClient, 'relationTo'> } & FieldBaseClient &
Pick<
JoinField,
'collection' | 'defaultLimit' | 'defaultSort' | 'index' | 'maxDepth' | 'on' | 'type' | 'where'
| 'collection'
| 'defaultLimit'
| 'defaultSort'
| 'index'
| 'maxDepth'
| 'on'
| 'orderable'
| 'type'
| 'where'
>
export type FlattenedBlock = {

View File

@@ -1090,6 +1090,7 @@ export {
} from './config/client.js'
export { defaults } from './config/defaults.js'
export { type OrderableEndpointBody } from './config/orderable/index.js'
export { sanitizeConfig } from './config/sanitize.js'
export type * from './config/types.js'
export { combineQueries } from './database/combineQueries.js'

View File

@@ -18,6 +18,11 @@ export const flattenBlock = ({ block }: { block: Block }): FlattenedBlock => {
const flattenedFieldsCache = new Map<Field[], FlattenedField[]>()
/**
* Flattens all fields in a collection, preserving the nested field structure.
* @param cache
* @param fields
*/
export const flattenAllFields = ({
cache,
fields,

View File

@@ -139,6 +139,10 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
columns: transformColumnsToPreferences(query?.columns) || defaultColumns,
docs,
enableRowSelections: false,
orderableFieldName:
!field.orderable || Array.isArray(field.collection)
? undefined
: `_${field.collection}_${field.name}_order`,
parent,
query: newQuery,
renderRowTypes: true,
@@ -153,6 +157,10 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
[
field.defaultLimit,
field.defaultSort,
field.admin.defaultColumns,
field.collection,
field.name,
field.orderable,
collectionConfig?.admin?.pagination?.defaultLimit,
collectionConfig?.defaultSort,
query,
@@ -329,6 +337,11 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
}
modifySearchParams={false}
onQueryChange={setQuery}
orderableFieldName={
!field.orderable || Array.isArray(field.collection)
? undefined
: `_${field.collection}_${field.name}_order`
}
>
<TableColumnsProvider
collectionSlug={Array.isArray(relationTo) ? relationTo[0] : relationTo}

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

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

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

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

@@ -25,6 +25,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
listPreferences,
modifySearchParams,
onQueryChange: onQueryChangeFromProps,
orderableFieldName,
}) => {
'use no memo'
const router = useRouter()
@@ -207,6 +208,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
handleSearchChange,
handleSortChange,
handleWhereChange,
orderableFieldName,
query: currentQuery,
refineListData,
setModified,

View File

@@ -29,6 +29,7 @@ export type ListQueryProps = {
readonly listPreferences?: ListPreferences
readonly modifySearchParams?: boolean
readonly onQueryChange?: OnListQueryChange
readonly orderableFieldName?: string
/**
* @deprecated
*/
@@ -40,6 +41,7 @@ export type IListQueryContext = {
data: PaginatedDocs
defaultLimit?: number
defaultSort?: Sort
orderableFieldName?: string
modified: boolean
query: ListQuery
refineListData: (args: ListQuery, setModified?: boolean) => Promise<void>

View File

@@ -66,7 +66,7 @@ export const buildTableStateHandler = async (
}
}
export const buildTableState = async (
const buildTableState = async (
args: BuildTableStateArgs,
): Promise<BuildTableStateSuccessResult> => {
const {
@@ -74,6 +74,7 @@ export const buildTableState = async (
columns,
docs: docsFromArgs,
enableRowSelections,
orderableFieldName,
parent,
query,
renderRowTypes,
@@ -233,6 +234,7 @@ export const buildTableState = async (
docs,
enableRowSelections,
i18n: req.i18n,
orderableFieldName,
payload,
renderRowTypes,
tableAppearance,

View File

@@ -13,16 +13,19 @@ 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 '../providers/TableColumns/buildColumnState.js'
import { buildPolymorphicColumnState } from '../providers/TableColumns/buildPolymorphicColumnState.js'
import { filterFields } from '../providers/TableColumns/filterFields.js'
import { getInitialColumns } from '../providers/TableColumns/getInitialColumns.js'
// eslint-disable-next-line payload/no-imports-from-exports-dir
import { Pill, SelectAll, SelectRow, Table } from '../exports/client/index.js'
@@ -62,6 +65,7 @@ export const renderTable = ({
docs,
enableRowSelections,
i18n,
orderableFieldName,
payload,
renderRowTypes,
tableAppearance,
@@ -78,6 +82,7 @@ export const renderTable = ({
drawerSlug?: string
enableRowSelections: boolean
i18n: I18nClient
orderableFieldName: string
payload: Payload
renderRowTypes?: boolean
tableAppearance?: 'condensed' | 'default'
@@ -195,9 +200,38 @@ export const renderTable = ({
} as Column)
}
if (!orderableFieldName) {
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;
}
}
}