Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: SafeRelationship field #14

Merged
merged 11 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading