Skip to content

Commit fca77a5

Browse files
authored
feat: SafeRelationship field (#14)
* 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
1 parent 6d4e1e6 commit fca77a5

File tree

11 files changed

+476
-9
lines changed

11 files changed

+476
-9
lines changed

README.md

+22-4
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,30 @@ This value will also be passed to the `DatePicker` component. Defaults to 5 mins
5959
Custom configuration for the scheduled posts collection that gets merged with the defaults.
6060

6161

62+
## Utils
63+
64+
### `SafeRelationship`
65+
66+
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.
67+
68+
```ts
69+
import type { Field } from 'payload'
70+
import { SafeRelationship } from 'payload-plugin-scheduler'
71+
72+
const example: Field = SafeRelationship({
73+
name: 'featured_content',
74+
relationTo: ['posts', 'pages'],
75+
hasMany: true,
76+
})
77+
```
78+
6279
## Approach
6380

64-
In a nutshell, the plugin creates a `publish_date` field that it uses to determine whether a pending draft update needs to be scheduled.
81+
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.
6582

6683
### `publish_date`
6784

68-
Custom Datetime field added to documents in enabled collections.
69-
Includes custom `Field` and `Cell` components that include schedule status in the client-side UI.
85+
Datetime field added to enabled collections. Custom `Field` and `Cell` components display the schedule status in the client-side UI.
7086

7187
### `scheduled_posts`
7288

@@ -81,4 +97,6 @@ A configurable timer checks for any posts to be scheduled in the upcoming interv
8197

8298
* 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.
8399

84-
* 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)
100+
* 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)
101+
102+
* There's no logic in place to automatically publish any pending scheduled posts that weren't published due to server downtime.

dev/src/collections/Basics.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { type CollectionConfig } from 'payload/types'
2+
3+
const Basics: CollectionConfig = {
4+
slug: 'basics',
5+
admin: {
6+
useAsTitle: 'title',
7+
},
8+
fields: [
9+
{
10+
name: 'title',
11+
type: 'text',
12+
},
13+
],
14+
}
15+
16+
export default Basics

dev/src/collections/Pages.ts

+28
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { type CollectionConfig } from 'payload/types'
2+
// @ts-expect-error
3+
import { SafeRelationship } from '../../../src'
24

35
// Example Collection - For reference only, this must be added to payload.config.ts to be used.
46
const Pages: CollectionConfig = {
@@ -16,6 +18,32 @@ const Pages: CollectionConfig = {
1618
name: 'content',
1719
type: 'textarea',
1820
},
21+
// @ts-expect-error @TODO fix clashing react/payload deps
22+
SafeRelationship({
23+
relationTo: 'posts',
24+
name: 'featured_post',
25+
label: 'Featured Post',
26+
hasMany: false,
27+
}),
28+
// @ts-expect-error @TODO fix clashing react/payload deps
29+
SafeRelationship({
30+
relationTo: 'pages',
31+
name: 'related_pages',
32+
label: 'Related Pages',
33+
hasMany: true,
34+
}),
35+
// @ts-expect-error @TODO fix clashing react/payload deps
36+
SafeRelationship({
37+
relationTo: ['pages', 'basics'],
38+
name: 'mixed_relationship',
39+
hasMany: true,
40+
}),
41+
// @ts-expect-error @TODO fix clashing react/payload deps
42+
SafeRelationship({
43+
relationTo: ['pages', 'posts'],
44+
name: 'polymorphic',
45+
hasMany: true,
46+
})
1947
],
2048
}
2149

dev/src/collections/Posts.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import { type CollectionConfig } from 'payload/types'
2-
import Pages from './Pages'
32

43
const Posts: CollectionConfig = {
5-
...Pages,
64
slug: 'posts',
5+
admin: {
6+
useAsTitle: 'title',
7+
},
8+
versions: { drafts: true },
9+
fields: [
10+
{
11+
name: 'title',
12+
type: 'text',
13+
},
14+
],
715
}
816

917
export default Posts

dev/src/collections/Users.ts

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ const Users: CollectionConfig = {
77
useAsTitle: 'email',
88
},
99
fields: [
10+
{
11+
name: 'name',
12+
type: 'text',
13+
}
1014
// Email added by default
1115
// Add more fields as needed
1216
],

dev/src/payload.base.config.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import PagesWithExtraHooks from "./collections/PagesWithExtraHooks";
1111
// @ts-expect-error
1212
import { ScheduledPostPlugin } from '../../src'
1313
import Home from "./globals/Home";
14+
import Basics from "./collections/Basics";
1415

1516
export const INTERVAL = 1
1617

@@ -35,7 +36,7 @@ export const baseConfig: Omit<Config, 'db'> = {
3536
},
3637
},
3738
editor: slateEditor({}),
38-
collections: [Pages, PagesWithExtraHooks, Posts, Users],
39+
collections: [Basics, Pages, PagesWithExtraHooks, Posts, Users],
3940
globals: [Home],
4041
typescript: {
4142
outputFile: path.resolve(__dirname, 'payload-types.ts'),

dev/test/safeRelationship.spec.ts

+234
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { addMinutes, subMinutes } from "date-fns"
2+
import type { Payload } from "payload"
3+
4+
describe('SafeRelationshipField', () => {
5+
const payload = globalThis.payloadClient as Payload
6+
7+
let post
8+
9+
beforeAll(async () => {
10+
post = await payload.create({
11+
collection: 'posts',
12+
data: {
13+
title: 'published',
14+
_status: 'published'
15+
}
16+
})
17+
})
18+
19+
describe('false positives', () => {
20+
test('published docs', async () => {
21+
const publishedPost = await payload.create({
22+
collection: 'posts',
23+
data: {
24+
title: 'published',
25+
publish_date: subMinutes(new Date(), 10).toISOString(),
26+
_status: 'published',
27+
}
28+
})
29+
30+
const published = await payload.create({
31+
collection: 'pages',
32+
data: {
33+
title: 'published',
34+
featured_post: publishedPost.id,
35+
_status: 'published',
36+
}
37+
})
38+
39+
expect(published._status).toBe('published')
40+
// @ts-expect-error
41+
expect(published.featured_post.id).toEqual(publishedPost.id)
42+
})
43+
44+
test('draft docs', async () => {
45+
const scheduledPost = await payload.create({
46+
collection: 'posts',
47+
data: {
48+
title: 'scheduled',
49+
publish_date: addMinutes(new Date(), 10).toISOString(),
50+
_status: 'draft',
51+
}
52+
})
53+
54+
const draftPage = await payload.create({
55+
collection: 'pages',
56+
data: {
57+
title: 'second page',
58+
featured_post: scheduledPost.id,
59+
_status: 'draft',
60+
}
61+
})
62+
63+
expect(draftPage._status).toBe('draft')
64+
// @ts-expect-error
65+
expect(draftPage.featured_post.id).toBe(scheduledPost.id)
66+
})
67+
68+
test('polymorphic field', async () => {
69+
const page = await payload.create({
70+
collection: 'pages',
71+
data: {
72+
title: 'page',
73+
_status: 'published',
74+
}
75+
})
76+
77+
await expect(payload.create({
78+
collection: 'pages',
79+
data: {
80+
title: 'polymorphic',
81+
polymorphic: [
82+
{ relationTo: 'pages', value: page.id},
83+
{ relationTo: 'posts', value: post.id},
84+
],
85+
_status: 'published',
86+
}
87+
})).resolves.not.toThrow()
88+
})
89+
90+
test('mixed field', async () => {
91+
const page = await payload.create({
92+
collection: 'pages',
93+
data: {
94+
title: 'page',
95+
_status: 'published',
96+
}
97+
})
98+
99+
const basic = await payload.create({
100+
collection: 'basics',
101+
data: {
102+
title: 'published',
103+
_status: 'published'
104+
}
105+
})
106+
107+
await expect(payload.create({
108+
collection: 'pages',
109+
data: {
110+
title: 'mixed',
111+
mixed_relationship: [
112+
{ relationTo: 'basics', value: basic.id },
113+
{ relationTo: 'pages', value: page.id }
114+
],
115+
_status: 'published',
116+
}
117+
})).resolves.not.toThrow()
118+
})
119+
})
120+
121+
describe('errors', () => {
122+
test('related document is scheduled after current document', async () => {
123+
const scheduledPost = await payload.create({
124+
collection: 'posts',
125+
data: {
126+
title: 'scheduled',
127+
publish_date: addMinutes(new Date(), 10).toISOString(),
128+
_status: 'draft',
129+
}
130+
})
131+
132+
await expect(payload.create({
133+
collection: 'pages',
134+
data: {
135+
title: 'second page',
136+
featured_post: scheduledPost.id,
137+
_status: 'published',
138+
}
139+
})).rejects.toThrow('The following field is invalid: featured_post')
140+
})
141+
142+
test('one invalid document out of multiple', async () => {
143+
const scheduledPage = await payload.create({
144+
collection: 'pages',
145+
data: {
146+
title: 'scheduled',
147+
publish_date: addMinutes(new Date(), 10).toISOString(),
148+
_status: 'draft',
149+
}
150+
})
151+
152+
const publishedPage = await payload.create({
153+
collection: 'pages',
154+
data: {
155+
title: 'published',
156+
_status: 'published',
157+
}
158+
})
159+
160+
await expect(payload.create({
161+
collection: 'pages',
162+
data: {
163+
title: 'multiple',
164+
related_pages: [
165+
{ relationTo: 'pages', value: scheduledPage.id },
166+
{ relationTo: 'pages', value: publishedPage.id }
167+
],
168+
_status: 'published',
169+
}
170+
})).rejects.toThrow('The following field is invalid: related_pages')
171+
})
172+
173+
test('one invalid document out of polymorphic', async () => {
174+
const scheduledPage = await payload.create({
175+
collection: 'pages',
176+
data: {
177+
title: 'scheduled',
178+
publish_date: addMinutes(new Date(), 10).toISOString(),
179+
_status: 'draft',
180+
}
181+
})
182+
183+
const publishedPost = await payload.create({
184+
collection: 'posts',
185+
data: {
186+
title: 'published',
187+
_status: 'published',
188+
}
189+
})
190+
191+
await expect(payload.create({
192+
collection: 'pages',
193+
data: {
194+
title: 'multiple',
195+
polymorphic: [
196+
{ relationTo: 'pages', value: scheduledPage.id },
197+
{ relationTo: 'posts', value: publishedPost.id }
198+
],
199+
_status: 'published',
200+
}
201+
})).rejects.toThrow('The following field is invalid: polymorphic')
202+
})
203+
204+
test('one invalid document out of mixed', async () => {
205+
const scheduledPage = await payload.create({
206+
collection: 'pages',
207+
data: {
208+
title: 'scheduled',
209+
publish_date: addMinutes(new Date(), 10).toISOString(),
210+
_status: 'draft',
211+
}
212+
})
213+
214+
const basic = await payload.create({
215+
collection: 'basics',
216+
data: {
217+
title: 'basic',
218+
}
219+
})
220+
221+
await expect(payload.create({
222+
collection: 'pages',
223+
data: {
224+
title: 'multiple',
225+
mixed_relationship: [
226+
{ relationTo: 'pages', value: scheduledPage.id },
227+
{ relationTo: 'basics', value: basic.id }
228+
],
229+
_status: 'published',
230+
}
231+
})).rejects.toThrow('The following field is invalid: mixed_relationship')
232+
})
233+
})
234+
})

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@
2626
],
2727
"scripts": {
2828
"build": "tsc",
29+
"dev": "cd dev && yarn dev",
2930
"format": "prettier --write",
3031
"test": "cd dev && yarn test",
3132
"test:all": "run-p test:mongo test:postgres",
3233
"test:mongo": "PORT=3001 DATABASE_URI=mongodb://127.0.0.1/plugin-development PAYLOAD_CONFIG_PATH=src/payload.mongo.config.ts yarn test",
33-
"test:postgres": "DATABASE_URI=postgres://127.0.0.1:5432/payload-plugin-scheduler PAYLOAD_CONFIG_PATH=src/payload.postgres.config.ts yarn test",
34+
"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",
3435
"lint": "eslint src",
3536
"lint:fix": "eslint --fix --ext .ts,.tsx src",
3637
"clean": "rimraf dist && rimraf dev/yarn.lock",

0 commit comments

Comments
 (0)