UNPKG

payload

Version:

Node, React, Headless CMS and Application Framework built on Next.js

315 lines (314 loc) • 12.7 kB
import { status as httpStatus } from 'http-status'; import { executeAccess } from '../../auth/executeAccess.js'; import { APIError } from '../../errors/index.js'; import { commitTransaction } from '../../utilities/commitTransaction.js'; import { initTransaction } from '../../utilities/initTransaction.js'; import { killTransaction } from '../../utilities/killTransaction.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)=>{ const fieldsToAdd = new Map(); 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 APIError('Orderable joins must target a single collection', httpStatus.BAD_REQUEST, {}, true); } 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, orderableFieldNames)=>{ // 1. Add field orderableFieldNames.forEach((orderableFieldName)=>{ const orderField = { name: orderableFieldName, type: 'text', admin: { disableBulkEdit: true, disabled: true, disableListColumn: true, disableListFilter: true, hidden: true, readOnly: true }, hooks: { beforeDuplicate: [ ({ siblingData })=>{ delete siblingData[orderableFieldName]; } ] }, index: true }; collection.fields.unshift(orderField); }); // 2. Add hook if (!collection.hooks) { collection.hooks = {}; } if (!collection.hooks.beforeChange) { collection.hooks.beforeChange = []; } const orderBeforeChangeHook = 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); }; export const addOrderableEndpoint = (config)=>{ // 3. Add endpoint const reorderHandler = async (req)=>{ const body = await req.json?.(); 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 (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); } /** * If there is no target.key, we can assume the user enabled `orderable` * on a collection with existing documents, and that this is the first * time they tried to reorder them. Therefore, we perform a one-time * migration by setting the key value for all documents. We do this * instead of enforcing `required` and `unique` at the database schema * level, so that users don't have to run a migration when they enable * `orderable` on a collection with existing documents. */ if (!target.key) { const { docs } = await req.payload.find({ collection: collection.slug, depth: 0, limit: 0, req, select: { [orderableFieldName]: true }, where: { [orderableFieldName]: { exists: false } } }); await initTransaction(req); // We cannot update all documents in a single operation with `payload.update`, // because they would all end up with the same order key (`a0`). try { for (const doc of docs){ await req.payload.update({ id: doc.id, collection: collection.slug, data: { }, depth: 0, req }); await commitTransaction(req); } } catch (e) { await killTransaction(req); if (e instanceof Error) { throw new APIError(e.message, httpStatus.INTERNAL_SERVER_ERROR); } } return new Response(JSON.stringify({ message: 'initial migration', success: true }), { headers: { 'Content-Type': 'application/json' }, status: 200 }); } 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' }), { headers: { 'Content-Type': 'application/json' }, status: 400 }); } 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 = { handler: reorderHandler, method: 'post', path: '/reorder' }; if (!config.endpoints) { config.endpoints = []; } config.endpoints.push(reorderEndpoint); }; //# sourceMappingURL=index.js.map