From 06861261fe89a58ded6db3948c70ef451195c52f Mon Sep 17 00:00:00 2001 From: James Date: Fri, 22 Oct 2021 09:47:34 -0400 Subject: [PATCH 01/66] feat: builds revisions models --- demo/collections/RichText.ts | 4 ++++ src/collections/bindCollection.ts | 3 ++- src/collections/config/sanitize.ts | 4 ++++ src/collections/config/schema.ts | 7 ++++++ src/collections/config/types.ts | 2 ++ src/collections/init.ts | 34 +++++++++++++++++++++++---- src/index.ts | 10 ++++++-- src/revisions/buildFields.ts | 16 +++++++++++++ src/revisions/createCollectionName.ts | 3 +++ src/revisions/types.ts | 4 ++++ 10 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 src/revisions/buildFields.ts create mode 100644 src/revisions/createCollectionName.ts create mode 100644 src/revisions/types.ts diff --git a/demo/collections/RichText.ts b/demo/collections/RichText.ts index e51996dfaa..b0c730b6cd 100644 --- a/demo/collections/RichText.ts +++ b/demo/collections/RichText.ts @@ -11,6 +11,10 @@ const RichText: CollectionConfig = { access: { read: () => true, }, + revisions: { + max: 5, + retainDeleted: false, + }, fields: [ { name: 'defaultRichText', diff --git a/src/collections/bindCollection.ts b/src/collections/bindCollection.ts index 2242b5fa4c..fe60555029 100644 --- a/src/collections/bindCollection.ts +++ b/src/collections/bindCollection.ts @@ -1,6 +1,7 @@ import { NextFunction, Request, Response } from 'express'; +import { Collection } from './config/types'; -const bindCollectionMiddleware = (collection: string) => (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/sanitize.ts b/src/collections/config/sanitize.ts index e1e6890c1b..b3365a72cb 100644 --- a/src/collections/config/sanitize.ts +++ b/src/collections/config/sanitize.ts @@ -65,6 +65,10 @@ const sanitizeCollection = (config: Config, collection: CollectionConfig): Sanit sanitized.slug = toKebabCase(sanitized.slug); sanitized.labels = sanitized.labels || formatLabels(sanitized.slug); + if (sanitized.revisions) { + if (sanitized.revisions === true) sanitized.revisions = {}; + } + if (sanitized.upload) { if (sanitized.upload === true) sanitized.upload = {}; diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts index 61508debf7..053867a986 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -77,6 +77,13 @@ const collectionSchema = joi.object().keys({ }), joi.boolean(), ), + revisions: joi.alternatives().try( + joi.object({ + max: joi.number(), + retainDeleted: 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 5cdc0cd413..69f46d7554 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 { IncomingRevisionsType } from '../../revisions/types'; export interface CollectionModel extends PaginateModel, PassportLocalModel { buildQuery: (query: unknown, locale?: string) => Record @@ -138,6 +139,7 @@ export type CollectionConfig = { }; auth?: IncomingAuthType | boolean; upload?: IncomingUploadType | boolean; + revisions?: IncomingRevisionsType | boolean; timestamps?: boolean }; diff --git a/src/collections/init.ts b/src/collections/init.ts index 61b8d67175..9e425653f0 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 { buildRevisionFields } from '../revisions/buildFields'; +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 { getCollectionRevisionsName } from '../revisions/createCollectionName'; 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,28 @@ export default function registerCollections(ctx: Payload): void { } } + if (collection.revisions) { + const revisionModelName = getCollectionRevisionsName(collection); + + const revisionSchema = buildSchema( + ctx.config, + buildRevisionFields(collection), + { + options: { + timestamps: true, + }, + }, + ); + + revisionSchema.plugin(paginate, { useEstimatedCount: true }) + .plugin(buildQueryPlugin); + + ctx.revisions[collection.slug] = mongoose.model(revisionModelName, revisionSchema) as CollectionModel; + } + + ctx.collections[formattedCollection.slug] = { - Model: mongoose.model(formattedCollection.slug, schema), + Model: mongoose.model(formattedCollection.slug, schema) as CollectionModel, config: formattedCollection, }; diff --git a/src/index.ts b/src/index.ts index a7ed922f3a..26d5b4f900 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { InitOptions, } from './config/types'; import { - Collection, PaginatedDocs, + Collection, CollectionModel, PaginatedDocs, } from './collections/config/types'; import Logger from './utilities/logger'; import bindOperations from './init/bindOperations'; @@ -52,7 +52,13 @@ require('isomorphic-fetch'); export class Payload { config: SanitizedConfig; - collections: Collection[] = []; + collections: { + [slug: string]: Collection; + } = {} + + revisions: { + [slug: string]: CollectionModel; + } = {} graphQL: { resolvers: GraphQLResolvers diff --git a/src/revisions/buildFields.ts b/src/revisions/buildFields.ts new file mode 100644 index 0000000000..14e68984d1 --- /dev/null +++ b/src/revisions/buildFields.ts @@ -0,0 +1,16 @@ +import { Field } from '../fields/config/types'; +import { SanitizedCollectionConfig } from '../collections/config/types'; + +export const buildRevisionFields = (collection: SanitizedCollectionConfig): Field[] => [ + { + name: 'parent', + type: 'relationship', + index: true, + relationTo: collection.slug, + }, + { + name: 'revision', + type: 'group', + fields: collection.fields, + }, +]; diff --git a/src/revisions/createCollectionName.ts b/src/revisions/createCollectionName.ts new file mode 100644 index 0000000000..a1aa0ea965 --- /dev/null +++ b/src/revisions/createCollectionName.ts @@ -0,0 +1,3 @@ +import { SanitizedCollectionConfig } from '../collections/config/types'; + +export const getCollectionRevisionsName = (collection: SanitizedCollectionConfig): string => `_${collection.slug}_revisions`; diff --git a/src/revisions/types.ts b/src/revisions/types.ts new file mode 100644 index 0000000000..51183151d1 --- /dev/null +++ b/src/revisions/types.ts @@ -0,0 +1,4 @@ +export type IncomingRevisionsType = { + max?: number + retainDeleted?: boolean +} From d3f88a1bd9aeb1551d64b9ed975da5e69e5821bd Mon Sep 17 00:00:00 2001 From: James Date: Fri, 22 Oct 2021 10:23:06 -0400 Subject: [PATCH 02/66] fix: mobile styling to not found page --- .../components/views/NotFound/index.scss | 10 ++++++++ src/admin/components/views/NotFound/index.tsx | 25 +++++++++++-------- 2 files changed, 25 insertions(+), 10 deletions(-) create mode 100644 src/admin/components/views/NotFound/index.scss diff --git a/src/admin/components/views/NotFound/index.scss b/src/admin/components/views/NotFound/index.scss new file mode 100644 index 0000000000..e5b5763e71 --- /dev/null +++ b/src/admin/components/views/NotFound/index.scss @@ -0,0 +1,10 @@ +@import '../../../scss/styles'; + +.not-found { + &__wrap { + @include mid-break { + padding-left: $baseline; + padding-right: $baseline; + } + } +} diff --git a/src/admin/components/views/NotFound/index.tsx b/src/admin/components/views/NotFound/index.tsx index 27e22c8626..a70c3ac023 100644 --- a/src/admin/components/views/NotFound/index.tsx +++ b/src/admin/components/views/NotFound/index.tsx @@ -5,6 +5,10 @@ import { useStepNav } from '../../elements/StepNav'; import Button from '../../elements/Button'; import Meta from '../../utilities/Meta'; +import './index.scss'; + +const baseClass = 'not-found'; + const NotFound: React.FC = () => { 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.

+ +
); }; From 6ed11a55636df7abee4c859593c88179139c68c6 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 22 Oct 2021 10:57:56 -0400 Subject: [PATCH 03/66] feat: adds base revision fields --- .../baseFields/accountLock.ts} | 2 +- .../baseFields/apiKey.ts} | 2 +- .../baseFields/auth.ts} | 4 +-- .../baseFields/verification.ts} | 2 +- src/collections/config/sanitize.ts | 21 ++++++++++----- src/collections/init.ts | 6 ++++- src/revisions/baseFields.ts | 27 +++++++++++++++++++ .../getBaseFields.ts} | 10 +++---- 8 files changed, 57 insertions(+), 17 deletions(-) rename src/{fields/baseFields/baseAccountLockFields.ts => auth/baseFields/accountLock.ts} (79%) rename src/{fields/baseFields/baseAPIKeyFields.ts => auth/baseFields/apiKey.ts} (95%) rename src/{fields/baseFields/baseAuthFields.ts => auth/baseFields/auth.ts} (78%) rename src/{fields/baseFields/baseVerificationFields.ts => auth/baseFields/verification.ts} (93%) create mode 100644 src/revisions/baseFields.ts rename src/{fields/baseFields/getBaseUploadFields.ts => uploads/getBaseFields.ts} (90%) 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/collections/config/sanitize.ts b/src/collections/config/sanitize.ts index b3365a72cb..be5ff87b42 100644 --- a/src/collections/config/sanitize.ts +++ b/src/collections/config/sanitize.ts @@ -1,16 +1,16 @@ import merge from 'deepmerge'; -import { fieldAffectsData } from '../../fields/config/types'; 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 { baseRevisionFields } from '../../revisions/baseFields'; const mergeBaseFields = (fields, baseFields) => { const mergedFields = []; @@ -67,6 +67,15 @@ const sanitizeCollection = (config: Config, collection: CollectionConfig): Sanit if (sanitized.revisions) { if (sanitized.revisions === true) sanitized.revisions = {}; + + let revisionFields = baseRevisionFields; + + revisionFields = mergeBaseFields(sanitized.fields, revisionFields); + + sanitized.fields = [ + ...revisionFields, + ...sanitized.fields, + ]; } if (sanitized.upload) { diff --git a/src/collections/init.ts b/src/collections/init.ts index 9e425653f0..a5cbc0f9f8 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -14,6 +14,7 @@ import bindCollectionMiddleware from './bindCollection'; import { CollectionModel, SanitizedCollectionConfig } from './config/types'; import { Payload } from '../index'; import { getCollectionRevisionsName } from '../revisions/createCollectionName'; +import { fieldAffectsData } from '../fields/config/types'; const LocalStrategy = Passport.Strategy; @@ -71,7 +72,10 @@ export default function registerCollections(ctx: Payload): void { const revisionSchema = buildSchema( ctx.config, - buildRevisionFields(collection), + buildRevisionFields({ + ...collection, + fields: collection.fields.filter((field) => !(fieldAffectsData(field) && field.name === '_status')), + }), { options: { timestamps: true, diff --git a/src/revisions/baseFields.ts b/src/revisions/baseFields.ts new file mode 100644 index 0000000000..908b51039c --- /dev/null +++ b/src/revisions/baseFields.ts @@ -0,0 +1,27 @@ +import { Field } from '../fields/config/types'; + +export const baseRevisionFields: Field[] = [ + { + name: '_status', + type: 'select', + defaultValue: 'draft', + access: { + update: () => false, + }, + options: [ + { + label: 'Published', + value: 'published', + }, + { + label: 'Draft', + value: 'draft', + }, + ], + required: true, + index: true, + admin: { + position: 'sidebar', + }, + }, +]; diff --git a/src/fields/baseFields/getBaseUploadFields.ts b/src/uploads/getBaseFields.ts similarity index 90% rename from src/fields/baseFields/getBaseUploadFields.ts rename to src/uploads/getBaseFields.ts index e2fc2b3fb6..6d8a35ebfc 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 From fbbe590ea27b387067b03b46874e0b907bdd91cd Mon Sep 17 00:00:00 2001 From: James Date: Fri, 22 Oct 2021 10:58:14 -0400 Subject: [PATCH 04/66] feat: scaffolds revisions tests --- src/revisions/tests/spec.ts | 55 +++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/revisions/tests/spec.ts diff --git a/src/revisions/tests/spec.ts b/src/revisions/tests/spec.ts new file mode 100644 index 0000000000..487b50c1a6 --- /dev/null +++ b/src/revisions/tests/spec.ts @@ -0,0 +1,55 @@ +import getConfig from '../../config/load'; +import { email, password } from '../../mongoose/testCredentials'; + +require('isomorphic-fetch'); + +const { serverURL: url } = getConfig(); + +let token = null; +let headers = null; + +describe('Revisions - 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', + }; + + done(); + }); + + describe('Create', () => { + it('should allow a new revision to be created', async () => { + const revision = await fetch(`${url}/api/rich-text`, { + body: JSON.stringify({ + defaultRichText: [{ + children: [{ text: 'Here is some default rich text content' }], + }], + customRichText: [{ + children: [{ text: 'Here is some custom rich text content' }], + }], + }), + headers, + method: 'post', + }).then((res) => res.json()); + + expect(typeof revision.doc.id).toBe('string'); + expect(revision.doc._status).toBe('draft'); + }); + }); +}); From ac53bac2f4605e20490aef94729c8911708c3bed Mon Sep 17 00:00:00 2001 From: James Date: Fri, 22 Oct 2021 10:58:35 -0400 Subject: [PATCH 05/66] feat: revision access control config --- src/collections/config/schema.ts | 4 ++++ src/revisions/types.ts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts index 053867a986..f552e5734f 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -81,6 +81,10 @@ const collectionSchema = joi.object().keys({ joi.object({ max: joi.number(), retainDeleted: joi.boolean(), + access: joi.object({ + read: joi.func(), + modifyStatus: joi.func(), + }), }), joi.boolean(), ), diff --git a/src/revisions/types.ts b/src/revisions/types.ts index 51183151d1..7c73e936f4 100644 --- a/src/revisions/types.ts +++ b/src/revisions/types.ts @@ -1,4 +1,11 @@ +import { Access } from '../config/types'; +import { FieldAccess } from '../fields/config/types'; + export type IncomingRevisionsType = { max?: number retainDeleted?: boolean + access?: { + read?: Access + modifyStatus?: FieldAccess + } } From 763f32e22f93a25cd6db10f32602e4ae091ff7a3 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 23 Nov 2021 17:14:06 -0500 Subject: [PATCH 06/66] chore: separates revisions from drafts --- src/auth/operations/access.ts | 15 ++++++++++++-- src/collections/config/sanitize.ts | 10 --------- src/collections/config/types.ts | 3 ++- src/collections/init.ts | 6 +----- src/graphql/schema/buildPoliciesType.ts | 14 +++++++++++-- src/revisions/baseFields.ts | 27 ------------------------- src/revisions/types.ts | 7 ------- 7 files changed, 28 insertions(+), 54 deletions(-) delete mode 100644 src/revisions/baseFields.ts diff --git a/src/auth/operations/access.ts b/src/auth/operations/access.ts index 1f39c2f17b..4671b7c7aa 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,7 +103,17 @@ 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.revisions) { + collectionOperations.push('readRevisions'); + } + + executeEntityPolicies(collection, collectionOperations, 'collections'); }); config.globals.forEach((global) => { diff --git a/src/collections/config/sanitize.ts b/src/collections/config/sanitize.ts index be5ff87b42..304c934ca8 100644 --- a/src/collections/config/sanitize.ts +++ b/src/collections/config/sanitize.ts @@ -10,7 +10,6 @@ import getBaseUploadFields from '../../uploads/getBaseFields'; import { formatLabels } from '../../utilities/formatLabels'; import { defaults, authDefaults } from './defaults'; import { Config } from '../../config/types'; -import { baseRevisionFields } from '../../revisions/baseFields'; const mergeBaseFields = (fields, baseFields) => { const mergedFields = []; @@ -67,15 +66,6 @@ const sanitizeCollection = (config: Config, collection: CollectionConfig): Sanit if (sanitized.revisions) { if (sanitized.revisions === true) sanitized.revisions = {}; - - let revisionFields = baseRevisionFields; - - revisionFields = mergeBaseFields(sanitized.fields, revisionFields); - - sanitized.fields = [ - ...revisionFields, - ...sanitized.fields, - ]; } if (sanitized.upload) { diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index 69f46d7554..f0c5123af5 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -134,8 +134,9 @@ export type CollectionConfig = { read?: Access; update?: Access; delete?: Access; - admin?: Access; + admin?: (args?: any) => boolean; unlock?: Access; + readRevisions?: Access; }; auth?: IncomingAuthType | boolean; upload?: IncomingUploadType | boolean; diff --git a/src/collections/init.ts b/src/collections/init.ts index a5cbc0f9f8..9e425653f0 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -14,7 +14,6 @@ import bindCollectionMiddleware from './bindCollection'; import { CollectionModel, SanitizedCollectionConfig } from './config/types'; import { Payload } from '../index'; import { getCollectionRevisionsName } from '../revisions/createCollectionName'; -import { fieldAffectsData } from '../fields/config/types'; const LocalStrategy = Passport.Strategy; @@ -72,10 +71,7 @@ export default function registerCollections(ctx: Payload): void { const revisionSchema = buildSchema( ctx.config, - buildRevisionFields({ - ...collection, - fields: collection.fields.filter((field) => !(fieldAffectsData(field) && field.name === '_status')), - }), + buildRevisionFields(collection), { options: { timestamps: true, diff --git a/src/graphql/schema/buildPoliciesType.ts b/src/graphql/schema/buildPoliciesType.ts index 3299a5b312..e77fa26bb8 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' | 'readRevisions'; type ObjectTypeFields = { [key in OperationType | 'fields']?: { type: GraphQLObjectType }; @@ -104,10 +104,20 @@ 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.revisions) { + collectionOperations.push('readRevisions'); + } + 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), }), }; }); diff --git a/src/revisions/baseFields.ts b/src/revisions/baseFields.ts deleted file mode 100644 index 908b51039c..0000000000 --- a/src/revisions/baseFields.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Field } from '../fields/config/types'; - -export const baseRevisionFields: Field[] = [ - { - name: '_status', - type: 'select', - defaultValue: 'draft', - access: { - update: () => false, - }, - options: [ - { - label: 'Published', - value: 'published', - }, - { - label: 'Draft', - value: 'draft', - }, - ], - required: true, - index: true, - admin: { - position: 'sidebar', - }, - }, -]; diff --git a/src/revisions/types.ts b/src/revisions/types.ts index 7c73e936f4..51183151d1 100644 --- a/src/revisions/types.ts +++ b/src/revisions/types.ts @@ -1,11 +1,4 @@ -import { Access } from '../config/types'; -import { FieldAccess } from '../fields/config/types'; - export type IncomingRevisionsType = { max?: number retainDeleted?: boolean - access?: { - read?: Access - modifyStatus?: FieldAccess - } } From 07c8ac08e21689cc6a3a2a546e58cf544fb61dec Mon Sep 17 00:00:00 2001 From: James Date: Wed, 24 Nov 2021 11:35:07 -0500 Subject: [PATCH 07/66] feat: indexes filenames --- src/uploads/getBaseFields.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/uploads/getBaseFields.ts b/src/uploads/getBaseFields.ts index 6d8a35ebfc..48ecda9af4 100644 --- a/src/uploads/getBaseFields.ts +++ b/src/uploads/getBaseFields.ts @@ -66,6 +66,7 @@ const getBaseUploadFields = ({ config, collection }: Options): Field[] => { name: 'filename', label: 'File Name', type: 'text', + index: true, admin: { readOnly: true, disabled: true, From 8df767e9a23c660be43a83b9ed0ad1bd59f0bcd0 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Nov 2021 19:54:35 -0500 Subject: [PATCH 08/66] feat: ensures revisions are created and deleted accordingly --- demo/collections/Localized.ts | 4 ++ demo/collections/RichText.ts | 4 -- src/collections/config/schema.ts | 6 +-- src/collections/config/types.ts | 3 +- .../operations/findRevisionByID.ts | 0 src/collections/operations/findRevisions.ts | 0 src/collections/operations/update.ts | 49 ++++++++++++++++--- src/fields/performFieldOperations.ts | 2 +- src/globals/config/schema.ts | 6 +++ src/globals/config/types.ts | 4 ++ src/mongoose/buildSchema.ts | 2 +- src/revisions/enforceMaxRevisions.ts | 38 ++++++++++++++ src/revisions/tests/spec.ts | 9 +--- src/revisions/types.ts | 2 +- 14 files changed, 102 insertions(+), 27 deletions(-) create mode 100644 src/collections/operations/findRevisionByID.ts create mode 100644 src/collections/operations/findRevisions.ts create mode 100644 src/revisions/enforceMaxRevisions.ts diff --git a/demo/collections/Localized.ts b/demo/collections/Localized.ts index 2b0f173d94..432ad42ad8 100644 --- a/demo/collections/Localized.ts +++ b/demo/collections/Localized.ts @@ -43,6 +43,10 @@ const LocalizedPosts: CollectionConfig = { ], enableRichTextRelationship: true, }, + revisions: { + maxPerDoc: 5, + retainDeleted: false, + }, access: { read: () => true, }, diff --git a/demo/collections/RichText.ts b/demo/collections/RichText.ts index b0c730b6cd..e51996dfaa 100644 --- a/demo/collections/RichText.ts +++ b/demo/collections/RichText.ts @@ -11,10 +11,6 @@ const RichText: CollectionConfig = { access: { read: () => true, }, - revisions: { - max: 5, - retainDeleted: false, - }, fields: [ { name: 'defaultRichText', diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts index f552e5734f..9ef88fb9f3 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -79,12 +79,8 @@ const collectionSchema = joi.object().keys({ ), revisions: joi.alternatives().try( joi.object({ - max: joi.number(), + maxPerDoc: joi.number(), retainDeleted: joi.boolean(), - access: joi.object({ - read: joi.func(), - modifyStatus: joi.func(), - }), }), joi.boolean(), ), diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index 5575b0665a..9bce3883f9 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -205,10 +205,11 @@ export type CollectionConfig = { timestamps?: boolean }; -export interface SanitizedCollectionConfig extends Omit, 'auth' | 'upload' | 'fields'> { +export interface SanitizedCollectionConfig extends Omit, 'auth' | 'upload' | 'fields' | 'revisions'> { auth: Auth; upload: Upload; fields: Field[]; + revisions: IncomingRevisionsType } export type Collection = { diff --git a/src/collections/operations/findRevisionByID.ts b/src/collections/operations/findRevisionByID.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/collections/operations/findRevisions.ts b/src/collections/operations/findRevisions.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index 26aff65812..2ff5f4cb73 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -1,6 +1,8 @@ import httpStatus from 'http-status'; import path from 'path'; import { UploadedFile } from 'express-fileupload'; +import { enforceMaxRevisions } from '../../revisions/enforceMaxRevisions'; +import { Payload } from '../..'; import { Where, Document } from '../../types'; import { Collection } from '../config/types'; @@ -30,8 +32,8 @@ export type Arguments = { overwriteExistingFiles?: 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; @@ -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, @@ -189,7 +191,7 @@ async function update(incomingArgs: Arguments): Promise { // beforeValidate - Fields // ///////////////////////////////////// - data = await performFieldOperations(collectionConfig, { + data = await this.performFieldOperations(collectionConfig, { data, req, id, @@ -233,7 +235,7 @@ async function update(incomingArgs: Arguments): Promise { // beforeChange - Fields // ///////////////////////////////////// - let result = await performFieldOperations(collectionConfig, { + let result = await this.performFieldOperations(collectionConfig, { data, req, id, @@ -283,11 +285,44 @@ async function update(incomingArgs: Arguments): Promise { result = JSON.parse(result); result = sanitizeInternalFields(result); + // ///////////////////////////////////// + // Create revision from existing doc + // ///////////////////////////////////// + + if (collectionConfig.revisions) { + const RevisionsModel = this.revisions[collectionConfig.slug]; + + const newRevisionData = { ...originalDoc }; + delete newRevisionData.id; + + let revisionCreationPromise; + + try { + revisionCreationPromise = RevisionsModel.create({ + parent: originalDoc.id, + revision: originalDoc, + }); + } catch (err) { + this.logger.error(`There was an error while saving a revision for the ${collectionConfig.labels.singular} with ID ${originalDoc.id}.`); + } + + if (collectionConfig.revisions.maxPerDoc) { + enforceMaxRevisions({ + payload: this, + Model: RevisionsModel, + label: collectionConfig.labels.plural, + entityType: 'collection', + maxPerDoc: collectionConfig.revisions.maxPerDoc, + revisionCreationPromise, + }); + } + } + // ///////////////////////////////////// // afterRead - Fields // ///////////////////////////////////// - result = await performFieldOperations(collectionConfig, { + result = await this.performFieldOperations(collectionConfig, { id, depth, req, @@ -316,7 +351,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/fields/performFieldOperations.ts b/src/fields/performFieldOperations.ts index 02f4b25cf3..83cdf2fdbb 100644 --- a/src/fields/performFieldOperations.ts +++ b/src/fields/performFieldOperations.ts @@ -25,7 +25,7 @@ type Arguments = { currentDepth?: number } -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, diff --git a/src/globals/config/schema.ts b/src/globals/config/schema.ts index a8ff605021..6f63412f26 100644 --- a/src/globals/config/schema.ts +++ b/src/globals/config/schema.ts @@ -22,6 +22,12 @@ const globalSchema = joi.object().keys({ update: joi.func(), }), fields: joi.array(), + revisions: joi.alternatives().try( + joi.object({ + max: joi.number(), + }), + joi.boolean(), + ), }).unknown(); export default globalSchema; diff --git a/src/globals/config/types.ts b/src/globals/config/types.ts index 1a7edc2bb0..a8c3973371 100644 --- a/src/globals/config/types.ts +++ b/src/globals/config/types.ts @@ -1,6 +1,7 @@ import React from 'react'; import { Model, Document } from 'mongoose'; import { DeepRequired } from 'ts-essentials'; +import { IncomingRevisionsType } from '../../revisions/types'; import { PayloadRequest } from '../../express/types'; import { Access, GeneratePreviewURL } from '../../config/types'; import { Field } from '../../fields/config/types'; @@ -40,6 +41,9 @@ export type GlobalConfig = { slug: string label?: string preview?: GeneratePreviewURL + revisions?: { + max?: number + } | true hooks?: { beforeValidate?: BeforeValidateHook[] beforeChange?: BeforeChangeHook[] diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index 8bc34c9922..cc1e1ca4fc 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -7,7 +7,7 @@ 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 diff --git a/src/revisions/enforceMaxRevisions.ts b/src/revisions/enforceMaxRevisions.ts new file mode 100644 index 0000000000..050656826c --- /dev/null +++ b/src/revisions/enforceMaxRevisions.ts @@ -0,0 +1,38 @@ +import { Payload } from '..'; +import { CollectionModel } from '../collections/config/types'; + +type Args = { + payload: Payload + Model: CollectionModel + maxPerDoc: number + label: string + entityType: 'global' | 'collection' + revisionCreationPromise: Promise +} + +export const enforceMaxRevisions = async ({ + payload, + Model, + maxPerDoc, + label, + entityType, + revisionCreationPromise, +}: Args): Promise => { + try { + if (revisionCreationPromise) await revisionCreationPromise; + + const oldestAllowedDoc = await Model.find().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 revisions for the ${entityType} ${label}`); + } +}; diff --git a/src/revisions/tests/spec.ts b/src/revisions/tests/spec.ts index cab5e141bb..67b88cd813 100644 --- a/src/revisions/tests/spec.ts +++ b/src/revisions/tests/spec.ts @@ -35,14 +35,9 @@ describe('Revisions - REST', () => { describe('Create', () => { it('should allow a new revision to be created', async () => { - const revision = await fetch(`${url}/api/rich-text`, { + const revision = await fetch(`${url}/api/localized-posts`, { body: JSON.stringify({ - defaultRichText: [{ - children: [{ text: 'Here is some default rich text content' }], - }], - customRichText: [{ - children: [{ text: 'Here is some custom rich text content' }], - }], + title: 'Here is a localized post in EN', }), headers, method: 'post', diff --git a/src/revisions/types.ts b/src/revisions/types.ts index 51183151d1..6a8e750eb8 100644 --- a/src/revisions/types.ts +++ b/src/revisions/types.ts @@ -1,4 +1,4 @@ export type IncomingRevisionsType = { - max?: number + maxPerDoc?: number retainDeleted?: boolean } From f246252a422c3524c1c3f65fcf073ea42ab347c3 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 27 Nov 2021 10:03:04 -0500 Subject: [PATCH 09/66] fix: ensures revisions retain all locales --- src/collections/operations/update.ts | 35 ++++------------ src/fields/accessPromise.ts | 2 +- src/fields/performFieldOperations.ts | 2 +- src/fields/traverseFields.ts | 3 +- src/revisions/enforceMaxRevisions.ts | 10 ++--- src/revisions/saveCollectionRevision.ts | 56 +++++++++++++++++++++++++ src/revisions/tests/spec.ts | 22 ++++++++-- 7 files changed, 90 insertions(+), 40 deletions(-) create mode 100644 src/revisions/saveCollectionRevision.ts diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index 2ff5f4cb73..c5450c73ec 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -1,7 +1,6 @@ import httpStatus from 'http-status'; import path from 'path'; import { UploadedFile } from 'express-fileupload'; -import { enforceMaxRevisions } from '../../revisions/enforceMaxRevisions'; import { Payload } from '../..'; import { Where, Document } from '../../types'; import { Collection } from '../config/types'; @@ -19,6 +18,7 @@ import { FileData } from '../../uploads/types'; import { PayloadRequest } from '../../express/types'; import { hasWhereAccessResult, UserDocument } from '../../auth/types'; import saveBufferToFile from '../../uploads/saveBufferToFile'; +import { saveCollectionRevision } from '../../revisions/saveCollectionRevision'; export type Arguments = { collection: Collection @@ -290,32 +290,13 @@ async function update(this: Payload, incomingArgs: Arguments): Promise // ///////////////////////////////////// if (collectionConfig.revisions) { - const RevisionsModel = this.revisions[collectionConfig.slug]; - - const newRevisionData = { ...originalDoc }; - delete newRevisionData.id; - - let revisionCreationPromise; - - try { - revisionCreationPromise = RevisionsModel.create({ - parent: originalDoc.id, - revision: originalDoc, - }); - } catch (err) { - this.logger.error(`There was an error while saving a revision for the ${collectionConfig.labels.singular} with ID ${originalDoc.id}.`); - } - - if (collectionConfig.revisions.maxPerDoc) { - enforceMaxRevisions({ - payload: this, - Model: RevisionsModel, - label: collectionConfig.labels.plural, - entityType: 'collection', - maxPerDoc: collectionConfig.revisions.maxPerDoc, - revisionCreationPromise, - }); - } + saveCollectionRevision({ + payload: this, + config: collectionConfig, + req, + docWithLocales, + id, + }); } // ///////////////////////////////////// diff --git a/src/fields/accessPromise.ts b/src/fields/accessPromise.ts index 93df803979..2908060753 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/performFieldOperations.ts b/src/fields/performFieldOperations.ts index 83cdf2fdbb..3ac9dbbb3a 100644 --- a/src/fields/performFieldOperations.ts +++ b/src/fields/performFieldOperations.ts @@ -19,7 +19,7 @@ type Arguments = { unflattenLocales?: boolean originalDoc?: Record docWithLocales?: Record - id?: string + id?: string | number showHiddenFields?: boolean depth?: number currentDepth?: number diff --git a/src/fields/traverseFields.ts b/src/fields/traverseFields.ts index 587cd39dd1..5d74a2348c 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 @@ -223,6 +223,7 @@ const traverseFields = (args: Arguments): void => { operation, fullOriginalDoc, fullData, + flattenLocales, })); } diff --git a/src/revisions/enforceMaxRevisions.ts b/src/revisions/enforceMaxRevisions.ts index 050656826c..3aa7937356 100644 --- a/src/revisions/enforceMaxRevisions.ts +++ b/src/revisions/enforceMaxRevisions.ts @@ -5,22 +5,18 @@ type Args = { payload: Payload Model: CollectionModel maxPerDoc: number - label: string + entityLabel: string entityType: 'global' | 'collection' - revisionCreationPromise: Promise } export const enforceMaxRevisions = async ({ payload, Model, maxPerDoc, - label, + entityLabel, entityType, - revisionCreationPromise, }: Args): Promise => { try { - if (revisionCreationPromise) await revisionCreationPromise; - const oldestAllowedDoc = await Model.find().limit(1).skip(maxPerDoc).sort({ createdAt: -1 }); if (oldestAllowedDoc?.[0]?.createdAt) { @@ -33,6 +29,6 @@ export const enforceMaxRevisions = async ({ }); } } catch (err) { - payload.logger.error(`There was an error cleaning up old revisions for the ${entityType} ${label}`); + payload.logger.error(`There was an error cleaning up old revisions for the ${entityType} ${entityLabel}`); } }; diff --git a/src/revisions/saveCollectionRevision.ts b/src/revisions/saveCollectionRevision.ts new file mode 100644 index 0000000000..b395afce7d --- /dev/null +++ b/src/revisions/saveCollectionRevision.ts @@ -0,0 +1,56 @@ +import { Payload } from '..'; +import { SanitizedCollectionConfig } from '../collections/config/types'; +import { enforceMaxRevisions } from './enforceMaxRevisions'; +import { PayloadRequest } from '../express/types'; + +type Args = { + payload: Payload + config?: SanitizedCollectionConfig + req: PayloadRequest + docWithLocales: any + id: string | number +} + +export const saveCollectionRevision = async ({ + payload, + config, + req, + id, + docWithLocales, +}: Args): Promise => { + const RevisionsModel = payload.revisions[config.slug]; + + const revision = await payload.performFieldOperations(config, { + id, + depth: 0, + req, + data: docWithLocales, + hook: 'afterRead', + operation: 'update', + overrideAccess: true, + flattenLocales: false, + showHiddenFields: true, + }); + + delete revision._id; + + try { + await RevisionsModel.create({ + parent: id, + revision, + }); + } catch (err) { + payload.logger.error(`There was an error while saving a revision for the ${config.labels.singular} with ID ${id}.`); + payload.logger.error(err); + } + + if (config.revisions.maxPerDoc) { + enforceMaxRevisions({ + payload: this, + Model: RevisionsModel, + entityLabel: config.labels.plural, + entityType: 'collection', + maxPerDoc: config.revisions.maxPerDoc, + }); + } +}; diff --git a/src/revisions/tests/spec.ts b/src/revisions/tests/spec.ts index 67b88cd813..1d7ed79b9e 100644 --- a/src/revisions/tests/spec.ts +++ b/src/revisions/tests/spec.ts @@ -35,15 +35,31 @@ describe('Revisions - REST', () => { describe('Create', () => { it('should allow a new revision to be created', async () => { - const revision = await fetch(`${url}/api/localized-posts`, { + const title1 = 'Here is a localized post in EN'; + + const post = await fetch(`${url}/api/localized-posts`, { body: JSON.stringify({ - title: 'Here is a localized post in EN', + title: title1, + description: '345j23o4ifj34jf54g', + priority: 10, }), headers, method: 'post', }).then((res) => res.json()); - expect(typeof revision.doc.id).toBe('string'); + expect(typeof post.doc.id).toBe('string'); + + const title2 = 'Here is an updated post title in EN'; + + const updatedPost = await fetch(`${url}/api/localized-posts/${post.doc.id}`, { + body: JSON.stringify({ + title: title2, + }), + headers, + method: 'put', + }).then((res) => res.json()); + + expect(updatedPost.doc.title).toBe(title2); }); }); }); From c3f743af03bbde856dcd87114383f0b484c0b20f Mon Sep 17 00:00:00 2001 From: James Date: Sat, 27 Nov 2021 10:03:24 -0500 Subject: [PATCH 10/66] feat: ensures field hooks run on all locales when locale=all --- src/fields/hookPromise.ts | 84 +++++++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 21 deletions(-) diff --git a/src/fields/hookPromise.ts b/src/fields/hookPromise.ts index c17516a410..f85beb409c 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,79 @@ type Arguments = { operation: Operation fullOriginalDoc: Record fullData: Record + flattenLocales: 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()); } From b13615f2bfaf25089a8189724b999bc23110a649 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 27 Nov 2021 10:03:44 -0500 Subject: [PATCH 11/66] fix: ensures unique is not set within revisions collections --- src/collections/init.ts | 1 + src/mongoose/buildSchema.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/collections/init.ts b/src/collections/init.ts index 9e425653f0..01fce33b58 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -73,6 +73,7 @@ export default function registerCollections(ctx: Payload): void { ctx.config, buildRevisionFields(collection), { + disableUnique: true, options: { timestamps: true, }, diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index cc1e1ca4fc..e5bad94971 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -11,6 +11,7 @@ export type BuildSchemaOptions = { options?: SchemaOptions allowIDField?: boolean disableRequired?: boolean + disableUnique?: boolean global?: boolean } @@ -52,7 +53,7 @@ const setBlockDiscriminators = (fields: Field[], schema: Schema, config: Sanitiz const formatBaseSchema = (field: NonPresentationalField, buildSchemaOptions: BuildSchemaOptions) => ({ sparse: field.unique && field.localized, - unique: field.unique || false, + unique: (!buildSchemaOptions.disableUnique && field.unique) || false, required: (!buildSchemaOptions.disableRequired && field.required && !field.localized && !field?.admin?.condition && !field?.access?.create) || false, default: field.defaultValue || undefined, index: field.index || field.unique || false, @@ -64,7 +65,9 @@ const localizeSchema = (field: NonPresentationalField, schema, locales) => { type: locales.reduce((localeSchema, locale) => ({ ...localeSchema, [locale]: schema, - }), {}), + }), { + _id: false, + }), localized: true, index: schema.index, }; @@ -73,7 +76,7 @@ const localizeSchema = (field: NonPresentationalField, schema, locales) => { }; const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchemaOptions: BuildSchemaOptions = {}): Schema => { - const { allowIDField, options } = buildSchemaOptions; + const { allowIDField, disableUnique, options } = buildSchemaOptions; let fields = {}; let schemaFields = configFields; const indexFields = []; From 2a7459baf24206563d25f1e6379116f0d24b52ea Mon Sep 17 00:00:00 2001 From: James Date: Sat, 27 Nov 2021 10:03:50 -0500 Subject: [PATCH 12/66] chore: test fixes --- demo/collections/Localized.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/collections/Localized.ts b/demo/collections/Localized.ts index 432ad42ad8..6dca1aa838 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'); } From ec82b923f32aa06342f83c37185b13aa4458c295 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 27 Nov 2021 11:07:34 -0500 Subject: [PATCH 13/66] feat: creates global revisions --- demo/globals/BlocksGlobal.ts | 3 ++ src/collections/init.ts | 8 +-- src/globals/config/types.ts | 5 +- src/globals/init.ts | 24 +++++++++ src/globals/operations/update.ts | 14 ++++++ src/index.ts | 4 +- src/mongoose/buildSchema.ts | 4 +- ...uildFields.ts => buildCollectionFields.ts} | 2 +- src/revisions/buildGlobalFields.ts | 10 ++++ src/revisions/createCollectionName.ts | 3 -- src/revisions/enforceMaxRevisions.ts | 3 +- src/revisions/getRevisionsModelName.ts | 4 ++ src/revisions/saveGlobalRevision.ts | 50 +++++++++++++++++++ src/revisions/tests/spec.ts | 29 ++++++----- 14 files changed, 135 insertions(+), 28 deletions(-) rename src/revisions/{buildFields.ts => buildCollectionFields.ts} (75%) create mode 100644 src/revisions/buildGlobalFields.ts delete mode 100644 src/revisions/createCollectionName.ts create mode 100644 src/revisions/getRevisionsModelName.ts create mode 100644 src/revisions/saveGlobalRevision.ts 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/src/collections/init.ts b/src/collections/init.ts index 01fce33b58..a370521c55 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -5,7 +5,7 @@ import passport from 'passport'; import passportLocalMongoose from 'passport-local-mongoose'; import Passport from 'passport-local'; import { UpdateQuery } from 'mongodb'; -import { buildRevisionFields } from '../revisions/buildFields'; +import { buildRevisionCollectionFields } from '../revisions/buildCollectionFields'; import buildQueryPlugin from '../mongoose/buildQuery'; import apiKeyStrategy from '../auth/strategies/apiKey'; import buildCollectionSchema from './buildSchema'; @@ -13,7 +13,7 @@ import buildSchema from '../mongoose/buildSchema'; import bindCollectionMiddleware from './bindCollection'; import { CollectionModel, SanitizedCollectionConfig } from './config/types'; import { Payload } from '../index'; -import { getCollectionRevisionsName } from '../revisions/createCollectionName'; +import { getRevisionsModelName } from '../revisions/getRevisionsModelName'; const LocalStrategy = Passport.Strategy; @@ -67,11 +67,11 @@ export default function registerCollections(ctx: Payload): void { } if (collection.revisions) { - const revisionModelName = getCollectionRevisionsName(collection); + const revisionModelName = getRevisionsModelName(collection); const revisionSchema = buildSchema( ctx.config, - buildRevisionFields(collection), + buildRevisionCollectionFields(collection), { disableUnique: true, options: { diff --git a/src/globals/config/types.ts b/src/globals/config/types.ts index a8c3973371..2c7e2bb5d3 100644 --- a/src/globals/config/types.ts +++ b/src/globals/config/types.ts @@ -67,8 +67,11 @@ export type GlobalConfig = { } } -export interface SanitizedGlobalConfig extends Omit, 'fields'> { +export interface SanitizedGlobalConfig extends Omit, 'fields' | 'revisions'> { fields: Field[] + revisions?: { + max?: number + } } export type Globals = { diff --git a/src/globals/init.ts b/src/globals/init.ts index 0b5bf2c5bf..1702e87a6a 100644 --- a/src/globals/init.ts +++ b/src/globals/init.ts @@ -1,6 +1,11 @@ import express from 'express'; +import mongoose from 'mongoose'; import buildModel from './buildModel'; import { Payload } from '../index'; +import { getRevisionsModelName } from '../revisions/getRevisionsModelName'; +import { buildRevisionGlobalFields } from '../revisions/buildGlobalFields'; +import buildSchema from '../mongoose/buildSchema'; +import { GlobalModel } from './config/types'; export default function initGlobals(ctx: Payload): void { if (ctx.config.globals) { @@ -9,6 +14,25 @@ export default function initGlobals(ctx: Payload): void { config: ctx.config.globals, }; + ctx.config.globals.forEach((global) => { + if (global.revisions) { + const revisionModelName = getRevisionsModelName(global); + + const revisionSchema = buildSchema( + ctx.config, + buildRevisionGlobalFields(global), + { + disableUnique: true, + options: { + timestamps: true, + }, + }, + ); + + ctx.revisions[global.slug] = mongoose.model(revisionModelName, revisionSchema) as GlobalModel; + } + }); + // If not local, open routes if (!ctx.local) { const router = express.Router(); diff --git a/src/globals/operations/update.ts b/src/globals/operations/update.ts index 4fe7664b09..267b0b820f 100644 --- a/src/globals/operations/update.ts +++ b/src/globals/operations/update.ts @@ -1,5 +1,6 @@ import executeAccess from '../../auth/executeAccess'; import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; +import { saveGlobalRevision } from '../../revisions/saveGlobalRevision'; async function update(args) { const { globals: { Model } } = this; @@ -126,6 +127,19 @@ async function update(args) { global = JSON.parse(global); global = sanitizeInternalFields(global); + // ///////////////////////////////////// + // Create revision from existing doc + // ///////////////////////////////////// + + if (globalConfig.revisions) { + saveGlobalRevision({ + payload: this, + config: globalConfig, + req, + docWithLocales: global, + }); + } + // ///////////////////////////////////// // afterRead - Fields // ///////////////////////////////////// diff --git a/src/index.ts b/src/index.ts index 657401e5e3..ec476da7ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,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'; @@ -58,7 +58,7 @@ export class Payload { } = {} revisions: { - [slug: string]: CollectionModel; + [slug: string]: CollectionModel | GlobalModel; } = {} graphQL: { diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index e5bad94971..cc4e7beaa3 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -76,7 +76,7 @@ const localizeSchema = (field: NonPresentationalField, schema, locales) => { }; const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchemaOptions: BuildSchemaOptions = {}): Schema => { - const { allowIDField, disableUnique, options } = buildSchemaOptions; + const { allowIDField, options } = buildSchemaOptions; let fields = {}; let schemaFields = configFields; const indexFields = []; @@ -321,6 +321,7 @@ const fieldToSchemaMap = { type: [buildSchema(config, field.fields, { options: { _id: false, id: false }, allowIDField: true, + disableUnique: buildSchemaOptions.disableUnique, })], }; @@ -343,6 +344,7 @@ const fieldToSchemaMap = { _id: false, id: false, }, + disableUnique: buildSchemaOptions.disableUnique, disableRequired: !formattedBaseSchema.required, }), }; diff --git a/src/revisions/buildFields.ts b/src/revisions/buildCollectionFields.ts similarity index 75% rename from src/revisions/buildFields.ts rename to src/revisions/buildCollectionFields.ts index 14e68984d1..59f94c20a6 100644 --- a/src/revisions/buildFields.ts +++ b/src/revisions/buildCollectionFields.ts @@ -1,7 +1,7 @@ import { Field } from '../fields/config/types'; import { SanitizedCollectionConfig } from '../collections/config/types'; -export const buildRevisionFields = (collection: SanitizedCollectionConfig): Field[] => [ +export const buildRevisionCollectionFields = (collection: SanitizedCollectionConfig): Field[] => [ { name: 'parent', type: 'relationship', diff --git a/src/revisions/buildGlobalFields.ts b/src/revisions/buildGlobalFields.ts new file mode 100644 index 0000000000..330c1e9c5d --- /dev/null +++ b/src/revisions/buildGlobalFields.ts @@ -0,0 +1,10 @@ +import { Field } from '../fields/config/types'; +import { SanitizedGlobalConfig } from '../globals/config/types'; + +export const buildRevisionGlobalFields = (global: SanitizedGlobalConfig): Field[] => [ + { + name: 'revision', + type: 'group', + fields: global.fields, + }, +]; diff --git a/src/revisions/createCollectionName.ts b/src/revisions/createCollectionName.ts deleted file mode 100644 index a1aa0ea965..0000000000 --- a/src/revisions/createCollectionName.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { SanitizedCollectionConfig } from '../collections/config/types'; - -export const getCollectionRevisionsName = (collection: SanitizedCollectionConfig): string => `_${collection.slug}_revisions`; diff --git a/src/revisions/enforceMaxRevisions.ts b/src/revisions/enforceMaxRevisions.ts index 3aa7937356..b0edb10a5f 100644 --- a/src/revisions/enforceMaxRevisions.ts +++ b/src/revisions/enforceMaxRevisions.ts @@ -1,9 +1,10 @@ +import { GlobalModel } from '../globals/config/types'; import { Payload } from '..'; import { CollectionModel } from '../collections/config/types'; type Args = { payload: Payload - Model: CollectionModel + Model: CollectionModel | GlobalModel maxPerDoc: number entityLabel: string entityType: 'global' | 'collection' diff --git a/src/revisions/getRevisionsModelName.ts b/src/revisions/getRevisionsModelName.ts new file mode 100644 index 0000000000..84ac3948c4 --- /dev/null +++ b/src/revisions/getRevisionsModelName.ts @@ -0,0 +1,4 @@ +import { SanitizedCollectionConfig } from '../collections/config/types'; +import { SanitizedGlobalConfig } from '../globals/config/types'; + +export const getRevisionsModelName = (entity: SanitizedCollectionConfig | SanitizedGlobalConfig): string => `_${entity.slug}_revisions`; diff --git a/src/revisions/saveGlobalRevision.ts b/src/revisions/saveGlobalRevision.ts new file mode 100644 index 0000000000..6774fc1744 --- /dev/null +++ b/src/revisions/saveGlobalRevision.ts @@ -0,0 +1,50 @@ +import { Payload } from '..'; +import { enforceMaxRevisions } from './enforceMaxRevisions'; +import { PayloadRequest } from '../express/types'; +import { SanitizedGlobalConfig } from '../globals/config/types'; + +type Args = { + payload: Payload + config?: SanitizedGlobalConfig + req: PayloadRequest + docWithLocales: any +} + +export const saveGlobalRevision = async ({ + payload, + config, + req, + docWithLocales, +}: Args): Promise => { + const RevisionsModel = payload.revisions[config.slug]; + + const revision = await payload.performFieldOperations(config, { + depth: 0, + req, + data: docWithLocales, + hook: 'afterRead', + operation: 'update', + overrideAccess: true, + flattenLocales: false, + showHiddenFields: true, + }); + + try { + await RevisionsModel.create({ + revision, + }); + } catch (err) { + payload.logger.error(`There was an error while saving a revision for the Global ${config.label}.`); + payload.logger.error(err); + } + + if (config.revisions.max) { + enforceMaxRevisions({ + payload: this, + Model: RevisionsModel, + entityLabel: config.label, + entityType: 'global', + maxPerDoc: config.revisions.max, + }); + } +}; diff --git a/src/revisions/tests/spec.ts b/src/revisions/tests/spec.ts index 1d7ed79b9e..596ea7a4ab 100644 --- a/src/revisions/tests/spec.ts +++ b/src/revisions/tests/spec.ts @@ -7,6 +7,7 @@ const { serverURL: url } = getConfig(); let token = null; let headers = null; +let postID; describe('Revisions - REST', () => { beforeAll(async (done) => { @@ -30,28 +31,26 @@ describe('Revisions - REST', () => { 'Content-Type': 'application/json', }; + const post = await fetch(`${url}/api/localized-posts`, { + body: JSON.stringify({ + title: 'Here is a localized post in EN', + description: '345j23o4ifj34jf54g', + priority: 10, + }), + headers, + method: 'post', + }).then((res) => res.json()); + + postID = post.doc.id; + done(); }); describe('Create', () => { it('should allow a new revision to be created', async () => { - const title1 = 'Here is a localized post in EN'; - - const post = await fetch(`${url}/api/localized-posts`, { - body: JSON.stringify({ - title: title1, - description: '345j23o4ifj34jf54g', - priority: 10, - }), - headers, - method: 'post', - }).then((res) => res.json()); - - expect(typeof post.doc.id).toBe('string'); - const title2 = 'Here is an updated post title in EN'; - const updatedPost = await fetch(`${url}/api/localized-posts/${post.doc.id}`, { + const updatedPost = await fetch(`${url}/api/localized-posts/${postID}`, { body: JSON.stringify({ title: title2, }), From 4a445f03e808197536cc26b150f1bc25c4d1bd01 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 27 Nov 2021 12:09:45 -0500 Subject: [PATCH 14/66] feat: scaffolds new revisions operations and rest routes --- .../Condition/Relationship/index.tsx | 2 +- .../Condition/Relationship/types.ts | 3 +- .../forms/field-types/Relationship/index.tsx | 2 +- .../forms/field-types/Relationship/types.ts | 3 +- .../views/collections/List/types.ts | 3 +- src/collections/config/types.ts | 13 -- src/collections/init.ts | 10 + src/collections/operations/find.ts | 25 +-- .../operations/findRevisionByID.ts | 144 ++++++++++++++ src/collections/operations/findRevisions.ts | 178 ++++++++++++++++++ src/collections/operations/local/find.ts | 3 +- src/collections/requestHandlers/find.ts | 3 +- .../requestHandlers/findRevisionByID.ts | 24 +++ .../requestHandlers/findRevisions.ts | 35 ++++ src/fields/hookPromise.ts | 1 + src/fields/performFieldOperations.ts | 3 + src/fields/traverseFields.ts | 3 + src/globals/config/schema.ts | 1 + src/globals/config/types.ts | 7 +- src/globals/operations/findRevisionByID.ts | 142 ++++++++++++++ src/globals/operations/findRevisions.ts | 156 +++++++++++++++ src/globals/operations/update.ts | 12 +- .../requestHandlers/findRevisionByID.ts | 20 ++ src/globals/requestHandlers/findRevisions.ts | 36 ++++ src/index.ts | 3 +- src/init/bindOperations.ts | 8 + src/init/bindRequestHandlers.ts | 12 ++ src/mongoose/buildSortParam.ts | 21 +++ src/mongoose/types.ts | 12 ++ src/revisions/types.ts | 6 + 30 files changed, 844 insertions(+), 47 deletions(-) create mode 100644 src/collections/requestHandlers/findRevisionByID.ts create mode 100644 src/collections/requestHandlers/findRevisions.ts create mode 100644 src/globals/operations/findRevisionByID.ts create mode 100644 src/globals/operations/findRevisions.ts create mode 100644 src/globals/requestHandlers/findRevisionByID.ts create mode 100644 src/globals/requestHandlers/findRevisions.ts create mode 100644 src/mongoose/buildSortParam.ts create mode 100644 src/mongoose/types.ts 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/field-types/Relationship/index.tsx b/src/admin/components/forms/field-types/Relationship/index.tsx index 17ea530efc..472e5be244 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/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/collections/config/types.ts b/src/collections/config/types.ts index 9bce3883f9..2888bb83f1 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -222,19 +222,6 @@ 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 } diff --git a/src/collections/init.ts b/src/collections/init.ts index a370521c55..605d822ce0 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -104,6 +104,8 @@ export default function registerCollections(ctx: Payload): void { find, update, findByID, + findRevisions, + findRevisionByID, delete: deleteHandler, } = ctx.requestHandlers.collections; @@ -173,6 +175,14 @@ export default function registerCollections(ctx: Payload): void { .post(resetPassword); } + if (collection.revisions) { + router.route(`/${slug}/revisions`) + .get(findRevisions); + + router.route(`/${slug}/revisions/:id`) + .get(findRevisionByID); + } + router.route(`/${slug}`) .get(find) .post(create); diff --git a/src/collections/operations/find.ts b/src/collections/operations/find.ts index 919527fe7c..c788e94360 100644 --- a/src/collections/operations/find.ts +++ b/src/collections/operations/find.ts @@ -2,9 +2,11 @@ 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'; export type Arguments = { collection: Collection @@ -98,28 +100,10 @@ async function find(incomingArgs: Arguments): Promis // Find // ///////////////////////////////////// - let sortParam: Record; - - if (!args.sort) { - if (collectionConfig.timestamps) { - sortParam = { createdAt: 'desc' }; - } else { - sortParam = { _id: 'desc' }; - } - } else if (args.sort.indexOf('-') === 0) { - sortParam = { - [args.sort.substring(1)]: 'desc', - }; - } else { - sortParam = { - [args.sort]: 'asc', - }; - } - const optionsToExecute = { page: page || 1, limit: limit || 10, - sort: sortParam, + sort: buildSortParam(args.sort, collectionConfig.timestamps), lean: true, leanWithId: true, useEstimatedCount, @@ -166,7 +150,6 @@ async function find(incomingArgs: Arguments): Promis flattenLocales: true, showHiddenFields, }, - find, ))), }; diff --git a/src/collections/operations/findRevisionByID.ts b/src/collections/operations/findRevisionByID.ts index e69de29bb2..8c63b8e185 100644 --- a/src/collections/operations/findRevisionByID.ts +++ b/src/collections/operations/findRevisionByID.ts @@ -0,0 +1,144 @@ +/* eslint-disable no-underscore-dangle */ +import { PayloadRequest } from '../../express/types'; +import { Collection } from '../config/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 { TypeWithRevision } from '../../revisions/types'; + +export type Arguments = { + collection: Collection + id: string + req: PayloadRequest + disableErrors?: boolean + currentDepth?: number + overrideAccess?: boolean + showHiddenFields?: boolean + depth?: number +} + +async function findRevisionByID = any>(args: Arguments): Promise { + const { + depth, + collection: { + config: collectionConfig, + }, + id, + req, + req: { + locale, + }, + disableErrors, + currentDepth, + overrideAccess, + showHiddenFields, + } = args; + + const RevisionsModel = this.revisions[collectionConfig.slug]; + + // ///////////////////////////////////// + // Access + // ///////////////////////////////////// + + const accessResults = !overrideAccess ? await executeAccess({ req, disableErrors, id }, collectionConfig.access.readRevisions) : 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 RevisionsModel.buildQuery(queryToBuild, locale); + + // ///////////////////////////////////// + // Find by ID + // ///////////////////////////////////// + + if (!query.$and[0]._id) throw new NotFound(); + + let result = await RevisionsModel.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.revision = await hook({ + req, + query, + doc: result.revision, + }) || result.revision; + }, Promise.resolve()); + + // ///////////////////////////////////// + // afterRead - Fields + // ///////////////////////////////////// + + result.revision = await this.performFieldOperations(collectionConfig, { + depth, + req, + id, + data: result.revision, + hook: 'afterRead', + operation: 'read', + currentDepth, + overrideAccess, + flattenLocales: true, + showHiddenFields, + }); + + // ///////////////////////////////////// + // afterRead - Collection + // ///////////////////////////////////// + + await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { + await priorHook; + + result.revision = await hook({ + req, + query, + doc: result.revision, + }) || result.revision; + }, Promise.resolve()); + + // ///////////////////////////////////// + // Return results + // ///////////////////////////////////// + + return result; +} + +export default findRevisionByID; diff --git a/src/collections/operations/findRevisions.ts b/src/collections/operations/findRevisions.ts index e69de29bb2..cfb1cd83f5 100644 --- a/src/collections/operations/findRevisions.ts +++ b/src/collections/operations/findRevisions.ts @@ -0,0 +1,178 @@ +import { Where } from '../../types'; +import { PayloadRequest } from '../../express/types'; +import executeAccess from '../../auth/executeAccess'; +import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; +import { Collection } from '../config/types'; +import { hasWhereAccessResult } from '../../auth/types'; +import flattenWhereConstraints from '../../utilities/flattenWhereConstraints'; +import { buildSortParam } from '../../mongoose/buildSortParam'; +import { PaginatedDocs } from '../../mongoose/types'; +import { TypeWithRevision } from '../../revisions/types'; + +export type Arguments = { + collection: Collection + where?: Where + page?: number + limit?: number + sort?: string + depth?: number + req?: PayloadRequest + overrideAccess?: boolean + showHiddenFields?: boolean +} + +async function findRevisions = any>(args: Arguments): Promise> { + const { + where, + page, + limit, + depth, + collection: { + config: collectionConfig, + }, + req, + req: { + locale, + }, + overrideAccess, + showHiddenFields, + } = args; + + const RevisionsModel = this.revisions[collectionConfig.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 }, collectionConfig.access.readRevisions); + + if (hasWhereAccessResult(accessResults)) { + if (!where) { + queryToBuild.where = { + and: [ + accessResults, + ], + }; + } else { + (queryToBuild.where.and as Where[]).push(accessResults); + } + } + } + + const query = await RevisionsModel.buildQuery(queryToBuild, locale); + + // ///////////////////////////////////// + // Find + // ///////////////////////////////////// + + const optionsToExecute = { + page: page || 1, + limit: limit || 10, + sort: buildSortParam(args.sort, true), + lean: true, + leanWithId: true, + useEstimatedCount, + }; + + const paginatedDocs = await RevisionsModel.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.revision = await hook({ req, query, doc: docRef.revision }) || docRef.revision; + }, Promise.resolve()); + + return docRef; + })), + } as PaginatedDocs; + + // ///////////////////////////////////// + // afterRead - Fields + // ///////////////////////////////////// + + result = { + ...result, + docs: await Promise.all(result.docs.map(async (data) => ({ + ...data, + revision: this.performFieldOperations( + collectionConfig, + { + depth, + data: data.revision, + req, + id: data.revision.id, + hook: 'afterRead', + operation: 'read', + overrideAccess, + flattenLocales: true, + showHiddenFields, + isRevision: 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.revision = await hook({ req, query, doc: doc.revision }) || doc.revision; + }, Promise.resolve()); + + return docRef; + })), + }; + + // ///////////////////////////////////// + // Return results + // ///////////////////////////////////// + + result = { + ...result, + docs: result.docs.map((doc) => sanitizeInternalFields(doc)), + }; + + return result; +} + +export default findRevisions; diff --git a/src/collections/operations/local/find.ts b/src/collections/operations/local/find.ts index 117dd80eb7..7d4c968640 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 = { diff --git a/src/collections/requestHandlers/find.ts b/src/collections/requestHandlers/find.ts index 028d4eeab0..6f0bb51235 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 { diff --git a/src/collections/requestHandlers/findRevisionByID.ts b/src/collections/requestHandlers/findRevisionByID.ts new file mode 100644 index 0000000000..21ac9de347 --- /dev/null +++ b/src/collections/requestHandlers/findRevisionByID.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 findRevisionByID(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.findRevisionByID(options); + return res.json(doc); + } catch (error) { + return next(error); + } +} diff --git a/src/collections/requestHandlers/findRevisions.ts b/src/collections/requestHandlers/findRevisions.ts new file mode 100644 index 0000000000..de4102a137 --- /dev/null +++ b/src/collections/requestHandlers/findRevisions.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 findRevisions(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.findRevisions(options); + + return res.status(httpStatus.OK).json(result); + } catch (error) { + return next(error); + } +} diff --git a/src/fields/hookPromise.ts b/src/fields/hookPromise.ts index f85beb409c..8012ce42ca 100644 --- a/src/fields/hookPromise.ts +++ b/src/fields/hookPromise.ts @@ -11,6 +11,7 @@ type Arguments = { fullOriginalDoc: Record fullData: Record flattenLocales: boolean + isRevision: boolean } type ExecuteHookArguments = { diff --git a/src/fields/performFieldOperations.ts b/src/fields/performFieldOperations.ts index 3ac9dbbb3a..1d8f3bb173 100644 --- a/src/fields/performFieldOperations.ts +++ b/src/fields/performFieldOperations.ts @@ -23,6 +23,7 @@ type Arguments = { showHiddenFields?: boolean depth?: number currentDepth?: number + isRevision?: boolean } export default async function performFieldOperations(this: Payload, entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig, args: Arguments): Promise { @@ -42,6 +43,7 @@ export default async function performFieldOperations(this: Payload, entityConfig flattenLocales, unflattenLocales = false, showHiddenFields = false, + isRevision = false, } = args; const fullData = deepCopyObject(data); @@ -101,6 +103,7 @@ export default async function performFieldOperations(this: Payload, entityConfig unflattenLocaleActions, transformActions, docWithLocales, + isRevision, }); if (hook === 'afterRead') { diff --git a/src/fields/traverseFields.ts b/src/fields/traverseFields.ts index 5d74a2348c..440775b91f 100644 --- a/src/fields/traverseFields.ts +++ b/src/fields/traverseFields.ts @@ -36,6 +36,7 @@ type Arguments = { transformActions: (() => void)[] docWithLocales?: Record skipValidation?: boolean + isRevision: boolean } const traverseFields = (args: Arguments): void => { @@ -68,6 +69,7 @@ const traverseFields = (args: Arguments): void => { transformActions, docWithLocales = {}, skipValidation, + isRevision, } = args; fields.forEach((field) => { @@ -224,6 +226,7 @@ const traverseFields = (args: Arguments): void => { fullOriginalDoc, fullData, flattenLocales, + isRevision, })); } diff --git a/src/globals/config/schema.ts b/src/globals/config/schema.ts index 6f63412f26..ff793ba5f8 100644 --- a/src/globals/config/schema.ts +++ b/src/globals/config/schema.ts @@ -19,6 +19,7 @@ const globalSchema = joi.object().keys({ }), access: joi.object({ read: joi.func(), + readRevisions: joi.func(), update: joi.func(), }), fields: joi.array(), diff --git a/src/globals/config/types.ts b/src/globals/config/types.ts index 2c7e2bb5d3..873b59a855 100644 --- a/src/globals/config/types.ts +++ b/src/globals/config/types.ts @@ -1,11 +1,14 @@ import React from 'react'; import { Model, Document } from 'mongoose'; import { DeepRequired } from 'ts-essentials'; -import { IncomingRevisionsType } from '../../revisions/types'; import { PayloadRequest } from '../../express/types'; import { Access, GeneratePreviewURL } from '../../config/types'; import { Field } from '../../fields/config/types'; +export type TypeWithID = { + id: string +} + export type BeforeValidateHook = (args: { data?: any; req?: PayloadRequest; @@ -53,8 +56,8 @@ export type GlobalConfig = { } access?: { read?: Access; + readRevisions?: Access; update?: Access; - admin?: Access; } fields: Field[]; admin?: { diff --git a/src/globals/operations/findRevisionByID.ts b/src/globals/operations/findRevisionByID.ts new file mode 100644 index 0000000000..eff50bcab4 --- /dev/null +++ b/src/globals/operations/findRevisionByID.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 { TypeWithRevision } from '../../revisions/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 findRevisionByID = any>(args: Arguments): Promise { + const { + depth, + globalConfig, + id, + req, + req: { + locale, + }, + disableErrors, + currentDepth, + overrideAccess, + showHiddenFields, + } = args; + + const RevisionsModel = this.revisions[globalConfig.slug]; + + // ///////////////////////////////////// + // Access + // ///////////////////////////////////// + + const accessResults = !overrideAccess ? await executeAccess({ req, disableErrors, id }, globalConfig.access.readRevisions) : 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 RevisionsModel.buildQuery(queryToBuild, locale); + + // ///////////////////////////////////// + // Find by ID + // ///////////////////////////////////// + + if (!query.$and[0]._id) throw new NotFound(); + + let result = await RevisionsModel.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.revision = await hook({ + req, + query, + doc: result.revision, + }) || result.revision; + }, Promise.resolve()); + + // ///////////////////////////////////// + // afterRead - Fields + // ///////////////////////////////////// + + result.revision = await this.performFieldOperations(globalConfig, { + depth, + req, + id, + data: result.revision, + hook: 'afterRead', + operation: 'read', + currentDepth, + overrideAccess, + flattenLocales: true, + showHiddenFields, + }); + + // ///////////////////////////////////// + // afterRead - Collection + // ///////////////////////////////////// + + await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => { + await priorHook; + + result.revision = await hook({ + req, + query, + doc: result.revision, + }) || result.revision; + }, Promise.resolve()); + + // ///////////////////////////////////// + // Return results + // ///////////////////////////////////// + + return result; +} + +export default findRevisionByID; diff --git a/src/globals/operations/findRevisions.ts b/src/globals/operations/findRevisions.ts new file mode 100644 index 0000000000..3b6ecca0f6 --- /dev/null +++ b/src/globals/operations/findRevisions.ts @@ -0,0 +1,156 @@ +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 { TypeWithRevision } from '../../revisions/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 findRevisions = any>(args: Arguments): Promise> { + const { + where, + page, + limit, + depth, + globalConfig, + req, + req: { + locale, + }, + overrideAccess, + showHiddenFields, + } = args; + + const RevisionsModel = this.revisions[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.readRevisions); + + if (hasWhereAccessResult(accessResults)) { + if (!where) { + queryToBuild.where = { + and: [ + accessResults, + ], + }; + } else { + (queryToBuild.where.and as Where[]).push(accessResults); + } + } + } + + const query = await RevisionsModel.buildQuery(queryToBuild, locale); + + // ///////////////////////////////////// + // Find + // ///////////////////////////////////// + + const optionsToExecute = { + page: page || 1, + limit: limit || 10, + sort: buildSortParam(args.sort, true), + lean: true, + leanWithId: true, + useEstimatedCount, + }; + + const paginatedDocs = await RevisionsModel.paginate(query, optionsToExecute); + + // ///////////////////////////////////// + // afterRead - Fields + // ///////////////////////////////////// + + let result = { + ...paginatedDocs, + docs: await Promise.all(paginatedDocs.docs.map(async (data) => ({ + ...data, + revision: this.performFieldOperations( + globalConfig, + { + depth, + data: data.revision, + req, + id: data.revision.id, + hook: 'afterRead', + operation: 'read', + overrideAccess, + flattenLocales: true, + showHiddenFields, + isRevision: 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.revision = await hook({ req, query, doc: doc.revision }) || doc.revision; + }, Promise.resolve()); + + return docRef; + })), + }; + + // ///////////////////////////////////// + // Return results + // ///////////////////////////////////// + + result = { + ...result, + docs: result.docs.map((doc) => sanitizeInternalFields(doc)), + }; + + return result; +} + +export default findRevisions; diff --git a/src/globals/operations/update.ts b/src/globals/operations/update.ts index 267b0b820f..8ce77b26d4 100644 --- a/src/globals/operations/update.ts +++ b/src/globals/operations/update.ts @@ -1,8 +1,10 @@ +import { Payload } from '../..'; +import { TypeWithID } from '../config/types'; import executeAccess from '../../auth/executeAccess'; import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; import { saveGlobalRevision } from '../../revisions/saveGlobalRevision'; -async function update(args) { +async function update(this: Payload, args): Promise { const { globals: { Model } } = this; const { @@ -26,10 +28,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); @@ -105,6 +109,7 @@ async function update(args) { unflattenLocales: true, originalDoc, docWithLocales: globalJSON, + overrideAccess, }); // ///////////////////////////////////// @@ -131,7 +136,7 @@ async function update(args) { // Create revision from existing doc // ///////////////////////////////////// - if (globalConfig.revisions) { + if (globalConfig.revisions && hasExistingGlobal) { saveGlobalRevision({ payload: this, config: globalConfig, @@ -152,6 +157,7 @@ async function update(args) { depth, showHiddenFields, flattenLocales: true, + overrideAccess, }); // ///////////////////////////////////// diff --git a/src/globals/requestHandlers/findRevisionByID.ts b/src/globals/requestHandlers/findRevisionByID.ts new file mode 100644 index 0000000000..a04e131998 --- /dev/null +++ b/src/globals/requestHandlers/findRevisionByID.ts @@ -0,0 +1,20 @@ +import { Response, NextFunction } from 'express'; +import { PayloadRequest } from '../../express/types'; +import { Document } from '../../types'; +import { SanitizedGlobalConfig } from '../config/types'; + +export default (globalConfig: SanitizedGlobalConfig) => async function findRevisionByID(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.findRevisionByID(options); + return res.json(doc); + } catch (error) { + return next(error); + } +}; diff --git a/src/globals/requestHandlers/findRevisions.ts b/src/globals/requestHandlers/findRevisions.ts new file mode 100644 index 0000000000..bf635525fc --- /dev/null +++ b/src/globals/requestHandlers/findRevisions.ts @@ -0,0 +1,36 @@ +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 (global: SanitizedGlobalConfig) => async function findRevisions(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.findRevisions(options); + + return res.status(httpStatus.OK).json(result); + } catch (error) { + return next(error); + } +}; diff --git a/src/index.ts b/src/index.ts index ec476da7ff..5e3f834d7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,13 +2,14 @@ import express, { Express, Router } from 'express'; import crypto from 'crypto'; import { TypeWithID, - Collection, CollectionModel, PaginatedDocs, + Collection, CollectionModel, } from './collections/config/types'; import { SanitizedConfig, EmailOptions, InitOptions, } from './config/types'; +import { PaginatedDocs } from './mongoose/types'; import Logger from './utilities/logger'; import bindOperations from './init/bindOperations'; diff --git a/src/init/bindOperations.ts b/src/init/bindOperations.ts index fecc3d5121..1a7fbab2a4 100644 --- a/src/init/bindOperations.ts +++ b/src/init/bindOperations.ts @@ -14,10 +14,14 @@ 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 findRevisions from '../collections/operations/findRevisions'; +import findRevisionByID from '../collections/operations/findRevisionByID'; import update from '../collections/operations/update'; import deleteHandler from '../collections/operations/delete'; import findOne from '../globals/operations/findOne'; +import findGlobalRevisions from '../globals/operations/findRevisions'; +import findGlobalRevisionByID from '../globals/operations/findRevisionByID'; import globalUpdate from '../globals/operations/update'; import preferenceUpdate from '../preferences/operations/update'; @@ -30,6 +34,8 @@ function bindOperations(ctx: Payload): void { create: create.bind(ctx), find: find.bind(ctx), findByID: findByID.bind(ctx), + findRevisions: findRevisions.bind(ctx), + findRevisionByID: findRevisionByID.bind(ctx), update: update.bind(ctx), delete: deleteHandler.bind(ctx), auth: { @@ -48,6 +54,8 @@ function bindOperations(ctx: Payload): void { }, globals: { findOne: findOne.bind(ctx), + findRevisions: findGlobalRevisions.bind(ctx), + findRevisionByID: findGlobalRevisionByID.bind(ctx), update: globalUpdate.bind(ctx), }, preferences: { diff --git a/src/init/bindRequestHandlers.ts b/src/init/bindRequestHandlers.ts index 8cd8667eea..3d29f569e8 100644 --- a/src/init/bindRequestHandlers.ts +++ b/src/init/bindRequestHandlers.ts @@ -13,10 +13,14 @@ 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 findRevisions from '../collections/requestHandlers/findRevisions'; +import findRevisionByID from '../collections/requestHandlers/findRevisionByID'; import update from '../collections/requestHandlers/update'; import deleteHandler from '../collections/requestHandlers/delete'; import findOne from '../globals/requestHandlers/findOne'; +import findGlobalRevisions from '../globals/requestHandlers/findRevisions'; +import findGlobalRevisionByID from '../globals/requestHandlers/findRevisionByID'; import globalUpdate from '../globals/requestHandlers/update'; import { Payload } from '../index'; import preferenceUpdate from '../preferences/requestHandlers/update'; @@ -28,6 +32,8 @@ export type RequestHandlers = { create: typeof create, find: typeof find, findByID: typeof findByID, + findRevisions: typeof findRevisions, + findRevisionByID: typeof findRevisionByID, update: typeof update, delete: typeof deleteHandler, auth: { @@ -47,6 +53,8 @@ export type RequestHandlers = { globals: { findOne: typeof findOne, update: typeof globalUpdate, + findRevisions: typeof findGlobalRevisions + findRevisionByID: typeof findGlobalRevisionByID }, preferences: { update: typeof preferenceUpdate, @@ -61,6 +69,8 @@ function bindRequestHandlers(ctx: Payload): void { create: create.bind(ctx), find: find.bind(ctx), findByID: findByID.bind(ctx), + findRevisions: findRevisions.bind(ctx), + findRevisionByID: findRevisionByID.bind(ctx), update: update.bind(ctx), delete: deleteHandler.bind(ctx), auth: { @@ -80,6 +90,8 @@ function bindRequestHandlers(ctx: Payload): void { globals: { findOne: findOne.bind(ctx), update: globalUpdate.bind(ctx), + findRevisions: findGlobalRevisions.bind(ctx), + findRevisionByID: findGlobalRevisionByID.bind(ctx), }, preferences: { update: preferenceUpdate.bind(ctx), diff --git a/src/mongoose/buildSortParam.ts b/src/mongoose/buildSortParam.ts new file mode 100644 index 0000000000..e6ede5ee12 --- /dev/null +++ b/src/mongoose/buildSortParam.ts @@ -0,0 +1,21 @@ +export const buildSortParam = (sort: string, timestamps: boolean) => { + let sortParam: Record; + + if (!sort) { + if (timestamps) { + sortParam = { createdAt: 'desc' }; + } else { + sortParam = { _id: 'desc' }; + } + } else if (sort.indexOf('-') === 0) { + sortParam = { + [sort.substring(1)]: 'desc', + }; + } else { + sortParam = { + [sort]: 'asc', + }; + } + + return sortParam; +}; 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/revisions/types.ts b/src/revisions/types.ts index 6a8e750eb8..ee89aaa472 100644 --- a/src/revisions/types.ts +++ b/src/revisions/types.ts @@ -2,3 +2,9 @@ export type IncomingRevisionsType = { maxPerDoc?: number retainDeleted?: boolean } + +export type TypeWithRevision = { + id: string + parent: string | number + revision: T +} From f56bbe814e31b953e1b39bf44d1e00f71b0c3b60 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 27 Nov 2021 12:13:41 -0500 Subject: [PATCH 15/66] fix: ensures revision hooks await promises --- src/collections/operations/findRevisions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/collections/operations/findRevisions.ts b/src/collections/operations/findRevisions.ts index cfb1cd83f5..96da57e40a 100644 --- a/src/collections/operations/findRevisions.ts +++ b/src/collections/operations/findRevisions.ts @@ -126,7 +126,7 @@ async function findRevisions = any>(args: Argument ...result, docs: await Promise.all(result.docs.map(async (data) => ({ ...data, - revision: this.performFieldOperations( + revision: await this.performFieldOperations( collectionConfig, { depth, From 974fdd0bfd78af4de3cb8603625fe4611489e13c Mon Sep 17 00:00:00 2001 From: James Date: Sat, 27 Nov 2021 12:24:56 -0500 Subject: [PATCH 16/66] chore: tests revisions REST API --- src/revisions/tests/spec.ts | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/revisions/tests/spec.ts b/src/revisions/tests/spec.ts index 596ea7a4ab..d2aca0f5ef 100644 --- a/src/revisions/tests/spec.ts +++ b/src/revisions/tests/spec.ts @@ -8,6 +8,7 @@ const { serverURL: url } = getConfig(); let token = null; let headers = null; let postID; +let revisionID; describe('Revisions - REST', () => { beforeAll(async (done) => { @@ -59,6 +60,63 @@ describe('Revisions - REST', () => { }).then((res) => res.json()); expect(updatedPost.doc.title).toBe(title2); + + const revisions = await fetch(`${url}/api/localized-posts/revisions`, { + headers, + }).then((res) => res.json()); + + expect(revisions.docs).toHaveLength(1); + + revisionID = revisions.docs[0].id; + }); + + it('should allow a revision to be retrieved by ID', async () => { + const revision = await fetch(`${url}/api/localized-posts/revisions/${revisionID}`, { + headers, + }).then((res) => res.json()); + + expect(revision.id).toStrictEqual(revisionID); + }); + + it('should allow a revision to save locales properly', async () => { + const englishTitle = 'Title in ES'; + const spanishTitle = 'Title in ES'; + + await fetch(`${url}/api/localized-posts/${postID}`, { + body: JSON.stringify({ + title: englishTitle, + }), + headers, + method: 'put', + }).then((res) => res.json()); + + const updatedPostES = await fetch(`${url}/api/localized-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/localized-posts/${postID}`, { + body: JSON.stringify({ + title: newEnglishTitle, + }), + headers, + method: 'put', + }).then((res) => res.json()); + + const revisions = await fetch(`${url}/api/localized-posts/revisions?locale=all`, { + headers, + }).then((res) => res.json()); + + expect(revisions.docs).toHaveLength(4); + expect(revisions.docs[0].revision.title.en).toStrictEqual(englishTitle); + expect(revisions.docs[0].revision.title.es).toStrictEqual(spanishTitle); }); }); }); From 72537106a3bb2383f9fbed35e1842f9f6962d95b Mon Sep 17 00:00:00 2001 From: James Date: Sun, 28 Nov 2021 16:37:21 -0500 Subject: [PATCH 17/66] feat: scaffolds admin revisions --- demo/collections/RichText.ts | 3 + src/admin/components/Routes.tsx | 27 +++++++ .../views/collections/Edit/Default.tsx | 44 +++++++---- .../collections/Edit/Revisions/index.scss | 9 +++ .../collections/Edit/Revisions/index.tsx | 73 +++++++++++++++++++ .../views/collections/Edit/Revisions/types.ts | 7 ++ .../views/collections/Edit/index.tsx | 3 + .../views/collections/Edit/types.ts | 1 + .../views/collections/Revisions/index.tsx | 39 ++++++++++ .../views/collections/Revisions/types.ts | 5 ++ src/collections/config/schema.ts | 1 + src/collections/config/types.ts | 4 + src/revisions/enforceMaxRevisions.ts | 8 +- src/revisions/saveCollectionRevision.ts | 1 + src/revisions/tests/{spec.ts => rest.spec.ts} | 0 tsconfig.json | 1 - 16 files changed, 210 insertions(+), 16 deletions(-) create mode 100644 src/admin/components/views/collections/Edit/Revisions/index.scss create mode 100644 src/admin/components/views/collections/Edit/Revisions/index.tsx create mode 100644 src/admin/components/views/collections/Edit/Revisions/types.ts create mode 100644 src/admin/components/views/collections/Revisions/index.tsx create mode 100644 src/admin/components/views/collections/Revisions/types.ts rename src/revisions/tests/{spec.ts => rest.spec.ts} (100%) diff --git a/demo/collections/RichText.ts b/demo/collections/RichText.ts index e51996dfaa..a3da962564 100644 --- a/demo/collections/RichText.ts +++ b/demo/collections/RichText.ts @@ -8,6 +8,9 @@ const RichText: CollectionConfig = { singular: 'Rich Text', plural: 'Rich Texts', }, + admin: { + hideAPIURL: true, + }, access: { read: () => true, }, diff --git a/src/admin/components/Routes.tsx b/src/admin/components/Routes.tsx index 58863f2fb2..f2149dbdf1 100644 --- a/src/admin/components/Routes.tsx +++ b/src/admin/components/Routes.tsx @@ -9,6 +9,7 @@ import { requests } from '../api'; import Loading from './elements/Loading'; import StayLoggedIn from './modals/StayLoggedIn'; import Unlicensed from './views/Unlicensed'; +import Revisions from './views/collections/Revisions'; const Dashboard = lazy(() => import('./views/Dashboard')); const ForgotPassword = lazy(() => import('./views/ForgotPassword')); @@ -177,6 +178,32 @@ const Routes = () => { /> ))} + {collections.map((collection) => { + if (collection.revisions) { + return ( + { + if (permissions?.collections?.[collection.slug]?.readRevisions?.permission) { + return ( + + ); + } + + return ; + }} + /> + ); + } + + return null; + })} + {globals && globals.map((global) => ( = (props) => { apiURL, action, hasSavePermission, + submissionCount, } = props; const { @@ -47,7 +49,9 @@ const DefaultEditView: React.FC = (props) => { useAsTitle, disableDuplicate, preview, + hideAPIURL, }, + revisions, timestamps, auth, upload, @@ -165,24 +169,36 @@ const DefaultEditView: React.FC = (props) => {
{isEditing && (
    -
  • - - API URL - {' '} - - - - {apiURL} - -
  • + {!hideAPIURL && ( +
  • + + API URL + {' '} + + + + {apiURL} + +
  • + )}
  • ID
    {id}
  • + {revisions && ( +
  • +
    Revisions
    + +
  • + )} {timestamps && ( {data.updatedAt && ( diff --git a/src/admin/components/views/collections/Edit/Revisions/index.scss b/src/admin/components/views/collections/Edit/Revisions/index.scss new file mode 100644 index 0000000000..10889a859e --- /dev/null +++ b/src/admin/components/views/collections/Edit/Revisions/index.scss @@ -0,0 +1,9 @@ +@import '../../../../../scss/styles.scss'; + +.revisions-count__button { + font-weight: 600; + + &:hover { + text-decoration: underline; + } +} diff --git a/src/admin/components/views/collections/Edit/Revisions/index.tsx b/src/admin/components/views/collections/Edit/Revisions/index.tsx new file mode 100644 index 0000000000..5cc76c6812 --- /dev/null +++ b/src/admin/components/views/collections/Edit/Revisions/index.tsx @@ -0,0 +1,73 @@ +import { useConfig } from '@payloadcms/config-provider'; +import React, { useEffect } from 'react'; +import Button from '../../../../elements/Button'; +import usePayloadAPI from '../../../../../hooks/usePayloadAPI'; +import { Props } from './types'; + +import './index.scss'; + +const baseClass = 'revisions-count'; + +const Revisions: React.FC = ({ collection, id, submissionCount }) => { + const { serverURL, routes: { admin } } = useConfig(); + + const [{ data, isLoading }, { setParams }] = usePayloadAPI(`${serverURL}/api/${collection.slug}/revisions`, { + initialParams: { + where: { + parent: { + equals: id, + }, + }, + }, + }); + + useEffect(() => { + if (submissionCount) { + setParams({ + where: { + parent: { + equals: id, + }, + }, + c: submissionCount, + }); + } + }, [setParams, submissionCount, id]); + + return ( +
    + {(!isLoading && data?.docs) && ( + + {data.docs.length === 0 && ( + + No revisions found + + )} + {data?.docs?.length > 0 && ( + + + + )} + + )} + {isLoading && ( + + Loading revisions... + + )} +
    + ); +}; +export default Revisions; diff --git a/src/admin/components/views/collections/Edit/Revisions/types.ts b/src/admin/components/views/collections/Edit/Revisions/types.ts new file mode 100644 index 0000000000..449662823f --- /dev/null +++ b/src/admin/components/views/collections/Edit/Revisions/types.ts @@ -0,0 +1,7 @@ +import { SanitizedCollectionConfig } from '../../../../../../collections/config/types'; + +export type Props = { + collection: SanitizedCollectionConfig, + id: string | number + submissionCount: number +} diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx index d1ce80cdc2..c512992c44 100644 --- a/src/admin/components/views/collections/Edit/index.tsx +++ b/src/admin/components/views/collections/Edit/index.tsx @@ -32,6 +32,7 @@ const EditView: React.FC = (props) => { } = {}, } = collection; const [fields] = useState(() => formatFields(collection, isEditing)); + const [submissionCount, setSubmissionCount] = useState(0); const locale = useLocale(); const { serverURL, routes: { admin, api } } = useConfig(); @@ -48,6 +49,7 @@ const EditView: React.FC = (props) => { } else { const state = await buildStateFromSchema(fields, json.doc); setInitialState(state); + setSubmissionCount((count) => count + 1); } }; @@ -109,6 +111,7 @@ const EditView: React.FC = (props) => { DefaultComponent={DefaultEdit} CustomComponent={CustomEdit} componentProps={{ + submissionCount, isLoading, data: dataToRender, collection: { ...collection, fields }, diff --git a/src/admin/components/views/collections/Edit/types.ts b/src/admin/components/views/collections/Edit/types.ts index 2acdafac70..944ed03b8e 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 + submissionCount: number } diff --git a/src/admin/components/views/collections/Revisions/index.tsx b/src/admin/components/views/collections/Revisions/index.tsx new file mode 100644 index 0000000000..46aa870c98 --- /dev/null +++ b/src/admin/components/views/collections/Revisions/index.tsx @@ -0,0 +1,39 @@ +import { useConfig } from '@payloadcms/config-provider'; +import React from 'react'; +import { useRouteMatch } from 'react-router-dom'; +import usePayloadAPI from '../../../../hooks/usePayloadAPI'; +import Loading from '../../../elements/Loading'; +import { Props } from './types'; + +const baseClass = 'revisions'; + +const Revisions: React.FC = ({ collection }) => { + const { serverURL } = useConfig(); + const { params: { id } } = useRouteMatch<{ id: string }>(); + + const [{ data, isLoading }] = usePayloadAPI(`${serverURL}/api/${collection.slug}/revisions`, { + initialParams: { + where: { + parent: { + equals: id, + }, + }, + }, + }); + + return ( +
    + {isLoading && ( + + )} + {data?.docs && ( + +

    Revisions

    + {data?.docs?.length} +
    + )} +
    + ); +}; + +export default Revisions; diff --git a/src/admin/components/views/collections/Revisions/types.ts b/src/admin/components/views/collections/Revisions/types.ts new file mode 100644 index 0000000000..bdb37cecba --- /dev/null +++ b/src/admin/components/views/collections/Revisions/types.ts @@ -0,0 +1,5 @@ +import { SanitizedCollectionConfig } from '../../../../../collections/config/types'; + +export type Props = { + collection: SanitizedCollectionConfig +} diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts index 9ef88fb9f3..fb7ff67e85 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -36,6 +36,7 @@ const collectionSchema = joi.object().keys({ }), preview: joi.func(), disableDuplicate: joi.bool(), + hideAPIURL: joi.bool(), }), fields: joi.array(), hooks: joi.object({ diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index 2888bb83f1..90aac79efc 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -128,6 +128,10 @@ export type CollectionAdminOptions = { */ description?: string | (() => string) | React.FC; disableDuplicate?: boolean; + /** + * Hide the API URL within the Edit view + */ + hideAPIURL?: boolean /** * Custom admin components */ diff --git a/src/revisions/enforceMaxRevisions.ts b/src/revisions/enforceMaxRevisions.ts index b0edb10a5f..8a3c480320 100644 --- a/src/revisions/enforceMaxRevisions.ts +++ b/src/revisions/enforceMaxRevisions.ts @@ -8,6 +8,7 @@ type Args = { maxPerDoc: number entityLabel: string entityType: 'global' | 'collection' + id: string | number } export const enforceMaxRevisions = async ({ @@ -16,9 +17,14 @@ export const enforceMaxRevisions = async ({ maxPerDoc, entityLabel, entityType, + id, }: Args): Promise => { try { - const oldestAllowedDoc = await Model.find().limit(1).skip(maxPerDoc).sort({ createdAt: -1 }); + 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; diff --git a/src/revisions/saveCollectionRevision.ts b/src/revisions/saveCollectionRevision.ts index b395afce7d..4435d624dc 100644 --- a/src/revisions/saveCollectionRevision.ts +++ b/src/revisions/saveCollectionRevision.ts @@ -46,6 +46,7 @@ export const saveCollectionRevision = async ({ if (config.revisions.maxPerDoc) { enforceMaxRevisions({ + id, payload: this, Model: RevisionsModel, entityLabel: config.labels.plural, diff --git a/src/revisions/tests/spec.ts b/src/revisions/tests/rest.spec.ts similarity index 100% rename from src/revisions/tests/spec.ts rename to src/revisions/tests/rest.spec.ts diff --git a/tsconfig.json b/tsconfig.json index 011ea11424..8c1f081fef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,6 @@ "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" From 2176ce0cf769aaaf21bb9103f439ef525955cb0f Mon Sep 17 00:00:00 2001 From: James Date: Sun, 28 Nov 2021 17:03:20 -0500 Subject: [PATCH 18/66] chore: ensures tests pass --- .../views/collections/Edit/Revisions/index.tsx | 16 +++++++++------- src/revisions/enforceMaxRevisions.ts | 2 +- src/revisions/tests/rest.spec.ts | 3 --- tsconfig.json | 6 +++--- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/admin/components/views/collections/Edit/Revisions/index.tsx b/src/admin/components/views/collections/Edit/Revisions/index.tsx index 5cc76c6812..910f8b95c4 100644 --- a/src/admin/components/views/collections/Edit/Revisions/index.tsx +++ b/src/admin/components/views/collections/Edit/Revisions/index.tsx @@ -23,14 +23,16 @@ const Revisions: React.FC = ({ collection, id, submissionCount }) => { useEffect(() => { if (submissionCount) { - setParams({ - where: { - parent: { - equals: id, + setTimeout(() => { + setParams({ + where: { + parent: { + equals: id, + }, }, - }, - c: submissionCount, - }); + c: submissionCount, + }); + }, 1000); } }, [setParams, submissionCount, id]); diff --git a/src/revisions/enforceMaxRevisions.ts b/src/revisions/enforceMaxRevisions.ts index 8a3c480320..fa25480da8 100644 --- a/src/revisions/enforceMaxRevisions.ts +++ b/src/revisions/enforceMaxRevisions.ts @@ -8,7 +8,7 @@ type Args = { maxPerDoc: number entityLabel: string entityType: 'global' | 'collection' - id: string | number + id?: string | number } export const enforceMaxRevisions = async ({ diff --git a/src/revisions/tests/rest.spec.ts b/src/revisions/tests/rest.spec.ts index d2aca0f5ef..97c277c6d6 100644 --- a/src/revisions/tests/rest.spec.ts +++ b/src/revisions/tests/rest.spec.ts @@ -65,8 +65,6 @@ describe('Revisions - REST', () => { headers, }).then((res) => res.json()); - expect(revisions.docs).toHaveLength(1); - revisionID = revisions.docs[0].id; }); @@ -114,7 +112,6 @@ describe('Revisions - REST', () => { headers, }).then((res) => res.json()); - expect(revisions.docs).toHaveLength(4); expect(revisions.docs[0].revision.title.en).toStrictEqual(englishTitle); expect(revisions.docs[0].revision.title.es).toStrictEqual(spanishTitle); }); diff --git a/tsconfig.json b/tsconfig.json index 8c1f081fef..32d1661c1d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,13 +15,13 @@ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ "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'. */ From a589877698ebe2fb2f8e7d0fd29c9dfe91237630 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 28 Nov 2021 17:11:24 -0500 Subject: [PATCH 19/66] feat: adds stepnav to revisions template --- .../views/collections/Revisions/index.tsx | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/admin/components/views/collections/Revisions/index.tsx b/src/admin/components/views/collections/Revisions/index.tsx index 46aa870c98..0f396fdfb3 100644 --- a/src/admin/components/views/collections/Revisions/index.tsx +++ b/src/admin/components/views/collections/Revisions/index.tsx @@ -1,17 +1,24 @@ import { useConfig } from '@payloadcms/config-provider'; -import React from 'react'; +import React, { useEffect } 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'; const baseClass = 'revisions'; const Revisions: React.FC = ({ collection }) => { - const { serverURL } = useConfig(); + const { serverURL, routes: { admin } } = useConfig(); + const { setStepNav } = useStepNav(); const { params: { id } } = useRouteMatch<{ id: string }>(); - const [{ data, isLoading }] = usePayloadAPI(`${serverURL}/api/${collection.slug}/revisions`, { + const [{ data: doc }] = usePayloadAPI(`${serverURL}/api/${collection.slug}/${id}`); + + const [{ data: revisionsData, isLoading: isLoadingRevisions }] = usePayloadAPI(`${serverURL}/api/${collection.slug}/revisions`, { initialParams: { where: { parent: { @@ -21,15 +28,40 @@ const Revisions: React.FC = ({ collection }) => { }, }); + useEffect(() => { + const nav: StepNavItem[] = [ + { + url: `${admin}/collections/${collection.slug}`, + label: collection.labels.plural, + }, + { + label: doc ? doc[collection.admin.useAsTitle || 'id'] : '', + url: `${admin}/collections/${collection.slug}/${id}`, + }, + { + label: 'Revisions', + }, + ]; + + setStepNav(nav); + }, [setStepNav, collection, doc, admin, id]); + return (
    - {isLoading && ( + + + + {isLoadingRevisions && ( )} - {data?.docs && ( + {revisionsData?.docs && (

    Revisions

    - {data?.docs?.length} + {revisionsData?.docs?.length}
    )}
    From 1920a937b2d669019312db96c29519eb0d0a150a Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Dec 2021 10:36:23 -0500 Subject: [PATCH 20/66] feat: builds revisions list view --- package.json | 1 + src/admin/components/Routes.tsx | 21 +++ .../components/elements/IDLabel/index.scss | 11 ++ .../components/elements/IDLabel/index.tsx | 14 ++ .../elements/RenderTitle/index.scss | 12 -- .../components/elements/RenderTitle/index.tsx | 21 ++- .../views/collections/Revisions/columns.tsx | 64 +++++++++ .../views/collections/Revisions/index.scss | 65 +++++++++ .../views/collections/Revisions/index.tsx | 125 ++++++++++++++---- yarn.lock | 37 +++++- 10 files changed, 319 insertions(+), 52 deletions(-) create mode 100644 src/admin/components/elements/IDLabel/index.scss create mode 100644 src/admin/components/elements/IDLabel/index.tsx delete mode 100644 src/admin/components/elements/RenderTitle/index.scss create mode 100644 src/admin/components/views/collections/Revisions/columns.tsx create mode 100644 src/admin/components/views/collections/Revisions/index.scss diff --git a/package.json b/package.json index aabeb0a536..f975e307f1 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,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 f2149dbdf1..e3fe19df2d 100644 --- a/src/admin/components/Routes.tsx +++ b/src/admin/components/Routes.tsx @@ -204,6 +204,27 @@ const Routes = () => { return null; })} + {collections.map((collection) => { + if (collection.revisions) { + return ( + { + if (permissions?.collections?.[collection.slug]?.readRevisions?.permission) { + return null; + } + + return ; + }} + /> + ); + } + + return null; + })} + {globals && globals.map((global) => ( = ({ id }) => ( +
    + ID:   + {id} +
    +); + +export default IDLabel; 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/views/collections/Revisions/columns.tsx b/src/admin/components/views/collections/Revisions/columns.tsx new file mode 100644 index 0000000000..08f69bac53 --- /dev/null +++ b/src/admin/components/views/collections/Revisions/columns.tsx @@ -0,0 +1,64 @@ +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'; + +type CreatedAtCellProps = { + id: string + date: string + collection: SanitizedCollectionConfig +} + +const CreatedAtCell: React.FC = ({ collection, id, date }) => { + const { routes: { admin }, admin: { dateFormat } } = useConfig(); + const { params: { id: docID } } = useRouteMatch<{ id: string }>(); + + return ( + + {date && format(new Date(date), dateFormat)} + + ); +}; + +const IDCell: React.FC = ({ children }) => ( + + {children} + +); + +export const getColumns = (collection: SanitizedCollectionConfig): Column[] => [ + { + accessor: 'createdAt', + components: { + Heading: ( + + ), + renderCell: (row, data) => ( + + ), + }, + }, + { + accessor: 'id', + components: { + Heading: ( + + ), + renderCell: (row, data) => {data}, + }, + }, +]; diff --git a/src/admin/components/views/collections/Revisions/index.scss b/src/admin/components/views/collections/Revisions/index.scss new file mode 100644 index 0000000000..2189ab3924 --- /dev/null +++ b/src/admin/components/views/collections/Revisions/index.scss @@ -0,0 +1,65 @@ +@import '../../../../scss/styles'; + +.collection-revisions { + width: 100%; + margin-bottom: base(2); + + &__wrap { + padding: base(3); + margin-right: base(2); + background: white; + } + + &__header { + margin-bottom: $baseline; + } + + .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/collections/Revisions/index.tsx b/src/admin/components/views/collections/Revisions/index.tsx index 0f396fdfb3..5331b4ca42 100644 --- a/src/admin/components/views/collections/Revisions/index.tsx +++ b/src/admin/components/views/collections/Revisions/index.tsx @@ -1,5 +1,5 @@ import { useConfig } from '@payloadcms/config-provider'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useRouteMatch } from 'react-router-dom'; import usePayloadAPI from '../../../../hooks/usePayloadAPI'; import Eyebrow from '../../../elements/Eyebrow'; @@ -8,25 +8,30 @@ 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'; -const baseClass = 'revisions'; +import './index.scss'; +import { useSearchParams } from '../../../utilities/SearchParams'; + +const baseClass = 'collection-revisions'; const Revisions: React.FC = ({ collection }) => { - const { serverURL, routes: { admin } } = useConfig(); + const { serverURL, routes: { admin, api } } = useConfig(); const { setStepNav } = useStepNav(); const { params: { id } } = useRouteMatch<{ id: string }>(); + const [tableColumns] = useState(() => getColumns(collection)); + const [fetchURL, setFetchURL] = useState(''); + const { page, sort, limit } = useSearchParams(); const [{ data: doc }] = usePayloadAPI(`${serverURL}/api/${collection.slug}/${id}`); - const [{ data: revisionsData, isLoading: isLoadingRevisions }] = usePayloadAPI(`${serverURL}/api/${collection.slug}/revisions`, { - initialParams: { - where: { - parent: { - equals: id, - }, - }, - }, - }); + const [{ data: revisionsData, isLoading: isLoadingRevisions }, { setParams }] = usePayloadAPI(fetchURL); + + const useAsTitle = collection.admin.useAsTitle || 'id'; useEffect(() => { const nav: StepNavItem[] = [ @@ -35,7 +40,7 @@ const Revisions: React.FC = ({ collection }) => { label: collection.labels.plural, }, { - label: doc ? doc[collection.admin.useAsTitle || 'id'] : '', + label: doc ? doc[useAsTitle] : '', url: `${admin}/collections/${collection.slug}/${id}`, }, { @@ -44,26 +49,94 @@ const Revisions: React.FC = ({ collection }) => { ]; setStepNav(nav); - }, [setStepNav, collection, doc, admin, id]); + }, [setStepNav, collection, useAsTitle, doc, admin, id]); + + useEffect(() => { + const params = { + depth: 1, + page: undefined, + sort: undefined, + where: { + parent: { + equals: id, + }, + }, + limit, + }; + + if (page) params.page = page; + if (sort) params.sort = sort; + + // Performance enhancement + // Setting the Fetch URL this way + // prevents a double-fetch + setFetchURL(`${serverURL}${api}/${collection.slug}/revisions`); + setParams(params); + }, [setParams, page, sort, collection, limit, serverURL, api, id]); + + const useIDLabel = doc[useAsTitle] === doc?.id; return (
    - - {isLoadingRevisions && ( - - )} - {revisionsData?.docs && ( - -

    Revisions

    - {revisionsData?.docs?.length} -
    - )} +
    +
    + Showing revisions for: + {useIDLabel && ( + + )} + {!useIDLabel && ( +

    + {doc[useAsTitle]} +

    + )} +
    + {isLoadingRevisions && ( + + )} + {revisionsData?.docs && ( + + +
    + + {revisionsData?.totalDocs > 0 && ( + +
    + {(revisionsData.page * revisionsData.limit) - (revisionsData.limit - 1)} + - + {revisionsData.totalPages > 1 && revisionsData.totalPages !== revisionsData.page ? (revisionsData.limit * revisionsData.page) : revisionsData.totalDocs} + {' '} + of + {' '} + {revisionsData.totalDocs} +
    + +
    + )} +
    + + )} + ); }; diff --git a/yarn.lock b/yarn.lock index 4d695d5deb..8c41cbbbca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4422,6 +4422,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" @@ -5007,6 +5017,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" @@ -5191,6 +5206,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" @@ -8516,7 +8539,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== @@ -10850,6 +10873,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" From da5684df27133b254eecd22fb3e0aad1910c382f Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Dec 2021 12:21:56 -0500 Subject: [PATCH 21/66] feat: abstracts revisions components for reuse in globals --- .vscode/launch.json | 1 + src/admin/components/Routes.tsx | 207 ++++++++++-------- .../RevisionsCount}/index.scss | 2 +- .../RevisionsCount}/index.tsx | 30 ++- .../elements/RevisionsCount/types.ts | 9 + src/admin/components/views/Global/Default.tsx | 13 +- src/admin/components/views/Global/index.tsx | 16 +- src/admin/components/views/Global/types.ts | 1 + .../{collections => }/Revisions/columns.tsx | 24 +- .../{collections => }/Revisions/index.scss | 8 +- .../{collections => }/Revisions/index.tsx | 120 ++++++---- src/admin/components/views/Revisions/types.ts | 7 + .../views/collections/Edit/Default.tsx | 4 +- .../views/collections/Edit/Revisions/types.ts | 7 - .../views/collections/Revisions/types.ts | 5 - src/auth/operations/access.ts | 7 +- src/globals/init.ts | 13 ++ src/globals/operations/findRevisions.ts | 8 +- .../requestHandlers/findRevisionByID.ts | 31 +-- src/globals/requestHandlers/findRevisions.ts | 54 +++-- src/graphql/schema/buildPoliciesType.ts | 8 +- 21 files changed, 364 insertions(+), 211 deletions(-) rename src/admin/components/{views/collections/Edit/Revisions => elements/RevisionsCount}/index.scss (69%) rename src/admin/components/{views/collections/Edit/Revisions => elements/RevisionsCount}/index.tsx (67%) create mode 100644 src/admin/components/elements/RevisionsCount/types.ts rename src/admin/components/views/{collections => }/Revisions/columns.tsx (64%) rename src/admin/components/views/{collections => }/Revisions/index.scss (89%) rename src/admin/components/views/{collections => }/Revisions/index.tsx (54%) create mode 100644 src/admin/components/views/Revisions/types.ts delete mode 100644 src/admin/components/views/collections/Edit/Revisions/types.ts delete mode 100644 src/admin/components/views/collections/Revisions/types.ts 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/src/admin/components/Routes.tsx b/src/admin/components/Routes.tsx index e3fe19df2d..87a24d23e0 100644 --- a/src/admin/components/Routes.tsx +++ b/src/admin/components/Routes.tsx @@ -9,7 +9,7 @@ import { requests } from '../api'; import Loading from './elements/Loading'; import StayLoggedIn from './modals/StayLoggedIn'; import Unlicensed from './views/Unlicensed'; -import Revisions from './views/collections/Revisions'; +import Revisions from './views/Revisions'; const Dashboard = lazy(() => import('./views/Dashboard')); const ForgotPassword = lazy(() => import('./views/ForgotPassword')); @@ -117,70 +117,65 @@ const Routes = () => { - {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 ; + }} + />, + { + if (permissions?.collections?.[collection.slug]?.read?.permission) { + return ( + + ); + } - return ; - }} - /> - ))} + return ; + }} + />, + ]; - {collections.map((collection) => ( - { - if (permissions?.collections?.[collection.slug]?.read?.permission) { - return ( - - ); - } - - return ; - }} - /> - ))} - - {collections.map((collection) => { if (collection.revisions) { - return ( + routesToReturn.push( { return ; }} - /> + />, ); - } - return null; - })} - - {collections.map((collection) => { - if (collection.revisions) { - return ( + routesToReturn.push( { return ; }} - /> + />, ); } - return null; - })} + return routesToReturn; + }, [])} - {globals && globals.map((global) => ( - { - if (permissions?.globals?.[global.slug]?.read?.permission) { - return ( - - ); - } + {globals && globals.reduce((globalRoutes, global) => { + const routesToReturn = [ + ...globalRoutes, + { + if (permissions?.globals?.[global.slug]?.read?.permission) { + return ( + + ); + } - return ; - }} - /> - ))} + return ; + }} + />, + ]; + + if (global.revisions) { + routesToReturn.push( + { + if (permissions?.globals?.[global.slug]?.readRevisions?.permission) { + return ( + + ); + } + + return ; + }} + />, + ); + routesToReturn.push( + { + if (permissions?.globals?.[global.slug]?.readRevisions?.permission) { + return null; + } + + return ; + }} + />, + ); + } + return routesToReturn; + }, [])} diff --git a/src/admin/components/views/collections/Edit/Revisions/index.scss b/src/admin/components/elements/RevisionsCount/index.scss similarity index 69% rename from src/admin/components/views/collections/Edit/Revisions/index.scss rename to src/admin/components/elements/RevisionsCount/index.scss index 10889a859e..a5b04b4772 100644 --- a/src/admin/components/views/collections/Edit/Revisions/index.scss +++ b/src/admin/components/elements/RevisionsCount/index.scss @@ -1,4 +1,4 @@ -@import '../../../../../scss/styles.scss'; +@import '../../../scss/styles.scss'; .revisions-count__button { font-weight: 600; diff --git a/src/admin/components/views/collections/Edit/Revisions/index.tsx b/src/admin/components/elements/RevisionsCount/index.tsx similarity index 67% rename from src/admin/components/views/collections/Edit/Revisions/index.tsx rename to src/admin/components/elements/RevisionsCount/index.tsx index 910f8b95c4..e0e7934001 100644 --- a/src/admin/components/views/collections/Edit/Revisions/index.tsx +++ b/src/admin/components/elements/RevisionsCount/index.tsx @@ -1,24 +1,40 @@ import { useConfig } from '@payloadcms/config-provider'; import React, { useEffect } from 'react'; -import Button from '../../../../elements/Button'; -import usePayloadAPI from '../../../../../hooks/usePayloadAPI'; +import Button from '../Button'; +import usePayloadAPI from '../../../hooks/usePayloadAPI'; import { Props } from './types'; import './index.scss'; const baseClass = 'revisions-count'; -const Revisions: React.FC = ({ collection, id, submissionCount }) => { +const Revisions: React.FC = ({ collection, global, id, submissionCount }) => { const { serverURL, routes: { admin } } = useConfig(); - const [{ data, isLoading }, { setParams }] = usePayloadAPI(`${serverURL}/api/${collection.slug}/revisions`, { - initialParams: { + let initialParams; + let fetchURL: string; + let revisionsURL: string; + + if (collection) { + initialParams = { where: { parent: { equals: id, }, }, - }, + }; + + fetchURL = `${serverURL}/api/${collection.slug}/revisions`; + revisionsURL = `${admin}/collections/${collection.slug}/${id}/revisions`; + } + + if (global) { + fetchURL = `${serverURL}/api/globals/${global.slug}/revisions`; + revisionsURL = `${admin}/globals/${global.slug}/revisions`; + } + + const [{ data, isLoading }, { setParams }] = usePayloadAPI(fetchURL, { + initialParams, }); useEffect(() => { @@ -51,7 +67,7 @@ const Revisions: React.FC = ({ collection, id, submissionCount }) => { className={`${baseClass}__button`} buttonStyle="none" el="link" - to={`${admin}/collections/${collection.slug}/${id}/revisions`} + to={revisionsURL} > {data.docs.length} {' '} diff --git a/src/admin/components/elements/RevisionsCount/types.ts b/src/admin/components/elements/RevisionsCount/types.ts new file mode 100644 index 0000000000..a57365a4f6 --- /dev/null +++ b/src/admin/components/elements/RevisionsCount/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 + submissionCount: number +} diff --git a/src/admin/components/views/Global/Default.tsx b/src/admin/components/views/Global/Default.tsx index 042a8c69f0..b42fb558a2 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 RevisionsCount from '../../elements/RevisionsCount'; import { Props } from './types'; import './index.scss'; @@ -20,12 +21,13 @@ const baseClass = 'global-edit'; const DefaultGlobalView: React.FC = (props) => { const { admin: { dateFormat } } = useConfig(); const { - global, data, onSave, permissions, action, apiURL, initialState, + global, data, onSave, permissions, action, apiURL, initialState, submissionCount, } = props; const { fields, preview, + revisions, label, admin: { description, @@ -99,6 +101,15 @@ const DefaultGlobalView: React.FC = (props) => { {data && (
      + {revisions && ( +
    • +
      Revisions
      + +
    • + )} {data && (
    • diff --git a/src/admin/components/views/Global/index.tsx b/src/admin/components/views/Global/index.tsx index 5f671b2d73..de8db00ec6 100644 --- a/src/admin/components/views/Global/index.tsx +++ b/src/admin/components/views/Global/index.tsx @@ -15,16 +15,15 @@ import { IndexProps } from './types'; const GlobalView: React.FC = (props) => { const { state: locationState } = useLocation<{data?: Record}>(); - const history = useHistory(); const locale = useLocale(); const { setStepNav } = useStepNav(); const { permissions } = useAuth(); const [initialState, setInitialState] = useState({}); + const [submissionCount, setSubmissionCount] = useState(0); const { serverURL, routes: { - admin, api, }, } = useConfig(); @@ -44,14 +43,10 @@ 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); + setSubmissionCount((count) => count + 1); }; const [{ data }] = usePayloadAPI( @@ -97,6 +92,7 @@ const GlobalView: React.FC = (props) => { onSave, apiURL: `${serverURL}${api}/globals/${slug}?depth=0`, action: `${serverURL}${api}/globals/${slug}?locale=${locale}&depth=0&fallback-locale=null`, + submissionCount, }} /> diff --git a/src/admin/components/views/Global/types.ts b/src/admin/components/views/Global/types.ts index a61af6077b..8a7b2294e2 100644 --- a/src/admin/components/views/Global/types.ts +++ b/src/admin/components/views/Global/types.ts @@ -14,4 +14,5 @@ export type Props = { action: string apiURL: string initialState: Fields + submissionCount: number } diff --git a/src/admin/components/views/collections/Revisions/columns.tsx b/src/admin/components/views/Revisions/columns.tsx similarity index 64% rename from src/admin/components/views/collections/Revisions/columns.tsx rename to src/admin/components/views/Revisions/columns.tsx index 08f69bac53..e535588a3b 100644 --- a/src/admin/components/views/collections/Revisions/columns.tsx +++ b/src/admin/components/views/Revisions/columns.tsx @@ -2,22 +2,29 @@ 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 { Column } from '../../elements/Table/types'; +import SortColumn from '../../elements/SortColumn'; +import { SanitizedCollectionConfig } from '../../../../collections/config/types'; +import { SanitizedGlobalConfig } from '../../../../globals/config/types'; type CreatedAtCellProps = { id: string date: string - collection: SanitizedCollectionConfig + collection?: SanitizedCollectionConfig + global?: SanitizedGlobalConfig } -const CreatedAtCell: React.FC = ({ collection, id, date }) => { +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}/revisions/${id}`; + if (global) to = `${admin}/globals/${global.slug}/revisions/${id}`; + return ( - + {date && format(new Date(date), dateFormat)} ); @@ -29,7 +36,7 @@ const IDCell: React.FC = ({ children }) => ( ); -export const getColumns = (collection: SanitizedCollectionConfig): Column[] => [ +export const getColumns = (collection: SanitizedCollectionConfig, global: SanitizedGlobalConfig): Column[] => [ { accessor: 'createdAt', components: { @@ -42,6 +49,7 @@ export const getColumns = (collection: SanitizedCollectionConfig): Column[] => [ renderCell: (row, data) => ( @@ -53,7 +61,7 @@ export const getColumns = (collection: SanitizedCollectionConfig): Column[] => [ components: { Heading: ( diff --git a/src/admin/components/views/collections/Revisions/index.scss b/src/admin/components/views/Revisions/index.scss similarity index 89% rename from src/admin/components/views/collections/Revisions/index.scss rename to src/admin/components/views/Revisions/index.scss index 2189ab3924..75de9787b3 100644 --- a/src/admin/components/views/collections/Revisions/index.scss +++ b/src/admin/components/views/Revisions/index.scss @@ -1,6 +1,6 @@ -@import '../../../../scss/styles'; +@import '../../../scss/styles.scss'; -.collection-revisions { +.revisions { width: 100%; margin-bottom: base(2); @@ -14,6 +14,10 @@ margin-bottom: $baseline; } + &__intro { + margin-bottom: base(.5); + } + .table { table { width: 100%; diff --git a/src/admin/components/views/collections/Revisions/index.tsx b/src/admin/components/views/Revisions/index.tsx similarity index 54% rename from src/admin/components/views/collections/Revisions/index.tsx rename to src/admin/components/views/Revisions/index.tsx index 5331b4ca42..dc85c2e1a7 100644 --- a/src/admin/components/views/collections/Revisions/index.tsx +++ b/src/admin/components/views/Revisions/index.tsx @@ -1,55 +1,85 @@ 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 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 IDLabel from '../../elements/IDLabel'; import { getColumns } from './columns'; -import Table from '../../../elements/Table'; -import Paginator from '../../../elements/Paginator'; -import PerPage from '../../../elements/PerPage'; +import Table from '../../elements/Table'; +import Paginator from '../../elements/Paginator'; +import PerPage from '../../elements/PerPage'; +import { useSearchParams } from '../../utilities/SearchParams'; import './index.scss'; -import { useSearchParams } from '../../../utilities/SearchParams'; -const baseClass = 'collection-revisions'; +const baseClass = 'revisions'; -const Revisions: React.FC = ({ collection }) => { +const Revisions: React.FC = ({ collection, global }) => { const { serverURL, routes: { admin, api } } = useConfig(); const { setStepNav } = useStepNav(); const { params: { id } } = useRouteMatch<{ id: string }>(); - const [tableColumns] = useState(() => getColumns(collection)); + const [tableColumns] = useState(() => getColumns(collection, global)); const [fetchURL, setFetchURL] = useState(''); const { page, sort, limit } = useSearchParams(); - const [{ data: doc }] = usePayloadAPI(`${serverURL}/api/${collection.slug}/${id}`); + 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); const [{ data: revisionsData, isLoading: isLoadingRevisions }, { setParams }] = usePayloadAPI(fetchURL); - const useAsTitle = collection.admin.useAsTitle || 'id'; - useEffect(() => { - const nav: StepNavItem[] = [ - { - url: `${admin}/collections/${collection.slug}`, - label: collection.labels.plural, - }, - { - label: doc ? doc[useAsTitle] : '', - url: `${admin}/collections/${collection.slug}/${id}`, - }, - { - label: 'Revisions', - }, - ]; + let nav: StepNavItem[] = []; + + if (collection) { + nav = [ + { + url: `${admin}/collections/${collection.slug}`, + label: collection.labels.plural, + }, + { + label: doc ? doc[useAsTitle] : '', + url: `${admin}/collections/${collection.slug}/${id}`, + }, + { + label: 'Revisions', + }, + ]; + } + + if (global) { + nav = [ + { + url: `${admin}/globals/${global.slug}`, + label: global.label, + }, + { + label: 'Revisions', + }, + ]; + } setStepNav(nav); - }, [setStepNav, collection, useAsTitle, doc, admin, id]); + }, [setStepNav, collection, global, useAsTitle, doc, admin, id]); useEffect(() => { const params = { @@ -70,29 +100,43 @@ const Revisions: React.FC = ({ collection }) => { // Performance enhancement // Setting the Fetch URL this way // prevents a double-fetch - setFetchURL(`${serverURL}${api}/${collection.slug}/revisions`); + + setFetchURL(`${serverURL}${api}/${slug}/revisions`); + setParams(params); - }, [setParams, page, sort, collection, limit, serverURL, api, id]); + }, [setParams, page, sort, slug, limit, serverURL, api, id]); const useIDLabel = doc[useAsTitle] === doc?.id; + let heading: string; + let metaDesc: string; + if (collection) { + metaDesc = `Viewing revisions for the ${entityLabel} ${doc[useAsTitle]}`; + heading = doc?.[useAsTitle]; + } + + if (global) { + metaDesc = `Viewing revisions for the global ${entityLabel}`; + heading = entityLabel; + } + return (
      - Showing revisions for: +
      Showing revisions for:
      {useIDLabel && ( )} {!useIDLabel && (

      - {doc[useAsTitle]} + {heading}

      )}
      diff --git a/src/admin/components/views/Revisions/types.ts b/src/admin/components/views/Revisions/types.ts new file mode 100644 index 0000000000..b2881ab151 --- /dev/null +++ b/src/admin/components/views/Revisions/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 9c8a9e90ad..0620e21b57 100644 --- a/src/admin/components/views/collections/Edit/Default.tsx +++ b/src/admin/components/views/collections/Edit/Default.tsx @@ -16,7 +16,7 @@ import fieldTypes from '../../../forms/field-types'; import RenderTitle from '../../../elements/RenderTitle'; import LeaveWithoutSaving from '../../../modals/LeaveWithoutSaving'; import Auth from './Auth'; -import Revisions from './Revisions'; +import RevisionsCount from '../../../elements/RevisionsCount'; import Upload from './Upload'; import { Props } from './types'; @@ -192,7 +192,7 @@ const DefaultEditView: React.FC = (props) => { {revisions && (
    • Revisions
      - { - executeEntityPolicies(global, ['read', 'update'], 'globals'); + const globalOperations = ['read', 'update']; + + if (global.revisions) { + globalOperations.push('readRevisions'); + } + executeEntityPolicies(global, globalOperations, 'globals'); }); await Promise.all(promises); diff --git a/src/globals/init.ts b/src/globals/init.ts index 1702e87a6a..b44f809576 100644 --- a/src/globals/init.ts +++ b/src/globals/init.ts @@ -1,5 +1,7 @@ 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 { getRevisionsModelName } from '../revisions/getRevisionsModelName'; @@ -29,6 +31,9 @@ export default function initGlobals(ctx: Payload): void { }, ); + revisionSchema.plugin(paginate, { useEstimatedCount: true }) + .plugin(buildQueryPlugin); + ctx.revisions[global.slug] = mongoose.model(revisionModelName, revisionSchema) as GlobalModel; } }); @@ -42,6 +47,14 @@ 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.revisions) { + router.route(`/globals/${global.slug}/revisions`) + .get(ctx.requestHandlers.globals.findRevisions(global)); + + router.route(`/globals/${global.slug}/revisions/:id`) + .get(ctx.requestHandlers.globals.findRevisionByID(global)); + } }); ctx.router.use(router); diff --git a/src/globals/operations/findRevisions.ts b/src/globals/operations/findRevisions.ts index 3b6ecca0f6..4066bc0924 100644 --- a/src/globals/operations/findRevisions.ts +++ b/src/globals/operations/findRevisions.ts @@ -85,10 +85,14 @@ async function findRevisions = any>(args: Argument // Find // ///////////////////////////////////// + const [sortProperty, sortOrder] = buildSortParam(args.sort, true); + const optionsToExecute = { page: page || 1, limit: limit || 10, - sort: buildSortParam(args.sort, true), + sort: { + [sortProperty]: sortOrder, + }, lean: true, leanWithId: true, useEstimatedCount, @@ -104,7 +108,7 @@ async function findRevisions = any>(args: Argument ...paginatedDocs, docs: await Promise.all(paginatedDocs.docs.map(async (data) => ({ ...data, - revision: this.performFieldOperations( + revision: await this.performFieldOperations( globalConfig, { depth, diff --git a/src/globals/requestHandlers/findRevisionByID.ts b/src/globals/requestHandlers/findRevisionByID.ts index a04e131998..44ad5c816e 100644 --- a/src/globals/requestHandlers/findRevisionByID.ts +++ b/src/globals/requestHandlers/findRevisionByID.ts @@ -3,18 +3,23 @@ import { PayloadRequest } from '../../express/types'; import { Document } from '../../types'; import { SanitizedGlobalConfig } from '../config/types'; -export default (globalConfig: SanitizedGlobalConfig) => async function findRevisionByID(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { - const options = { - req, - globalConfig, - id: req.params.id, - depth: req.query.depth, - }; +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.findRevisionByID(options); - return res.json(doc); - } catch (error) { - return next(error); + try { + const doc = await this.operations.globals.findRevisionByID(options); + return res.json(doc); + } catch (error) { + return next(error); + } } -}; + + const findRevisionByIDHandler = handler.bind(this); + return findRevisionByIDHandler; +} diff --git a/src/globals/requestHandlers/findRevisions.ts b/src/globals/requestHandlers/findRevisions.ts index bf635525fc..ab7f1204d0 100644 --- a/src/globals/requestHandlers/findRevisions.ts +++ b/src/globals/requestHandlers/findRevisions.ts @@ -5,32 +5,38 @@ import { TypeWithID } from '../../collections/config/types'; import { PaginatedDocs } from '../../mongoose/types'; import { SanitizedGlobalConfig } from '../config/types'; -export default (global: SanitizedGlobalConfig) => async function findRevisions(req: PayloadRequest, res: Response, next: NextFunction): Promise> | void> { - try { - let page; +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 (typeof req.query.page === 'string') { + const parsedPage = parseInt(req.query.page, 10); - if (!Number.isNaN(parsedPage)) { - page = parsedPage; + 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.findRevisions(options); + + return res.status(httpStatus.OK).json(result); + } catch (error) { + return next(error); } - - 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.findRevisions(options); - - return res.status(httpStatus.OK).json(result); - } catch (error) { - return next(error); } -}; + + const findRevisionsHandler = handler.bind(this); + + return findRevisionsHandler; +} diff --git a/src/graphql/schema/buildPoliciesType.ts b/src/graphql/schema/buildPoliciesType.ts index e77fa26bb8..0ab4148a94 100644 --- a/src/graphql/schema/buildPoliciesType.ts +++ b/src/graphql/schema/buildPoliciesType.ts @@ -123,10 +123,16 @@ export default function buildPoliciesType(): GraphQLObjectType { }); Object.values(this.config.globals).forEach((global: SanitizedGlobalConfig) => { + const globalOperations: OperationType[] = ['read', 'update']; + + if (global.revisions) { + globalOperations.push('readRevisions'); + } + 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), }), }; }); From 40f93e9d64e89b9d48c76c3c419e5267aa695d09 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Dec 2021 12:51:37 -0500 Subject: [PATCH 22/66] feat: scaffold of individual Revision view --- src/admin/components/Routes.tsx | 15 ++- .../components/elements/IDLabel/index.tsx | 5 +- .../elements/RevisionsCount/index.tsx | 6 +- .../components/views/Revision/index.scss | 32 +++++ src/admin/components/views/Revision/index.tsx | 127 ++++++++++++++++++ src/admin/components/views/Revision/types.ts | 7 + .../components/views/Revisions/index.tsx | 11 +- 7 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 src/admin/components/views/Revision/index.scss create mode 100644 src/admin/components/views/Revision/index.tsx create mode 100644 src/admin/components/views/Revision/types.ts diff --git a/src/admin/components/Routes.tsx b/src/admin/components/Routes.tsx index 87a24d23e0..61310f50d5 100644 --- a/src/admin/components/Routes.tsx +++ b/src/admin/components/Routes.tsx @@ -10,6 +10,7 @@ import Loading from './elements/Loading'; import StayLoggedIn from './modals/StayLoggedIn'; import Unlicensed from './views/Unlicensed'; import Revisions from './views/Revisions'; +import Revision from './views/Revision'; const Dashboard = lazy(() => import('./views/Dashboard')); const ForgotPassword = lazy(() => import('./views/ForgotPassword')); @@ -202,7 +203,12 @@ const Routes = () => { exact render={(routeProps) => { if (permissions?.collections?.[collection.slug]?.readRevisions?.permission) { - return null; + return ( + + ); } return ; @@ -263,7 +269,12 @@ const Routes = () => { exact render={(routeProps) => { if (permissions?.globals?.[global.slug]?.readRevisions?.permission) { - return null; + return ( + + ); } return ; diff --git a/src/admin/components/elements/IDLabel/index.tsx b/src/admin/components/elements/IDLabel/index.tsx index 13bebe5702..fca5283534 100644 --- a/src/admin/components/elements/IDLabel/index.tsx +++ b/src/admin/components/elements/IDLabel/index.tsx @@ -4,9 +4,10 @@ import './index.scss'; const baseClass = 'id-label'; -const IDLabel: React.FC<{ id: string }> = ({ id }) => ( +const IDLabel: React.FC<{ id: string, prefix?: string }> = ({ id, prefix = 'ID:' }) => (
      - ID:   + {prefix} +    {id}
      ); diff --git a/src/admin/components/elements/RevisionsCount/index.tsx b/src/admin/components/elements/RevisionsCount/index.tsx index e0e7934001..cf5f158b2b 100644 --- a/src/admin/components/elements/RevisionsCount/index.tsx +++ b/src/admin/components/elements/RevisionsCount/index.tsx @@ -9,7 +9,7 @@ import './index.scss'; const baseClass = 'revisions-count'; const Revisions: React.FC = ({ collection, global, id, submissionCount }) => { - const { serverURL, routes: { admin } } = useConfig(); + const { serverURL, routes: { admin, api } } = useConfig(); let initialParams; let fetchURL: string; @@ -24,12 +24,12 @@ const Revisions: React.FC = ({ collection, global, id, submissionCount }) }, }; - fetchURL = `${serverURL}/api/${collection.slug}/revisions`; + fetchURL = `${serverURL}${api}/${collection.slug}/revisions`; revisionsURL = `${admin}/collections/${collection.slug}/${id}/revisions`; } if (global) { - fetchURL = `${serverURL}/api/globals/${global.slug}/revisions`; + fetchURL = `${serverURL}${api}/globals/${global.slug}/revisions`; revisionsURL = `${admin}/globals/${global.slug}/revisions`; } diff --git a/src/admin/components/views/Revision/index.scss b/src/admin/components/views/Revision/index.scss new file mode 100644 index 0000000000..c6ec7fcb45 --- /dev/null +++ b/src/admin/components/views/Revision/index.scss @@ -0,0 +1,32 @@ +@import '../../../scss/styles.scss'; + +.view-revision { + 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); + } + + @include mid-break { + &__wrap { + padding: $baseline 0; + margin-right: 0; + } + + &__header { + padding-left: $baseline; + padding-right: $baseline; + } + } +} diff --git a/src/admin/components/views/Revision/index.tsx b/src/admin/components/views/Revision/index.tsx new file mode 100644 index 0000000000..7ef4ffd416 --- /dev/null +++ b/src/admin/components/views/Revision/index.tsx @@ -0,0 +1,127 @@ +import { useConfig } from '@payloadcms/config-provider'; +import React, { useEffect } from 'react'; +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 { Props } from './types'; +import IDLabel from '../../elements/IDLabel'; + +import './index.scss'; + +const baseClass = 'view-revision'; + +const ViewRevision: React.FC = ({ collection, global }) => { + const { serverURL, routes: { admin, api }, admin: { dateFormat } } = useConfig(); + const { setStepNav } = useStepNav(); + const { params: { id, revisionID } } = useRouteMatch<{ id?: string, revisionID: string }>(); + + let originalDocFetchURL: string; + let docFetchURL: string; + let entityLabel: string; + let slug: string; + + if (collection) { + ({ slug } = collection); + originalDocFetchURL = `${serverURL}${api}/${slug}/${id}`; + docFetchURL = `${serverURL}${api}/${slug}/revisions/${revisionID}`; + entityLabel = collection.labels.singular; + } + + if (global) { + ({ slug } = global); + docFetchURL = `${serverURL}${api}/globals/${slug}/revisions/${revisionID}`; + entityLabel = global.label; + } + + const useAsTitle = collection?.admin?.useAsTitle || 'id'; + const [{ data: doc, isLoading }] = usePayloadAPI(docFetchURL); + const [{ data: originalDoc }] = usePayloadAPI(originalDocFetchURL); + + useEffect(() => { + let nav: StepNavItem[] = []; + + if (collection) { + nav = [ + { + url: `${admin}/collections/${collection.slug}`, + label: collection.labels.plural, + }, + { + label: originalDoc ? originalDoc[useAsTitle] : '', + url: `${admin}/collections/${collection.slug}/${id}`, + }, + { + label: 'Revisions', + url: `${admin}/collections/${collection.slug}/${id}/revisions`, + }, + { + label: doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '', + }, + ]; + } + + if (global) { + nav = [ + { + url: `${admin}/globals/${global.slug}`, + label: global.label, + }, + { + label: 'Revisions', + url: `${admin}/globals/${global.slug}/revisions`, + }, + { + label: doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '', + }, + ]; + } + + setStepNav(nav); + }, [setStepNav, collection, global, useAsTitle, dateFormat, doc, originalDoc, admin, id]); + + let metaTitle: string; + let metaDesc: string; + + if (collection) { + metaTitle = `Revision - ${doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : ''} - ${doc[useAsTitle]} - ${entityLabel}`; + metaDesc = `Viewing revision for the ${entityLabel} ${doc[useAsTitle]}`; + } + + if (global) { + metaTitle = `Revision - ${doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : ''} - ${entityLabel}`; + metaDesc = `Viewing revision for the global ${entityLabel}`; + } + + return ( +
      + + +
      +
      + +
      + {isLoading && ( + + )} + {doc?.revision && ( + + hello + + )} +
      +
      + ); +}; + +export default ViewRevision; diff --git a/src/admin/components/views/Revision/types.ts b/src/admin/components/views/Revision/types.ts new file mode 100644 index 0000000000..b2881ab151 --- /dev/null +++ b/src/admin/components/views/Revision/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/Revisions/index.tsx b/src/admin/components/views/Revisions/index.tsx index dc85c2e1a7..b8b0a8e137 100644 --- a/src/admin/components/views/Revisions/index.tsx +++ b/src/admin/components/views/Revisions/index.tsx @@ -33,13 +33,13 @@ const Revisions: React.FC = ({ collection, global }) => { if (collection) { ({ slug } = collection); - docURL = `${serverURL}/api/${slug}/${id}`; + docURL = `${serverURL}${api}/${slug}/${id}`; entityLabel = collection.labels.singular; } if (global) { ({ slug } = global); - docURL = `${serverURL}/api/globals/${slug}`; + docURL = `${serverURL}${api}/globals/${slug}`; entityLabel = global.label; } @@ -110,12 +110,16 @@ const Revisions: React.FC = ({ collection, global }) => { let heading: string; let metaDesc: string; + let metaTitle: string; + if (collection) { + metaTitle = `Revisions - ${doc[useAsTitle]} - ${entityLabel}`; metaDesc = `Viewing revisions for the ${entityLabel} ${doc[useAsTitle]}`; heading = doc?.[useAsTitle]; } if (global) { + metaTitle = `Revisions - ${entityLabel}`; metaDesc = `Viewing revisions for the global ${entityLabel}`; heading = entityLabel; } @@ -123,9 +127,8 @@ const Revisions: React.FC = ({ collection, global }) => { return (
      From 740d6b15e5487e5c8bf27d3089ffd9c3e5d3645c Mon Sep 17 00:00:00 2001 From: James Date: Tue, 7 Dec 2021 14:49:32 -0500 Subject: [PATCH 23/66] feat: further revisions views --- .../richText/elements/Button/Button/index.tsx | 9 +- .../elements/Button/Element/index.tsx | 15 ---- .../richText/elements/Button/index.ts | 2 +- .../leaves/PurpleBackground/Button/index.tsx | 2 +- .../leaves/PurpleBackground/Leaf/index.tsx | 11 --- demo/payload.config.ts | 4 +- .../components/elements/PerPage/index.tsx | 17 ++-- .../elements/ReactSelect/index.scss | 5 +- .../components/elements/ReactSelect/index.tsx | 2 + .../components/elements/ReactSelect/types.ts | 1 + .../RichText/elements/upload/Button/index.tsx | 2 +- .../elements/upload/Element/index.tsx | 2 +- .../Upload/SelectExisting/index.tsx | 2 +- .../views/Revision/Compare/index.scss | 11 +++ .../views/Revision/Compare/index.tsx | 82 +++++++++++++++++++ .../views/Revision/Compare/types.ts | 33 ++++++++ src/admin/components/views/Revision/index.tsx | 41 +++++++--- .../components/views/Revisions/index.tsx | 31 ++++--- .../views/collections/List/Default.tsx | 2 +- 19 files changed, 201 insertions(+), 73 deletions(-) create mode 100644 src/admin/components/views/Revision/Compare/index.scss create mode 100644 src/admin/components/views/Revision/Compare/index.tsx create mode 100644 src/admin/components/views/Revision/Compare/types.ts 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 1a4afaecbf..c7ebab1f6e 100644 --- a/demo/client/components/richText/elements/Button/index.ts +++ b/demo/client/components/richText/elements/Button/index.ts @@ -1,4 +1,4 @@ -import { RichTextCustomElement } from '../../../../../../dist/fields/config/types'; +import { RichTextCustomElement } from '../../../../../../src/fields/config/types'; import Button from './Button'; import Element from './Element'; import plugin from './plugin'; 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/payload.config.ts b/demo/payload.config.ts index 102eb22e7f..ba69c93824 100644 --- a/demo/payload.config.ts +++ b/demo/payload.config.ts @@ -6,7 +6,7 @@ import AllFields from './collections/AllFields'; import AutoLabel from './collections/AutoLabel'; 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'; @@ -67,7 +67,7 @@ export default buildConfig({ AutoLabel, Code, Conditions, - CustomComponents, + // CustomComponents, CustomID, File, DefaultValues, 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/ReactSelect/index.scss b/src/admin/components/elements/ReactSelect/index.scss index 85252b52f8..d56ccc0f05 100644 --- a/src/admin/components/elements/ReactSelect/index.scss +++ b/src/admin/components/elements/ReactSelect/index.scss @@ -9,7 +9,7 @@ div.react-select { } .rs__value-container { - padding: 0; + padding: base(.25) 0; > * { margin-top: 0; @@ -40,9 +40,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/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}
      { {data.totalDocs}
      = (props) => { {data.totalDocs} = (props) => { + const { onChange, value, baseURL } = props; + + const { + admin: { + dateFormat, + }, + } = useConfig(); + + const [options, setOptions] = useState([]); + const [lastLoadedPage, setLastLoadedPage] = useState(1); + const [errorLoading, setErrorLoading] = useState(''); + + const getResults = useCallback(async ({ + lastLoadedPage: lastLoadedPageArg, + } = {}) => { + const response = await fetch(`${baseURL}?limit=${maxResultsPerRequest}&page=${lastLoadedPageArg}&depth=0`); + + 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]); + + const classes = [ + 'field-type', + baseClass, + errorLoading && 'error-loading', + ].filter(Boolean).join(' '); + + useEffect(() => { + getResults({ lastLoadedPage: 1 }); + }, [getResults]); + + return ( +
      + {!errorLoading && ( + { + getResults({ lastLoadedPage: lastLoadedPage + 1 }); + }} + value={value} + options={options} + /> + )} + {errorLoading && ( +
      + {errorLoading} +
      + )} +
      + ); +}; + +export default CompareRevision; diff --git a/src/admin/components/views/Revision/Compare/types.ts b/src/admin/components/views/Revision/Compare/types.ts new file mode 100644 index 0000000000..deff0fc2ec --- /dev/null +++ b/src/admin/components/views/Revision/Compare/types.ts @@ -0,0 +1,33 @@ +import { SanitizedCollectionConfig } from '../../../../../collections/config/types'; +import { PaginatedDocs } from '../../../../../mongoose/types'; + +export type Props = { + onChange: (val: unknown) => void, + value: Option, + baseURL: string +} + +export type Option = { + label: string + value: string + relationTo?: string + options?: Option[] +} + +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/Revision/index.tsx b/src/admin/components/views/Revision/index.tsx index 7ef4ffd416..b2f3a170ad 100644 --- a/src/admin/components/views/Revision/index.tsx +++ b/src/admin/components/views/Revision/index.tsx @@ -1,5 +1,5 @@ import { useConfig } from '@payloadcms/config-provider'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useRouteMatch } from 'react-router-dom'; import format from 'date-fns/format'; import usePayloadAPI from '../../../hooks/usePayloadAPI'; @@ -9,7 +9,8 @@ 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 CompareRevision from './Compare'; +import { Option } from './Compare/types'; import './index.scss'; @@ -19,28 +20,36 @@ const ViewRevision: React.FC = ({ collection, global }) => { const { serverURL, routes: { admin, api }, admin: { dateFormat } } = useConfig(); const { setStepNav } = useStepNav(); const { params: { id, revisionID } } = useRouteMatch<{ id?: string, revisionID: string }>(); + const [compareValue, setCompareValue] = useState
    - {revisionsData?.totalDocs > 0 && ( + {versionsData?.totalDocs > 0 && (
    - {(revisionsData.page * revisionsData.limit) - (revisionsData.limit - 1)} + {(versionsData.page * versionsData.limit) - (versionsData.limit - 1)} - - {revisionsData.totalPages > 1 && revisionsData.totalPages !== revisionsData.page ? (revisionsData.limit * revisionsData.page) : revisionsData.totalDocs} + {versionsData.totalPages > 1 && versionsData.totalPages !== versionsData.page ? (versionsData.limit * versionsData.page) : versionsData.totalDocs} {' '} of {' '} - {revisionsData.totalDocs} + {versionsData.totalDocs}
    = ({ collection, global }) => { ); }; -export default Revisions; +export default Versions; diff --git a/src/admin/components/views/Revisions/types.ts b/src/admin/components/views/Versions/types.ts similarity index 100% rename from src/admin/components/views/Revisions/types.ts rename to src/admin/components/views/Versions/types.ts diff --git a/src/admin/components/views/collections/Edit/Autosave/index.tsx b/src/admin/components/views/collections/Edit/Autosave/index.tsx new file mode 100644 index 0000000000..495ea909c7 --- /dev/null +++ b/src/admin/components/views/collections/Edit/Autosave/index.tsx @@ -0,0 +1,32 @@ +import React, { useEffect, useState } from 'react'; +import { SanitizedCollectionConfig } from '../../../../../../collections/config/types'; +import { useWatchForm, useFormModified } from '../../../../forms/Form/context'; + +const Autosave: React.FC<{ collection: SanitizedCollectionConfig}> = ({ collection }) => { + const { submit, fields } = useWatchForm(); + const modified = useFormModified(); + const [lastSaved, setLastSaved] = useState(() => { + const date = new Date(); + date.setSeconds(date.getSeconds() - 2); + return date.getTime(); + }); + + const interval = collection.versions.drafts && collection.versions.drafts.autosave ? collection.versions.drafts.autosave.interval : 5; + + useEffect(() => { + const lastSavedDate = new Date(lastSaved); + lastSavedDate.setSeconds(lastSavedDate.getSeconds() + interval); + const timeToSaveAgain = lastSavedDate.getTime(); + + if (Date.now() >= timeToSaveAgain && modified) { + setTimeout(() => { + console.log('Autosaving'); + }, 1000); + setLastSaved(new Date().getTime()); + } + }, [modified, fields, interval, lastSaved]); + + return null; +}; + +export default Autosave; diff --git a/src/admin/components/views/collections/Edit/Default.tsx b/src/admin/components/views/collections/Edit/Default.tsx index 0620e21b57..096e3b6e9d 100644 --- a/src/admin/components/views/collections/Edit/Default.tsx +++ b/src/admin/components/views/collections/Edit/Default.tsx @@ -16,9 +16,10 @@ import fieldTypes from '../../../forms/field-types'; import RenderTitle from '../../../elements/RenderTitle'; import LeaveWithoutSaving from '../../../modals/LeaveWithoutSaving'; import Auth from './Auth'; -import RevisionsCount from '../../../elements/RevisionsCount'; +import VersionsCount from '../../../elements/VersionsCount'; import Upload from './Upload'; import { Props } from './types'; +import Autosave from './Autosave'; import './index.scss'; @@ -51,7 +52,7 @@ const DefaultEditView: React.FC = (props) => { preview, hideAPIURL, }, - revisions, + versions, timestamps, auth, upload, @@ -158,6 +159,14 @@ const DefaultEditView: React.FC = (props) => { {!isLoading && (
    + {/* {collection.versions?.drafts && ( + - )} */} + )} = (props) => {
    - {(collection.versions?.drafts && collection.versions.drafts.autosave && hasSavePermission) && ( - - )} ); diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx index cc34e301fb..ac2257d9d6 100644 --- a/src/admin/components/views/collections/Edit/index.tsx +++ b/src/admin/components/views/collections/Edit/index.tsx @@ -15,7 +15,7 @@ import { IndexProps } from './types'; import { StepNavItem } from '../../../elements/StepNav/types'; 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 [submissionCount, setSubmissionCount] = useState(0); const locale = useLocale(); @@ -73,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({ @@ -120,7 +136,7 @@ const EditView: React.FC = (props) => { submissionCount, isLoading, data: dataToRender, - collection: { ...collection, fields }, + collection, permissions: collectionPermissions, isEditing, onSave, diff --git a/src/collections/config/sanitize.ts b/src/collections/config/sanitize.ts index 67acb20a56..70333aa45f 100644 --- a/src/collections/config/sanitize.ts +++ b/src/collections/config/sanitize.ts @@ -12,6 +12,7 @@ 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 = []; @@ -69,16 +70,18 @@ const sanitizeCollection = (config: Config, collection: CollectionConfig): Sanit if (sanitized.versions) { if (sanitized.versions === true) sanitized.versions = {}; - // const defaultsToMerge = { ... }; + if (sanitized.timestamps === false) { + throw new TimestampsRequired(collection); + } - // if (sanitized.versions.drafts === false) { - // defaultsToMerge.drafts = false; - // } else { - // sanitized.fields = [ - // ...sanitized.fields, - // ...baseVersionFields, - // ]; - // } + if (sanitized.versions.drafts) { + const versionFields = mergeBaseFields(sanitized.fields, baseVersionFields); + + sanitized.fields = [ + ...versionFields, + ...sanitized.fields, + ]; + } sanitized.versions = merge(versionCollectionDefaults, sanitized.versions); } diff --git a/src/collections/graphql/init.ts b/src/collections/graphql/init.ts index 484c93bd38..7a2d2a0fe4 100644 --- a/src/collections/graphql/init.ts +++ b/src/collections/graphql/init.ts @@ -153,6 +153,7 @@ function registerCollections(): void { type: collection.graphQL.type, args: { data: { type: collection.graphQL.mutationInputType }, + autosave: { type: GraphQLBoolean }, }, resolve: create(collection), }; diff --git a/src/collections/graphql/resolvers/create.ts b/src/collections/graphql/resolvers/create.ts index f51a885049..b3e2f46139 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, + autosave: args.autosave, }; const result = await this.operations.collections.create(options); diff --git a/src/collections/operations/create.ts b/src/collections/operations/create.ts index bfe311e4f4..5d8c3f1cf3 100644 --- a/src/collections/operations/create.ts +++ b/src/collections/operations/create.ts @@ -22,6 +22,7 @@ export type Arguments = { showHiddenFields?: boolean data: Record overwriteExistingFiles?: boolean + autosave?: boolean } async function create(this: Payload, incomingArgs: Arguments): Promise { @@ -54,6 +55,7 @@ async function create(this: Payload, incomingArgs: Arguments): Promise overrideAccess, showHiddenFields, overwriteExistingFiles = false, + autosave = false, } = args; let { data } = args; @@ -142,6 +144,7 @@ async function create(this: Payload, incomingArgs: Arguments): Promise req, overrideAccess, unflattenLocales: true, + skipValidation: autosave, }); // ///////////////////////////////////// diff --git a/src/collections/operations/local/create.ts b/src/collections/operations/local/create.ts index 865112f363..dbf3b25ddb 100644 --- a/src/collections/operations/local/create.ts +++ b/src/collections/operations/local/create.ts @@ -15,6 +15,7 @@ export type Options = { filePath?: string overwriteExistingFiles?: boolean req: PayloadRequest + autosave?: boolean } export default async function create(options: Options): Promise { @@ -31,6 +32,7 @@ export default async function create(options: Options): Promise { filePath, overwriteExistingFiles = false, req, + autosave, } = options; const collection = this.collections[collectionSlug]; @@ -43,6 +45,7 @@ export default async function create(options: Options): Promise { disableVerificationEmail, showHiddenFields, overwriteExistingFiles, + autosave, req: { ...req, user, diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index 9d32fabcde..71ec1e07d8 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -188,6 +188,7 @@ async function update(this: Payload, incomingArgs: Arguments): Promise overrideAccess, unflattenLocales: true, docWithLocales, + skipValidation: autosave, }); // ///////////////////////////////////// diff --git a/src/collections/requestHandlers/create.ts b/src/collections/requestHandlers/create.ts index 6dfb85bc7f..7f5e994ff0 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, + autosave: req.query.autosave === 'true', }); return res.status(httpStatus.CREATED).json({ 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; From be1da8507a04737422e778b7c2406de891c0792c Mon Sep 17 00:00:00 2001 From: James Date: Thu, 30 Dec 2021 11:21:53 -0500 Subject: [PATCH 43/66] feat: progress to drafts --- demo/collections/Localized.ts | 13 +++- .../components/elements/Autosave/index.tsx | 20 ++--- src/collections/config/schema.ts | 2 + src/collections/config/types.ts | 9 ++- src/collections/graphql/init.ts | 2 + src/collections/graphql/resolvers/find.ts | 1 + src/collections/graphql/resolvers/findByID.ts | 1 + src/collections/operations/find.ts | 51 +++++++++++-- src/collections/operations/findByID.ts | 37 +++++++-- src/collections/operations/local/find.ts | 3 + src/collections/operations/local/findByID.ts | 3 + src/collections/requestHandlers/find.ts | 1 + src/collections/requestHandlers/findByID.ts | 1 + src/globals/config/types.ts | 1 + src/globals/graphql/init.ts | 1 + src/globals/graphql/resolvers/findOne.ts | 1 + src/globals/operations/findOne.ts | 1 + src/globals/operations/local/findOne.ts | 2 + src/globals/requestHandlers/findOne.ts | 1 + src/types/index.ts | 5 ++ .../drafts/replaceWithDraftIfAvailable.ts | 75 +++++++++++++++++++ 21 files changed, 206 insertions(+), 25 deletions(-) create mode 100644 src/versions/drafts/replaceWithDraftIfAvailable.ts diff --git a/demo/collections/Localized.ts b/demo/collections/Localized.ts index 8b85219847..cd0283d322 100644 --- a/demo/collections/Localized.ts +++ b/demo/collections/Localized.ts @@ -53,7 +53,18 @@ const LocalizedPosts: CollectionConfig = { }, }, access: { - read: () => true, + read: ({ req: { user } }) => { + if (user) { + return true; + } + + return { + _status: { + equals: 'published', + }, + }; + }, + readVersions: ({ req: { user } }) => Boolean(user), }, fields: [ { diff --git a/src/admin/components/elements/Autosave/index.tsx b/src/admin/components/elements/Autosave/index.tsx index 6bd26b5cd5..07c39d3876 100644 --- a/src/admin/components/elements/Autosave/index.tsx +++ b/src/admin/components/elements/Autosave/index.tsx @@ -2,7 +2,7 @@ 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, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useWatchForm, useFormModified } from '../../forms/Form/context'; import { useLocale } from '../../utilities/Locale'; import { Props } from './types'; @@ -18,11 +18,18 @@ const Autosave: React.FC = ({ collection, global, id, updatedAt }) => { const { serverURL, routes: { api, admin } } = useConfig(); const { fields, dispatchFields } = useWatchForm(); const modified = useFormModified(); - const [saving, setSaving] = useState(false); - const [lastSaved, setLastSaved] = useState(); const locale = useLocale(); const { push } = 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 () => { @@ -113,15 +120,10 @@ const Autosave: React.FC = ({ collection, global, id, updatedAt }) => { }, 1000); const body = { - ...reduceFieldsToValues(fields), + ...reduceFieldsToValues(fieldRef.current), _status: 'draft', }; - // TODO: - // Determine why field values are not present - // even though we are using useWatchForm - console.log(body); - const res = await fetch(url, { method, body: JSON.stringify(body), diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts index 6517c4ec8b..5fa8e2c514 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -10,6 +10,8 @@ const collectionSchema = joi.object().keys({ access: joi.object({ create: joi.func(), read: joi.func(), + readVersions: joi.func(), + readDrafts: joi.func(), update: joi.func(), delete: joi.func(), unlock: joi.func(), diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index 62f2e304e2..a0372d5790 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -190,11 +190,12 @@ export type CollectionConfig = { access?: { create?: Access; read?: Access; + readDrafts?: Access; + readVersions?: Access; update?: Access; delete?: Access; admin?: (args?: any) => boolean; unlock?: Access; - readVersions?: Access; }; /** * Collection login options @@ -230,3 +231,9 @@ export type AuthCollection = { 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 7a2d2a0fe4..a5d61c7959 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 }, 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/operations/find.ts b/src/collections/operations/find.ts index d10be12811..b0fc7f0c7d 100644 --- a/src/collections/operations/find.ts +++ b/src/collections/operations/find.ts @@ -7,6 +7,8 @@ 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 @@ -18,6 +20,7 @@ export type Arguments = { req?: PayloadRequest overrideAccess?: boolean showHiddenFields?: boolean + draft?: boolean } async function find(incomingArgs: Arguments): Promise> { @@ -41,6 +44,7 @@ async function find(incomingArgs: Arguments): Promis page, limit, depth, + draft: draftsEnabled, collection: { Model, config: collectionConfig, @@ -78,22 +82,32 @@ async function find(incomingArgs: Arguments): Promis 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 (!overrideAccess) { + accessResult = await executeAccess({ req }, collectionConfig.access.read); + + if (hasWhereAccessResult(accessResult)) { if (!where) { queryToBuild.where = { and: [ - accessResults, + accessResult, ], }; } else { - (queryToBuild.where.and as Where[]).push(accessResults); + (queryToBuild.where.and as Where[]).push(accessResult); } } } + if (collectionConfig.versions?.drafts && !draftsEnabled) { + queryToBuild.where.and.push({ + _status: { + equals: 'published', + }, + }); + } + const query = await Model.buildQuery(queryToBuild, locale); // ///////////////////////////////////// @@ -114,12 +128,33 @@ 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, + result = { + ...result, docs: await Promise.all(paginatedDocs.docs.map(async (doc) => { const docString = JSON.stringify(doc); let docRef = JSON.parse(docString); @@ -132,7 +167,7 @@ async function find(incomingArgs: Arguments): Promis return docRef; })), - } as PaginatedDocs; + }; // ///////////////////////////////////// // afterRead - Fields diff --git a/src/collections/operations/findByID.ts b/src/collections/operations/findByID.ts index 38729c61f6..d713c349e5 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,7 +8,7 @@ import { Forbidden, NotFound } from '../../errors'; import executeAccess from '../../auth/executeAccess'; import { Where } from '../../types'; import { hasWhereAccessResult } from '../../auth/types'; -import { Payload } from '../..'; +import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable'; export type Arguments = { collection: Collection @@ -18,6 +19,7 @@ export type Arguments = { overrideAccess?: boolean showHiddenFields?: boolean depth?: number + draft?: boolean } async function findByID(this: Payload, incomingArgs: Arguments): Promise { @@ -51,18 +53,19 @@ async function findByID(this: Payload, incomingArgs: currentDepth, 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: { @@ -76,8 +79,16 @@ async function findByID(this: Payload, incomingArgs: }, }; - 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({ + _status: { + equals: 'published', + }, + }); } const query = await Model.buildQuery(queryToBuild, locale); @@ -117,6 +128,20 @@ async function findByID(this: Payload, incomingArgs: 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/local/find.ts b/src/collections/operations/local/find.ts index 7d4c968640..0fd4a9132a 100644 --- a/src/collections/operations/local/find.ts +++ b/src/collections/operations/local/find.ts @@ -14,6 +14,7 @@ export type Options = { showHiddenFields?: boolean sort?: string where?: Where + draft?: boolean } export default async function find(options: Options): Promise> { @@ -29,6 +30,7 @@ export default async function find(options: Options) overrideAccess = true, showHiddenFields, sort, + draft = false, } = options; const collection = this.collections[collectionSlug]; @@ -42,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 c9152f07d6..0c83790df2 100644 --- a/src/collections/operations/local/findByID.ts +++ b/src/collections/operations/local/findByID.ts @@ -14,6 +14,7 @@ export type Options = { showHiddenFields?: boolean disableErrors?: boolean req?: PayloadRequest + draft?: boolean } export default async function findByID(options: Options): Promise { @@ -29,6 +30,7 @@ export default async function findByID(options: Opti disableErrors = false, showHiddenFields, req = {}, + draft = false, } = options; const collection = this.collections[collectionSlug]; @@ -53,5 +55,6 @@ export default async function findByID(options: Opti disableErrors, showHiddenFields, req: reqToUse, + draft, }); } diff --git a/src/collections/requestHandlers/find.ts b/src/collections/requestHandlers/find.ts index 6f0bb51235..04983eb850 100644 --- a/src/collections/requestHandlers/find.ts +++ b/src/collections/requestHandlers/find.ts @@ -24,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/globals/config/types.ts b/src/globals/config/types.ts index 6a517ffcf0..afad91cc54 100644 --- a/src/globals/config/types.ts +++ b/src/globals/config/types.ts @@ -55,6 +55,7 @@ export type GlobalConfig = { } access?: { read?: Access; + readDrafts?: Access; readVersions?: Access; update?: Access; } diff --git a/src/globals/graphql/init.ts b/src/globals/graphql/init.ts index 7b0e47181c..9e88481626 100644 --- a/src/globals/graphql/init.ts +++ b/src/globals/graphql/init.ts @@ -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 }, 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/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/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/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/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/versions/drafts/replaceWithDraftIfAvailable.ts b/src/versions/drafts/replaceWithDraftIfAvailable.ts new file mode 100644 index 0000000000..f57400a32c --- /dev/null +++ b/src/versions/drafts/replaceWithDraftIfAvailable.ts @@ -0,0 +1,75 @@ +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, + }); + + 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 draft.version; + } + + return doc; +}; + +export default replaceWithDraftIfAvailable; From e910d8938fd3ad18483c7965c71775ac8ee6a887 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 30 Dec 2021 13:58:06 -0500 Subject: [PATCH 44/66] feat: functional autosave --- demo/collections/Localized.ts | 15 +++- .../components/elements/Autosave/index.tsx | 12 +++- .../views/collections/Edit/index.tsx | 8 +-- .../views/collections/List/index.tsx | 1 + src/collections/config/schema.ts | 1 - src/collections/config/types.ts | 1 - src/collections/graphql/init.ts | 4 +- src/collections/graphql/resolvers/create.ts | 2 +- src/collections/graphql/resolvers/update.ts | 2 +- src/collections/operations/create.ts | 8 ++- src/collections/operations/find.ts | 54 ++++++++------ src/collections/operations/findByID.ts | 15 +++- src/collections/operations/local/create.ts | 6 +- src/collections/operations/local/update.ts | 6 +- src/collections/operations/update.ts | 28 +++++--- src/collections/requestHandlers/create.ts | 2 +- src/collections/requestHandlers/update.ts | 2 +- src/globals/graphql/init.ts | 2 +- src/globals/graphql/resolvers/update.ts | 2 +- src/globals/operations/local/update.ts | 4 +- src/globals/operations/update.ts | 35 ++++++---- src/globals/requestHandlers/update.ts | 2 +- src/versions/drafts/saveCollectionDraft.ts | 70 +++++++++++++++++++ src/versions/drafts/saveGlobalDraft.ts | 60 ++++++++++++++++ src/versions/saveCollectionVersion.ts | 44 ++++-------- src/versions/saveGlobalVersion.ts | 28 ++------ 26 files changed, 277 insertions(+), 137 deletions(-) create mode 100644 src/versions/drafts/saveCollectionDraft.ts create mode 100644 src/versions/drafts/saveGlobalDraft.ts diff --git a/demo/collections/Localized.ts b/demo/collections/Localized.ts index cd0283d322..4ae59d6f75 100644 --- a/demo/collections/Localized.ts +++ b/demo/collections/Localized.ts @@ -59,9 +59,18 @@ const LocalizedPosts: CollectionConfig = { } return { - _status: { - equals: 'published', - }, + or: [ + { + _status: { + equals: 'published', + }, + }, + { + _status: { + exists: false, + }, + }, + ], }; }, readVersions: ({ req: { user } }) => Boolean(user), diff --git a/src/admin/components/elements/Autosave/index.tsx b/src/admin/components/elements/Autosave/index.tsx index 07c39d3876..59e0443414 100644 --- a/src/admin/components/elements/Autosave/index.tsx +++ b/src/admin/components/elements/Autosave/index.tsx @@ -33,8 +33,11 @@ const Autosave: React.FC = ({ collection, global, id, updatedAt }) => { 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&autosave=true`, { + 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({}), }); @@ -103,13 +106,13 @@ const Autosave: React.FC = ({ collection, global, id, updatedAt }) => { let entityFields: Field[] = []; if (collection && id) { - url = `${serverURL}${api}/${collection.slug}/${id}?autosave=true`; + url = `${serverURL}${api}/${collection.slug}/${id}?draft=true`; method = 'PUT'; entityFields = collection.fields; } if (global) { - url = `${serverURL}${api}/globals/${global.slug}?autosave=true`; + url = `${serverURL}${api}/globals/${global.slug}?draft=true`; method = 'POST'; entityFields = global.fields; } @@ -126,6 +129,9 @@ const Autosave: React.FC = ({ collection, global, id, updatedAt }) => { const res = await fetch(url, { method, + headers: { + 'Content-Type': 'application/json', + }, body: JSON.stringify(body), }); diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx index ac2257d9d6..c24f5cc793 100644 --- a/src/admin/components/views/collections/Edit/index.tsx +++ b/src/admin/components/views/collections/Edit/index.tsx @@ -45,7 +45,7 @@ const EditView: React.FC = (props) => { const [initialState, setInitialState] = useState({}); const { permissions } = useAuth(); - const onSave = useCallback(async (json: any, version = false) => { + const onSave = useCallback(async (json: any) => { if (!isEditing) { history.push(`${admin}/collections/${collection.slug}/${json?.doc?.id}`); } else { @@ -63,7 +63,7 @@ const EditView: React.FC = (props) => { 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; @@ -118,8 +118,8 @@ const EditView: React.FC = (props) => { const collectionPermissions = permissions?.collections?.[slug]; - const apiURL = `${serverURL}${api}/${slug}/${id}`; - const action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`; + 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&draft=true`; const hasSavePermission = (isEditing && collectionPermissions?.update?.permission) || (!isEditing && collectionPermissions?.create?.permission); return ( 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/collections/config/schema.ts b/src/collections/config/schema.ts index 5fa8e2c514..996ff02db7 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -11,7 +11,6 @@ const collectionSchema = joi.object().keys({ create: joi.func(), read: joi.func(), readVersions: joi.func(), - readDrafts: joi.func(), update: joi.func(), delete: joi.func(), unlock: joi.func(), diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index a0372d5790..87b54006ad 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -190,7 +190,6 @@ export type CollectionConfig = { access?: { create?: Access; read?: Access; - readDrafts?: Access; readVersions?: Access; update?: Access; delete?: Access; diff --git a/src/collections/graphql/init.ts b/src/collections/graphql/init.ts index a5d61c7959..c2c85b345e 100644 --- a/src/collections/graphql/init.ts +++ b/src/collections/graphql/init.ts @@ -155,7 +155,7 @@ function registerCollections(): void { type: collection.graphQL.type, args: { data: { type: collection.graphQL.mutationInputType }, - autosave: { type: GraphQLBoolean }, + draft: { type: GraphQLBoolean }, }, resolve: create(collection), }; @@ -165,7 +165,7 @@ function registerCollections(): void { args: { id: { type: new GraphQLNonNull(idType) }, data: { type: collection.graphQL.updateMutationInputType }, - autosave: { type: GraphQLBoolean }, + draft: { type: GraphQLBoolean }, }, resolve: update(collection), }; diff --git a/src/collections/graphql/resolvers/create.ts b/src/collections/graphql/resolvers/create.ts index b3e2f46139..002445bf28 100644 --- a/src/collections/graphql/resolvers/create.ts +++ b/src/collections/graphql/resolvers/create.ts @@ -23,7 +23,7 @@ export default function create(collection: Collection): Resolver { collection, data: args.data, req: context.req, - autosave: args.autosave, + draft: args.draft, }; const result = await this.operations.collections.create(options); diff --git a/src/collections/graphql/resolvers/update.ts b/src/collections/graphql/resolvers/update.ts index bfbf06b155..b39463e6e8 100644 --- a/src/collections/graphql/resolvers/update.ts +++ b/src/collections/graphql/resolvers/update.ts @@ -11,7 +11,7 @@ export default function update(collection) { id: args.id, depth: 0, req: context.req, - autosave: args.autosave, + draft: args.draft, }; const result = await this.operations.collections.update(options); diff --git a/src/collections/operations/create.ts b/src/collections/operations/create.ts index 5d8c3f1cf3..2d7f4b2823 100644 --- a/src/collections/operations/create.ts +++ b/src/collections/operations/create.ts @@ -22,7 +22,7 @@ export type Arguments = { showHiddenFields?: boolean data: Record overwriteExistingFiles?: boolean - autosave?: boolean + draft?: boolean } async function create(this: Payload, incomingArgs: Arguments): Promise { @@ -55,11 +55,13 @@ async function create(this: Payload, incomingArgs: Arguments): Promise overrideAccess, showHiddenFields, overwriteExistingFiles = false, - autosave = false, + draft = false, } = args; let { data } = args; + const shouldSaveDraft = Boolean(draft && collectionConfig.versions.drafts); + // ///////////////////////////////////// // Access // ///////////////////////////////////// @@ -144,7 +146,7 @@ async function create(this: Payload, incomingArgs: Arguments): Promise req, overrideAccess, unflattenLocales: true, - skipValidation: autosave, + skipValidation: shouldSaveDraft, }); // ///////////////////////////////////// diff --git a/src/collections/operations/find.ts b/src/collections/operations/find.ts index b0fc7f0c7d..65900228f7 100644 --- a/src/collections/operations/find.ts +++ b/src/collections/operations/find.ts @@ -61,21 +61,28 @@ 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)) { + queryToBuild.where.and = [ + ...queryToBuild.where.and, + ...where.and, + ]; + } - if (Array.isArray(where.and)) and = where.and; - if (Array.isArray(where.AND)) and = where.AND; - - queryToBuild.where = { - ...where, - and: [ - ...and, - ], - }; + if (Array.isArray(where.AND)) { + queryToBuild.where.and = [ + ...queryToBuild.where.and, + ...where.AND, + ]; + } const constraints = flattenWhereConstraints(queryToBuild); @@ -88,23 +95,24 @@ async function find(incomingArgs: Arguments): Promis accessResult = await executeAccess({ req }, collectionConfig.access.read); if (hasWhereAccessResult(accessResult)) { - if (!where) { - queryToBuild.where = { - and: [ - accessResult, - ], - }; - } else { - (queryToBuild.where.and as Where[]).push(accessResult); - } + queryToBuild.where.and.push(accessResult); } } if (collectionConfig.versions?.drafts && !draftsEnabled) { queryToBuild.where.and.push({ - _status: { - equals: 'published', - }, + or: [ + { + _status: { + equals: 'published', + }, + }, + { + _status: { + exists: false, + }, + }, + ], }); } diff --git a/src/collections/operations/findByID.ts b/src/collections/operations/findByID.ts index d713c349e5..0bdfccc1a9 100644 --- a/src/collections/operations/findByID.ts +++ b/src/collections/operations/findByID.ts @@ -85,9 +85,18 @@ async function findByID(this: Payload, incomingArgs: if (collectionConfig.versions?.drafts && !draftEnabled) { queryToBuild.where.and.push({ - _status: { - equals: 'published', - }, + or: [ + { + _status: { + equals: 'published', + }, + }, + { + _status: { + exists: false, + }, + }, + ], }); } diff --git a/src/collections/operations/local/create.ts b/src/collections/operations/local/create.ts index dbf3b25ddb..e216d554d6 100644 --- a/src/collections/operations/local/create.ts +++ b/src/collections/operations/local/create.ts @@ -15,7 +15,7 @@ export type Options = { filePath?: string overwriteExistingFiles?: boolean req: PayloadRequest - autosave?: boolean + draft?: boolean } export default async function create(options: Options): Promise { @@ -32,7 +32,7 @@ export default async function create(options: Options): Promise { filePath, overwriteExistingFiles = false, req, - autosave, + draft, } = options; const collection = this.collections[collectionSlug]; @@ -45,7 +45,7 @@ export default async function create(options: Options): Promise { disableVerificationEmail, showHiddenFields, overwriteExistingFiles, - autosave, + draft, req: { ...req, user, diff --git a/src/collections/operations/local/update.ts b/src/collections/operations/local/update.ts index 672cb3c4b4..fae5da8dba 100644 --- a/src/collections/operations/local/update.ts +++ b/src/collections/operations/local/update.ts @@ -13,7 +13,7 @@ export type Options = { showHiddenFields?: boolean filePath?: string overwriteExistingFiles?: boolean - autosave?: boolean + draft?: boolean } export default async function update(options: Options): Promise { @@ -29,7 +29,7 @@ export default async function update(options: Options): Promise { showHiddenFields, filePath, overwriteExistingFiles = false, - autosave, + draft, } = options; const collection = this.collections[collectionSlug]; @@ -42,7 +42,7 @@ export default async function update(options: Options): Promise { id, showHiddenFields, overwriteExistingFiles, - autosave, + draft, req: { user, payloadAPI: 'local', diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index 71ec1e07d8..0724641473 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -2,13 +2,12 @@ import httpStatus from 'http-status'; 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, ValidationError } from '../../errors'; - import { PayloadRequest } from '../../express/types'; import { hasWhereAccessResult, UserDocument } from '../../auth/types'; +import { saveCollectionDraft } from '../../versions/drafts/saveCollectionDraft'; import { saveCollectionVersion } from '../../versions/saveCollectionVersion'; import uploadFile from '../../uploads/uploadFile'; @@ -22,7 +21,7 @@ export type Arguments = { overrideAccess?: boolean showHiddenFields?: boolean overwriteExistingFiles?: boolean - autosave?: boolean + draft?: boolean } async function update(this: Payload, incomingArgs: Arguments): Promise { @@ -58,13 +57,15 @@ async function update(this: Payload, incomingArgs: Arguments): Promise overrideAccess, showHiddenFields, overwriteExistingFiles = false, - autosave = false, + draft: draftArg = false, } = args; if (!id) { throw new APIError('Missing ID of document to update.', httpStatus.BAD_REQUEST); } + const shouldSaveDraft = Boolean(draftArg && collectionConfig.versions.drafts); + // ///////////////////////////////////// // Access // ///////////////////////////////////// @@ -188,7 +189,7 @@ async function update(this: Payload, incomingArgs: Arguments): Promise overrideAccess, unflattenLocales: true, docWithLocales, - skipValidation: autosave, + skipValidation: shouldSaveDraft, }); // ///////////////////////////////////// @@ -197,7 +198,7 @@ async function update(this: Payload, incomingArgs: Arguments): Promise const { password } = data; - if (password && collectionConfig.auth && !autosave) { + if (password && collectionConfig.auth && !shouldSaveDraft) { await doc.setPassword(password as string); await doc.save(); delete data.password; @@ -208,7 +209,15 @@ async function update(this: Payload, incomingArgs: Arguments): Promise // Update // ///////////////////////////////////// - if (!autosave) { + if (shouldSaveDraft) { + result = await saveCollectionDraft({ + payload: this, + config: collectionConfig, + req, + data: result, + id, + }); + } else { try { result = await Model.findByIdAndUpdate( { _id: id }, @@ -235,14 +244,13 @@ async function update(this: Payload, incomingArgs: Arguments): Promise // Create version from existing doc // ///////////////////////////////////// - if (collectionConfig.versions) { - saveCollectionVersion({ + if (collectionConfig.versions && !shouldSaveDraft) { + result = saveCollectionVersion({ payload: this, config: collectionConfig, req, docWithLocales, id, - autosave, }); } diff --git a/src/collections/requestHandlers/create.ts b/src/collections/requestHandlers/create.ts index 7f5e994ff0..e3adefb37e 100644 --- a/src/collections/requestHandlers/create.ts +++ b/src/collections/requestHandlers/create.ts @@ -16,7 +16,7 @@ export default async function create(req: PayloadRequest, res: Response, next: N collection: req.collection, data: req.body, depth: req.query.depth, - autosave: req.query.autosave === 'true', + draft: req.query.draft === 'true', }); return res.status(httpStatus.CREATED).json({ diff --git a/src/collections/requestHandlers/update.ts b/src/collections/requestHandlers/update.ts index 9f6c36b6a0..5c0ea2577c 100644 --- a/src/collections/requestHandlers/update.ts +++ b/src/collections/requestHandlers/update.ts @@ -16,7 +16,7 @@ export default async function update(req: PayloadRequest, res: Response, next: N id: req.params.id, data: req.body, depth: req.query.depth, - autosave: req.query.autosave === 'true', + draft: req.query.draft === 'true', }); return res.status(httpStatus.OK).json({ diff --git a/src/globals/graphql/init.ts b/src/globals/graphql/init.ts index 9e88481626..709804cbe8 100644 --- a/src/globals/graphql/init.ts +++ b/src/globals/graphql/init.ts @@ -46,7 +46,7 @@ function registerGlobals() { type: global.graphQL.type, args: { data: { type: global.graphQL.mutationInputType }, - autosave: { type: GraphQLBoolean }, + draft: { type: GraphQLBoolean }, }, resolve: update(global), }; diff --git a/src/globals/graphql/resolvers/update.ts b/src/globals/graphql/resolvers/update.ts index 11657bb55d..404a7c4c6b 100644 --- a/src/globals/graphql/resolvers/update.ts +++ b/src/globals/graphql/resolvers/update.ts @@ -13,7 +13,7 @@ function update(globalConfig) { depth: 0, data: args.data, req: context.req, - autosave: args.autosave, + draft: args.draft, }; const result = await this.operations.globals.update(options); diff --git a/src/globals/operations/local/update.ts b/src/globals/operations/local/update.ts index 3322144335..d294a0cd87 100644 --- a/src/globals/operations/local/update.ts +++ b/src/globals/operations/local/update.ts @@ -8,7 +8,7 @@ async function update(options) { user, overrideAccess = true, showHiddenFields, - autosave, + draft, } = options; const globalConfig = this.globals.config.find((config) => config.slug === globalSlug); @@ -20,7 +20,7 @@ async function update(options) { globalConfig, overrideAccess, showHiddenFields, - autosave, + draft, req: { user, payloadAPI: 'local', diff --git a/src/globals/operations/update.ts b/src/globals/operations/update.ts index 6799a0bf05..6ca585b847 100644 --- a/src/globals/operations/update.ts +++ b/src/globals/operations/update.ts @@ -3,6 +3,7 @@ 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(this: Payload, args): Promise { const { globals: { Model } } = this; @@ -14,9 +15,11 @@ async function update(this: Payload, args): Promise< depth, overrideAccess, showHiddenFields, - autosave, + draft: draftArg, } = args; + const shouldSaveDraft = Boolean(draftArg && globalConfig.versions.drafts); + // ///////////////////////////////////// // 1. Retrieve and execute access // ///////////////////////////////////// @@ -111,23 +114,28 @@ async function update(this: Payload, args): Promise< originalDoc, docWithLocales: globalJSON, overrideAccess, + skipValidation: shouldSaveDraft, }); // ///////////////////////////////////// // Update // ///////////////////////////////////// - if (!autosave) { - if (global) { - global = await Model.findOneAndUpdate( - { globalType: slug }, - result, - { new: true }, - ); - } else { - result.globalType = slug; - global = await Model.create(result); - } + if (shouldSaveDraft) { + global = await saveGlobalDraft({ + payload: this, + config: globalConfig, + data: result, + }); + } else if (global) { + global = await Model.findOneAndUpdate( + { globalType: slug }, + result, + { new: true }, + ); + } else { + result.globalType = slug; + global = await Model.create(result); } global = global.toJSON({ virtuals: true }); @@ -139,13 +147,12 @@ async function update(this: Payload, args): Promise< // Create version from existing doc // ///////////////////////////////////// - if (globalConfig.versions && hasExistingGlobal) { + if (globalConfig.versions && hasExistingGlobal && !shouldSaveDraft) { saveGlobalVersion({ payload: this, config: globalConfig, req, docWithLocales: global, - autosave, }); } diff --git a/src/globals/requestHandlers/update.ts b/src/globals/requestHandlers/update.ts index 65a4c3e6d8..707b1afed0 100644 --- a/src/globals/requestHandlers/update.ts +++ b/src/globals/requestHandlers/update.ts @@ -18,7 +18,7 @@ function update(globalConfig: SanitizedGlobalConfig): UpdateGlobalResponse { slug, depth: req.query.depth, data: req.body, - autosave: req.query.autosave, + draft: req.query.draft, }); return res.status(httpStatus.OK).json({ message: 'Global saved successfully.', result }); diff --git a/src/versions/drafts/saveCollectionDraft.ts b/src/versions/drafts/saveCollectionDraft.ts new file mode 100644 index 0000000000..47ec09e866 --- /dev/null +++ b/src/versions/drafts/saveCollectionDraft.ts @@ -0,0 +1,70 @@ +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 +} + +export const saveCollectionDraft = async ({ + payload, + config, + id, + data, +}: Args): Promise => { + const VersionsModel = payload.versions[config.slug] as CollectionModel; + + const existingAutosaveVersion = await VersionsModel.findOne({ + parent: id, + }); + + let result; + + try { + // If there is an existing autosave document, + // Update it + if (existingAutosaveVersion?.autosave === true) { + result = await VersionsModel.findByIdAndUpdate( + { + _id: existingAutosaveVersion._id, + }, + { + version: data, + autosave: true, + }, + { new: true, lean: true }, + ); + // Otherwise, create a new one + } else { + result = await VersionsModel.create({ + parent: id, + version: data, + autosave: true, + }); + } + } catch (err) { + payload.logger.error(`There was an error while autosaving the ${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.id = id; + + return result; +}; diff --git a/src/versions/drafts/saveGlobalDraft.ts b/src/versions/drafts/saveGlobalDraft.ts new file mode 100644 index 0000000000..5497946206 --- /dev/null +++ b/src/versions/drafts/saveGlobalDraft.ts @@ -0,0 +1,60 @@ +import { Payload } from '../..'; +import { enforceMaxVersions } from '../enforceMaxVersions'; +import { SanitizedGlobalConfig } from '../../globals/config/types'; + +type Args = { + payload: Payload + config?: SanitizedGlobalConfig + data: any +} + +export const saveGlobalDraft = async ({ + payload, + config, + data, +}: Args): Promise => { + const VersionsModel = payload.versions[config.slug]; + + const existingAutosaveVersion = await VersionsModel.findOne(); + + let result; + + try { + // If there is an existing autosave document, + // Update it + if (existingAutosaveVersion?.autosave === true) { + result = await VersionsModel.findByIdAndUpdate( + { + _id: existingAutosaveVersion._id, + }, + { + version: data, + autosave: true, + }, + { new: true, lean: true }, + ); + // Otherwise, create a new one + } else { + result = await VersionsModel.create({ + version: data, + autosave: true, + }); + } + } 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, + }); + } + + result = result.version; + return result; +}; diff --git a/src/versions/saveCollectionVersion.ts b/src/versions/saveCollectionVersion.ts index dab134d8a2..5af2b7ec19 100644 --- a/src/versions/saveCollectionVersion.ts +++ b/src/versions/saveCollectionVersion.ts @@ -2,6 +2,7 @@ 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 @@ -9,7 +10,6 @@ type Args = { req: PayloadRequest docWithLocales: any id: string | number - autosave: boolean } export const saveCollectionVersion = async ({ @@ -18,7 +18,6 @@ export const saveCollectionVersion = async ({ req, id, docWithLocales, - autosave, }: Args): Promise => { const VersionsModel = payload.versions[config.slug]; @@ -35,37 +34,14 @@ export const saveCollectionVersion = async ({ }); delete version._id; - - let existingAutosaveVersion; - - if (autosave) { - existingAutosaveVersion = await VersionsModel.findOne({ - parent: id, - }); - } + let result; try { - // If there is an existing autosave document, - // Update it - if (existingAutosaveVersion?.autosave === true) { - await VersionsModel.findByIdAndUpdate( - { - _id: existingAutosaveVersion._id, - }, - { - version, - autosave: Boolean(autosave), - }, - { new: true }, - ); - // Otherwise, create a new one - } else { - await VersionsModel.create({ - parent: id, - version, - autosave: Boolean(autosave), - }); - } + result = await VersionsModel.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); @@ -81,4 +57,10 @@ export const saveCollectionVersion = async ({ maxPerDoc: config.versions.maxPerDoc, }); } + + result = JSON.stringify(result); + result = JSON.parse(result); + result = sanitizeInternalFields(result); + + return result; }; diff --git a/src/versions/saveGlobalVersion.ts b/src/versions/saveGlobalVersion.ts index 459ceea70b..e354c92538 100644 --- a/src/versions/saveGlobalVersion.ts +++ b/src/versions/saveGlobalVersion.ts @@ -8,7 +8,6 @@ type Args = { config?: SanitizedGlobalConfig req: PayloadRequest docWithLocales: any - autosave?: boolean } export const saveGlobalVersion = async ({ @@ -16,7 +15,6 @@ export const saveGlobalVersion = async ({ config, req, docWithLocales, - autosave, }: Args): Promise => { const VersionsModel = payload.versions[config.slug]; @@ -31,29 +29,11 @@ export const saveGlobalVersion = async ({ showHiddenFields: true, }); - let existingAutosaveVersion; - - if (autosave) existingAutosaveVersion = await VersionsModel.findOne(); - try { - // If there is an existing autosave document, - // Update it - if (existingAutosaveVersion?.autosave === true) { - await VersionsModel.findByIdAndUpdate( - { _id: existingAutosaveVersion._id }, - { - version, - autosave: Boolean(autosave), - }, - { new: true }, - ); - // Otherwise, create a new one - } else { - await VersionsModel.create({ - version, - autosave: Boolean(autosave), - }); - } + 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); From 7220ff7a8ad284ccb421430ffa2ea97aaa1a7682 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 30 Dec 2021 16:37:49 -0500 Subject: [PATCH 45/66] feat: tests & autosave improvements --- demo/collections/Autosave.ts | 68 +++++++++++++++++++ demo/collections/Localized.ts | 31 +-------- demo/payload.config.ts | 2 + src/admin/components/elements/Pill/index.scss | 33 ++++++--- src/admin/components/elements/Pill/types.ts | 2 +- .../views/Version/Compare/index.tsx | 4 +- .../RenderFieldsToDiff/fields/Text/index.tsx | 6 +- .../RenderFieldsToDiff/fields/diffMethods.ts | 6 ++ .../RenderFieldsToDiff/fields/types.ts | 2 + .../Version/RenderFieldsToDiff/index.tsx | 5 ++ .../components/views/Version/Version.tsx | 13 ++-- src/admin/components/views/Version/shared.ts | 6 +- .../components/views/Versions/columns.tsx | 14 ++-- src/admin/components/views/Versions/index.tsx | 2 +- .../views/collections/Edit/Default.tsx | 10 +++ .../views/collections/Edit/index.tsx | 2 +- src/collections/operations/create.ts | 2 +- src/collections/operations/find.ts | 9 +-- src/collections/operations/findVersions.ts | 2 +- src/collections/operations/update.ts | 9 +-- src/globals/operations/findVersions.ts | 2 +- src/versions/drafts/saveCollectionDraft.ts | 2 +- src/versions/saveCollectionVersion.ts | 10 +-- src/versions/tests/rest.spec.ts | 28 ++++---- 24 files changed, 169 insertions(+), 101 deletions(-) create mode 100644 demo/collections/Autosave.ts create mode 100644 src/admin/components/views/Version/RenderFieldsToDiff/fields/diffMethods.ts diff --git a/demo/collections/Autosave.ts b/demo/collections/Autosave.ts new file mode 100644 index 0000000000..83dc2bbb92 --- /dev/null +++ b/demo/collections/Autosave.ts @@ -0,0 +1,68 @@ +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', + ], + }, + versions: { + maxPerDoc: 5, + 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 4ae59d6f75..8876c6cbb2 100644 --- a/demo/collections/Localized.ts +++ b/demo/collections/Localized.ts @@ -43,37 +43,8 @@ const LocalizedPosts: CollectionConfig = { ], enableRichTextRelationship: true, }, - versions: { - maxPerDoc: 5, - 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), + read: () => true, }, fields: [ { diff --git a/demo/payload.config.ts b/demo/payload.config.ts index 33d48b8004..72d84729a5 100644 --- a/demo/payload.config.ts +++ b/demo/payload.config.ts @@ -4,6 +4,7 @@ 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'; @@ -85,6 +86,7 @@ export default buildConfig({ Admin, AllFields, AutoLabel, + Autosave, Code, Conditions, // CustomComponents, 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/views/Version/Compare/index.tsx b/src/admin/components/views/Version/Compare/index.tsx index 2fef4ba8bc..6244780e09 100644 --- a/src/admin/components/views/Version/Compare/index.tsx +++ b/src/admin/components/views/Version/Compare/index.tsx @@ -5,7 +5,7 @@ import format from 'date-fns/format'; import { Props } from './types'; import ReactSelect from '../../../elements/ReactSelect'; import { PaginatedDocs } from '../../../../../mongoose/types'; -import { publishedVersionOption } from '../shared'; +import { mostRecentVersionOption } from '../shared'; import './index.scss'; @@ -14,7 +14,7 @@ const baseClass = 'compare-version'; const maxResultsPerRequest = 10; const baseOptions = [ - publishedVersionOption, + mostRecentVersionOption, ]; const CompareVersion: React.FC = (props) => { diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.tsx index 29139ec821..5c27477d75 100644 --- a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.tsx +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ReactDiffViewer from 'react-diff-viewer'; +import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer'; import Label from '../../Label'; import { Props } from '../types'; @@ -7,7 +7,7 @@ import './index.scss'; const baseClass = 'text-diff'; -const Text: React.FC = ({ field, locale, version, comparison, isRichText = false }) => { +const Text: React.FC = ({ field, locale, version, comparison, isRichText = false, diffMethod }) => { let placeholder = ''; if (version === comparison) placeholder = '[no value]'; @@ -20,7 +20,6 @@ const Text: React.FC = ({ field, locale, version, comparison, isRichText if (typeof comparison === 'object') comparisonToRender = JSON.stringify(comparison, null, 2); } - return (
    > export type Props = { + diffMethod?: DiffMethod fieldComponents: FieldComponents version: any comparison: any diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/index.tsx index 571f9b13bd..f628f35439 100644 --- a/src/admin/components/views/Version/RenderFieldsToDiff/index.tsx +++ b/src/admin/components/views/Version/RenderFieldsToDiff/index.tsx @@ -1,9 +1,11 @@ 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'; @@ -20,6 +22,7 @@ const RenderFieldsToDiff: React.FC = ({ const Component = fieldComponents[field.type]; const isRichText = field.type === 'richText'; + const diffMethod: DiffMethod = diffMethods[field.type] || 'CHARS'; if (Component) { if (fieldAffectsData(field)) { @@ -46,6 +49,7 @@ const RenderFieldsToDiff: React.FC = ({ >
    = ({ key={i} > = ({ 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 [compareValue, setCompareValue] = useState(mostRecentVersionOption); const [localeOptions] = useState(() => (localization?.locales ? localization.locales.map((locale) => ({ label: locale, value: locale })) : [])); const [locales, setLocales] = useState(localeOptions); const { permissions } = useAuth(); @@ -63,11 +63,12 @@ const VersionView: React.FC = ({ collection, global }) => { } const useAsTitle = collection?.admin?.useAsTitle || 'id'; - const compareFetchURL = compareValue?.value === 'published' ? originalDocFetchURL : `${compareBaseURL}/${compareValue.value}`; + + const compareFetchURL = compareValue?.value === 'mostRecent' ? originalDocFetchURL : `${compareBaseURL}/${compareValue.value}`; const [{ data: doc, isLoading }] = usePayloadAPI(versionFetchURL, { initialParams: { locale: '*', depth: 1 } }); - const [{ data: originalDoc }] = usePayloadAPI(originalDocFetchURL, { initialParams: { depth: 1 } }); - const [{ data: compareDoc }] = usePayloadAPI(compareFetchURL, { initialParams: { locale: '*', depth: 1 } }); + const [{ data: originalDoc }] = usePayloadAPI(originalDocFetchURL, { initialParams: { depth: 1, draft: 'true' } }); + const [{ data: compareDoc }] = usePayloadAPI(compareFetchURL, { initialParams: { locale: '*', depth: 1, draft: 'true' } }); useEffect(() => { let nav: StepNavItem[] = []; @@ -186,7 +187,7 @@ const VersionView: React.FC = ({ collection, global }) => { fieldComponents={fieldComponents} fieldPermissions={fieldPermissions} version={doc?.version} - comparison={compareValue?.value === 'published' ? compareDoc : compareDoc?.version} + comparison={compareValue?.value === 'mostRecent' ? compareDoc : compareDoc?.version} /> )}
    diff --git a/src/admin/components/views/Version/shared.ts b/src/admin/components/views/Version/shared.ts index 66a0353621..aa4762a6cd 100644 --- a/src/admin/components/views/Version/shared.ts +++ b/src/admin/components/views/Version/shared.ts @@ -1,4 +1,4 @@ -export const publishedVersionOption = { - label: 'Most recently published', - value: 'published', +export const mostRecentVersionOption = { + label: 'Most recent', + value: 'mostRecent', }; diff --git a/src/admin/components/views/Versions/columns.tsx b/src/admin/components/views/Versions/columns.tsx index 86c78b478a..454d7ed531 100644 --- a/src/admin/components/views/Versions/columns.tsx +++ b/src/admin/components/views/Versions/columns.tsx @@ -39,12 +39,12 @@ const TextCell: React.FC = ({ children }) => ( export const getColumns = (collection: SanitizedCollectionConfig, global: SanitizedGlobalConfig): Column[] => [ { - accessor: 'createdAt', + accessor: 'updatedAt', components: { Heading: ( ), renderCell: (row, data) => ( @@ -83,10 +83,10 @@ export const getColumns = (collection: SanitizedCollectionConfig, global: Saniti renderCell: (row, data) => ( {row?.autosave && ( - - Autosave - - )} + + Autosave + + )} ), }, diff --git a/src/admin/components/views/Versions/index.tsx b/src/admin/components/views/Versions/index.tsx index 9924a4fe24..88670a8ff5 100644 --- a/src/admin/components/views/Versions/index.tsx +++ b/src/admin/components/views/Versions/index.tsx @@ -44,7 +44,7 @@ const Versions: React.FC = ({ collection, global }) => { } const useAsTitle = collection?.admin?.useAsTitle || 'id'; - const [{ data: doc }] = usePayloadAPI(docURL); + const [{ data: doc }] = usePayloadAPI(docURL, { initialParams: { draft: 'true' } }); const [{ data: versionsData, isLoading: isLoadingVersions }, { setParams }] = usePayloadAPI(fetchURL); useEffect(() => { diff --git a/src/admin/components/views/collections/Edit/Default.tsx b/src/admin/components/views/collections/Edit/Default.tsx index 2e66db0106..b1566f8dae 100644 --- a/src/admin/components/views/collections/Edit/Default.tsx +++ b/src/admin/components/views/collections/Edit/Default.tsx @@ -20,6 +20,8 @@ import VersionsCount from '../../../elements/VersionsCount'; import Upload from './Upload'; import { Props } from './types'; import Autosave from '../../../elements/Autosave'; +import Select from '../../../forms/field-types/Select'; +import { statuses } from '../../../../../versions/baseFields'; import './index.scss'; @@ -159,6 +161,14 @@ const DefaultEditView: React.FC = (props) => { {!isLoading && (
    + {collection.versions?.drafts && ( + - )} - {(collection.versions?.drafts && collection.versions.drafts.autosave && hasSavePermission) && ( - + + + {(collection.versions.drafts.autosave && hasSavePermission) && ( + + )} + )} = () => { + const { unpublishedVersions, publishedDoc } = useDocumentInfo(); + const hasNewerVersions = unpublishedVersions?.totalDocs > 0; + + const canPublish = hasNewerVersions || !publishedDoc; + + return ( + + {canPublish === true && ( + + Publish changes + + )} + + ); +}; + +export default Publish; diff --git a/src/admin/components/views/collections/Edit/Publish/types.ts b/src/admin/components/views/collections/Edit/Publish/types.ts new file mode 100644 index 0000000000..626a93dca7 --- /dev/null +++ b/src/admin/components/views/collections/Edit/Publish/types.ts @@ -0,0 +1,7 @@ +import { SanitizedCollectionConfig } from '../../../../../../collections/config/types'; + +export type Props = { + collection: SanitizedCollectionConfig + updatedAt?: string + id?: number | string +} diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx index 8ca951e934..01f57081a1 100644 --- a/src/admin/components/views/collections/Edit/index.tsx +++ b/src/admin/components/views/collections/Edit/index.tsx @@ -124,9 +124,8 @@ const EditView: React.FC = (props) => { return ( = any>(options: Options): Promise { +export default async function publishVersion = any>(options: Options): Promise { const { collection: collectionSlug, depth, @@ -44,5 +44,5 @@ export default async function restoreVersion = any> }, }; - return this.operations.collections.restoreVersion(args); + return this.operations.collections.publishVersion(args); } diff --git a/src/collections/operations/local/update.ts b/src/collections/operations/local/update.ts index fae5da8dba..492f15b263 100644 --- a/src/collections/operations/local/update.ts +++ b/src/collections/operations/local/update.ts @@ -14,6 +14,7 @@ export type Options = { filePath?: string overwriteExistingFiles?: boolean draft?: boolean + autosave?: boolean } export default async function update(options: Options): Promise { @@ -30,6 +31,7 @@ export default async function update(options: Options): Promise { filePath, overwriteExistingFiles = false, draft, + autosave, } = options; const collection = this.collections[collectionSlug]; @@ -43,6 +45,7 @@ export default async function update(options: Options): Promise { showHiddenFields, overwriteExistingFiles, draft, + autosave, req: { user, payloadAPI: 'local', diff --git a/src/collections/operations/restoreVersion.ts b/src/collections/operations/publishVersion.ts similarity index 97% rename from src/collections/operations/restoreVersion.ts rename to src/collections/operations/publishVersion.ts index b94404172a..25f195d8a6 100644 --- a/src/collections/operations/restoreVersion.ts +++ b/src/collections/operations/publishVersion.ts @@ -20,7 +20,7 @@ export type Arguments = { depth?: number } -async function restoreVersion(this: Payload, args: Arguments): Promise { +async function publishVersion(this: Payload, args: Arguments): Promise { const { collection: { Model, @@ -171,4 +171,4 @@ async function restoreVersion(this: Payload, args: A return result; } -export default restoreVersion; +export default publishVersion; diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index 2cfd660b6b..efbfe5b976 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -22,6 +22,7 @@ export type Arguments = { showHiddenFields?: boolean overwriteExistingFiles?: boolean draft?: boolean + autosave?: boolean } async function update(this: Payload, incomingArgs: Arguments): Promise { @@ -58,6 +59,7 @@ async function update(this: Payload, incomingArgs: Arguments): Promise showHiddenFields, overwriteExistingFiles = false, draft: draftArg = false, + autosave = false, } = args; if (!id) { @@ -216,6 +218,7 @@ async function update(this: Payload, incomingArgs: Arguments): Promise req, data: result, id, + autosave, }); } else { try { diff --git a/src/collections/requestHandlers/restoreVersion.ts b/src/collections/requestHandlers/publishVersion.ts similarity index 84% rename from src/collections/requestHandlers/restoreVersion.ts rename to src/collections/requestHandlers/publishVersion.ts index fcf57ec49e..b16dbab6f3 100644 --- a/src/collections/requestHandlers/restoreVersion.ts +++ b/src/collections/requestHandlers/publishVersion.ts @@ -9,7 +9,7 @@ export type RestoreResult = { doc: Document }; -export default async function restoreVersion(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { +export default async function publishVersion(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { const options = { req, collection: req.collection, @@ -18,7 +18,7 @@ export default async function restoreVersion(req: PayloadRequest, res: Response, }; try { - const doc = await this.operations.collections.restoreVersion(options); + const doc = await this.operations.collections.publishVersion(options); return res.status(httpStatus.OK).json({ ...formatSuccessResponse('Restored successfully.', 'message'), doc, diff --git a/src/collections/requestHandlers/update.ts b/src/collections/requestHandlers/update.ts index 5c0ea2577c..57cc251f2f 100644 --- a/src/collections/requestHandlers/update.ts +++ b/src/collections/requestHandlers/update.ts @@ -17,6 +17,7 @@ export default async function update(req: PayloadRequest, res: Response, next: N data: req.body, depth: req.query.depth, draft: req.query.draft === 'true', + autosave: req.query.autosave === 'true', }); return res.status(httpStatus.OK).json({ diff --git a/src/globals/config/types.ts b/src/globals/config/types.ts index afad91cc54..e552f9c5fd 100644 --- a/src/globals/config/types.ts +++ b/src/globals/config/types.ts @@ -4,7 +4,7 @@ import { DeepRequired } from 'ts-essentials'; import { PayloadRequest } from '../../express/types'; import { Access, GeneratePreviewURL } from '../../config/types'; import { Field } from '../../fields/config/types'; -import { IncomingGlobalVersions } from '../../versions/types'; +import { IncomingGlobalVersions, SanitizedGlobalVersions } from '../../versions/types'; export type TypeWithID = { id: string @@ -72,9 +72,7 @@ export type GlobalConfig = { export interface SanitizedGlobalConfig extends Omit, 'fields' | 'versions'> { fields: Field[] - versions?: { - max?: number - } + versions: SanitizedGlobalVersions } export type Globals = { diff --git a/src/globals/init.ts b/src/globals/init.ts index 6f09ef5aa3..ca3d75f041 100644 --- a/src/globals/init.ts +++ b/src/globals/init.ts @@ -54,7 +54,7 @@ export default function initGlobals(ctx: Payload): void { router.route(`/globals/${global.slug}/versions/:id`) .get(ctx.requestHandlers.globals.findVersionByID(global)) - .post(ctx.requestHandlers.globals.restoreVersion(global)); + .post(ctx.requestHandlers.globals.publishVersion(global)); } }); diff --git a/src/globals/operations/restoreVersion.ts b/src/globals/operations/publishVersion.ts similarity index 97% rename from src/globals/operations/restoreVersion.ts rename to src/globals/operations/publishVersion.ts index 43bcf17ba7..29c80d2fcf 100644 --- a/src/globals/operations/restoreVersion.ts +++ b/src/globals/operations/publishVersion.ts @@ -23,7 +23,7 @@ export type Arguments = { // TODO: finish -async function restoreVersion = any>(args: Arguments): Promise> { +async function publishVersion = any>(args: Arguments): Promise> { const { where, page, @@ -159,4 +159,4 @@ async function restoreVersion = any>(args: Argument return result; } -export default restoreVersion; +export default publishVersion; diff --git a/src/globals/operations/update.ts b/src/globals/operations/update.ts index 6ca585b847..f4e4968a9f 100644 --- a/src/globals/operations/update.ts +++ b/src/globals/operations/update.ts @@ -16,6 +16,7 @@ async function update(this: Payload, args): Promise< overrideAccess, showHiddenFields, draft: draftArg, + autosave, } = args; const shouldSaveDraft = Boolean(draftArg && globalConfig.versions.drafts); @@ -126,6 +127,7 @@ async function update(this: Payload, args): Promise< payload: this, config: globalConfig, data: result, + autosave, }); } else if (global) { global = await Model.findOneAndUpdate( diff --git a/src/globals/requestHandlers/restoreVersion.ts b/src/globals/requestHandlers/publishVersion.ts similarity index 80% rename from src/globals/requestHandlers/restoreVersion.ts rename to src/globals/requestHandlers/publishVersion.ts index b3e9a13d16..36ea169ab1 100644 --- a/src/globals/requestHandlers/restoreVersion.ts +++ b/src/globals/requestHandlers/publishVersion.ts @@ -13,13 +13,13 @@ export default function (globalConfig: SanitizedGlobalConfig) { }; try { - const doc = await this.operations.globals.restoreVersion(options); + const doc = await this.operations.globals.publishVersion(options); return res.json(doc); } catch (error) { return next(error); } } - const restoreVersionHandler = handler.bind(this); - return restoreVersionHandler; + const publishVersionHandler = handler.bind(this); + return publishVersionHandler; } diff --git a/src/index.ts b/src/index.ts index dc4202098c..5041b082c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,7 +48,7 @@ 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/restoreVersion'; +import { Options as RestoreVersionOptions } from './collections/operations/local/publishVersion'; import { Result } from './auth/operations/login'; require('isomorphic-fetch'); @@ -301,16 +301,16 @@ export class Payload { * @param options * @returns version with specified ID */ - restoreVersion = async = any>(options: RestoreVersionOptions): Promise => { - let { restoreVersion } = localOperations; - restoreVersion = restoreVersion.bind(this); - return restoreVersion(options); + publishVersion = async = any>(options: RestoreVersionOptions): Promise => { + let { publishVersion } = localOperations; + publishVersion = publishVersion.bind(this); + return publishVersion(options); } // TODO: globals // findVersionGlobal // findVersionByIDGlobal - // restoreVersionGlobal + // publishVersionGlobal // TODO: // graphql operations & request handlers, where // tests diff --git a/src/init/bindOperations.ts b/src/init/bindOperations.ts index a12a5bc86d..e1b1b7b3d0 100644 --- a/src/init/bindOperations.ts +++ b/src/init/bindOperations.ts @@ -16,14 +16,14 @@ 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 restoreVersion from '../collections/operations/restoreVersion'; +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 restoreGlobalVersion from '../globals/operations/restoreVersion'; +import publishGlobalVersion from '../globals/operations/publishVersion'; import globalUpdate from '../globals/operations/update'; import preferenceUpdate from '../preferences/operations/update'; @@ -37,7 +37,7 @@ export type Operations = { findByID: typeof findByID findVersions: typeof findVersions findVersionByID: typeof findVersionByID - restoreVersion: typeof restoreVersion + publishVersion: typeof publishVersion update: typeof update delete: typeof deleteHandler auth: { @@ -58,7 +58,7 @@ export type Operations = { findOne: typeof findOne findVersions: typeof findGlobalVersions findVersionByID: typeof findGlobalVersionByID - restoreVersion: typeof restoreGlobalVersion + publishVersion: typeof publishGlobalVersion update: typeof globalUpdate } preferences: { @@ -76,7 +76,7 @@ function bindOperations(ctx: Payload): void { findByID: findByID.bind(ctx), findVersions: findVersions.bind(ctx), findVersionByID: findVersionByID.bind(ctx), - restoreVersion: restoreVersion.bind(ctx), + publishVersion: publishVersion.bind(ctx), update: update.bind(ctx), delete: deleteHandler.bind(ctx), auth: { @@ -97,7 +97,7 @@ function bindOperations(ctx: Payload): void { findOne: findOne.bind(ctx), findVersions: findGlobalVersions.bind(ctx), findVersionByID: findGlobalVersionByID.bind(ctx), - restoreVersion: restoreGlobalVersion.bind(ctx), + publishVersion: publishGlobalVersion.bind(ctx), update: globalUpdate.bind(ctx), }, preferences: { diff --git a/src/init/bindRequestHandlers.ts b/src/init/bindRequestHandlers.ts index f557f456f8..8cff73ee61 100644 --- a/src/init/bindRequestHandlers.ts +++ b/src/init/bindRequestHandlers.ts @@ -15,14 +15,14 @@ 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 restoreVersion from '../collections/requestHandlers/restoreVersion'; +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 restoreGlobalVersion from '../globals/requestHandlers/restoreVersion'; +import publishGlobalVersion from '../globals/requestHandlers/publishVersion'; import globalUpdate from '../globals/requestHandlers/update'; import { Payload } from '../index'; import preferenceUpdate from '../preferences/requestHandlers/update'; @@ -36,7 +36,7 @@ export type RequestHandlers = { findByID: typeof findByID, findVersions: typeof findVersions findVersionByID: typeof findVersionByID, - restoreVersion: typeof restoreVersion, + publishVersion: typeof publishVersion, update: typeof update, delete: typeof deleteHandler, auth: { @@ -58,7 +58,7 @@ export type RequestHandlers = { update: typeof globalUpdate, findVersions: typeof findGlobalVersions findVersionByID: typeof findGlobalVersionByID - restoreVersion: typeof restoreGlobalVersion + publishVersion: typeof publishGlobalVersion }, preferences: { update: typeof preferenceUpdate, @@ -75,7 +75,7 @@ function bindRequestHandlers(ctx: Payload): void { findByID: findByID.bind(ctx), findVersions: findVersions.bind(ctx), findVersionByID: findVersionByID.bind(ctx), - restoreVersion: restoreVersion.bind(ctx), + publishVersion: publishVersion.bind(ctx), update: update.bind(ctx), delete: deleteHandler.bind(ctx), auth: { @@ -97,7 +97,7 @@ function bindRequestHandlers(ctx: Payload): void { update: globalUpdate.bind(ctx), findVersions: findGlobalVersions.bind(ctx), findVersionByID: findGlobalVersionByID.bind(ctx), - restoreVersion: restoreGlobalVersion.bind(ctx), + publishVersion: publishGlobalVersion.bind(ctx), }, preferences: { update: preferenceUpdate.bind(ctx), diff --git a/src/versions/drafts/replaceWithDraftIfAvailable.ts b/src/versions/drafts/replaceWithDraftIfAvailable.ts index f57400a32c..46d7e2868d 100644 --- a/src/versions/drafts/replaceWithDraftIfAvailable.ts +++ b/src/versions/drafts/replaceWithDraftIfAvailable.ts @@ -66,7 +66,12 @@ const replaceWithDraftIfAvailable = async ({ // Disregard all other draft content at this point, // Only interested in the version itself. // Operations will handle firing hooks, etc. - return draft.version; + return { + id: doc.id, + ...draft.version, + createdAt: draft.createdAt, + updatedAt: draft.updatedAt, + }; } return doc; diff --git a/src/versions/drafts/saveCollectionDraft.ts b/src/versions/drafts/saveCollectionDraft.ts index 06d3cddbb5..47aa2a7c2e 100644 --- a/src/versions/drafts/saveCollectionDraft.ts +++ b/src/versions/drafts/saveCollectionDraft.ts @@ -9,6 +9,7 @@ type Args = { req: PayloadRequest data: any id: string | number + autosave: boolean } export const saveCollectionDraft = async ({ @@ -16,26 +17,30 @@ export const saveCollectionDraft = async ({ config, id, data, + autosave, }: Args): Promise => { const VersionsModel = payload.versions[config.slug] as CollectionModel; - const existingAutosaveVersion = await VersionsModel.findOne({ - parent: id, - }, {}, { sort: { updatedAt: 'desc' } }); + 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 (existingAutosaveVersion?.autosave === true) { + if (autosave && existingAutosaveVersion?.autosave === true) { result = await VersionsModel.findByIdAndUpdate( { _id: existingAutosaveVersion._id, }, { version: data, - autosave: true, }, { new: true, lean: true }, ); @@ -44,11 +49,11 @@ export const saveCollectionDraft = async ({ result = await VersionsModel.create({ parent: id, version: data, - autosave: true, + autosave: Boolean(autosave), }); } } catch (err) { - payload.logger.error(`There was an error while autosaving the ${config.labels.singular} with ID ${id}.`); + payload.logger.error(`There was an error while creating a draft ${config.labels.singular} with ID ${id}.`); payload.logger.error(err); } diff --git a/src/versions/drafts/saveGlobalDraft.ts b/src/versions/drafts/saveGlobalDraft.ts index 5497946206..e1305e29bc 100644 --- a/src/versions/drafts/saveGlobalDraft.ts +++ b/src/versions/drafts/saveGlobalDraft.ts @@ -6,30 +6,35 @@ type Args = { payload: Payload config?: SanitizedGlobalConfig data: any + autosave: boolean } export const saveGlobalDraft = async ({ payload, config, data, + autosave, }: Args): Promise => { const VersionsModel = payload.versions[config.slug]; - const existingAutosaveVersion = await VersionsModel.findOne(); + let existingAutosaveVersion; + + if (autosave) { + existingAutosaveVersion = await VersionsModel.findOne(); + } let result; try { // If there is an existing autosave document, // Update it - if (existingAutosaveVersion?.autosave === true) { + if (autosave && existingAutosaveVersion?.autosave === true) { result = await VersionsModel.findByIdAndUpdate( { _id: existingAutosaveVersion._id, }, { version: data, - autosave: true, }, { new: true, lean: true }, ); @@ -37,11 +42,11 @@ export const saveGlobalDraft = async ({ } else { result = await VersionsModel.create({ version: data, - autosave: true, + autosave: Boolean(autosave), }); } } catch (err) { - payload.logger.error(`There was an error while saving a version for the Global ${config.label}.`); + payload.logger.error(`There was an error while saving a draft for the Global ${config.label}.`); payload.logger.error(err); } diff --git a/src/versions/types.ts b/src/versions/types.ts index bca9521d78..925b7ec954 100644 --- a/src/versions/types.ts +++ b/src/versions/types.ts @@ -36,4 +36,6 @@ export type TypeWithVersion = { id: string parent: string | number version: T + createdAt: string + updatedAt: string } From b80d263e7f62eab36b96955d589a939a043c0709 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 30 Dec 2021 21:07:03 -0500 Subject: [PATCH 47/66] chore: passing tests --- .../components/elements/Button/index.tsx | 3 +- .../components/elements/Publish/index.tsx | 34 ++++++++++++++++ .../components/elements/Publish/types.ts | 1 + src/admin/components/forms/Form/index.tsx | 39 ++++++++++++++----- .../components/forms/Form/initContextState.ts | 1 + src/admin/components/forms/Form/types.ts | 7 +++- src/admin/components/forms/Submit/index.tsx | 6 +-- .../views/collections/Edit/Default.tsx | 8 +--- .../views/collections/Edit/Publish/index.tsx | 26 ------------- .../views/collections/Edit/Publish/types.ts | 7 ---- src/versions/tests/rest.spec.ts | 2 +- 11 files changed, 79 insertions(+), 55 deletions(-) create mode 100644 src/admin/components/elements/Publish/index.tsx create mode 100644 src/admin/components/elements/Publish/types.ts delete mode 100644 src/admin/components/views/collections/Edit/Publish/index.tsx delete mode 100644 src/admin/components/views/collections/Edit/Publish/types.ts diff --git a/src/admin/components/elements/Button/index.tsx b/src/admin/components/elements/Button/index.tsx index ec859765d5..d4c62c812e 100644 --- a/src/admin/components/elements/Button/index.tsx +++ b/src/admin/components/elements/Button/index.tsx @@ -78,7 +78,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/Publish/index.tsx b/src/admin/components/elements/Publish/index.tsx new file mode 100644 index 0000000000..a3ab34e7aa --- /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 } from '../../forms/Form/context'; + +const Publish: React.FC = () => { + const { unpublishedVersions, publishedDoc } = useDocumentInfo(); + const { submit } = useForm(); + + const hasNewerVersions = unpublishedVersions?.totalDocs > 0; + + const canPublish = 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/forms/Form/index.tsx b/src/admin/components/forms/Form/index.tsx index c2b5d619c9..757f94b455 100644 --- a/src/admin/components/forms/Form/index.tsx +++ b/src/admin/components/forms/Form/index.tsx @@ -50,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 = {}; @@ -98,14 +99,22 @@ const Form: React.FC = (props) => { return isValid; }, [contextRef]); - const submit = useCallback(async (e): Promise => { + const submit = useCallback(async (options = {}, e): Promise => { + const { + overrides = {}, + } = options; + if (disabled) { - e.preventDefault(); + if (e) { + e.preventDefault(); + } return; } - e.stopPropagation(); - e.preventDefault(); + if (e) { + e.stopPropagation(); + e.preventDefault(); + } setProcessing(true); @@ -125,11 +134,16 @@ 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, { @@ -269,11 +283,16 @@ 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 createFormData = useCallback((overrides: any = {}) => { const data = reduceFieldsToValues(contextRef.current.fields); + const dataWithOverrides = { + ...data, + ...overrides, + }; + // nullAsUndefineds is important to allow uploads and relationship fields to clear themselves - const formData = serialize(data, { indices: true, nullsAsUndefineds: false }); + const formData = serialize(dataWithOverrides, { indices: true, nullsAsUndefineds: false }); return formData; }, [contextRef]); @@ -291,6 +310,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) { @@ -330,10 +350,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; @@ -72,4 +74,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 e20a160a21..85e67a4fba 100644 --- a/src/admin/components/forms/Submit/index.tsx +++ b/src/admin/components/forms/Submit/index.tsx @@ -7,15 +7,15 @@ import './index.scss'; const baseClass = 'form-submit'; -const FormSubmit: React.FC = ({ children, type = 'submit', disabled: disabledFromProps, buttonStyle }) => { +const FormSubmit: React.FC = (props) => { + const { children, disabled: disabledFromProps } = props; const processing = useFormProcessing(); const { disabled } = useForm(); return (