From ca52a50dd93f4de9500756cc4856028e6eac7bf0 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Date: Tue, 10 Dec 2024 21:44:47 -0500 Subject: [PATCH 1/8] feat: consolidates create and duplicate operations (#9866) --- docs/local-api/overview.mdx | 3 + .../src/resolvers/collections/create.ts | 6 +- .../src/resolvers/collections/duplicate.ts | 8 +- .../graphql/src/schema/initCollections.ts | 3 + .../src/routes/rest/collections/duplicate.ts | 1 + .../src/collections/operations/create.ts | 31 +- .../src/collections/operations/duplicate.ts | 391 +----------------- .../collections/operations/local/create.ts | 4 + .../collections/operations/local/duplicate.ts | 10 +- .../payload/src/duplicateDocument/index.ts | 106 +++++ .../src/fields/hooks/beforeChange/promise.ts | 1 - .../payload/src/uploads/generateFileData.ts | 45 +- 12 files changed, 201 insertions(+), 408 deletions(-) create mode 100644 packages/payload/src/duplicateDocument/index.ts diff --git a/docs/local-api/overview.mdx b/docs/local-api/overview.mdx index 31fd1071dda..caf3ce6f0bd 100644 --- a/docs/local-api/overview.mdx +++ b/docs/local-api/overview.mdx @@ -131,6 +131,9 @@ const post = await payload.create({ // Alternatively, you can directly pass a File, // if file is provided, filePath will be omitted file: uploadedFile, + + // If you want to create a document that is a duplicate of another document + duplicateFromID: 'document-id-to-duplicate', }) ``` diff --git a/packages/graphql/src/resolvers/collections/create.ts b/packages/graphql/src/resolvers/collections/create.ts index 17ad5180d61..ce7a4e49584 100644 --- a/packages/graphql/src/resolvers/collections/create.ts +++ b/packages/graphql/src/resolvers/collections/create.ts @@ -30,15 +30,13 @@ export function createResolver( context.req.locale = args.locale } - const options = { + const result = await createOperation({ collection, data: args.data, depth: 0, draft: args.draft, req: isolateObjectProperty(context.req, 'transactionID'), - } - - const result = await createOperation(options) + }) return result } diff --git a/packages/graphql/src/resolvers/collections/duplicate.ts b/packages/graphql/src/resolvers/collections/duplicate.ts index b80609b30dd..73fca9b15db 100644 --- a/packages/graphql/src/resolvers/collections/duplicate.ts +++ b/packages/graphql/src/resolvers/collections/duplicate.ts @@ -7,6 +7,7 @@ import type { Context } from '../types.js' export type Resolver = ( _: unknown, args: { + data: TData draft: boolean fallbackLocale?: string id: string @@ -28,15 +29,14 @@ export function duplicateResolver( req.fallbackLocale = args.fallbackLocale || fallbackLocale context.req = req - const options = { + const result = await duplicateOperation({ id: args.id, collection, + data: args.data, depth: 0, draft: args.draft, req: isolateObjectProperty(req, 'transactionID'), - } - - const result = await duplicateOperation(options) + }) return result } diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index 3f08e1ce7e2..394f621b801 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -280,6 +280,9 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ type: collection.graphQL.type, args: { id: { type: new GraphQLNonNull(idType) }, + ...(createMutationInputType + ? { data: { type: collection.graphQL.mutationInputType } } + : {}), }, resolve: duplicateResolver(collection), } diff --git a/packages/next/src/routes/rest/collections/duplicate.ts b/packages/next/src/routes/rest/collections/duplicate.ts index 57350f53550..7e7a7d56c39 100644 --- a/packages/next/src/routes/rest/collections/duplicate.ts +++ b/packages/next/src/routes/rest/collections/duplicate.ts @@ -27,6 +27,7 @@ export const duplicate: CollectionRouteHandlerWithID = async ({ const doc = await duplicateOperation({ id, collection, + data: req.data, depth: isNumber(depth) ? Number(depth) : undefined, draft, populate: sanitizePopulateParam(req.query.populate), diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index a5580200f0b..9af12e1ed6d 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -13,6 +13,7 @@ import type { BeforeOperationHook, BeforeValidateHook, Collection, + DataFromCollectionSlug, RequiredDataFromCollectionSlug, SelectFromCollectionSlug, } from '../config/types.js' @@ -21,6 +22,7 @@ import { ensureUsernameOrEmail } from '../../auth/ensureUsernameOrEmail.js' import executeAccess from '../../auth/executeAccess.js' import { sendVerificationEmail } from '../../auth/sendVerificationEmail.js' import { registerLocalStrategy } from '../../auth/strategies/local/register.js' +import { getDuplicateDocumentData } from '../../duplicateDocument/index.js' import { afterChange } from '../../fields/hooks/afterChange/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { beforeChange } from '../../fields/hooks/beforeChange/index.js' @@ -43,6 +45,7 @@ export type Arguments = { disableTransaction?: boolean disableVerificationEmail?: boolean draft?: boolean + duplicateFromID?: DataFromCollectionSlug['id'] overrideAccess?: boolean overwriteExistingFiles?: boolean populate?: PopulateType @@ -97,6 +100,7 @@ export const createOperation = async < depth, disableVerificationEmail, draft = false, + duplicateFromID, overrideAccess, overwriteExistingFiles = false, populate, @@ -115,6 +119,23 @@ export const createOperation = async < const shouldSaveDraft = Boolean(draft && collectionConfig.versions.drafts) + let duplicatedFromDocWithLocales: JsonObject = {} + let duplicatedFromDoc: JsonObject = {} + + if (duplicateFromID) { + const duplicateResult = await getDuplicateDocumentData({ + id: duplicateFromID, + collectionConfig, + draftArg: shouldSaveDraft, + overrideAccess, + req, + shouldSaveDraft, + }) + + duplicatedFromDoc = duplicateResult.duplicatedFromDoc + duplicatedFromDocWithLocales = duplicateResult.duplicatedFromDocWithLocales + } + // ///////////////////////////////////// // Access // ///////////////////////////////////// @@ -131,7 +152,9 @@ export const createOperation = async < collection, config, data, + isDuplicating: Boolean(duplicateFromID), operation: 'create', + originalDoc: duplicatedFromDoc, overwriteExistingFiles, req, throwOnMissingFile: @@ -148,7 +171,7 @@ export const createOperation = async < collection: collectionConfig, context: req.context, data, - doc: {}, + doc: duplicatedFromDoc, global: null, operation: 'create', overrideAccess, @@ -169,6 +192,7 @@ export const createOperation = async < context: req.context, data, operation: 'create', + originalDoc: duplicatedFromDoc, req, })) || data }, @@ -188,6 +212,7 @@ export const createOperation = async < context: req.context, data, operation: 'create', + originalDoc: duplicatedFromDoc, req, })) || data }, Promise.resolve()) @@ -200,8 +225,8 @@ export const createOperation = async < collection: collectionConfig, context: req.context, data, - doc: {}, - docWithLocales: {}, + doc: duplicatedFromDoc, + docWithLocales: duplicatedFromDocWithLocales, global: null, operation: 'create', req, diff --git a/packages/payload/src/collections/operations/duplicate.ts b/packages/payload/src/collections/operations/duplicate.ts index 386318af202..888bea0a6f1 100644 --- a/packages/payload/src/collections/operations/duplicate.ts +++ b/packages/payload/src/collections/operations/duplicate.ts @@ -1,391 +1,26 @@ import type { DeepPartial } from 'ts-essentials' -import httpStatus from 'http-status' - -import type { FindOneArgs } from '../../database/types.js' import type { CollectionSlug } from '../../index.js' -import type { - PayloadRequest, - PopulateType, - SelectType, - TransformCollectionWithSelect, -} from '../../types/index.js' -import type { - Collection, - DataFromCollectionSlug, - SelectFromCollectionSlug, -} from '../config/types.js' +import type { TransformCollectionWithSelect } from '../../types/index.js' +import type { RequiredDataFromCollectionSlug, SelectFromCollectionSlug } from '../config/types.js' -import executeAccess from '../../auth/executeAccess.js' -import { hasWhereAccessResult } from '../../auth/types.js' -import { combineQueries } from '../../database/combineQueries.js' -import { APIError, Forbidden, NotFound } from '../../errors/index.js' -import { afterChange } from '../../fields/hooks/afterChange/index.js' -import { afterRead } from '../../fields/hooks/afterRead/index.js' -import { beforeChange } from '../../fields/hooks/beforeChange/index.js' -import { beforeDuplicate } from '../../fields/hooks/beforeDuplicate/index.js' -import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' -import { generateFileData } from '../../uploads/generateFileData.js' -import { uploadFiles } from '../../uploads/uploadFiles.js' -import { commitTransaction } from '../../utilities/commitTransaction.js' -import { initTransaction } from '../../utilities/initTransaction.js' -import { killTransaction } from '../../utilities/killTransaction.js' -import sanitizeInternalFields from '../../utilities/sanitizeInternalFields.js' -import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion.js' -import { saveVersion } from '../../versions/saveVersion.js' -import { buildAfterOperation } from './utils.js' +import { type Arguments as CreateArguments, createOperation } from './create.js' -export type Arguments = { - collection: Collection - depth?: number - disableTransaction?: boolean - draft?: boolean +export type Arguments = { + data?: DeepPartial> id: number | string - overrideAccess?: boolean - populate?: PopulateType - req: PayloadRequest - select?: SelectType - showHiddenFields?: boolean -} +} & Omit, 'data' | 'duplicateFromID'> export const duplicateOperation = async < TSlug extends CollectionSlug, TSelect extends SelectFromCollectionSlug, >( - incomingArgs: Arguments, + incomingArgs: Arguments, ): Promise> => { - let args = incomingArgs - const operation = 'create' - - try { - const shouldCommit = !args.disableTransaction && (await initTransaction(args.req)) - - // ///////////////////////////////////// - // beforeOperation - Collection - // ///////////////////////////////////// - - await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => { - await priorHook - - args = - (await hook({ - args, - collection: args.collection.config, - context: args.req.context, - operation, - req: args.req, - })) || args - }, Promise.resolve()) - - const { - id, - collection: { config: collectionConfig }, - depth, - draft: draftArg = true, - overrideAccess, - populate, - req: { fallbackLocale, locale: localeArg, payload }, - req, - select, - showHiddenFields, - } = args - - if (!id) { - throw new APIError('Missing ID of document to duplicate.', httpStatus.BAD_REQUEST) - } - const shouldSaveDraft = Boolean(draftArg && collectionConfig.versions.drafts) - - // ///////////////////////////////////// - // Read Access - // ///////////////////////////////////// - - const accessResults = !overrideAccess - ? await executeAccess({ id, req }, collectionConfig.access.read) - : true - const hasWherePolicy = hasWhereAccessResult(accessResults) - - // ///////////////////////////////////// - // Retrieve document - // ///////////////////////////////////// - const findOneArgs: FindOneArgs = { - collection: collectionConfig.slug, - locale: req.locale, - req, - where: combineQueries({ id: { equals: id } }, accessResults), - } - - let docWithLocales = await getLatestCollectionVersion({ - id, - config: collectionConfig, - payload, - query: findOneArgs, - req, - }) - - if (!docWithLocales && !hasWherePolicy) { - throw new NotFound(req.t) - } - if (!docWithLocales && hasWherePolicy) { - throw new Forbidden(req.t) - } - - // remove the createdAt timestamp and id to rely on the db to set the default it - delete docWithLocales.createdAt - delete docWithLocales.id - - docWithLocales = await beforeDuplicate({ - id, - collection: collectionConfig, - context: req.context, - doc: docWithLocales, - overrideAccess, - req, - }) - - // for version enabled collections, override the current status with draft, unless draft is explicitly set to false - if (shouldSaveDraft) { - docWithLocales._status = 'draft' - } - - let result - - let originalDoc = await afterRead({ - collection: collectionConfig, - context: req.context, - depth: 0, - doc: docWithLocales, - draft: draftArg, - fallbackLocale: null, - global: null, - locale: req.locale, - overrideAccess: true, - req, - showHiddenFields: true, - }) - - const { data: newFileData, files: filesToUpload } = await generateFileData({ - collection: args.collection, - config: req.payload.config, - data: originalDoc, - operation: 'create', - overwriteExistingFiles: 'forceDisable', - req, - throwOnMissingFile: true, - }) - - originalDoc = newFileData - - // ///////////////////////////////////// - // Create Access - // ///////////////////////////////////// - - if (!overrideAccess) { - await executeAccess({ data: originalDoc, req }, collectionConfig.access.create) - } - - // ///////////////////////////////////// - // beforeValidate - Fields - // ///////////////////////////////////// - - let data = await beforeValidate>>({ - id, - collection: collectionConfig, - context: req.context, - data: originalDoc, - doc: originalDoc, - duplicate: true, - global: null, - operation, - overrideAccess, - req, - }) - - // ///////////////////////////////////// - // beforeValidate - Collection - // ///////////////////////////////////// - - await collectionConfig.hooks.beforeValidate.reduce(async (priorHook, hook) => { - await priorHook - - data = - (await hook({ - collection: collectionConfig, - context: req.context, - data, - operation, - originalDoc, - req, - })) || result - }, Promise.resolve()) - - // ///////////////////////////////////// - // beforeChange - Collection - // ///////////////////////////////////// - - await collectionConfig.hooks.beforeChange.reduce(async (priorHook, hook) => { - await priorHook - - data = - (await hook({ - collection: collectionConfig, - context: req.context, - data, - operation, - originalDoc: result, - req, - })) || result - }, Promise.resolve()) - - // ///////////////////////////////////// - // beforeChange - Fields - // ///////////////////////////////////// - - result = await beforeChange({ - id, - collection: collectionConfig, - context: req.context, - data, - doc: originalDoc, - docWithLocales, - global: null, - operation, - req, - skipValidation: - shouldSaveDraft && - collectionConfig.versions.drafts && - !collectionConfig.versions.drafts.validate, - }) - - // set req.locale back to the original locale - req.locale = localeArg - - // ///////////////////////////////////// - // Create / Update - // ///////////////////////////////////// - - // ///////////////////////////////////// - // Write files to local storage - // ///////////////////////////////////// - - if (!collectionConfig.upload.disableLocalStorage) { - await uploadFiles(payload, filesToUpload, req) - } - - let versionDoc = await payload.db.create({ - collection: collectionConfig.slug, - data: result, - req, - select, - }) - - versionDoc = sanitizeInternalFields(versionDoc) - - // ///////////////////////////////////// - // Create version - // ///////////////////////////////////// - - if (collectionConfig.versions) { - result = await saveVersion({ - id: versionDoc.id, - collection: collectionConfig, - docWithLocales: versionDoc, - draft: shouldSaveDraft, - payload, - req, - }) - } - - // ///////////////////////////////////// - // afterRead - Fields - // ///////////////////////////////////// - - result = await afterRead({ - collection: collectionConfig, - context: req.context, - depth, - doc: versionDoc, - draft: draftArg, - fallbackLocale, - global: null, - locale: localeArg, - overrideAccess, - populate, - req, - select, - showHiddenFields, - }) - - // ///////////////////////////////////// - // afterRead - Collection - // ///////////////////////////////////// - - await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result, - req, - })) || result - }, Promise.resolve()) - - // ///////////////////////////////////// - // afterChange - Fields - // ///////////////////////////////////// - - result = await afterChange({ - collection: collectionConfig, - context: req.context, - data: versionDoc, - doc: result, - global: null, - operation, - previousDoc: {}, - req, - }) - - // ///////////////////////////////////// - // afterChange - Collection - // ///////////////////////////////////// - - await collectionConfig.hooks.afterChange.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result, - operation, - previousDoc: {}, - req, - })) || result - }, Promise.resolve()) - - // ///////////////////////////////////// - // afterOperation - Collection - // ///////////////////////////////////// - - result = await buildAfterOperation({ - args, - collection: collectionConfig, - operation, - result, - }) - - // ///////////////////////////////////// - // Return results - // ///////////////////////////////////// - - if (shouldCommit) { - await commitTransaction(req) - } - - return result - } catch (error: unknown) { - await killTransaction(args.req) - throw error - } + const { id, ...args } = incomingArgs + return createOperation({ + ...args, + data: incomingArgs?.data || {}, + duplicateFromID: id, + }) } diff --git a/packages/payload/src/collections/operations/local/create.ts b/packages/payload/src/collections/operations/local/create.ts index 36c171a1dba..e680ea13920 100644 --- a/packages/payload/src/collections/operations/local/create.ts +++ b/packages/payload/src/collections/operations/local/create.ts @@ -8,6 +8,7 @@ import type { } from '../../../types/index.js' import type { File } from '../../../uploads/types.js' import type { + DataFromCollectionSlug, RequiredDataFromCollectionSlug, SelectFromCollectionSlug, } from '../../config/types.js' @@ -28,6 +29,7 @@ export type Options = disableTransaction?: boolean disableVerificationEmail?: boolean draft?: boolean + duplicateFromID?: DataFromCollectionSlug['id'] fallbackLocale?: false | TypedLocale file?: File filePath?: string @@ -56,6 +58,7 @@ export default async function createLocal< disableTransaction, disableVerificationEmail, draft, + duplicateFromID, file, filePath, overrideAccess = true, @@ -82,6 +85,7 @@ export default async function createLocal< disableTransaction, disableVerificationEmail, draft, + duplicateFromID, overrideAccess, overwriteExistingFiles, populate, diff --git a/packages/payload/src/collections/operations/local/duplicate.ts b/packages/payload/src/collections/operations/local/duplicate.ts index 63c1b87796f..747bb679da3 100644 --- a/packages/payload/src/collections/operations/local/duplicate.ts +++ b/packages/payload/src/collections/operations/local/duplicate.ts @@ -1,3 +1,5 @@ +import type { DeepPartial } from 'ts-essentials' + import type { CollectionSlug, TypedLocale } from '../../..//index.js' import type { Payload, RequestContext } from '../../../index.js' import type { @@ -7,7 +9,10 @@ import type { SelectType, TransformCollectionWithSelect, } from '../../../types/index.js' -import type { SelectFromCollectionSlug } from '../../config/types.js' +import type { + RequiredDataFromCollectionSlug, + SelectFromCollectionSlug, +} from '../../config/types.js' import { APIError } from '../../../errors/index.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' @@ -19,6 +24,7 @@ export type Options = * context, which will then be passed to req.context, which can be read by hooks */ context?: RequestContext + data?: DeepPartial> depth?: number disableTransaction?: boolean draft?: boolean @@ -43,6 +49,7 @@ export async function duplicate< const { id, collection: collectionSlug, + data, depth, disableTransaction, draft, @@ -71,6 +78,7 @@ export async function duplicate< return duplicateOperation({ id, collection, + data, depth, disableTransaction, draft, diff --git a/packages/payload/src/duplicateDocument/index.ts b/packages/payload/src/duplicateDocument/index.ts new file mode 100644 index 00000000000..206c4349e72 --- /dev/null +++ b/packages/payload/src/duplicateDocument/index.ts @@ -0,0 +1,106 @@ +import type { SanitizedCollectionConfig } from '../collections/config/types.js' +import type { FindOneArgs } from '../database/types.js' +import type { JsonObject, PayloadRequest } from '../types/index.js' + +import executeAccess from '../auth/executeAccess.js' +import { hasWhereAccessResult } from '../auth/types.js' +import { combineQueries } from '../database/combineQueries.js' +import { Forbidden } from '../errors/Forbidden.js' +import { NotFound } from '../errors/NotFound.js' +import { afterRead } from '../fields/hooks/afterRead/index.js' +import { beforeDuplicate } from '../fields/hooks/beforeDuplicate/index.js' +import { getLatestCollectionVersion } from '../versions/getLatestCollectionVersion.js' + +type GetDuplicateDocumentArgs = { + collectionConfig: SanitizedCollectionConfig + draftArg?: boolean + id: number | string + overrideAccess?: boolean + req: PayloadRequest + shouldSaveDraft?: boolean +} +export const getDuplicateDocumentData = async ({ + id, + collectionConfig, + draftArg, + overrideAccess, + req, + shouldSaveDraft, +}: GetDuplicateDocumentArgs): Promise<{ + duplicatedFromDoc: JsonObject + duplicatedFromDocWithLocales: JsonObject +}> => { + const { payload } = req + // ///////////////////////////////////// + // Read Access + // ///////////////////////////////////// + + const accessResults = !overrideAccess + ? await executeAccess({ id, req }, collectionConfig.access.read) + : true + const hasWherePolicy = hasWhereAccessResult(accessResults) + + // ///////////////////////////////////// + // Retrieve document + // ///////////////////////////////////// + const findOneArgs: FindOneArgs = { + collection: collectionConfig.slug, + locale: req.locale, + req, + where: combineQueries({ id: { equals: id } }, accessResults), + } + + let duplicatedFromDocWithLocales = await getLatestCollectionVersion({ + id, + config: collectionConfig, + payload, + query: findOneArgs, + req, + }) + + if (!duplicatedFromDocWithLocales && !hasWherePolicy) { + throw new NotFound(req.t) + } + if (!duplicatedFromDocWithLocales && hasWherePolicy) { + throw new Forbidden(req.t) + } + + // remove the createdAt timestamp and rely on the db to set it + if ('createdAt' in duplicatedFromDocWithLocales) { + delete duplicatedFromDocWithLocales.createdAt + } + // remove the id and rely on the db to set it + if ('id' in duplicatedFromDocWithLocales) { + delete duplicatedFromDocWithLocales.id + } + + duplicatedFromDocWithLocales = await beforeDuplicate({ + id, + collection: collectionConfig, + context: req.context, + doc: duplicatedFromDocWithLocales, + overrideAccess, + req, + }) + + // for version enabled collections, override the current status with draft, unless draft is explicitly set to false + if (shouldSaveDraft) { + duplicatedFromDocWithLocales._status = 'draft' + } + + const duplicatedFromDoc = await afterRead({ + collection: collectionConfig, + context: req.context, + depth: 0, + doc: duplicatedFromDocWithLocales, + draft: draftArg, + fallbackLocale: null, + global: null, + locale: req.locale, + overrideAccess: true, + req, + showHiddenFields: true, + }) + + return { duplicatedFromDoc, duplicatedFromDocWithLocales } +} diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index 3d38bb86041..c37f05011c2 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -286,7 +286,6 @@ export const promise = async ({ } case 'collapsible': - case 'row': { await traverseFields({ id, diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index ed7acdb5429..01f0f80a52f 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -25,10 +25,10 @@ type Args = { collection: Collection config: SanitizedConfig data: T + isDuplicating?: boolean operation: 'create' | 'update' originalDoc?: T - /** pass forceDisable to not overwrite existing files even if they already exist in `data` */ - overwriteExistingFiles?: 'forceDisable' | boolean + overwriteExistingFiles?: boolean req: PayloadRequest throwOnMissingFile?: boolean } @@ -41,6 +41,7 @@ type Result = Promise<{ export const generateFileData = async ({ collection: { config: collectionConfig }, data, + isDuplicating, operation, originalDoc, overwriteExistingFiles, @@ -60,6 +61,7 @@ export const generateFileData = async ({ const uploadEdits = parseUploadEditsFromReqOrIncomingData({ data, + isDuplicating, operation, originalDoc, req, @@ -78,33 +80,31 @@ export const generateFileData = async ({ const staticPath = staticDir - if (!file && uploadEdits && data) { - const { filename, url } = data as FileData + const incomingFileData = isDuplicating ? originalDoc : data + + if (!file && uploadEdits && incomingFileData) { + const { filename, url } = incomingFileData as FileData try { if (url && url.startsWith('/') && !disableLocalStorage) { const filePath = `${staticPath}/${filename}` const response = await getFileByPath(filePath) file = response - if (overwriteExistingFiles !== 'forceDisable') { - overwriteExistingFiles = true - } + overwriteExistingFiles = true } else if (filename && url) { file = await getExternalFile({ - data: data as FileData, + data: incomingFileData as FileData, req, uploadConfig: collectionConfig.upload, }) - if (overwriteExistingFiles !== 'forceDisable') { - overwriteExistingFiles = true - } + overwriteExistingFiles = true } } catch (err: unknown) { throw new FileRetrievalError(req.t, err instanceof Error ? err.message : undefined) } } - if (overwriteExistingFiles === 'forceDisable') { + if (isDuplicating) { overwriteExistingFiles = false } @@ -362,11 +362,12 @@ export const generateFileData = async ({ */ function parseUploadEditsFromReqOrIncomingData(args: { data: unknown + isDuplicating?: boolean operation: 'create' | 'update' originalDoc: unknown req: PayloadRequest }): UploadEdits { - const { data, operation, originalDoc, req } = args + const { data, isDuplicating, operation, originalDoc, req } = args // Get intended focal point change from query string or incoming data const uploadEdits = @@ -381,10 +382,19 @@ function parseUploadEditsFromReqOrIncomingData(args: { const incomingData = data as FileData const origDoc = originalDoc as FileData - // If no change in focal point, return undefined. - // This prevents a refocal operation triggered from admin, because it always sends the focal point. - if (origDoc && incomingData.focalX === origDoc.focalX && incomingData.focalY === origDoc.focalY) { - return undefined + if (origDoc && 'focalX' in origDoc && 'focalY' in origDoc) { + // If no change in focal point, return undefined. + // This prevents a refocal operation triggered from admin, because it always sends the focal point. + if (incomingData.focalX === origDoc.focalX && incomingData.focalY === origDoc.focalY) { + return undefined + } + + if (isDuplicating) { + uploadEdits.focalPoint = { + x: incomingData?.focalX || origDoc.focalX, + y: incomingData?.focalY || origDoc.focalX, + } + } } if (incomingData?.focalX && incomingData?.focalY) { @@ -402,5 +412,6 @@ function parseUploadEditsFromReqOrIncomingData(args: { y: 50, } } + return uploadEdits } From 306b5d230040526acc7645037a3b0f6373c64289 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 11 Dec 2024 08:43:22 -0500 Subject: [PATCH 2/8] fix: forgotPassword set expiration time (#9871) The logic for creating a timestamp for use in resetPassword was not correctly returning a valid date. --------- Co-authored-by: Patrik Kozak --- .../src/auth/operations/forgotPassword.ts | 4 +- test/access-control/int.spec.ts | 37 +++++++++++++++++++ test/auth/config.ts | 3 ++ test/auth/int.spec.ts | 35 ++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/payload/src/auth/operations/forgotPassword.ts b/packages/payload/src/auth/operations/forgotPassword.ts index 36df4979173..b30bdee6c5d 100644 --- a/packages/payload/src/auth/operations/forgotPassword.ts +++ b/packages/payload/src/auth/operations/forgotPassword.ts @@ -137,8 +137,8 @@ export const forgotPasswordOperation = async ( user.resetPasswordToken = token user.resetPasswordExpiration = new Date( - collectionConfig.auth?.forgotPassword?.expiration || expiration || Date.now() + 3600000, - ).toISOString() // 1 hour + Date.now() + (collectionConfig.auth?.forgotPassword?.expiration ?? expiration ?? 3600000), + ).toISOString() user = await payload.update({ id: user.id, diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts index b4180b73902..df3055ffcd0 100644 --- a/test/access-control/int.spec.ts +++ b/test/access-control/int.spec.ts @@ -605,6 +605,43 @@ describe('Access Control', () => { expect(res).toBeTruthy() }) }) + + describe('Auth - Local API', () => { + it('should not allow reset password if forgotPassword expiration token is expired', async () => { + // Mock Date.now() to simulate the forgotPassword call happening 1 hour ago (default is 1 hour) + const originalDateNow = Date.now + const mockDateNow = jest.spyOn(Date, 'now').mockImplementation(() => { + // Move the current time back by 1 hour + return originalDateNow() - 60 * 60 * 1000 + }) + + let forgot + try { + // Call forgotPassword while the mocked Date.now() is active + forgot = await payload.forgotPassword({ + collection: 'users', + data: { + email: 'dev@payloadcms.com', + }, + }) + } finally { + // Restore the original Date.now() after the forgotPassword call + mockDateNow.mockRestore() + } + + // Attempt to reset password, which should fail because the token is expired + await expect( + payload.resetPassword({ + collection: 'users', + data: { + password: 'test', + token: forgot, + }, + overrideAccess: true, + }), + ).rejects.toThrow('Token is either invalid or has expired.') + }) + }) }) async function createDoc( diff --git a/test/auth/config.ts b/test/auth/config.ts index 66b349fabb8..66d5ddceffc 100644 --- a/test/auth/config.ts +++ b/test/auth/config.ts @@ -44,6 +44,9 @@ export default buildConfigWithDefaults({ tokenExpiration: 7200, // 2 hours useAPIKey: true, verify: false, + forgotPassword: { + expiration: 300000, // 5 minutes + }, }, fields: [ { diff --git a/test/auth/int.spec.ts b/test/auth/int.spec.ts index 9d969dc797f..88d886287d2 100644 --- a/test/auth/int.spec.ts +++ b/test/auth/int.spec.ts @@ -932,5 +932,40 @@ describe('Auth', () => { expect(reset.user.email).toStrictEqual('dev@payloadcms.com') }) + + it('should not allow reset password if forgotPassword expiration token is expired', async () => { + // Mock Date.now() to simulate the forgotPassword call happening 6 minutes ago (current expiration is set to 5 minutes) + const originalDateNow = Date.now + const mockDateNow = jest.spyOn(Date, 'now').mockImplementation(() => { + // Move the current time back by 6 minutes (360,000 ms) + return originalDateNow() - 6 * 60 * 1000 + }) + + let forgot + try { + // Call forgotPassword while the mocked Date.now() is active + forgot = await payload.forgotPassword({ + collection: 'users', + data: { + email: 'dev@payloadcms.com', + }, + }) + } finally { + // Restore the original Date.now() after the forgotPassword call + mockDateNow.mockRestore() + } + + // Attempt to reset password, which should fail because the token is expired + await expect( + payload.resetPassword({ + collection: 'users', + data: { + password: 'test', + token: forgot, + }, + overrideAccess: true, + }), + ).rejects.toThrow('Token is either invalid or has expired.') + }) }) }) From 522399095c7f5e40df969c8338f0a6a97943e360 Mon Sep 17 00:00:00 2001 From: Patrik Date: Wed, 11 Dec 2024 10:24:56 -0500 Subject: [PATCH 3/8] feat(next): adds `suppressHydrationWarning` property to payload config admin options (#9867) ### What? There are scenarios where the server-rendered HTML might intentionally differ from the client-rendered DOM causing `Hydration` errors in the DOM. ### How? Added a new prop to the payload config `admin` object called `suppressHydrationWarning` that allows control to display these warnings or not. If you set `suppressHydrationWarning` to `true`, React will not warn you about mismatches in the attributes and the content of that element. Defaults to `false` - so if there is a mismatch and this prop is not defined in your config, the hydration errors will show. ``` admin: { suppressHydrationWarning: true // will suppress the errors if there is a mismatch } ``` --- docs/admin/overview.mdx | 29 ++++++++++++------------ packages/next/src/layouts/Root/index.tsx | 7 +++++- packages/payload/src/config/types.ts | 6 +++++ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/docs/admin/overview.mdx b/docs/admin/overview.mdx index a84ad55318a..5da2abb3487 100644 --- a/docs/admin/overview.mdx +++ b/docs/admin/overview.mdx @@ -86,20 +86,21 @@ const config = buildConfig({ The following options are available: -| Option | Description | -|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **`avatar`** | Set account profile picture. Options: `gravatar`, `default` or a custom React component. | -| **`autoLogin`** | Used to automate log-in for dev and demonstration convenience. [More details](../authentication/overview). | -| **`buildPath`** | Specify an absolute path for where to store the built Admin bundle used in production. Defaults to `path.resolve(process.cwd(), 'build')`. | -| **`components`** | Component overrides that affect the entirety of the Admin Panel. [More details](./components). | -| **`custom`** | Any custom properties you wish to pass to the Admin Panel. | -| **`dateFormat`** | The date format that will be used for all dates within the Admin Panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. | -| **`disable`** | If set to `true`, the entire Admin Panel will be disabled. | -| **`livePreview`** | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). | -| **`meta`** | Base metadata to use for the Admin Panel. [More details](./metadata). | -| **`routes`** | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). | -| **`theme`** | Restrict the Admin Panel theme to use only one of your choice. Default is `all`. -| **`user`** | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). | +| Option | Description | +|--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **`avatar`** | Set account profile picture. Options: `gravatar`, `default` or a custom React component. | +| **`autoLogin`** | Used to automate log-in for dev and demonstration convenience. [More details](../authentication/overview). | +| **`buildPath`** | Specify an absolute path for where to store the built Admin bundle used in production. Defaults to `path.resolve(process.cwd(), 'build')`. | +| **`components`** | Component overrides that affect the entirety of the Admin Panel. [More details](./components). | +| **`custom`** | Any custom properties you wish to pass to the Admin Panel. | +| **`dateFormat`** | The date format that will be used for all dates within the Admin Panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. | +| **`disable`** | If set to `true`, the entire Admin Panel will be disabled. | +| **`livePreview`** | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). | +| **`meta`** | Base metadata to use for the Admin Panel. [More details](./metadata). | +| **`routes`** | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). | +| **`suppressHydrationWarning`** | If set to `true`, suppresses React hydration mismatch warnings during the hydration of the root tag. Defaults to `false`. | +| **`theme`** | Restrict the Admin Panel theme to use only one of your choice. Default is `all`. | +| **`user`** | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). | Reminder: diff --git a/packages/next/src/layouts/Root/index.tsx b/packages/next/src/layouts/Root/index.tsx index 39d89887df2..32e45e782dd 100644 --- a/packages/next/src/layouts/Root/index.tsx +++ b/packages/next/src/layouts/Root/index.tsx @@ -93,7 +93,12 @@ export const RootLayout = async ({ }) return ( - + diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index c61e68ecd1e..9a455c93fa9 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -841,6 +841,12 @@ export type Config = { /** The route for the unauthorized page. */ unauthorized?: string } + /** + * Suppresses React hydration mismatch warnings during the hydration of the root tag. + * Useful in scenarios where the server-rendered HTML might intentionally differ from the client-rendered DOM. + * @default false + */ + suppressHydrationWarning?: boolean /** * Restrict the Admin Panel theme to use only one of your choice * From a0f0316534e59b6012ce9cf85ca8c8e1a5382661 Mon Sep 17 00:00:00 2001 From: James Mikrut Date: Wed, 11 Dec 2024 10:33:48 -0500 Subject: [PATCH 4/8] fix: ensures autosave only runs sequentially (#9892) Previously, Autosave could trigger 2 parallel fetches where the second could outpace the first, leading to inconsistent results. Now, we use a simple queue-based system where we can push multiple autosave events into a queue, and only the latest autosave will be performed. This also prevents multiple autosaves from ever running in parallel. --- packages/ui/src/elements/Autosave/index.tsx | 31 ++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index 38ec81d20dc..2c540a05533 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -3,7 +3,7 @@ import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload' import { versionDefaults } from 'payload/shared' -import React, { useEffect, useRef, useState } from 'react' +import React, { useRef, useState } from 'react' import { toast } from 'sonner' import { @@ -49,6 +49,9 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) setLastUpdateTime, setMostRecentVersionIsAutosaved, } = useDocumentInfo() + const queueRef = useRef([]) + const isProcessingRef = useRef(false) + const { reportUpdate } = useDocumentEvents() const { dispatchFields, setSubmitted } = useForm() const submitted = useFormSubmitted() @@ -88,6 +91,25 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) // can always retrieve the most to date locale localeRef.current = locale + const processQueue = React.useCallback(async () => { + if (isProcessingRef.current || queueRef.current.length === 0) { + return + } + + isProcessingRef.current = true + const latestAction = queueRef.current[queueRef.current.length - 1] + queueRef.current = [] + + try { + await latestAction() + } finally { + isProcessingRef.current = false + if (queueRef.current.length > 0) { + await processQueue() + } + } + }, []) + // When debounced fields change, autosave useIgnoredEffect( () => { @@ -97,7 +119,7 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) let startTimestamp = undefined let endTimestamp = undefined - const autosave = () => { + const autosave = async () => { if (modified) { startTimestamp = new Date().getTime() @@ -129,7 +151,7 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate if (!skipSubmission) { - void fetch(url, { + await fetch(url, { body: JSON.stringify(data), credentials: 'include', headers: { @@ -229,7 +251,8 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) } } - void autosave() + queueRef.current.push(autosave) + void processQueue() return () => { if (autosaveTimeout) { From 0303b78d628422bac99457fd8d6bcf82844c18d1 Mon Sep 17 00:00:00 2001 From: Said Akhrarov <36972061+akhrarovsaid@users.noreply.github.com> Date: Wed, 11 Dec 2024 12:19:37 -0500 Subject: [PATCH 5/8] fix(next): thread default ServerProps to view actions and other components that were missing (#9868) This PR threads default `serverProps` to Edit and List view action slots, as well as other various components that were missing them. --------- Co-authored-by: Alessio Gravili --- packages/next/src/templates/Default/index.tsx | 42 +++++++++---------- .../views/Document/renderDocumentSlots.tsx | 14 ++++++- packages/payload/src/config/types.ts | 2 +- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/packages/next/src/templates/Default/index.tsx b/packages/next/src/templates/Default/index.tsx index 32b9019fd43..b410ee6a15f 100644 --- a/packages/next/src/templates/Default/index.tsx +++ b/packages/next/src/templates/Default/index.tsx @@ -48,6 +48,20 @@ export const DefaultTemplate: React.FC = ({ } = {}, } = payload.config || {} + const serverProps = React.useMemo( + () => ({ + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + visibleEntities, + }), + [i18n, locale, params, payload, permissions, searchParams, user, visibleEntities], + ) + const { Actions } = React.useMemo<{ Actions: Record }>(() => { @@ -59,11 +73,13 @@ export const DefaultTemplate: React.FC = ({ acc[action.path] = RenderServerComponent({ Component: action, importMap: payload.importMap, + serverProps, }) } else { acc[action] = RenderServerComponent({ Component: action, importMap: payload.importMap, + serverProps, }) } } @@ -72,23 +88,14 @@ export const DefaultTemplate: React.FC = ({ }, {}) : undefined, } - }, [viewActions, payload]) + }, [payload, serverProps, viewActions]) const NavComponent = RenderServerComponent({ clientProps: { clientProps: { visibleEntities } }, Component: CustomNav, Fallback: DefaultNav, importMap: payload.importMap, - serverProps: { - i18n, - locale, - params, - payload, - permissions, - searchParams, - user, - visibleEntities, - }, + serverProps, }) return ( @@ -99,16 +106,7 @@ export const DefaultTemplate: React.FC = ({ clientProps: { clientProps: { visibleEntities } }, Component: CustomHeader, importMap: payload.importMap, - serverProps: { - i18n, - locale, - params, - payload, - permissions, - searchParams, - user, - visibleEntities, - }, + serverProps, })}