diff --git a/.release-it.beta.json b/.release-it.canary.json similarity index 89% rename from .release-it.beta.json rename to .release-it.canary.json index 5dff68cbd7..f65c00ec61 100644 --- a/.release-it.beta.json +++ b/.release-it.canary.json @@ -1,5 +1,5 @@ { - "preReleaseId": "beta", + "preReleaseId": "canary", "git": { "requireCleanWorkingDir": false, "commit": false, @@ -11,7 +11,7 @@ }, "npm": { "skipChecks": true, - "tag": "beta" + "tag": "canary" }, "hooks": { "before:init": ["yarn", "yarn clean", "yarn test"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 97fe654e63..91e29d5bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ +## [1.5.9](https://github.com/payloadcms/payload/compare/v1.5.8...v1.5.9) (2023-01-15) + + +### Bug Fixes + +* [#1877](https://github.com/payloadcms/payload/issues/1877), [#1867](https://github.com/payloadcms/payload/issues/1867) - mimeTypes and imageSizes no longer cause error in admin ui ([b06ca70](https://github.com/payloadcms/payload/commit/b06ca700be36cc3a945f81e3fa23ebb53d06ca23)) + +## [1.5.8](https://github.com/payloadcms/payload/compare/v1.5.7...v1.5.8) (2023-01-12) + + +### Features + +* throws descriptive error when collection or global slug not found ([b847d85](https://github.com/payloadcms/payload/commit/b847d85e60032b47a8eacc2c9426fdd373dff879)) + +## [1.5.7](https://github.com/payloadcms/payload/compare/v1.5.6...v1.5.7) (2023-01-12) + + +### Bug Fixes + +* ensures find with draft=true does not improperly exclude draft ids ([69026c5](https://github.com/payloadcms/payload/commit/69026c577914ba029f2c45423d9f621b605a3ca0)) +* ensures querying with drafts works on both published and non-published posts ([f018fc0](https://github.com/payloadcms/payload/commit/f018fc04b02f70d0e6ea545d5eb36ea860206964)) + ## [1.5.6](https://github.com/payloadcms/payload/compare/v1.5.5...v1.5.6) (2023-01-11) diff --git a/package.json b/package.json index 00eaf8bdb1..19c52769fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.5.6", + "version": "1.5.9", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "engines": { @@ -134,6 +134,7 @@ "minimist": "^1.2.0", "mkdirp": "^1.0.4", "mongoose": "6.5.0", + "mongoose-aggregate-paginate-v2": "^1.0.6", "mongoose-paginate-v2": "^1.6.1", "nodemailer": "^6.4.2", "object-to-formdata": "^4.1.0", diff --git a/src/admin/components/elements/Autosave/index.tsx b/src/admin/components/elements/Autosave/index.tsx index e51cbb5fd4..71706806e4 100644 --- a/src/admin/components/elements/Autosave/index.tsx +++ b/src/admin/components/elements/Autosave/index.tsx @@ -44,7 +44,7 @@ const Autosave: React.FC = ({ collection, global, id, publishedDocUpdated modifiedRef.current = modified; const createCollectionDoc = useCallback(async () => { - const res = await fetch(`${serverURL}${api}/${collection.slug}?locale=${locale}&fallback-locale=null&depth=0&draft=true`, { + const res = await fetch(`${serverURL}${api}/${collection.slug}?locale=${locale}&fallback-locale=null&depth=0&draft=true&autosave=true`, { method: 'POST', credentials: 'include', headers: { @@ -95,13 +95,13 @@ const Autosave: React.FC = ({ collection, global, id, publishedDocUpdated } if (url) { - const body = { - ...reduceFieldsToValues(fieldRef.current, true), - _status: 'draft', - }; - setTimeout(async () => { if (modifiedRef.current) { + const body = { + ...reduceFieldsToValues(fieldRef.current, true), + _status: 'draft', + }; + const res = await fetch(url, { method, credentials: 'include', diff --git a/src/admin/components/elements/VersionsCount/index.tsx b/src/admin/components/elements/VersionsCount/index.tsx index f58731fd56..2ca490fa68 100644 --- a/src/admin/components/elements/VersionsCount/index.tsx +++ b/src/admin/components/elements/VersionsCount/index.tsx @@ -4,9 +4,6 @@ import { useConfig } from '../../utilities/Config'; import Button from '../Button'; import { Props } from './types'; import { useDocumentInfo } from '../../utilities/DocumentInfo'; -import { SanitizedCollectionConfig } from '../../../../collections/config/types'; -import { SanitizedGlobalConfig } from '../../../../globals/config/types'; -import { shouldIncrementVersionCount } from '../../../../versions/shouldIncrementVersionCount'; import './index.scss'; @@ -14,35 +11,20 @@ const baseClass = 'versions-count'; const VersionsCount: React.FC = ({ collection, global, id }) => { const { routes: { admin } } = useConfig(); - const { versions, publishedDoc, unpublishedVersions } = useDocumentInfo(); + const { versions } = useDocumentInfo(); const { t } = useTranslation('version'); - // Doc status could come from three places: - // 1. the newest unpublished version (a draft) - // 2. the published doc's status, in the event that the doc is published and there are no newer versions - // 3. if there is no published doc, it's a draft - const docStatus = unpublishedVersions?.docs?.[0]?.version?._status || publishedDoc?._status || 'draft'; - let versionsURL: string; - let entity: SanitizedCollectionConfig | SanitizedGlobalConfig; if (collection) { versionsURL = `${admin}/collections/${collection.slug}/${id}/versions`; - entity = collection; } if (global) { versionsURL = `${admin}/globals/${global.slug}/versions`; - entity = global; } - let initialVersionsCount = 0; - - if (shouldIncrementVersionCount({ entity, versions, docStatus })) { - initialVersionsCount = 1; - } - - const versionCount = (versions?.totalDocs || 0) + initialVersionsCount; + const versionCount = versions?.totalDocs || 0; return (
diff --git a/src/admin/components/forms/Form/buildStateFromSchema/addFieldStatePromise.ts b/src/admin/components/forms/Form/buildStateFromSchema/addFieldStatePromise.ts index b1572d9d26..dfc96a1c04 100644 --- a/src/admin/components/forms/Form/buildStateFromSchema/addFieldStatePromise.ts +++ b/src/admin/components/forms/Form/buildStateFromSchema/addFieldStatePromise.ts @@ -191,7 +191,7 @@ export const addFieldStatePromise = async ({ id, operation, fields: field.fields, - data: data?.[field.name], + data: data?.[field.name] || {}, fullData, parentPassesCondition: passesCondition, path: `${path}${field.name}.`, diff --git a/src/admin/components/views/Versions/index.tsx b/src/admin/components/views/Versions/index.tsx index eca3f3d7a3..123f793f06 100644 --- a/src/admin/components/views/Versions/index.tsx +++ b/src/admin/components/views/Versions/index.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; import { useRouteMatch } from 'react-router-dom'; -import format from 'date-fns/format'; import { useTranslation } from 'react-i18next'; import { useConfig } from '../../utilities/Config'; import usePayloadAPI from '../../../hooks/usePayloadAPI'; @@ -16,10 +15,6 @@ import Table from '../../elements/Table'; import Paginator from '../../elements/Paginator'; import PerPage from '../../elements/PerPage'; import { useSearchParams } from '../../utilities/SearchParams'; -import { Banner, Pill } from '../..'; -import { SanitizedCollectionConfig } from '../../../../collections/config/types'; -import { SanitizedGlobalConfig } from '../../../../globals/config/types'; -import { shouldIncrementVersionCount } from '../../../../versions/shouldIncrementVersionCount'; import { Gutter } from '../../elements/Gutter'; import { getTranslation } from '../../../../utilities/getTranslation'; @@ -28,7 +23,7 @@ import './index.scss'; const baseClass = 'versions'; const Versions: React.FC = ({ collection, global }) => { - const { serverURL, routes: { admin, api }, admin: { dateFormat } } = useConfig(); + const { serverURL, routes: { admin, api } } = useConfig(); const { setStepNav } = useStepNav(); const { params: { id } } = useRouteMatch<{ id: string }>(); const { t, i18n } = useTranslation('version'); @@ -39,14 +34,12 @@ const Versions: React.FC = ({ collection, global }) => { let docURL: string; let entityLabel: string; let slug: string; - let entity: SanitizedCollectionConfig | SanitizedGlobalConfig; let editURL: string; if (collection) { ({ slug } = collection); docURL = `${serverURL}${api}/${slug}/${id}`; entityLabel = getTranslation(collection.labels.singular, i18n); - entity = collection; editURL = `${admin}/collections/${collection.slug}/${id}`; } @@ -54,7 +47,6 @@ const Versions: React.FC = ({ collection, global }) => { ({ slug } = global); docURL = `${serverURL}${api}/globals/${slug}`; entityLabel = getTranslation(global.label, i18n); - entity = global; editURL = `${admin}/globals/${global.slug}`; } @@ -164,10 +156,6 @@ const Versions: React.FC = ({ collection, global }) => { useIDLabel = false; } - const docStatus = doc?._status; - const docUpdatedAt = doc?.updatedAt; - const showParentDoc = versionsData?.page === 1 && shouldIncrementVersionCount({ entity, docStatus, versions: versionsData }); - return (
= ({ collection, global }) => { {isLoadingVersions && ( )} - {showParentDoc && ( - - {t('currentDocumentStatus', { docStatus })} - - - {' '} - {format(new Date(docUpdatedAt), dateFormat)} -
-    - - {t('general:edit')} - -
-
- )} {versionsData?.totalDocs > 0 && ( = ({ collection, global }) => { numberOfNeighbors={1} /> {versionsData?.totalDocs > 0 && ( - -
- {(versionsData.page * versionsData.limit) - (versionsData.limit - 1)} - - - {versionsData.totalPages > 1 && versionsData.totalPages !== versionsData.page ? (versionsData.limit * versionsData.page) : versionsData.totalDocs} - {' '} - {t('of')} - {' '} - {versionsData.totalDocs} -
- -
- )} + +
+ {(versionsData.page * versionsData.limit) - (versionsData.limit - 1)} + - + {versionsData.totalPages > 1 && versionsData.totalPages !== versionsData.page ? (versionsData.limit * versionsData.page) : versionsData.totalDocs} + {' '} + {t('of')} + {' '} + {versionsData.totalDocs} +
+ +
+ )} )} diff --git a/src/auth/operations/local/forgotPassword.ts b/src/auth/operations/local/forgotPassword.ts index 32735cd207..9b66ab8219 100644 --- a/src/auth/operations/local/forgotPassword.ts +++ b/src/auth/operations/local/forgotPassword.ts @@ -3,6 +3,7 @@ import forgotPassword, { Result } from '../forgotPassword'; import { Payload } from '../../../payload'; import { getDataLoader } from '../../../collections/dataloader'; import i18n from '../../../translations/init'; +import { APIError } from '../../../errors'; export type Options = { collection: string @@ -25,6 +26,10 @@ async function localForgotPassword(payload: Payload, options: Options): Promise< const collection = payload.collections[collectionSlug]; + if (!collection) { + throw new APIError(`The collection with slug ${collectionSlug} can't be found.`); + } + req.payloadAPI = 'local'; req.i18n = i18n(payload.config.i18n); diff --git a/src/auth/operations/local/login.ts b/src/auth/operations/local/login.ts index 3b891af8c8..23c6e90370 100644 --- a/src/auth/operations/local/login.ts +++ b/src/auth/operations/local/login.ts @@ -5,6 +5,7 @@ import { TypeWithID } from '../../../collections/config/types'; import { Payload } from '../../../payload'; import { getDataLoader } from '../../../collections/dataloader'; import i18n from '../../../translations/init'; +import { APIError } from '../../../errors'; export type Options = { collection: string @@ -36,6 +37,10 @@ async function localLogin(payload: Payload, options: const collection = payload.collections[collectionSlug]; + if (!collection) { + throw new APIError(`The collection with slug ${collectionSlug} can't be found.`); + } + req.payloadAPI = 'local'; req.payload = payload; req.i18n = i18n(payload.config.i18n); diff --git a/src/auth/operations/local/resetPassword.ts b/src/auth/operations/local/resetPassword.ts index 99434ad3e4..878ac42f56 100644 --- a/src/auth/operations/local/resetPassword.ts +++ b/src/auth/operations/local/resetPassword.ts @@ -3,6 +3,7 @@ import resetPassword, { Result } from '../resetPassword'; import { PayloadRequest } from '../../../express/types'; import { getDataLoader } from '../../../collections/dataloader'; import i18n from '../../../translations/init'; +import { APIError } from '../../../errors'; export type Options = { collection: string @@ -24,6 +25,10 @@ async function localResetPassword(payload: Payload, options: Options): Promise const collection = payload.collections[collectionSlug]; + if (!collection) { + throw new APIError(`The collection with slug ${collectionSlug} can't be found.`); + } + req.payload = payload; req.payloadAPI = 'local'; req.i18n = i18n(payload.config.i18n); diff --git a/src/auth/operations/local/verifyEmail.ts b/src/auth/operations/local/verifyEmail.ts index 371cc184da..b6f220b70d 100644 --- a/src/auth/operations/local/verifyEmail.ts +++ b/src/auth/operations/local/verifyEmail.ts @@ -1,3 +1,4 @@ +import { APIError } from '../../../errors'; import { Payload } from '../../../payload'; import verifyEmail from '../verifyEmail'; @@ -14,6 +15,10 @@ async function localVerifyEmail(payload: Payload, options: Options): Promise = { collection: Collection @@ -27,6 +28,7 @@ export type Arguments data: Omit overwriteExistingFiles?: boolean draft?: boolean + autosave?: boolean } async function create( @@ -67,6 +69,7 @@ async function create( showHiddenFields, overwriteExistingFiles = false, draft = false, + autosave = false, } = args; let { data } = args; @@ -215,6 +218,23 @@ async function create( result = JSON.parse(result); result = sanitizeInternalFields(result); + // ///////////////////////////////////// + // Create version + // ///////////////////////////////////// + + if (collectionConfig.versions) { + await saveVersion({ + payload, + collection: collectionConfig, + req, + id: result.id, + docWithLocales: result, + autosave, + createdAt: result.createdAt, + onCreate: true, + }); + } + // ///////////////////////////////////// // Send verification email if applicable // ///////////////////////////////////// diff --git a/src/collections/operations/find.ts b/src/collections/operations/find.ts index 10195753b5..2f91afaf0e 100644 --- a/src/collections/operations/find.ts +++ b/src/collections/operations/find.ts @@ -9,7 +9,7 @@ import flattenWhereConstraints from '../../utilities/flattenWhereConstraints'; import { buildSortParam } from '../../mongoose/buildSortParam'; import { AccessResult } from '../../config/types'; import { afterRead } from '../../fields/hooks/afterRead'; -import { mergeDrafts } from '../../versions/drafts/mergeDrafts'; +import { queryDrafts } from '../../versions/drafts/queryDrafts'; export type Arguments = { collection: Collection @@ -162,13 +162,12 @@ async function find>( }; if (collectionConfig.versions?.drafts && draftsEnabled) { - result = await mergeDrafts({ + result = await queryDrafts({ accessResult, collection, locale, paginationOptions, payload, - query, where, }); } else { diff --git a/src/collections/operations/local/create.ts b/src/collections/operations/local/create.ts index dd1f9499d5..cd12b9ba31 100644 --- a/src/collections/operations/local/create.ts +++ b/src/collections/operations/local/create.ts @@ -8,6 +8,7 @@ import create from '../create'; import { getDataLoader } from '../../dataloader'; import { File } from '../../../uploads/types'; import i18n from '../../../translations/init'; +import { APIError } from '../../../errors'; export type Options = { collection: TSlug @@ -50,6 +51,10 @@ export default async function createLocal = { collection: T @@ -35,6 +36,11 @@ export default async function deleteLocal = { collection: T @@ -53,6 +54,10 @@ export default async function findLocal = { collection: T @@ -43,6 +44,10 @@ export default async function findByIDLocal const collection = payload.collections[collectionSlug]; const defaultLocale = payload?.config?.localization ? payload?.config?.localization?.defaultLocale : null; + if (!collection) { + throw new APIError(`The collection with slug ${collectionSlug} can't be found.`); + } + req.payloadAPI = 'local'; req.locale = locale ?? req?.locale ?? defaultLocale; req.fallbackLocale = fallbackLocale ?? req?.fallbackLocale ?? defaultLocale; diff --git a/src/collections/operations/local/findVersions.ts b/src/collections/operations/local/findVersions.ts index 0f688566c3..8b0c6a3411 100644 --- a/src/collections/operations/local/findVersions.ts +++ b/src/collections/operations/local/findVersions.ts @@ -6,6 +6,7 @@ import { PayloadRequest } from '../../../express/types'; import findVersions from '../findVersions'; import { getDataLoader } from '../../dataloader'; import i18nInit from '../../../translations/init'; +import { APIError } from '../../../errors'; export type Options = { collection: string @@ -39,6 +40,10 @@ export default async function findVersionsLocal = a const collection = payload.collections[collectionSlug]; const defaultLocale = payload?.config?.localization ? payload?.config?.localization?.defaultLocale : null; + if (!collection) { + throw new APIError(`The collection with slug ${collectionSlug} can't be found.`); + } + const i18n = i18nInit(payload.config.i18n); const req = { user, diff --git a/src/collections/operations/local/restoreVersion.ts b/src/collections/operations/local/restoreVersion.ts index 11d410801f..81a0411318 100644 --- a/src/collections/operations/local/restoreVersion.ts +++ b/src/collections/operations/local/restoreVersion.ts @@ -5,6 +5,7 @@ import { TypeWithVersion } from '../../../versions/types'; import { getDataLoader } from '../../dataloader'; import restoreVersion from '../restoreVersion'; import i18nInit from '../../../translations/init'; +import { APIError } from '../../../errors'; export type Options = { collection: string @@ -30,6 +31,11 @@ export default async function restoreVersionLocal = } = options; const collection = payload.collections[collectionSlug]; + + if (!collection) { + throw new APIError(`The collection with slug ${collectionSlug} can't be found.`); + } + const i18n = i18nInit(payload.config.i18n); const req = { user, diff --git a/src/collections/operations/local/update.ts b/src/collections/operations/local/update.ts index e5015101c9..424f761b69 100644 --- a/src/collections/operations/local/update.ts +++ b/src/collections/operations/local/update.ts @@ -7,6 +7,7 @@ import { PayloadRequest } from '../../../express/types'; import { getDataLoader } from '../../dataloader'; import { File } from '../../../uploads/types'; import i18nInit from '../../../translations/init'; +import { APIError } from '../../../errors'; export type Options = { collection: TSlug @@ -47,6 +48,11 @@ export default async function updateLocal( delete result.password; } - // ///////////////////////////////////// - // Create version from existing doc - // ///////////////////////////////////// - - let createdVersion; - - if (collectionConfig.versions && !shouldSaveDraft) { - createdVersion = await saveCollectionVersion({ - payload, - config: collectionConfig, - req, - docWithLocales, - id, - }); - } - // ///////////////////////////////////// // Update // ///////////////////////////////////// - if (shouldSaveDraft) { - await ensurePublishedCollectionVersion({ - payload, - config: collectionConfig, - req, - docWithLocales, - id, - }); - - result = await saveCollectionDraft({ - payload, - config: collectionConfig, - req, - data: result, - id, - autosave, - }); - } else { + if (!shouldSaveDraft) { try { result = await Model.findByIdAndUpdate( { _id: id }, @@ -269,23 +233,33 @@ async function update( { new: true }, ); } catch (error) { - cleanUpFailedVersion({ - payload, - entityConfig: collectionConfig, - version: createdVersion, - }); - // Handle uniqueness error from MongoDB throw error.code === 11000 && error.keyValue ? new ValidationError([{ message: 'Value must be unique', field: Object.keys(error.keyValue)[0] }], t) : error; } - - const resultString = JSON.stringify(result); - result = JSON.parse(resultString); } - result = sanitizeInternalFields(result); + result = JSON.parse(JSON.stringify(result)); + result.id = result._id as string | number; + result = sanitizeInternalFields(result); + + // ///////////////////////////////////// + // Create version + // ///////////////////////////////////// + + if (collectionConfig.versions) { + result = await saveVersion({ + payload, + collection: collectionConfig, + req, + docWithLocales: result, + id, + autosave, + draft: shouldSaveDraft, + createdAt: originalDoc.createdAt, + }); + } // ///////////////////////////////////// // afterRead - Fields diff --git a/src/collections/requestHandlers/create.ts b/src/collections/requestHandlers/create.ts index 29b23aff6f..fc30fddc29 100644 --- a/src/collections/requestHandlers/create.ts +++ b/src/collections/requestHandlers/create.ts @@ -13,12 +13,16 @@ export type CreateResult = { export default async function createHandler(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { try { + const autosave = req.query.autosave === 'true'; + const draft = req.query.draft === 'true'; + const doc = await create({ req, collection: req.collection, data: req.body, depth: Number(req.query.depth), - draft: req.query.draft === 'true', + draft, + autosave, }); return res.status(httpStatus.CREATED).json({ diff --git a/src/globals/initLocal.ts b/src/globals/initLocal.ts index 15175a891e..0cbf9ae04c 100644 --- a/src/globals/initLocal.ts +++ b/src/globals/initLocal.ts @@ -26,7 +26,7 @@ export default function initGlobalsLocal(ctx: Payload): void { disableUnique: true, draftsEnabled: true, options: { - timestamps: true, + timestamps: false, }, }, ); diff --git a/src/globals/operations/local/findOne.ts b/src/globals/operations/local/findOne.ts index 532de0068f..aed4954ffb 100644 --- a/src/globals/operations/local/findOne.ts +++ b/src/globals/operations/local/findOne.ts @@ -5,6 +5,7 @@ import { PayloadRequest } from '../../../express/types'; import { Document } from '../../../types'; import findOne from '../findOne'; import i18nInit from '../../../translations/init'; +import { APIError } from '../../../errors'; export type Options = { slug: T @@ -35,6 +36,11 @@ export default async function findOneLocal config.slug === globalSlug); const i18n = i18nInit(payload.config.i18n); + + if (!globalConfig) { + throw new APIError(`The global with slug ${globalSlug} can't be found.`); + } + const req = { user, payloadAPI: 'local', diff --git a/src/globals/operations/local/findVersionByID.ts b/src/globals/operations/local/findVersionByID.ts index ba37edadda..5124d2b958 100644 --- a/src/globals/operations/local/findVersionByID.ts +++ b/src/globals/operations/local/findVersionByID.ts @@ -5,6 +5,7 @@ import { Document } from '../../../types'; import { TypeWithVersion } from '../../../versions/types'; import findVersionByID from '../findVersionByID'; import i18nInit from '../../../translations/init'; +import { APIError } from '../../../errors'; export type Options = { slug: string @@ -34,6 +35,10 @@ export default async function findVersionByIDLocal const globalConfig = payload.globals.config.find((config) => config.slug === globalSlug); const i18n = i18nInit(payload.config.i18n); + if (!globalConfig) { + throw new APIError(`The global with slug ${globalSlug} can't be found.`); + } + const req = { user, payloadAPI: 'local', diff --git a/src/globals/operations/local/findVersions.ts b/src/globals/operations/local/findVersions.ts index 504a38f22e..af90e919a7 100644 --- a/src/globals/operations/local/findVersions.ts +++ b/src/globals/operations/local/findVersions.ts @@ -6,6 +6,7 @@ import { PayloadRequest } from '../../../express/types'; import findVersions from '../findVersions'; import { getDataLoader } from '../../../collections/dataloader'; import i18nInit from '../../../translations/init'; +import { APIError } from '../../../errors'; export type Options = { slug: string @@ -39,6 +40,10 @@ export default async function findVersionsLocal = a const globalConfig = payload.globals.config.find((config) => config.slug === globalSlug); const i18n = i18nInit(payload.config.i18n); + if (!globalConfig) { + throw new APIError(`The global with slug ${globalSlug} can't be found.`); + } + const req = { user, payloadAPI: 'local', diff --git a/src/globals/operations/local/restoreVersion.ts b/src/globals/operations/local/restoreVersion.ts index de2917d6f1..0b70d499de 100644 --- a/src/globals/operations/local/restoreVersion.ts +++ b/src/globals/operations/local/restoreVersion.ts @@ -5,6 +5,7 @@ import { Document } from '../../../types'; import { TypeWithVersion } from '../../../versions/types'; import restoreVersion from '../restoreVersion'; import i18nInit from '../../../translations/init'; +import { APIError } from '../../../errors'; export type Options = { slug: string @@ -32,6 +33,10 @@ export default async function restoreVersionLocal = const globalConfig = payload.globals.config.find((config) => config.slug === globalSlug); const i18n = i18nInit(payload.config.i18n); + if (!globalConfig) { + throw new APIError(`The global with slug ${globalSlug} can't be found.`); + } + const req = { user, payloadAPI: 'local', diff --git a/src/globals/operations/local/update.ts b/src/globals/operations/local/update.ts index ca155a2e68..11bd9cf011 100644 --- a/src/globals/operations/local/update.ts +++ b/src/globals/operations/local/update.ts @@ -5,6 +5,7 @@ import { TypeWithID } from '../../config/types'; import update from '../update'; import { getDataLoader } from '../../../collections/dataloader'; import i18nInit from '../../../translations/init'; +import { APIError } from '../../../errors'; export type Options = { slug: string @@ -34,6 +35,10 @@ export default async function updateLocal(payload: P const globalConfig = payload.globals.config.find((config) => config.slug === globalSlug); const i18n = i18nInit(payload.config.i18n); + if (!globalConfig) { + throw new APIError(`The global with slug ${globalSlug} can't be found.`); + } + const req = { user, payloadAPI: 'local', diff --git a/src/globals/operations/update.ts b/src/globals/operations/update.ts index 7847effac7..aed1e05d93 100644 --- a/src/globals/operations/update.ts +++ b/src/globals/operations/update.ts @@ -1,17 +1,14 @@ import { docHasTimestamps, Where } from '../../types'; import { SanitizedGlobalConfig, TypeWithID } from '../config/types'; import executeAccess from '../../auth/executeAccess'; -import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; -import { saveGlobalVersion } from '../../versions/saveGlobalVersion'; -import { saveGlobalDraft } from '../../versions/drafts/saveGlobalDraft'; -import { ensurePublishedGlobalVersion } from '../../versions/ensurePublishedGlobalVersion'; -import cleanUpFailedVersion from '../../versions/cleanUpFailedVersion'; import { hasWhereAccessResult } from '../../auth'; import { beforeChange } from '../../fields/hooks/beforeChange'; import { beforeValidate } from '../../fields/hooks/beforeValidate'; import { afterChange } from '../../fields/hooks/afterChange'; import { afterRead } from '../../fields/hooks/afterRead'; import { PayloadRequest } from '../../express/types'; +import { saveVersion } from '../../versions/saveVersion'; +import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; type Args = { globalConfig: SanitizedGlobalConfig @@ -182,57 +179,20 @@ async function update(args: Args): Promise { skipValidation: shouldSaveDraft, }); - // ///////////////////////////////////// - // Create version from existing doc - // ///////////////////////////////////// - - let createdVersion; - - if (globalConfig.versions && !shouldSaveDraft) { - createdVersion = await saveGlobalVersion({ - payload, - config: globalConfig, - req, - docWithLocales: result, - }); - } - // ///////////////////////////////////// // Update // ///////////////////////////////////// - if (shouldSaveDraft) { - await ensurePublishedGlobalVersion({ - payload, - config: globalConfig, - req, - docWithLocales: result, - }); - - global = await saveGlobalDraft({ - payload, - config: globalConfig, - data: result, - autosave, - }); - } else { - try { - if (existingGlobal) { - global = await Model.findOneAndUpdate( - { globalType: slug }, - result, - { new: true }, - ); - } else { - result.globalType = slug; - global = await Model.create(result); - } - } catch (error) { - cleanUpFailedVersion({ - payload, - entityConfig: globalConfig, - version: createdVersion, - }); + if (!shouldSaveDraft) { + if (existingGlobal) { + global = await Model.findOneAndUpdate( + { globalType: slug }, + result, + { new: true }, + ); + } else { + result.globalType = slug; + global = await Model.create(result); } } @@ -240,6 +200,22 @@ async function update(args: Args): Promise { global = JSON.parse(global); global = sanitizeInternalFields(global); + // ///////////////////////////////////// + // Create version + // ///////////////////////////////////// + + if (globalConfig.versions) { + global = await saveVersion({ + payload, + global: globalConfig, + req, + docWithLocales: result, + autosave, + draft: shouldSaveDraft, + createdAt: global.createdAt, + }); + } + // ///////////////////////////////////// // afterRead - Fields // ///////////////////////////////////// diff --git a/src/versions/buildCollectionFields.ts b/src/versions/buildCollectionFields.ts index ff6b552758..69395e23e1 100644 --- a/src/versions/buildCollectionFields.ts +++ b/src/versions/buildCollectionFields.ts @@ -14,6 +14,20 @@ export const buildVersionCollectionFields = (collection: SanitizedCollectionConf type: 'group', fields: collection.fields, }, + { + name: 'createdAt', + type: 'date', + admin: { + disabled: true, + }, + }, + { + name: 'updatedAt', + type: 'date', + admin: { + disabled: true, + }, + }, ]; if (collection?.versions?.drafts && collection?.versions?.drafts?.autosave) { diff --git a/src/versions/buildGlobalFields.ts b/src/versions/buildGlobalFields.ts index 23102fec93..8afb8723c4 100644 --- a/src/versions/buildGlobalFields.ts +++ b/src/versions/buildGlobalFields.ts @@ -8,6 +8,20 @@ export const buildVersionGlobalFields = (global: SanitizedGlobalConfig): Field[] type: 'group', fields: global.fields, }, + { + name: 'createdAt', + type: 'date', + admin: { + disabled: true, + }, + }, + { + name: 'updatedAt', + type: 'date', + admin: { + disabled: true, + }, + }, ]; if (global?.versions?.drafts && global?.versions?.drafts?.autosave) { diff --git a/src/versions/cleanUpFailedVersion.ts b/src/versions/cleanUpFailedVersion.ts deleted file mode 100644 index 83746ae398..0000000000 --- a/src/versions/cleanUpFailedVersion.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Payload } from '../payload'; -import { SanitizedCollectionConfig } from '../collections/config/types'; -import { SanitizedGlobalConfig } from '../globals/config/types'; -import { TypeWithVersion } from './types'; - -type Args = { - payload: Payload, - entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig, - version: TypeWithVersion -} - -const cleanUpFailedVersion = (args: Args) => { - const { payload, entityConfig, version } = args; - - if (version) { - const VersionModel = payload.versions[entityConfig.slug]; - VersionModel.findOneAndDelete({ _id: version.id }); - } -}; - -export default cleanUpFailedVersion; diff --git a/src/versions/drafts/mergeDrafts.ts b/src/versions/drafts/mergeDrafts.ts deleted file mode 100644 index 1951514105..0000000000 --- a/src/versions/drafts/mergeDrafts.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { AccessResult } from '../../config/types'; -import { Where } from '../../types'; -import { Payload } from '../../payload'; -import { PaginatedDocs } from '../../mongoose/types'; -import { Collection, CollectionModel } from '../../collections/config/types'; -import { hasWhereAccessResult } from '../../auth'; -import { appendVersionToQueryKey } from './appendVersionToQueryKey'; -import replaceWithDraftIfAvailable from './replaceWithDraftIfAvailable'; - -type AggregateVersion = { - _id: string - version: T - updatedAt: string - createdAt: string -} - -type VersionCollectionMatchMap = { - [_id: string | number]: { - updatedAt: string - createdAt: string - version: T - } -} - -type Args = { - accessResult: AccessResult - collection: Collection - locale: string - paginationOptions: any - payload: Payload - query: Record - where: Where -} - -export const mergeDrafts = async >({ - accessResult, - collection, - locale, - payload, - paginationOptions, - query, - where: incomingWhere, -}: Args): Promise> => { - // Query the main collection for any IDs that match the query - // Create object "map" for performant lookup - const mainCollectionMatchMap = await collection.Model.find(query, { updatedAt: 1 }, { limit: paginationOptions.limit, sort: paginationOptions.sort }) - .lean().then((res) => res.reduce((map, { _id, updatedAt }) => { - const newMap = map; - newMap[_id] = updatedAt; - return newMap; - }, {})); - - // Query the versions collection with a version-specific query - const VersionModel = payload.versions[collection.config.slug] as CollectionModel; - - const where = appendVersionToQueryKey(incomingWhere || {}); - - const versionQueryToBuild: { where: Where } = { - where: { - ...where, - and: [ - ...where?.and || [], - { - 'version._status': { - equals: 'draft', - }, - }, - ], - }, - }; - - if (hasWhereAccessResult(accessResult)) { - const versionAccessResult = appendVersionToQueryKey(accessResult); - versionQueryToBuild.where.and.push(versionAccessResult); - } - - const versionQuery = await VersionModel.buildQuery(versionQueryToBuild, locale); - const includedParentIDs: (string | number)[] = []; - - // Create version "map" for performant lookup - // and in the same loop, check if there are matched versions without a matched parent - // This means that the newer version's parent should appear in the main query. - // To do so, add the version's parent ID into an explicit `includedIDs` array - const versionCollectionMatchMap = await VersionModel.aggregate>([ - { - $sort: Object.entries(paginationOptions.sort).reduce((sort, [key, order]) => { - return { - ...sort, - [key]: order === 'asc' ? 1 : -1, - }; - }, {}), - }, - { - $group: { - _id: '$parent', - versionID: { $first: '$_id' }, - version: { $first: '$version' }, - updatedAt: { $first: '$updatedAt' }, - createdAt: { $first: '$createdAt' }, - }, - }, - { - $addFields: { - id: { - $toObjectId: '$_id', - }, - }, - }, - { - $lookup: { - from: collection.config.slug, - localField: 'id', - foreignField: '_id', - as: 'parent', - }, - }, - { - $match: { - parent: { - $size: 1, - }, - }, - }, - { $match: versionQuery }, - { $limit: paginationOptions.limit }, - ]).then((res) => res.reduce>((map, { _id, updatedAt, createdAt, version }) => { - const newMap = map; - newMap[_id] = { version, updatedAt, createdAt }; - - const matchedParent = mainCollectionMatchMap[_id]; - if (!matchedParent) includedParentIDs.push(_id); - return newMap; - }, {})); - - // Now we need to explicitly exclude any parent matches that have newer versions - // which did NOT appear in the versions query - const excludedParentIDs = await Promise.all(Object.entries(mainCollectionMatchMap).map(async ([parentDocID, parentDocUpdatedAt]) => { - // If there is a matched version, and it's newer, this parent should remain - if (versionCollectionMatchMap[parentDocID] && versionCollectionMatchMap[parentDocID].updatedAt > parentDocUpdatedAt) { - return null; - } - - // Otherwise, we need to check if there are newer versions present - // that did not get returned from the versions query - const versionsQuery = await VersionModel.find({ - updatedAt: { - $gt: parentDocUpdatedAt, - }, - parent: { - $eq: parentDocID, - }, - }, {}, { limit: 1 }).lean(); - - // If there are, - // this says that the newest version does not match the incoming query, - // and the parent ID should be excluded - if (versionsQuery.length > 0) { - return parentDocID; - } - - return null; - })).then((res) => res.filter((result) => Boolean(result))); - - // Run a final query against the main collection, - // passing in any ids to exclude and include - // so that they appear properly paginated - const finalQueryToBuild: { where: Where } = { - where: { - and: [], - }, - }; - - finalQueryToBuild.where.and.push({ or: [] }); - - if (hasWhereAccessResult(accessResult)) { - finalQueryToBuild.where.and.push(accessResult); - } - - if (where) { - finalQueryToBuild.where.and[0].or.push(where); - } - - if (includedParentIDs.length > 0) { - finalQueryToBuild.where.and[0].or.push({ - id: { - in: includedParentIDs, - }, - }); - } - - if (excludedParentIDs.length > 0) { - finalQueryToBuild.where.and[0].or.push({ - id: { - not_in: excludedParentIDs, - }, - }); - } - - const finalQuery = await collection.Model.buildQuery(finalQueryToBuild, locale); - - let result = await collection.Model.paginate(finalQuery, paginationOptions); - - result = { - ...result, - docs: await Promise.all(result.docs.map(async (doc) => { - const matchedVersion = versionCollectionMatchMap[doc.id]; - - if (matchedVersion && matchedVersion.updatedAt > doc.updatedAt) { - return { - ...doc, - ...matchedVersion.version, - createdAt: matchedVersion.createdAt, - updatedAt: matchedVersion.updatedAt, - }; - } - - return replaceWithDraftIfAvailable({ - accessResult, - payload, - entity: collection.config, - entityType: 'collection', - doc, - locale, - }); - })), - }; - - return result; -}; diff --git a/src/versions/drafts/queryDrafts.ts b/src/versions/drafts/queryDrafts.ts new file mode 100644 index 0000000000..437d698ff7 --- /dev/null +++ b/src/versions/drafts/queryDrafts.ts @@ -0,0 +1,91 @@ +import { AccessResult } from '../../config/types'; +import { Where } from '../../types'; +import { Payload } from '../..'; +import { PaginatedDocs } from '../../mongoose/types'; +import { Collection, CollectionModel, TypeWithID } from '../../collections/config/types'; +import { hasWhereAccessResult } from '../../auth'; +import { appendVersionToQueryKey } from './appendVersionToQueryKey'; + +type AggregateVersion = { + _id: string + version: T + updatedAt: string + createdAt: string +} + +type Args = { + accessResult: AccessResult + collection: Collection + locale: string + paginationOptions: any + payload: Payload + where: Where +} + +export const queryDrafts = async ({ + accessResult, + collection, + locale, + payload, + paginationOptions, + where: incomingWhere, +}: Args): Promise> => { + const VersionModel = payload.versions[collection.config.slug] as CollectionModel; + + const where = appendVersionToQueryKey(incomingWhere || {}); + + const versionQueryToBuild: { where: Where } = { + where: { + ...where, + and: [ + ...where?.and || [], + ], + }, + }; + + if (hasWhereAccessResult(accessResult)) { + const versionAccessResult = appendVersionToQueryKey(accessResult); + versionQueryToBuild.where.and.push(versionAccessResult); + } + + const versionQuery = await VersionModel.buildQuery(versionQueryToBuild, locale); + + const aggregate = VersionModel.aggregate>([ + // Sort so that newest are first + { $sort: { updatedAt: -1 } }, + // Group by parent ID, and take the first of each + { + $group: { + _id: '$parent', + version: { $first: '$version' }, + updatedAt: { $first: '$updatedAt' }, + createdAt: { $first: '$createdAt' }, + }, + }, + // Filter based on incoming query + { $match: versionQuery }, + // Re-sort based on incoming sort + { + $sort: Object.entries(paginationOptions.sort).reduce((sort, [key, order]) => { + return { + ...sort, + [key]: order === 'asc' ? 1 : -1, + }; + }, {}), + }, + // Add pagination limit + { $limit: paginationOptions.limit }, + ]); + + const result = await VersionModel.aggregatePaginate(aggregate, paginationOptions); + + return { + ...result, + docs: result.docs.map((doc) => ({ + _id: doc._id, + ...doc.version, + updatedAt: doc.updatedAt, + createdAt: doc.createdAt, + })), + }; +}; diff --git a/src/versions/drafts/replaceWithDraftIfAvailable.ts b/src/versions/drafts/replaceWithDraftIfAvailable.ts index c133c404ef..861bb0d4af 100644 --- a/src/versions/drafts/replaceWithDraftIfAvailable.ts +++ b/src/versions/drafts/replaceWithDraftIfAvailable.ts @@ -3,7 +3,6 @@ import { docHasTimestamps, Where } from '../../types'; import { hasWhereAccessResult } from '../../auth'; import { AccessResult } from '../../config/types'; import { CollectionModel, SanitizedCollectionConfig, TypeWithID } from '../../collections/config/types'; -import flattenWhereConstraints from '../../utilities/flattenWhereConstraints'; import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; import { appendVersionToQueryKey } from './appendVersionToQueryKey'; import { SanitizedGlobalConfig } from '../../globals/config/types'; diff --git a/src/versions/drafts/saveCollectionDraft.ts b/src/versions/drafts/saveCollectionDraft.ts deleted file mode 100644 index e7fc48bfcc..0000000000 --- a/src/versions/drafts/saveCollectionDraft.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Payload } from '../../payload'; -import { SanitizedCollectionConfig } from '../../collections/config/types'; -import { enforceMaxVersions } from '../enforceMaxVersions'; -import { PayloadRequest } from '../../express/types'; - -type Args = { - payload: Payload - config?: SanitizedCollectionConfig - req: PayloadRequest - data: T - id: string | number - autosave: boolean -} - -export const saveCollectionDraft = async >({ - payload, - config, - id, - data, - autosave, -}: Args): Promise => { - const VersionsModel = payload.versions[config.slug]; - - const dataAsDraft = { ...data, _status: 'draft' }; - - let existingAutosaveVersion; - - if (autosave) { - existingAutosaveVersion = await VersionsModel.findOne({ - parent: id, - }, {}, { sort: { updatedAt: 'desc' } }); - } - - let result; - - try { - // If there is an existing autosave document, - // Update it - if (autosave && existingAutosaveVersion?.autosave === true) { - result = await VersionsModel.findByIdAndUpdate( - { - _id: existingAutosaveVersion._id, - }, - { - version: dataAsDraft, - }, - { new: true, lean: true }, - ); - // Otherwise, create a new one - } else { - result = await VersionsModel.create({ - parent: id, - version: dataAsDraft, - autosave: Boolean(autosave), - }); - } - } catch (err) { - payload.logger.error(`There was an error while creating a draft ${config.labels.singular} with ID ${id}.`); - payload.logger.error(err); - } - - if (config.versions.maxPerDoc) { - enforceMaxVersions({ - id, - payload, - Model: VersionsModel, - slug: config.slug, - entityType: 'collection', - max: config.versions.maxPerDoc, - }); - } - - result = result.version; - result = JSON.stringify(result); - result = JSON.parse(result); - result.id = id; - - return result; -}; diff --git a/src/versions/drafts/saveGlobalDraft.ts b/src/versions/drafts/saveGlobalDraft.ts deleted file mode 100644 index 4d20d2fb87..0000000000 --- a/src/versions/drafts/saveGlobalDraft.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Payload } from '../../payload'; -import { enforceMaxVersions } from '../enforceMaxVersions'; -import { SanitizedGlobalConfig } from '../../globals/config/types'; - -type Args = { - payload: Payload - config?: SanitizedGlobalConfig - data: any - autosave: boolean -} - -export const saveGlobalDraft = async ({ - payload, - config, - data, - autosave, -}: Args): Promise => { - const VersionsModel = payload.versions[config.slug]; - - const dataAsDraft = { ...data, _status: 'draft' }; - - let existingAutosaveVersion; - - if (autosave) { - existingAutosaveVersion = await VersionsModel.findOne(); - } - - let result; - - try { - // If there is an existing autosave document, - // Update it - if (autosave && existingAutosaveVersion?.autosave === true) { - result = await VersionsModel.findByIdAndUpdate( - { - _id: existingAutosaveVersion._id, - }, - { - version: dataAsDraft, - }, - { new: true, lean: true }, - ); - // Otherwise, create a new one - } else { - result = await VersionsModel.create({ - version: dataAsDraft, - autosave: Boolean(autosave), - }); - } - } catch (err) { - payload.logger.error(`There was an error while saving a draft for the Global ${config.slug}.`); - payload.logger.error(err); - } - - if (config.versions.max) { - enforceMaxVersions({ - payload: this, - Model: VersionsModel, - slug: config.slug, - entityType: 'global', - max: config.versions.max, - }); - } - - result = result.version; - result = JSON.stringify(result); - result = JSON.parse(result); - - return result; -}; diff --git a/src/versions/ensurePublishedCollectionVersion.ts b/src/versions/ensurePublishedCollectionVersion.ts deleted file mode 100644 index d8b92c6417..0000000000 --- a/src/versions/ensurePublishedCollectionVersion.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Payload } from '../payload'; -import { SanitizedCollectionConfig } from '../collections/config/types'; -import { enforceMaxVersions } from './enforceMaxVersions'; -import { PayloadRequest } from '../express/types'; -import { afterRead } from '../fields/hooks/afterRead'; - -type Args = { - payload: Payload - config?: SanitizedCollectionConfig - req: PayloadRequest - docWithLocales: any - id: string | number -} - -export const ensurePublishedCollectionVersion = async ({ - payload, - config, - req, - id, - docWithLocales, -}: Args): Promise => { - // If there are no newer drafts, - // And the current doc is published, - // We need to keep a version of the published document - - if (docWithLocales?._status === 'published') { - const VersionModel = payload.versions[config.slug]; - - const moreRecentDrafts = await VersionModel.find({ - parent: { - $eq: docWithLocales.id, - }, - updatedAt: { - $gt: docWithLocales.updatedAt, - }, - }, - {}, - { - lean: true, - leanWithId: true, - sort: { - updatedAt: 'desc', - }, - }); - - if (moreRecentDrafts?.length === 0) { - const version = await afterRead({ - depth: 0, - doc: docWithLocales, - entityConfig: config, - req, - overrideAccess: true, - showHiddenFields: true, - flattenLocales: false, - }); - - try { - await VersionModel.create({ - parent: id, - version, - autosave: false, - }); - } catch (err) { - payload.logger.error(`There was an error while saving a version for the ${config.slug} with ID ${id}.`); - payload.logger.error(err); - } - - if (config.versions.maxPerDoc) { - enforceMaxVersions({ - id, - payload, - Model: VersionModel, - slug: config.slug, - entityType: 'collection', - max: config.versions.maxPerDoc, - }); - } - } - } -}; diff --git a/src/versions/ensurePublishedGlobalVersion.ts b/src/versions/ensurePublishedGlobalVersion.ts deleted file mode 100644 index 080e691aaa..0000000000 --- a/src/versions/ensurePublishedGlobalVersion.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Payload } from '../payload'; -import { enforceMaxVersions } from './enforceMaxVersions'; -import { PayloadRequest } from '../express/types'; -import { SanitizedGlobalConfig } from '../globals/config/types'; -import { afterRead } from '../fields/hooks/afterRead'; - -type Args = { - payload: Payload - config?: SanitizedGlobalConfig - req: PayloadRequest - docWithLocales: any -} - -export const ensurePublishedGlobalVersion = async ({ - payload, - config, - req, - docWithLocales, -}: Args): Promise => { - // If there are no newer drafts, - // And the current doc is published, - // We need to keep a version of the published document - - if (docWithLocales?._status === 'published') { - const VersionModel = payload.versions[config.slug]; - - const moreRecentDrafts = await VersionModel.find({ - updatedAt: { - $gt: docWithLocales.updatedAt, - }, - }, - {}, - { - lean: true, - leanWithId: true, - sort: { - updatedAt: 'desc', - }, - }); - - if (moreRecentDrafts?.length === 0) { - const version = await afterRead({ - depth: 0, - doc: docWithLocales, - entityConfig: config, - req, - overrideAccess: true, - showHiddenFields: true, - flattenLocales: false, - }); - - try { - await VersionModel.create({ - version, - autosave: false, - }); - } catch (err) { - payload.logger.error(`There was an error while saving a version for the Global ${config.label}.`); - payload.logger.error(err); - } - - if (config.versions.max) { - enforceMaxVersions({ - payload: this, - Model: VersionModel, - slug: config.slug, - entityType: 'global', - max: config.versions.max, - }); - } - } - } -}; diff --git a/src/versions/saveCollectionVersion.ts b/src/versions/saveCollectionVersion.ts deleted file mode 100644 index 5a1f4a0243..0000000000 --- a/src/versions/saveCollectionVersion.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Payload } from '../payload'; -import { SanitizedCollectionConfig } from '../collections/config/types'; -import { enforceMaxVersions } from './enforceMaxVersions'; -import { PayloadRequest } from '../express/types'; -import sanitizeInternalFields from '../utilities/sanitizeInternalFields'; -import { afterRead } from '../fields/hooks/afterRead'; - -type Args = { - payload: Payload - config?: SanitizedCollectionConfig - req: PayloadRequest - docWithLocales: any - id: string | number -} - -export const saveCollectionVersion = async ({ - payload, - config, - req, - id, - docWithLocales, -}: Args): Promise => { - const VersionModel = payload.versions[config.slug]; - - let version = docWithLocales; - - if (config.versions?.drafts) { - const latestVersion = await VersionModel.findOne({ - parent: { - $eq: docWithLocales.id, - }, - updatedAt: { - $gt: docWithLocales.updatedAt, - }, - }, - {}, - { - lean: true, - leanWithId: true, - sort: { - updatedAt: 'desc', - }, - }); - - if (latestVersion) { - // If the latest version is a draft, no need to re-save it - // Example: when "promoting" a draft to published, the draft already exists. - // Instead, return null - if (latestVersion?.version?._status === 'draft') { - return null; - } - - version = latestVersion.version; - version = JSON.parse(JSON.stringify(version)); - version = sanitizeInternalFields(version); - } - } - - version = await afterRead({ - depth: 0, - doc: version, - entityConfig: config, - req, - overrideAccess: true, - showHiddenFields: true, - flattenLocales: false, - }); - - if (version._id) delete version._id; - - let createdVersion; - - try { - createdVersion = await VersionModel.create({ - parent: id, - version, - autosave: false, - }); - } catch (err) { - payload.logger.error(`There was an error while saving a version for the ${config.labels.singular} with ID ${id}.`); - payload.logger.error(err); - } - - if (config.versions.maxPerDoc) { - enforceMaxVersions({ - id, - payload, - Model: VersionModel, - slug: config.slug, - entityType: 'collection', - max: config.versions.maxPerDoc, - }); - } - - if (createdVersion) { - createdVersion = JSON.parse(JSON.stringify(createdVersion)); - createdVersion = sanitizeInternalFields(createdVersion); - } - - return createdVersion; -}; diff --git a/src/versions/saveGlobalVersion.ts b/src/versions/saveGlobalVersion.ts deleted file mode 100644 index 2f6779b46c..0000000000 --- a/src/versions/saveGlobalVersion.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Payload } from '../payload'; -import { enforceMaxVersions } from './enforceMaxVersions'; -import { PayloadRequest } from '../express/types'; -import { SanitizedGlobalConfig } from '../globals/config/types'; -import sanitizeInternalFields from '../utilities/sanitizeInternalFields'; -import { afterRead } from '../fields/hooks/afterRead'; - -type Args = { - payload: Payload - config?: SanitizedGlobalConfig - req: PayloadRequest - docWithLocales: any -} - -export const saveGlobalVersion = async ({ - payload, - config, - req, - docWithLocales, -}: Args): Promise => { - const VersionModel = payload.versions[config.slug]; - - let version = docWithLocales; - - if (config.versions?.drafts) { - const latestVersion = await VersionModel.findOne({ - updatedAt: { - $gt: docWithLocales.updatedAt, - }, - }, - {}, - { - lean: true, - leanWithId: true, - sort: { - updatedAt: 'desc', - }, - }); - - if (latestVersion) { - // If the latest version is a draft, no need to re-save it - // Example: when "promoting" a draft to published, the draft already exists. - // Instead, return null - if (latestVersion?.version?._status === 'draft') { - return null; - } - - version = latestVersion.version; - version = JSON.parse(JSON.stringify(version)); - version = sanitizeInternalFields(version); - } - } - - version = await afterRead({ - depth: 0, - doc: version, - entityConfig: config, - flattenLocales: false, - overrideAccess: true, - req, - showHiddenFields: true, - }); - - if (version._id) delete version._id; - - let createdVersion; - - try { - createdVersion = await VersionModel.create({ - version, - autosave: false, - }); - } catch (err) { - payload.logger.error(`There was an error while saving a version for the Global ${config.slug}.`); - payload.logger.error(err); - } - - if (config.versions.max) { - enforceMaxVersions({ - payload: this, - Model: VersionModel, - slug: config.slug, - entityType: 'global', - max: config.versions.max, - }); - } - - if (createdVersion) { - createdVersion = JSON.parse(JSON.stringify(createdVersion)); - createdVersion = sanitizeInternalFields(createdVersion); - } - - return createdVersion; -}; diff --git a/src/versions/saveVersion.ts b/src/versions/saveVersion.ts new file mode 100644 index 0000000000..a63bb7810c --- /dev/null +++ b/src/versions/saveVersion.ts @@ -0,0 +1,124 @@ +import { FilterQuery } from 'mongoose'; +import { Payload } from '../payload'; +import { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types'; +import { enforceMaxVersions } from './enforceMaxVersions'; +import { PayloadRequest } from '../express/types'; +import { SanitizedGlobalConfig } from '../globals/config/types'; +import sanitizeInternalFields from '../utilities/sanitizeInternalFields'; + +type Args = { + payload: Payload + global?: SanitizedGlobalConfig + collection?: SanitizedCollectionConfig + req: PayloadRequest + docWithLocales: any + id?: string | number + autosave?: boolean + draft?: boolean + createdAt: string + onCreate?: boolean +} + +export const saveVersion = async ({ + payload, + collection, + global, + id, + docWithLocales, + autosave, + draft, + createdAt, + onCreate = false, +}: Args): Promise => { + let entityConfig; + let entityType: 'global' | 'collection'; + + if (collection) { + entityConfig = collection; + entityType = 'collection'; + } + + if (global) { + entityConfig = global; + entityType = 'global'; + } + + const VersionModel = payload.versions[entityConfig.slug]; + + const versionData = { ...docWithLocales }; + if (draft) versionData._status = 'draft'; + if (versionData._id) delete versionData._id; + + let existingAutosaveVersion; + + if (autosave) { + const query: FilterQuery = {}; + if (collection) query.parent = id; + existingAutosaveVersion = await VersionModel.findOne(query, {}, { sort: { updatedAt: 'desc' } }); + } + + let result; + const now = new Date().toISOString(); + + try { + if (autosave && existingAutosaveVersion?.autosave === true) { + const data: Record = { + version: versionData, + createdAt, + updatedAt: now, + }; + + if (createdAt) data.updatedAt = createdAt; + + result = await VersionModel.findByIdAndUpdate( + { + _id: existingAutosaveVersion._id, + }, + data, + { new: true, lean: true }, + ); + // Otherwise, create a new one + } else { + const data: Record = { + version: versionData, + autosave: Boolean(autosave), + updatedAt: onCreate ? createdAt : now, + createdAt: createdAt || now, + }; + + if (collection) data.parent = id; + + result = await VersionModel.create(data); + } + } catch (err) { + let errorMessage: string; + + if (collection) errorMessage = `There was an error while saving a version for the ${collection.labels.singular} with ID ${id}.`; + if (global) errorMessage = `There was an error while saving a version for the global ${global.label}.`; + payload.logger.error(errorMessage); + payload.logger.error(err); + } + + let max: number; + + if (collection && typeof collection.versions.maxPerDoc === 'number') max = collection.versions.maxPerDoc; + if (global && typeof global.versions.max === 'number') max = global.versions.max; + + if (collection && collection.versions.maxPerDoc) { + enforceMaxVersions({ + id, + payload, + Model: VersionModel, + slug: entityConfig.slug, + entityType, + max, + }); + } + + result = result.version; + result = JSON.parse(JSON.stringify(result)); + result = sanitizeInternalFields(result); + result.id = id; + + return result; +}; diff --git a/src/versions/shouldIncrementVersionCount.ts b/src/versions/shouldIncrementVersionCount.ts deleted file mode 100644 index 4839c33430..0000000000 --- a/src/versions/shouldIncrementVersionCount.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SanitizedCollectionConfig } from '../collections/config/types'; -import { SanitizedGlobalConfig } from '../globals/config/types'; -import { PaginatedDocs } from '../mongoose/types'; - -type ShouldIncrementVersionCount = (args: { - entity: SanitizedGlobalConfig | SanitizedCollectionConfig - versions: PaginatedDocs<{ version?: { _status: string} }> - docStatus: string -}) => boolean - -export const shouldIncrementVersionCount: ShouldIncrementVersionCount = ({ entity, docStatus, versions }) => { - return !(entity?.versions?.drafts && entity.versions.drafts?.autosave) - && (docStatus === 'published' || (docStatus === 'draft' && versions?.docs?.[0]?.version?._status !== 'draft')); -}; diff --git a/test/uploads/config.ts b/test/uploads/config.ts index 93cc7ce21b..c4a9f1c0b7 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -39,6 +39,7 @@ export default buildConfig({ upload: { staticURL: '/media', staticDir: './media', + mimeTypes: ['image/png', 'image/jpg', 'image/jpeg', 'image/svg+xml'], resizeOptions: { width: 1280, height: 720, diff --git a/test/versions/collections/Versions.ts b/test/versions/collections/Versions.ts new file mode 100644 index 0000000000..1d38c7ca29 --- /dev/null +++ b/test/versions/collections/Versions.ts @@ -0,0 +1,56 @@ +import type { CollectionConfig } from '../../../src/collections/config/types'; + +const VersionPosts: CollectionConfig = { + slug: 'version-posts', + admin: { + useAsTitle: 'title', + defaultColumns: ['title', 'description', 'createdAt'], + preview: () => 'https://payloadcms.com', + }, + versions: { + drafts: false, + maxPerDoc: 35, + retainDeleted: false, + }, + access: { + read: ({ req: { user } }) => { + if (user) { + return true; + } + + return { + or: [ + { + _status: { + equals: 'published', + }, + }, + { + _status: { + exists: false, + }, + }, + ], + }; + }, + readVersions: ({ req: { user } }) => Boolean(user), + }, + fields: [ + { + name: 'title', + label: 'Title', + type: 'text', + required: true, + unique: true, + localized: true, + }, + { + name: 'description', + label: 'Description', + type: 'textarea', + required: true, + }, + ], +}; + +export default VersionPosts; diff --git a/test/versions/config.ts b/test/versions/config.ts index 7711c9e2b5..9e0b4383d3 100644 --- a/test/versions/config.ts +++ b/test/versions/config.ts @@ -4,11 +4,13 @@ import DraftPosts from './collections/Drafts'; import AutosaveGlobal from './globals/Autosave'; import { devUser } from '../credentials'; import DraftGlobal from './globals/Draft'; +import VersionPosts from './collections/Versions'; export default buildConfig({ collections: [ AutosavePosts, DraftPosts, + VersionPosts, ], globals: [ AutosaveGlobal, diff --git a/test/versions/int.spec.ts b/test/versions/int.spec.ts index b54bad5f9f..d7689797e1 100644 --- a/test/versions/int.spec.ts +++ b/test/versions/int.spec.ts @@ -157,9 +157,14 @@ describe('Versions', () => { const versions = await payload.findVersions({ collection, locale: 'all', + where: { + parent: { + equals: collectionLocalPostID, + }, + }, }); - expect(versions.docs[0].version.title.en).toStrictEqual(englishTitle); + expect(versions.docs[0].version.title.en).toStrictEqual(newEnglishTitle); expect(versions.docs[0].version.title.es).toStrictEqual(spanishTitle); }); }); @@ -184,7 +189,7 @@ describe('Versions', () => { const restore = await payload.restoreVersion({ collection, - id: versions.docs[0].id, + id: versions.docs[1].id, }); expect(restore.title).toBeDefined(); @@ -195,7 +200,7 @@ describe('Versions', () => { draft: true, }); - expect(restoredPost.title).toBe(restore.title); + expect(restoredPost.title).toBe(versions.docs[1].version.title); }); }); @@ -226,13 +231,15 @@ describe('Versions', () => { draft: true, }); + const spanishTitle = 'es title'; + // second update to existing draft await payload.update({ id: collectionLocalPostID, collection, locale: 'es', data: { - title: updatedTitle, + title: spanishTitle, }, draft: true, }); @@ -251,7 +258,62 @@ describe('Versions', () => { expect(publishedPost.title).toBe(originalTitle); expect(draftPost.title.en).toBe(updatedTitle); - expect(draftPost.title.es).toBe(updatedTitle); + expect(draftPost.title.es).toBe(spanishTitle); + }); + }); + + describe('Draft Count', () => { + it('creates proper number of drafts', async () => { + const originalDraft = await payload.create({ + collection: 'draft-posts', + draft: true, + data: { + title: 'A', + description: 'A', + _status: 'draft', + }, + }); + + await payload.update({ + collection: 'draft-posts', + id: originalDraft.id, + draft: true, + data: { + title: 'B', + description: 'B', + _status: 'draft', + }, + }); + + await payload.update({ + collection: 'draft-posts', + id: originalDraft.id, + draft: true, + data: { + title: 'C', + description: 'C', + _status: 'draft', + }, + }); + + const mostRecentDraft = await payload.findByID({ + collection: 'draft-posts', + id: originalDraft.id, + draft: true, + }); + + expect(mostRecentDraft.title).toStrictEqual('C'); + + const versions = await payload.findVersions({ + collection: 'draft-posts', + where: { + parent: { + equals: originalDraft.id, + }, + }, + }); + + expect(versions.docs).toHaveLength(3); }); }); }); @@ -423,7 +485,7 @@ describe('Versions', () => { expect(data.id).toBeDefined(); expect(data.parent.id).toStrictEqual(collectionGraphQLPostID); - expect(data.version.title).toStrictEqual(collectionGraphQLOriginalTitle); + expect(data.version.title).toStrictEqual(updatedTitle); }); it('should allow read of versions by querying version content', async () => { diff --git a/yarn.lock b/yarn.lock index ca47c70840..497ea791f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8514,6 +8514,11 @@ mongodb@^3.7.3: optionalDependencies: saslprep "^1.0.0" +mongoose-aggregate-paginate-v2@^1.0.6: + version "1.0.6" + resolved "https://registry.npmjs.org/mongoose-aggregate-paginate-v2/-/mongoose-aggregate-paginate-v2-1.0.6.tgz#fd2f2564d1bbf52f49a196f0b7b03675913dacca" + integrity sha512-UuALu+mjhQa1K9lMQvjLL3vm3iALvNw8PQNIh2gp1b+tO5hUa0NC0Wf6/8QrT9PSJVTihXaD8hQVy3J4e0jO0Q== + mongoose-paginate-v2@*, mongoose-paginate-v2@^1.6.1: version "1.7.1" resolved "https://registry.yarnpkg.com/mongoose-paginate-v2/-/mongoose-paginate-v2-1.7.1.tgz#0b390f5eb8e5dca55ffcb1fd7b4d8078636cb8f1"