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>,
|
Heading: <span>{t('general:time')}</span>,
|
||||||
renderedCells: docs.map((doc) => (
|
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) {
|
if (localization) {
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
|
|
||||||
import type { Where } from 'payload'
|
import type { Where } from 'payload'
|
||||||
|
|
||||||
|
import { TZDateMini as TZDate } from '@date-fns/tz/date/mini'
|
||||||
import { useModal } from '@faceless-ui/modal'
|
import { useModal } from '@faceless-ui/modal'
|
||||||
import { getTranslation } from '@payloadcms/translations'
|
import { getTranslation } from '@payloadcms/translations'
|
||||||
|
import { transpose } from 'date-fns/transpose'
|
||||||
import * as qs from 'qs-esm'
|
import * as qs from 'qs-esm'
|
||||||
import React from 'react'
|
import React, { useCallback, useMemo } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import type { Column } from '../../Table/index.js'
|
import type { Column } from '../../Table/index.js'
|
||||||
@@ -28,8 +30,9 @@ import { Gutter } from '../../Gutter/index.js'
|
|||||||
import { ReactSelect } from '../../ReactSelect/index.js'
|
import { ReactSelect } from '../../ReactSelect/index.js'
|
||||||
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
|
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
|
||||||
import { Table } from '../../Table/index.js'
|
import { Table } from '../../Table/index.js'
|
||||||
import { buildUpcomingColumns } from './buildUpcomingColumns.js'
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
import { TimezonePicker } from '../../TimezonePicker/index.js'
|
||||||
|
import { buildUpcomingColumns } from './buildUpcomingColumns.js'
|
||||||
|
|
||||||
const baseClass = 'schedule-publish'
|
const baseClass = 'schedule-publish'
|
||||||
|
|
||||||
@@ -46,7 +49,10 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
|
|||||||
const { toggleModal } = useModal()
|
const { toggleModal } = useModal()
|
||||||
const {
|
const {
|
||||||
config: {
|
config: {
|
||||||
admin: { dateFormat },
|
admin: {
|
||||||
|
dateFormat,
|
||||||
|
timezones: { defaultTimezone, supportedTimezones },
|
||||||
|
},
|
||||||
localization,
|
localization,
|
||||||
routes: { api },
|
routes: { api },
|
||||||
serverURL,
|
serverURL,
|
||||||
@@ -57,6 +63,7 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
|
|||||||
const { schedulePublish } = useServerFunctions()
|
const { schedulePublish } = useServerFunctions()
|
||||||
const [type, setType] = React.useState<PublishType>('publish')
|
const [type, setType] = React.useState<PublishType>('publish')
|
||||||
const [date, setDate] = React.useState<Date>()
|
const [date, setDate] = React.useState<Date>()
|
||||||
|
const [timezone, setTimezone] = React.useState<string>(defaultTimezone)
|
||||||
const [locale, setLocale] = React.useState<{ label: string; value: string }>(defaultLocaleOption)
|
const [locale, setLocale] = React.useState<{ label: string; value: string }>(defaultLocaleOption)
|
||||||
const [processing, setProcessing] = React.useState(false)
|
const [processing, setProcessing] = React.useState(false)
|
||||||
const modalTitle = t('general:schedulePublishFor', { title })
|
const modalTitle = t('general:schedulePublishFor', { title })
|
||||||
@@ -64,6 +71,9 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
|
|||||||
const [upcomingColumns, setUpcomingColumns] = React.useState<Column[]>()
|
const [upcomingColumns, setUpcomingColumns] = React.useState<Column[]>()
|
||||||
const deleteHandlerRef = React.useRef<((id: number | string) => Promise<void>) | null>(() => null)
|
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(() => {
|
const localeOptions = React.useMemo(() => {
|
||||||
if (localization) {
|
if (localization) {
|
||||||
const options = localization.locales.map(({ code, label }) => ({
|
const options = localization.locales.map(({ code, label }) => ({
|
||||||
@@ -189,8 +199,10 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
global: globalSlug || undefined,
|
global: globalSlug || undefined,
|
||||||
locale: publishSpecificLocale,
|
locale: publishSpecificLocale,
|
||||||
|
timezone,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setTimezone(defaultTimezone)
|
||||||
setDate(undefined)
|
setDate(undefined)
|
||||||
toast.success(t('version:scheduledSuccessfully'))
|
toast.success(t('version:scheduledSuccessfully'))
|
||||||
void fetchUpcoming()
|
void fetchUpcoming()
|
||||||
@@ -200,7 +212,60 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setProcessing(false)
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!upcoming) {
|
if (!upcoming) {
|
||||||
@@ -252,12 +317,20 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
|
|||||||
<FieldLabel label={t('general:time')} required />
|
<FieldLabel label={t('general:time')} required />
|
||||||
<DatePickerField
|
<DatePickerField
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
onChange={(e) => setDate(e)}
|
onChange={(e) => onChangeDate(e)}
|
||||||
pickerAppearance="dayAndTime"
|
pickerAppearance="dayAndTime"
|
||||||
readOnly={processing}
|
readOnly={processing}
|
||||||
timeIntervals={5}
|
timeIntervals={5}
|
||||||
value={date}
|
value={displayedValue}
|
||||||
/>
|
/>
|
||||||
|
{supportedTimezones.length > 0 && (
|
||||||
|
<TimezonePicker
|
||||||
|
id={`timezone-picker`}
|
||||||
|
onChange={setTimezone}
|
||||||
|
options={supportedTimezones}
|
||||||
|
selectedTimezone={timezone}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<br />
|
<br />
|
||||||
{localeOptions.length > 0 && type === 'publish' && (
|
{localeOptions.length > 0 && type === 'publish' && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type UpcomingEvent = {
|
|||||||
id: number | string
|
id: number | string
|
||||||
input: {
|
input: {
|
||||||
locale?: string
|
locale?: string
|
||||||
|
timezone?: string
|
||||||
type: PublishType
|
type: PublishType
|
||||||
}
|
}
|
||||||
waitUntil: Date
|
waitUntil: Date
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
import type { I18n } from '@payloadcms/translations'
|
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 = {
|
type FormatDateArgs = {
|
||||||
date: Date | number | string | undefined
|
date: Date | number | string | undefined
|
||||||
i18n: I18n<any, any>
|
i18n: I18n<any, any>
|
||||||
pattern: string
|
pattern: string
|
||||||
|
timezone?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatDate = ({ date, i18n, pattern }: FormatDateArgs): string => {
|
export const formatDate = ({ date, i18n, pattern, timezone }: FormatDateArgs): string => {
|
||||||
const theDate = new Date(date)
|
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
|
return i18n.dateFNS
|
||||||
? format(theDate, pattern, { locale: i18n.dateFNS })
|
? format(theDate, pattern, { locale: i18n.dateFNS })
|
||||||
: `${i18n.t('general:loading')}...`
|
: `${i18n.t('general:loading')}...`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type SchedulePublishHandlerArgs = {
|
|||||||
*/
|
*/
|
||||||
deleteID?: number | string
|
deleteID?: number | string
|
||||||
req: PayloadRequest
|
req: PayloadRequest
|
||||||
|
timezone?: string
|
||||||
} & SchedulePublishTaskInput
|
} & SchedulePublishTaskInput
|
||||||
|
|
||||||
export const schedulePublishHandler = async ({
|
export const schedulePublishHandler = async ({
|
||||||
@@ -17,6 +18,7 @@ export const schedulePublishHandler = async ({
|
|||||||
global,
|
global,
|
||||||
locale,
|
locale,
|
||||||
req,
|
req,
|
||||||
|
timezone,
|
||||||
}: SchedulePublishHandlerArgs) => {
|
}: SchedulePublishHandlerArgs) => {
|
||||||
const { i18n, payload, user } = req
|
const { i18n, payload, user } = req
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ export const schedulePublishHandler = async ({
|
|||||||
doc,
|
doc,
|
||||||
global,
|
global,
|
||||||
locale,
|
locale,
|
||||||
|
timezone,
|
||||||
user: user.id,
|
user: user.id,
|
||||||
},
|
},
|
||||||
task: 'schedulePublish',
|
task: 'schedulePublish',
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ const { beforeAll, beforeEach, describe } = test
|
|||||||
let payload: PayloadTestSDK<Config>
|
let payload: PayloadTestSDK<Config>
|
||||||
let context: BrowserContext
|
let context: BrowserContext
|
||||||
|
|
||||||
|
const londonTimezone = 'Europe/London'
|
||||||
|
|
||||||
describe('Versions', () => {
|
describe('Versions', () => {
|
||||||
let page: Page
|
let page: Page
|
||||||
let url: AdminUrlUtil
|
let url: AdminUrlUtil
|
||||||
@@ -283,7 +285,9 @@ describe('Versions', () => {
|
|||||||
await page.locator('tbody tr .cell-title a').first().click()
|
await page.locator('tbody tr .cell-title a').first().click()
|
||||||
await page.waitForSelector('.doc-header__title', { state: 'visible' })
|
await page.waitForSelector('.doc-header__title', { state: 'visible' })
|
||||||
await page.goto(`${page.url()}/versions`)
|
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 () => {
|
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 global = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
|
||||||
const versionsURL = `${global.global(autoSaveGlobalSlug)}/versions`
|
const versionsURL = `${global.global(autoSaveGlobalSlug)}/versions`
|
||||||
await page.goto(versionsURL)
|
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 () => {
|
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