From fca77a520cafef18c119487400e2647d7dcb52c9 Mon Sep 17 00:00:00 2001 From: wkd <3254957+wkentdag@users.noreply.github.com> Date: Thu, 26 Sep 2024 20:48:58 -0500 Subject: [PATCH] feat: `SafeRelationship` field (#14) * basic SafeRelationship field * :construction: refactor * cleanup * :construction: wip tests * rm collection spread * fix tests * thread req through to api call; catch api errors * fix type error * better error message * add dev cmd; specify separate port for pg tests * update docs --- README.md | 26 ++- dev/src/collections/Basics.ts | 16 ++ dev/src/collections/Pages.ts | 28 ++++ dev/src/collections/Posts.ts | 12 +- dev/src/collections/Users.ts | 4 + dev/src/payload.base.config.ts | 3 +- dev/test/safeRelationship.spec.ts | 234 +++++++++++++++++++++++++++ package.json | 3 +- src/fields/SafeRelationship/index.ts | 153 ++++++++++++++++++ src/index.ts | 3 +- src/types.ts | 3 + 11 files changed, 476 insertions(+), 9 deletions(-) create mode 100644 dev/src/collections/Basics.ts create mode 100644 dev/test/safeRelationship.spec.ts create mode 100644 src/fields/SafeRelationship/index.ts diff --git a/README.md b/README.md index d12ca78..40e6abe 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,30 @@ This value will also be passed to the `DatePicker` component. Defaults to 5 mins Custom configuration for the scheduled posts collection that gets merged with the defaults. +## Utils + +### `SafeRelationship` + +Drop-in replacement for the default [`relationship` field](https://payloadcms.com/docs/fields/relationship) to prevent users from publishing documents that have references to other docs that are still in draft / scheduled mode. + +```ts +import type { Field } from 'payload' +import { SafeRelationship } from 'payload-plugin-scheduler' + +const example: Field = SafeRelationship({ + name: 'featured_content', + relationTo: ['posts', 'pages'], + hasMany: true, +}) +``` + ## Approach -In a nutshell, the plugin creates a `publish_date` field that it uses to determine whether a pending draft update needs to be scheduled. +In a nutshell, the plugin creates a `publish_date` field that it uses to determine whether a pending draft update needs to be scheduled. If a draft document is saved with a `publish_date` that's in the future, it will be scheduled and automatically published on that date. ### `publish_date` -Custom Datetime field added to documents in enabled collections. -Includes custom `Field` and `Cell` components that include schedule status in the client-side UI. +Datetime field added to enabled collections. Custom `Field` and `Cell` components display the schedule status in the client-side UI. ### `scheduled_posts` @@ -81,4 +97,6 @@ A configurable timer checks for any posts to be scheduled in the upcoming interv * This plugin doesn't support Payload 3.0 beta. I intend to update it once 3.0 is stable, but it'll require substantial re-architecting to work in a serverless environment. -* There's no logic in place to dedupe schedules across multiple instances of a single app (see https://github.com/wkentdag/payload-plugin-scheduler/issues/9) \ No newline at end of file +* There's no logic in place to dedupe schedules across multiple instances of a single app (see https://github.com/wkentdag/payload-plugin-scheduler/issues/9) + +* There's no logic in place to automatically publish any pending scheduled posts that weren't published due to server downtime. \ No newline at end of file diff --git a/dev/src/collections/Basics.ts b/dev/src/collections/Basics.ts new file mode 100644 index 0000000..04fa764 --- /dev/null +++ b/dev/src/collections/Basics.ts @@ -0,0 +1,16 @@ +import { type CollectionConfig } from 'payload/types' + +const Basics: CollectionConfig = { + slug: 'basics', + admin: { + useAsTitle: 'title', + }, + fields: [ + { + name: 'title', + type: 'text', + }, + ], +} + +export default Basics diff --git a/dev/src/collections/Pages.ts b/dev/src/collections/Pages.ts index 82ea837..e188c59 100644 --- a/dev/src/collections/Pages.ts +++ b/dev/src/collections/Pages.ts @@ -1,4 +1,6 @@ import { type CollectionConfig } from 'payload/types' +// @ts-expect-error +import { SafeRelationship } from '../../../src' // Example Collection - For reference only, this must be added to payload.config.ts to be used. const Pages: CollectionConfig = { @@ -16,6 +18,32 @@ const Pages: CollectionConfig = { name: 'content', type: 'textarea', }, + // @ts-expect-error @TODO fix clashing react/payload deps + SafeRelationship({ + relationTo: 'posts', + name: 'featured_post', + label: 'Featured Post', + hasMany: false, + }), + // @ts-expect-error @TODO fix clashing react/payload deps + SafeRelationship({ + relationTo: 'pages', + name: 'related_pages', + label: 'Related Pages', + hasMany: true, + }), + // @ts-expect-error @TODO fix clashing react/payload deps + SafeRelationship({ + relationTo: ['pages', 'basics'], + name: 'mixed_relationship', + hasMany: true, + }), + // @ts-expect-error @TODO fix clashing react/payload deps + SafeRelationship({ + relationTo: ['pages', 'posts'], + name: 'polymorphic', + hasMany: true, + }) ], } diff --git a/dev/src/collections/Posts.ts b/dev/src/collections/Posts.ts index e2b6b57..b2f4459 100644 --- a/dev/src/collections/Posts.ts +++ b/dev/src/collections/Posts.ts @@ -1,9 +1,17 @@ import { type CollectionConfig } from 'payload/types' -import Pages from './Pages' const Posts: CollectionConfig = { - ...Pages, slug: 'posts', + admin: { + useAsTitle: 'title', + }, + versions: { drafts: true }, + fields: [ + { + name: 'title', + type: 'text', + }, + ], } export default Posts diff --git a/dev/src/collections/Users.ts b/dev/src/collections/Users.ts index f7d3794..2b8418e 100644 --- a/dev/src/collections/Users.ts +++ b/dev/src/collections/Users.ts @@ -7,6 +7,10 @@ const Users: CollectionConfig = { useAsTitle: 'email', }, fields: [ + { + name: 'name', + type: 'text', + } // Email added by default // Add more fields as needed ], diff --git a/dev/src/payload.base.config.ts b/dev/src/payload.base.config.ts index aab59bc..3e8ef03 100644 --- a/dev/src/payload.base.config.ts +++ b/dev/src/payload.base.config.ts @@ -11,6 +11,7 @@ import PagesWithExtraHooks from "./collections/PagesWithExtraHooks"; // @ts-expect-error import { ScheduledPostPlugin } from '../../src' import Home from "./globals/Home"; +import Basics from "./collections/Basics"; export const INTERVAL = 1 @@ -35,7 +36,7 @@ export const baseConfig: Omit = { }, }, editor: slateEditor({}), - collections: [Pages, PagesWithExtraHooks, Posts, Users], + collections: [Basics, Pages, PagesWithExtraHooks, Posts, Users], globals: [Home], typescript: { outputFile: path.resolve(__dirname, 'payload-types.ts'), diff --git a/dev/test/safeRelationship.spec.ts b/dev/test/safeRelationship.spec.ts new file mode 100644 index 0000000..8b3bdb9 --- /dev/null +++ b/dev/test/safeRelationship.spec.ts @@ -0,0 +1,234 @@ +import { addMinutes, subMinutes } from "date-fns" +import type { Payload } from "payload" + +describe('SafeRelationshipField', () => { + const payload = globalThis.payloadClient as Payload + + let post + + beforeAll(async () => { + post = await payload.create({ + collection: 'posts', + data: { + title: 'published', + _status: 'published' + } + }) + }) + + describe('false positives', () => { + test('published docs', async () => { + const publishedPost = await payload.create({ + collection: 'posts', + data: { + title: 'published', + publish_date: subMinutes(new Date(), 10).toISOString(), + _status: 'published', + } + }) + + const published = await payload.create({ + collection: 'pages', + data: { + title: 'published', + featured_post: publishedPost.id, + _status: 'published', + } + }) + + expect(published._status).toBe('published') + // @ts-expect-error + expect(published.featured_post.id).toEqual(publishedPost.id) + }) + + test('draft docs', async () => { + const scheduledPost = await payload.create({ + collection: 'posts', + data: { + title: 'scheduled', + publish_date: addMinutes(new Date(), 10).toISOString(), + _status: 'draft', + } + }) + + const draftPage = await payload.create({ + collection: 'pages', + data: { + title: 'second page', + featured_post: scheduledPost.id, + _status: 'draft', + } + }) + + expect(draftPage._status).toBe('draft') + // @ts-expect-error + expect(draftPage.featured_post.id).toBe(scheduledPost.id) + }) + + test('polymorphic field', async () => { + const page = await payload.create({ + collection: 'pages', + data: { + title: 'page', + _status: 'published', + } + }) + + await expect(payload.create({ + collection: 'pages', + data: { + title: 'polymorphic', + polymorphic: [ + { relationTo: 'pages', value: page.id}, + { relationTo: 'posts', value: post.id}, + ], + _status: 'published', + } + })).resolves.not.toThrow() + }) + + test('mixed field', async () => { + const page = await payload.create({ + collection: 'pages', + data: { + title: 'page', + _status: 'published', + } + }) + + const basic = await payload.create({ + collection: 'basics', + data: { + title: 'published', + _status: 'published' + } + }) + + await expect(payload.create({ + collection: 'pages', + data: { + title: 'mixed', + mixed_relationship: [ + { relationTo: 'basics', value: basic.id }, + { relationTo: 'pages', value: page.id } + ], + _status: 'published', + } + })).resolves.not.toThrow() + }) + }) + + describe('errors', () => { + test('related document is scheduled after current document', async () => { + const scheduledPost = await payload.create({ + collection: 'posts', + data: { + title: 'scheduled', + publish_date: addMinutes(new Date(), 10).toISOString(), + _status: 'draft', + } + }) + + await expect(payload.create({ + collection: 'pages', + data: { + title: 'second page', + featured_post: scheduledPost.id, + _status: 'published', + } + })).rejects.toThrow('The following field is invalid: featured_post') + }) + + test('one invalid document out of multiple', async () => { + const scheduledPage = await payload.create({ + collection: 'pages', + data: { + title: 'scheduled', + publish_date: addMinutes(new Date(), 10).toISOString(), + _status: 'draft', + } + }) + + const publishedPage = await payload.create({ + collection: 'pages', + data: { + title: 'published', + _status: 'published', + } + }) + + await expect(payload.create({ + collection: 'pages', + data: { + title: 'multiple', + related_pages: [ + { relationTo: 'pages', value: scheduledPage.id }, + { relationTo: 'pages', value: publishedPage.id } + ], + _status: 'published', + } + })).rejects.toThrow('The following field is invalid: related_pages') + }) + + test('one invalid document out of polymorphic', async () => { + const scheduledPage = await payload.create({ + collection: 'pages', + data: { + title: 'scheduled', + publish_date: addMinutes(new Date(), 10).toISOString(), + _status: 'draft', + } + }) + + const publishedPost = await payload.create({ + collection: 'posts', + data: { + title: 'published', + _status: 'published', + } + }) + + await expect(payload.create({ + collection: 'pages', + data: { + title: 'multiple', + polymorphic: [ + { relationTo: 'pages', value: scheduledPage.id }, + { relationTo: 'posts', value: publishedPost.id } + ], + _status: 'published', + } + })).rejects.toThrow('The following field is invalid: polymorphic') + }) + + test('one invalid document out of mixed', async () => { + const scheduledPage = await payload.create({ + collection: 'pages', + data: { + title: 'scheduled', + publish_date: addMinutes(new Date(), 10).toISOString(), + _status: 'draft', + } + }) + + const basic = await payload.create({ + collection: 'basics', + data: { + title: 'basic', + } + }) + + await expect(payload.create({ + collection: 'pages', + data: { + title: 'multiple', + mixed_relationship: [ + { relationTo: 'pages', value: scheduledPage.id }, + { relationTo: 'basics', value: basic.id } + ], + _status: 'published', + } + })).rejects.toThrow('The following field is invalid: mixed_relationship') + }) + }) +}) \ No newline at end of file diff --git a/package.json b/package.json index 0b3cf7c..3b88594 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,12 @@ ], "scripts": { "build": "tsc", + "dev": "cd dev && yarn dev", "format": "prettier --write", "test": "cd dev && yarn test", "test:all": "run-p test:mongo test:postgres", "test:mongo": "PORT=3001 DATABASE_URI=mongodb://127.0.0.1/plugin-development PAYLOAD_CONFIG_PATH=src/payload.mongo.config.ts yarn test", - "test:postgres": "DATABASE_URI=postgres://127.0.0.1:5432/payload-plugin-scheduler PAYLOAD_CONFIG_PATH=src/payload.postgres.config.ts yarn test", + "test:postgres": "PORT=3002 DATABASE_URI=postgres://127.0.0.1:5432/payload-plugin-scheduler PAYLOAD_CONFIG_PATH=src/payload.postgres.config.ts yarn test", "lint": "eslint src", "lint:fix": "eslint --fix --ext .ts,.tsx src", "clean": "rimraf dist && rimraf dev/yarn.lock", diff --git a/src/fields/SafeRelationship/index.ts b/src/fields/SafeRelationship/index.ts new file mode 100644 index 0000000..d24bcf0 --- /dev/null +++ b/src/fields/SafeRelationship/index.ts @@ -0,0 +1,153 @@ +import type { Payload } from 'payload' +import type { SanitizedConfig } from 'payload/config' +import type { + PayloadRequest, + RelationshipField, + RelationshipValue, + TypeWithID, + Validate, +} from 'payload/types' + +type MaybeScheduledDoc = TypeWithID & Record & { + _status?: 'published' | 'draft' + publish_date?: string + +} + +/** + * Wrapper around the default `RelationshipField` that ensures "related" documents are published before the primary document + */ +export const SafeRelationship: ( + props: Omit, +) => RelationshipField = (props) => { + const validate: Validate = async ( + value, + options, + ): Promise => { + // first run user validate fn + if (props.validate) { + const validateRes = await props.validate(value, options) + if (validateRes !== true) { + return validateRes + } + } + + const { + config, + data, + payload, + req, + }: { config: SanitizedConfig; data: MaybeScheduledDoc; payload?: Payload, req?: PayloadRequest } = options + + // can't run on the client + if (!payload) { + return true + } + + // abort if the field is empty + if (!value || Array.isArray(value) && value.length === 0) { + return true + } + + // abort if the current document is an unscheduled draft + if (data?._status === 'draft' && !data?.publish_date) { + return true + } + + // cast relationTo to an array + const relationsTo = Array.isArray(props.relationTo) + ? props.relationTo + : [props.relationTo] + + // cast value to an array + const values = Array.isArray(value) ? value : [value] + + // build a list of collections we need to check draft status for + // format: { [collectionSlug]: } so we can generate a pretty error message later + const relatedDraftCollections: Record = {} + + relationsTo.forEach((name) => { + const collection = config.collections.find(({ slug }) => slug === name) + if (collection?.versions?.drafts) { + relatedDraftCollections[collection.slug] = collection.admin.useAsTitle + } + }) + + // compile an array of related draft documents to check + const relatedDocs = await values.reduce>>(async (accPromise, v) => { + const acc = await accPromise; + + // naively assume that we're dealing with a simple (not polymorphic) relationship + // https://payloadcms.com/docs/fields/relationship#how-the-data-is-saved + let collection = props.relationTo as string; + let id = v as string | number + + // handle polymorphic relationships + if (typeof v === 'object') { + collection = v.relationTo + id = v.value + } + + // ignore related docs w/o drafts + if (!relatedDraftCollections[collection]) { + return acc + } + + try { + const doc = await payload.findByID({ + id, + collection, + req, + }); + + // Only add the document if it's in draft status + if (doc && doc._status === 'draft') { + acc.push({ ...doc, collection }) + } + } catch (error: unknown) { + payload.logger.error(error, `[SafeRelationship] ${collection}:${id}`) + } + + return acc; + }, Promise.resolve([])) + + const invalidRelatedDocs: Array<{ collection: string; title: string }> = [] + + // store a reference to the publish date for the current document + let publishDate: Date + if (data?._status === 'published' || !data?._status) { + // for published docs, or docs w/o drafts, we assume they are currently being published + publishDate = new Date() + } else if (data?._status === 'draft' && data?.publish_date) { + // for scheduled documents, use the set publish_date + publishDate = new Date(data.publish_date) + } + + // loop over the related documents and find any stragglers + relatedDocs + .forEach((rd) => { + const docPubDate = rd.publish_date + ? new Date(rd.publish_date) + : null // this check is necessary otherwise new Date(null) => 1/1/70 + if (!docPubDate || docPubDate >= publishDate) { + invalidRelatedDocs.push({ + collection: rd.collection, + title: rd[relatedDraftCollections[rd.collection]] as string + }) + } + }) + + if (invalidRelatedDocs.length > 0) { + return `The following docs must be published before this one: ${invalidRelatedDocs.map(({ collection, title }) => `${title} (${collection})`).join(', ')}` + } + + return true + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return { + type: 'relationship', + ...props, + validate + } as RelationshipField +} diff --git a/src/index.ts b/src/index.ts index 28dd83b..1bd2ca8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export { ScheduledPostPlugin } from './plugin' -export type { ScheduledPostConfig } from './types' +export { SafeRelationship } from './fields/SafeRelationship' +export type { ScheduledPostConfig, SafeRelationshipField } from './types' diff --git a/src/types.ts b/src/types.ts index 0f1b83c..1302834 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { CollectionConfig, ValueWithRelation } from 'payload/types' +import type { SafeRelationship } from './fields/SafeRelationship' export interface ScheduledPostConfig { collections?: string[] @@ -14,3 +15,5 @@ export interface ScheduledPost { date: string status: 'queued' | 'complete' } + +export type SafeRelationshipField = typeof SafeRelationship \ No newline at end of file