feat: delete scheduled published events (#10504)

### What?

Allows a user to delete a scheduled publish event after it has been
added:

![image](https://github.com/user-attachments/assets/79b1a206-c8a7-4ffa-a9bf-d0f84f86b8f9)

### Why?

Previously a user had no control over making changes once scheduled.

### How?

Extends the `scheduledPublishHandler` server action to accept a
`deleteID` for the event that should be removed and exposes this to the
user via the admin UI in a new column in the Upcoming Events table.
This commit is contained in:
Dan Ribbens
2025-01-13 14:41:38 -05:00
committed by GitHub
parent 6ada450531
commit f95d6ba94a
6 changed files with 195 additions and 12 deletions

View File

@@ -7,6 +7,6 @@ export type SchedulePublishTaskInput = {
}
global?: GlobalSlug
locale?: string
type: string
type?: string
user?: number | string
}

View File

@@ -1,15 +1,18 @@
import type { ClientConfig } from 'payload'
import { getTranslation, type I18nClient, type TFunction } from '@payloadcms/translations'
import React from 'react'
import type { Column } from '../../Table/index.js'
import type { UpcomingEvent } from './types.js'
import { formatDate } from '../../../utilities/formatDate.js'
import { Button } from '../../Button/index.js'
import { Pill } from '../../Pill/index.js'
type Args = {
dateFormat: string
deleteHandler: (id: number | string) => void
docs: UpcomingEvent[]
i18n: I18nClient
localization: ClientConfig['localization']
@@ -18,6 +21,7 @@ type Args = {
export const buildUpcomingColumns = ({
dateFormat,
deleteHandler,
docs,
i18n,
localization,
@@ -81,5 +85,28 @@ export const buildUpcomingColumns = ({
})
}
columns.push({
accessor: 'delete',
active: true,
field: {
name: 'delete',
type: 'text',
},
Heading: <span>{t('general:delete')}</span>,
renderedCells: docs.map((doc) => (
<Button
buttonStyle="icon-label"
className="schedule-publish__delete"
icon="x"
key={doc.id}
onClick={(e) => {
e.preventDefault()
deleteHandler(doc.id)
}}
tooltip={t('general:delete')}
/>
)),
})
return columns
}

View File

@@ -44,5 +44,9 @@
margin-bottom: var(--base);
}
}
&__delete {
margin: 0;
}
}
}

View File

