feat(ui): add timezone support to scheduled publish (#11090)

This PR extends timezone support to scheduled publish UI and collection,
the timezone will be stored on the `input` JSON instead of the
`waitUntil` date field so that we avoid needing a schema migration for
SQL databases.


![image](https://github.com/user-attachments/assets/0cc6522b-1b2f-4608-a592-67e3cdcdb566)

If a timezone is selected then the displayed date in the table will be
formatted for that timezone.

Timezones remain optional here as they can be deselected in which case
the date will behave as normal, rendering and formatting to the user's
local timezone.

For the backend logic that can be left untouched since the underlying
date values are stored in UTC the job runners will always handle this
relative time by default.

Todo:
- [x] add e2e to this drawer too to ensure that dates are rendered as
expected
This commit is contained in:
Paul
2025-02-11 00:48:52 +00:00
committed by GitHub
parent 36168184b5
commit c18c58e1fb
6 changed files with 201 additions and 12 deletions

View File

@@ -56,9 +56,28 @@ export const buildUpcomingColumns = ({
},
Heading: <span>{t('general:time')}</span>,
renderedCells: docs.map((doc) => (
<span key={doc.id}>{formatDate({ date: doc.waitUntil, i18n, pattern: dateFormat })}</span>
<span key={doc.id}>
{formatDate({
date: doc.waitUntil,
i18n,
pattern: dateFormat,
timezone: doc.input.timezone,
})}
</span>
)),
},
{
accessor: 'input.timezone',
active: true,
field: {
name: '',
type: 'text',
},
Heading: <span>{t('general:timezone')}</span>,
renderedCells: docs.map((doc) => {
return <span key={doc.id}>{doc.input.timezone || t('general:noValue')}</span>
}),
},
]
if (localization) {

View File

@@ -3,10 +3,12 @@
import type { Where } from 'payload'
import { TZDateMini as TZDate } from '@date-fns/tz/date/mini'
import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import { transpose } from 'date-fns/transpose'
import * as qs from 'qs-esm'
import React from 'react'
import React, { useCallback, useMemo } from 'react'
import { toast } from 'sonner'
import type { Column } from '../../Table/index.js'
@@ -28,8 +30,9 @@ import { Gutter } from '../../Gutter/index.js'
import { ReactSelect } from '../../ReactSelect/index.js'
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
import { Table } from '../../Table/index.js'
import { buildUpcomingColumns } from './buildUpcomingColumns.js'
import './index.scss'
import { TimezonePicker } from '../../TimezonePicker/index.js'
import { buildUpcomingColumns } from './buildUpcomingColumns.js'
const baseClass = 'schedule-publish'
@@ -46,7 +49,10 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
const { toggleModal } = useModal()
const {
config: {
admin: { dateFormat },
admin: {
dateFormat,
timezones: { defaultTimezone, supportedTimezones },
},
localization,
routes: { api },
serverURL,
@@ -57,6 +63,7 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
const { schedulePublish } = useServerFunctions()
const [type, setType] = React.useState<PublishType>('publish')
const [date, setDate] = React.useState<Date>()
const [timezone, setTimezone] = React.useState<string>(defaultTimezone)
const [locale, setLocale] = React.useState<{ label: string; value: string }>(defaultLocaleOption)
const [processing, setProcessing] = React.useState(false)
const modalTitle = t('general:schedulePublishFor', { title })
@@ -64,6 +71,9 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
const [upcomingColumns, setUpcomingColumns] = React.useState<Column[]>()
const deleteHandlerRef = React.useRef<((id: number | string) => Promise<void>) | null>(() => null)
// Get the user timezone so we can adjust the displayed value against it
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const localeOptions = React.useMemo(() => {
if (localization) {
const options = localization.locales.map(({ code, label }) => ({
@@ -189,8 +199,10 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
: undefined,
global: globalSlug || undefined,
locale: publishSpecificLocale,
timezone,
})
setTimezone(defaultTimezone)
setDate(undefined)
toast.success(t('version:scheduledSuccessfully'))
void fetchUpcoming()
@@ -200,7 +212,60 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
}
setProcessing(false)
}, [date, t, schedulePublish, type, locale, collectionSlug, id, globalSlug, fetchUpcoming])
}, [
date,
locale,
type,
t,
schedulePublish,
collectionSlug,
id,
globalSlug,
timezone,
defaultTimezone,
fetchUpcoming,
])
const displayedValue = useMemo(() => {
if (timezone && userTimezone && date) {
// Create TZDate instances for the selected timezone and the user's timezone
// These instances allow us to transpose the date between timezones while keeping the same time value
const DateWithOriginalTz = TZDate.tz(timezone)
const DateWithUserTz = TZDate.tz(userTimezone)
const modifiedDate = new TZDate(date).withTimeZone(timezone)
// Transpose the date to the selected timezone
const dateWithTimezone = transpose(modifiedDate, DateWithOriginalTz)
// Transpose the date to the user's timezone - this is necessary because the react-datepicker component insists on displaying the date in the user's timezone
const dateWithUserTimezone = transpose(dateWithTimezone, DateWithUserTz)
return dateWithUserTimezone.toISOString()
}
return date
}, [timezone, date, userTimezone])
const onChangeDate = useCallback(
(incomingDate: Date) => {
if (timezone && incomingDate) {
// Create TZDate instances for the selected timezone
const tzDateWithUTC = TZDate.tz(timezone)
// Creates a TZDate instance for the user's timezone — this is default behaviour of TZDate as it wraps the Date constructor
const dateToUserTz = new TZDate(incomingDate)
// Transpose the date to the selected timezone
const dateWithTimezone = transpose(dateToUserTz, tzDateWithUTC)
setDate(dateWithTimezone || null)
} else {
setDate(incomingDate || null)
}
},
[setDate, timezone],
)
React.useEffect(() => {
if (!upcoming) {
@@ -252,12 +317,20 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
<FieldLabel label={t('general:time')} required />
<DatePickerField
minDate={new Date()}
onChange={(e) => setDate(e)}
onChange={(e) => onChangeDate(e)}
pickerAppearance="dayAndTime"
readOnly={processing}
timeIntervals={5}
value={date}
value={displayedValue}
/>
{supportedTimezones.length > 0 && (
<TimezonePicker
id={`timezone-picker`}
onChange={setTimezone}
options={supportedTimezones}
selectedTimezone={timezone}
/>
)}
<br />
{localeOptions.length > 0 && type === 'publish' && (
<React.Fragment>

View File

@@ -4,6 +4,7 @@ export type UpcomingEvent = {
id: number | string
input: {
locale?: string
timezone?: string
type: PublishType
}
waitUntil: Date

View File

@@ -1,15 +1,32 @@
import type { I18n } from '@payloadcms/translations'
import { format, formatDistanceToNow } from 'date-fns'
import { TZDateMini as TZDate } from '@date-fns/tz/date/mini'
import { format, formatDistanceToNow, transpose } from 'date-fns'
type FormatDateArgs = {
date: Date | number | string | undefined
i18n: I18n<any, any>
pattern: string
timezone?: string
}
export const formatDate = ({ date, i18n, pattern }: FormatDateArgs): string => {
const theDate = new Date(date)
export const formatDate = ({ date, i18n, pattern, timezone }: FormatDateArgs): string => {
const theDate = new TZDate(new Date(date))
if (timezone) {
const DateWithOriginalTz = TZDate.tz(timezone)
const modifiedDate = theDate.withTimeZone(timezone)
// Transpose the date to the selected timezone
const dateWithTimezone = transpose(modifiedDate, DateWithOriginalTz)
// Transpose the date to the user's timezone - this is necessary because the react-datepicker component insists on displaying the date in the user's timezone
return i18n.dateFNS
? format(dateWithTimezone, pattern, { locale: i18n.dateFNS })
: `${i18n.t('general:loading')}...`
}
return i18n.dateFNS
? format(theDate, pattern, { locale: i18n.dateFNS })
: `${i18n.t('general:loading')}...`

View File

@@ -7,6 +7,7 @@ export type SchedulePublishHandlerArgs = {
*/
deleteID?: number | string
req: PayloadRequest
timezone?: string
} & SchedulePublishTaskInput
export const schedulePublishHandler = async ({
@@ -17,6 +18,7 @@ export const schedulePublishHandler = async ({
global,
locale,
req,
timezone,
}: SchedulePublishHandlerArgs) => {
const { i18n, payload, user } = req
@@ -57,6 +59,7 @@ export const schedulePublishHandler = async ({
doc,
global,
locale,
timezone,
user: user.id,
},
task: 'schedulePublish',

View File

@@ -72,6 +72,8 @@ const { beforeAll, beforeEach, describe } = test
let payload: PayloadTestSDK<Config>
let context: BrowserContext
const londonTimezone = 'Europe/London'
describe('Versions', () => {
let page: Page
let url: AdminUrlUtil
@@ -283,7 +285,9 @@ describe('Versions', () => {
await page.locator('tbody tr .cell-title a').first().click()
await page.waitForSelector('.doc-header__title', { state: 'visible' })
await page.goto(`${page.url()}/versions`)
expect(page.url()).toMatch(/\/versions/)
await expect(() => {
expect(page.url()).toMatch(/\/versions/)
}).toPass({ timeout: 10000, intervals: [100] })
})
test('should show collection versions view level action in collection versions view', async () => {
@@ -388,7 +392,9 @@ describe('Versions', () => {
const global = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
const versionsURL = `${global.global(autoSaveGlobalSlug)}/versions`
await page.goto(versionsURL)
expect(page.url()).toMatch(/\/versions$/)
await expect(() => {
expect(page.url()).toMatch(/\/versions/)
}).toPass({ timeout: 10000, intervals: [100] })
})
test('collection - should autosave', async () => {
@@ -1237,4 +1243,74 @@ describe('Versions', () => {
)
})
})
describe('Scheduled publish', () => {
test.use({
timezoneId: londonTimezone,
})
test('correctly sets a UTC date for the chosen timezone', async () => {
const post = await payload.create({
collection: draftCollectionSlug,
data: {
title: 'new post',
description: 'new description',
},
})
await page.goto(`${serverURL}/admin/collections/${draftCollectionSlug}/${post.id}`)
const publishDropdown = page.locator('.doc-controls__controls .popup-button')
await publishDropdown.click()
const schedulePublishButton = page.locator(
'.popup-button-list__button:has-text("Schedule Publish")',
)
await schedulePublishButton.click()
const drawerContent = page.locator('.schedule-publish__scheduler')
const dropdownControlSelector = drawerContent.locator(`.timezone-picker .rs__control`)
const timezoneOptionSelector = drawerContent.locator(
`.timezone-picker .rs__menu .rs__option:has-text("Paris")`,
)
await dropdownControlSelector.click()
await timezoneOptionSelector.click()
const dateInput = drawerContent.locator('.date-time-picker__input-wrapper input')
// Create a date for 2049-01-01 18:00:00
const date = new Date(2049, 0, 1, 18, 0)
await dateInput.fill(date.toISOString())
await page.keyboard.press('Enter') // formats the date to the correct format
const saveButton = drawerContent.locator('.schedule-publish__actions button')
await saveButton.click()
const upcomingContent = page.locator('.schedule-publish__upcoming')
const createdDate = await upcomingContent.locator('.row-1 .cell-waitUntil').textContent()
await expect(() => {
expect(createdDate).toContain('6:00:00 PM')
}).toPass({ timeout: 10000, intervals: [100] })
const {
docs: [createdJob],
} = await payload.find({
collection: 'payload-jobs',
where: {
'input.doc.value': {
equals: String(post.id),
},
},
})
// eslint-disable-next-line payload/no-flaky-assertions
expect(createdJob).toBeTruthy()
// eslint-disable-next-line payload/no-flaky-assertions
expect(createdJob?.waitUntil).toEqual('2049-01-01T17:00:00.000Z')
})
})
})