diff --git a/.vscode/launch.json b/.vscode/launch.json index b31b4f0752..8604344c70 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -55,6 +55,7 @@ "/**" ], "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/babel-node", + "outputCapture": "std", "runtimeArgs": [ "--nolazy" ], diff --git a/demo/client/components/AfterDashboard/index.scss b/demo/client/components/AfterDashboard/index.scss index 1b72fd0ba2..1617123d17 100644 --- a/demo/client/components/AfterDashboard/index.scss +++ b/demo/client/components/AfterDashboard/index.scss @@ -6,6 +6,8 @@ // @import '~payload/scss'; .after-dashboard { + margin-top: base(2); + &__cards { list-style: none; margin: 0; diff --git a/demo/client/components/richText/elements/Button/Button/index.tsx b/demo/client/components/richText/elements/Button/Button/index.tsx index ff5a4c3021..412cf83f4c 100644 --- a/demo/client/components/richText/elements/Button/Button/index.tsx +++ b/demo/client/components/richText/elements/Button/Button/index.tsx @@ -3,10 +3,15 @@ import { Modal, useModal } from '@faceless-ui/modal'; import { Transforms } from 'slate'; import { useSlate, ReactEditor } from 'slate-react'; import MinimalTemplate from '../../../../../../../src/admin/components/templates/Minimal'; -import { ElementButton } from '../../../../../../../components/rich-text'; +import ElementButton from '../../../../../../../src/admin/components/forms/field-types/RichText/elements/Button'; import X from '../../../../../../../src/admin/components/icons/X'; import Button from '../../../../../../../src/admin/components/elements/Button'; -import { Form, Text, Checkbox, Select, Submit, reduceFieldsToValues } from '../../../../../../../components/forms'; +import Form from '../../../../../../../src/admin/components/forms/Form'; +import Submit from '../../../../../../../src/admin/components/forms/Submit'; +import reduceFieldsToValues from '../../../../../../../src/admin/components/forms/Form/reduceFieldsToValues'; +import Text from '../../../../../../../src/admin/components/forms/field-types/Text'; +import Checkbox from '../../../../../../../src/admin/components/forms/field-types/Checkbox'; +import Select from '../../../../../../../src/admin/components/forms/field-types/Select'; import './index.scss'; diff --git a/demo/client/components/richText/elements/Button/Element/index.tsx b/demo/client/components/richText/elements/Button/Element/index.tsx index 99c19e1ff3..ab54d63c69 100644 --- a/demo/client/components/richText/elements/Button/Element/index.tsx +++ b/demo/client/components/richText/elements/Button/Element/index.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import './index.scss'; @@ -27,18 +26,4 @@ const ButtonElement: React.FC = ({ attributes, children, element }) => { ); }; -ButtonElement.defaultProps = { - attributes: {}, - children: null, -}; - -ButtonElement.propTypes = { - attributes: PropTypes.shape({}), - children: PropTypes.node, - element: PropTypes.shape({ - style: PropTypes.oneOf(['primary', 'secondary']), - label: PropTypes.string, - }).isRequired, -}; - export default ButtonElement; diff --git a/demo/client/components/richText/elements/Button/index.ts b/demo/client/components/richText/elements/Button/index.ts index 4cde513af1..c7ebab1f6e 100644 --- a/demo/client/components/richText/elements/Button/index.ts +++ b/demo/client/components/richText/elements/Button/index.ts @@ -1,8 +1,9 @@ +import { RichTextCustomElement } from '../../../../../../src/fields/config/types'; import Button from './Button'; import Element from './Element'; import plugin from './plugin'; -export default { +const button: RichTextCustomElement = { name: 'button', Button, Element, @@ -10,3 +11,5 @@ export default { plugin, ], }; + +export default button; diff --git a/demo/client/components/richText/leaves/PurpleBackground/Button/index.tsx b/demo/client/components/richText/leaves/PurpleBackground/Button/index.tsx index 8fe493d632..f28e8f44d6 100644 --- a/demo/client/components/richText/leaves/PurpleBackground/Button/index.tsx +++ b/demo/client/components/richText/leaves/PurpleBackground/Button/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { LeafButton } from '../../../../../../../components/rich-text'; +import LeafButton from '../../../../../../../src/admin/components/forms/field-types/RichText/leaves/Button'; const Button = () => ( diff --git a/demo/client/components/richText/leaves/PurpleBackground/Leaf/index.tsx b/demo/client/components/richText/leaves/PurpleBackground/Leaf/index.tsx index a4b2d72821..17fb88f079 100644 --- a/demo/client/components/richText/leaves/PurpleBackground/Leaf/index.tsx +++ b/demo/client/components/richText/leaves/PurpleBackground/Leaf/index.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; const PurpleBackground: React.FC = ({ attributes, children }) => ( = ({ attributes, children }) => ( ); -PurpleBackground.defaultProps = { - attributes: {}, - children: null, -}; - -PurpleBackground.propTypes = { - attributes: PropTypes.shape({}), - children: PropTypes.node, -}; - export default PurpleBackground; diff --git a/demo/collections/AllFields.ts b/demo/collections/AllFields.ts index c959864f62..e043cec164 100644 --- a/demo/collections/AllFields.ts +++ b/demo/collections/AllFields.ts @@ -28,6 +28,11 @@ const AllFields: CollectionConfig = { }, description: CollectionDescription, }, + versions: { + maxPerDoc: 20, + retainDeleted: true, + drafts: false, + }, access: { read: () => true, }, @@ -136,6 +141,11 @@ const AllFields: CollectionConfig = { }, }, }, + { + name: 'point', + label: 'Point Field (GeoJSON)', + type: 'point', + }, { name: 'radioGroupExample', label: 'Radio Group Example', @@ -284,26 +294,6 @@ const AllFields: CollectionConfig = { type: 'richText', label: 'Rich Text', required: true, - admin: { - elements: [ - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'blockquote', - 'ul', - 'ol', - 'link', - ], - leaves: [ - 'bold', - 'italic', - 'underline', - 'strikethrough', - ], - }, }, { type: 'ui', diff --git a/demo/collections/Autosave.ts b/demo/collections/Autosave.ts new file mode 100644 index 0000000000..459bcbb2b7 --- /dev/null +++ b/demo/collections/Autosave.ts @@ -0,0 +1,69 @@ +import { CollectionConfig } from '../../src/collections/config/types'; + +const Autosave: CollectionConfig = { + slug: 'autosave-posts', + labels: { + singular: 'Autosave Post', + plural: 'Autosave Posts', + }, + admin: { + useAsTitle: 'title', + defaultColumns: [ + 'title', + 'description', + 'createdAt', + ], + preview: () => 'https://payloadcms.com', + }, + versions: { + maxPerDoc: 35, + retainDeleted: false, + drafts: { + autosave: { + interval: 5, + }, + }, + }, + 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, + }, + ], + timestamps: true, +}; + +export default Autosave; diff --git a/demo/collections/Localized.ts b/demo/collections/Localized.ts index d217e2c029..011f2422fe 100644 --- a/demo/collections/Localized.ts +++ b/demo/collections/Localized.ts @@ -3,7 +3,7 @@ import { PayloadRequest } from '../../src/express/types'; import { Block } from '../../src/fields/config/types'; const validateLocalizationTransform = (hook: string, value, req: PayloadRequest) => { - if (req.locale !== 'all' && value !== undefined && typeof value !== 'string') { + if (req.locale !== 'all' && value !== undefined && typeof value !== 'string' && value !== null) { console.error(hook, value); throw new Error('Locale transformation should happen before hook is called'); } diff --git a/demo/collections/RichText.ts b/demo/collections/RichText.ts index f79e061b3b..6eb80609c8 100644 --- a/demo/collections/RichText.ts +++ b/demo/collections/RichText.ts @@ -11,6 +11,14 @@ const RichText: CollectionConfig = { access: { read: () => true, }, + versions: { + drafts: { + autosave: false, + }, + }, + admin: { + preview: () => 'https://payloadcms.com', + }, fields: [ { name: 'defaultRichText', diff --git a/demo/globals/BlocksGlobal.ts b/demo/globals/BlocksGlobal.ts index ddd7066bf1..db5f849427 100644 --- a/demo/globals/BlocksGlobal.ts +++ b/demo/globals/BlocksGlobal.ts @@ -6,6 +6,9 @@ import { GlobalConfig } from '../../src/globals/config/types'; export default { slug: 'blocks-global', label: 'Blocks Global', + revisions: { + max: 20, + }, access: { update: ({ req: { user } }) => checkRole(['admin'], user), read: () => true, diff --git a/demo/payload.config.ts b/demo/payload.config.ts index b984825b80..c4224e5b2f 100644 --- a/demo/payload.config.ts +++ b/demo/payload.config.ts @@ -4,9 +4,10 @@ import { buildConfig } from '../src/config/build'; import Admin from './collections/Admin'; import AllFields from './collections/AllFields'; import AutoLabel from './collections/AutoLabel'; +import Autosave from './collections/Autosave'; import Code from './collections/Code'; import Conditions from './collections/Conditions'; -import CustomComponents from './collections/CustomComponents'; +// import CustomComponents from './collections/CustomComponents'; import File from './collections/File'; import Blocks from './collections/Blocks'; import CustomID from './collections/CustomID'; @@ -89,9 +90,10 @@ export default buildConfig({ Admin, AllFields, AutoLabel, + Autosave, Code, Conditions, - CustomComponents, + // CustomComponents, CustomID, File, DefaultValues, diff --git a/package.json b/package.json index 40108559c6..0c5639fd5b 100644 --- a/package.json +++ b/package.json @@ -155,6 +155,7 @@ "react-animate-height": "^2.0.20", "react-beautiful-dnd": "^13.0.0", "react-datepicker": "^3.3.0", + "react-diff-viewer": "^3.1.1", "react-dom": "^17.0.1", "react-helmet": "^6.1.0", "react-router-dom": "^5.1.2", diff --git a/src/admin/components/Routes.tsx b/src/admin/components/Routes.tsx index 39939a7372..e6f5903fd5 100644 --- a/src/admin/components/Routes.tsx +++ b/src/admin/components/Routes.tsx @@ -9,6 +9,9 @@ import { requests } from '../api'; import Loading from './elements/Loading'; import StayLoggedIn from './modals/StayLoggedIn'; import Unlicensed from './views/Unlicensed'; +import Versions from './views/Versions'; +import Version from './views/Version'; +import { DocumentInfoProvider } from './utilities/DocumentInfo'; const Dashboard = lazy(() => import('./views/Dashboard')); const ForgotPassword = lazy(() => import('./views/ForgotPassword')); @@ -142,89 +145,190 @@ const Routes = () => { - + slug === userSlug)} + id={user.id} + > + + - {collections.map((collection) => ( - { - if (permissions?.collections?.[collection.slug]?.read?.permission) { - return ( - - ); - } + {collections.reduce((collectionRoutes, collection) => { + const routesToReturn = [ + ...collectionRoutes, + { + if (permissions?.collections?.[collection.slug]?.read?.permission) { + return ( + + ); + } - return ; - }} - /> - ))} + return ; + }} + />, + { + if (permissions?.collections?.[collection.slug]?.create?.permission) { + return ( + + + + ); + } - {collections.map((collection) => ( - { - if (permissions?.collections?.[collection.slug]?.create?.permission) { - return ( - - ); - } + return ; + }} + />, + { + const { match: { params: { id } } } = routeProps; + if (permissions?.collections?.[collection.slug]?.read?.permission) { + return ( + + + + ); + } - return ; - }} - /> - ))} + return ; + }} + />, + ]; - {collections.map((collection) => ( - { - if (permissions?.collections?.[collection.slug]?.read?.permission) { - return ( - - ); - } + if (collection.versions) { + routesToReturn.push( + { + if (permissions?.collections?.[collection.slug]?.readVersions?.permission) { + return ( + + ); + } - return ; - }} - /> - ))} + return ; + }} + />, + ); - {globals && globals.map((global) => ( - { - if (permissions?.globals?.[global.slug]?.read?.permission) { - return ( - - ); - } + routesToReturn.push( + { + if (permissions?.collections?.[collection.slug]?.readVersions?.permission) { + return ( + + ); + } - return ; - }} - /> - ))} + return ; + }} + />, + ); + } + + return routesToReturn; + }, [])} + + {globals && globals.reduce((globalRoutes, global) => { + const routesToReturn = [ + ...globalRoutes, + { + if (permissions?.globals?.[global.slug]?.read?.permission) { + return ( + + + + ); + } + + return ; + }} + />, + ]; + + if (global.versions) { + routesToReturn.push( + { + if (permissions?.globals?.[global.slug]?.readVersions?.permission) { + return ( + + ); + } + + return ; + }} + />, + ); + routesToReturn.push( + { + if (permissions?.globals?.[global.slug]?.readVersions?.permission) { + return ( + + ); + } + + return ; + }} + />, + ); + } + return routesToReturn; + }, [])} diff --git a/src/admin/components/elements/Autosave/index.scss b/src/admin/components/elements/Autosave/index.scss new file mode 100644 index 0000000000..ba75d45118 --- /dev/null +++ b/src/admin/components/elements/Autosave/index.scss @@ -0,0 +1,5 @@ +@import '../../../scss/styles.scss'; + +.autosave { + min-height: $baseline; +} diff --git a/src/admin/components/elements/Autosave/index.tsx b/src/admin/components/elements/Autosave/index.tsx new file mode 100644 index 0000000000..aaeb1d10a9 --- /dev/null +++ b/src/admin/components/elements/Autosave/index.tsx @@ -0,0 +1,136 @@ +import { useConfig } from '@payloadcms/config-provider'; +import { formatDistance } from 'date-fns'; +import { useHistory } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useWatchForm, useFormModified } from '../../forms/Form/context'; +import { useLocale } from '../../utilities/Locale'; +import { Props } from './types'; +import reduceFieldsToValues from '../../forms/Form/reduceFieldsToValues'; +import { useDocumentInfo } from '../../utilities/DocumentInfo'; + +import './index.scss'; + +const baseClass = 'autosave'; + +const Autosave: React.FC = ({ collection, global, id, publishedDocUpdatedAt }) => { + const { serverURL, routes: { api, admin } } = useConfig(); + const { versions, getVersions } = useDocumentInfo(); + const { fields, dispatchFields } = useWatchForm(); + const modified = useFormModified(); + const locale = useLocale(); + const { replace } = useHistory(); + + const fieldRef = useRef(fields); + const [saving, setSaving] = useState(false); + const [lastSaved, setLastSaved] = useState(); + + // Store fields in ref so the autosave func + // can always retrieve the most to date copies + // after the timeout has executed + fieldRef.current = fields; + + const interval = collection.versions.drafts && collection.versions.drafts.autosave ? collection.versions.drafts.autosave.interval : 5; + + const createDoc = useCallback(async () => { + const res = await fetch(`${serverURL}${api}/${collection.slug}?locale=${locale}&fallback-locale=null&depth=0&draft=true`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + if (res.status === 201) { + const json = await res.json(); + replace(`${admin}/collections/${collection.slug}/${json.doc.id}`); + } else { + toast.error('There was a problem while autosaving this document.'); + } + }, [collection, serverURL, api, admin, locale, replace]); + + useEffect(() => { + // If no ID, but this is used for a collection doc, + // Immediately save it and set lastSaved + if (!id && collection) { + createDoc(); + } + }, [id, collection, global, createDoc]); + + // When fields change, autosave + useEffect(() => { + const autosave = async () => { + if (lastSaved && modified && !saving) { + const lastSavedDate = new Date(lastSaved); + lastSavedDate.setSeconds(lastSavedDate.getSeconds() + interval); + const timeToSaveAgain = lastSavedDate.getTime(); + + if (Date.now() >= timeToSaveAgain) { + setSaving(true); + + setTimeout(async () => { + let url: string; + let method: string; + + if (collection && id) { + url = `${serverURL}${api}/${collection.slug}/${id}?draft=true&autosave=true`; + method = 'PUT'; + } + + if (global) { + url = `${serverURL}${api}/globals/${global.slug}?draft=true&autosave=true`; + method = 'POST'; + } + + if (url) { + const body = { + ...reduceFieldsToValues(fieldRef.current), + _status: 'draft', + }; + + const res = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (res.status === 200) { + setLastSaved(new Date().getTime()); + getVersions(); + } + + setSaving(false); + } + }, 2000); + } + } + }; + + autosave(); + }, [fields, modified, interval, lastSaved, serverURL, api, collection, global, id, saving, dispatchFields, getVersions]); + + useEffect(() => { + if (versions?.docs?.[0]) { + setLastSaved(new Date(versions.docs[0].updatedAt).getTime()); + } else if (publishedDocUpdatedAt) { + setLastSaved(new Date(publishedDocUpdatedAt).getTime()); + } + }, [publishedDocUpdatedAt, versions]); + + return ( +
+ {saving && 'Saving...'} + {(!saving && lastSaved) && ( + + Last saved  + {formatDistance(new Date(), new Date(lastSaved))} +  ago + + )} +
+ ); +}; + +export default Autosave; diff --git a/src/admin/components/elements/Autosave/types.ts b/src/admin/components/elements/Autosave/types.ts new file mode 100644 index 0000000000..9591fc5424 --- /dev/null +++ b/src/admin/components/elements/Autosave/types.ts @@ -0,0 +1,9 @@ +import { SanitizedCollectionConfig } from '../../../../collections/config/types'; +import { SanitizedGlobalConfig } from '../../../../globals/config/types'; + +export type Props = { + collection?: SanitizedCollectionConfig, + global?: SanitizedGlobalConfig, + id?: string | number + publishedDocUpdatedAt: string +} diff --git a/src/admin/components/elements/Button/index.scss b/src/admin/components/elements/Button/index.scss index c189f12652..da4f300897 100644 --- a/src/admin/components/elements/Button/index.scss +++ b/src/admin/components/elements/Button/index.scss @@ -72,8 +72,18 @@ background-color: $color-dark-gray; color: white; - &:hover { - background: lighten($color-dark-gray, 5%); + &.btn--disabled { + background-color: rgba($color-dark-gray, .6); + } + + &:not(.btn--disabled) { + &:hover { + background: lighten($color-dark-gray, 5%); + } + + &:active { + background: lighten($color-dark-gray, 10%); + } } &:focus { @@ -81,9 +91,6 @@ outline: none; } - &:active { - background: lighten($color-dark-gray, 10%); - } } &--style-secondary { @@ -99,14 +106,20 @@ box-shadow: $hover-box-shadow; } + &:active { + background: lighten($color-light-gray, 7%); + } + + &.btn--disabled { + color: rgba($color-dark-gray, .6); + background: none; + box-shadow: inset 0 0 0 $style-stroke-width rgba($color-dark-gray, .4); + } + &:focus { outline: none; box-shadow: $hover-box-shadow, $focus-box-shadow; } - - &:active { - background: lighten($color-light-gray, 7%); - } } &--style-none { @@ -172,6 +185,10 @@ } } + &--disabled { + cursor: default; + } + &:hover { .btn__icon { @include color-svg(white); diff --git a/src/admin/components/elements/Button/index.tsx b/src/admin/components/elements/Button/index.tsx index 96dd5c0cab..2552aa3421 100644 --- a/src/admin/components/elements/Button/index.tsx +++ b/src/admin/components/elements/Button/index.tsx @@ -88,7 +88,8 @@ const Button: React.FC = (props) => { const buttonProps = { type, className: classes, - onClick: handleClick, + disabled, + onClick: !disabled ? handleClick : undefined, rel: newTab ? 'noopener noreferrer' : undefined, target: newTab ? '_blank' : undefined, }; diff --git a/src/admin/components/elements/DeleteDocument/index.tsx b/src/admin/components/elements/DeleteDocument/index.tsx index 6b96dd5105..5204a2d7dc 100644 --- a/src/admin/components/elements/DeleteDocument/index.tsx +++ b/src/admin/components/elements/DeleteDocument/index.tsx @@ -55,14 +55,8 @@ const DeleteDocument: React.FC = (props) => { const json = await res.json(); if (res.status < 400) { closeAll(); - return history.push({ - pathname: `${admin}/collections/${slug}`, - state: { - status: { - message: `${singular} "${title}" successfully deleted.`, - }, - }, - }); + toast.success(`${singular} "${title}" successfully deleted.`); + return history.push(`${admin}/collections/${slug}`); } closeAll(); diff --git a/src/admin/components/elements/IDLabel/index.scss b/src/admin/components/elements/IDLabel/index.scss new file mode 100644 index 0000000000..7fa0eb14bb --- /dev/null +++ b/src/admin/components/elements/IDLabel/index.scss @@ -0,0 +1,11 @@ +@import '../../../scss/styles'; + +.id-label { + font-size: base(.75); + font-weight: normal; + color: $color-gray; + background: $color-background-gray; + padding: base(.25) base(.5); + border-radius: $style-radius-m; + display: inline-flex; +} diff --git a/src/admin/components/elements/IDLabel/index.tsx b/src/admin/components/elements/IDLabel/index.tsx new file mode 100644 index 0000000000..fca5283534 --- /dev/null +++ b/src/admin/components/elements/IDLabel/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import './index.scss'; + +const baseClass = 'id-label'; + +const IDLabel: React.FC<{ id: string, prefix?: string }> = ({ id, prefix = 'ID:' }) => ( +
+ {prefix} +    + {id} +
+); + +export default IDLabel; diff --git a/src/admin/components/elements/PerPage/index.tsx b/src/admin/components/elements/PerPage/index.tsx index 4a5df115c2..2fcedca2b1 100644 --- a/src/admin/components/elements/PerPage/index.tsx +++ b/src/admin/components/elements/PerPage/index.tsx @@ -4,27 +4,22 @@ import { useHistory } from 'react-router-dom'; import { useSearchParams } from '../../utilities/SearchParams'; import Popup from '../Popup'; import Chevron from '../../icons/Chevron'; -import { SanitizedCollectionConfig } from '../../../../collections/config/types'; +import { defaults } from '../../../../collections/config/defaults'; import './index.scss'; const baseClass = 'per-page'; + +const defaultLimits = defaults.admin.pagination.limits; + type Props = { - collection: SanitizedCollectionConfig + limits: number[] limit: number handleChange?: (limit: number) => void modifySearchParams?: boolean } -const PerPage: React.FC = ({ collection, limit, handleChange, modifySearchParams = true }) => { - const { - admin: { - pagination: { - limits, - }, - }, - } = collection; - +const PerPage: React.FC = ({ limits = defaultLimits, limit, handleChange, modifySearchParams = true }) => { const params = useSearchParams(); const history = useHistory(); diff --git a/src/admin/components/elements/Pill/index.scss b/src/admin/components/elements/Pill/index.scss index 83b98e0e95..13e0fec497 100644 --- a/src/admin/components/elements/Pill/index.scss +++ b/src/admin/components/elements/Pill/index.scss @@ -11,6 +11,7 @@ border-radius: $style-radius-s; padding: 0 base(.25); padding-left: base(.0875 + .25); + cursor: default; &:active, &:focus { @@ -37,12 +38,14 @@ } &--style-light { - &:hover { - background: lighten($color-light-gray, 3%); - } + &.pill--has-action { + &:hover { + background: lighten($color-light-gray, 3%); + } - &:active { - background: lighten($color-light-gray, 5%); + &:active { + background: lighten($color-light-gray, 5%); + } } } @@ -51,6 +54,14 @@ color: $color-dark-gray; } + &--style-warning { + background: $color-yellow; + } + + &--style-success { + background: $color-green; + } + &--style-dark { background: $color-dark-gray; color: white; @@ -59,12 +70,14 @@ @include color-svg(white); } - &:hover { - background: lighten($color-dark-gray, 3%); - } + &.pill--has-action { + &:hover { + background: lighten($color-dark-gray, 3%); + } - &:active { - background: lighten($color-dark-gray, 5%); + &:active { + background: lighten($color-dark-gray, 5%); + } } } } diff --git a/src/admin/components/elements/Pill/types.ts b/src/admin/components/elements/Pill/types.ts index ded6e6db74..9aab40aea9 100644 --- a/src/admin/components/elements/Pill/types.ts +++ b/src/admin/components/elements/Pill/types.ts @@ -4,7 +4,7 @@ export type Props = { icon?: React.ReactNode, alignIcon?: 'left' | 'right', onClick?: () => void, - pillStyle?: 'light' | 'dark' | 'light-gray', + pillStyle?: 'light' | 'dark' | 'light-gray' | 'warning' | 'success', } export type RenderedTypeProps = { diff --git a/src/admin/components/elements/PreviewButton/index.scss b/src/admin/components/elements/PreviewButton/index.scss new file mode 100644 index 0000000000..e1a2f40120 --- /dev/null +++ b/src/admin/components/elements/PreviewButton/index.scss @@ -0,0 +1 @@ +@import '../../../scss/styles.scss'; diff --git a/src/admin/components/elements/PreviewButton/index.tsx b/src/admin/components/elements/PreviewButton/index.tsx index 7b1672eeaf..bb47ec4a0f 100644 --- a/src/admin/components/elements/PreviewButton/index.tsx +++ b/src/admin/components/elements/PreviewButton/index.tsx @@ -4,12 +4,14 @@ import Button from '../Button'; import { Props } from './types'; import { useLocale } from '../../utilities/Locale'; +import './index.scss'; + const baseClass = 'preview-btn'; const PreviewButton: React.FC = (props) => { const { generatePreviewURL, - data + data, } = props; const [url, setUrl] = useState(undefined); @@ -22,7 +24,7 @@ const PreviewButton: React.FC = (props) => { const makeRequest = async () => { const previewURL = await generatePreviewURL(data, { locale, token }); setUrl(previewURL); - } + }; makeRequest(); } @@ -30,7 +32,7 @@ const PreviewButton: React.FC = (props) => { generatePreviewURL, locale, token, - data + data, ]); if (url) { diff --git a/src/admin/components/elements/Publish/index.tsx b/src/admin/components/elements/Publish/index.tsx new file mode 100644 index 0000000000..54978239d2 --- /dev/null +++ b/src/admin/components/elements/Publish/index.tsx @@ -0,0 +1,34 @@ +import React, { useCallback } from 'react'; +import FormSubmit from '../../forms/Submit'; +import { Props } from './types'; +import { useDocumentInfo } from '../../utilities/DocumentInfo'; +import { useForm, useFormModified } from '../../forms/Form/context'; + +const Publish: React.FC = () => { + const { unpublishedVersions, publishedDoc } = useDocumentInfo(); + const { submit } = useForm(); + const modified = useFormModified(); + + const hasNewerVersions = unpublishedVersions?.totalDocs > 0; + const canPublish = modified || hasNewerVersions || !publishedDoc; + + const publish = useCallback(() => { + submit({ + overrides: { + _status: 'published', + }, + }); + }, [submit]); + + return ( + + Publish changes + + ); +}; + +export default Publish; diff --git a/src/admin/components/elements/Publish/types.ts b/src/admin/components/elements/Publish/types.ts new file mode 100644 index 0000000000..f63b61fdaa --- /dev/null +++ b/src/admin/components/elements/Publish/types.ts @@ -0,0 +1 @@ +export type Props = {} diff --git a/src/admin/components/elements/ReactSelect/index.scss b/src/admin/components/elements/ReactSelect/index.scss index 85252b52f8..ef8b6dabb5 100644 --- a/src/admin/components/elements/ReactSelect/index.scss +++ b/src/admin/components/elements/ReactSelect/index.scss @@ -9,7 +9,8 @@ div.react-select { } .rs__value-container { - padding: 0; + padding: base(.25) 0; + min-height: base(1.5); > * { margin-top: 0; @@ -20,6 +21,8 @@ div.react-select { &--is-multi { margin-left: - base(.25); + padding-top: 0; + padding-bottom: 0; } } @@ -40,9 +43,6 @@ div.react-select { } .rs__input { - margin-top: base(.25); - margin-bottom: base(.25); - input { font-family: $font-body; width: 100% !important; diff --git a/src/admin/components/elements/ReactSelect/index.tsx b/src/admin/components/elements/ReactSelect/index.tsx index 92c0de3e87..5974d923bc 100644 --- a/src/admin/components/elements/ReactSelect/index.tsx +++ b/src/admin/components/elements/ReactSelect/index.tsx @@ -14,6 +14,7 @@ const ReactSelect: React.FC = (props) => { value, disabled = false, placeholder, + isSearchable = true, } = props; const classes = [ @@ -33,6 +34,7 @@ const ReactSelect: React.FC = (props) => { className={classes} classNamePrefix="rs" options={options} + isSearchable={isSearchable} /> ); }; diff --git a/src/admin/components/elements/ReactSelect/types.ts b/src/admin/components/elements/ReactSelect/types.ts index 7b7c318e57..913999cdcb 100644 --- a/src/admin/components/elements/ReactSelect/types.ts +++ b/src/admin/components/elements/ReactSelect/types.ts @@ -20,4 +20,5 @@ export type Props = { onInputChange?: (val: string) => void onMenuScrollToBottom?: () => void placeholder?: string + isSearchable?: boolean } diff --git a/src/admin/components/elements/RenderTitle/index.scss b/src/admin/components/elements/RenderTitle/index.scss deleted file mode 100644 index 3fd557b0c5..0000000000 --- a/src/admin/components/elements/RenderTitle/index.scss +++ /dev/null @@ -1,12 +0,0 @@ -@import '../../../scss/styles.scss'; - -.render-title { - &--id-as-title { - font-size: base(.75); - font-weight: normal; - color: $color-gray; - background: $color-background-gray; - padding: base(.25) base(.5); - border-radius: $style-radius-m; - } -} diff --git a/src/admin/components/elements/RenderTitle/index.tsx b/src/admin/components/elements/RenderTitle/index.tsx index 482270f703..94aa6fe59e 100644 --- a/src/admin/components/elements/RenderTitle/index.tsx +++ b/src/admin/components/elements/RenderTitle/index.tsx @@ -1,8 +1,7 @@ -import React, { Fragment } from 'react'; +import React from 'react'; import { Props } from './types'; import useTitle from '../../../hooks/useTitle'; - -import './index.scss'; +import IDLabel from '../IDLabel'; const baseClass = 'render-title'; @@ -25,18 +24,14 @@ const RenderTitle : React.FC = (props) => { const idAsTitle = title === data?.id; - const classes = [ - baseClass, - idAsTitle && `${baseClass}--id-as-title`, - ].filter(Boolean).join(' '); + if (idAsTitle) { + return ( + + ); + } return ( - - {idAsTitle && ( - - ID:   - - )} + {title} ); diff --git a/src/admin/components/elements/SaveDraft/index.scss b/src/admin/components/elements/SaveDraft/index.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/admin/components/elements/SaveDraft/index.tsx b/src/admin/components/elements/SaveDraft/index.tsx new file mode 100644 index 0000000000..e1c14639af --- /dev/null +++ b/src/admin/components/elements/SaveDraft/index.tsx @@ -0,0 +1,57 @@ +import React, { useCallback } from 'react'; +import { useConfig } from '@payloadcms/config-provider'; +import FormSubmit from '../../forms/Submit'; +import { useForm, useFormModified } from '../../forms/Form/context'; +import { useDocumentInfo } from '../../utilities/DocumentInfo'; +import { useLocale } from '../../utilities/Locale'; + +import './index.scss'; + +const baseClass = 'save-draft'; + +const SaveDraft: React.FC = () => { + const { serverURL, routes: { api } } = useConfig(); + const { submit } = useForm(); + const { collection, global, id } = useDocumentInfo(); + const modified = useFormModified(); + const locale = useLocale(); + + const canSaveDraft = modified; + + const saveDraft = useCallback(() => { + const search = `?locale=${locale}&depth=0&fallback-locale=null&draft=true`; + let action; + let method = 'POST'; + + if (collection) { + action = `${serverURL}${api}/${collection.slug}${id ? `/${id}` : ''}${search}`; + if (id) method = 'PUT'; + } + + if (global) { + action = `${serverURL}${api}/globals/${global.slug}${search}`; + } + + submit({ + action, + method, + overrides: { + _status: 'draft', + }, + }); + }, [submit, collection, global, serverURL, api, locale, id]); + + return ( + + Save draft + + ); +}; + +export default SaveDraft; diff --git a/src/admin/components/elements/Status/index.scss b/src/admin/components/elements/Status/index.scss new file mode 100644 index 0000000000..210bcfba0f --- /dev/null +++ b/src/admin/components/elements/Status/index.scss @@ -0,0 +1,11 @@ +@import '../../../scss/styles.scss'; + +.status { + &__label { + color: gray; + } + + &__value { + font-weight: 600; + } +} diff --git a/src/admin/components/elements/Status/index.tsx b/src/admin/components/elements/Status/index.tsx new file mode 100644 index 0000000000..e2f410580a --- /dev/null +++ b/src/admin/components/elements/Status/index.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Props } from './types'; +import { useDocumentInfo } from '../../utilities/DocumentInfo'; + +import './index.scss'; + +const baseClass = 'status'; + +const Status: React.FC = () => { + const { publishedDoc, unpublishedVersions } = useDocumentInfo(); + + let statusToRender; + + if (unpublishedVersions?.docs?.length > 0 && publishedDoc) { + statusToRender = 'Changed'; + } else if (!publishedDoc) { + statusToRender = 'Draft'; + } else if (publishedDoc && unpublishedVersions?.docs?.length <= 1) { + statusToRender = 'Published'; + } + + if (statusToRender) { + return ( +
+
+ {statusToRender} +
+
+ ); + } + + return null; +}; + +export default Status; diff --git a/src/admin/components/elements/Status/types.ts b/src/admin/components/elements/Status/types.ts new file mode 100644 index 0000000000..daededa188 --- /dev/null +++ b/src/admin/components/elements/Status/types.ts @@ -0,0 +1,3 @@ +export type Props = { + +} diff --git a/src/admin/components/elements/VersionsCount/index.scss b/src/admin/components/elements/VersionsCount/index.scss new file mode 100644 index 0000000000..641148f574 --- /dev/null +++ b/src/admin/components/elements/VersionsCount/index.scss @@ -0,0 +1,9 @@ +@import '../../../scss/styles.scss'; + +.versions-count__button { + font-weight: 600; + + &:hover { + text-decoration: underline; + } +} diff --git a/src/admin/components/elements/VersionsCount/index.tsx b/src/admin/components/elements/VersionsCount/index.tsx new file mode 100644 index 0000000000..d6b66b931f --- /dev/null +++ b/src/admin/components/elements/VersionsCount/index.tsx @@ -0,0 +1,56 @@ +import { useConfig } from '@payloadcms/config-provider'; +import React from 'react'; +import Button from '../Button'; +import { Props } from './types'; + +import './index.scss'; +import { useDocumentInfo } from '../../utilities/DocumentInfo'; + +const baseClass = 'versions-count'; + +const Versions: React.FC = ({ collection, global, id }) => { + const { routes: { admin } } = useConfig(); + const { versions } = useDocumentInfo(); + + let versionsURL: string; + + if (collection) { + versionsURL = `${admin}/collections/${collection.slug}/${id}/versions`; + } + + if (global) { + versionsURL = `${admin}/globals/${global.slug}/versions`; + } + + return ( +
+ {versions?.docs && ( + + {versions.docs.length === 0 && ( + + No versions found + + )} + {versions?.docs?.length > 0 && ( + + + + )} + + )} +
+ ); +}; +export default Versions; diff --git a/src/admin/components/elements/VersionsCount/types.ts b/src/admin/components/elements/VersionsCount/types.ts new file mode 100644 index 0000000000..a75e0f0c41 --- /dev/null +++ b/src/admin/components/elements/VersionsCount/types.ts @@ -0,0 +1,8 @@ +import { SanitizedCollectionConfig } from '../../../../collections/config/types'; +import { SanitizedGlobalConfig } from '../../../../globals/config/types'; + +export type Props = { + collection?: SanitizedCollectionConfig, + global?: SanitizedGlobalConfig + id?: string | number +} diff --git a/src/admin/components/elements/WhereBuilder/Condition/Relationship/index.tsx b/src/admin/components/elements/WhereBuilder/Condition/Relationship/index.tsx index 4d4321c41c..31f0ad056d 100644 --- a/src/admin/components/elements/WhereBuilder/Condition/Relationship/index.tsx +++ b/src/admin/components/elements/WhereBuilder/Condition/Relationship/index.tsx @@ -5,7 +5,7 @@ import optionsReducer from './optionsReducer'; import useDebounce from '../../../../../hooks/useDebounce'; import ReactSelect from '../../../ReactSelect'; import { Value } from '../../../ReactSelect/types'; -import { PaginatedDocs } from '../../../../../../collections/config/types'; +import { PaginatedDocs } from '../../../../../../mongoose/types'; import './index.scss'; diff --git a/src/admin/components/elements/WhereBuilder/Condition/Relationship/types.ts b/src/admin/components/elements/WhereBuilder/Condition/Relationship/types.ts index 0071195bd9..711b7ec063 100644 --- a/src/admin/components/elements/WhereBuilder/Condition/Relationship/types.ts +++ b/src/admin/components/elements/WhereBuilder/Condition/Relationship/types.ts @@ -1,5 +1,6 @@ import { RelationshipField } from '../../../../../../fields/config/types'; -import { PaginatedDocs, SanitizedCollectionConfig } from '../../../../../../collections/config/types'; +import { SanitizedCollectionConfig } from '../../../../../../collections/config/types'; +import { PaginatedDocs } from '../../../../../../mongoose/types'; export type Props = { onChange: (val: unknown) => void, diff --git a/src/admin/components/forms/Form/getDataByPath.ts b/src/admin/components/forms/Form/getDataByPath.ts index a139d02fe9..52ee905f4b 100644 --- a/src/admin/components/forms/Form/getDataByPath.ts +++ b/src/admin/components/forms/Form/getDataByPath.ts @@ -2,7 +2,7 @@ import { unflatten } from 'flatley'; import reduceFieldsToValues from './reduceFieldsToValues'; import { Fields } from './types'; -const getDataByPath = (fields: Fields, path: string): unknown => { +const getDataByPath = (fields: Fields, path: string): T => { const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1); const name = path.split('.').pop(); diff --git a/src/admin/components/forms/Form/index.tsx b/src/admin/components/forms/Form/index.tsx index d345a7de4e..ff13c18bee 100644 --- a/src/admin/components/forms/Form/index.tsx +++ b/src/admin/components/forms/Form/index.tsx @@ -1,3 +1,4 @@ +/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ import React, { useReducer, useEffect, useRef, useState, useCallback, } from 'react'; @@ -16,7 +17,7 @@ import getDataByPathFunc from './getDataByPath'; import wait from '../../../../utilities/wait'; import buildInitialState from './buildInitialState'; import errorMessages from './errorMessages'; -import { Context as FormContextType, Props } from './types'; +import { Context as FormContextType, Props, SubmitOptions } from './types'; import { SubmittedContext, ProcessingContext, ModifiedContext, FormContext, FormWatchContext } from './context'; @@ -49,6 +50,7 @@ const Form: React.FC = (props) => { const [submitted, setSubmitted] = useState(false); const [formattedInitialData, setFormattedInitialData] = useState(buildInitialState(initialData)); + const formRef = useRef(null); const contextRef = useRef({} as FormContextType); let initialFieldState = {}; @@ -97,14 +99,24 @@ const Form: React.FC = (props) => { return isValid; }, [contextRef]); - const submit = useCallback(async (e): Promise => { + const submit = useCallback(async (options: SubmitOptions = {}, e): Promise => { + const { + overrides = {}, + action: actionToUse = action, + method: methodToUse = method, + } = options; + if (disabled) { - e.preventDefault(); + if (e) { + e.preventDefault(); + } return; } - e.stopPropagation(); - e.preventDefault(); + if (e) { + e.stopPropagation(); + e.preventDefault(); + } setProcessing(true); @@ -124,14 +136,19 @@ const Form: React.FC = (props) => { // If submit handler comes through via props, run that if (onSubmit) { - onSubmit(fields, reduceFieldsToValues(fields)); + const data = { + ...reduceFieldsToValues(fields), + ...overrides, + }; + + onSubmit(fields, data); return; } - const formData = contextRef.current.createFormData(); + const formData = contextRef.current.createFormData(overrides); try { - const res = await requests[method.toLowerCase()](action, { + const res = await requests[methodToUse.toLowerCase()](actionToUse, { body: formData, }); @@ -268,8 +285,8 @@ const Form: React.FC = (props) => { const getDataByPath = useCallback((path: string) => getDataByPathFunc(contextRef.current.fields, path), [contextRef]); const getUnflattenedValues = useCallback(() => reduceFieldsToValues(contextRef.current.fields), [contextRef]); - const createFormData = useCallback(() => { - const data = reduceFieldsToValues(contextRef.current.fields, true); + const createFormData = useCallback((overrides: any = {}) => { + const data = reduceFieldsToValues(contextRef.current.fields); const file = data?.file; @@ -277,8 +294,13 @@ const Form: React.FC = (props) => { delete data.file; } + const dataWithOverrides = { + ...data, + ...overrides, + }; + const dataToSerialize = { - _payload: JSON.stringify(data), + _payload: JSON.stringify(dataWithOverrides), file, }; @@ -301,6 +323,7 @@ const Form: React.FC = (props) => { contextRef.current.setProcessing = setProcessing; contextRef.current.setSubmitted = setSubmitted; contextRef.current.disabled = disabled; + contextRef.current.formRef = formRef; useEffect(() => { if (initialState) { @@ -340,10 +363,11 @@ const Form: React.FC = (props) => { return (
contextRef.current.submit({}, e)} method={method} action={action} className={classes} + ref={formRef} > +} + export type DispatchFields = React.Dispatch -export type Submit = (e: React.FormEvent) => void; +export type Submit = (options?: SubmitOptions, e?: React.FormEvent) => void; export type ValidateForm = () => Promise; -export type CreateFormData = () => FormData; +export type CreateFormData = (overrides?: any) => FormData; export type GetFields = () => Fields; export type GetField = (path: string) => Field; export type GetData = () => Data; export type GetSiblingData = (path: string) => Data; export type GetUnflattenedValues = () => Data; -export type GetDataByPath = (path: string) => unknown; +export type GetDataByPath = (path: string) => T; export type SetModified = (modified: boolean) => void; export type SetSubmitted = (submitted: boolean) => void; export type SetProcessing = (processing: boolean) => void; @@ -71,4 +77,5 @@ export type Context = { setModified: SetModified setProcessing: SetProcessing setSubmitted: SetSubmitted + formRef: React.MutableRefObject } diff --git a/src/admin/components/forms/Submit/index.tsx b/src/admin/components/forms/Submit/index.tsx index e853658ca0..86ff04475a 100644 --- a/src/admin/components/forms/Submit/index.tsx +++ b/src/admin/components/forms/Submit/index.tsx @@ -1,20 +1,23 @@ import React from 'react'; import { useForm, useFormProcessing } from '../Form/context'; import Button from '../../elements/Button'; +import { Props } from '../../elements/Button/types'; import './index.scss'; const baseClass = 'form-submit'; -const FormSubmit = ({ children }) => { +const FormSubmit: React.FC = (props) => { + const { children, disabled: disabledFromProps, type = 'submit' } = props; const processing = useFormProcessing(); const { disabled } = useForm(); return (
diff --git a/src/admin/components/forms/field-types/Relationship/index.tsx b/src/admin/components/forms/field-types/Relationship/index.tsx index cbf1f702bf..2a78d1aa3f 100644 --- a/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/src/admin/components/forms/field-types/Relationship/index.tsx @@ -10,7 +10,7 @@ import Label from '../../Label'; import Error from '../../Error'; import FieldDescription from '../../FieldDescription'; import { relationship } from '../../../../../fields/validations'; -import { PaginatedDocs } from '../../../../../collections/config/types'; +import { PaginatedDocs } from '../../../../../mongoose/types'; import { useFormProcessing } from '../../Form/context'; import optionsReducer from './optionsReducer'; import { Props, Option, ValueWithRelation } from './types'; diff --git a/src/admin/components/forms/field-types/Relationship/types.ts b/src/admin/components/forms/field-types/Relationship/types.ts index 44c12dfa78..aeced06841 100644 --- a/src/admin/components/forms/field-types/Relationship/types.ts +++ b/src/admin/components/forms/field-types/Relationship/types.ts @@ -1,4 +1,5 @@ -import { PaginatedDocs, SanitizedCollectionConfig } from '../../../../../collections/config/types'; +import { SanitizedCollectionConfig } from '../../../../../collections/config/types'; +import { PaginatedDocs } from '../../../../../mongoose/types'; import { RelationshipField } from '../../../../../fields/config/types'; export type Props = Omit & { diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.tsx index 5a6fa08635..97893aa9e4 100644 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.tsx @@ -207,7 +207,7 @@ const UploadButton: React.FC<{path: string}> = ({ path }) => { {data.totalDocs}
= (props) => { {data.totalDocs} = (props) => { - const { children, type, slug } = props; +export const DocumentInfoProvider: React.FC = ({ + children, + global, + collection, + id, +}) => { + const { serverURL, routes: { api } } = useConfig(); + const [publishedDoc, setPublishedDoc] = useState(null); + const [versions, setVersions] = useState>(null); + const [unpublishedVersions, setUnpublishedVersions] = useState>(null); - if (type === 'global') { - return ( - - {children} - - ); + const baseURL = `${serverURL}${api}`; + let slug; + let type; + let preferencesKey; + + if (global) { + slug = global.slug; + type = 'global'; + preferencesKey = `global-${slug}`; } - if (type === 'collection') { - const { id } = props as CollectionDoc; - - const value: ContextType = { - type, - slug, - }; + if (collection) { + slug = collection.slug; + type = 'collection'; if (id) { - value.id = id; - value.preferencesKey = `collection-${slug}-${id}`; + preferencesKey = `collection-${slug}-${id}`; } - - return ( - - {children} - - ); } - return null; + const getVersions = useCallback(async () => { + let versionFetchURL; + let publishedFetchURL; + let shouldFetchVersions = false; + let unpublishedVersionJSON = null; + let versionJSON = null; + let shouldFetch = true; + + const params = { + where: { + and: [], + }, + depth: 0, + }; + + if (global) { + shouldFetchVersions = Boolean(global?.versions); + versionFetchURL = `${baseURL}/globals/${global.slug}/versions?depth=0`; + publishedFetchURL = `${baseURL}/globals/${global.slug}?depth=0`; + } + + if (collection) { + shouldFetchVersions = Boolean(collection?.versions); + versionFetchURL = `${baseURL}/${collection.slug}/versions?where[parent][equals]=${id}&depth=0`; + publishedFetchURL = `${baseURL}/${collection.slug}?where[id][equals]=${id}&depth=0`; + + if (!id) { + shouldFetch = false; + } + + params.where.and.push({ + parent: { + equals: id, + }, + }); + } + + if (shouldFetch) { + let publishedJSON = await fetch(publishedFetchURL).then((res) => res.json()); + + if (collection) { + publishedJSON = publishedJSON?.docs?.[0]; + } + + if (shouldFetchVersions) { + versionJSON = await fetch(versionFetchURL).then((res) => res.json()); + + if (publishedJSON?.updatedAt) { + const newerVersionParams = { + ...params, + where: { + ...params.where, + and: [ + ...params.where.and, + { + updatedAt: { + greater_than: publishedJSON?.updatedAt, + }, + }, + ], + }, + }; + + // Get any newer versions available + const newerVersionRes = await fetch(`${versionFetchURL}?${qs.stringify(newerVersionParams)}`); + + if (newerVersionRes.status === 200) { + unpublishedVersionJSON = await newerVersionRes.json(); + } + } + } + + setPublishedDoc(publishedJSON); + setVersions(versionJSON); + setUnpublishedVersions(unpublishedVersionJSON); + } + }, [global, collection, id, baseURL]); + + useEffect(() => { + getVersions(); + }, [getVersions]); + + const value = { + slug, + type, + preferencesKey, + global, + collection, + versions, + unpublishedVersions, + getVersions, + publishedDoc, + id, + }; + + return ( + + {children} + + ); }; export const useDocumentInfo = (): ContextType => useContext(Context); diff --git a/src/admin/components/utilities/DocumentInfo/types.ts b/src/admin/components/utilities/DocumentInfo/types.ts new file mode 100644 index 0000000000..6a8dd99fa6 --- /dev/null +++ b/src/admin/components/utilities/DocumentInfo/types.ts @@ -0,0 +1,24 @@ +import { SanitizedCollectionConfig, TypeWithID, TypeWithTimestamps } from '../../../../collections/config/types'; +import { SanitizedGlobalConfig } from '../../../../globals/config/types'; +import { PaginatedDocs } from '../../../../mongoose/types'; +import { TypeWithVersion } from '../../../../versions/types'; + +export type Version = TypeWithVersion + +export type ContextType = { + collection?: SanitizedCollectionConfig + global?: SanitizedGlobalConfig + type: 'global' | 'collection' + id?: string | number + preferencesKey?: string + versions?: PaginatedDocs + unpublishedVersions?: PaginatedDocs + publishedDoc?: TypeWithID & TypeWithTimestamps + getVersions: () => void +} + +export type Props = { + collection?: SanitizedCollectionConfig + global?: SanitizedGlobalConfig + id?: string | number +} diff --git a/src/admin/components/views/Account/Default.tsx b/src/admin/components/views/Account/Default.tsx index 04fe7d8165..db52b693c1 100644 --- a/src/admin/components/views/Account/Default.tsx +++ b/src/admin/components/views/Account/Default.tsx @@ -69,7 +69,9 @@ const DefaultAccount: React.FC = (props) => { keywords="Account, Dashboard, Payload, CMS" /> - + {!(collection.versions?.drafts && collection.versions?.drafts?.autosave) && ( + + )}

diff --git a/src/admin/components/views/Account/index.tsx b/src/admin/components/views/Account/index.tsx index d1dcf22ea2..5b4b992f69 100644 --- a/src/admin/components/views/Account/index.tsx +++ b/src/admin/components/views/Account/index.tsx @@ -5,7 +5,6 @@ import { useStepNav } from '../../elements/StepNav'; import usePayloadAPI from '../../../hooks/usePayloadAPI'; import { useLocale } from '../../utilities/Locale'; -import { DocumentInfoProvider } from '../../utilities/DocumentInfo'; import DefaultAccount from './Default'; import buildStateFromSchema from '../../forms/Form/buildStateFromSchema'; import RenderCustomComponent from '../../utilities/RenderCustomComponent'; @@ -70,28 +69,22 @@ const AccountView: React.FC = () => { }, [dataToRender, fields]); return ( - - - - - + + + ); }; diff --git a/src/admin/components/views/Global/Default.tsx b/src/admin/components/views/Global/Default.tsx index 89122b53ab..667f77aa33 100644 --- a/src/admin/components/views/Global/Default.tsx +++ b/src/admin/components/views/Global/Default.tsx @@ -10,6 +10,7 @@ import CopyToClipboard from '../../elements/CopyToClipboard'; import Meta from '../../utilities/Meta'; import fieldTypes from '../../forms/field-types'; import LeaveWithoutSaving from '../../modals/LeaveWithoutSaving'; +import VersionsCount from '../../elements/VersionsCount'; import { Props } from './types'; import ViewDescription from '../../elements/ViewDescription'; @@ -28,9 +29,11 @@ const DefaultGlobalView: React.FC = (props) => { const { fields, preview, + versions, label, admin: { description, + hideAPIURL, } = {}, } = global; @@ -42,97 +45,103 @@ const DefaultGlobalView: React.FC = (props) => { )} {!isLoading && ( - -
- - - -
-
-

- Edit - {' '} - {label} -

- {description && ( -
- -
- )} -
- (!field.admin.position || (field.admin.position && field.admin.position !== 'sidebar'))} - fieldTypes={fieldTypes} - fieldSchema={fields} + +
+ -
-
-
-
-
-
- - {hasSavePermission && ( - Save + + {!(global.versions?.drafts && global.versions?.drafts?.autosave) && ( + + )} +
+
+

+ Edit + {' '} + {label} +

+ {description && ( +
+ +
)} -
-
- field.admin.position === 'sidebar'} - fieldTypes={fieldTypes} - fieldSchema={fields} - /> -
- {data && ( -
    - {data && ( -
  • - - API URL - {' '} - - - - {apiURL} - -
  • - )} - {data.updatedAt && ( -
  • -
    Last Modified
    -
    {format(new Date(data.updatedAt as string), dateFormat)}
    -
  • - )} -
- )} +

+ (!field.admin.position || (field.admin.position && field.admin.position !== 'sidebar'))} + fieldTypes={fieldTypes} + fieldSchema={fields} + />
- - +
+
+
+
+ + {hasSavePermission && ( + Save + )} +
+
+ field.admin.position === 'sidebar'} + fieldTypes={fieldTypes} + fieldSchema={fields} + /> +
+
    + {versions && ( +
  • +
    Versions
    + +
  • + )} + {(data && !hideAPIURL) && ( +
  • + + API URL + {' '} + + + + {apiURL} + +
  • + )} + {data.updatedAt && ( +
  • +
    Last Modified
    +
    {format(new Date(data.updatedAt as string), dateFormat)}
    +
  • + )} +
+
+
+
+ )} ); diff --git a/src/admin/components/views/Global/index.tsx b/src/admin/components/views/Global/index.tsx index dc6b804f7f..e7fa79d510 100644 --- a/src/admin/components/views/Global/index.tsx +++ b/src/admin/components/views/Global/index.tsx @@ -1,9 +1,8 @@ import React, { useState, useEffect } from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { useConfig, useAuth } from '@payloadcms/config-provider'; import { useStepNav } from '../../elements/StepNav'; import usePayloadAPI from '../../../hooks/usePayloadAPI'; -import { DocumentInfoProvider } from '../../utilities/DocumentInfo'; import { useLocale } from '../../utilities/Locale'; @@ -12,10 +11,10 @@ import DefaultGlobal from './Default'; import buildStateFromSchema from '../../forms/Form/buildStateFromSchema'; import { NegativeFieldGutterProvider } from '../../forms/FieldTypeGutter/context'; import { IndexProps } from './types'; +import { DocumentInfoProvider } from '../../utilities/DocumentInfo'; const GlobalView: React.FC = (props) => { const { state: locationState } = useLocation<{data?: Record}>(); - const history = useHistory(); const locale = useLocale(); const { setStepNav } = useStepNav(); const { permissions } = useAuth(); @@ -24,7 +23,6 @@ const GlobalView: React.FC = (props) => { const { serverURL, routes: { - admin, api, }, } = useConfig(); @@ -44,14 +42,9 @@ const GlobalView: React.FC = (props) => { } = {}, } = global; - const onSave = (json) => { - history.push(`${admin}/globals/${global.slug}`, { - status: { - message: json.message, - type: 'success', - }, - data: json.doc, - }); + const onSave = async (json) => { + const state = await buildStateFromSchema(fields, json.doc); + setInitialState(state); }; const [{ data, isLoading }] = usePayloadAPI( @@ -82,8 +75,7 @@ const GlobalView: React.FC = (props) => { return ( { const { setStepNav } = useStepNav(); const { routes: { admin } } = useConfig(); @@ -16,22 +20,23 @@ const NotFound: React.FC = () => { }, [setStepNav]); return ( -
+
-

Nothing found

-

Sorry—there is nothing to correspond with your request.

-
- +
+

Nothing found

+

Sorry—there is nothing to correspond with your request.

+ +
); }; diff --git a/src/admin/components/views/Version/Compare/index.scss b/src/admin/components/views/Version/Compare/index.scss new file mode 100644 index 0000000000..385c383fcc --- /dev/null +++ b/src/admin/components/views/Version/Compare/index.scss @@ -0,0 +1,15 @@ +@import '../../../../scss/styles.scss'; + +.compare-version { + &__error-loading { + border: 1px solid $color-red; + min-height: base(2); + padding: base(.5) base(.75); + background-color: $color-red; + color: white; + } + + &__label { + margin-bottom: base(.25); + } +} diff --git a/src/admin/components/views/Version/Compare/index.tsx b/src/admin/components/views/Version/Compare/index.tsx new file mode 100644 index 0000000000..72003e5080 --- /dev/null +++ b/src/admin/components/views/Version/Compare/index.tsx @@ -0,0 +1,108 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import qs from 'qs'; +import { useConfig } from '@payloadcms/config-provider'; +import format from 'date-fns/format'; +import { Props } from './types'; +import ReactSelect from '../../../elements/ReactSelect'; +import { PaginatedDocs } from '../../../../../mongoose/types'; +import { mostRecentVersionOption, publishedVersionOption } from '../shared'; + +import './index.scss'; + +const baseClass = 'compare-version'; + +const maxResultsPerRequest = 10; + +const baseOptions = [ + publishedVersionOption, + mostRecentVersionOption, +]; + +const CompareVersion: React.FC = (props) => { + const { onChange, value, baseURL, parentID } = props; + + const { + admin: { + dateFormat, + }, + } = useConfig(); + + const [options, setOptions] = useState(baseOptions); + const [lastLoadedPage, setLastLoadedPage] = useState(1); + const [errorLoading, setErrorLoading] = useState(''); + + const getResults = useCallback(async ({ + lastLoadedPage: lastLoadedPageArg, + } = {}) => { + const query = { + limit: maxResultsPerRequest, + page: lastLoadedPageArg, + depth: 0, + where: undefined, + }; + + if (parentID) { + query.where = { + parent: { + equals: parentID, + }, + }; + } + + const search = qs.stringify(query); + const response = await fetch(`${baseURL}?${search}`); + + if (response.ok) { + const data: PaginatedDocs = await response.json(); + if (data.docs.length > 0) { + setOptions((existingOptions) => [ + ...existingOptions, + ...data.docs.map((doc) => ({ + label: format(new Date(doc.createdAt), dateFormat), + value: doc.id, + })), + ]); + setLastLoadedPage(data.page); + } + } else { + setErrorLoading('An error has occurred.'); + } + }, [dateFormat, baseURL, parentID]); + + const classes = [ + 'field-type', + baseClass, + errorLoading && 'error-loading', + ].filter(Boolean).join(' '); + + useEffect(() => { + getResults({ lastLoadedPage: 1 }); + }, [getResults]); + + return ( +
+
+ Compare version against: +
+ {!errorLoading && ( + { + getResults({ lastLoadedPage: lastLoadedPage + 1 }); + }} + value={value} + options={options} + /> + )} + {errorLoading && ( +
+ {errorLoading} +
+ )} +
+ ); +}; + +export default CompareVersion; diff --git a/src/admin/components/views/Version/Compare/types.ts b/src/admin/components/views/Version/Compare/types.ts new file mode 100644 index 0000000000..5430d8e355 --- /dev/null +++ b/src/admin/components/views/Version/Compare/types.ts @@ -0,0 +1,29 @@ +import { SanitizedCollectionConfig } from '../../../../../collections/config/types'; +import { PaginatedDocs } from '../../../../../mongoose/types'; +import { CompareOption } from '../types'; + +export type Props = { + onChange: (val: CompareOption) => void, + value: CompareOption, + baseURL: string + parentID?: string +} + + +type CLEAR = { + type: 'CLEAR' + required: boolean +} + +type ADD = { + type: 'ADD' + data: PaginatedDocs + collection: SanitizedCollectionConfig +} + +export type Action = CLEAR | ADD + +export type ValueWithRelation = { + relationTo: string + value: string +} diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/Label/index.scss b/src/admin/components/views/Version/RenderFieldsToDiff/Label/index.scss new file mode 100644 index 0000000000..5813e248f6 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/Label/index.scss @@ -0,0 +1,6 @@ +@import '../../../../../scss/styles.scss'; + +.field-diff-label { + margin-bottom: base(.25); + font-weight: 600; +} diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/Label/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/Label/index.tsx new file mode 100644 index 0000000000..c4572488e2 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/Label/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import './index.scss'; + +const baseClass = 'field-diff-label'; + +const Label: React.FC = ({ children }) => ( +
+ {children} +
+); + +export default Label; diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Iterable/index.scss b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Iterable/index.scss new file mode 100644 index 0000000000..ae201e4ef5 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Iterable/index.scss @@ -0,0 +1,25 @@ +@import '../../../../../../scss/styles.scss'; + +.iterable-diff { + margin-bottom: base(2); + + &__locale-label { + margin-right: base(.25); + background: $color-background-gray; + padding: base(.25); + border-radius: $style-radius-m; + } + + &__wrap { + margin: base(.5) 0; + padding-left: base(.5); + border-left: $style-stroke-width-s solid $color-light-gray; + } + + &__no-rows { + font-family: monospace; + background-color: #fafbfc; + padding: base(.125) 0; + margin: base(.125) 0; + } +} diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx new file mode 100644 index 0000000000..116167d82c --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import RenderFieldsToDiff from '../..'; +import { Props } from '../types'; +import Label from '../../Label'; +import { ArrayField, BlockField, Field, fieldAffectsData } from '../../../../../../../fields/config/types'; +import getUniqueListBy from '../../../../../../../utilities/getUniqueListBy'; + +import './index.scss'; + +const baseClass = 'iterable-diff'; + +const Iterable: React.FC = ({ + version, + comparison, + permissions, + field, + locale, + locales, + fieldComponents, +}) => { + const versionRowCount = Array.isArray(version) ? version.length : 0; + const comparisonRowCount = Array.isArray(comparison) ? comparison.length : 0; + const maxRows = Math.max(versionRowCount, comparisonRowCount); + + return ( +
+ {field.label && ( + + )} + {maxRows > 0 && ( + + {Array.from(Array(maxRows).keys()).map((row, i) => { + const versionRow = version?.[i] || {}; + const comparisonRow = comparison?.[i] || {}; + + let subFields: Field[] = []; + + if (field.type === 'array') subFields = field.fields; + + if (field.type === 'blocks') { + subFields = [ + { + name: 'blockType', + label: 'Block Type', + type: 'text', + }, + ]; + + if (versionRow?.blockType === comparisonRow?.blockType) { + const matchedBlock = field.blocks.find((block) => block.slug === versionRow?.blockType) || { fields: [] }; + subFields = [ + ...subFields, + ...matchedBlock.fields, + ]; + } else { + const matchedVersionBlock = field.blocks.find((block) => block.slug === versionRow?.blockType) || { fields: [] }; + const matchedComparisonBlock = field.blocks.find((block) => block.slug === comparisonRow?.blockType) || { fields: [] }; + + subFields = getUniqueListBy([ + ...subFields, + ...matchedVersionBlock.fields, + ...matchedComparisonBlock.fields, + ], 'name'); + } + } + + return ( +
+ !(fieldAffectsData(subField) && subField.name === 'id'))} + fieldComponents={fieldComponents} + /> +
+ ); + })} +
+ )} + {maxRows === 0 && ( +
+ No + {' '} + {field.labels.plural} + {' '} + found +
+ )} +
+ ); +}; + +export default Iterable; diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Nested/index.scss b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Nested/index.scss new file mode 100644 index 0000000000..11a2fc1551 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Nested/index.scss @@ -0,0 +1,8 @@ +@import '../../../../../../scss/styles.scss'; + +.nested-diff { + &__wrap--gutter { + padding-left: base(1); + border-left: $style-stroke-width-s solid $color-light-gray; + } +} diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Nested/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Nested/index.tsx new file mode 100644 index 0000000000..a8e3b567d9 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Nested/index.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import RenderFieldsToDiff from '../..'; +import { Props } from '../types'; +import Label from '../../Label'; +import { FieldWithSubFields } from '../../../../../../../fields/config/types'; + +import './index.scss'; + +const baseClass = 'nested-diff'; + +const Nested: React.FC = ({ + version, + comparison, + permissions, + field, + locale, + locales, + fieldComponents, + disableGutter = false, +}) => ( +
+ {field.label && ( + + )} +
+ +
+
+); + +export default Nested; diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Relationship/index.scss b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Relationship/index.scss new file mode 100644 index 0000000000..a499f9d6ff --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Relationship/index.scss @@ -0,0 +1,10 @@ +@import '../../../../../../scss/styles.scss'; + +.relationship-diff { + &__locale-label { + margin-right: base(.25); + background: $color-background-gray; + padding: base(.25); + border-radius: $style-radius-m; + } +} diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx new file mode 100644 index 0000000000..a98b498c96 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx @@ -0,0 +1,100 @@ +import { useConfig } from '@payloadcms/config-provider'; +import React from 'react'; +import ReactDiffViewer from 'react-diff-viewer'; +import { useLocale } from '../../../../../utilities/Locale'; +import { SanitizedCollectionConfig } from '../../../../../../../collections/config/types'; +import { fieldAffectsData, fieldIsPresentationalOnly, RelationshipField } from '../../../../../../../fields/config/types'; +import Label from '../../Label'; +import { Props } from '../types'; + +import './index.scss'; + +const baseClass = 'relationship-diff'; + +type RelationshipValue = Record; + +const generateLabelFromValue = ( + collections: SanitizedCollectionConfig[], + field: RelationshipField, + locale: string, + value: RelationshipValue | { relationTo: string, value: RelationshipValue }, +): string => { + let relation: string; + let relatedDoc: RelationshipValue; + let valueToReturn = ''; + + if (Array.isArray(field.relationTo)) { + if (typeof value === 'object') { + relation = value.relationTo; + relatedDoc = value.value; + } + } else { + relation = field.relationTo; + relatedDoc = value; + } + + const relatedCollection = collections.find((c) => c.slug === relation); + + if (relatedCollection) { + const useAsTitle = relatedCollection?.admin?.useAsTitle; + const useAsTitleField = relatedCollection.fields.find((f) => (fieldAffectsData(f) && !fieldIsPresentationalOnly(f)) && f.name === useAsTitle); + let titleFieldIsLocalized = false; + + if (useAsTitleField && fieldAffectsData(useAsTitleField)) titleFieldIsLocalized = useAsTitleField.localized; + + + if (typeof relatedDoc?.[useAsTitle] !== 'undefined') { + valueToReturn = relatedDoc[useAsTitle]; + } else if (typeof relatedDoc?.id !== 'undefined') { + valueToReturn = relatedDoc.id; + } + + if (typeof valueToReturn === 'object' && titleFieldIsLocalized) { + valueToReturn = valueToReturn[locale]; + } + } + + return valueToReturn; +}; + +const Relationship: React.FC = ({ field, version, comparison }) => { + const { collections } = useConfig(); + const locale = useLocale(); + + let placeholder = ''; + + if (version === comparison) placeholder = '[no value]'; + + let versionToRender = version; + let comparisonToRender = comparison; + + if (field.hasMany) { + if (Array.isArray(version)) versionToRender = version.map((val) => generateLabelFromValue(collections, field, locale, val)).join(', '); + if (Array.isArray(comparison)) comparisonToRender = comparison.map((val) => generateLabelFromValue(collections, field, locale, val)).join(', '); + } else { + versionToRender = generateLabelFromValue(collections, field, locale, version); + comparisonToRender = generateLabelFromValue(collections, field, locale, comparison); + } + + return ( +
+ + +
+ ); + + return null; +}; + +export default Relationship; diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.scss b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.scss new file mode 100644 index 0000000000..10f858f3af --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.scss @@ -0,0 +1,10 @@ +@import '../../../../../../scss/styles.scss'; + +.text-diff { + &__locale-label { + margin-right: base(.25); + background: $color-background-gray; + padding: base(.25); + border-radius: $style-radius-m; + } +} diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.tsx new file mode 100644 index 0000000000..5c27477d75 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer'; +import Label from '../../Label'; +import { Props } from '../types'; + +import './index.scss'; + +const baseClass = 'text-diff'; + +const Text: React.FC = ({ field, locale, version, comparison, isRichText = false, diffMethod }) => { + let placeholder = ''; + + if (version === comparison) placeholder = '[no value]'; + + let versionToRender = version; + let comparisonToRender = comparison; + + if (isRichText) { + if (typeof version === 'object') versionToRender = JSON.stringify(version, null, 2); + if (typeof comparison === 'object') comparisonToRender = JSON.stringify(comparison, null, 2); + } + + return ( +
+ + +
+ ); + + return null; +}; + +export default Text; diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/richTextToHTML.ts b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/richTextToHTML.ts new file mode 100644 index 0000000000..869b583014 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/richTextToHTML.ts @@ -0,0 +1,115 @@ +import { Text } from 'slate'; + +export const richTextToHTML = (content: unknown): string => { + if (Array.isArray(content)) { + return content.reduce((output, node) => { + const isTextNode = Text.isText(node); + + const { + text, + bold, + code, + italic, + underline, + strikethrough, + } = node; + + if (isTextNode) { + // convert straight single quotations to curly + // "\u201C" is starting double curly + // "\u201D" is ending double curly + let html = text?.replace(/'/g, '\u2019'); // single quotes + + if (bold) { + html = `${html}`; + } + + if (code) { + html = `${html}`; + } + + if (italic) { + html = `${html}`; + } + + if (underline) { + html = `${html}`; + } + + if (strikethrough) { + html = `${html}`; + } + + return `${output}${html}`; + } + + if (node) { + let nodeHTML; + switch (node.type) { + case 'h1': + nodeHTML = `

${richTextToHTML(node.children)}

`; + break; + + case 'h2': + nodeHTML = `

${richTextToHTML(node.children)}

`; + break; + + case 'h3': + nodeHTML = `

${richTextToHTML(node.children)}

`; + break; + + case 'h4': + nodeHTML = `

${richTextToHTML(node.children)}

`; + break; + + case 'h5': + nodeHTML = `
${richTextToHTML(node.children)}
`; + break; + + case 'h6': + nodeHTML = `
${richTextToHTML(node.children)}
`; + break; + + case 'ul': + nodeHTML = `
    ${richTextToHTML(node.children)}
`; + break; + + case 'ol': + nodeHTML = `
    ${richTextToHTML(node.children)}
`; + break; + + case 'li': + nodeHTML = `
  • ${richTextToHTML(node.children)}
  • `; + break; + + case 'link': + nodeHTML = `${richTextToHTML(node.children)}`; + break; + + case 'relationship': + nodeHTML = `Relationship to ${node.relationTo}: ${node.value}
    `; + break; + + case 'upload': + nodeHTML = `${node.relationTo} Upload: ${node.value}
    `; + break; + + case 'p': + case undefined: + nodeHTML = `

    ${richTextToHTML(node.children)}

    `; + break; + + default: + nodeHTML = `${node.type}:
    ${JSON.stringify(node)}`; + break; + } + + return `${output}${nodeHTML}\n`; + } + + return output; + }, ''); + } + + return ''; +}; diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/stringifyRichText.ts b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/stringifyRichText.ts new file mode 100644 index 0000000000..116a9432b3 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/stringifyRichText.ts @@ -0,0 +1,65 @@ +import { Text } from 'slate'; + +export const stringifyRichText = (content: unknown): string => { + if (Array.isArray(content)) { + return content.reduce((output, node) => { + const isTextNode = Text.isText(node); + + const { + text, + } = node; + + if (isTextNode) { + // convert straight single quotations to curly + // "\u201C" is starting double curly + // "\u201D" is ending double curly + const sanitizedText = text?.replace(/'/g, '\u2019'); // single quotes + return `${output}${sanitizedText}`; + } + + if (node) { + let nodeHTML; + switch (node.type) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + case 'li': + case 'p': + case undefined: + nodeHTML = `${stringifyRichText(node.children)}\n`; + break; + + case 'ul': + case 'ol': + nodeHTML = `${stringifyRichText(node.children)}\n\n`; + break; + + case 'link': + nodeHTML = `${stringifyRichText(node.children)}`; + break; + + case 'relationship': + nodeHTML = `Relationship to ${node.relationTo}: ${node?.value?.id}\n\n`; + break; + + case 'upload': + nodeHTML = `${node.relationTo} Upload: ${node?.value?.id}\n\n`; + break; + + default: + nodeHTML = `${node.type}: ${JSON.stringify(node)}\n\n`; + break; + } + + return `${output}${nodeHTML}`; + } + + return output; + }, ''); + } + + return ''; +}; diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/diffMethods.ts b/src/admin/components/views/Version/RenderFieldsToDiff/fields/diffMethods.ts new file mode 100644 index 0000000000..c15fb7629d --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/diffMethods.ts @@ -0,0 +1,6 @@ +export const diffMethods = { + select: 'WORDS_WITH_SPACE', + relationship: 'WORDS_WITH_SPACE', + upload: 'WORDS_WITH_SPACE', + radio: 'WORDS_WITH_SPACE', +}; diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/fields/index.tsx new file mode 100644 index 0000000000..2628c7e072 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/index.tsx @@ -0,0 +1,24 @@ +import Text from './Text'; +import Nested from './Nested'; +import Iterable from './Iterable'; +import Relationship from './Relationship'; + +export default { + text: Text, + textarea: Text, + number: Text, + email: Text, + code: Text, + checkbox: Text, + radio: Text, + row: Nested, + group: Nested, + array: Iterable, + blocks: Iterable, + date: Text, + select: Text, + richText: Text, + relationship: Relationship, + upload: Relationship, + point: Text, +}; diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/types.ts b/src/admin/components/views/Version/RenderFieldsToDiff/fields/types.ts new file mode 100644 index 0000000000..37b174ec13 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/types.ts @@ -0,0 +1,18 @@ +import React from 'react'; +import { DiffMethod } from 'react-diff-viewer'; +import { FieldPermissions } from '../../../../../../auth'; + +export type FieldComponents = Record> + +export type Props = { + diffMethod?: DiffMethod + fieldComponents: FieldComponents + version: any + comparison: any + field: any + permissions?: Record + locale?: string + locales?: string[] + disableGutter?: boolean + isRichText?: boolean +} diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/index.scss b/src/admin/components/views/Version/RenderFieldsToDiff/index.scss new file mode 100644 index 0000000000..fb0b9f42b0 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/index.scss @@ -0,0 +1,11 @@ +@import '../../../../scss/styles.scss'; + +.render-field-diffs { + &__field { + margin-bottom: $baseline; + } + + &__locale { + margin-bottom: base(.5); + } +} diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/index.tsx new file mode 100644 index 0000000000..f628f35439 --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/index.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { DiffMethod } from 'react-diff-viewer'; +import { Props } from './types'; +import { fieldAffectsData, fieldHasSubFields } from '../../../../../fields/config/types'; +import Nested from './fields/Nested'; + +import './index.scss'; +import { diffMethods } from './fields/diffMethods'; + +const baseClass = 'render-field-diffs'; + +const RenderFieldsToDiff: React.FC = ({ + fields, + fieldComponents, + fieldPermissions, + version, + comparison, + locales, +}) => ( +
    + {fields.map((field, i) => { + const Component = fieldComponents[field.type]; + + const isRichText = field.type === 'richText'; + const diffMethod: DiffMethod = diffMethods[field.type] || 'CHARS'; + + if (Component) { + if (fieldAffectsData(field)) { + const versionValue = version?.[field.name]; + const comparisonValue = comparison?.[field.name]; + const hasPermission = fieldPermissions?.[field.name]?.read?.permission; + const subFieldPermissions = fieldPermissions?.[field.name]?.fields; + + if (hasPermission === false) return null; + + if (field.localized) { + return ( +
    + {locales.map((locale) => { + const versionLocaleValue = versionValue?.[locale]; + const comparisonLocaleValue = comparisonValue?.[locale]; + return ( +
    +
    + +
    +
    + ); + })} +
    + ); + } + + return ( +
    + +
    + ); + } + + // At this point, we are dealing with a `row` or similar + if (fieldHasSubFields(field)) { + return ( + + ); + } + } + + return null; + })} +
    +); + +export default RenderFieldsToDiff; diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/types.ts b/src/admin/components/views/Version/RenderFieldsToDiff/types.ts new file mode 100644 index 0000000000..9a4216d0be --- /dev/null +++ b/src/admin/components/views/Version/RenderFieldsToDiff/types.ts @@ -0,0 +1,12 @@ +import { FieldPermissions } from '../../../../../auth'; +import { Field } from '../../../../../fields/config/types'; +import { FieldComponents } from './fields/types'; + +export type Props = { + fields: Field[] + fieldComponents: FieldComponents, + fieldPermissions: Record + version: Record + comparison: Record + locales: string[] +} diff --git a/src/admin/components/views/Version/Restore/index.scss b/src/admin/components/views/Version/Restore/index.scss new file mode 100644 index 0000000000..16898cb5f8 --- /dev/null +++ b/src/admin/components/views/Version/Restore/index.scss @@ -0,0 +1,20 @@ +@import '../../../../scss/styles.scss'; + +.restore-version { + cursor: pointer; + + &__modal { + @include blur-bg; + display: flex; + align-items: center; + height: 100%; + + &__toggle { + @extend %btn-reset; + } + + .btn { + margin-right: $baseline; + } + } +} diff --git a/src/admin/components/views/Version/Restore/index.tsx b/src/admin/components/views/Version/Restore/index.tsx new file mode 100644 index 0000000000..65cc1c02c3 --- /dev/null +++ b/src/admin/components/views/Version/Restore/index.tsx @@ -0,0 +1,84 @@ +import React, { Fragment, useCallback, useState } from 'react'; +import { toast } from 'react-toastify'; +import { Modal, useModal } from '@faceless-ui/modal'; +import { useConfig } from '@payloadcms/config-provider'; +import { useHistory } from 'react-router-dom'; +import { Button, MinimalTemplate, Pill } from '../../..'; +import { Props } from './types'; +import { requests } from '../../../../api'; + +import './index.scss'; + +const baseClass = 'restore-version'; +const modalSlug = 'restore-version'; + +const Restore: React.FC = ({ collection, global, className, versionID, originalDocID, versionDate }) => { + const { serverURL, routes: { api, admin } } = useConfig(); + const history = useHistory(); + const { toggle } = useModal(); + const [processing, setProcessing] = useState(false); + + let fetchURL = `${serverURL}${api}`; + let redirectURL: string; + let restoreMessage: string; + + if (collection) { + fetchURL = `${fetchURL}/${collection.slug}/versions/${versionID}`; + redirectURL = `${admin}/collections/${collection.slug}/${originalDocID}`; + restoreMessage = `You are about to restore this ${collection.labels.singular} document to the state that it was in on ${versionDate}.`; + } + + if (global) { + fetchURL = `${fetchURL}/globals/${global.slug}/versions/${versionID}`; + redirectURL = `${admin}/globals/${global.slug}`; + restoreMessage = `You are about to restore the global ${global.label} to the state that it was in on ${versionDate}.`; + } + + const handleRestore = useCallback(async () => { + setProcessing(true); + + const res = await requests.post(fetchURL); + + if (res.status === 200) { + const json = await res.json(); + toast.success(json.message); + history.push(redirectURL); + } else { + toast.error('There was a problem while restoring this version.'); + } + }, [history, fetchURL, redirectURL]); + + return ( + + toggle(modalSlug)} + className={[baseClass, className].filter(Boolean).join(' ')} + > + Restore this version + + + +

    Confirm version restoration

    +

    {restoreMessage}

    + + +
    +
    +
    + ); +}; + +export default Restore; diff --git a/src/admin/components/views/Version/Restore/types.ts b/src/admin/components/views/Version/Restore/types.ts new file mode 100644 index 0000000000..7355b70884 --- /dev/null +++ b/src/admin/components/views/Version/Restore/types.ts @@ -0,0 +1,11 @@ +import { SanitizedCollectionConfig } from '../../../../../collections/config/types'; +import { SanitizedGlobalConfig } from '../../../../../globals/config/types'; + +export type Props = { + collection?: SanitizedCollectionConfig + global?: SanitizedGlobalConfig + className?: string + versionID: string + originalDocID: string + versionDate: string +} diff --git a/src/admin/components/views/Version/SelectLocales/index.scss b/src/admin/components/views/Version/SelectLocales/index.scss new file mode 100644 index 0000000000..a9374f85fe --- /dev/null +++ b/src/admin/components/views/Version/SelectLocales/index.scss @@ -0,0 +1,9 @@ +@import '../../../../scss/styles.scss'; + +.select-version-locales { + flex-grow: 1; + + &__label { + margin-bottom: base(.25); + } +} diff --git a/src/admin/components/views/Version/SelectLocales/index.tsx b/src/admin/components/views/Version/SelectLocales/index.tsx new file mode 100644 index 0000000000..f23e53bd37 --- /dev/null +++ b/src/admin/components/views/Version/SelectLocales/index.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import ReactSelect from '../../../elements/ReactSelect'; +import { Props } from './types'; + +import './index.scss'; + +const baseClass = 'select-version-locales'; + +const SelectLocales: React.FC = ({ onChange, value, options }) => ( +
    +
    + Show locales: +
    + +
    +); + +export default SelectLocales; diff --git a/src/admin/components/views/Version/SelectLocales/types.ts b/src/admin/components/views/Version/SelectLocales/types.ts new file mode 100644 index 0000000000..f3602d6730 --- /dev/null +++ b/src/admin/components/views/Version/SelectLocales/types.ts @@ -0,0 +1,7 @@ +import { LocaleOption } from '../types'; + +export type Props = { + onChange: (options: LocaleOption[]) => void + value: LocaleOption[] + options: LocaleOption[] +} diff --git a/src/admin/components/views/Version/Version.tsx b/src/admin/components/views/Version/Version.tsx new file mode 100644 index 0000000000..ff3c5984d3 --- /dev/null +++ b/src/admin/components/views/Version/Version.tsx @@ -0,0 +1,222 @@ +import React, { useEffect, useState } from 'react'; +import { useAuth, useConfig } from '@payloadcms/config-provider'; +import { useRouteMatch } from 'react-router-dom'; +import format from 'date-fns/format'; +import usePayloadAPI from '../../../hooks/usePayloadAPI'; +import Eyebrow from '../../elements/Eyebrow'; +import Loading from '../../elements/Loading'; +import { useStepNav } from '../../elements/StepNav'; +import { StepNavItem } from '../../elements/StepNav/types'; +import Meta from '../../utilities/Meta'; +import { LocaleOption, CompareOption, Props } from './types'; +import CompareVersion from './Compare'; +import { publishedVersionOption } from './shared'; +import Restore from './Restore'; +import SelectLocales from './SelectLocales'; +import RenderFieldsToDiff from './RenderFieldsToDiff'; +import fieldComponents from './RenderFieldsToDiff/fields'; + +import { Field, FieldAffectingData, fieldAffectsData } from '../../../../fields/config/types'; +import { FieldPermissions } from '../../../../auth'; +import { useLocale } from '../../utilities/Locale'; + +import './index.scss'; + +const baseClass = 'view-version'; + +const VersionView: React.FC = ({ collection, global }) => { + const { serverURL, routes: { admin, api }, admin: { dateFormat }, localization } = useConfig(); + const { setStepNav } = useStepNav(); + const { params: { id, versionID } } = useRouteMatch<{ id?: string, versionID: string }>(); + const [compareValue, setCompareValue] = useState(publishedVersionOption); + const [localeOptions] = useState(() => (localization?.locales ? localization.locales.map((locale) => ({ label: locale, value: locale })) : [])); + const [locales, setLocales] = useState(localeOptions); + const { permissions } = useAuth(); + const locale = useLocale(); + + let originalDocFetchURL: string; + let versionFetchURL: string; + let entityLabel: string; + let fields: Field[]; + let fieldPermissions: Record; + let compareBaseURL: string; + let slug: string; + let parentID: string; + + if (collection) { + ({ slug } = collection); + originalDocFetchURL = `${serverURL}${api}/${slug}/${id}`; + versionFetchURL = `${serverURL}${api}/${slug}/versions/${versionID}`; + compareBaseURL = `${serverURL}${api}/${slug}/versions`; + entityLabel = collection.labels.singular; + parentID = id; + fields = collection.fields; + fieldPermissions = permissions.collections[collection.slug].fields; + } + + if (global) { + ({ slug } = global); + originalDocFetchURL = `${serverURL}${api}/globals/${slug}`; + versionFetchURL = `${serverURL}${api}/globals/${slug}/versions/${versionID}`; + compareBaseURL = `${serverURL}${api}/globals/${slug}/versions`; + entityLabel = global.label; + fields = global.fields; + fieldPermissions = permissions.globals[global.slug].fields; + } + + const compareFetchURL = compareValue?.value === 'mostRecent' || compareValue?.value === 'published' ? originalDocFetchURL : `${compareBaseURL}/${compareValue.value}`; + + const [{ data: doc, isLoading }] = usePayloadAPI(versionFetchURL, { initialParams: { locale: '*', depth: 1 } }); + const [{ data: publishedDoc }] = usePayloadAPI(originalDocFetchURL, { initialParams: { locale: '*', depth: 1 } }); + const [{ data: mostRecentDoc }] = usePayloadAPI(originalDocFetchURL, { initialParams: { locale: '*', depth: 1, draft: true } }); + const [{ data: compareDoc }] = usePayloadAPI(compareFetchURL, { initialParams: { locale: '*', depth: 1, draft: 'true' } }); + + useEffect(() => { + let nav: StepNavItem[] = []; + + if (collection) { + let docLabel = ''; + + if (publishedDoc) { + const { useAsTitle } = collection.admin; + + if (useAsTitle !== 'id') { + const titleField = collection.fields.find((field) => fieldAffectsData(field) && field.name === useAsTitle) as FieldAffectingData; + + if (titleField && publishedDoc[useAsTitle]) { + if (titleField.localized) { + docLabel = publishedDoc[useAsTitle]?.[locale]; + } else { + docLabel = publishedDoc[useAsTitle]; + } + } else { + docLabel = '[Untitled]'; + } + } else { + docLabel = publishedDoc.id; + } + } + + nav = [ + { + url: `${admin}/collections/${collection.slug}`, + label: collection.labels.plural, + }, + { + label: docLabel, + url: `${admin}/collections/${collection.slug}/${id}`, + }, + { + label: 'Versions', + url: `${admin}/collections/${collection.slug}/${id}/versions`, + }, + { + label: doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '', + }, + ]; + } + + if (global) { + nav = [ + { + url: `${admin}/globals/${global.slug}`, + label: global.label, + }, + { + label: 'Versions', + url: `${admin}/globals/${global.slug}/versions`, + }, + { + label: doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '', + }, + ]; + } + + setStepNav(nav); + }, [setStepNav, collection, global, dateFormat, doc, publishedDoc, admin, id, locale]); + + let metaTitle: string; + let metaDesc: string; + const formattedCreatedAt = doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : ''; + + if (collection) { + const useAsTitle = collection?.admin?.useAsTitle || 'id'; + metaTitle = `Version - ${formattedCreatedAt} - ${doc[useAsTitle]} - ${entityLabel}`; + metaDesc = `Viewing version for the ${entityLabel} ${doc[useAsTitle]}`; + } + + if (global) { + metaTitle = `Version - ${formattedCreatedAt} - ${entityLabel}`; + metaDesc = `Viewing version for the global ${entityLabel}`; + } + + let comparison = compareDoc?.version; + + if (compareValue?.value === 'mostRecent') { + comparison = mostRecentDoc; + } + + if (compareValue?.value === 'published') { + comparison = publishedDoc; + } + + return ( +
    + + +
    +
    + {doc?.autosave ? 'Autosaved version ' : 'Version'} + {' '} + created on: +
    +
    +

    + {formattedCreatedAt} +

    + +
    +
    + {localization && ( + + )} + +
    + {isLoading && ( + + )} + {doc?.version && ( + locale.value)} + fields={fields} + fieldComponents={fieldComponents} + fieldPermissions={fieldPermissions} + version={doc?.version} + comparison={comparison} + /> + )} +
    +
    + ); +}; + +export default VersionView; diff --git a/src/admin/components/views/Version/index.scss b/src/admin/components/views/Version/index.scss new file mode 100644 index 0000000000..821ae11d17 --- /dev/null +++ b/src/admin/components/views/Version/index.scss @@ -0,0 +1,65 @@ +@import '../../../scss/styles.scss'; + +.view-version { + width: 100%; + margin-bottom: base(2); + + &__wrap { + padding: base(3); + margin-right: base(2); + background: white; + } + + &__header { + margin-bottom: $baseline; + display: flex; + align-items: center; + flex-wrap: wrap; + + h2 { + margin: 0; + } + } + + &__controls { + display: flex; + margin-bottom: $baseline; + margin-left: base(-.5); + margin-right: base(-.5); + + > * { + margin-left: base(.5); + margin-right: base(.5); + flex-basis: 100%; + } + } + + &__restore { + margin: 0 0 0 $baseline; + } + + @include mid-break { + &__wrap { + padding: $baseline; + margin-right: 0; + } + + &__intro, + &__header { + display: block; + } + + &__controls { + display: block; + margin: 0 base(-.5) base(2); + + > * { + margin-bottom: base(.5); + } + } + + &__restore { + margin: base(.5) 0 0 0; + } + } +} diff --git a/src/admin/components/views/Version/index.tsx b/src/admin/components/views/Version/index.tsx new file mode 100644 index 0000000000..fee86184df --- /dev/null +++ b/src/admin/components/views/Version/index.tsx @@ -0,0 +1,13 @@ +import React, { Suspense, lazy } from 'react'; +import Loading from '../../elements/Loading'; +import { Props } from './types'; + +const VersionView = lazy(() => import('./Version')); + +const Version: React.FC = (props) => ( + }> + + +); + +export default Version; diff --git a/src/admin/components/views/Version/shared.ts b/src/admin/components/views/Version/shared.ts new file mode 100644 index 0000000000..a514b1c114 --- /dev/null +++ b/src/admin/components/views/Version/shared.ts @@ -0,0 +1,9 @@ +export const mostRecentVersionOption = { + label: 'Most recent draft', + value: 'mostRecent', +}; + +export const publishedVersionOption = { + label: 'Most recently published', + value: 'published', +}; diff --git a/src/admin/components/views/Version/types.ts b/src/admin/components/views/Version/types.ts new file mode 100644 index 0000000000..276b8913ab --- /dev/null +++ b/src/admin/components/views/Version/types.ts @@ -0,0 +1,19 @@ +import { SanitizedCollectionConfig } from '../../../../collections/config/types'; +import { SanitizedGlobalConfig } from '../../../../globals/config/types'; + +export type LocaleOption = { + label: string + value: string +} + +export type CompareOption = { + label: string + value: string + relationTo?: string + options?: CompareOption[] +} + +export type Props = { + collection?: SanitizedCollectionConfig + global?: SanitizedGlobalConfig +} diff --git a/src/admin/components/views/Versions/columns.tsx b/src/admin/components/views/Versions/columns.tsx new file mode 100644 index 0000000000..6bd4f03127 --- /dev/null +++ b/src/admin/components/views/Versions/columns.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { Link, useRouteMatch } from 'react-router-dom'; +import { useConfig } from '@payloadcms/config-provider'; +import format from 'date-fns/format'; +import { Column } from '../../elements/Table/types'; +import SortColumn from '../../elements/SortColumn'; +import { SanitizedCollectionConfig } from '../../../../collections/config/types'; +import { SanitizedGlobalConfig } from '../../../../globals/config/types'; +import { Pill } from '../..'; + +type CreatedAtCellProps = { + id: string + date: string + collection?: SanitizedCollectionConfig + global?: SanitizedGlobalConfig +} + +const CreatedAtCell: React.FC = ({ collection, global, id, date }) => { + const { routes: { admin }, admin: { dateFormat } } = useConfig(); + const { params: { id: docID } } = useRouteMatch<{ id: string }>(); + + let to: string; + + if (collection) to = `${admin}/collections/${collection.slug}/${docID}/versions/${id}`; + if (global) to = `${admin}/globals/${global.slug}/versions/${id}`; + + return ( + + {date && format(new Date(date), dateFormat)} + + ); +}; + +const TextCell: React.FC = ({ children }) => ( + + {children} + +); + +export const getColumns = (collection: SanitizedCollectionConfig, global: SanitizedGlobalConfig): Column[] => [ + { + accessor: 'updatedAt', + components: { + Heading: ( + + ), + renderCell: (row, data) => ( + + ), + }, + }, + { + accessor: 'id', + components: { + Heading: ( + + ), + renderCell: (row, data) => {data}, + }, + }, + { + accessor: 'autosave', + components: { + Heading: ( + + ), + renderCell: (row) => ( + + {row?.autosave && ( + + + Autosave + +    + + )} + {row?.version._status === 'published' && ( + + + Published + +    + + )} + {row?.version._status === 'draft' && ( + + Draft + + )} + + ), + }, + }, +]; diff --git a/src/admin/components/views/Versions/index.scss b/src/admin/components/views/Versions/index.scss new file mode 100644 index 0000000000..3ca9608a8e --- /dev/null +++ b/src/admin/components/views/Versions/index.scss @@ -0,0 +1,69 @@ +@import '../../../scss/styles.scss'; + +.versions { + width: 100%; + margin-bottom: base(2); + + &__wrap { + padding: base(3); + margin-right: base(2); + background: white; + } + + &__header { + margin-bottom: $baseline; + } + + &__intro { + margin-bottom: base(.5); + } + + .table { + table { + width: 100%; + overflow: auto; + } + } + + &__page-controls { + width: 100%; + display: flex; + align-items: center; + } + + .paginator { + margin-bottom: 0; + } + + &__page-info { + margin-right: base(1); + margin-left: auto; + } + + @include mid-break { + &__wrap { + padding: $baseline 0; + margin-right: 0; + } + + &__header, + .table, + &__page-controls { + padding-left: $baseline; + padding-right: $baseline; + } + + &__page-controls { + flex-wrap: wrap; + } + + &__page-info { + margin-left: 0; + } + + .paginator { + width: 100%; + margin-bottom: $baseline; + } + } +} diff --git a/src/admin/components/views/Versions/index.tsx b/src/admin/components/views/Versions/index.tsx new file mode 100644 index 0000000000..88670a8ff5 --- /dev/null +++ b/src/admin/components/views/Versions/index.tsx @@ -0,0 +1,216 @@ +import { useConfig } from '@payloadcms/config-provider'; +import React, { useEffect, useState } from 'react'; +import { useRouteMatch } from 'react-router-dom'; +import usePayloadAPI from '../../../hooks/usePayloadAPI'; +import Eyebrow from '../../elements/Eyebrow'; +import Loading from '../../elements/Loading'; +import { useStepNav } from '../../elements/StepNav'; +import { StepNavItem } from '../../elements/StepNav/types'; +import Meta from '../../utilities/Meta'; +import { Props } from './types'; +import IDLabel from '../../elements/IDLabel'; +import { getColumns } from './columns'; +import Table from '../../elements/Table'; +import Paginator from '../../elements/Paginator'; +import PerPage from '../../elements/PerPage'; +import { useSearchParams } from '../../utilities/SearchParams'; + +import './index.scss'; + +const baseClass = 'versions'; + +const Versions: React.FC = ({ collection, global }) => { + const { serverURL, routes: { admin, api } } = useConfig(); + const { setStepNav } = useStepNav(); + const { params: { id } } = useRouteMatch<{ id: string }>(); + const [tableColumns] = useState(() => getColumns(collection, global)); + const [fetchURL, setFetchURL] = useState(''); + const { page, sort, limit } = useSearchParams(); + + let docURL: string; + let entityLabel: string; + let slug: string; + + if (collection) { + ({ slug } = collection); + docURL = `${serverURL}${api}/${slug}/${id}`; + entityLabel = collection.labels.singular; + } + + if (global) { + ({ slug } = global); + docURL = `${serverURL}${api}/globals/${slug}`; + entityLabel = global.label; + } + + const useAsTitle = collection?.admin?.useAsTitle || 'id'; + const [{ data: doc }] = usePayloadAPI(docURL, { initialParams: { draft: 'true' } }); + const [{ data: versionsData, isLoading: isLoadingVersions }, { setParams }] = usePayloadAPI(fetchURL); + + useEffect(() => { + let nav: StepNavItem[] = []; + + if (collection) { + let docLabel = ''; + + if (doc) { + if (useAsTitle) { + if (doc[useAsTitle]) { + docLabel = doc[useAsTitle]; + } else { + docLabel = '[Untitled]'; + } + } else { + docLabel = doc.id; + } + } + + nav = [ + { + url: `${admin}/collections/${collection.slug}`, + label: collection.labels.plural, + }, + { + label: docLabel, + url: `${admin}/collections/${collection.slug}/${id}`, + }, + { + label: 'Versions', + }, + ]; + } + + if (global) { + nav = [ + { + url: `${admin}/globals/${global.slug}`, + label: global.label, + }, + { + label: 'Versions', + }, + ]; + } + + setStepNav(nav); + }, [setStepNav, collection, global, useAsTitle, doc, admin, id]); + + useEffect(() => { + const params = { + depth: 1, + page: undefined, + sort: undefined, + limit, + where: {}, + }; + + if (page) params.page = page; + if (sort) params.sort = sort; + + let fetchURLToSet: string; + + if (collection) { + fetchURLToSet = `${serverURL}${api}/${collection.slug}/versions`; + params.where = { + parent: { + equals: id, + }, + }; + } + + if (global) { + fetchURLToSet = `${serverURL}${api}/globals/${global.slug}/versions`; + } + + // Performance enhancement + // Setting the Fetch URL this way + // prevents a double-fetch + + setFetchURL(fetchURLToSet); + + setParams(params); + }, [setParams, page, sort, limit, serverURL, api, id, global, collection]); + + let useIDLabel = doc[useAsTitle] === doc?.id; + let heading: string; + let metaDesc: string; + let metaTitle: string; + + if (collection) { + metaTitle = `Versions - ${doc[useAsTitle]} - ${entityLabel}`; + metaDesc = `Viewing versions for the ${entityLabel} ${doc[useAsTitle]}`; + heading = doc?.[useAsTitle]; + } + + if (global) { + metaTitle = `Versions - ${entityLabel}`; + metaDesc = `Viewing versions for the global ${entityLabel}`; + heading = entityLabel; + useIDLabel = false; + } + + return ( +
    + + +
    +
    +
    Showing versions for:
    + {useIDLabel && ( + + )} + {!useIDLabel && ( +

    + {heading} +

    + )} +
    + {isLoadingVersions && ( + + )} + {versionsData?.docs && ( + + +
    + + {versionsData?.totalDocs > 0 && ( + +
    + {(versionsData.page * versionsData.limit) - (versionsData.limit - 1)} + - + {versionsData.totalPages > 1 && versionsData.totalPages !== versionsData.page ? (versionsData.limit * versionsData.page) : versionsData.totalDocs} + {' '} + of + {' '} + {versionsData.totalDocs} +
    + +
    + )} +
    + + )} + + + ); +}; + +export default Versions; diff --git a/src/admin/components/views/Versions/types.ts b/src/admin/components/views/Versions/types.ts new file mode 100644 index 0000000000..b2881ab151 --- /dev/null +++ b/src/admin/components/views/Versions/types.ts @@ -0,0 +1,7 @@ +import { SanitizedCollectionConfig } from '../../../../collections/config/types'; +import { SanitizedGlobalConfig } from '../../../../globals/config/types'; + +export type Props = { + collection?: SanitizedCollectionConfig + global?: SanitizedGlobalConfig +} diff --git a/src/admin/components/views/collections/Edit/Default.tsx b/src/admin/components/views/collections/Edit/Default.tsx index ab442ef5d5..79fd5135aa 100644 --- a/src/admin/components/views/collections/Edit/Default.tsx +++ b/src/admin/components/views/collections/Edit/Default.tsx @@ -16,8 +16,15 @@ import fieldTypes from '../../../forms/field-types'; import RenderTitle from '../../../elements/RenderTitle'; import LeaveWithoutSaving from '../../../modals/LeaveWithoutSaving'; import Auth from './Auth'; +import VersionsCount from '../../../elements/VersionsCount'; import Upload from './Upload'; import { Props } from './types'; +import Autosave from '../../../elements/Autosave'; + +import Status from '../../../elements/Status'; +import Publish from '../../../elements/Publish'; +import SaveDraft from '../../../elements/SaveDraft'; +import { useDocumentInfo } from '../../../utilities/DocumentInfo'; import './index.scss'; @@ -26,6 +33,7 @@ const baseClass = 'collection-edit'; const DefaultEditView: React.FC = (props) => { const { params: { id } = {} } = useRouteMatch>(); const { admin: { dateFormat }, routes: { admin } } = useConfig(); + const { publishedDoc } = useDocumentInfo(); const { collection, @@ -38,6 +46,7 @@ const DefaultEditView: React.FC = (props) => { apiURL, action, hasSavePermission, + autosaveEnabled, } = props; const { @@ -47,7 +56,9 @@ const DefaultEditView: React.FC = (props) => { useAsTitle, disableDuplicate, preview, + hideAPIURL, }, + versions, timestamps, auth, upload, @@ -81,15 +92,16 @@ const DefaultEditView: React.FC = (props) => { keywords={`${collection.labels.singular}, Payload, CMS`} /> - + {!(collection.versions?.drafts && collection.versions?.drafts?.autosave) && ( + + )}
    - -
    -

    - -

    -
    - {auth && ( +
    +

    + +

    +
    + {auth && ( = (props) => { email={data?.email} operation={operation} /> - )} - {upload && ( + )} + {upload && ( - )} - (!field?.admin?.position || (field?.admin?.position !== 'sidebar'))} - fieldTypes={fieldTypes} - fieldSchema={fields} - /> -
    + )} + (!field?.admin?.position || (field?.admin?.position !== 'sidebar'))} + fieldTypes={fieldTypes} + fieldSchema={fields} + />
    - {isEditing ? ( -
      - {(permissions?.create?.permission) && ( +
        + {(permissions?.create?.permission) && (
      • Create New
      • {!disableDuplicate && (
      • )}
        - )} - {permissions?.delete?.permission && ( + )} + {permissions?.delete?.permission && (
      • - )} -
      - ) : undefined} -
      - {isEditing && ( - + )} +
    +
    + {(preview && !autosaveEnabled) && ( + )} {hasSavePermission && ( - Save + + {collection.versions.drafts && ( + + {!collection.versions.drafts.autosave && ( + + )} + + + )} + {!collection.versions.drafts && ( + Save + )} + )}
    + {(isEditing && preview && autosaveEnabled) && ( + + )} + {collection.versions?.drafts && ( + + + {(collection.versions.drafts.autosave && hasSavePermission) && ( + + )} + + )} = (props) => { />
    {isEditing && ( -
      +
        + {!hideAPIURL && (
      • API URL @@ -176,27 +216,37 @@ const DefaultEditView: React.FC = (props) => { {apiURL}
      • + )} +
      • +
        Created
        +
        {format(new Date(data.createdAt), dateFormat)}
        +
      • + {versions && (
      • -
        ID
        -
        {id}
        +
        Versions
        +
      • - {timestamps && ( + )} + {timestamps && ( {data.updatedAt && ( -
      • -
        Last Modified
        -
        {format(new Date(data.updatedAt), dateFormat)}
        -
      • +
      • +
        Last Modified
        +
        {format(new Date(data.updatedAt), dateFormat)}
        +
      • )} - {data.createdAt && ( -
      • -
        Created
        -
        {format(new Date(data.createdAt), dateFormat)}
        -
      • + {(publishedDoc?.createdAt || data?.createdAt) && ( +
      • +
        Created
        +
        {format(new Date(publishedDoc?.createdAt || data?.createdAt), dateFormat)}
        +
      • )}
        - )} -
      + )} +
    )}
    diff --git a/src/admin/components/views/collections/Edit/index.scss b/src/admin/components/views/collections/Edit/index.scss index 38e80f3c28..a4ebf832ee 100644 --- a/src/admin/components/views/collections/Edit/index.scss +++ b/src/admin/components/views/collections/Edit/index.scss @@ -78,7 +78,7 @@ z-index: $z-nav; } - &__document-actions--with-preview { + &__document-actions--has-2 { display: flex; > * { @@ -96,8 +96,8 @@ .form-submit { .btn { width: 100%; - padding-left: base(2); - padding-right: base(2); + padding-left: base(.5); + padding-right: base(.5); } } } @@ -115,6 +115,12 @@ &__sidebar-fields { padding-right: $baseline; + + .preview-btn { + display: inline-block; + margin-top: 0; + width: calc(50% - #{base(.5)}); + } } &__meta { @@ -192,6 +198,12 @@ padding-left: $baseline; } + &__sidebar-fields { + .preview-btn { + width: 100%; + } + } + &__collection-actions { margin-top: base(.5); padding-left: $baseline; diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx index 5b8aa26a23..2251ce7edb 100644 --- a/src/admin/components/views/collections/Edit/index.tsx +++ b/src/admin/components/views/collections/Edit/index.tsx @@ -1,11 +1,10 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Redirect, useRouteMatch, useHistory, useLocation } from 'react-router-dom'; import { useConfig, useAuth } from '@payloadcms/config-provider'; import { useStepNav } from '../../../elements/StepNav'; import usePayloadAPI from '../../../../hooks/usePayloadAPI'; import RenderCustomComponent from '../../../utilities/RenderCustomComponent'; -import { DocumentInfoProvider } from '../../../utilities/DocumentInfo'; import DefaultEdit from './Default'; import formatFields from './formatFields'; import buildStateFromSchema from '../../../forms/Form/buildStateFromSchema'; @@ -13,9 +12,10 @@ import { NegativeFieldGutterProvider } from '../../../forms/FieldTypeGutter/cont import { useLocale } from '../../../utilities/Locale'; import { IndexProps } from './types'; import { StepNavItem } from '../../../elements/StepNav/types'; +import { useDocumentInfo } from '../../../utilities/DocumentInfo'; const EditView: React.FC = (props) => { - const { collection, isEditing } = props; + const { collection: incomingCollection, isEditing } = props; const { slug, @@ -30,8 +30,10 @@ const EditView: React.FC = (props) => { } = {}, } = {}, } = {}, - } = collection; - const [fields] = useState(() => formatFields(collection, isEditing)); + } = incomingCollection; + + const [fields] = useState(() => formatFields(incomingCollection, isEditing)); + const [collection] = useState(() => ({ ...incomingCollection, fields })); const locale = useLocale(); const { serverURL, routes: { admin, api } } = useConfig(); @@ -41,8 +43,10 @@ const EditView: React.FC = (props) => { const { setStepNav } = useStepNav(); const [initialState, setInitialState] = useState({}); const { permissions } = useAuth(); + const { getVersions } = useDocumentInfo(); - const onSave = async (json) => { + const onSave = useCallback(async (json: any) => { + getVersions(); if (!isEditing) { history.push(`${admin}/collections/${collection.slug}/${json?.doc?.id}`); } else { @@ -55,11 +59,11 @@ const EditView: React.FC = (props) => { }, }); } - }; + }, [admin, collection, fields, history, isEditing, getVersions]); const [{ data, isLoading, isError }] = usePayloadAPI( (isEditing ? `${serverURL}${api}/${slug}/${id}` : null), - { initialParams: { 'fallback-locale': 'null', depth: 0 } }, + { initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } }, ); const dataToRender = (locationState as Record)?.data || data; @@ -71,8 +75,22 @@ const EditView: React.FC = (props) => { }]; if (isEditing) { + let label = ''; + + if (dataToRender) { + if (useAsTitle) { + if (dataToRender[useAsTitle]) { + label = dataToRender[useAsTitle]; + } else { + label = '[Untitled]'; + } + } else { + label = dataToRender.id; + } + } + nav.push({ - label: dataToRender ? dataToRender[useAsTitle || 'id'] : '', + label, }); } else { nav.push({ @@ -99,36 +117,31 @@ const EditView: React.FC = (props) => { } const collectionPermissions = permissions?.collections?.[slug]; - - const apiURL = `${serverURL}${api}/${slug}/${id}`; + const apiURL = `${serverURL}${api}/${slug}/${id}${collection.versions.drafts ? '?draft=true' : ''}`; const action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`; const hasSavePermission = (isEditing && collectionPermissions?.update?.permission) || (!isEditing && collectionPermissions?.create?.permission); + const autosaveEnabled = collection.versions?.drafts && !collection.versions.drafts.autosave; return ( - - - - - + + + ); }; export default EditView; diff --git a/src/admin/components/views/collections/Edit/types.ts b/src/admin/components/views/collections/Edit/types.ts index 2acdafac70..bdc6525400 100644 --- a/src/admin/components/views/collections/Edit/types.ts +++ b/src/admin/components/views/collections/Edit/types.ts @@ -17,4 +17,5 @@ export type Props = IndexProps & { apiURL: string action: string hasSavePermission: boolean + autosaveEnabled: boolean } diff --git a/src/admin/components/views/collections/List/Default.tsx b/src/admin/components/views/collections/List/Default.tsx index 96e757d088..4c849529ec 100644 --- a/src/admin/components/views/collections/List/Default.tsx +++ b/src/admin/components/views/collections/List/Default.tsx @@ -138,7 +138,7 @@ const DefaultList: React.FC = (props) => { {data.totalDocs}
    diff --git a/src/admin/components/views/collections/List/index.tsx b/src/admin/components/views/collections/List/index.tsx index 5222f03b34..a4bd6f42d6 100644 --- a/src/admin/components/views/collections/List/index.tsx +++ b/src/admin/components/views/collections/List/index.tsx @@ -77,6 +77,7 @@ const ListView: React.FC = (props) => { useEffect(() => { const params = { depth: 1, + draft: 'true', page: undefined, sort: undefined, where: undefined, diff --git a/src/admin/components/views/collections/List/types.ts b/src/admin/components/views/collections/List/types.ts index 946e6b89ab..3a814b9d20 100644 --- a/src/admin/components/views/collections/List/types.ts +++ b/src/admin/components/views/collections/List/types.ts @@ -1,4 +1,5 @@ -import { SanitizedCollectionConfig, PaginatedDocs } from '../../../../../collections/config/types'; +import { SanitizedCollectionConfig } from '../../../../../collections/config/types'; +import { PaginatedDocs } from '../../../../../mongoose/types'; import { Column } from '../../../elements/Table/types'; export type Props = { diff --git a/src/fields/baseFields/baseAccountLockFields.ts b/src/auth/baseFields/accountLock.ts similarity index 79% rename from src/fields/baseFields/baseAccountLockFields.ts rename to src/auth/baseFields/accountLock.ts index 935a6084da..aac4bbcbdd 100644 --- a/src/fields/baseFields/baseAccountLockFields.ts +++ b/src/auth/baseFields/accountLock.ts @@ -1,4 +1,4 @@ -import { Field } from '../config/types'; +import { Field } from '../../fields/config/types'; export default [ { diff --git a/src/fields/baseFields/baseAPIKeyFields.ts b/src/auth/baseFields/apiKey.ts similarity index 95% rename from src/fields/baseFields/baseAPIKeyFields.ts rename to src/auth/baseFields/apiKey.ts index 7db80bbffb..5973583513 100644 --- a/src/fields/baseFields/baseAPIKeyFields.ts +++ b/src/auth/baseFields/apiKey.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { Field, FieldHook } from '../config/types'; +import { Field, FieldHook } from '../../fields/config/types'; const encryptKey: FieldHook = ({ req, value }) => (value ? req.payload.encrypt(value as string) : undefined); const decryptKey: FieldHook = ({ req, value }) => (value ? req.payload.decrypt(value as string) : undefined); diff --git a/src/fields/baseFields/baseAuthFields.ts b/src/auth/baseFields/auth.ts similarity index 78% rename from src/fields/baseFields/baseAuthFields.ts rename to src/auth/baseFields/auth.ts index c6f399cce4..e410a93fa7 100644 --- a/src/fields/baseFields/baseAuthFields.ts +++ b/src/auth/baseFields/auth.ts @@ -1,5 +1,5 @@ -import { email } from '../validations'; -import { Field } from '../config/types'; +import { email } from '../../fields/validations'; +import { Field } from '../../fields/config/types'; export default [ { diff --git a/src/fields/baseFields/baseVerificationFields.ts b/src/auth/baseFields/verification.ts similarity index 93% rename from src/fields/baseFields/baseVerificationFields.ts rename to src/auth/baseFields/verification.ts index 79499a20a9..87014bea03 100644 --- a/src/fields/baseFields/baseVerificationFields.ts +++ b/src/auth/baseFields/verification.ts @@ -1,4 +1,4 @@ -import { Field, FieldHook } from '../config/types'; +import { Field, FieldHook } from '../../fields/config/types'; const autoRemoveVerificationToken: FieldHook = ({ originalDoc, data, value, operation }) => { // If a user manually sets `_verified` to true, diff --git a/src/auth/operations/access.ts b/src/auth/operations/access.ts index 1f39c2f17b..d3c4c516f6 100644 --- a/src/auth/operations/access.ts +++ b/src/auth/operations/access.ts @@ -1,3 +1,4 @@ +import { Payload } from '../..'; import { PayloadRequest } from '../../express/types'; import { Permissions } from '../types'; @@ -7,7 +8,7 @@ type Arguments = { req: PayloadRequest } -async function accessOperation(args: Arguments): Promise { +async function accessOperation(this: Payload, args: Arguments): Promise { const { config } = this; const { @@ -102,11 +103,26 @@ async function accessOperation(args: Arguments): Promise { } config.collections.forEach((collection) => { - executeEntityPolicies(collection, allOperations, 'collections'); + const collectionOperations = [...allOperations]; + + if (collection.auth && (typeof collection.auth.maxLoginAttempts !== 'undefined' && collection.auth.maxLoginAttempts !== 0)) { + collectionOperations.push('unlock'); + } + + if (collection.versions) { + collectionOperations.push('readVersions'); + } + + executeEntityPolicies(collection, collectionOperations, 'collections'); }); config.globals.forEach((global) => { - executeEntityPolicies(global, ['read', 'update'], 'globals'); + const globalOperations = ['read', 'update']; + + if (global.versions) { + globalOperations.push('readVersions'); + } + executeEntityPolicies(global, globalOperations, 'globals'); }); await Promise.all(promises); diff --git a/src/auth/operations/local/login.ts b/src/auth/operations/local/login.ts index 37cea37b8c..6b39451224 100644 --- a/src/auth/operations/local/login.ts +++ b/src/auth/operations/local/login.ts @@ -1,6 +1,7 @@ import { Response } from 'express'; import { Result } from '../login'; import { PayloadRequest } from '../../../express/types'; +import { TypeWithID } from '../../../collections/config/types'; export type Options = { collection: string @@ -17,7 +18,7 @@ export type Options = { showHiddenFields?: boolean } -async function login(options: Options): Promise { +async function login(options: Options): Promise { const { collection: collectionSlug, req = {}, diff --git a/src/auth/operations/login.ts b/src/auth/operations/login.ts index 4660228eb3..3cfc7cbe92 100644 --- a/src/auth/operations/login.ts +++ b/src/auth/operations/login.ts @@ -8,6 +8,7 @@ import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; import { Field, fieldHasSubFields, fieldAffectsData } from '../../fields/config/types'; import { User } from '../types'; import { Collection } from '../../collections/config/types'; +import { Payload } from '../..'; export type Result = { user?: User, @@ -28,7 +29,7 @@ export type Arguments = { showHiddenFields?: boolean } -async function login(incomingArgs: Arguments): Promise { +async function login(this: Payload, incomingArgs: Arguments): Promise { const { config, operations, secret } = this; let args = incomingArgs; @@ -176,7 +177,7 @@ async function login(incomingArgs: Arguments): Promise { id: user.id, data: user, hook: 'afterRead', - operation: 'login', + operation: 'read', overrideAccess, flattenLocales: true, showHiddenFields, diff --git a/src/auth/operations/registerFirstUser.ts b/src/auth/operations/registerFirstUser.ts index 125dd9af1b..25369f619b 100644 --- a/src/auth/operations/registerFirstUser.ts +++ b/src/auth/operations/registerFirstUser.ts @@ -1,10 +1,16 @@ import { Document } from '../../types'; import { Forbidden } from '../../errors'; import { Payload } from '../..'; -import { Collection } from '../../collections/config/types'; +import { PayloadRequest } from '../../express/types'; +import { Collection, TypeWithID } from '../../collections/config/types'; export type Arguments = { collection: Collection + data: { + email: string + password: string + } + req: PayloadRequest } export type Result = { @@ -23,6 +29,11 @@ async function registerFirstUser(this: Payload, args: Arguments): Promise({ + req, + collection: slug, + data, overrideAccess: true, }); // auto-verify (if applicable) if (verify) { - await this.update({ + await payload.update({ id: result.id, collection: slug, data: { @@ -53,18 +66,19 @@ async function registerFirstUser(this: Payload, args: Arguments): Promise (req: Request & { collection: string }, res: Response, next: NextFunction) => { +const bindCollectionMiddleware = (collection: Collection) => (req: Request & { collection: Collection }, res: Response, next: NextFunction): void => { req.collection = collection; next(); }; diff --git a/src/collections/config/defaults.ts b/src/collections/config/defaults.ts index 7a4a3d8724..ff47e8f958 100644 --- a/src/collections/config/defaults.ts +++ b/src/collections/config/defaults.ts @@ -34,6 +34,7 @@ export const defaults = { }, auth: false, upload: false, + versions: false, }; export const authDefaults = { diff --git a/src/collections/config/sanitize.ts b/src/collections/config/sanitize.ts index d61c37bd6f..70333aa45f 100644 --- a/src/collections/config/sanitize.ts +++ b/src/collections/config/sanitize.ts @@ -2,14 +2,17 @@ import merge from 'deepmerge'; import { SanitizedCollectionConfig, CollectionConfig } from './types'; import sanitizeFields from '../../fields/config/sanitize'; import toKebabCase from '../../utilities/toKebabCase'; -import baseAuthFields from '../../fields/baseFields/baseAuthFields'; -import baseAPIKeyFields from '../../fields/baseFields/baseAPIKeyFields'; -import baseVerificationFields from '../../fields/baseFields/baseVerificationFields'; -import baseAccountLockFields from '../../fields/baseFields/baseAccountLockFields'; -import getBaseUploadFields from '../../fields/baseFields/getBaseUploadFields'; +import baseAuthFields from '../../auth/baseFields/auth'; +import baseAPIKeyFields from '../../auth/baseFields/apiKey'; +import baseVerificationFields from '../../auth/baseFields/verification'; +import baseAccountLockFields from '../../auth/baseFields/accountLock'; +import getBaseUploadFields from '../../uploads/getBaseFields'; import { formatLabels } from '../../utilities/formatLabels'; import { defaults, authDefaults } from './defaults'; import { Config } from '../../config/types'; +import { versionCollectionDefaults } from '../../versions/defaults'; +import baseVersionFields from '../../versions/baseFields'; +import TimestampsRequired from '../../errors/TimestampsRequired'; const mergeBaseFields = (fields, baseFields) => { const mergedFields = []; @@ -64,6 +67,25 @@ const sanitizeCollection = (config: Config, collection: CollectionConfig): Sanit sanitized.slug = toKebabCase(sanitized.slug); sanitized.labels = sanitized.labels || formatLabels(sanitized.slug); + if (sanitized.versions) { + if (sanitized.versions === true) sanitized.versions = {}; + + if (sanitized.timestamps === false) { + throw new TimestampsRequired(collection); + } + + if (sanitized.versions.drafts) { + const versionFields = mergeBaseFields(sanitized.fields, baseVersionFields); + + sanitized.fields = [ + ...versionFields, + ...sanitized.fields, + ]; + } + + sanitized.versions = merge(versionCollectionDefaults, sanitized.versions); + } + if (sanitized.upload) { if (sanitized.upload === true) sanitized.upload = {}; diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts index 7e48748f39..85b1eb90ad 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -10,6 +10,7 @@ const collectionSchema = joi.object().keys({ access: joi.object({ create: joi.func(), read: joi.func(), + readVersions: joi.func(), update: joi.func(), delete: joi.func(), unlock: joi.func(), @@ -36,6 +37,7 @@ const collectionSchema = joi.object().keys({ }), preview: joi.func(), disableDuplicate: joi.bool(), + hideAPIURL: joi.bool(), }), fields: joi.array(), hooks: joi.object({ @@ -77,6 +79,24 @@ const collectionSchema = joi.object().keys({ }), joi.boolean(), ), + versions: joi.alternatives().try( + joi.object({ + maxPerDoc: joi.number(), + retainDeleted: joi.boolean(), + drafts: joi.alternatives().try( + joi.object({ + autosave: joi.alternatives().try( + joi.boolean(), + joi.object({ + interval: joi.number(), + }), + ), + }), + joi.boolean(), + ), + }), + joi.boolean(), + ), upload: joi.alternatives().try( joi.object({ staticURL: joi.string(), diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index 6fbf067a09..87b54006ad 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -6,6 +6,7 @@ import { Field } from '../../fields/config/types'; import { PayloadRequest } from '../../express/types'; import { IncomingAuthType, Auth } from '../../auth/types'; import { IncomingUploadType, Upload } from '../../uploads/types'; +import { IncomingCollectionVersions, SanitizedCollectionVersions } from '../../versions/types'; export interface CollectionModel extends PaginateModel, PassportLocalModel { buildQuery: (query: unknown, locale?: string) => Record @@ -18,6 +19,7 @@ export interface AuthCollectionModel extends CollectionModel { export type HookOperationType = | 'create' +| 'autosave' | 'read' | 'update' | 'delete' @@ -127,6 +129,10 @@ export type CollectionAdminOptions = { */ description?: string | (() => string) | React.FC; disableDuplicate?: boolean; + /** + * Hide the API URL within the Edit view + */ + hideAPIURL?: boolean /** * Custom admin components */ @@ -184,9 +190,10 @@ export type CollectionConfig = { access?: { create?: Access; read?: Access; + readVersions?: Access; update?: Access; delete?: Access; - admin?: Access; + admin?: (args?: any) => boolean; unlock?: Access; }; /** @@ -199,13 +206,15 @@ export type CollectionConfig = { * Upload options */ upload?: IncomingUploadType | boolean; + versions?: IncomingCollectionVersions | boolean; timestamps?: boolean }; -export interface SanitizedCollectionConfig extends Omit, 'auth' | 'upload' | 'fields'> { +export interface SanitizedCollectionConfig extends Omit, 'auth' | 'upload' | 'fields' | 'versions'> { auth: Auth; upload: Upload; fields: Field[]; + versions: SanitizedCollectionVersions } export type Collection = { @@ -218,19 +227,12 @@ export type AuthCollection = { config: SanitizedCollectionConfig; } -export type PaginatedDocs = { - docs: T[] - totalDocs: number - limit: number - totalPages: number - page: number - pagingCounter: number - hasPrevPage: boolean - hasNextPage: boolean - prevPage: number | null - nextPage: number | null -} - export type TypeWithID = { id: string | number } + +export type TypeWithTimestamps = { + id: string | number + createdAt: string + updatedAt: string +} diff --git a/src/collections/graphql/init.ts b/src/collections/graphql/init.ts index 1d08512447..1d454a82d3 100644 --- a/src/collections/graphql/init.ts +++ b/src/collections/graphql/init.ts @@ -126,6 +126,7 @@ function registerCollections(): void { type: collection.graphQL.type, args: { id: { type: idType }, + draft: { type: GraphQLBoolean }, ...(this.config.localization ? { locale: { type: this.types.localeInputType }, fallbackLocale: { type: this.types.fallbackLocaleInputType }, @@ -138,6 +139,7 @@ function registerCollections(): void { type: buildPaginatedListType(pluralLabel, collection.graphQL.type), args: { where: { type: collection.graphQL.whereInputType }, + draft: { type: GraphQLBoolean }, ...(this.config.localization ? { locale: { type: this.types.localeInputType }, fallbackLocale: { type: this.types.fallbackLocaleInputType }, @@ -153,6 +155,7 @@ function registerCollections(): void { type: collection.graphQL.type, args: { data: { type: collection.graphQL.mutationInputType }, + draft: { type: GraphQLBoolean }, }, resolve: create(collection), }; @@ -162,6 +165,8 @@ function registerCollections(): void { args: { id: { type: new GraphQLNonNull(idType) }, data: { type: collection.graphQL.updateMutationInputType }, + draft: { type: GraphQLBoolean }, + autosave: { type: GraphQLBoolean }, }, resolve: update(collection), }; diff --git a/src/collections/graphql/resolvers/create.ts b/src/collections/graphql/resolvers/create.ts index f51a885049..002445bf28 100644 --- a/src/collections/graphql/resolvers/create.ts +++ b/src/collections/graphql/resolvers/create.ts @@ -23,6 +23,7 @@ export default function create(collection: Collection): Resolver { collection, data: args.data, req: context.req, + draft: args.draft, }; const result = await this.operations.collections.create(options); diff --git a/src/collections/graphql/resolvers/find.ts b/src/collections/graphql/resolvers/find.ts index 6e26a6b715..f62678ba06 100644 --- a/src/collections/graphql/resolvers/find.ts +++ b/src/collections/graphql/resolvers/find.ts @@ -11,6 +11,7 @@ export default function find(collection) { page: args.page, sort: args.sort, req: context.req, + draft: args.draft, }; const results = await this.operations.collections.find(options); diff --git a/src/collections/graphql/resolvers/findByID.ts b/src/collections/graphql/resolvers/findByID.ts index ff9ea90dd2..c6444898cf 100644 --- a/src/collections/graphql/resolvers/findByID.ts +++ b/src/collections/graphql/resolvers/findByID.ts @@ -8,6 +8,7 @@ export default function findByID(collection) { collection, id: args.id, req: context.req, + draft: args.draft, }; const result = await this.operations.collections.findByID(options); diff --git a/src/collections/graphql/resolvers/update.ts b/src/collections/graphql/resolvers/update.ts index 9ba1f56648..56b7012e7d 100644 --- a/src/collections/graphql/resolvers/update.ts +++ b/src/collections/graphql/resolvers/update.ts @@ -11,6 +11,8 @@ export default function update(collection) { id: args.id, depth: 0, req: context.req, + draft: args.draft, + autosave: args.autosave, }; const result = await this.operations.collections.update(options); diff --git a/src/collections/init.ts b/src/collections/init.ts index 61b8d67175..9ad618ea08 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -1,15 +1,19 @@ import mongoose from 'mongoose'; +import paginate from 'mongoose-paginate-v2'; import express from 'express'; import passport from 'passport'; import passportLocalMongoose from 'passport-local-mongoose'; import Passport from 'passport-local'; import { UpdateQuery } from 'mongodb'; +import { buildVersionCollectionFields } from '../versions/buildCollectionFields'; +import buildQueryPlugin from '../mongoose/buildQuery'; import apiKeyStrategy from '../auth/strategies/apiKey'; -import buildSchema from './buildSchema'; +import buildCollectionSchema from './buildSchema'; +import buildSchema from '../mongoose/buildSchema'; import bindCollectionMiddleware from './bindCollection'; -import { SanitizedCollectionConfig } from './config/types'; -import { SanitizedConfig } from '../config/types'; +import { CollectionModel, SanitizedCollectionConfig } from './config/types'; import { Payload } from '../index'; +import { getVersionsModelName } from '../versions/getVersionsModelName'; const LocalStrategy = Passport.Strategy; @@ -17,7 +21,7 @@ export default function registerCollections(ctx: Payload): void { ctx.config.collections = ctx.config.collections.map((collection: SanitizedCollectionConfig) => { const formattedCollection = collection; - const schema = buildSchema(formattedCollection, ctx.config as SanitizedConfig); + const schema = buildCollectionSchema(formattedCollection, ctx.config); if (collection.auth) { schema.plugin(passportLocalMongoose, { @@ -62,8 +66,29 @@ export default function registerCollections(ctx: Payload): void { } } + if (collection.versions) { + const versionModelName = getVersionsModelName(collection); + + const versionSchema = buildSchema( + ctx.config, + buildVersionCollectionFields(collection), + { + disableUnique: true, + options: { + timestamps: true, + }, + }, + ); + + versionSchema.plugin(paginate, { useEstimatedCount: true }) + .plugin(buildQueryPlugin); + + ctx.versions[collection.slug] = mongoose.model(versionModelName, versionSchema) as CollectionModel; + } + + ctx.collections[formattedCollection.slug] = { - Model: mongoose.model(formattedCollection.slug, schema), + Model: mongoose.model(formattedCollection.slug, schema) as CollectionModel, config: formattedCollection, }; @@ -79,6 +104,9 @@ export default function registerCollections(ctx: Payload): void { find, update, findByID, + findVersions, + findVersionByID, + publishVersion, delete: deleteHandler, } = ctx.requestHandlers.collections; @@ -148,6 +176,15 @@ export default function registerCollections(ctx: Payload): void { .post(resetPassword); } + if (collection.versions) { + router.route(`/${slug}/versions`) + .get(findVersions); + + router.route(`/${slug}/versions/:id`) + .get(findVersionByID) + .post(publishVersion); + } + router.route(`/${slug}`) .get(find) .post(create); diff --git a/src/collections/operations/create.ts b/src/collections/operations/create.ts index 7e3a3d9f19..df0dc85ff8 100644 --- a/src/collections/operations/create.ts +++ b/src/collections/operations/create.ts @@ -1,25 +1,17 @@ -import mkdirp from 'mkdirp'; -import path from 'path'; import crypto from 'crypto'; -import { UploadedFile } from 'express-fileupload'; import executeAccess from '../../auth/executeAccess'; import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; -import { MissingFile, FileUploadError, ValidationError } from '../../errors'; -import resizeAndSave from '../../uploads/imageResizer'; -import getSafeFilename from '../../uploads/getSafeFilename'; -import getImageSize from '../../uploads/getImageSize'; -import isImage from '../../uploads/isImage'; -import { FileData } from '../../uploads/types'; +import { ValidationError } from '../../errors'; import sendVerificationEmail from '../../auth/sendVerificationEmail'; import { AfterChangeHook, BeforeOperationHook, BeforeValidateHook, Collection } from '../config/types'; import { PayloadRequest } from '../../express/types'; import { Document } from '../../types'; import { Payload } from '../..'; -import saveBufferToFile from '../../uploads/saveBufferToFile'; import { fieldAffectsData } from '../../fields/config/types'; +import uploadFile from '../../uploads/uploadFile'; export type Arguments = { collection: Collection @@ -30,6 +22,7 @@ export type Arguments = { showHiddenFields?: boolean data: Record overwriteExistingFiles?: boolean + draft?: boolean } async function create(this: Payload, incomingArgs: Arguments): Promise { @@ -51,6 +44,7 @@ async function create(this: Payload, incomingArgs: Arguments): Promise }, Promise.resolve()); const { + collection, collection: { Model, config: collectionConfig, @@ -61,10 +55,13 @@ async function create(this: Payload, incomingArgs: Arguments): Promise overrideAccess, showHiddenFields, overwriteExistingFiles = false, + draft = false, } = args; let { data } = args; + const shouldSaveDraft = Boolean(draft && collectionConfig.versions.drafts); + // ///////////////////////////////////// // Access // ///////////////////////////////////// @@ -89,66 +86,14 @@ async function create(this: Payload, incomingArgs: Arguments): Promise // Upload and resize potential files // ///////////////////////////////////// - if (collectionConfig.upload) { - const fileData: Partial = {}; - - const { staticDir, imageSizes, disableLocalStorage } = collectionConfig.upload; - - const { file } = req.files || {}; - - if (!file) { - throw new MissingFile(); - } - - let staticPath = staticDir; - - if (staticDir.indexOf('/') !== 0) { - staticPath = path.resolve(config.paths.configDir, staticDir); - } - - if (!disableLocalStorage) { - mkdirp.sync(staticPath); - } - - const fsSafeName = !overwriteExistingFiles ? await getSafeFilename(Model, staticPath, file.name) : file.name; - - try { - if (!disableLocalStorage) { - await saveBufferToFile(file.data, `${staticPath}/${fsSafeName}`); - } - - if (isImage(file.mimetype)) { - const dimensions = await getImageSize(file); - fileData.width = dimensions.width; - fileData.height = dimensions.height; - - if (Array.isArray(imageSizes) && file.mimetype !== 'image/svg+xml') { - req.payloadUploadSizes = {}; - fileData.sizes = await resizeAndSave({ - req, - file: file.data, - dimensions, - staticPath, - config: collectionConfig, - savedFilename: fsSafeName, - mimeType: fileData.mimeType, - }); - } - } - } catch (err) { - console.error(err); - throw new FileUploadError(); - } - - fileData.filename = fsSafeName; - fileData.filesize = file.size; - fileData.mimeType = file.mimetype; - - data = { - ...data, - ...fileData, - }; - } + data = await uploadFile({ + config, + collection, + req, + data, + throwOnMissingFile: !shouldSaveDraft, + overwriteExistingFiles, + }); // ///////////////////////////////////// // beforeValidate - Fields @@ -201,6 +146,7 @@ async function create(this: Payload, incomingArgs: Arguments): Promise req, overrideAccess, unflattenLocales: true, + skipValidation: shouldSaveDraft, }); // ///////////////////////////////////// diff --git a/src/collections/operations/find.ts b/src/collections/operations/find.ts index fe1b0b8332..04577d90d4 100644 --- a/src/collections/operations/find.ts +++ b/src/collections/operations/find.ts @@ -2,9 +2,13 @@ import { Where } from '../../types'; import { PayloadRequest } from '../../express/types'; import executeAccess from '../../auth/executeAccess'; import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; -import { Collection, TypeWithID, PaginatedDocs } from '../config/types'; +import { Collection, TypeWithID } from '../config/types'; +import { PaginatedDocs } from '../../mongoose/types'; import { hasWhereAccessResult } from '../../auth/types'; import flattenWhereConstraints from '../../utilities/flattenWhereConstraints'; +import { buildSortParam } from '../../mongoose/buildSortParam'; +import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable'; +import { AccessResult } from '../../config/types'; export type Arguments = { collection: Collection @@ -16,6 +20,7 @@ export type Arguments = { req?: PayloadRequest overrideAccess?: boolean showHiddenFields?: boolean + draft?: boolean } async function find(incomingArgs: Arguments): Promise> { @@ -39,6 +44,7 @@ async function find(incomingArgs: Arguments): Promis page, limit, depth, + draft: draftsEnabled, collection: { Model, config: collectionConfig, @@ -55,66 +61,65 @@ async function find(incomingArgs: Arguments): Promis // Access // ///////////////////////////////////// - const queryToBuild: { where?: Where} = {}; + const queryToBuild: { where?: Where} = { + where: { + and: [], + }, + }; + let useEstimatedCount = false; if (where) { - let and = []; - - if (Array.isArray(where.and)) and = where.and; - if (Array.isArray(where.AND)) and = where.AND; - queryToBuild.where = { + and: [], ...where, - and: [ - ...and, - ], }; + if (Array.isArray(where.AND)) { + queryToBuild.where.and = [ + ...queryToBuild.where.and, + ...where.AND, + ]; + } + const constraints = flattenWhereConstraints(queryToBuild); useEstimatedCount = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near')); } - if (!overrideAccess) { - const accessResults = await executeAccess({ req }, collectionConfig.access.read); + let accessResult: AccessResult; - if (hasWhereAccessResult(accessResults)) { - if (!where) { - queryToBuild.where = { - and: [ - accessResults, - ], - }; - } else { - (queryToBuild.where.and as Where[]).push(accessResults); - } + if (!overrideAccess) { + accessResult = await executeAccess({ req }, collectionConfig.access.read); + + if (hasWhereAccessResult(accessResult)) { + queryToBuild.where.and.push(accessResult); } } + if (collectionConfig.versions?.drafts && !draftsEnabled) { + queryToBuild.where.and.push({ + or: [ + { + _status: { + equals: 'published', + }, + }, + { + _status: { + exists: false, + }, + }, + ], + }); + } + const query = await Model.buildQuery(queryToBuild, locale); // ///////////////////////////////////// // Find // ///////////////////////////////////// - - let sortProperty: string; - let sortOrder = 'desc'; - - if (!args.sort) { - if (collectionConfig.timestamps) { - sortProperty = 'createdAt'; - } else { - sortProperty = '_id'; - } - } else if (args.sort.indexOf('-') === 0) { - sortProperty = args.sort.substring(1); - } else { - sortProperty = args.sort; - sortOrder = 'asc'; - } - - if (sortProperty === 'id') sortProperty = '_id'; + const [sortProperty, sortOrder] = buildSortParam(args.sort, collectionConfig.timestamps); const optionsToExecute = { page: page || 1, @@ -129,13 +134,34 @@ async function find(incomingArgs: Arguments): Promis const paginatedDocs = await Model.paginate(query, optionsToExecute); + let result = { + ...paginatedDocs, + } as PaginatedDocs; + + // ///////////////////////////////////// + // Replace documents with drafts if available + // ///////////////////////////////////// + + if (collectionConfig.versions?.drafts && draftsEnabled) { + result = { + ...result, + docs: await Promise.all(result.docs.map(async (doc) => replaceWithDraftIfAvailable({ + accessResult, + payload: this, + collection: collectionConfig, + doc, + locale, + }))), + }; + } + // ///////////////////////////////////// // beforeRead - Collection // ///////////////////////////////////// - let result = { - ...paginatedDocs, - docs: await Promise.all(paginatedDocs.docs.map(async (doc) => { + result = { + ...result, + docs: await Promise.all(result.docs.map(async (doc) => { const docString = JSON.stringify(doc); let docRef = JSON.parse(docString); @@ -147,7 +173,7 @@ async function find(incomingArgs: Arguments): Promis return docRef; })), - } as PaginatedDocs; + }; // ///////////////////////////////////// // afterRead - Fields @@ -168,7 +194,6 @@ async function find(incomingArgs: Arguments): Promis flattenLocales: true, showHiddenFields, }, - find, ))), }; diff --git a/src/collections/operations/findByID.ts b/src/collections/operations/findByID.ts index fbc11846df..7035257f3e 100644 --- a/src/collections/operations/findByID.ts +++ b/src/collections/operations/findByID.ts @@ -1,5 +1,6 @@ /* eslint-disable no-underscore-dangle */ import memoize from 'micro-memoize'; +import { Payload } from '../..'; import { PayloadRequest } from '../../express/types'; import { Collection, TypeWithID } from '../config/types'; import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; @@ -7,6 +8,7 @@ import { Forbidden, NotFound } from '../../errors'; import executeAccess from '../../auth/executeAccess'; import { Where } from '../../types'; import { hasWhereAccessResult } from '../../auth/types'; +import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable'; export type Arguments = { collection: Collection @@ -17,9 +19,10 @@ export type Arguments = { overrideAccess?: boolean showHiddenFields?: boolean depth?: number + draft?: boolean } -async function findByID(incomingArgs: Arguments): Promise { +async function findByID(this: Payload, incomingArgs: Arguments): Promise { let args = incomingArgs; // ///////////////////////////////////// @@ -48,20 +51,21 @@ async function findByID(incomingArgs: Arguments): Pr }, disableErrors, currentDepth, - overrideAccess, + overrideAccess = false, showHiddenFields, + draft: draftEnabled = false, } = args; // ///////////////////////////////////// // Access // ///////////////////////////////////// - const accessResults = !overrideAccess ? await executeAccess({ req, disableErrors, id }, collectionConfig.access.read) : true; + const accessResult = !overrideAccess ? await executeAccess({ req, disableErrors, id }, collectionConfig.access.read) : true; // If errors are disabled, and access returns false, return null - if (accessResults === false) return null; + if (accessResult === false) return null; - const hasWhereAccess = typeof accessResults === 'object'; + const hasWhereAccess = typeof accessResult === 'object'; const queryToBuild: { where: Where } = { where: { @@ -75,8 +79,25 @@ async function findByID(incomingArgs: Arguments): Pr }, }; - if (hasWhereAccessResult(accessResults)) { - (queryToBuild.where.and as Where[]).push(accessResults); + if (hasWhereAccessResult(accessResult)) { + queryToBuild.where.and.push(accessResult); + } + + if (collectionConfig.versions?.drafts && !draftEnabled) { + queryToBuild.where.and.push({ + or: [ + { + _status: { + equals: 'published', + }, + }, + { + _status: { + exists: false, + }, + }, + ], + }); } const query = await Model.buildQuery(queryToBuild, locale); @@ -104,8 +125,7 @@ async function findByID(incomingArgs: Arguments): Pr if (!result) { if (!disableErrors) { - if (!hasWhereAccess) throw new NotFound(); - if (hasWhereAccess) throw new Forbidden(); + throw new NotFound(); } return null; @@ -116,6 +136,20 @@ async function findByID(incomingArgs: Arguments): Pr result = sanitizeInternalFields(result); + // ///////////////////////////////////// + // Replace document with draft if available + // ///////////////////////////////////// + + if (collectionConfig.versions?.drafts && draftEnabled) { + result = await replaceWithDraftIfAvailable({ + payload: this, + collection: collectionConfig, + doc: result, + accessResult, + locale, + }); + } + // ///////////////////////////////////// // beforeRead - Collection // ///////////////////////////////////// diff --git a/src/collections/operations/findVersionByID.ts b/src/collections/operations/findVersionByID.ts new file mode 100644 index 0000000000..5463e4c230 --- /dev/null +++ b/src/collections/operations/findVersionByID.ts @@ -0,0 +1,150 @@ +/* eslint-disable no-underscore-dangle */ +import httpStatus from 'http-status'; +import { Payload } from '../../index'; +import { PayloadRequest } from '../../express/types'; +import { Collection, CollectionModel } from '../config/types'; +import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; +import { APIError, Forbidden, NotFound } from '../../errors'; +import executeAccess from '../../auth/executeAccess'; +import { Where } from '../../types'; +import { hasWhereAccessResult } from '../../auth/types'; +import { TypeWithVersion } from '../../versions/types'; + +export type Arguments = { + collection: Collection + id: string + req: PayloadRequest + disableErrors?: boolean + currentDepth?: number + overrideAccess?: boolean + showHiddenFields?: boolean + depth?: number +} + +async function findVersionByID = any>(this: Payload, args: Arguments): Promise { + const { + depth, + collection: { + config: collectionConfig, + }, + id, + req, + req: { + locale, + }, + disableErrors, + currentDepth, + overrideAccess, + showHiddenFields, + } = args; + + if (!id) { + throw new APIError('Missing ID of version.', httpStatus.BAD_REQUEST); + } + + const VersionsModel = (this.versions[collectionConfig.slug]) as CollectionModel; + + // ///////////////////////////////////// + // Access + // ///////////////////////////////////// + + const accessResults = !overrideAccess ? await executeAccess({ req, disableErrors, id }, collectionConfig.access.readVersions) : true; + + // If errors are disabled, and access returns false, return null + if (accessResults === false) return null; + + const hasWhereAccess = typeof accessResults === 'object'; + + const queryToBuild: { where: Where } = { + where: { + and: [ + { + _id: { + equals: id, + }, + }, + ], + }, + }; + + if (hasWhereAccessResult(accessResults)) { + (queryToBuild.where.and as Where[]).push(accessResults); + } + + const query = await VersionsModel.buildQuery(queryToBuild, locale); + + // ///////////////////////////////////// + // Find by ID + // ///////////////////////////////////// + + if (!query.$and[0]._id) throw new NotFound(); + + let result = await VersionsModel.findOne(query, {}).lean(); + + if (!result) { + if (!disableErrors) { + if (!hasWhereAccess) throw new NotFound(); + if (hasWhereAccess) throw new Forbidden(); + } + + return null; + } + + // Clone the result - it may have come back memoized + result = JSON.parse(JSON.stringify(result)); + + result = sanitizeInternalFields(result); + + // ///////////////////////////////////// + // beforeRead - Collection + // ///////////////////////////////////// + + await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { + await priorHook; + + result.version = await hook({ + req, + query, + doc: result.version, + }) || result.version; + }, Promise.resolve()); + + // ///////////////////////////////////// + // afterRead - Fields + // ///////////////////////////////////// + + result.version = await this.performFieldOperations(collectionConfig, { + depth, + req, + id, + data: result.version, + hook: 'afterRead', + operation: 'read', + currentDepth, + overrideAccess, + flattenLocales: true, + showHiddenFields, + }); + + // ///////////////////////////////////// + // afterRead - Collection + // ///////////////////////////////////// + + await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { + await priorHook; + + result.version = await hook({ + req, + query, + doc: result.version, + }) || result.version; + }, Promise.resolve()); + + // ///////////////////////////////////// + // Return results + // ///////////////////////////////////// + + return result; +} + +export default findVersionByID; diff --git a/src/collections/operations/findVersions.ts b/src/collections/operations/findVersions.ts new file mode 100644 index 0000000000..3432092440 --- /dev/null +++ b/src/collections/operations/findVersions.ts @@ -0,0 +1,183 @@ +import { Where } from '../../types'; +import { PayloadRequest } from '../../express/types'; +import executeAccess from '../../auth/executeAccess'; +import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; +import { Collection, CollectionModel } from '../config/types'; +import { hasWhereAccessResult } from '../../auth/types'; +import flattenWhereConstraints from '../../utilities/flattenWhereConstraints'; +import { buildSortParam } from '../../mongoose/buildSortParam'; +import { PaginatedDocs } from '../../mongoose/types'; +import { TypeWithVersion } from '../../versions/types'; +import { Payload } from '../../index'; + +export type Arguments = { + collection: Collection + where?: Where + page?: number + limit?: number + sort?: string + depth?: number + req?: PayloadRequest + overrideAccess?: boolean + showHiddenFields?: boolean +} + +async function findVersions = any>(this: Payload, args: Arguments): Promise> { + const { + where, + page, + limit, + depth, + collection: { + config: collectionConfig, + }, + req, + req: { + locale, + }, + overrideAccess, + showHiddenFields, + } = args; + + const VersionsModel = this.versions[collectionConfig.slug] as CollectionModel; + + // ///////////////////////////////////// + // Access + // ///////////////////////////////////// + + const queryToBuild: { where?: Where} = {}; + let useEstimatedCount = false; + + if (where) { + let and = []; + + if (Array.isArray(where.and)) and = where.and; + if (Array.isArray(where.AND)) and = where.AND; + + queryToBuild.where = { + ...where, + and: [ + ...and, + ], + }; + + const constraints = flattenWhereConstraints(queryToBuild); + + useEstimatedCount = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near')); + } + + if (!overrideAccess) { + const accessResults = await executeAccess({ req }, collectionConfig.access.readVersions); + + if (hasWhereAccessResult(accessResults)) { + if (!where) { + queryToBuild.where = { + and: [ + accessResults, + ], + }; + } else { + (queryToBuild.where.and as Where[]).push(accessResults); + } + } + } + + const query = await VersionsModel.buildQuery(queryToBuild, locale); + + // ///////////////////////////////////// + // Find + // ///////////////////////////////////// + + const [sortProperty, sortOrder] = buildSortParam(args.sort || '-updatedAt', true); + + const optionsToExecute = { + page: page || 1, + limit: limit || 10, + sort: { + [sortProperty]: sortOrder, + }, + lean: true, + leanWithId: true, + useEstimatedCount, + }; + + const paginatedDocs = await VersionsModel.paginate(query, optionsToExecute); + + // ///////////////////////////////////// + // beforeRead - Collection + // ///////////////////////////////////// + + let result = { + ...paginatedDocs, + docs: await Promise.all(paginatedDocs.docs.map(async (doc) => { + const docString = JSON.stringify(doc); + const docRef = JSON.parse(docString); + + await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { + await priorHook; + + docRef.version = await hook({ req, query, doc: docRef.version }) || docRef.version; + }, Promise.resolve()); + + return docRef; + })), + } as PaginatedDocs; + + // ///////////////////////////////////// + // afterRead - Fields + // ///////////////////////////////////// + + result = { + ...result, + docs: await Promise.all(result.docs.map(async (data) => ({ + ...data, + version: await this.performFieldOperations( + collectionConfig, + { + depth, + data: data.version, + req, + id: data.version.id, + hook: 'afterRead', + operation: 'read', + overrideAccess, + flattenLocales: true, + showHiddenFields, + isVersion: true, + }, + ), + }))), + }; + + // ///////////////////////////////////// + // afterRead - Collection + // ///////////////////////////////////// + + result = { + ...result, + docs: await Promise.all(result.docs.map(async (doc) => { + const docRef = doc; + + await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { + await priorHook; + + docRef.version = await hook({ req, query, doc: doc.version }) || doc.version; + }, Promise.resolve()); + + return docRef; + })), + }; + + // ///////////////////////////////////// + // Return results + // ///////////////////////////////////// + + result = { + ...result, + docs: result.docs.map((doc) => sanitizeInternalFields(doc)), + }; + + return result; +} + +export default findVersions; diff --git a/src/collections/operations/local/create.ts b/src/collections/operations/local/create.ts index 3d73870cbd..e216d554d6 100644 --- a/src/collections/operations/local/create.ts +++ b/src/collections/operations/local/create.ts @@ -1,7 +1,8 @@ +import { PayloadRequest } from '../../../express/types'; import { Document } from '../../../types'; import getFileByPath from '../../../uploads/getFileByPath'; -export type Options = { +export type Options = { collection: string data: Record depth?: number @@ -13,8 +14,11 @@ export type Options = { showHiddenFields?: boolean filePath?: string overwriteExistingFiles?: boolean + req: PayloadRequest + draft?: boolean } -export default async function create(options: Options): Promise { + +export default async function create(options: Options): Promise { const { collection: collectionSlug, depth, @@ -27,6 +31,8 @@ export default async function create(options: Options): Promise { showHiddenFields, filePath, overwriteExistingFiles = false, + req, + draft, } = options; const collection = this.collections[collectionSlug]; @@ -39,7 +45,9 @@ export default async function create(options: Options): Promise { disableVerificationEmail, showHiddenFields, overwriteExistingFiles, + draft, req: { + ...req, user, payloadAPI: 'local', locale, diff --git a/src/collections/operations/local/find.ts b/src/collections/operations/local/find.ts index 117dd80eb7..0fd4a9132a 100644 --- a/src/collections/operations/local/find.ts +++ b/src/collections/operations/local/find.ts @@ -1,4 +1,5 @@ -import { PaginatedDocs, TypeWithID } from '../../config/types'; +import { TypeWithID } from '../../config/types'; +import { PaginatedDocs } from '../../../mongoose/types'; import { Document, Where } from '../../../types'; export type Options = { @@ -13,6 +14,7 @@ export type Options = { showHiddenFields?: boolean sort?: string where?: Where + draft?: boolean } export default async function find(options: Options): Promise> { @@ -28,6 +30,7 @@ export default async function find(options: Options) overrideAccess = true, showHiddenFields, sort, + draft = false, } = options; const collection = this.collections[collectionSlug]; @@ -41,6 +44,7 @@ export default async function find(options: Options) collection, overrideAccess, showHiddenFields, + draft, req: { user, payloadAPI: 'local', diff --git a/src/collections/operations/local/findByID.ts b/src/collections/operations/local/findByID.ts index fc209f16db..0c83790df2 100644 --- a/src/collections/operations/local/findByID.ts +++ b/src/collections/operations/local/findByID.ts @@ -6,6 +6,7 @@ export type Options = { collection: string id: string depth?: number + currentDepth?: number locale?: string fallbackLocale?: string user?: Document @@ -13,12 +14,14 @@ export type Options = { showHiddenFields?: boolean disableErrors?: boolean req?: PayloadRequest + draft?: boolean } export default async function findByID(options: Options): Promise { const { collection: collectionSlug, depth, + currentDepth, id, locale = this?.config?.localization?.defaultLocale, fallbackLocale = null, @@ -26,12 +29,14 @@ export default async function findByID(options: Opti overrideAccess = true, disableErrors = false, showHiddenFields, - req, + req = {}, + draft = false, } = options; const collection = this.collections[collectionSlug]; const reqToUse = { + user: undefined, ...req || {}, payloadAPI: 'local', locale, @@ -43,11 +48,13 @@ export default async function findByID(options: Opti return this.operations.collections.findByID({ depth, + currentDepth, id, collection, overrideAccess, disableErrors, showHiddenFields, req: reqToUse, + draft, }); } diff --git a/src/collections/operations/local/findVersionByID.ts b/src/collections/operations/local/findVersionByID.ts new file mode 100644 index 0000000000..4641f117f4 --- /dev/null +++ b/src/collections/operations/local/findVersionByID.ts @@ -0,0 +1,48 @@ +import { Document } from '../../../types'; +import { PayloadRequest } from '../../../express/types'; +import { TypeWithVersion } from '../../../versions/types'; + +export type Options = { + collection: string + id: string + depth?: number + locale?: string + fallbackLocale?: string + user?: Document + overrideAccess?: boolean + showHiddenFields?: boolean + disableErrors?: boolean + req?: PayloadRequest +} + +export default async function findVersionByID = any>(options: Options): Promise { + const { + collection: collectionSlug, + depth, + id, + locale = this?.config?.localization?.defaultLocale, + fallbackLocale = null, + overrideAccess = true, + disableErrors = false, + showHiddenFields, + req, + } = options; + + const collection = this.collections[collectionSlug]; + + return this.operations.collections.findVersionByID({ + depth, + id, + collection, + overrideAccess, + disableErrors, + showHiddenFields, + req: { + ...req, + payloadAPI: 'local', + locale, + fallbackLocale, + payload: this, + }, + }); +} diff --git a/src/collections/operations/local/findVersions.ts b/src/collections/operations/local/findVersions.ts new file mode 100644 index 0000000000..6fd381a043 --- /dev/null +++ b/src/collections/operations/local/findVersions.ts @@ -0,0 +1,53 @@ +import { Document, Where } from '../../../types'; +import { PaginatedDocs } from '../../../mongoose/types'; +import { TypeWithVersion } from '../../../versions/types'; + +export type Options = { + collection: string + depth?: number + page?: number + limit?: number + locale?: string + fallbackLocale?: string + user?: Document + overrideAccess?: boolean + showHiddenFields?: boolean + sort?: string + where?: Where +} + +export default async function findVersions = any>(options: Options): Promise> { + const { + collection: collectionSlug, + depth, + page, + limit, + where, + locale = this?.config?.localization?.defaultLocale, + fallbackLocale = null, + user, + overrideAccess = true, + showHiddenFields, + sort, + } = options; + + const collection = this.collections[collectionSlug]; + + return this.operations.collections.findVersions({ + where, + page, + limit, + depth, + collection, + sort, + overrideAccess, + showHiddenFields, + req: { + user, + payloadAPI: 'local', + locale, + fallbackLocale, + payload: this, + }, + }); +} diff --git a/src/collections/operations/local/index.ts b/src/collections/operations/local/index.ts index a7f16bb919..e13d251f54 100644 --- a/src/collections/operations/local/index.ts +++ b/src/collections/operations/local/index.ts @@ -4,6 +4,9 @@ import create from './create'; import update from './update'; import localDelete from './delete'; import auth from '../../../auth/operations/local'; +import findVersionByID from './findVersionByID'; +import findVersions from './findVersions'; +import publishVersion from './publishVersion'; export default { find, @@ -12,4 +15,7 @@ export default { update, localDelete, auth, + findVersionByID, + findVersions, + publishVersion, }; diff --git a/src/collections/operations/local/publishVersion.ts b/src/collections/operations/local/publishVersion.ts new file mode 100644 index 0000000000..352f9fe807 --- /dev/null +++ b/src/collections/operations/local/publishVersion.ts @@ -0,0 +1,48 @@ +import { Document } from '../../../types'; +import { TypeWithVersion } from '../../../versions/types'; + +export type Options = { + collection: string + id: string + data: Record + depth?: number + locale?: string + fallbackLocale?: string + user?: Document + overrideAccess?: boolean + showHiddenFields?: boolean +} + +export default async function publishVersion = any>(options: Options): Promise { + const { + collection: collectionSlug, + depth, + locale = this?.config?.localization?.defaultLocale, + fallbackLocale = null, + data, + id, + user, + overrideAccess = true, + showHiddenFields, + } = options; + + const collection = this.collections[collectionSlug]; + + const args = { + depth, + data, + collection, + overrideAccess, + id, + showHiddenFields, + req: { + user, + payloadAPI: 'local', + locale, + fallbackLocale, + payload: this, + }, + }; + + return this.operations.collections.publishVersion(args); +} diff --git a/src/collections/operations/local/update.ts b/src/collections/operations/local/update.ts index c2bb7800d1..492f15b263 100644 --- a/src/collections/operations/local/update.ts +++ b/src/collections/operations/local/update.ts @@ -1,11 +1,10 @@ -import { TypeWithID } from '../../config/types'; import { Document } from '../../../types'; import getFileByPath from '../../../uploads/getFileByPath'; -export type Options = { +export type Options = { collection: string - id: string - data: Record + id: string | number + data: Partial depth?: number locale?: string fallbackLocale?: string @@ -14,9 +13,11 @@ export type Options = { showHiddenFields?: boolean filePath?: string overwriteExistingFiles?: boolean + draft?: boolean + autosave?: boolean } -export default async function update(options: Options): Promise { +export default async function update(options: Options): Promise { const { collection: collectionSlug, depth, @@ -29,6 +30,8 @@ export default async function update(options: Option showHiddenFields, filePath, overwriteExistingFiles = false, + draft, + autosave, } = options; const collection = this.collections[collectionSlug]; @@ -41,6 +44,8 @@ export default async function update(options: Option id, showHiddenFields, overwriteExistingFiles, + draft, + autosave, req: { user, payloadAPI: 'local', diff --git a/src/collections/operations/publishVersion.ts b/src/collections/operations/publishVersion.ts new file mode 100644 index 0000000000..25f195d8a6 --- /dev/null +++ b/src/collections/operations/publishVersion.ts @@ -0,0 +1,174 @@ +/* eslint-disable no-underscore-dangle */ +import httpStatus from 'http-status'; +import { PayloadRequest } from '../../express/types'; +import { Collection, TypeWithID } from '../config/types'; +import { APIError, Forbidden, NotFound } from '../../errors'; +import executeAccess from '../../auth/executeAccess'; +import { Payload } from '../../index'; +import { hasWhereAccessResult } from '../../auth/types'; +import { Where } from '../../types'; +import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; + +export type Arguments = { + collection: Collection + id: string + req: PayloadRequest + disableErrors?: boolean + currentDepth?: number + overrideAccess?: boolean + showHiddenFields?: boolean + depth?: number +} + +async function publishVersion(this: Payload, args: Arguments): Promise { + const { + collection: { + Model, + config: collectionConfig, + }, + id, + overrideAccess = false, + showHiddenFields, + depth, + req: { + locale, + }, + req, + } = args; + + if (!id) { + throw new APIError('Missing ID of version to restore.', httpStatus.BAD_REQUEST); + } + + // ///////////////////////////////////// + // Retrieve original raw version to get parent ID + // ///////////////////////////////////// + + const VersionModel = this.versions[collectionConfig.slug]; + + let rawVersion = await VersionModel.findOne({ + _id: id, + }); + + if (!rawVersion) { + throw new NotFound(); + } + + rawVersion = rawVersion.toJSON({ virtuals: true }); + + const parentDocID = rawVersion.parent; + + // ///////////////////////////////////// + // Access + // ///////////////////////////////////// + + const accessResults = !overrideAccess ? await executeAccess({ req, id: parentDocID }, collectionConfig.access.update) : true; + const hasWherePolicy = hasWhereAccessResult(accessResults); + + // ///////////////////////////////////// + // Retrieve document + // ///////////////////////////////////// + + const queryToBuild: { where: Where } = { + where: { + and: [ + { + id: { + equals: parentDocID, + }, + }, + ], + }, + }; + + if (hasWhereAccessResult(accessResults)) { + (queryToBuild.where.and as Where[]).push(accessResults); + } + + const query = await Model.buildQuery(queryToBuild, locale); + + const doc = await Model.findOne(query); + + if (!doc && !hasWherePolicy) throw new NotFound(); + if (!doc && hasWherePolicy) throw new Forbidden(); + + // ///////////////////////////////////// + // Update + // ///////////////////////////////////// + + let result = await Model.findByIdAndUpdate( + { _id: parentDocID }, + rawVersion.version, + { new: true }, + ); + + result = result.toJSON({ virtuals: true }); + + // custom id type reset + result.id = result._id; + result = JSON.stringify(result); + result = JSON.parse(result); + result = sanitizeInternalFields(result); + + // ///////////////////////////////////// + // afterRead - Fields + // ///////////////////////////////////// + + result = await this.performFieldOperations(collectionConfig, { + id: parentDocID, + depth, + req, + data: result, + hook: 'afterRead', + operation: 'update', + overrideAccess, + flattenLocales: true, + showHiddenFields, + }); + + // ///////////////////////////////////// + // afterRead - Collection + // ///////////////////////////////////// + + await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { + await priorHook; + + result = await hook({ + req, + doc: result, + }) || result; + }, Promise.resolve()); + + // ///////////////////////////////////// + // afterChange - Fields + // ///////////////////////////////////// + + result = await this.performFieldOperations(collectionConfig, { + data: result, + hook: 'afterChange', + operation: 'update', + req, + id: parentDocID, + depth, + overrideAccess, + showHiddenFields, + }); + + // ///////////////////////////////////// + // afterChange - Collection + // ///////////////////////////////////// + + await collectionConfig.hooks.afterChange.reduce(async (priorHook, hook) => { + await priorHook; + + result = await hook({ + doc: result, + req, + operation: 'update', + }) || result; + }, Promise.resolve()); + + return result; +} + +export default publishVersion; diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index 14e6db4d02..af9bf2e956 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -1,37 +1,34 @@ import httpStatus from 'http-status'; -import path from 'path'; -import { UploadedFile } from 'express-fileupload'; +import { Payload } from '../..'; import { Where, Document } from '../../types'; import { Collection } from '../config/types'; - import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; import executeAccess from '../../auth/executeAccess'; -import { NotFound, Forbidden, APIError, FileUploadError, ValidationError } from '../../errors'; -import isImage from '../../uploads/isImage'; -import getImageSize from '../../uploads/getImageSize'; -import getSafeFilename from '../../uploads/getSafeFilename'; - -import resizeAndSave from '../../uploads/imageResizer'; -import { FileData } from '../../uploads/types'; - +import { NotFound, Forbidden, APIError, ValidationError } from '../../errors'; import { PayloadRequest } from '../../express/types'; import { hasWhereAccessResult, UserDocument } from '../../auth/types'; -import saveBufferToFile from '../../uploads/saveBufferToFile'; +import { saveCollectionDraft } from '../../versions/drafts/saveCollectionDraft'; +import { saveCollectionVersion } from '../../versions/saveCollectionVersion'; +import uploadFile from '../../uploads/uploadFile'; +import cleanUpFailedCollectionVersion from '../../versions/cleanUpFailedCollectionVersion'; +import { ensurePublishedCollectionVersion } from '../../versions/ensurePublishedCollectionVersion'; export type Arguments = { collection: Collection req: PayloadRequest - id: string + id: string | number data: Record depth?: number disableVerificationEmail?: boolean overrideAccess?: boolean showHiddenFields?: boolean overwriteExistingFiles?: boolean + draft?: boolean + autosave?: boolean } -async function update(incomingArgs: Arguments): Promise { - const { performFieldOperations, config } = this; +async function update(this: Payload, incomingArgs: Arguments): Promise { + const { config } = this; let args = incomingArgs; @@ -50,6 +47,7 @@ async function update(incomingArgs: Arguments): Promise { const { depth, + collection, collection: { Model, config: collectionConfig, @@ -62,12 +60,16 @@ async function update(incomingArgs: Arguments): Promise { overrideAccess, showHiddenFields, overwriteExistingFiles = false, + draft: draftArg = false, + autosave = false, } = args; if (!id) { throw new APIError('Missing ID of document to update.', httpStatus.BAD_REQUEST); } + const shouldSaveDraft = Boolean(draftArg && collectionConfig.versions.drafts); + // ///////////////////////////////////// // Access // ///////////////////////////////////// @@ -106,7 +108,7 @@ async function update(incomingArgs: Arguments): Promise { docWithLocales = JSON.stringify(docWithLocales); docWithLocales = JSON.parse(docWithLocales); - const originalDoc = await performFieldOperations(collectionConfig, { + const originalDoc = await this.performFieldOperations(collectionConfig, { id, depth: 0, req, @@ -124,72 +126,20 @@ async function update(incomingArgs: Arguments): Promise { // Upload and resize potential files // ///////////////////////////////////// - if (collectionConfig.upload) { - const fileData: Partial = {}; - - const { staticDir, imageSizes, disableLocalStorage } = collectionConfig.upload; - - let staticPath = staticDir; - - if (staticDir.indexOf('/') !== 0) { - staticPath = path.resolve(config.paths.configDir, staticDir); - } - - const { file } = req.files || {}; - - if (file) { - const fsSafeName = !overwriteExistingFiles ? await getSafeFilename(Model, staticPath, file.name) : file.name; - - try { - if (!disableLocalStorage) { - await saveBufferToFile(file.data, `${staticPath}/${fsSafeName}`); - } - - fileData.filename = fsSafeName; - fileData.filesize = file.size; - fileData.mimeType = file.mimetype; - - if (isImage(file.mimetype)) { - const dimensions = await getImageSize(file); - fileData.width = dimensions.width; - fileData.height = dimensions.height; - - if (Array.isArray(imageSizes) && file.mimetype !== 'image/svg+xml') { - req.payloadUploadSizes = {}; - fileData.sizes = await resizeAndSave({ - req, - file: file.data, - dimensions, - staticPath, - config: collectionConfig, - savedFilename: fsSafeName, - mimeType: fileData.mimeType, - }); - } - } - } catch (err) { - console.error(err); - throw new FileUploadError(); - } - - data = { - ...data, - ...fileData, - }; - } else if (data.file === null) { - data = { - ...data, - filename: null, - sizes: null, - }; - } - } + data = await uploadFile({ + config, + collection, + req, + data, + throwOnMissingFile: false, + overwriteExistingFiles, + }); // ///////////////////////////////////// // beforeValidate - Fields // ///////////////////////////////////// - data = await performFieldOperations(collectionConfig, { + data = await this.performFieldOperations(collectionConfig, { data, req, id, @@ -233,7 +183,7 @@ async function update(incomingArgs: Arguments): Promise { // beforeChange - Fields // ///////////////////////////////////// - let result = await performFieldOperations(collectionConfig, { + let result = await this.performFieldOperations(collectionConfig, { data, req, id, @@ -243,6 +193,7 @@ async function update(incomingArgs: Arguments): Promise { overrideAccess, unflattenLocales: true, docWithLocales, + skipValidation: shouldSaveDraft, }); // ///////////////////////////////////// @@ -251,43 +202,85 @@ async function update(incomingArgs: Arguments): Promise { const { password } = data; - if (password && collectionConfig.auth) { + if (password && collectionConfig.auth && !shouldSaveDraft) { await doc.setPassword(password as string); await doc.save(); delete data.password; delete result.password; } + // ///////////////////////////////////// + // Create version from existing doc + // ///////////////////////////////////// + + let createdVersion; + + if (collectionConfig.versions && !shouldSaveDraft) { + createdVersion = await saveCollectionVersion({ + payload: this, + config: collectionConfig, + req, + docWithLocales, + id, + }); + } + // ///////////////////////////////////// // Update // ///////////////////////////////////// - try { - result = await Model.findByIdAndUpdate( - { _id: id }, - result, - { new: true }, - ); - } catch (error) { - // Handle uniqueness error from MongoDB - throw error.code === 11000 - ? new ValidationError([{ message: 'Value must be unique', field: Object.keys(error.keyValue)[0] }]) - : error; + if (shouldSaveDraft) { + await ensurePublishedCollectionVersion({ + payload: this, + config: collectionConfig, + req, + docWithLocales, + id, + }); + + result = await saveCollectionDraft({ + payload: this, + config: collectionConfig, + req, + data: result, + id, + autosave, + }); + } else { + try { + result = await Model.findByIdAndUpdate( + { _id: id }, + result, + { new: true }, + ); + } catch (error) { + cleanUpFailedCollectionVersion({ + payload: this, + collection: collectionConfig, + version: createdVersion, + }); + + // Handle uniqueness error from MongoDB + throw error.code === 11000 + ? new ValidationError([{ message: 'Value must be unique', field: Object.keys(error.keyValue)[0] }]) + : error; + } + + result = result.toJSON({ virtuals: true }); + result = JSON.stringify(result); + result = JSON.parse(result); + + // custom id type reset + result.id = result._id; } - result = result.toJSON({ virtuals: true }); - - // custom id type reset - result.id = result._id; - result = JSON.stringify(result); - result = JSON.parse(result); result = sanitizeInternalFields(result); // ///////////////////////////////////// // afterRead - Fields // ///////////////////////////////////// - result = await performFieldOperations(collectionConfig, { + result = await this.performFieldOperations(collectionConfig, { id, depth, req, @@ -316,7 +309,7 @@ async function update(incomingArgs: Arguments): Promise { // afterChange - Fields // ///////////////////////////////////// - result = await performFieldOperations(collectionConfig, { + result = await this.performFieldOperations(collectionConfig, { data: result, hook: 'afterChange', operation: 'update', diff --git a/src/collections/requestHandlers/create.ts b/src/collections/requestHandlers/create.ts index 6dfb85bc7f..e3adefb37e 100644 --- a/src/collections/requestHandlers/create.ts +++ b/src/collections/requestHandlers/create.ts @@ -16,6 +16,7 @@ export default async function create(req: PayloadRequest, res: Response, next: N collection: req.collection, data: req.body, depth: req.query.depth, + draft: req.query.draft === 'true', }); return res.status(httpStatus.CREATED).json({ diff --git a/src/collections/requestHandlers/find.ts b/src/collections/requestHandlers/find.ts index 028d4eeab0..04983eb850 100644 --- a/src/collections/requestHandlers/find.ts +++ b/src/collections/requestHandlers/find.ts @@ -1,7 +1,8 @@ import { Response, NextFunction } from 'express'; import httpStatus from 'http-status'; import { PayloadRequest } from '../../express/types'; -import { PaginatedDocs, TypeWithID } from '../config/types'; +import { TypeWithID } from '../config/types'; +import { PaginatedDocs } from '../../mongoose/types'; export default async function find(req: PayloadRequest, res: Response, next: NextFunction): Promise> | void> { try { @@ -23,6 +24,7 @@ export default async function find(req: PayloadReque limit: req.query.limit, sort: req.query.sort, depth: req.query.depth, + draft: req.query.draft === 'true', }; const result = await this.operations.collections.find(options); diff --git a/src/collections/requestHandlers/findByID.ts b/src/collections/requestHandlers/findByID.ts index d42d81e404..5a9955f1a3 100644 --- a/src/collections/requestHandlers/findByID.ts +++ b/src/collections/requestHandlers/findByID.ts @@ -13,6 +13,7 @@ export default async function findByID(req: PayloadRequest, res: Response, next: collection: req.collection, id: req.params.id, depth: req.query.depth, + draft: req.query.draft === 'true', }; try { diff --git a/src/collections/requestHandlers/findVersionByID.ts b/src/collections/requestHandlers/findVersionByID.ts new file mode 100644 index 0000000000..39ae12e2b2 --- /dev/null +++ b/src/collections/requestHandlers/findVersionByID.ts @@ -0,0 +1,24 @@ +import { Response, NextFunction } from 'express'; +import { PayloadRequest } from '../../express/types'; +import { Document } from '../../types'; + +export type FindByIDResult = { + message: string; + doc: Document; +}; + +export default async function findVersionByID(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { + const options = { + req, + collection: req.collection, + id: req.params.id, + depth: req.query.depth, + }; + + try { + const doc = await this.operations.collections.findVersionByID(options); + return res.json(doc); + } catch (error) { + return next(error); + } +} diff --git a/src/collections/requestHandlers/findVersions.ts b/src/collections/requestHandlers/findVersions.ts new file mode 100644 index 0000000000..61b1da764b --- /dev/null +++ b/src/collections/requestHandlers/findVersions.ts @@ -0,0 +1,35 @@ +import { Response, NextFunction } from 'express'; +import httpStatus from 'http-status'; +import { PayloadRequest } from '../../express/types'; +import { TypeWithID } from '../config/types'; +import { PaginatedDocs } from '../../mongoose/types'; + +export default async function findVersions(req: PayloadRequest, res: Response, next: NextFunction): Promise> | void> { + try { + let page; + + if (typeof req.query.page === 'string') { + const parsedPage = parseInt(req.query.page, 10); + + if (!Number.isNaN(parsedPage)) { + page = parsedPage; + } + } + + const options = { + req, + collection: req.collection, + where: req.query.where, + page, + limit: req.query.limit, + sort: req.query.sort, + depth: req.query.depth, + }; + + const result = await this.operations.collections.findVersions(options); + + return res.status(httpStatus.OK).json(result); + } catch (error) { + return next(error); + } +} diff --git a/src/collections/requestHandlers/publishVersion.ts b/src/collections/requestHandlers/publishVersion.ts new file mode 100644 index 0000000000..b16dbab6f3 --- /dev/null +++ b/src/collections/requestHandlers/publishVersion.ts @@ -0,0 +1,29 @@ +import { Response, NextFunction } from 'express'; +import httpStatus from 'http-status'; +import { PayloadRequest } from '../../express/types'; +import { Document } from '../../types'; +import formatSuccessResponse from '../../express/responses/formatSuccess'; + +export type RestoreResult = { + message: string + doc: Document +}; + +export default async function publishVersion(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { + const options = { + req, + collection: req.collection, + id: req.params.id, + depth: req.query.depth, + }; + + try { + const doc = await this.operations.collections.publishVersion(options); + return res.status(httpStatus.OK).json({ + ...formatSuccessResponse('Restored successfully.', 'message'), + doc, + }); + } catch (error) { + return next(error); + } +} diff --git a/src/collections/requestHandlers/update.ts b/src/collections/requestHandlers/update.ts index ca1f7f259a..7830ec47ec 100644 --- a/src/collections/requestHandlers/update.ts +++ b/src/collections/requestHandlers/update.ts @@ -10,16 +10,26 @@ export type UpdateResult = { export default async function update(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { try { + const draft = req.query.draft === 'true'; + const autosave = req.query.autosave === 'true'; + const doc = await this.operations.collections.update({ req, collection: req.collection, id: req.params.id, data: req.body, depth: req.query.depth, + draft, + autosave, }); + let message = 'Updated successfully.'; + + if (draft) message = 'Draft saved successfully.'; + if (autosave) message = 'Autosaved successfully.'; + return res.status(httpStatus.OK).json({ - ...formatSuccessResponse('Updated successfully.', 'message'), + ...formatSuccessResponse(message, 'message'), doc, }); } catch (error) { diff --git a/src/errors/TimestampsRequired.ts b/src/errors/TimestampsRequired.ts new file mode 100644 index 0000000000..f68355811f --- /dev/null +++ b/src/errors/TimestampsRequired.ts @@ -0,0 +1,10 @@ +import { CollectionConfig } from '../collections/config/types'; +import APIError from './APIError'; + +class TimestampsRequired extends APIError { + constructor(collection: CollectionConfig) { + super(`Timestamps are required in the collection ${collection.slug} because you have opted in to Versions.`); + } +} + +export default TimestampsRequired; diff --git a/src/fields/accessPromise.ts b/src/fields/accessPromise.ts index 1d3968d9fc..36d28c8821 100644 --- a/src/fields/accessPromise.ts +++ b/src/fields/accessPromise.ts @@ -12,7 +12,7 @@ type Arguments = { operation: Operation overrideAccess: boolean req: PayloadRequest - id: string + id: string | number relationshipPopulations: (() => Promise)[] depth: number currentDepth: number diff --git a/src/fields/hookPromise.ts b/src/fields/hookPromise.ts index c17516a410..aeeac86a5a 100644 --- a/src/fields/hookPromise.ts +++ b/src/fields/hookPromise.ts @@ -1,6 +1,6 @@ import { PayloadRequest } from '../express/types'; import { Operation } from '../types'; -import { HookName, FieldAffectingData } from './config/types'; +import { HookName, FieldAffectingData, FieldHook } from './config/types'; type Arguments = { data: Record @@ -10,37 +10,80 @@ type Arguments = { operation: Operation fullOriginalDoc: Record fullData: Record + flattenLocales: boolean + isVersion: boolean } -const hookPromise = async ({ - data, - field, - hook, - req, - operation, +type ExecuteHookArguments = { + currentHook: FieldHook + value: unknown +} & Arguments; + +const executeHook = async ({ + currentHook, fullOriginalDoc, fullData, -}: Arguments): Promise => { - const resultingData = data; + operation, + req, + value, +}: ExecuteHookArguments) => { + let hookedValue = await currentHook({ + value, + originalDoc: fullOriginalDoc, + data: fullData, + operation, + req, + }); + + if (typeof hookedValue === 'undefined') { + hookedValue = value; + } + + return hookedValue; +}; + +const hookPromise = async (args: Arguments): Promise => { + const { + field, + hook, + req, + flattenLocales, + data, + } = args; if (field.hooks && field.hooks[hook]) { await field.hooks[hook].reduce(async (priorHook, currentHook) => { await priorHook; - let hookedValue = await currentHook({ - value: data[field.name], - originalDoc: fullOriginalDoc, - data: fullData, - operation, - req, - }); + const shouldRunHookOnAllLocales = hook === 'afterRead' + && field.localized + && (req.locale === 'all' || !flattenLocales) + && typeof data[field.name] === 'object'; - if (typeof hookedValue === 'undefined') { - hookedValue = data[field.name]; - } + if (shouldRunHookOnAllLocales) { + const hookPromises = Object.entries(data[field.name]).map(([locale, value]) => (async () => { + const hookedValue = await executeHook({ + ...args, + currentHook, + value, + }); - if (hookedValue !== undefined) { - resultingData[field.name] = hookedValue; + if (hookedValue !== undefined) { + data[field.name][locale] = hookedValue; + } + })()); + + await Promise.all(hookPromises); + } else { + const hookedValue = await executeHook({ + ...args, + value: data[field.name], + currentHook, + }); + + if (hookedValue !== undefined) { + data[field.name] = hookedValue; + } } }, Promise.resolve()); } diff --git a/src/fields/performFieldOperations.ts b/src/fields/performFieldOperations.ts index 02f4b25cf3..5a95aeecb4 100644 --- a/src/fields/performFieldOperations.ts +++ b/src/fields/performFieldOperations.ts @@ -19,13 +19,15 @@ type Arguments = { unflattenLocales?: boolean originalDoc?: Record docWithLocales?: Record - id?: string + id?: string | number showHiddenFields?: boolean depth?: number currentDepth?: number + isVersion?: boolean + skipValidation?: boolean } -export default async function performFieldOperations(this: Payload, entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig, args: Arguments): Promise<{ [key: string]: unknown }> { +export default async function performFieldOperations(this: Payload, entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig, args: Arguments): Promise { const { data, originalDoc: fullOriginalDoc, @@ -42,6 +44,8 @@ export default async function performFieldOperations(this: Payload, entityConfig flattenLocales, unflattenLocales = false, showHiddenFields = false, + isVersion = false, + skipValidation = false, } = args; const fullData = deepCopyObject(data); @@ -101,6 +105,8 @@ export default async function performFieldOperations(this: Payload, entityConfig unflattenLocaleActions, transformActions, docWithLocales, + isVersion, + skipValidation, }); if (hook === 'afterRead') { diff --git a/src/fields/relationshipPopulationPromise.ts b/src/fields/relationshipPopulationPromise.ts index 9909224ed3..40f4df18d5 100644 --- a/src/fields/relationshipPopulationPromise.ts +++ b/src/fields/relationshipPopulationPromise.ts @@ -42,10 +42,10 @@ const populate = async ({ let populatedRelationship; if (depth && currentDepth <= depth) { - populatedRelationship = await payload.operations.collections.findByID({ + populatedRelationship = await payload.findByID({ req, - collection: relatedCollection, - id: idString, + collection: relatedCollection.config.slug, + id: idString as string, currentDepth: currentDepth + 1, overrideAccess, disableErrors: true, diff --git a/src/fields/traverseFields.ts b/src/fields/traverseFields.ts index c18bf2d58d..9c2d96d0a8 100644 --- a/src/fields/traverseFields.ts +++ b/src/fields/traverseFields.ts @@ -19,7 +19,7 @@ type Arguments = { operation: Operation overrideAccess: boolean req: PayloadRequest - id?: string + id?: string | number relationshipPopulations: (() => Promise)[] depth: number currentDepth: number @@ -36,6 +36,7 @@ type Arguments = { transformActions: (() => void)[] docWithLocales?: Record skipValidation?: boolean + isVersion: boolean } const traverseFields = (args: Arguments): void => { @@ -68,6 +69,7 @@ const traverseFields = (args: Arguments): void => { transformActions, docWithLocales = {}, skipValidation, + isVersion, } = args; fields.forEach((field) => { @@ -226,6 +228,8 @@ const traverseFields = (args: Arguments): void => { operation, fullOriginalDoc, fullData, + flattenLocales, + isVersion, })); } diff --git a/src/globals/config/schema.ts b/src/globals/config/schema.ts index a8ff605021..d5404dda0f 100644 --- a/src/globals/config/schema.ts +++ b/src/globals/config/schema.ts @@ -5,6 +5,7 @@ const globalSchema = joi.object().keys({ slug: joi.string().required(), label: joi.string(), admin: joi.object({ + hideAPIURL: joi.boolean(), description: joi.alternatives().try( joi.string(), componentSchema, @@ -19,9 +20,27 @@ const globalSchema = joi.object().keys({ }), access: joi.object({ read: joi.func(), + readVersions: joi.func(), update: joi.func(), }), fields: joi.array(), + versions: joi.alternatives().try( + joi.object({ + max: joi.number(), + drafts: joi.alternatives().try( + joi.object({ + autosave: joi.alternatives().try( + joi.boolean(), + joi.object({ + interval: joi.number(), + }), + ), + }), + joi.boolean(), + ), + }), + joi.boolean(), + ), }).unknown(); export default globalSchema; diff --git a/src/globals/config/types.ts b/src/globals/config/types.ts index 1a7edc2bb0..6b101a28de 100644 --- a/src/globals/config/types.ts +++ b/src/globals/config/types.ts @@ -4,6 +4,11 @@ import { DeepRequired } from 'ts-essentials'; import { PayloadRequest } from '../../express/types'; import { Access, GeneratePreviewURL } from '../../config/types'; import { Field } from '../../fields/config/types'; +import { IncomingGlobalVersions, SanitizedGlobalVersions } from '../../versions/types'; + +export type TypeWithID = { + id: string +} export type BeforeValidateHook = (args: { data?: any; @@ -40,6 +45,7 @@ export type GlobalConfig = { slug: string label?: string preview?: GeneratePreviewURL + versions?: IncomingGlobalVersions | boolean hooks?: { beforeValidate?: BeforeValidateHook[] beforeChange?: BeforeChangeHook[] @@ -49,12 +55,14 @@ export type GlobalConfig = { } access?: { read?: Access; + readDrafts?: Access; + readVersions?: Access; update?: Access; - admin?: Access; } fields: Field[]; admin?: { description?: string | (() => string); + hideAPIURL?: boolean; components?: { views?: { Edit?: React.ComponentType @@ -63,8 +71,9 @@ export type GlobalConfig = { } } -export interface SanitizedGlobalConfig extends Omit, 'fields'> { +export interface SanitizedGlobalConfig extends Omit, 'fields' | 'versions'> { fields: Field[] + versions: SanitizedGlobalVersions } export type Globals = { diff --git a/src/globals/graphql/init.ts b/src/globals/graphql/init.ts index 2ed31cd547..709804cbe8 100644 --- a/src/globals/graphql/init.ts +++ b/src/globals/graphql/init.ts @@ -1,4 +1,4 @@ -import { GraphQLNonNull } from 'graphql'; +import { GraphQLNonNull, GraphQLBoolean } from 'graphql'; import formatName from '../../graphql/utilities/formatName'; function registerGlobals() { @@ -33,6 +33,7 @@ function registerGlobals() { this.Query.fields[formattedLabel] = { type: global.graphQL.type, args: { + draft: { type: GraphQLBoolean }, ...(this.config.localization ? { locale: { type: this.types.localeInputType }, fallbackLocale: { type: this.types.fallbackLocaleInputType }, @@ -45,6 +46,7 @@ function registerGlobals() { type: global.graphQL.type, args: { data: { type: global.graphQL.mutationInputType }, + draft: { type: GraphQLBoolean }, }, resolve: update(global), }; diff --git a/src/globals/graphql/resolvers/findOne.ts b/src/globals/graphql/resolvers/findOne.ts index 09e5fb3053..8ad084c95e 100644 --- a/src/globals/graphql/resolvers/findOne.ts +++ b/src/globals/graphql/resolvers/findOne.ts @@ -15,6 +15,7 @@ function findOne(globalConfig: SanitizedGlobalConfig): Document { slug, depth: 0, req: context.req, + draft: args.draft, }; const result = await this.operations.globals.findOne(options); diff --git a/src/globals/graphql/resolvers/update.ts b/src/globals/graphql/resolvers/update.ts index 7e6fdda342..404a7c4c6b 100644 --- a/src/globals/graphql/resolvers/update.ts +++ b/src/globals/graphql/resolvers/update.ts @@ -13,6 +13,7 @@ function update(globalConfig) { depth: 0, data: args.data, req: context.req, + draft: args.draft, }; const result = await this.operations.globals.update(options); diff --git a/src/globals/init.ts b/src/globals/init.ts index 0b5bf2c5bf..ca3d75f041 100644 --- a/src/globals/init.ts +++ b/src/globals/init.ts @@ -1,6 +1,13 @@ import express from 'express'; +import mongoose from 'mongoose'; +import paginate from 'mongoose-paginate-v2'; +import buildQueryPlugin from '../mongoose/buildQuery'; import buildModel from './buildModel'; import { Payload } from '../index'; +import { getVersionsModelName } from '../versions/getVersionsModelName'; +import { buildVersionGlobalFields } from '../versions/buildGlobalFields'; +import buildSchema from '../mongoose/buildSchema'; +import { GlobalModel } from './config/types'; export default function initGlobals(ctx: Payload): void { if (ctx.config.globals) { @@ -9,6 +16,28 @@ export default function initGlobals(ctx: Payload): void { config: ctx.config.globals, }; + ctx.config.globals.forEach((global) => { + if (global.versions) { + const versionModelName = getVersionsModelName(global); + + const versionSchema = buildSchema( + ctx.config, + buildVersionGlobalFields(global), + { + disableUnique: true, + options: { + timestamps: true, + }, + }, + ); + + versionSchema.plugin(paginate, { useEstimatedCount: true }) + .plugin(buildQueryPlugin); + + ctx.versions[global.slug] = mongoose.model(versionModelName, versionSchema) as GlobalModel; + } + }); + // If not local, open routes if (!ctx.local) { const router = express.Router(); @@ -18,6 +47,15 @@ export default function initGlobals(ctx: Payload): void { .route(`/globals/${global.slug}`) .get(ctx.requestHandlers.globals.findOne(global)) .post(ctx.requestHandlers.globals.update(global)); + + if (global.versions) { + router.route(`/globals/${global.slug}/versions`) + .get(ctx.requestHandlers.globals.findVersions(global)); + + router.route(`/globals/${global.slug}/versions/:id`) + .get(ctx.requestHandlers.globals.findVersionByID(global)) + .post(ctx.requestHandlers.globals.publishVersion(global)); + } }); ctx.router.use(router); diff --git a/src/globals/operations/findOne.ts b/src/globals/operations/findOne.ts index c6b7db3e85..ca22ea0c4c 100644 --- a/src/globals/operations/findOne.ts +++ b/src/globals/operations/findOne.ts @@ -10,6 +10,7 @@ async function findOne(args) { slug, depth, showHiddenFields, + draft = false, } = args; // ///////////////////////////////////// diff --git a/src/globals/operations/findVersionByID.ts b/src/globals/operations/findVersionByID.ts new file mode 100644 index 0000000000..68cdc8b322 --- /dev/null +++ b/src/globals/operations/findVersionByID.ts @@ -0,0 +1,142 @@ +/* eslint-disable no-underscore-dangle */ +import { PayloadRequest } from '../../express/types'; +import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; +import { Forbidden, NotFound } from '../../errors'; +import executeAccess from '../../auth/executeAccess'; +import { Where } from '../../types'; +import { hasWhereAccessResult } from '../../auth/types'; +import { TypeWithVersion } from '../../versions/types'; +import { SanitizedGlobalConfig } from '../config/types'; + +export type Arguments = { + globalConfig: SanitizedGlobalConfig + id: string + req: PayloadRequest + disableErrors?: boolean + currentDepth?: number + overrideAccess?: boolean + showHiddenFields?: boolean + depth?: number +} + +async function findVersionByID = any>(args: Arguments): Promise { + const { + depth, + globalConfig, + id, + req, + req: { + locale, + }, + disableErrors, + currentDepth, + overrideAccess, + showHiddenFields, + } = args; + + const VersionsModel = this.versions[globalConfig.slug]; + + // ///////////////////////////////////// + // Access + // ///////////////////////////////////// + + const accessResults = !overrideAccess ? await executeAccess({ req, disableErrors, id }, globalConfig.access.readVersions) : true; + + // If errors are disabled, and access returns false, return null + if (accessResults === false) return null; + + const hasWhereAccess = typeof accessResults === 'object'; + + const queryToBuild: { where: Where } = { + where: { + and: [ + { + _id: { + equals: id, + }, + }, + ], + }, + }; + + if (hasWhereAccessResult(accessResults)) { + (queryToBuild.where.and as Where[]).push(accessResults); + } + + const query = await VersionsModel.buildQuery(queryToBuild, locale); + + // ///////////////////////////////////// + // Find by ID + // ///////////////////////////////////// + + if (!query.$and[0]._id) throw new NotFound(); + + let result = await VersionsModel.findOne(query, {}).lean(); + + if (!result) { + if (!disableErrors) { + if (!hasWhereAccess) throw new NotFound(); + if (hasWhereAccess) throw new Forbidden(); + } + + return null; + } + + // Clone the result - it may have come back memoized + result = JSON.parse(JSON.stringify(result)); + + result = sanitizeInternalFields(result); + + // ///////////////////////////////////// + // beforeRead - Collection + // ///////////////////////////////////// + + await globalConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { + await priorHook; + + result.version = await hook({ + req, + query, + doc: result.version, + }) || result.version; + }, Promise.resolve()); + + // ///////////////////////////////////// + // afterRead - Fields + // ///////////////////////////////////// + + result.version = await this.performFieldOperations(globalConfig, { + depth, + req, + id, + data: result.version, + hook: 'afterRead', + operation: 'read', + currentDepth, + overrideAccess, + flattenLocales: true, + showHiddenFields, + }); + + // ///////////////////////////////////// + // afterRead - Collection + // ///////////////////////////////////// + + await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => { + await priorHook; + + result.version = await hook({ + req, + query, + doc: result.version, + }) || result.version; + }, Promise.resolve()); + + // ///////////////////////////////////// + // Return results + // ///////////////////////////////////// + + return result; +} + +export default findVersionByID; diff --git a/src/globals/operations/findVersions.ts b/src/globals/operations/findVersions.ts new file mode 100644 index 0000000000..f208d23199 --- /dev/null +++ b/src/globals/operations/findVersions.ts @@ -0,0 +1,160 @@ +import { Where } from '../../types'; +import { PayloadRequest } from '../../express/types'; +import executeAccess from '../../auth/executeAccess'; +import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; +import { PaginatedDocs } from '../../mongoose/types'; +import { hasWhereAccessResult } from '../../auth/types'; +import flattenWhereConstraints from '../../utilities/flattenWhereConstraints'; +import { buildSortParam } from '../../mongoose/buildSortParam'; +import { TypeWithVersion } from '../../versions/types'; +import { SanitizedGlobalConfig } from '../config/types'; + +export type Arguments = { + globalConfig: SanitizedGlobalConfig + where?: Where + page?: number + limit?: number + sort?: string + depth?: number + req?: PayloadRequest + overrideAccess?: boolean + showHiddenFields?: boolean +} + +async function findVersions = any>(args: Arguments): Promise> { + const { + where, + page, + limit, + depth, + globalConfig, + req, + req: { + locale, + }, + overrideAccess, + showHiddenFields, + } = args; + + const VersionsModel = this.versions[globalConfig.slug]; + + // ///////////////////////////////////// + // Access + // ///////////////////////////////////// + + const queryToBuild: { where?: Where} = {}; + let useEstimatedCount = false; + + if (where) { + let and = []; + + if (Array.isArray(where.and)) and = where.and; + if (Array.isArray(where.AND)) and = where.AND; + + queryToBuild.where = { + ...where, + and: [ + ...and, + ], + }; + + const constraints = flattenWhereConstraints(queryToBuild); + + useEstimatedCount = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near')); + } + + if (!overrideAccess) { + const accessResults = await executeAccess({ req }, globalConfig.access.readVersions); + + if (hasWhereAccessResult(accessResults)) { + if (!where) { + queryToBuild.where = { + and: [ + accessResults, + ], + }; + } else { + (queryToBuild.where.and as Where[]).push(accessResults); + } + } + } + + const query = await VersionsModel.buildQuery(queryToBuild, locale); + + // ///////////////////////////////////// + // Find + // ///////////////////////////////////// + + const [sortProperty, sortOrder] = buildSortParam(args.sort || '-updatedAt', true); + + const optionsToExecute = { + page: page || 1, + limit: limit || 10, + sort: { + [sortProperty]: sortOrder, + }, + lean: true, + leanWithId: true, + useEstimatedCount, + }; + + const paginatedDocs = await VersionsModel.paginate(query, optionsToExecute); + + // ///////////////////////////////////// + // afterRead - Fields + // ///////////////////////////////////// + + let result = { + ...paginatedDocs, + docs: await Promise.all(paginatedDocs.docs.map(async (data) => ({ + ...data, + version: await this.performFieldOperations( + globalConfig, + { + depth, + data: data.version, + req, + id: data.version.id, + hook: 'afterRead', + operation: 'read', + overrideAccess, + flattenLocales: true, + showHiddenFields, + isVersion: true, + }, + ), + }))), + }; + + // ///////////////////////////////////// + // afterRead - Collection + // ///////////////////////////////////// + + result = { + ...result, + docs: await Promise.all(result.docs.map(async (doc) => { + const docRef = doc; + + await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => { + await priorHook; + + docRef.version = await hook({ req, query, doc: doc.version }) || doc.version; + }, Promise.resolve()); + + return docRef; + })), + }; + + // ///////////////////////////////////// + // Return results + // ///////////////////////////////////// + + result = { + ...result, + docs: result.docs.map((doc) => sanitizeInternalFields(doc)), + }; + + return result; +} + +export default findVersions; diff --git a/src/globals/operations/local/findOne.ts b/src/globals/operations/local/findOne.ts index aad5724e8e..d79eaf7f5b 100644 --- a/src/globals/operations/local/findOne.ts +++ b/src/globals/operations/local/findOne.ts @@ -7,6 +7,7 @@ async function findOne(options) { user, overrideAccess = true, showHiddenFields, + draft = false, } = options; const globalConfig = this.globals.config.find((config) => config.slug === globalSlug); @@ -17,6 +18,7 @@ async function findOne(options) { globalConfig, overrideAccess, showHiddenFields, + draft, req: { user, payloadAPI: 'local', diff --git a/src/globals/operations/local/update.ts b/src/globals/operations/local/update.ts index ac3c64ff4b..d294a0cd87 100644 --- a/src/globals/operations/local/update.ts +++ b/src/globals/operations/local/update.ts @@ -8,6 +8,7 @@ async function update(options) { user, overrideAccess = true, showHiddenFields, + draft, } = options; const globalConfig = this.globals.config.find((config) => config.slug === globalSlug); @@ -19,6 +20,7 @@ async function update(options) { globalConfig, overrideAccess, showHiddenFields, + draft, req: { user, payloadAPI: 'local', diff --git a/src/globals/operations/publishVersion.ts b/src/globals/operations/publishVersion.ts new file mode 100644 index 0000000000..29c80d2fcf --- /dev/null +++ b/src/globals/operations/publishVersion.ts @@ -0,0 +1,162 @@ +import { Where } from '../../types'; +import { PayloadRequest } from '../../express/types'; +import executeAccess from '../../auth/executeAccess'; +import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; +import { PaginatedDocs } from '../../mongoose/types'; +import { hasWhereAccessResult } from '../../auth/types'; +import flattenWhereConstraints from '../../utilities/flattenWhereConstraints'; +import { buildSortParam } from '../../mongoose/buildSortParam'; +import { TypeWithVersion } from '../../versions/types'; +import { SanitizedGlobalConfig } from '../config/types'; + +export type Arguments = { + globalConfig: SanitizedGlobalConfig + where?: Where + page?: number + limit?: number + sort?: string + depth?: number + req?: PayloadRequest + overrideAccess?: boolean + showHiddenFields?: boolean +} + +// TODO: finish + +async function publishVersion = any>(args: Arguments): Promise> { + const { + where, + page, + limit, + depth, + globalConfig, + req, + req: { + locale, + }, + overrideAccess, + showHiddenFields, + } = args; + + const VersionsModel = this.versions[globalConfig.slug]; + + // ///////////////////////////////////// + // Access + // ///////////////////////////////////// + + const queryToBuild: { where?: Where} = {}; + let useEstimatedCount = false; + + if (where) { + let and = []; + + if (Array.isArray(where.and)) and = where.and; + if (Array.isArray(where.AND)) and = where.AND; + + queryToBuild.where = { + ...where, + and: [ + ...and, + ], + }; + + const constraints = flattenWhereConstraints(queryToBuild); + + useEstimatedCount = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near')); + } + + if (!overrideAccess) { + const accessResults = await executeAccess({ req }, globalConfig.access.readVersions); + + if (hasWhereAccessResult(accessResults)) { + if (!where) { + queryToBuild.where = { + and: [ + accessResults, + ], + }; + } else { + (queryToBuild.where.and as Where[]).push(accessResults); + } + } + } + + const query = await VersionsModel.buildQuery(queryToBuild, locale); + + // ///////////////////////////////////// + // Find + // ///////////////////////////////////// + + const [sortProperty, sortOrder] = buildSortParam(args.sort, true); + + const optionsToExecute = { + page: page || 1, + limit: limit || 10, + sort: { + [sortProperty]: sortOrder, + }, + lean: true, + leanWithId: true, + useEstimatedCount, + }; + + const paginatedDocs = await VersionsModel.paginate(query, optionsToExecute); + + // ///////////////////////////////////// + // afterRead - Fields + // ///////////////////////////////////// + + let result = { + ...paginatedDocs, + docs: await Promise.all(paginatedDocs.docs.map(async (data) => ({ + ...data, + version: await this.performFieldOperations( + globalConfig, + { + depth, + data: data.version, + req, + id: data.version.id, + hook: 'afterRead', + operation: 'read', + overrideAccess, + flattenLocales: true, + showHiddenFields, + isVersion: true, + }, + ), + }))), + }; + + // ///////////////////////////////////// + // afterRead - Collection + // ///////////////////////////////////// + + result = { + ...result, + docs: await Promise.all(result.docs.map(async (doc) => { + const docRef = doc; + + await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => { + await priorHook; + + docRef.version = await hook({ req, query, doc: doc.version }) || doc.version; + }, Promise.resolve()); + + return docRef; + })), + }; + + // ///////////////////////////////////// + // Return results + // ///////////////////////////////////// + + result = { + ...result, + docs: result.docs.map((doc) => sanitizeInternalFields(doc)), + }; + + return result; +} + +export default publishVersion; diff --git a/src/globals/operations/update.ts b/src/globals/operations/update.ts index 4fe7664b09..f4e4968a9f 100644 --- a/src/globals/operations/update.ts +++ b/src/globals/operations/update.ts @@ -1,7 +1,11 @@ +import { Payload } from '../..'; +import { 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'; -async function update(args) { +async function update(this: Payload, args): Promise { const { globals: { Model } } = this; const { @@ -11,8 +15,12 @@ async function update(args) { depth, overrideAccess, showHiddenFields, + draft: draftArg, + autosave, } = args; + const shouldSaveDraft = Boolean(draftArg && globalConfig.versions.drafts); + // ///////////////////////////////////// // 1. Retrieve and execute access // ///////////////////////////////////// @@ -25,10 +33,12 @@ async function update(args) { // 2. Retrieve document // ///////////////////////////////////// - let global = await Model.findOne({ globalType: slug }); + let hasExistingGlobal = false; + let global: any = await Model.findOne({ globalType: slug }); let globalJSON; if (global) { + hasExistingGlobal = true; globalJSON = global.toJSON({ virtuals: true }); globalJSON = JSON.stringify(globalJSON); globalJSON = JSON.parse(globalJSON); @@ -104,13 +114,22 @@ async function update(args) { unflattenLocales: true, originalDoc, docWithLocales: globalJSON, + overrideAccess, + skipValidation: shouldSaveDraft, }); // ///////////////////////////////////// // Update // ///////////////////////////////////// - if (global) { + if (shouldSaveDraft) { + global = await saveGlobalDraft({ + payload: this, + config: globalConfig, + data: result, + autosave, + }); + } else if (global) { global = await Model.findOneAndUpdate( { globalType: slug }, result, @@ -126,6 +145,19 @@ async function update(args) { global = JSON.parse(global); global = sanitizeInternalFields(global); + // ///////////////////////////////////// + // Create version from existing doc + // ///////////////////////////////////// + + if (globalConfig.versions && hasExistingGlobal && !shouldSaveDraft) { + saveGlobalVersion({ + payload: this, + config: globalConfig, + req, + docWithLocales: global, + }); + } + // ///////////////////////////////////// // afterRead - Fields // ///////////////////////////////////// @@ -138,6 +170,7 @@ async function update(args) { depth, showHiddenFields, flattenLocales: true, + overrideAccess, }); // ///////////////////////////////////// diff --git a/src/globals/requestHandlers/findOne.ts b/src/globals/requestHandlers/findOne.ts index 94f59124d0..9a0566b75b 100644 --- a/src/globals/requestHandlers/findOne.ts +++ b/src/globals/requestHandlers/findOne.ts @@ -17,6 +17,7 @@ export default function findOne(globalConfig: SanitizedGlobalConfig): FindOneGlo globalConfig, slug, depth: req.query.depth, + draft: req.query.draft === 'true', }); return res.status(httpStatus.OK).json(result); diff --git a/src/globals/requestHandlers/findVersionByID.ts b/src/globals/requestHandlers/findVersionByID.ts new file mode 100644 index 0000000000..d3f0143e0d --- /dev/null +++ b/src/globals/requestHandlers/findVersionByID.ts @@ -0,0 +1,25 @@ +import { Response, NextFunction } from 'express'; +import { PayloadRequest } from '../../express/types'; +import { Document } from '../../types'; +import { SanitizedGlobalConfig } from '../config/types'; + +export default function (globalConfig: SanitizedGlobalConfig) { + async function handler(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { + const options = { + req, + globalConfig, + id: req.params.id, + depth: req.query.depth, + }; + + try { + const doc = await this.operations.globals.findVersionByID(options); + return res.json(doc); + } catch (error) { + return next(error); + } + } + + const findVersionByIDHandler = handler.bind(this); + return findVersionByIDHandler; +} diff --git a/src/globals/requestHandlers/findVersions.ts b/src/globals/requestHandlers/findVersions.ts new file mode 100644 index 0000000000..8e1e6ccb61 --- /dev/null +++ b/src/globals/requestHandlers/findVersions.ts @@ -0,0 +1,42 @@ +import { Response, NextFunction } from 'express'; +import httpStatus from 'http-status'; +import { PayloadRequest } from '../../express/types'; +import { TypeWithID } from '../../collections/config/types'; +import { PaginatedDocs } from '../../mongoose/types'; +import { SanitizedGlobalConfig } from '../config/types'; + +export default function (global: SanitizedGlobalConfig) { + async function handler(req: PayloadRequest, res: Response, next: NextFunction): Promise> | void> { + try { + let page; + + if (typeof req.query.page === 'string') { + const parsedPage = parseInt(req.query.page, 10); + + if (!Number.isNaN(parsedPage)) { + page = parsedPage; + } + } + + const options = { + req, + globalConfig: global, + where: req.query.where, + page, + limit: req.query.limit, + sort: req.query.sort, + depth: req.query.depth, + }; + + const result = await this.operations.globals.findVersions(options); + + return res.status(httpStatus.OK).json(result); + } catch (error) { + return next(error); + } + } + + const findVersionsandler = handler.bind(this); + + return findVersionsandler; +} diff --git a/src/globals/requestHandlers/publishVersion.ts b/src/globals/requestHandlers/publishVersion.ts new file mode 100644 index 0000000000..36ea169ab1 --- /dev/null +++ b/src/globals/requestHandlers/publishVersion.ts @@ -0,0 +1,25 @@ +import { Response, NextFunction } from 'express'; +import { PayloadRequest } from '../../express/types'; +import { Document } from '../../types'; +import { SanitizedGlobalConfig } from '../config/types'; + +export default function (globalConfig: SanitizedGlobalConfig) { + async function handler(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { + const options = { + req, + globalConfig, + id: req.params.id, + depth: req.query.depth, + }; + + try { + const doc = await this.operations.globals.publishVersion(options); + return res.json(doc); + } catch (error) { + return next(error); + } + } + + const publishVersionHandler = handler.bind(this); + return publishVersionHandler; +} diff --git a/src/globals/requestHandlers/update.ts b/src/globals/requestHandlers/update.ts index b58b5a254a..317543c4f0 100644 --- a/src/globals/requestHandlers/update.ts +++ b/src/globals/requestHandlers/update.ts @@ -11,6 +11,8 @@ function update(globalConfig: SanitizedGlobalConfig): UpdateGlobalResponse { async function handler(req: PayloadRequest, res: Response, next: NextFunction) { try { const { slug } = globalConfig; + const draft = req.query.draft === 'true'; + const autosave = req.query.autosave === 'true'; const result = await this.operations.globals.update({ req, @@ -18,9 +20,16 @@ function update(globalConfig: SanitizedGlobalConfig): UpdateGlobalResponse { slug, depth: req.query.depth, data: req.body, + draft, + autosave, }); - return res.status(httpStatus.OK).json({ message: 'Global saved successfully.', result }); + let message = 'Saved successfully.'; + + if (draft) message = 'Draft saved successfully.'; + if (autosave) message = 'Autosaved successfully.'; + + return res.status(httpStatus.OK).json({ message, result }); } catch (error) { return next(error); } diff --git a/src/graphql/schema/buildPoliciesType.ts b/src/graphql/schema/buildPoliciesType.ts index 3299a5b312..488c8aace0 100644 --- a/src/graphql/schema/buildPoliciesType.ts +++ b/src/graphql/schema/buildPoliciesType.ts @@ -6,7 +6,7 @@ import { SanitizedCollectionConfig } from '../../collections/config/types'; import { SanitizedGlobalConfig } from '../../globals/config/types'; import { Field } from '../../fields/config/types'; -type OperationType = 'create' | 'read' | 'update' | 'delete'; +type OperationType = 'create' | 'read' | 'update' | 'delete' | 'unlock' | 'readVersions'; type ObjectTypeFields = { [key in OperationType | 'fields']?: { type: GraphQLObjectType }; @@ -104,19 +104,35 @@ export default function buildPoliciesType(): GraphQLObjectType { }; Object.values(this.config.collections).forEach((collection: SanitizedCollectionConfig) => { + const collectionOperations: OperationType[] = ['create', 'read', 'update', 'delete']; + + if (collection.auth && (typeof collection.auth.maxLoginAttempts !== 'undefined' && collection.auth.maxLoginAttempts !== 0)) { + collectionOperations.push('unlock'); + } + + if (collection.versions) { + collectionOperations.push('readVersions'); + } + fields[formatName(collection.slug)] = { type: new GraphQLObjectType({ name: formatName(`${collection.labels.singular}Access`), - fields: buildEntity(collection.labels.singular, collection.fields, ['create', 'read', 'update', 'delete']), + fields: buildEntity(collection.labels.singular, collection.fields, collectionOperations), }), }; }); Object.values(this.config.globals).forEach((global: SanitizedGlobalConfig) => { + const globalOperations: OperationType[] = ['read', 'update']; + + if (global.versions) { + globalOperations.push('readVersions'); + } + fields[formatName(global.slug)] = { type: new GraphQLObjectType({ name: formatName(`${global.label}Access`), - fields: buildEntity(global.label, global.fields, ['read', 'update']), + fields: buildEntity(global.label, global.fields, globalOperations), }), }; }); diff --git a/src/index.ts b/src/index.ts index 55cfc35902..5041b082c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,15 +2,18 @@ import express, { Express, Router } from 'express'; import crypto from 'crypto'; import { TypeWithID, - Collection, PaginatedDocs, + Collection, CollectionModel, } from './collections/config/types'; import { SanitizedConfig, EmailOptions, InitOptions, } from './config/types'; +import { TypeWithVersion } from './versions/types'; +import { PaginatedDocs } from './mongoose/types'; + import Logger from './utilities/logger'; -import bindOperations from './init/bindOperations'; +import bindOperations, { Operations } from './init/bindOperations'; import bindRequestHandlers, { RequestHandlers } from './init/bindRequestHandlers'; import loadConfig from './config/load'; import authenticate, { PayloadAuthenticate } from './express/middleware/authenticate'; @@ -21,7 +24,7 @@ import initAuth from './auth/init'; import initCollections from './collections/init'; import initPreferences from './preferences/init'; import initGlobals from './globals/init'; -import { Globals } from './globals/config/types'; +import { GlobalModel, Globals } from './globals/config/types'; import initGraphQLPlayground from './graphql/initPlayground'; import initStatic from './express/static'; import GraphQL from './graphql'; @@ -43,6 +46,10 @@ import { Options as FindOptions } from './collections/operations/local/find'; import { Options as FindByIDOptions } from './collections/operations/local/findByID'; import { Options as UpdateOptions } from './collections/operations/local/update'; import { Options as DeleteOptions } from './collections/operations/local/delete'; +import { Options as FindVersionsOptions } from './collections/operations/local/findVersions'; +import { Options as FindVersionByIDOptions } from './collections/operations/local/findVersionByID'; +import { Options as RestoreVersionOptions } from './collections/operations/local/publishVersion'; +import { Result } from './auth/operations/login'; require('isomorphic-fetch'); @@ -52,7 +59,13 @@ require('isomorphic-fetch'); export class Payload { config: SanitizedConfig; - collections: Collection[] = []; + collections: { + [slug: string]: Collection; + } = {} + + versions: { + [slug: string]: CollectionModel | GlobalModel; + } = {} graphQL: { resolvers: GraphQLResolvers @@ -86,7 +99,7 @@ export class Payload { decrypt = decrypt; - operations: { [key: string]: any }; + operations: Operations; errorHandler: ErrorHandler; @@ -204,7 +217,7 @@ export class Payload { * @param options * @returns created document */ - create = async (options: CreateOptions): Promise => { + create = async (options: CreateOptions): Promise => { let { create } = localOperations; create = create.bind(this); return create(options); @@ -249,7 +262,7 @@ export class Payload { * @param options * @returns Updated document */ - update = async (options: UpdateOptions): Promise => { + update = async (options: UpdateOptions): Promise => { let { update } = localOperations; update = update.bind(this); return update(options); @@ -261,7 +274,48 @@ export class Payload { return deleteOperation(options); } - login = async (options): Promise => { + /** + * @description Find versions with criteria + * @param options + * @returns versions satisfying query + */ + findVersions = async = any>(options: FindVersionsOptions): Promise> => { + let { findVersions } = localOperations; + findVersions = findVersions.bind(this); + return findVersions(options); + } + + /** + * @description Find version by ID + * @param options + * @returns version with specified ID + */ + findVersionByID = async = any>(options: FindVersionByIDOptions): Promise => { + let { findVersionByID } = localOperations; + findVersionByID = findVersionByID.bind(this); + return findVersionByID(options); + } + + /** + * @description Restore version by ID + * @param options + * @returns version with specified ID + */ + publishVersion = async = any>(options: RestoreVersionOptions): Promise => { + let { publishVersion } = localOperations; + publishVersion = publishVersion.bind(this); + return publishVersion(options); + } + + // TODO: globals + // findVersionGlobal + // findVersionByIDGlobal + // publishVersionGlobal + // TODO: + // graphql operations & request handlers, where + // tests + + login = async (options): Promise => { let { login } = localOperations.auth; login = login.bind(this); return login(options); diff --git a/src/init/bindOperations.ts b/src/init/bindOperations.ts index fecc3d5121..e1b1b7b3d0 100644 --- a/src/init/bindOperations.ts +++ b/src/init/bindOperations.ts @@ -14,22 +14,69 @@ import unlock from '../auth/operations/unlock'; import create from '../collections/operations/create'; import find from '../collections/operations/find'; import findByID from '../collections/operations/findByID'; +import findVersions from '../collections/operations/findVersions'; +import findVersionByID from '../collections/operations/findVersionByID'; +import publishVersion from '../collections/operations/publishVersion'; import update from '../collections/operations/update'; import deleteHandler from '../collections/operations/delete'; import findOne from '../globals/operations/findOne'; +import findGlobalVersions from '../globals/operations/findVersions'; +import findGlobalVersionByID from '../globals/operations/findVersionByID'; +import publishGlobalVersion from '../globals/operations/publishVersion'; import globalUpdate from '../globals/operations/update'; import preferenceUpdate from '../preferences/operations/update'; import preferenceFindOne from '../preferences/operations/findOne'; import preferenceDelete from '../preferences/operations/delete'; +export type Operations = { + collections: { + create: typeof create + find: typeof find + findByID: typeof findByID + findVersions: typeof findVersions + findVersionByID: typeof findVersionByID + publishVersion: typeof publishVersion + update: typeof update + delete: typeof deleteHandler + auth: { + access: typeof access + forgotPassword: typeof forgotPassword + init: typeof init + login: typeof login + logout: typeof logout + me: typeof me + refresh: typeof refresh + registerFirstUser: typeof registerFirstUser + resetPassword: typeof resetPassword + verifyEmail: typeof verifyEmail + unlock: typeof unlock + } + } + globals: { + findOne: typeof findOne + findVersions: typeof findGlobalVersions + findVersionByID: typeof findGlobalVersionByID + publishVersion: typeof publishGlobalVersion + update: typeof globalUpdate + } + preferences: { + update: typeof preferenceUpdate + findOne: typeof preferenceFindOne + delete: typeof preferenceDelete + } +} + function bindOperations(ctx: Payload): void { ctx.operations = { collections: { create: create.bind(ctx), find: find.bind(ctx), findByID: findByID.bind(ctx), + findVersions: findVersions.bind(ctx), + findVersionByID: findVersionByID.bind(ctx), + publishVersion: publishVersion.bind(ctx), update: update.bind(ctx), delete: deleteHandler.bind(ctx), auth: { @@ -48,6 +95,9 @@ function bindOperations(ctx: Payload): void { }, globals: { findOne: findOne.bind(ctx), + findVersions: findGlobalVersions.bind(ctx), + findVersionByID: findGlobalVersionByID.bind(ctx), + publishVersion: publishGlobalVersion.bind(ctx), update: globalUpdate.bind(ctx), }, preferences: { diff --git a/src/init/bindRequestHandlers.ts b/src/init/bindRequestHandlers.ts index 8cd8667eea..8cff73ee61 100644 --- a/src/init/bindRequestHandlers.ts +++ b/src/init/bindRequestHandlers.ts @@ -13,10 +13,16 @@ import unlock from '../auth/requestHandlers/unlock'; import create from '../collections/requestHandlers/create'; import find from '../collections/requestHandlers/find'; import findByID from '../collections/requestHandlers/findByID'; +import findVersions from '../collections/requestHandlers/findVersions'; +import findVersionByID from '../collections/requestHandlers/findVersionByID'; +import publishVersion from '../collections/requestHandlers/publishVersion'; import update from '../collections/requestHandlers/update'; import deleteHandler from '../collections/requestHandlers/delete'; import findOne from '../globals/requestHandlers/findOne'; +import findGlobalVersions from '../globals/requestHandlers/findVersions'; +import findGlobalVersionByID from '../globals/requestHandlers/findVersionByID'; +import publishGlobalVersion from '../globals/requestHandlers/publishVersion'; import globalUpdate from '../globals/requestHandlers/update'; import { Payload } from '../index'; import preferenceUpdate from '../preferences/requestHandlers/update'; @@ -28,6 +34,9 @@ export type RequestHandlers = { create: typeof create, find: typeof find, findByID: typeof findByID, + findVersions: typeof findVersions + findVersionByID: typeof findVersionByID, + publishVersion: typeof publishVersion, update: typeof update, delete: typeof deleteHandler, auth: { @@ -47,6 +56,9 @@ export type RequestHandlers = { globals: { findOne: typeof findOne, update: typeof globalUpdate, + findVersions: typeof findGlobalVersions + findVersionByID: typeof findGlobalVersionByID + publishVersion: typeof publishGlobalVersion }, preferences: { update: typeof preferenceUpdate, @@ -61,6 +73,9 @@ function bindRequestHandlers(ctx: Payload): void { create: create.bind(ctx), find: find.bind(ctx), findByID: findByID.bind(ctx), + findVersions: findVersions.bind(ctx), + findVersionByID: findVersionByID.bind(ctx), + publishVersion: publishVersion.bind(ctx), update: update.bind(ctx), delete: deleteHandler.bind(ctx), auth: { @@ -80,6 +95,9 @@ function bindRequestHandlers(ctx: Payload): void { globals: { findOne: findOne.bind(ctx), update: globalUpdate.bind(ctx), + findVersions: findGlobalVersions.bind(ctx), + findVersionByID: findGlobalVersionByID.bind(ctx), + publishVersion: publishGlobalVersion.bind(ctx), }, preferences: { update: preferenceUpdate.bind(ctx), diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index a1bf9222c6..ee321fd412 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -7,10 +7,10 @@ import { SanitizedConfig } from '../config/types'; import { ArrayField, Block, BlockField, CheckboxField, CodeField, DateField, EmailField, Field, fieldAffectsData, GroupField, NumberField, PointField, RadioField, RelationshipField, RichTextField, RowField, SelectField, TextareaField, TextField, UploadField, fieldIsPresentationalOnly, NonPresentationalField } from '../fields/config/types'; import sortableFieldTypes from '../fields/sortableFieldTypes'; -type BuildSchemaOptions = { +export type BuildSchemaOptions = { options?: SchemaOptions allowIDField?: boolean - disableRequired?: boolean + disableUnique?: boolean global?: boolean } @@ -52,8 +52,8 @@ const setBlockDiscriminators = (fields: Field[], schema: Schema, config: Sanitiz const formatBaseSchema = (field: NonPresentationalField, buildSchemaOptions: BuildSchemaOptions) => ({ sparse: field.unique && field.localized, - unique: field.unique || false, - required: (!buildSchemaOptions.disableRequired && field.required && !field.localized && !field?.admin?.condition && !field?.access?.create) || false, + unique: (!buildSchemaOptions.disableUnique && field.unique) || false, + required: false, default: field.defaultValue || undefined, index: field.index || field.unique || false, }); @@ -64,7 +64,9 @@ const localizeSchema = (field: NonPresentationalField, schema, locales) => { type: locales.reduce((localeSchema, locale) => ({ ...localeSchema, [locale]: schema, - }), {}), + }), { + _id: false, + }), localized: true, index: schema.index, }; @@ -322,6 +324,7 @@ const fieldToSchemaMap = { type: [buildSchema(config, field.fields, { options: { _id: false, id: false }, allowIDField: true, + disableUnique: buildSchemaOptions.disableUnique, })], }; @@ -344,7 +347,7 @@ const fieldToSchemaMap = { _id: false, id: false, }, - disableRequired: !formattedBaseSchema.required, + disableUnique: buildSchemaOptions.disableUnique, }), }; diff --git a/src/mongoose/buildSortParam.ts b/src/mongoose/buildSortParam.ts new file mode 100644 index 0000000000..a8864dcce0 --- /dev/null +++ b/src/mongoose/buildSortParam.ts @@ -0,0 +1,21 @@ +export const buildSortParam = (sort: string, timestamps: boolean) => { + let sortProperty: string; + let sortOrder = 'desc'; + + if (!sort) { + if (timestamps) { + sortProperty = 'createdAt'; + } else { + sortProperty = '_id'; + } + } else if (sort.indexOf('-') === 0) { + sortProperty = sort.substring(1); + } else { + sortProperty = sort; + sortOrder = 'asc'; + } + + if (sortProperty === 'id') sortProperty = '_id'; + + return [sortProperty, sortOrder]; +}; diff --git a/src/mongoose/types.ts b/src/mongoose/types.ts new file mode 100644 index 0000000000..884e5e9f22 --- /dev/null +++ b/src/mongoose/types.ts @@ -0,0 +1,12 @@ +export type PaginatedDocs = { + docs: T[] + totalDocs: number + limit: number + totalPages: number + page: number + pagingCounter: number + hasPrevPage: boolean + hasNextPage: boolean + prevPage: number | null + nextPage: number | null +} diff --git a/src/types/index.ts b/src/types/index.ts index d2e0ec8ea6..19619308b6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ import { Document as MongooseDocument } from 'mongoose'; +import { TypeWithID, TypeWithTimestamps } from '../collections/config/types'; import { FileData } from '../uploads/types'; export type Operator = 'equals' @@ -33,3 +34,7 @@ export interface PayloadMongooseDocument extends MongooseDocument { } export type Operation = 'create' | 'read' | 'update' | 'delete' + +export function docHasTimestamps(doc: any): doc is TypeWithTimestamps { + return doc?.createdAt && doc?.updatedAt; +} diff --git a/src/fields/baseFields/getBaseUploadFields.ts b/src/uploads/getBaseFields.ts similarity index 91% rename from src/fields/baseFields/getBaseUploadFields.ts rename to src/uploads/getBaseFields.ts index 927a925f6c..deba9d2f31 100644 --- a/src/fields/baseFields/getBaseUploadFields.ts +++ b/src/uploads/getBaseFields.ts @@ -1,8 +1,8 @@ -import { Field } from '../config/types'; -import { Config } from '../../config/types'; -import { CollectionConfig } from '../../collections/config/types'; -import { mimeTypeValidator } from '../../uploads/mimeTypeValidator'; -import { IncomingUploadType } from '../../uploads/types'; +import { Field } from '../fields/config/types'; +import { Config } from '../config/types'; +import { CollectionConfig } from '../collections/config/types'; +import { mimeTypeValidator } from './mimeTypeValidator'; +import { IncomingUploadType } from './types'; type Options = { config: Config diff --git a/src/uploads/uploadFile.ts b/src/uploads/uploadFile.ts new file mode 100644 index 0000000000..c8421bf08f --- /dev/null +++ b/src/uploads/uploadFile.ts @@ -0,0 +1,102 @@ +import mkdirp from 'mkdirp'; +import path from 'path'; +import { SanitizedConfig } from '../config/types'; +import { Collection } from '../collections/config/types'; +import { FileUploadError, MissingFile } from '../errors'; +import { PayloadRequest } from '../express/types'; +import { FileData } from './types'; +import saveBufferToFile from './saveBufferToFile'; +import getSafeFileName from './getSafeFilename'; +import getImageSize from './getImageSize'; +import resizeAndSave from './imageResizer'; +import isImage from './isImage'; + +type Args = { + config: SanitizedConfig, + collection: Collection + throwOnMissingFile?: boolean + req: PayloadRequest + data: Record + overwriteExistingFiles?: boolean +} + +const uploadFile = async ({ + config, + collection: { + config: collectionConfig, + Model, + }, + req, + data, + throwOnMissingFile, + overwriteExistingFiles, +}: Args): Promise> => { + let newData = data; + + if (collectionConfig.upload) { + const fileData: Partial = {}; + + const { staticDir, imageSizes, disableLocalStorage } = collectionConfig.upload; + + const { file } = req.files || {}; + + if (throwOnMissingFile && !file) { + throw new MissingFile(); + } + + let staticPath = staticDir; + + if (staticDir.indexOf('/') !== 0) { + staticPath = path.resolve(config.paths.configDir, staticDir); + } + + if (!disableLocalStorage) { + mkdirp.sync(staticPath); + } + + if (file) { + const fsSafeName = !overwriteExistingFiles ? await getSafeFileName(Model, staticPath, file.name) : file.name; + + try { + if (!disableLocalStorage) { + await saveBufferToFile(file.data, `${staticPath}/${fsSafeName}`); + } + + fileData.filename = fsSafeName; + fileData.filesize = file.size; + fileData.mimeType = file.mimetype; + + if (isImage(file.mimetype)) { + const dimensions = await getImageSize(file); + fileData.width = dimensions.width; + fileData.height = dimensions.height; + + if (Array.isArray(imageSizes) && file.mimetype !== 'image/svg+xml') { + req.payloadUploadSizes = {}; + fileData.sizes = await resizeAndSave({ + req, + file: file.data, + dimensions, + staticPath, + config: collectionConfig, + savedFilename: fsSafeName, + mimeType: fileData.mimeType, + }); + } + } + } catch (err) { + console.error(err); + throw new FileUploadError(); + } + + newData = { + ...newData, + ...fileData, + }; + } + } + + return newData; +}; + +export default uploadFile; diff --git a/src/utilities/getUniqueListBy.ts b/src/utilities/getUniqueListBy.ts new file mode 100644 index 0000000000..f0dbedebd9 --- /dev/null +++ b/src/utilities/getUniqueListBy.ts @@ -0,0 +1,3 @@ +export default function getUniqueListBy(arr: T[], key: string): T[] { + return [...new Map(arr.map((item) => [item[key], item])).values()]; +} diff --git a/src/versions/baseFields.ts b/src/versions/baseFields.ts new file mode 100644 index 0000000000..934aafda1f --- /dev/null +++ b/src/versions/baseFields.ts @@ -0,0 +1,30 @@ +import { Field } from '../fields/config/types'; + +export const statuses = [ + { + label: 'Draft', + value: 'draft', + }, + { + label: 'Published', + value: 'published', + }, +]; + +const baseVersionFields: Field[] = [ + { + name: '_status', + label: 'Status', + type: 'select', + options: statuses, + defaultValue: 'draft', + required: true, + admin: { + components: { + Field: () => null, + }, + }, + }, +]; + +export default baseVersionFields; diff --git a/src/versions/buildCollectionFields.ts b/src/versions/buildCollectionFields.ts new file mode 100644 index 0000000000..7d7c71c770 --- /dev/null +++ b/src/versions/buildCollectionFields.ts @@ -0,0 +1,21 @@ +import { Field } from '../fields/config/types'; +import { SanitizedCollectionConfig } from '../collections/config/types'; + +export const buildVersionCollectionFields = (collection: SanitizedCollectionConfig): Field[] => [ + { + name: 'parent', + type: 'relationship', + index: true, + relationTo: collection.slug, + }, + { + name: 'autosave', + type: 'checkbox', + index: true, + }, + { + name: 'version', + type: 'group', + fields: collection.fields, + }, +]; diff --git a/src/versions/buildGlobalFields.ts b/src/versions/buildGlobalFields.ts new file mode 100644 index 0000000000..c60601ecd1 --- /dev/null +++ b/src/versions/buildGlobalFields.ts @@ -0,0 +1,15 @@ +import { Field } from '../fields/config/types'; +import { SanitizedGlobalConfig } from '../globals/config/types'; + +export const buildVersionGlobalFields = (global: SanitizedGlobalConfig): Field[] => [ + { + name: 'version', + type: 'group', + fields: global.fields, + }, + { + name: 'autosave', + type: 'checkbox', + index: true, + }, +]; diff --git a/src/versions/cleanUpFailedCollectionVersion.ts b/src/versions/cleanUpFailedCollectionVersion.ts new file mode 100644 index 0000000000..0b38486662 --- /dev/null +++ b/src/versions/cleanUpFailedCollectionVersion.ts @@ -0,0 +1,20 @@ +import { Payload } from '..'; +import { SanitizedCollectionConfig } from '../collections/config/types'; +import { TypeWithVersion } from './types'; + +type Args = { + payload: Payload, + collection: SanitizedCollectionConfig, + version: TypeWithVersion +} + +const cleanUpFailedCollectionVersion = (args: Args) => { + const { payload, collection, version } = args; + + if (version) { + const VersionModel = payload.versions[collection.slug]; + VersionModel.findOneAndDelete({ _id: version.id }); + } +}; + +export default cleanUpFailedCollectionVersion; diff --git a/src/versions/defaults.ts b/src/versions/defaults.ts new file mode 100644 index 0000000000..89a183d7dc --- /dev/null +++ b/src/versions/defaults.ts @@ -0,0 +1,20 @@ +import { IncomingCollectionVersions, IncomingGlobalVersions } from './types'; + +export const versionCollectionDefaults: IncomingCollectionVersions = { + drafts: { + autosave: { + interval: 5, // in seconds + }, + }, + maxPerDoc: 50, + retainDeleted: true, +}; + +export const versionGlobalDefaults: IncomingGlobalVersions = { + drafts: { + autosave: { + interval: 5, // in seconds + }, + }, + max: 50, +}; diff --git a/src/versions/drafts/replaceWithDraftIfAvailable.ts b/src/versions/drafts/replaceWithDraftIfAvailable.ts new file mode 100644 index 0000000000..3aac1a0c70 --- /dev/null +++ b/src/versions/drafts/replaceWithDraftIfAvailable.ts @@ -0,0 +1,81 @@ +import { Payload } from '../..'; +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'; + +type Arguments = { + payload: Payload + collection: SanitizedCollectionConfig + doc: T + locale: string + accessResult: AccessResult +} + +const replaceWithDraftIfAvailable = async ({ + payload, + collection, + doc, + locale, + accessResult, +}: Arguments): Promise => { + if (docHasTimestamps(doc)) { + const VersionModel = payload.versions[collection.slug] as CollectionModel; + + let useEstimatedCount = false; + const queryToBuild: { where: Where } = { + where: { + and: [ + { + parent: { + equals: doc.id, + }, + }, + { + updatedAt: { + greater_than: doc.updatedAt, + }, + }, + ], + }, + }; + + if (hasWhereAccessResult(accessResult)) { + queryToBuild.where.and.push(accessResult); + } + + const constraints = flattenWhereConstraints(queryToBuild); + useEstimatedCount = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near')); + const query = await VersionModel.buildQuery(queryToBuild, locale); + + let draft = await VersionModel.findOne(query, {}, { + lean: true, + leanWithId: true, + useEstimatedCount, + sort: { updatedAt: 'desc' }, + }); + + if (!draft) { + return doc; + } + + draft = JSON.parse(JSON.stringify(draft)); + draft = sanitizeInternalFields(draft); + + // Disregard all other draft content at this point, + // Only interested in the version itself. + // Operations will handle firing hooks, etc. + return { + id: doc.id, + ...draft.version, + createdAt: draft.createdAt, + updatedAt: draft.updatedAt, + }; + } + + return doc; +}; + +export default replaceWithDraftIfAvailable; diff --git a/src/versions/drafts/saveCollectionDraft.ts b/src/versions/drafts/saveCollectionDraft.ts new file mode 100644 index 0000000000..e3089a48df --- /dev/null +++ b/src/versions/drafts/saveCollectionDraft.ts @@ -0,0 +1,77 @@ +import { Payload } from '../..'; +import { SanitizedCollectionConfig, CollectionModel } from '../../collections/config/types'; +import { enforceMaxVersions } from '../enforceMaxVersions'; +import { PayloadRequest } from '../../express/types'; + +type Args = { + payload: Payload + config?: SanitizedCollectionConfig + req: PayloadRequest + data: any + id: string | number + autosave: boolean +} + +export const saveCollectionDraft = async ({ + payload, + config, + id, + data, + autosave, +}: Args): Promise => { + const VersionsModel = payload.versions[config.slug] as CollectionModel; + + 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: data, + }, + { new: true, lean: true }, + ); + // Otherwise, create a new one + } else { + result = await VersionsModel.create({ + parent: id, + version: data, + 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: this, + Model: VersionsModel, + entityLabel: config.labels.plural, + entityType: 'collection', + maxPerDoc: 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 new file mode 100644 index 0000000000..e1305e29bc --- /dev/null +++ b/src/versions/drafts/saveGlobalDraft.ts @@ -0,0 +1,65 @@ +import { Payload } from '../..'; +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]; + + 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: data, + }, + { new: true, lean: true }, + ); + // Otherwise, create a new one + } else { + result = await VersionsModel.create({ + version: data, + autosave: Boolean(autosave), + }); + } + } catch (err) { + payload.logger.error(`There was an error while saving a draft for the Global ${config.label}.`); + payload.logger.error(err); + } + + if (config.versions.max) { + enforceMaxVersions({ + payload: this, + Model: VersionsModel, + entityLabel: config.label, + entityType: 'global', + maxPerDoc: config.versions.max, + }); + } + + result = result.version; + return result; +}; diff --git a/src/versions/enforceMaxVersions.ts b/src/versions/enforceMaxVersions.ts new file mode 100644 index 0000000000..ce99653e86 --- /dev/null +++ b/src/versions/enforceMaxVersions.ts @@ -0,0 +1,41 @@ +import { GlobalModel } from '../globals/config/types'; +import { Payload } from '..'; +import { CollectionModel } from '../collections/config/types'; + +type Args = { + payload: Payload + Model: CollectionModel | GlobalModel + maxPerDoc: number + entityLabel: string + entityType: 'global' | 'collection' + id?: string | number +} + +export const enforceMaxVersions = async ({ + payload, + Model, + maxPerDoc, + entityLabel, + entityType, + id, +}: Args): Promise => { + try { + const query: { parent?: string | number } = {}; + + if (id) query.parent = id; + + const oldestAllowedDoc = await Model.find(query).limit(1).skip(maxPerDoc).sort({ createdAt: -1 }); + + if (oldestAllowedDoc?.[0]?.createdAt) { + const deleteLessThan = oldestAllowedDoc[0].createdAt; + + await Model.deleteMany({ + createdAt: { + $lte: deleteLessThan, + }, + }); + } + } catch (err) { + payload.logger.error(`There was an error cleaning up old versions for the ${entityType} ${entityLabel}`); + } +}; diff --git a/src/versions/ensurePublishedCollectionVersion.ts b/src/versions/ensurePublishedCollectionVersion.ts new file mode 100644 index 0000000000..fd6a3617d1 --- /dev/null +++ b/src/versions/ensurePublishedCollectionVersion.ts @@ -0,0 +1,81 @@ +import { Payload } from '..'; +import { SanitizedCollectionConfig } from '../collections/config/types'; +import { enforceMaxVersions } from './enforceMaxVersions'; +import { PayloadRequest } from '../express/types'; + +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 payload.performFieldOperations(config, { + id, + depth: 0, + req, + data: docWithLocales, + hook: 'afterRead', + operation: 'update', + overrideAccess: true, + flattenLocales: false, + showHiddenFields: true, + }); + + 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.labels.singular} with ID ${id}.`); + payload.logger.error(err); + } + + if (config.versions.maxPerDoc) { + enforceMaxVersions({ + id, + payload: this, + Model: VersionModel, + entityLabel: config.labels.plural, + entityType: 'collection', + maxPerDoc: config.versions.maxPerDoc, + }); + } + } + } +}; diff --git a/src/versions/getVersionsModelName.ts b/src/versions/getVersionsModelName.ts new file mode 100644 index 0000000000..872648f52d --- /dev/null +++ b/src/versions/getVersionsModelName.ts @@ -0,0 +1,4 @@ +import { SanitizedCollectionConfig } from '../collections/config/types'; +import { SanitizedGlobalConfig } from '../globals/config/types'; + +export const getVersionsModelName = (entity: SanitizedCollectionConfig | SanitizedGlobalConfig): string => `_${entity.slug}_versions`; diff --git a/src/versions/saveCollectionVersion.ts b/src/versions/saveCollectionVersion.ts new file mode 100644 index 0000000000..1515f0817a --- /dev/null +++ b/src/versions/saveCollectionVersion.ts @@ -0,0 +1,95 @@ +import { Payload } from '..'; +import { SanitizedCollectionConfig } from '../collections/config/types'; +import { enforceMaxVersions } from './enforceMaxVersions'; +import { PayloadRequest } from '../express/types'; +import sanitizeInternalFields from '../utilities/sanitizeInternalFields'; + +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) { + version = latestVersion.version; + version = JSON.parse(JSON.stringify(version)); + version = sanitizeInternalFields(version); + } + } + + version = await payload.performFieldOperations(config, { + id, + depth: 0, + req, + data: version, + hook: 'afterRead', + operation: 'update', + overrideAccess: true, + flattenLocales: false, + showHiddenFields: true, + }); + + 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: this, + Model: VersionModel, + entityLabel: config.labels.plural, + entityType: 'collection', + maxPerDoc: 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 new file mode 100644 index 0000000000..e354c92538 --- /dev/null +++ b/src/versions/saveGlobalVersion.ts @@ -0,0 +1,51 @@ +import { Payload } from '..'; +import { enforceMaxVersions } from './enforceMaxVersions'; +import { PayloadRequest } from '../express/types'; +import { SanitizedGlobalConfig } from '../globals/config/types'; + +type Args = { + payload: Payload + config?: SanitizedGlobalConfig + req: PayloadRequest + docWithLocales: any +} + +export const saveGlobalVersion = async ({ + payload, + config, + req, + docWithLocales, +}: Args): Promise => { + const VersionsModel = payload.versions[config.slug]; + + const version = await payload.performFieldOperations(config, { + depth: 0, + req, + data: docWithLocales, + hook: 'afterRead', + operation: 'update', + overrideAccess: true, + flattenLocales: false, + showHiddenFields: true, + }); + + try { + await VersionsModel.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: VersionsModel, + entityLabel: config.label, + entityType: 'global', + maxPerDoc: config.versions.max, + }); + } +}; diff --git a/src/versions/tests/rest.spec.ts b/src/versions/tests/rest.spec.ts new file mode 100644 index 0000000000..42d74d1b8a --- /dev/null +++ b/src/versions/tests/rest.spec.ts @@ -0,0 +1,178 @@ +import getConfig from '../../config/load'; +import { email, password } from '../../mongoose/testCredentials'; + +require('isomorphic-fetch'); + +const { serverURL: url } = getConfig(); + +let token = null; +let headers = null; +let postID; +let versionID; + +describe('Versions - REST', () => { + beforeAll(async (done) => { + const response = await fetch(`${url}/api/admins/login`, { + body: JSON.stringify({ + email, + password, + }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'post', + }); + + const data = await response.json(); + + ({ token } = data); + + headers = { + Authorization: `JWT ${token}`, + 'Content-Type': 'application/json', + }; + + const post = await fetch(`${url}/api/autosave-posts`, { + body: JSON.stringify({ + title: 'Here is an autosave post in EN', + description: '345j23o4ifj34jf54g', + }), + headers, + method: 'post', + }).then((res) => res.json()); + + postID = post.doc.id; + + done(); + }); + + describe('Create', () => { + it('should allow a new version to be created', async () => { + const title2 = 'Here is an updated post title in EN'; + + const updatedPost = await fetch(`${url}/api/autosave-posts/${postID}`, { + body: JSON.stringify({ + title: title2, + }), + headers, + method: 'put', + }).then((res) => res.json()); + + expect(updatedPost.doc.title).toBe(title2); + expect(updatedPost.doc._status).toStrictEqual('draft'); + + const versions = await fetch(`${url}/api/autosave-posts/versions`, { + headers, + }).then((res) => res.json()); + + versionID = versions.docs[0].id; + }); + + it('should allow a version to be retrieved by ID', async () => { + const version = await fetch(`${url}/api/autosave-posts/versions/${versionID}`, { + headers, + }).then((res) => res.json()); + + expect(version.id).toStrictEqual(versionID); + }); + + it('should allow a version to save locales properly', async () => { + const englishTitle = 'Title in ES'; + const spanishTitle = 'Title in ES'; + + await fetch(`${url}/api/autosave-posts/${postID}`, { + body: JSON.stringify({ + title: englishTitle, + }), + headers, + method: 'put', + }).then((res) => res.json()); + + const updatedPostES = await fetch(`${url}/api/autosave-posts/${postID}?locale=es`, { + body: JSON.stringify({ + title: spanishTitle, + }), + headers, + method: 'put', + }).then((res) => res.json()); + + expect(updatedPostES.doc.title).toBe(spanishTitle); + + const newEnglishTitle = 'New title in EN'; + + await fetch(`${url}/api/autosave-posts/${postID}`, { + body: JSON.stringify({ + title: newEnglishTitle, + }), + headers, + method: 'put', + }).then((res) => res.json()); + + const versions = await fetch(`${url}/api/autosave-posts/versions?locale=all`, { + headers, + }).then((res) => res.json()); + + expect(versions.docs[0].version.title.en).toStrictEqual(englishTitle); + expect(versions.docs[0].version.title.es).toStrictEqual(spanishTitle); + }); + }); + + describe('Restore', () => { + it('should allow a version to be restored', async () => { + const title2 = 'Here is an updated post title in EN'; + + const updatedPost = await fetch(`${url}/api/autosave-posts/${postID}`, { + body: JSON.stringify({ + title: title2, + }), + headers, + method: 'put', + }).then((res) => res.json()); + + expect(updatedPost.doc.title).toBe(title2); + + const versions = await fetch(`${url}/api/autosave-posts/versions`, { + headers, + }).then((res) => res.json()); + + versionID = versions.docs[0].id; + + const restore = await fetch(`${url}/api/autosave-posts/versions/${versionID}`, { + headers, + method: 'post', + }).then((res) => res.json()); + + expect(restore.message).toBeDefined(); + expect(restore.doc.title).toBeDefined(); + + const restoredPost = await fetch(`${url}/api/autosave-posts/${postID}?draft=true`, { + headers, + }).then((res) => res.json()); + + expect(restoredPost.title).toBe(restore.doc.title); + }); + }); + + describe('Draft Access Control', () => { + it('should prevent a draft from being publicly readable', async () => { + const badAttempt = await fetch(`${url}/api/autosave-posts/${postID}`); + expect(badAttempt.status).toBe(404); + }); + + it('should prevent an authenticated user from retrieving drafts without asking', async () => { + const badAttempt = await fetch(`${url}/api/autosave-posts/${postID}`, { + headers, + }); + + expect(badAttempt.status).toBe(404); + }); + + it('should allow an authenticated user to explicitly retrieve draft', async () => { + const badAttempt = await fetch(`${url}/api/autosave-posts/${postID}?draft=true`, { + headers, + }); + + expect(badAttempt.status).toBe(200); + }); + }); +}); diff --git a/src/versions/types.ts b/src/versions/types.ts new file mode 100644 index 0000000000..925b7ec954 --- /dev/null +++ b/src/versions/types.ts @@ -0,0 +1,41 @@ +export type Autosave = { + interval?: number +} + +export type IncomingDrafts = { + autosave?: boolean | Autosave +} + +export type SanitizedDrafts = { + autosave: false | Autosave +} + +export type IncomingCollectionVersions = { + maxPerDoc?: number + retainDeleted?: boolean + drafts?: boolean | IncomingDrafts +} + +export interface SanitizedCollectionVersions extends Omit { + maxPerDoc?: number + retainDeleted: boolean + drafts: SanitizedDrafts | false +} + +export type IncomingGlobalVersions= { + max?: number + drafts?: boolean | IncomingDrafts +} + +export type SanitizedGlobalVersions= { + max: number + drafts: SanitizedDrafts | false +} + +export type TypeWithVersion = { + id: string + parent: string | number + version: T + createdAt: string + updatedAt: string +} diff --git a/tsconfig.json b/tsconfig.json index 011ea11424..32d1661c1d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,16 +13,15 @@ "noEmit": false, /* Do not emit outputs. */ "strict": false, /* Enable all strict type-checking options. */ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ - "baseUrl": "", "paths": { "payload/config": [ - "src/config/types.ts" + "./src/config/types.ts" ], "payload/auth": [ - "src/auth/types.ts" + "./src/auth/types.ts" ], "payload/types": [ - "src/types/index.ts" + "./src/types/index.ts" ] }, "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ diff --git a/yarn.lock b/yarn.lock index 5d21e97596..58fc6f0f78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4421,6 +4421,16 @@ cosmiconfig@^6.0.0: path-type "^4.0.0" yaml "^1.7.2" +create-emotion@^10.0.14, create-emotion@^10.0.27: + version "10.0.27" + resolved "https://registry.npmjs.org/create-emotion/-/create-emotion-10.0.27.tgz#cb4fa2db750f6ca6f9a001a33fbf1f6c46789503" + integrity sha512-fIK73w82HPPn/RsAij7+Zt8eCE8SptcJ3WoRMfxMtjteYxud8GDTKKld7MYwAX2TVhrw29uR1N/bVGxeStHILg== + dependencies: + "@emotion/cache" "^10.0.27" + "@emotion/serialize" "^0.11.15" + "@emotion/sheet" "0.9.4" + "@emotion/utils" "0.11.3" + cross-env@^7.0.2: version "7.0.3" resolved "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" @@ -5006,6 +5016,11 @@ diff-sequences@^27.4.0: resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.4.0.tgz#d783920ad8d06ec718a060d00196dfef25b132a5" integrity sha512-YqiQzkrsmHMH5uuh8OdQFU9/ZpADnwzml8z0O5HvRNda+5UZsaX/xN+AAxfR2hWq1Y7HZnAzO9J5lJXOuDz2Ww== +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -5190,6 +5205,14 @@ emojis-list@^3.0.0: resolved "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== +emotion@^10.0.14: + version "10.0.27" + resolved "https://registry.npmjs.org/emotion/-/emotion-10.0.27.tgz#f9ca5df98630980a23c819a56262560562e5d75e" + integrity sha512-2xdDzdWWzue8R8lu4G76uWX5WhyQuzATon9LmNeCy/2BHVC6dsEpfhN1a0qhELgtDVdjyEA6J8Y/VlI5ZnaH0g== + dependencies: + babel-plugin-emotion "^10.0.27" + create-emotion "^10.0.27" + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -8495,7 +8518,7 @@ memfs@^3.2.2: dependencies: fs-monkey "1.0.3" -memoize-one@^5.0.0, memoize-one@^5.1.1: +memoize-one@^5.0.0, memoize-one@^5.0.4, memoize-one@^5.1.1: version "5.2.1" resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== @@ -10810,6 +10833,18 @@ react-datepicker@^3.3.0: react-onclickoutside "^6.10.0" react-popper "^1.3.8" +react-diff-viewer@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/react-diff-viewer/-/react-diff-viewer-3.1.1.tgz#21ac9c891193d05a3734bfd6bd54b107ee6d46cc" + integrity sha512-rmvwNdcClp6ZWdS11m1m01UnBA4OwYaLG/li0dB781e/bQEzsGyj+qewVd6W5ztBwseQ72pO7nwaCcq5jnlzcw== + dependencies: + classnames "^2.2.6" + create-emotion "^10.0.14" + diff "^4.0.1" + emotion "^10.0.14" + memoize-one "^5.0.4" + prop-types "^15.6.2" + react-dom@^17.0.1: version "17.0.2" resolved "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"