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.  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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@ export type UpcomingEvent = {
|
||||
id: number | string
|
||||
input: {
|
||||
locale?: string
|
||||
timezone?: string
|
||||
type: PublishType
|
||||
}
|
||||
waitUntil: Date
|
||||
|
||||
@@ -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')}...`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`)
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user