@@ -62,6 +62,7 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
const modalTitle = t('general:schedulePublishFor', { title })
const [upcoming, setUpcoming] = React.useState<UpcomingEvent[]>()
const [upcomingColumns, setUpcomingColumns] = React.useState<Column[]>()
const deleteHandlerRef = React.useRef<((id: number | string) => Promise<void>) | null>(() => null)
const localeOptions = React.useMemo(() => {
if (localization) {
@@ -129,9 +130,39 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
})
.then((res) => res.json())
setUpcomingColumns(buildUpcomingColumns({ dateFormat, docs, i18n, localization, t }))
setUpcomingColumns(
buildUpcomingColumns({
dateFormat,
// eslint-disable-next-line @typescript-eslint/no-misused-promises
deleteHandler: deleteHandlerRef.current,
docs,
i18n,
localization,
t,
}),
)
setUpcoming(docs)
}, [api, collectionSlug, dateFormat, globalSlug, i18n, id, serverURL, t, localization])
}, [collectionSlug, globalSlug, serverURL, api, dateFormat, id, t, i18n, localization])
const deleteHandler = React.useCallback(
async (id: number | string) => {
try {
await schedulePublish({
deleteID: id,
})
await fetchUpcoming()
toast.success(t('general:deletedSuccessfully'))
} catch (err) {
console.error(err)
toast.error(err.message)
}
},
[fetchUpcoming, schedulePublish, t],
)
React.useEffect(() => {
deleteHandlerRef.current = deleteHandler
}, [deleteHandler])
const handleSave = React.useCallback(async () => {
if (!date) {

View File

@@ -1,13 +1,18 @@
import type { PayloadRequest, SchedulePublishTaskInput } from 'payload'
export type SchedulePublishHandlerArgs = {
date: Date
date?: Date
/**
* The job id to delete to remove a scheduled publish event
*/
deleteID?: number | string
req: PayloadRequest
} & SchedulePublishTaskInput
export const schedulePublishHandler = async ({
type,
date,
deleteID,
doc,
global,
locale,
@@ -38,6 +43,14 @@ export const schedulePublishHandler = async ({
}
try {
if (deleteID) {
await payload.delete({
collection: 'payload-jobs',
req,
where: { id: { equals: deleteID } },
})
}
await payload.jobs.queue({
input: {
type,
@@ -50,10 +63,15 @@ export const schedulePublishHandler = async ({
waitUntil: date,
})
} catch (err) {
let error = `Error scheduling ${type} for `
let error
if (doc) {
error += `document with ID ${doc.value} in collection ${doc.relationTo}`
if (deleteID) {
error = `Error deleting scheduled publish event with ID ${deleteID}`
} else {
error = `Error scheduling ${type} for `
if (doc) {
error += `document with ID ${doc.value} in collection ${doc.relationTo}`
}
}
payload.logger.error(error)

View File

@@ -1,4 +1,5 @@
import type { Payload, PayloadRequest } from 'payload'
import { createLocalReq, Payload } from 'payload'
import { schedulePublishHandler } from '@payloadcms/ui/utilities/schedulePublishHandler'
import path from 'path'
import { ValidationError } from 'payload'
@@ -48,6 +49,7 @@ const formatGraphQLID = (id: number | string) =>
payload.db.defaultIDType === 'number' ? id : `"${id}"`
describe('Versions', () => {
let user
beforeAll(async () => {
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, restClient } = await initPayloadInt(dirname))
@@ -69,12 +71,16 @@ describe('Versions', () => {
password: "${devUser.password}"
) {
token
user {
id
}
}
}`
const { data } = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query: login }) })
.then((res) => res.json())
user = { ...data.loginUser.user, collection: 'users' }
token = data.loginUser.token
// now: initialize
@@ -1862,10 +1868,6 @@ describe('Versions', () => {
const currentDate = new Date()
const user = (
await payload.find({ collection: 'users', where: { email: { equals: devUser.email } } })
).docs[0]
await payload.jobs.queue({
task: 'schedulePublish',
waitUntil: new Date(currentDate.getTime() + 3000),
@@ -1998,6 +2000,107 @@ describe('Versions', () => {
expect(retrieved._status).toStrictEqual('draft')
expect(retrieved.title).toStrictEqual('i will be a draft')
})
describe('server functions', () => {
let draftDoc
let event
beforeAll(async () => {
draftDoc = await payload.create({
collection: draftCollectionSlug,
data: {
title: 'my doc',
description: 'hello',
_status: 'draft',
},
})
})
it('should create using schedule-publish', async () => {
const currentDate = new Date()
const req = await createLocalReq({ user }, payload)
// use server action to create the event
await schedulePublishHandler({
req,
type: 'publish',
date: new Date(currentDate.getTime() + 3000),
doc: {
relationTo: draftCollectionSlug,
value: draftDoc.id,
},
user,
locale: 'all',
})
// fetch the job
;[event] = (
await payload.find({
collection: 'payload-jobs',
where: {
'input.doc.value': {
equals: draftDoc.id,
},
},
})
).docs
expect(event).toBeDefined()
})
it('should delete using schedule-publish', async () => {
const currentDate = new Date()
const req = await createLocalReq({ user }, payload)
// use server action to create the event
await schedulePublishHandler({
req,
type: 'publish',
date: new Date(currentDate.getTime() + 3000),
doc: {
relationTo: draftCollectionSlug,
value: draftDoc.id,
},
user,
locale: 'all',
})
// fetch the job
;[event] = (
await payload.find({
collection: 'payload-jobs',
where: {
'input.doc.value': {
equals: draftDoc.id,
},
},
})
).docs
// use server action to delete the event
await schedulePublishHandler({
req,
deleteID: event.id,
user,
})
// fetch the job
;[event] = (
await payload.find({
collection: 'payload-jobs',
where: {
'input.doc.value': {
equals: String(draftDoc.id),
},
},
})
).docs
expect(event).toBeUndefined()
})
})
})
describe('Publish Individual Locale', () => {