Skip to content

Commit d963e6a

Browse files
feat: orderable collections (#11452)
Closes #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](#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>
1 parent 968a066 commit d963e6a

File tree

35 files changed

+1616
-49
lines changed

35 files changed

+1616
-49
lines changed

docs/configuration/collections.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ The following options are available:
7373
| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
7474
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
7575
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
76+
| `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. |
7677
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
7778
| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
7879
| `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). |

docs/fields/join.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ powerful Admin UI.
138138
| **`name`** \* | To be used as the property name when retrieved from the database. [More](./overview#field-names) |
139139
| **`collection`** \* | The `slug`s having the relationship field or an array of collection slugs. |
140140
| **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. If `collection` is an array, this field must exist for all specified collections |
141+
| **`orderable`** | If true, enables custom ordering and joined documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
141142
| **`where`** | A `Where` query to hide related documents from appearing. Will be merged with any `where` specified in the request. |
142143
| **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](../queries/depth#max-depth). |
143144
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |

packages/next/src/views/List/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ export const renderListView = async (
195195
drawerSlug,
196196
enableRowSelections,
197197
i18n: req.i18n,
198+
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
198199
payload,
199200
useAsTitle: collectionConfig.admin.useAsTitle,
200201
})
@@ -259,6 +260,7 @@ export const renderListView = async (
259260
defaultSort={sort}
260261
listPreferences={listPreferences}
261262
modifySearchParams={!isInDrawer}
263+
orderableFieldName={collectionConfig.orderable === true ? '_order' : undefined}
262264
>
263265
{RenderServerComponent({
264266
clientProps: {

packages/next/src/views/Versions/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ export async function VersionsView(props: DocumentViewServerProps) {
193193
defaultLimit={limitToUse}
194194
defaultSort={sort as string}
195195
modifySearchParams
196+
orderableFieldName={collectionConfig?.orderable === true ? '_order' : undefined}
196197
>
197198
<VersionsViewClient
198199
baseClass={baseClass}

packages/payload/src/admin/functions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export type BuildTableStateArgs = {
6060
columns?: ColumnPreference[]
6161
docs?: PaginatedDocs['docs']
6262
enableRowSelections?: boolean
63+
orderableFieldName: string
6364
parent?: {
6465
collectionSlug: CollectionSlug
6566
id: number | string

packages/payload/src/collections/config/sanitize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// @ts-strict-ignore
2+
23
import type { Config, SanitizedConfig } from '../../config/types.js'
34
import type {
45
CollectionConfig,

packages/payload/src/collections/config/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,17 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
507507
duration: number
508508
}
509509
| false
510+
/**
511+
* If true, enables custom ordering for the collection, and documents in the listView can be reordered via drag and drop.
512+
* New documents are inserted at the end of the list according to this parameter.
513+
*
514+
* Under the hood, a field with {@link https://observablehq.com/@dgreensp/implementing-fractional-indexing|fractional indexing} is used to optimize inserts and reorderings.
515+
*
516+
* @default false
517+
*
518+
* @experimental There may be frequent breaking changes to this API
519+
*/
520+
orderable?: boolean
510521
slug: string
511522
/**
512523
* Add `createdAt` and `updatedAt` fields
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
// @ts-check
2+
3+
/**
4+
* THIS FILE IS COPIED FROM:
5+
* https://github.com/rocicorp/fractional-indexing/blob/main/src/index.js
6+
*
7+
* I AM NOT INSTALLING THAT LIBRARY BECAUSE JEST COMPLAINS ABOUT THE ESM MODULE AND THE TESTS FAIL.
8+
* DO NOT MODIFY IT
9+
*/
10+
11+
// License: CC0 (no rights reserved).
12+
13+
// This is based on https://observablehq.com/@dgreensp/implementing-fractional-indexing
14+
15+
export const BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
16+
17+
// `a` may be empty string, `b` is null or non-empty string.
18+
// `a < b` lexicographically if `b` is non-null.
19+
// no trailing zeros allowed.
20+
// digits is a string such as '0123456789' for base 10. Digits must be in
21+
// ascending character code order!
22+
/**
23+
* @param {string} a
24+
* @param {string | null | undefined} b
25+
* @param {string} digits
26+
* @returns {string}
27+
*/
28+
function midpoint(a, b, digits) {
29+
const zero = digits[0]
30+
if (b != null && a >= b) {
31+
throw new Error(a + ' >= ' + b)
32+
}
33+
if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) {
34+
throw new Error('trailing zero')
35+
}
36+
if (b) {
37+
// remove longest common prefix. pad `a` with 0s as we
38+
// go. note that we don't need to pad `b`, because it can't
39+
// end before `a` while traversing the common prefix.
40+
let n = 0
41+
while ((a[n] || zero) === b[n]) {
42+
n++
43+
}
44+
if (n > 0) {
45+
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits)
46+
}
47+
}
48+
// first digits (or lack of digit) are different
49+
const digitA = a ? digits.indexOf(a[0]) : 0
50+
const digitB = b != null ? digits.indexOf(b[0]) : digits.length
51+
if (digitB - digitA > 1) {
52+
const midDigit = Math.round(0.5 * (digitA + digitB))
53+
return digits[midDigit]
54+
} else {
55+
// first digits are consecutive
56+
if (b && b.length > 1) {
57+
return b.slice(0, 1)
58+
} else {
59+
// `b` is null or has length 1 (a single digit).
60+
// the first digit of `a` is the previous digit to `b`,
61+
// or 9 if `b` is null.
62+
// given, for example, midpoint('49', '5'), return
63+
// '4' + midpoint('9', null), which will become
64+
// '4' + '9' + midpoint('', null), which is '495'
65+
return digits[digitA] + midpoint(a.slice(1), null, digits)
66+
}
67+
}
68+
}
69+
70+
/**
71+
* @param {string} int
72+
* @return {void}
73+
*/
74+
75+
function validateInteger(int) {
76+
if (int.length !== getIntegerLength(int[0])) {
77+
throw new Error('invalid integer part of order key: ' + int)
78+
}
79+
}
80+
81+
/**
82+
* @param {string} head
83+
* @return {number}
84+
*/
85+
86+
function getIntegerLength(head) {
87+
if (head >= 'a' && head <= 'z') {
88+
return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2
89+
} else if (head >= 'A' && head <= 'Z') {
90+
return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2
91+
} else {
92+
throw new Error('invalid order key head: ' + head)
93+
}
94+
}
95+
96+
/**
97+
* @param {string} key
98+
* @return {string}
99+
*/
100+
101+
function getIntegerPart(key) {
102+
const integerPartLength = getIntegerLength(key[0])
103+
if (integerPartLength > key.length) {
104+
throw new Error('invalid order key: ' + key)
105+
}
106+
return key.slice(0, integerPartLength)
107+
}
108+
109+
/**
110+
* @param {string} key
111+
* @param {string} digits
112+
* @return {void}
113+
*/
114+
115+
function validateOrderKey(key, digits) {
116+
if (key === 'A' + digits[0].repeat(26)) {
117+
throw new Error('invalid order key: ' + key)
118+
}
119+
// getIntegerPart will throw if the first character is bad,
120+
// or the key is too short. we'd call it to check these things
121+
// even if we didn't need the result
122+
const i = getIntegerPart(key)
123+
const f = key.slice(i.length)
124+
if (f.slice(-1) === digits[0]) {
125+
throw new Error('invalid order key: ' + key)
126+
}
127+
}
128+
129+
// note that this may return null, as there is a largest integer
130+
/**
131+
* @param {string} x
132+
* @param {string} digits
133+
* @return {string | null}
134+
*/
135+
function incrementInteger(x, digits) {
136+
validateInteger(x)
137+
const [head, ...digs] = x.split('')
138+
let carry = true
139+
for (let i = digs.length - 1; carry && i >= 0; i--) {
140+
const d = digits.indexOf(digs[i]) + 1
141+
if (d === digits.length) {
142+
digs[i] = digits[0]
143+
} else {
144+
digs[i] = digits[d]
145+
carry = false
146+
}
147+
}
148+
if (carry) {
149+
if (head === 'Z') {
150+
return 'a' + digits[0]
151+
}
152+
if (head === 'z') {
153+
return null
154+
}
155+
const h = String.fromCharCode(head.charCodeAt(0) + 1)
156+
if (h > 'a') {
157+
digs.push(digits[0])
158+
} else {
159+
digs.pop()
160+
}
161+
return h + digs.join('')
162+
} else {
163+
return head + digs.join('')
164+
}
165+
}
166+
167+
// note that this may return null, as there is a smallest integer
168+
/**
169+
* @param {string} x
170+
* @param {string} digits
171+
* @return {string | null}
172+
*/
173+
174+
function decrementInteger(x, digits) {
175+
validateInteger(x)
176+
const [head, ...digs] = x.split('')
177+
let borrow = true
178+
for (let i = digs.length - 1; borrow && i >= 0; i--) {
179+
const d = digits.indexOf(digs[i]) - 1
180+
if (d === -1) {
181+
digs[i] = digits.slice(-1)
182+
} else {
183+
digs[i] = digits[d]
184+
borrow = false
185+
}
186+
}
187+
if (borrow) {
188+
if (head === 'a') {
189+
return 'Z' + digits.slice(-1)
190+
}
191+
if (head === 'A') {
192+
return null
193+
}
194+
const h = String.fromCharCode(head.charCodeAt(0) - 1)
195+
if (h < 'Z') {
196+
digs.push(digits.slice(-1))
197+
} else {
198+
digs.pop()
199+
}
200+
return h + digs.join('')
201+
} else {
202+
return head + digs.join('')
203+
}
204+
}
205+
206+
// `a` is an order key or null (START).
207+
// `b` is an order key or null (END).
208+
// `a < b` lexicographically if both are non-null.
209+
// digits is a string such as '0123456789' for base 10. Digits must be in
210+
// ascending character code order!
211+
/**
212+
* @param {string | null | undefined} a
213+
* @param {string | null | undefined} b
214+
* @param {string=} digits
215+
* @return {string}
216+
*/
217+
export function generateKeyBetween(a, b, digits = BASE_62_DIGITS) {
218+
if (a != null) {
219+
validateOrderKey(a, digits)
220+
}
221+
if (b != null) {
222+
validateOrderKey(b, digits)
223+
}
224+
if (a != null && b != null && a >= b) {
225+
throw new Error(a + ' >= ' + b)
226+
}
227+
if (a == null) {
228+
if (b == null) {
229+
return 'a' + digits[0]
230+
}
231+
232+
const ib = getIntegerPart(b)
233+
const fb = b.slice(ib.length)
234+
if (ib === 'A' + digits[0].repeat(26)) {
235+
return ib + midpoint('', fb, digits)
236+
}
237+
if (ib < b) {
238+
return ib
239+
}
240+
const res = decrementInteger(ib, digits)
241+
if (res == null) {
242+
throw new Error('cannot decrement any more')
243+
}
244+
return res
245+
}
246+
247+
if (b == null) {
248+
const ia = getIntegerPart(a)
249+
const fa = a.slice(ia.length)
250+
const i = incrementInteger(ia, digits)
251+
return i == null ? ia + midpoint(fa, null, digits) : i
252+
}
253+
254+
const ia = getIntegerPart(a)
255+
const fa = a.slice(ia.length)
256+
const ib = getIntegerPart(b)
257+
const fb = b.slice(ib.length)
258+
if (ia === ib) {
259+
return ia + midpoint(fa, fb, digits)
260+
}
261+
const i = incrementInteger(ia, digits)
262+
if (i == null) {
263+
throw new Error('cannot increment any more')
264+
}
265+
if (i < b) {
266+
return i
267+
}
268+
return ia + midpoint(fa, null, digits)
269+
}
270+
271+
/**
272+
* same preconditions as generateKeysBetween.
273+
* n >= 0.
274+
* Returns an array of n distinct keys in sorted order.
275+
* If a and b are both null, returns [a0, a1, ...]
276+
* If one or the other is null, returns consecutive "integer"
277+
* keys. Otherwise, returns relatively short keys between
278+
* a and b.
279+
* @param {string | null | undefined} a
280+
* @param {string | null | undefined} b
281+
* @param {number} n
282+
* @param {string} digits
283+
* @return {string[]}
284+
*/
285+
export function generateNKeysBetween(a, b, n, digits = BASE_62_DIGITS) {
286+
if (n === 0) {
287+
return []
288+
}
289+
if (n === 1) {
290+
return [generateKeyBetween(a, b, digits)]
291+
}
292+
if (b == null) {
293+
let c = generateKeyBetween(a, b, digits)
294+
const result = [c]
295+
for (let i = 0; i < n - 1; i++) {
296+
c = generateKeyBetween(c, b, digits)
297+
result.push(c)
298+
}
299+
return result
300+
}
301+
if (a == null) {
302+
let c = generateKeyBetween(a, b, digits)
303+
const result = [c]
304+
for (let i = 0; i < n - 1; i++) {
305+
c = generateKeyBetween(a, c, digits)
306+
result.push(c)
307+
}
308+
result.reverse()
309+
return result
310+
}
311+
const mid = Math.floor(n / 2)
312+
const c = generateKeyBetween(a, b, digits)
313+
return [
314+
...generateNKeysBetween(a, c, mid, digits),
315+
c,
316+
...generateNKeysBetween(c, b, n - mid - 1, digits),
317+
]
318+
}

0 commit comments

Comments
 (0)