Skip to content

Commit 37d1f2d

Browse files
authored
feat: scheduled publish / unpublish (#10203)
Adds a feature to allow editors to schedule publish / unpublish events in the future. Must be enabled by setting `versions.drafts.schedulePublish: true` in your Collection / Global configs. https://github.com/user-attachments/assets/ca1d7a8b-946a-4eac-b911-c2177dbe3b1c Todo: - [x] Translate new i18n keys - [x] Wire up locale-specific scheduled publish / unpublish actions
1 parent a46609e commit 37d1f2d

File tree

58 files changed

+1491
-194
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+1491
-194
lines changed

docs/versions/drafts.mdx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ _If Drafts are enabled, the typical Save button is replaced with new actions whi
1919

2020
Collections and Globals both support the same options for configuring drafts. You can either set `versions.drafts` to `true`, or pass an object to configure draft properties.
2121

22-
| Draft Option | Description |
23-
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24-
| `autosave` | Enable `autosave` to automatically save progress while documents are edited. To enable, set to `true` or pass an object with [options](/docs/versions/autosave). |
25-
| `validate` | Set `validate` to `true` to validate draft documents when saved. Default is `false`. |
22+
| Draft Option | Description |
23+
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24+
| `autosave` | Enable `autosave` to automatically save progress while documents are edited. To enable, set to `true` or pass an object with [options](/docs/versions/autosave). |
25+
| `schedulePublish` | Allow for editors to schedule publish / unpublish events in the future. [More](#scheduled-publish) |
26+
| `validate` | Set `validate` to `true` to validate draft documents when saved. Default is `false`. |
2627

2728
## Database changes
2829

@@ -166,6 +167,16 @@ export const Pages: CollectionConfig = {
166167
}
167168
```
168169

170+
## Scheduled publish
171+
172+
Payload provides for an ability to schedule publishing / unpublishing events in the future, which can be helpful if you need to set certain documents to "go live" at a given date in the future, or, vice versa, revert to a draft state after a certain time has passed.
173+
174+
You can enable this functionality on both collections and globals via the `versions.drafts.schedulePublish: true` property.
175+
176+
<Banner type="warning">
177+
**Important:** if you are going to enable scheduled publish / unpublish, you need to make sure your Payload app is set up to process [Jobs](/docs/jobs-queue/overview). This feature works by creating a Job in the background, which will be picked up after the job becomes available. If you do not have any mechanism in place to run jobs, your scheduled publish / unpublish jobs will never be executed.
178+
</Banner>
179+
169180
## Unpublishing drafts
170181

171182
If a document is published, the Payload Admin UI will be updated to show an "unpublish" button at the top of the sidebar, which will "unpublish" the currently published document. Consider this as a way to "revert" a document back to a draft state. On the API side, this is done by simply setting `_status: 'draft'` on any document.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@
9494
"test:int:sqlite": "cross-env NODE_OPTIONS=\"--no-deprecation\" NODE_NO_WARNINGS=1 PAYLOAD_DATABASE=sqlite DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand",
9595
"test:types": "tstyche",
9696
"test:unit": "cross-env NODE_OPTIONS=\"--no-deprecation\" NODE_NO_WARNINGS=1 DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=jest.config.js --runInBand",
97-
"translateNewKeys": "pnpm --filter payload run translateNewKeys"
97+
"translateNewKeys": "pnpm --filter translations run translateNewKeys"
9898
},
9999
"lint-staged": {
100100
"**/package.json": "sort-package-json",

packages/next/src/utilities/handleServerFunctions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ServerFunction, ServerFunctionHandler } from 'payload'
33
import { copyDataFromLocaleHandler } from '@payloadcms/ui/rsc'
44
import { buildFormStateHandler } from '@payloadcms/ui/utilities/buildFormState'
55
import { buildTableStateHandler } from '@payloadcms/ui/utilities/buildTableState'
6+
import { schedulePublishHandler } from '@payloadcms/ui/utilities/schedulePublishHandler'
67

78
import { renderDocumentHandler } from '../views/Document/handleServerFunction.js'
89
import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlots.js'
@@ -26,6 +27,7 @@ export const handleServerFunctions: ServerFunctionHandler = async (args) => {
2627
'render-document': renderDocumentHandler as any as ServerFunction,
2728
'render-document-slots': renderDocumentSlotsHandler as any as ServerFunction,
2829
'render-list': renderListHandler as any as ServerFunction,
30+
'schedule-publish': schedulePublishHandler as any as ServerFunction,
2931
'table-state': buildTableStateHandler as any as ServerFunction,
3032
}
3133

packages/payload/src/config/sanitize.ts

Lines changed: 84 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { AcceptedLanguages } from '@payloadcms/translations'
33
import { en } from '@payloadcms/translations/languages/en'
44
import { deepMergeSimple } from '@payloadcms/translations/utilities'
55

6+
import type { CollectionSlug, GlobalSlug } from '../index.js'
67
import type {
78
Config,
89
LocalizationConfigWithLabels,
@@ -13,12 +14,12 @@ import type {
1314
import { defaultUserCollection } from '../auth/defaultUser.js'
1415
import { sanitizeCollection } from '../collections/config/sanitize.js'
1516
import { migrationsCollection } from '../database/migrations/migrationsCollection.js'
16-
import { InvalidConfiguration } from '../errors/index.js'
17-
import { sanitizeGlobals } from '../globals/config/sanitize.js'
17+
import { DuplicateCollection, InvalidConfiguration } from '../errors/index.js'
18+
import { sanitizeGlobal } from '../globals/config/sanitize.js'
1819
import { getLockedDocumentsCollection } from '../lockedDocuments/lockedDocumentsCollection.js'
1920
import getPreferencesCollection from '../preferences/preferencesCollection.js'
2021
import { getDefaultJobsCollection } from '../queues/config/jobsCollection.js'
21-
import checkDuplicateCollections from '../utilities/checkDuplicateCollections.js'
22+
import { getSchedulePublishTask } from '../versions/schedule/job.js'
2223
import { defaults } from './defaults.js'
2324

2425
const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig> => {
@@ -171,6 +172,62 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
171172

172173
config.i18n = i18nConfig
173174

175+
const richTextSanitizationPromises: Array<(config: SanitizedConfig) => Promise<void>> = []
176+
177+
const schedulePublishCollections: CollectionSlug[] = []
178+
const schedulePublishGlobals: GlobalSlug[] = []
179+
180+
const collectionSlugs = new Set<CollectionSlug>()
181+
182+
for (let i = 0; i < config.collections.length; i++) {
183+
if (collectionSlugs.has(config.collections[i].slug)) {
184+
throw new DuplicateCollection('slug', config.collections[i].slug)
185+
}
186+
187+
collectionSlugs.add(config.collections[i].slug)
188+
189+
const draftsConfig = config.collections[i]?.versions?.drafts
190+
191+
if (typeof draftsConfig === 'object' && draftsConfig.schedulePublish) {
192+
schedulePublishCollections.push(config.collections[i].slug)
193+
}
194+
195+
config.collections[i] = await sanitizeCollection(
196+
config as unknown as Config,
197+
config.collections[i],
198+
richTextSanitizationPromises,
199+
)
200+
}
201+
202+
if (config.globals.length > 0) {
203+
for (let i = 0; i < config.globals.length; i++) {
204+
const draftsConfig = config.globals[i]?.versions?.drafts
205+
206+
if (typeof draftsConfig === 'object' && draftsConfig.schedulePublish) {
207+
schedulePublishGlobals.push(config.globals[i].slug)
208+
}
209+
210+
config.globals[i] = await sanitizeGlobal(
211+
config as unknown as Config,
212+
config.globals[i],
213+
richTextSanitizationPromises,
214+
)
215+
}
216+
}
217+
218+
if (schedulePublishCollections.length > 0 || schedulePublishGlobals.length > 0) {
219+
if (!Array.isArray(configWithDefaults.jobs?.tasks)) {
220+
configWithDefaults.jobs.tasks = []
221+
}
222+
223+
configWithDefaults.jobs.tasks.push(
224+
getSchedulePublishTask({
225+
collections: schedulePublishCollections,
226+
globals: schedulePublishGlobals,
227+
}),
228+
)
229+
}
230+
174231
// Need to add default jobs collection before locked documents collections
175232
if (
176233
(Array.isArray(configWithDefaults.jobs?.tasks) && configWithDefaults.jobs?.tasks?.length) ||
@@ -185,30 +242,36 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
185242
})
186243
}
187244

188-
configWithDefaults.collections.push(defaultJobsCollection)
189-
}
190-
191-
configWithDefaults.collections.push(getLockedDocumentsCollection(config as unknown as Config))
192-
configWithDefaults.collections.push(getPreferencesCollection(config as unknown as Config))
193-
configWithDefaults.collections.push(migrationsCollection)
194-
195-
const richTextSanitizationPromises: Array<(config: SanitizedConfig) => Promise<void>> = []
196-
for (let i = 0; i < config.collections.length; i++) {
197-
config.collections[i] = await sanitizeCollection(
245+
const sanitizedJobsCollection = await sanitizeCollection(
198246
config as unknown as Config,
199-
config.collections[i],
247+
defaultJobsCollection,
200248
richTextSanitizationPromises,
201249
)
202-
}
203250

204-
checkDuplicateCollections(config.collections)
251+
configWithDefaults.collections.push(sanitizedJobsCollection)
252+
}
205253

206-
if (config.globals.length > 0) {
207-
config.globals = await sanitizeGlobals(
254+
configWithDefaults.collections.push(
255+
await sanitizeCollection(
208256
config as unknown as Config,
257+
getLockedDocumentsCollection(config as unknown as Config),
209258
richTextSanitizationPromises,
210-
)
211-
}
259+
),
260+
)
261+
configWithDefaults.collections.push(
262+
await sanitizeCollection(
263+
config as unknown as Config,
264+
getPreferencesCollection(config as unknown as Config),
265+
richTextSanitizationPromises,
266+
),
267+
)
268+
configWithDefaults.collections.push(
269+
await sanitizeCollection(
270+
config as unknown as Config,
271+
migrationsCollection,
272+
richTextSanitizationPromises,
273+
),
274+
)
212275

213276
if (config.serverURL !== '') {
214277
config.csrf.push(config.serverURL)
@@ -218,6 +281,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
218281
if (!config.upload) {
219282
config.upload = { adapters: [] }
220283
}
284+
221285
config.upload.adapters = Array.from(
222286
new Set(config.collections.map((c) => c.upload?.adapter).filter(Boolean)),
223287
)
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { APIError } from './APIError.js'
22

33
export class DuplicateCollection extends APIError {
4-
constructor(propertyName: string, duplicates: string[]) {
5-
super(`Collection ${propertyName} already in use: "${duplicates.join(', ')}"`)
4+
constructor(propertyName: string, duplicate: string) {
5+
super(`Collection ${propertyName} already in use: "${duplicate}"`)
66
}
77
}

0 commit comments

Comments
 (0)