UNPKG

payload

Version:

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

265 lines (264 loc) • 10.5 kB
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)=>{ 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 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, 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, 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 = 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 (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 = { handler: reorderHandler, method: 'post', path: '/reorder' }; if (!config.endpoints) { config.endpoints = []; } config.endpoints.push(reorderEndpoint); }; //# sourceMappingURL=index.js.map