Skip to content

Commit

Permalink
feat: SafeRelationship field (#14)
Browse files Browse the repository at this point in the history
* basic SafeRelationship field

* 🚧 refactor

* cleanup

* 🚧 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
  • Loading branch information
wkentdag authored Sep 27, 2024
1 parent 6d4e1e6 commit fca77a5
Show file tree
Hide file tree
Showing 11 changed files with 476 additions and 9 deletions.
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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)
* 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.
16 changes: 16 additions & 0 deletions dev/src/collections/Basics.ts
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions dev/src/collections/Pages.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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,
})
],
}

Expand Down
12 changes: 10 additions & 2 deletions dev/src/collections/Posts.ts
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions dev/src/collections/Users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ const Users: CollectionConfig = {
useAsTitle: 'email',
},
fields: [
{
name: 'name',
type: 'text',
}
// Email added by default
// Add more fields as needed
],
Expand Down
3 changes: 2 additions & 1 deletion dev/src/payload.base.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -35,7 +36,7 @@ export const baseConfig: Omit<Config, 'db'> = {
},
},
editor: slateEditor({}),
collections: [Pages, PagesWithExtraHooks, Posts, Users],
collections: [Basics, Pages, PagesWithExtraHooks, Posts, Users],
globals: [Home],
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
Expand Down
234 changes: 234 additions & 0 deletions dev/test/safeRelationship.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit fca77a5

Please sign in to comment.