diff --git a/docs/versions/drafts.mdx b/docs/versions/drafts.mdx index a97d362d38..0f92e4fa14 100644 --- a/docs/versions/drafts.mdx +++ b/docs/versions/drafts.mdx @@ -3,7 +3,7 @@ title: Drafts label: Drafts order: 20 desc: Enable drafts on collection documents or globals and build true preview environments for your data. -keywords: version history, drafts, preview, draft, publish, autosave, Content Management System, cms, headless, javascript, node, react, express +keywords: version history, drafts, preview, draft, restore, publish, autosave, Content Management System, cms, headless, javascript, node, react, express --- Payload's Draft functionality builds on top of the Versions functionality to allow you to make changes to your collection documents and globals, but publish only when you're ready. This functionality allows you to build powerful Preview environments for your data, where you can make sure your changes look good before publishing documents. diff --git a/docs/versions/overview.mdx b/docs/versions/overview.mdx index eb796e3865..79207d179d 100644 --- a/docs/versions/overview.mdx +++ b/docs/versions/overview.mdx @@ -3,7 +3,7 @@ title: Versions label: Overview order: 10 desc: Keep a running history or audit log of changes to collection documents and globals. -keywords: version history, revisions, audit log, draft, publish, autosave, Content Management System, cms, headless, javascript, node, react, express +keywords: version history, revisions, audit log, draft, publish, restore, autosave, Content Management System, cms, headless, javascript, node, react, express --- diff --git a/src/collections/graphql/init.ts b/src/collections/graphql/init.ts index 1d454a82d3..452fd1aa99 100644 --- a/src/collections/graphql/init.ts +++ b/src/collections/graphql/init.ts @@ -11,9 +11,11 @@ import formatName from '../../graphql/utilities/formatName'; import buildPaginatedListType from '../../graphql/schema/buildPaginatedListType'; import { BaseFields } from './types'; import { getCollectionIDType } from '../../graphql/schema/buildMutationInputType'; +import buildVersionWhereInputType from '../../graphql/schema/buildVersionWhereInputType'; function registerCollections(): void { const { + findVersions, findVersionByID, restoreVersion, create, find, findByID, deleteResolver, update, } = this.graphQL.resolvers.collections; @@ -179,6 +181,42 @@ function registerCollections(): void { resolve: deleteResolver(collection), }; + if (collection.config.versions) { + collection.graphQL.versionType = this.buildVersionType(collection.graphQL.type, collection.config.versions); + this.Query.fields[`version${formatName(singularLabel)}`] = { + type: collection.graphQL.versionType, + args: { + id: { type: GraphQLString }, + ...(this.config.localization ? { + locale: { type: this.types.localeInputType }, + fallbackLocale: { type: this.types.fallbackLocaleInputType }, + } : {}), + }, + resolve: findVersionByID(collection), + }; + this.Query.fields[`versions${pluralLabel}`] = { + type: buildPaginatedListType(`versions${formatName(pluralLabel)}`, collection.graphQL.versionType), + args: { + where: { type: buildVersionWhereInputType(singularLabel, collection.config) }, + ...(this.config.localization ? { + locale: { type: this.types.localeInputType }, + fallbackLocale: { type: this.types.fallbackLocaleInputType }, + } : {}), + page: { type: GraphQLInt }, + limit: { type: GraphQLInt }, + sort: { type: GraphQLString }, + }, + resolve: findVersions(collection), + }; + this.Mutation.fields[`restoreVersion${formatName(singularLabel)}`] = { + type: new GraphQLNonNull(GraphQLBoolean), + args: { + id: { type: GraphQLString }, + }, + resolve: restoreVersion(collection), + }; + } + if (collection.config.auth) { collection.graphQL.JWT = this.buildObjectType( formatName(`${slug}JWT`), diff --git a/src/collections/graphql/resolvers/findVersionByID.ts b/src/collections/graphql/resolvers/findVersionByID.ts new file mode 100644 index 0000000000..5c547c9fbf --- /dev/null +++ b/src/collections/graphql/resolvers/findVersionByID.ts @@ -0,0 +1,37 @@ +/* eslint-disable no-param-reassign */ +import { Response } from 'express'; +import { Collection } from '../../config/types'; +import { PayloadRequest } from '../../../express/types'; + +export type Resolver = ( + _: unknown, + args: { + locale?: string + fallbackLocale?: string + }, + context: { + req: PayloadRequest, + res: Response + } +) => Promise + +export default function findVersionByID(collection: Collection): Resolver { + async function resolver(_, args, context) { + if (args.locale) context.req.locale = args.locale; + if (args.fallbackLocale) context.req.fallbackLocale = args.fallbackLocale; + + const options = { + collection, + id: args.id, + req: context.req, + draft: args.draft, + }; + + const result = await this.operations.collections.findVersionByID(options); + + return result; + } + + const findVersionByIDResolver = resolver.bind(this); + return findVersionByIDResolver; +} diff --git a/src/collections/graphql/resolvers/findVersions.ts b/src/collections/graphql/resolvers/findVersions.ts new file mode 100644 index 0000000000..741a164cbf --- /dev/null +++ b/src/collections/graphql/resolvers/findVersions.ts @@ -0,0 +1,45 @@ +/* eslint-disable no-param-reassign */ + +import { Response } from 'express'; +import { Where } from '../../../types'; +import { PayloadRequest } from '../../../express/types'; +import { Collection } from '../../config/types'; + +export type Resolver = ( + _: unknown, + args: { + locale?: string + fallbackLocale?: string + where: Where + limit?: number + page?: number + sort?: string + }, + context: { + req: PayloadRequest, + res: Response + } +) => Promise + +export default function findVersions(collection: Collection): Resolver { + async function resolver(_, args, context) { + if (args.locale) context.req.locale = args.locale; + if (args.fallbackLocale) context.req.fallbackLocale = args.fallbackLocale; + + const options = { + collection, + where: args.where, + limit: args.limit, + page: args.page, + sort: args.sort, + req: context.req, + }; + + const result = await this.operations.collections.findVersions(options); + + return result; + } + + const findVersionsResolver = resolver.bind(this); + return findVersionsResolver; +} diff --git a/src/collections/graphql/resolvers/restoreVersion.ts b/src/collections/graphql/resolvers/restoreVersion.ts new file mode 100644 index 0000000000..e229eeed61 --- /dev/null +++ b/src/collections/graphql/resolvers/restoreVersion.ts @@ -0,0 +1,35 @@ +/* eslint-disable no-param-reassign */ +import { Response } from 'express'; +import { Collection } from '../../config/types'; +import { PayloadRequest } from '../../../express/types'; + +export type Resolver = ( + _: unknown, + args: { + locale?: string + fallbackLocale?: string + }, + context: { + req: PayloadRequest, + res: Response + } +) => Promise + +export default function restoreVersion(collection: Collection): Resolver { + async function resolver(_, args, context) { + if (args.locale) context.req.locale = args.locale; + if (args.fallbackLocale) context.req.fallbackLocale = args.fallbackLocale; + + const options = { + collection, + id: args.id, + req: context.req, + }; + + await this.operations.collections.restoreVersion(options); + return true; + } + + const restoreVersionResolver = resolver.bind(this); + return restoreVersionResolver; +} diff --git a/src/collections/init.ts b/src/collections/init.ts index 9ad618ea08..adbe2a0bb3 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -106,7 +106,7 @@ export default function registerCollections(ctx: Payload): void { findByID, findVersions, findVersionByID, - publishVersion, + restoreVersion, delete: deleteHandler, } = ctx.requestHandlers.collections; @@ -182,7 +182,7 @@ export default function registerCollections(ctx: Payload): void { router.route(`/${slug}/versions/:id`) .get(findVersionByID) - .post(publishVersion); + .post(restoreVersion); } router.route(`/${slug}`) diff --git a/src/collections/operations/local/index.ts b/src/collections/operations/local/index.ts index e13d251f54..c6cc8fe5f4 100644 --- a/src/collections/operations/local/index.ts +++ b/src/collections/operations/local/index.ts @@ -6,7 +6,7 @@ import localDelete from './delete'; import auth from '../../../auth/operations/local'; import findVersionByID from './findVersionByID'; import findVersions from './findVersions'; -import publishVersion from './publishVersion'; +import restoreVersion from './restoreVersion'; export default { find, @@ -17,5 +17,5 @@ export default { auth, findVersionByID, findVersions, - publishVersion, + restoreVersion, }; diff --git a/src/collections/operations/local/publishVersion.ts b/src/collections/operations/local/restoreVersion.ts similarity index 87% rename from src/collections/operations/local/publishVersion.ts rename to src/collections/operations/local/restoreVersion.ts index 352f9fe807..b01da6012a 100644 --- a/src/collections/operations/local/publishVersion.ts +++ b/src/collections/operations/local/restoreVersion.ts @@ -13,7 +13,7 @@ export type Options = { showHiddenFields?: boolean } -export default async function publishVersion = any>(options: Options): Promise { +export default async function restoreVersion = any>(options: Options): Promise { const { collection: collectionSlug, depth, @@ -44,5 +44,5 @@ export default async function publishVersion = any> }, }; - return this.operations.collections.publishVersion(args); + return this.operations.collections.restoreVersion(args); } diff --git a/src/collections/operations/publishVersion.ts b/src/collections/operations/restoreVersion.ts similarity index 97% rename from src/collections/operations/publishVersion.ts rename to src/collections/operations/restoreVersion.ts index 069bfaf077..1fde3d1705 100644 --- a/src/collections/operations/publishVersion.ts +++ b/src/collections/operations/restoreVersion.ts @@ -20,7 +20,7 @@ export type Arguments = { depth?: number } -async function publishVersion(this: Payload, args: Arguments): Promise { +async function restoreVersion(this: Payload, args: Arguments): Promise { const { collection: { Model, @@ -171,4 +171,4 @@ async function publishVersion(this: Payload, args: A return result; } -export default publishVersion; +export default restoreVersion; diff --git a/src/collections/requestHandlers/publishVersion.ts b/src/collections/requestHandlers/restoreVersion.ts similarity index 84% rename from src/collections/requestHandlers/publishVersion.ts rename to src/collections/requestHandlers/restoreVersion.ts index b16dbab6f3..fcf57ec49e 100644 --- a/src/collections/requestHandlers/publishVersion.ts +++ b/src/collections/requestHandlers/restoreVersion.ts @@ -9,7 +9,7 @@ export type RestoreResult = { doc: Document }; -export default async function publishVersion(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { +export default async function restoreVersion(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { const options = { req, collection: req.collection, @@ -18,7 +18,7 @@ export default async function publishVersion(req: PayloadRequest, res: Response, }; try { - const doc = await this.operations.collections.publishVersion(options); + const doc = await this.operations.collections.restoreVersion(options); return res.status(httpStatus.OK).json({ ...formatSuccessResponse('Restored successfully.', 'message'), doc, diff --git a/src/globals/init.ts b/src/globals/init.ts index f135565059..4d6ca309dc 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.publishVersion(global)); + .post(ctx.requestHandlers.globals.restoreVersion(global)); } }); diff --git a/src/globals/operations/publishVersion.ts b/src/globals/operations/restoreVersion.ts similarity index 97% rename from src/globals/operations/publishVersion.ts rename to src/globals/operations/restoreVersion.ts index 413a2f1d2e..6162191b58 100644 --- a/src/globals/operations/publishVersion.ts +++ b/src/globals/operations/restoreVersion.ts @@ -23,7 +23,7 @@ export type Arguments = { // TODO: finish -async function publishVersion = any>(this: Payload, args: Arguments): Promise> { +async function restoreVersion = any>(this: Payload, args: Arguments): Promise> { const { globals: { Model } } = this; const { @@ -143,4 +143,4 @@ async function publishVersion = any>(this: Payload, return result; } -export default publishVersion; +export default restoreVersion; diff --git a/src/globals/requestHandlers/publishVersion.ts b/src/globals/requestHandlers/restoreVersion.ts similarity index 84% rename from src/globals/requestHandlers/publishVersion.ts rename to src/globals/requestHandlers/restoreVersion.ts index 046acdeb23..bb638bb97e 100644 --- a/src/globals/requestHandlers/publishVersion.ts +++ b/src/globals/requestHandlers/restoreVersion.ts @@ -15,7 +15,7 @@ export default function (globalConfig: SanitizedGlobalConfig) { }; try { - const doc = await this.operations.globals.publishVersion(options); + const doc = await this.operations.globals.restoreVersion(options); return res.status(httpStatus.OK).json({ ...formatSuccessResponse('Restored successfully.', 'message'), doc, @@ -25,6 +25,6 @@ export default function (globalConfig: SanitizedGlobalConfig) { } } - const publishVersionHandler = handler.bind(this); - return publishVersionHandler; + const restoreVersionHandler = handler.bind(this); + return restoreVersionHandler; } diff --git a/src/graphql/bindResolvers.ts b/src/graphql/bindResolvers.ts index a61b2d1775..0f5d44a365 100644 --- a/src/graphql/bindResolvers.ts +++ b/src/graphql/bindResolvers.ts @@ -14,6 +14,9 @@ import find from '../collections/graphql/resolvers/find'; import findByID from '../collections/graphql/resolvers/findByID'; import update from '../collections/graphql/resolvers/update'; import deleteResolver from '../collections/graphql/resolvers/delete'; +import findVersions from '../collections/graphql/resolvers/findVersions'; +import findVersionByID from '../collections/graphql/resolvers/findVersionByID'; +import restoreVersion from '../collections/graphql/resolvers/restoreVersion'; import findOne from '../globals/graphql/resolvers/findOne'; import globalUpdate from '../globals/graphql/resolvers/update'; @@ -24,7 +27,10 @@ export type GraphQLResolvers = { collections: { create: typeof create, find: typeof find, + findVersions: typeof findVersions, findByID: typeof findByID, + findVersionByID: typeof findVersionByID, + restoreVersion: typeof restoreVersion, update: typeof update, deleteResolver: typeof deleteResolver, auth: { @@ -52,7 +58,10 @@ function bindResolvers(ctx: Payload): void { collections: { create: create.bind(ctx), find: find.bind(ctx), + findVersions: findVersions.bind(ctx), findByID: findByID.bind(ctx), + findVersionByID: findVersionByID.bind(ctx), + restoreVersion: restoreVersion.bind(ctx), update: update.bind(ctx), deleteResolver: deleteResolver.bind(ctx), auth: { diff --git a/src/graphql/index.ts b/src/graphql/index.ts index 0e5b81bff2..3478d11877 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -13,6 +13,7 @@ import initCollections from '../collections/graphql/init'; import initGlobals from '../globals/graphql/init'; import initPreferences from '../preferences/graphql/init'; import { GraphQLResolvers } from './bindResolvers'; +import buildVersionType from './schema/buildVersionType'; import buildWhereInputType from './schema/buildWhereInputType'; import { SanitizedConfig } from '../config/types'; @@ -46,6 +47,8 @@ class InitializeGraphQL { buildPoliciesType: typeof buildPoliciesType; + buildVersionType: typeof buildVersionType; + initCollections: typeof initCollections; initGlobals: typeof initGlobals; @@ -90,6 +93,7 @@ class InitializeGraphQL { this.buildWhereInputType = buildWhereInputType; this.buildObjectType = buildObjectType.bind(this); this.buildPoliciesType = buildPoliciesType.bind(this); + this.buildVersionType = buildVersionType.bind(this); this.initCollections = initCollections.bind(this); this.initGlobals = initGlobals.bind(this); this.initPreferences = initPreferences.bind(this); diff --git a/src/graphql/schema/buildVersionType.ts b/src/graphql/schema/buildVersionType.ts new file mode 100644 index 0000000000..d330f4e1f1 --- /dev/null +++ b/src/graphql/schema/buildVersionType.ts @@ -0,0 +1,27 @@ +import { + GraphQLBoolean, + GraphQLNonNull, + GraphQLObjectType, + GraphQLString, +} from 'graphql'; +import { DateTimeResolver } from 'graphql-scalars'; +import formatName from '../utilities/formatName'; +import { SanitizedCollectionVersions } from '../../versions/types'; + +const buildVersionType = (type: GraphQLObjectType, versionsConfig: SanitizedCollectionVersions): GraphQLObjectType => { + const autosave = (versionsConfig.drafts && versionsConfig.drafts?.autosave && { autosave: { type: GraphQLBoolean } }); + + return new GraphQLObjectType({ + name: formatName(`${type.name}Version`), + fields: { + id: { type: GraphQLString }, + parent: { type: GraphQLString }, + version: { type }, + updatedAt: { type: new GraphQLNonNull(DateTimeResolver) }, + createdAt: { type: new GraphQLNonNull(DateTimeResolver) }, + ...autosave, + }, + }); +}; + +export default buildVersionType; diff --git a/src/graphql/schema/buildVersionWhereInputType.ts b/src/graphql/schema/buildVersionWhereInputType.ts new file mode 100644 index 0000000000..9df17133d2 --- /dev/null +++ b/src/graphql/schema/buildVersionWhereInputType.ts @@ -0,0 +1,47 @@ +import { + GraphQLBoolean, + GraphQLInputObjectType, + GraphQLString, +} from 'graphql'; +import { DateTimeResolver } from 'graphql-scalars'; +import { GraphQLJSON } from 'graphql-type-json'; +import formatName from '../utilities/formatName'; +import withOperators from './withOperators'; +import { FieldAffectingData } from '../../fields/config/types'; +import withWhereAndOr from './withWhereAndOr'; +import operators from './operators'; +import { SanitizedCollectionConfig } from '../../collections/config/types'; +import recursivelyBuildNestedPaths from './recursivelyBuildNestedPaths'; + +const buildVersionWhereInputType = (singularLabel: string, parentCollection: SanitizedCollectionConfig): GraphQLInputObjectType => { + const name = `version${formatName(singularLabel)}`; + const fieldTypes = { + id: { type: GraphQLString }, + autosave: { type: GraphQLBoolean }, + // TODO: test with custom id field types, may need to support number + updatedAt: { type: DateTimeResolver }, + createdAt: { type: DateTimeResolver }, + parent: { + type: withOperators( + { name: 'parent' } as FieldAffectingData, + GraphQLJSON, + name, + [...operators.equality, ...operators.contains], + ), + }, + }; + + const versionFields = recursivelyBuildNestedPaths(name, { + name: 'version', + type: 'group', + fields: parentCollection.fields, + }); + + versionFields.forEach((versionField) => { + fieldTypes[versionField.key] = versionField.type; + }); + + return withWhereAndOr(name, fieldTypes); +}; + +export default buildVersionWhereInputType; diff --git a/src/graphql/schema/buildWhereInputType.ts b/src/graphql/schema/buildWhereInputType.ts index a1b3900776..ace27b8e02 100644 --- a/src/graphql/schema/buildWhereInputType.ts +++ b/src/graphql/schema/buildWhereInputType.ts @@ -1,46 +1,22 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable no-use-before-define */ import { - GraphQLBoolean, - GraphQLEnumType, - GraphQLFloat, GraphQLInputObjectType, - GraphQLInt, - GraphQLList, - GraphQLString, } from 'graphql'; import { GraphQLJSON } from 'graphql-type-json'; -import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars'; import { - optionIsObject, - ArrayField, - CheckboxField, - CodeField, - DateField, - EmailField, Field, - FieldWithSubFields, - GroupField, - NumberField, - RadioField, - RelationshipField, - RichTextField, - RowField, - SelectField, - TextareaField, - TextField, - UploadField, - PointField, FieldAffectingData, - fieldAffectsData, fieldHasSubFields, fieldIsPresentationalOnly, } from '../../fields/config/types'; import formatName from '../utilities/formatName'; -import combineParentName from '../utilities/combineParentName'; import withOperators from './withOperators'; +import operators from './operators'; +import withWhereAndOr from './withWhereAndOr'; +import fieldToSchemaMap from './fieldToSchemaMap'; // buildWhereInputType is similar to buildObjectType and operates // on a field basis with a few distinct differences. @@ -52,281 +28,10 @@ import withOperators from './withOperators'; const buildWhereInputType = (name: string, fields: Field[], parentName: string): GraphQLInputObjectType => { // This is the function that builds nested paths for all // field types with nested paths. - const recursivelyBuildNestedPaths = (field: FieldWithSubFields & FieldAffectingData) => { - const nestedPaths = field.fields.reduce((nestedFields, nestedField) => { - if (!fieldIsPresentationalOnly(nestedField)) { - const getFieldSchema = fieldToSchemaMap[nestedField.type]; - const nestedFieldName = fieldAffectsData(nestedField) ? `${field.name}__${nestedField.name}` : undefined; - - if (getFieldSchema) { - const fieldSchema = getFieldSchema({ - ...nestedField, - name: nestedFieldName, - }); - - if (Array.isArray(fieldSchema)) { - return [ - ...nestedFields, - ...fieldSchema, - ]; - } - - return [ - ...nestedFields, - { - key: nestedFieldName, - type: fieldSchema, - }, - ]; - } - } - - return nestedFields; - }, []); - - return nestedPaths; - }; - - const operators = { - equality: ['equals', 'not_equals'], - contains: ['in', 'not_in', 'all'], - comparison: ['greater_than_equal', 'greater_than', 'less_than_equal', 'less_than'], - geo: ['near'], - }; - - const fieldToSchemaMap = { - number: (field: NumberField) => { - const type = GraphQLFloat; - return { - type: withOperators( - field, - type, - parentName, - [...operators.equality, ...operators.comparison], - ), - }; - }, - text: (field: TextField) => { - const type = GraphQLString; - return { - type: withOperators( - field, - type, - parentName, - [...operators.equality, 'like'], - ), - }; - }, - email: (field: EmailField) => { - const type = EmailAddressResolver; - return { - type: withOperators( - field, - type, - parentName, - [...operators.equality, 'like'], - ), - }; - }, - textarea: (field: TextareaField) => { - const type = GraphQLString; - return { - type: withOperators( - field, - type, - parentName, - [...operators.equality, 'like'], - ), - }; - }, - richText: (field: RichTextField) => { - const type = GraphQLJSON; - return { - type: withOperators( - field, - type, - parentName, - [...operators.equality, 'like'], - ), - }; - }, - code: (field: CodeField) => { - const type = GraphQLString; - return { - type: withOperators( - field, - type, - parentName, - [...operators.equality, 'like'], - ), - }; - }, - radio: (field: RadioField) => ({ - type: withOperators( - field, - new GraphQLEnumType({ - name: `${combineParentName(parentName, field.name)}_Input`, - values: field.options.reduce((values, option) => { - if (optionIsObject(option)) { - return { - ...values, - [formatName(option.value)]: { - value: option.value, - }, - }; - } - - return { - ...values, - [formatName(option)]: { - value: option, - }, - }; - }, {}), - }), - parentName, - [...operators.equality, 'like'], - ), - }), - date: (field: DateField) => { - const type = DateTimeResolver; - return { - type: withOperators( - field, - type, - parentName, - [...operators.equality, ...operators.comparison, 'like'], - ), - }; - }, - point: (field: PointField) => { - const type = GraphQLList(GraphQLFloat); - return { - type: withOperators( - field, - type, - parentName, - [...operators.equality, ...operators.comparison, ...operators.geo], - ), - }; - }, - relationship: (field: RelationshipField) => { - let type = withOperators( - field, - GraphQLString, - parentName, - [...operators.equality, ...operators.contains], - ); - - if (Array.isArray(field.relationTo)) { - type = new GraphQLInputObjectType({ - name: `${combineParentName(parentName, field.name)}_Relation`, - fields: { - relationTo: { - type: new GraphQLEnumType({ - name: `${combineParentName(parentName, field.name)}_Relation_RelationTo`, - values: field.relationTo.reduce((values, relation) => ({ - ...values, - [formatName(relation)]: { - value: relation, - }, - }), {}), - }), - }, - value: { type: GraphQLString }, - }, - }); - } - - if (field.hasMany) { - return { - type: new GraphQLList(type), - }; - } - - return { type }; - }, - upload: (field: UploadField) => ({ - type: withOperators( - field, - GraphQLString, - parentName, - [...operators.equality], - ), - }), - checkbox: (field: CheckboxField) => ({ - type: withOperators( - field, - GraphQLBoolean, - parentName, - [...operators.equality], - ), - }), - select: (field: SelectField) => ({ - type: withOperators( - field, - new GraphQLEnumType({ - name: `${combineParentName(parentName, field.name)}_Input`, - values: field.options.reduce((values, option) => { - if (typeof option === 'object' && option.value) { - return { - ...values, - [formatName(option.value)]: { - value: option.value, - }, - }; - } - - if (typeof option === 'string') { - return { - ...values, - [option]: { - value: option, - }, - }; - } - - return values; - }, {}), - }), - parentName, - [...operators.equality, ...operators.contains], - ), - }), - array: (field: ArrayField) => recursivelyBuildNestedPaths(field), - group: (field: GroupField) => recursivelyBuildNestedPaths(field), - row: (field: RowField) => field.fields.reduce((rowSchema, rowField) => { - const getFieldSchema = fieldToSchemaMap[rowField.type]; - - if (getFieldSchema) { - const rowFieldSchema = getFieldSchema(rowField); - - if (fieldHasSubFields(rowField)) { - return [ - ...rowSchema, - ...rowFieldSchema, - ]; - } - - if (fieldAffectsData(rowField)) { - return [ - ...rowSchema, - { - key: rowField.name, - type: rowFieldSchema, - }, - ]; - } - } - - - return rowSchema; - }, []), - }; const fieldTypes = fields.reduce((schema, field) => { if (!fieldIsPresentationalOnly(field) && !field.hidden) { - const getFieldSchema = fieldToSchemaMap[field.type]; + const getFieldSchema = fieldToSchemaMap(parentName)[field.type]; if (getFieldSchema) { const fieldSchema = getFieldSchema(field); @@ -362,31 +67,7 @@ const buildWhereInputType = (name: string, fields: Field[], parentName: string): const fieldName = formatName(name); - return new GraphQLInputObjectType({ - name: `${fieldName}_where`, - fields: { - ...fieldTypes, - OR: { - type: new GraphQLList(new GraphQLInputObjectType({ - name: `${fieldName}_where_or`, - fields: { - ...fieldTypes, - }, - })), - }, - AND: { - type: new GraphQLList(new GraphQLInputObjectType({ - name: `${fieldName}_where_and`, - fields: { - ...fieldTypes, - }, - })), - }, - page: { type: GraphQLInt }, - limit: { type: GraphQLInt }, - sort: { type: GraphQLString }, - }, - }); + return withWhereAndOr(fieldName, fieldTypes); }; export default buildWhereInputType; diff --git a/src/graphql/schema/fieldToSchemaMap.ts b/src/graphql/schema/fieldToSchemaMap.ts new file mode 100644 index 0000000000..7c9d5a1ccf --- /dev/null +++ b/src/graphql/schema/fieldToSchemaMap.ts @@ -0,0 +1,258 @@ +import { + GraphQLBoolean, + GraphQLEnumType, + GraphQLFloat, + GraphQLInputObjectType, + GraphQLList, + GraphQLString, +} from 'graphql'; +import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars'; +import { GraphQLJSON } from 'graphql-type-json'; +import { + ArrayField, + CheckboxField, + CodeField, DateField, + EmailField, fieldAffectsData, fieldHasSubFields, GroupField, + NumberField, optionIsObject, PointField, + RadioField, RelationshipField, + RichTextField, RowField, SelectField, + TextareaField, + TextField, UploadField, +} from '../../fields/config/types'; +import withOperators from './withOperators'; +import operators from './operators'; +import combineParentName from '../utilities/combineParentName'; +import formatName from '../utilities/formatName'; +import recursivelyBuildNestedPaths from './recursivelyBuildNestedPaths'; + +const fieldToSchemaMap: (parentName: string) => any = (parentName: string) => ({ + number: (field: NumberField) => { + const type = GraphQLFloat; + return { + type: withOperators( + field, + type, + parentName, + [...operators.equality, ...operators.comparison], + ), + }; + }, + text: (field: TextField) => { + const type = GraphQLString; + return { + type: withOperators( + field, + type, + parentName, + [...operators.equality, 'like'], + ), + }; + }, + email: (field: EmailField) => { + const type = EmailAddressResolver; + return { + type: withOperators( + field, + type, + parentName, + [...operators.equality, 'like'], + ), + }; + }, + textarea: (field: TextareaField) => { + const type = GraphQLString; + return { + type: withOperators( + field, + type, + parentName, + [...operators.equality, 'like'], + ), + }; + }, + richText: (field: RichTextField) => { + const type = GraphQLJSON; + return { + type: withOperators( + field, + type, + parentName, + [...operators.equality, 'like'], + ), + }; + }, + code: (field: CodeField) => { + const type = GraphQLString; + return { + type: withOperators( + field, + type, + parentName, + [...operators.equality, 'like'], + ), + }; + }, + radio: (field: RadioField) => ({ + type: withOperators( + field, + new GraphQLEnumType({ + name: `${combineParentName(parentName, field.name)}_Input`, + values: field.options.reduce((values, option) => { + if (optionIsObject(option)) { + return { + ...values, + [formatName(option.value)]: { + value: option.value, + }, + }; + } + + return { + ...values, + [formatName(option)]: { + value: option, + }, + }; + }, {}), + }), + parentName, + [...operators.equality, 'like'], + ), + }), + date: (field: DateField) => { + const type = DateTimeResolver; + return { + type: withOperators( + field, + type, + parentName, + [...operators.equality, ...operators.comparison, 'like'], + ), + }; + }, + point: (field: PointField) => { + const type = GraphQLList(GraphQLFloat); + return { + type: withOperators( + field, + type, + parentName, + [...operators.equality, ...operators.comparison, ...operators.geo], + ), + }; + }, + relationship: (field: RelationshipField) => { + let type = withOperators( + field, + GraphQLString, + parentName, + [...operators.equality, ...operators.contains], + ); + + if (Array.isArray(field.relationTo)) { + type = new GraphQLInputObjectType({ + name: `${combineParentName(parentName, field.name)}_Relation`, + fields: { + relationTo: { + type: new GraphQLEnumType({ + name: `${combineParentName(parentName, field.name)}_Relation_RelationTo`, + values: field.relationTo.reduce((values, relation) => ({ + ...values, + [formatName(relation)]: { + value: relation, + }, + }), {}), + }), + }, + value: { type: GraphQLString }, + }, + }); + } + + if (field.hasMany) { + return { + type: new GraphQLList(type), + }; + } + + return { type }; + }, + upload: (field: UploadField) => ({ + type: withOperators( + field, + GraphQLString, + parentName, + [...operators.equality], + ), + }), + checkbox: (field: CheckboxField) => ({ + type: withOperators( + field, + GraphQLBoolean, + parentName, + [...operators.equality], + ), + }), + select: (field: SelectField) => ({ + type: withOperators( + field, + new GraphQLEnumType({ + name: `${combineParentName(parentName, field.name)}_Input`, + values: field.options.reduce((values, option) => { + if (typeof option === 'object' && option.value) { + return { + ...values, + [formatName(option.value)]: { + value: option.value, + }, + }; + } + + if (typeof option === 'string') { + return { + ...values, + [option]: { + value: option, + }, + }; + } + + return values; + }, {}), + }), + parentName, + [...operators.equality, ...operators.contains], + ), + }), + array: (field: ArrayField) => recursivelyBuildNestedPaths(parentName, field), + group: (field: GroupField) => recursivelyBuildNestedPaths(parentName, field), + row: (field: RowField) => field.fields.reduce((rowSchema, rowField) => { + const getFieldSchema = fieldToSchemaMap(parentName)[rowField.type]; + + if (getFieldSchema) { + const rowFieldSchema = getFieldSchema(rowField); + + if (fieldHasSubFields(rowField)) { + return [ + ...rowSchema, + ...rowFieldSchema, + ]; + } + + if (fieldAffectsData(rowField)) { + return [ + ...rowSchema, + { + key: rowField.name, + type: rowFieldSchema, + }, + ]; + } + } + + + return rowSchema; + }, []), +}); + +export default fieldToSchemaMap; diff --git a/src/graphql/schema/operators.ts b/src/graphql/schema/operators.ts new file mode 100644 index 0000000000..b59112e030 --- /dev/null +++ b/src/graphql/schema/operators.ts @@ -0,0 +1,8 @@ +const operators = { + equality: ['equals', 'not_equals'], + contains: ['in', 'not_in', 'all'], + comparison: ['greater_than_equal', 'greater_than', 'less_than_equal', 'less_than'], + geo: ['near'], +}; + +export default operators; diff --git a/src/graphql/schema/recursivelyBuildNestedPaths.ts b/src/graphql/schema/recursivelyBuildNestedPaths.ts new file mode 100644 index 0000000000..bca906b772 --- /dev/null +++ b/src/graphql/schema/recursivelyBuildNestedPaths.ts @@ -0,0 +1,44 @@ +import { + FieldAffectingData, + fieldAffectsData, + fieldIsPresentationalOnly, + FieldWithSubFields, +} from '../../fields/config/types'; +import fieldToSchemaMap from './fieldToSchemaMap'; + +const recursivelyBuildNestedPaths = (parentName: string, field: FieldWithSubFields & FieldAffectingData) => { + const nestedPaths = field.fields.reduce((nestedFields, nestedField) => { + if (!fieldIsPresentationalOnly(nestedField)) { + const getFieldSchema = fieldToSchemaMap(parentName)[nestedField.type]; + const nestedFieldName = fieldAffectsData(nestedField) ? `${field.name}__${nestedField.name}` : undefined; + + if (getFieldSchema) { + const fieldSchema = getFieldSchema({ + ...nestedField, + name: nestedFieldName, + }); + + if (Array.isArray(fieldSchema)) { + return [ + ...nestedFields, + ...fieldSchema, + ]; + } + + return [ + ...nestedFields, + { + key: nestedFieldName, + type: fieldSchema, + }, + ]; + } + } + + return nestedFields; + }, []); + + return nestedPaths; +}; + +export default recursivelyBuildNestedPaths; diff --git a/src/graphql/schema/withWhereAndOr.ts b/src/graphql/schema/withWhereAndOr.ts new file mode 100644 index 0000000000..b5925123b1 --- /dev/null +++ b/src/graphql/schema/withWhereAndOr.ts @@ -0,0 +1,29 @@ +import { + GraphQLInputFieldConfigMap, + GraphQLInputObjectType, + GraphQLList, + Thunk, +} from 'graphql'; + +const withWhereAndOr = (name: string, fieldTypes: Thunk): GraphQLInputObjectType => { + return new GraphQLInputObjectType({ + name: `${name}_where`, + fields: { + ...fieldTypes, + OR: { + type: new GraphQLList(new GraphQLInputObjectType({ + name: `${name}_where_or`, + fields: fieldTypes, + })), + }, + AND: { + type: new GraphQLList(new GraphQLInputObjectType({ + name: `${name}_where_and`, + fields: fieldTypes, + })), + }, + }, + }); +}; + +export default withWhereAndOr; diff --git a/src/index.ts b/src/index.ts index cafa9d1b8b..4523887331 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/publishVersion'; +import { Options as RestoreVersionOptions } from './collections/operations/local/restoreVersion'; import { Result } from './auth/operations/login'; require('isomorphic-fetch'); @@ -301,19 +301,14 @@ export class Payload { * @param options * @returns version with specified ID */ - publishVersion = async = any>(options: RestoreVersionOptions): Promise => { - let { publishVersion } = localOperations; - publishVersion = publishVersion.bind(this); - return publishVersion(options); + restoreVersion = async = any>(options: RestoreVersionOptions): Promise => { + let { restoreVersion } = localOperations; + restoreVersion = restoreVersion.bind(this); + return restoreVersion(options); } - // TODO: globals - // findVersionGlobal - // findVersionByIDGlobal - // publishVersionGlobal // TODO: - // graphql operations & request handlers, where - // tests + // graphql Global Versions login = async (options): Promise => { let { login } = localOperations.auth; diff --git a/src/init/bindOperations.ts b/src/init/bindOperations.ts index e1b1b7b3d0..a12a5bc86d 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 publishVersion from '../collections/operations/publishVersion'; +import restoreVersion from '../collections/operations/restoreVersion'; import update from '../collections/operations/update'; import deleteHandler from '../collections/operations/delete'; import findOne from '../globals/operations/findOne'; import findGlobalVersions from '../globals/operations/findVersions'; import findGlobalVersionByID from '../globals/operations/findVersionByID'; -import publishGlobalVersion from '../globals/operations/publishVersion'; +import restoreGlobalVersion from '../globals/operations/restoreVersion'; 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 - publishVersion: typeof publishVersion + restoreVersion: typeof restoreVersion update: typeof update delete: typeof deleteHandler auth: { @@ -58,7 +58,7 @@ export type Operations = { findOne: typeof findOne findVersions: typeof findGlobalVersions findVersionByID: typeof findGlobalVersionByID - publishVersion: typeof publishGlobalVersion + restoreVersion: typeof restoreGlobalVersion 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), - publishVersion: publishVersion.bind(ctx), + restoreVersion: restoreVersion.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), - publishVersion: publishGlobalVersion.bind(ctx), + restoreVersion: restoreGlobalVersion.bind(ctx), update: globalUpdate.bind(ctx), }, preferences: { diff --git a/src/init/bindRequestHandlers.ts b/src/init/bindRequestHandlers.ts index 8cff73ee61..f557f456f8 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 publishVersion from '../collections/requestHandlers/publishVersion'; +import restoreVersion from '../collections/requestHandlers/restoreVersion'; import update from '../collections/requestHandlers/update'; import deleteHandler from '../collections/requestHandlers/delete'; import findOne from '../globals/requestHandlers/findOne'; import findGlobalVersions from '../globals/requestHandlers/findVersions'; import findGlobalVersionByID from '../globals/requestHandlers/findVersionByID'; -import publishGlobalVersion from '../globals/requestHandlers/publishVersion'; +import restoreGlobalVersion from '../globals/requestHandlers/restoreVersion'; 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, - publishVersion: typeof publishVersion, + restoreVersion: typeof restoreVersion, update: typeof update, delete: typeof deleteHandler, auth: { @@ -58,7 +58,7 @@ export type RequestHandlers = { update: typeof globalUpdate, findVersions: typeof findGlobalVersions findVersionByID: typeof findGlobalVersionByID - publishVersion: typeof publishGlobalVersion + restoreVersion: typeof restoreGlobalVersion }, 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), - publishVersion: publishVersion.bind(ctx), + restoreVersion: restoreVersion.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), - publishVersion: publishGlobalVersion.bind(ctx), + restoreVersion: restoreGlobalVersion.bind(ctx), }, preferences: { update: preferenceUpdate.bind(ctx), diff --git a/src/types/index.ts b/src/types/index.ts index 19619308b6..a399b6adf2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ import { Document as MongooseDocument } from 'mongoose'; -import { TypeWithID, TypeWithTimestamps } from '../collections/config/types'; +import { TypeWithTimestamps } from '../collections/config/types'; import { FileData } from '../uploads/types'; export type Operator = 'equals' diff --git a/src/versions/tests/collections-rest.spec.ts b/src/versions/tests/collections-rest.spec.ts index 9f529977d0..54be35869e 100644 --- a/src/versions/tests/collections-rest.spec.ts +++ b/src/versions/tests/collections-rest.spec.ts @@ -58,14 +58,16 @@ describe('Collection Versions - REST', () => { method: 'put', }).then((res) => res.json()); - expect(updatedPost.doc.title).toBe(title2); - expect(updatedPost.doc._status).toStrictEqual('draft'); - const versions = await fetch(`${url}/api/autosave-posts/versions`, { headers, }).then((res) => res.json()); versionID = versions.docs[0].id; + + expect(updatedPost.doc.title).toBe(title2); + expect(updatedPost.doc._status).toStrictEqual('draft'); + + expect(versionID).toBeDefined(); }); it('should allow a version to be retrieved by ID', async () => { diff --git a/src/versions/tests/graphql.spec.ts b/src/versions/tests/graphql.spec.ts new file mode 100644 index 0000000000..ff35f1a689 --- /dev/null +++ b/src/versions/tests/graphql.spec.ts @@ -0,0 +1,161 @@ +/** + * @jest-environment node + */ +import { request, GraphQLClient } from 'graphql-request'; +import getConfig from '../../config/load'; +import { email, password } from '../../mongoose/testCredentials'; + +require('isomorphic-fetch'); + +const config = getConfig(); + +const url = `${config.serverURL}${config.routes.api}${config.routes.graphQL}`; + +let client; +let token; +let postID; +let versionID; + +describe('GrahpQL Version Resolvers', () => { + const title = 'autosave title'; + + beforeAll(async (done) => { + const login = ` + mutation { + loginAdmin( + email: "${email}", + password: "${password}" + ) { + token + } + }`; + + const response = await request(url, login); + + token = response.loginAdmin.token; + + client = new GraphQLClient(url, { headers: { Authorization: `JWT ${token}` } }); + + done(); + }); + + describe('Create', () => { + it('should allow a new autosavePost to be created with draft status', async () => { + const description = 'autosave description'; + + const query = `mutation { + createAutosavePost(data: {title: "${title}", description: "${description}"}) { + id + title + description + createdAt + updatedAt + _status + } + }`; + + const response = await client.request(query); + + const data = response.createAutosavePost; + postID = data.id; + + expect(data._status).toStrictEqual('draft'); + }); + }); + + describe('Read', () => { + const updatedTitle = 'updated title'; + + beforeAll(async (done) => { + // modify the post to create a new version + // language=graphQL + const update = `mutation { + updateAutosavePost(id: "${postID}", data: {title: "${updatedTitle}"}) { + title + } + }`; + await client.request(update); + + // language=graphQL + const query = `query { + versionsAutosavePosts(where: { parent: { equals: "${postID}" } }) { + docs { + id + } + } + }`; + + const response = await client.request(query); + + versionID = response.versionsAutosavePosts.docs[0].id; + done(); + }); + + it('should allow read of versions by version id', async () => { + const query = `query { + versionAutosavePost(id: "${versionID}") { + id + parent + version { + title + } + } + }`; + + const response = await client.request(query); + + const data = response.versionAutosavePost; + versionID = data.id; + + expect(data.id).toBeDefined(); + expect(data.parent).toStrictEqual(postID); + expect(data.version.title).toStrictEqual(title); + }); + + it('should allow read of versions by querying version content', async () => { + // language=graphQL + const query = `query { + versionsAutosavePosts(where: { version__title: {equals: "${title}" } }) { + docs { + id + parent + version { + title + } + } + } + }`; + + const response = await client.request(query); + + const data = response.versionsAutosavePosts; + const doc = data.docs[0]; + versionID = doc.id; + + expect(doc.id).toBeDefined(); + expect(doc.parent).toStrictEqual(postID); + expect(doc.version.title).toStrictEqual(title); + }); + }); + + describe('Restore', () => { + it('should allow a version to be restored', async () => { + // update a versionsPost + const restore = `mutation { + restoreVersionAutosavePost(id: "${versionID}") + }`; + + await client.request(restore); + + const query = `query { + AutosavePost(id: "${postID}") { + title + } + }`; + + const response = await client.request(query); + const data = response.AutosavePost; + expect(data.title).toStrictEqual(title); + }); + }); +});