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

@@ -1,3 +1,5 @@
import type { CollectionSlug, Payload } from 'payload'
import { fileURLToPath } from 'node:url'
import path from 'path'
@@ -6,17 +8,39 @@ import { devUser } from '../credentials.js'
import { DefaultSortCollection } from './collections/DefaultSort/index.js'
import { DraftsCollection } from './collections/Drafts/index.js'
import { LocalizedCollection } from './collections/Localized/index.js'
import { OrderableCollection } from './collections/Orderable/index.js'
import { OrderableJoinCollection } from './collections/OrderableJoin/index.js'
import { PostsCollection } from './collections/Posts/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [PostsCollection, DraftsCollection, DefaultSortCollection, LocalizedCollection],
collections: [
PostsCollection,
DraftsCollection,
DefaultSortCollection,
LocalizedCollection,
OrderableCollection,
OrderableJoinCollection,
],
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 +54,41 @@ 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: 'orderable', where: {} })
await payload.delete({ collection: 'orderable-join', where: {} })
const joinA = await payload.create({ collection: 'orderable-join', data: { title: 'Join A' } })
await createData(payload, 'orderable', [
{ title: 'A', orderableField: joinA.id },
{ title: 'B', orderableField: joinA.id },
{ title: 'C', orderableField: joinA.id },
{ title: 'D', orderableField: joinA.id },
])
await payload.create({ collection: 'orderable-join', data: { title: 'Join B' } })
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
})
}