Files
payloadcms/packages/ui/src/elements/Table/OrderableTable.tsx
Germán Jabloñski d963e6a54c 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>
2025-04-01 14:11:11 -04:00

199 lines
6.3 KiB
TypeScript

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