Skip to content

Commit

Permalink
feat: scheduled publish / unpublish (#10203)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
jmikrut authored Dec 27, 2024
1 parent a46609e commit 37d1f2d
Show file tree
Hide file tree
Showing 58 changed files with 1,491 additions and 194 deletions.
19 changes: 15 additions & 4 deletions docs/versions/drafts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ _If Drafts are enabled, the typical Save button is replaced with new actions whi

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.

| Draft Option | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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). |
| `validate` | Set `validate` to `true` to validate draft documents when saved. Default is `false`. |
| Draft Option | Description |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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). |
| `schedulePublish` | Allow for editors to schedule publish / unpublish events in the future. [More](#scheduled-publish) |
| `validate` | Set `validate` to `true` to validate draft documents when saved. Default is `false`. |

## Database changes

Expand Down Expand Up @@ -166,6 +167,16 @@ export const Pages: CollectionConfig = {
}
```

## Scheduled publish

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.

You can enable this functionality on both collections and globals via the `versions.drafts.schedulePublish: true` property.

<Banner type="warning">
**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.
</Banner>

## Unpublishing drafts

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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"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",
"test:types": "tstyche",
"test:unit": "cross-env NODE_OPTIONS=\"--no-deprecation\" NODE_NO_WARNINGS=1 DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=jest.config.js --runInBand",
"translateNewKeys": "pnpm --filter payload run translateNewKeys"
"translateNewKeys": "pnpm --filter translations run translateNewKeys"
},
"lint-staged": {
"**/package.json": "sort-package-json",
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/utilities/handleServerFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ServerFunction, ServerFunctionHandler } from 'payload'
import { copyDataFromLocaleHandler } from '@payloadcms/ui/rsc'
import { buildFormStateHandler } from '@payloadcms/ui/utilities/buildFormState'
import { buildTableStateHandler } from '@payloadcms/ui/utilities/buildTableState'
import { schedulePublishHandler } from '@payloadcms/ui/utilities/schedulePublishHandler'

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

Expand Down
104 changes: 84 additions & 20 deletions packages/payload/src/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { AcceptedLanguages } from '@payloadcms/translations'
import { en } from '@payloadcms/translations/languages/en'
import { deepMergeSimple } from '@payloadcms/translations/utilities'

import type { CollectionSlug, GlobalSlug } from '../index.js'
import type {
Config,
LocalizationConfigWithLabels,
Expand All @@ -13,12 +14,12 @@ import type {
import { defaultUserCollection } from '../auth/defaultUser.js'
import { sanitizeCollection } from '../collections/config/sanitize.js'
import { migrationsCollection } from '../database/migrations/migrationsCollection.js'
import { InvalidConfiguration } from '../errors/index.js'
import { sanitizeGlobals } from '../globals/config/sanitize.js'
import { DuplicateCollection, InvalidConfiguration } from '../errors/index.js'
import { sanitizeGlobal } from '../globals/config/sanitize.js'
import { getLockedDocumentsCollection } from '../lockedDocuments/lockedDocumentsCollection.js'
import getPreferencesCollection from '../preferences/preferencesCollection.js'
import { getDefaultJobsCollection } from '../queues/config/jobsCollection.js'
import checkDuplicateCollections from '../utilities/checkDuplicateCollections.js'
import { getSchedulePublishTask } from '../versions/schedule/job.js'
import { defaults } from './defaults.js'

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

config.i18n = i18nConfig

const richTextSanitizationPromises: Array<(config: SanitizedConfig) => Promise<void>> = []

const schedulePublishCollections: CollectionSlug[] = []
const schedulePublishGlobals: GlobalSlug[] = []

const collectionSlugs = new Set<CollectionSlug>()

for (let i = 0; i < config.collections.length; i++) {
if (collectionSlugs.has(config.collections[i].slug)) {
throw new DuplicateCollection('slug', config.collections[i].slug)
}

collectionSlugs.add(config.collections[i].slug)

const draftsConfig = config.collections[i]?.versions?.drafts

if (typeof draftsConfig === 'object' && draftsConfig.schedulePublish) {
schedulePublishCollections.push(config.collections[i].slug)
}

config.collections[i] = await sanitizeCollection(
config as unknown as Config,
config.collections[i],
richTextSanitizationPromises,
)
}

if (config.globals.length > 0) {
for (let i = 0; i < config.globals.length; i++) {
const draftsConfig = config.globals[i]?.versions?.drafts

if (typeof draftsConfig === 'object' && draftsConfig.schedulePublish) {
schedulePublishGlobals.push(config.globals[i].slug)
}

config.globals[i] = await sanitizeGlobal(
config as unknown as Config,
config.globals[i],
richTextSanitizationPromises,
)
}
}

if (schedulePublishCollections.length > 0 || schedulePublishGlobals.length > 0) {
if (!Array.isArray(configWithDefaults.jobs?.tasks)) {
configWithDefaults.jobs.tasks = []
}

configWithDefaults.jobs.tasks.push(
getSchedulePublishTask({
collections: schedulePublishCollections,
globals: schedulePublishGlobals,
}),
)
}

// Need to add default jobs collection before locked documents collections
if (
(Array.isArray(configWithDefaults.jobs?.tasks) && configWithDefaults.jobs?.tasks?.length) ||
Expand All @@ -185,30 +242,36 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
})
}

configWithDefaults.collections.push(defaultJobsCollection)
}

configWithDefaults.collections.push(getLockedDocumentsCollection(config as unknown as Config))
configWithDefaults.collections.push(getPreferencesCollection(config as unknown as Config))
configWithDefaults.collections.push(migrationsCollection)

const richTextSanitizationPromises: Array<(config: SanitizedConfig) => Promise<void>> = []
for (let i = 0; i < config.collections.length; i++) {
config.collections[i] = await sanitizeCollection(
const sanitizedJobsCollection = await sanitizeCollection(
config as unknown as Config,
config.collections[i],
defaultJobsCollection,
richTextSanitizationPromises,
)
}

checkDuplicateCollections(config.collections)
configWithDefaults.collections.push(sanitizedJobsCollection)
}

if (config.globals.length > 0) {
config.globals = await sanitizeGlobals(
configWithDefaults.collections.push(
await sanitizeCollection(
config as unknown as Config,
getLockedDocumentsCollection(config as unknown as Config),
richTextSanitizationPromises,
)
}
),
)
configWithDefaults.collections.push(
await sanitizeCollection(
config as unknown as Config,
getPreferencesCollection(config as unknown as Config),
richTextSanitizationPromises,
),
)
configWithDefaults.collections.push(
await sanitizeCollection(
config as unknown as Config,
migrationsCollection,
richTextSanitizationPromises,
),
)

if (config.serverURL !== '') {
config.csrf.push(config.serverURL)
Expand All @@ -218,6 +281,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
if (!config.upload) {
config.upload = { adapters: [] }
}

config.upload.adapters = Array.from(
new Set(config.collections.map((c) => c.upload?.adapter).filter(Boolean)),
)
Expand Down
4 changes: 2 additions & 2 deletions packages/payload/src/errors/DuplicateCollection.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { APIError } from './APIError.js'

export class DuplicateCollection extends APIError {
constructor(propertyName: string, duplicates: string[]) {
super(`Collection ${propertyName} already in use: "${duplicates.join(', ')}"`)
constructor(propertyName: string, duplicate: string) {
super(`Collection ${propertyName} already in use: "${duplicate}"`)
}
}
Loading

0 comments on commit 37d1f2d

Please sign in to comment.