Re-ordering documents with drafts uses `payload.update()` with `select:
{ id: true }` and that causes draft versions of those docs to be updated
without any data. I've removed the `select` optimization to prevent data
loss.
Fixes #12097
280 lines
9.0 KiB
TypeScript
280 lines
9.0 KiB
TypeScript
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, originalDoc, req }) => {
|
|
for (const orderableFieldName of orderableFieldNames) {
|
|
if (!data[orderableFieldName] && !originalDoc?.[orderableFieldName]) {
|
|
const lastDoc = await req.payload.find({
|
|
collection: collection.slug,
|
|
depth: 0,
|
|
limit: 1,
|
|
pagination: false,
|
|
req,
|
|
select: { [orderableFieldName]: true },
|
|
sort: `-${orderableFieldName}`,
|
|
where: {
|
|
[orderableFieldName]: {
|
|
exists: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
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 === 'undefined' ||
|
|
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,
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|