diff --git a/packages/ui/src/elements/PublishButton/ScheduleDrawer/buildUpcomingColumns.tsx b/packages/ui/src/elements/PublishButton/ScheduleDrawer/buildUpcomingColumns.tsx
index 8f4d477a17..d709f5bd1f 100644
--- a/packages/ui/src/elements/PublishButton/ScheduleDrawer/buildUpcomingColumns.tsx
+++ b/packages/ui/src/elements/PublishButton/ScheduleDrawer/buildUpcomingColumns.tsx
@@ -56,9 +56,28 @@ export const buildUpcomingColumns = ({
},
Heading: {t('general:time')},
renderedCells: docs.map((doc) => (
- {formatDate({ date: doc.waitUntil, i18n, pattern: dateFormat })}
+
+ {formatDate({
+ date: doc.waitUntil,
+ i18n,
+ pattern: dateFormat,
+ timezone: doc.input.timezone,
+ })}
+
)),
},
+ {
+ accessor: 'input.timezone',
+ active: true,
+ field: {
+ name: '',
+ type: 'text',
+ },
+ Heading: {t('general:timezone')},
+ renderedCells: docs.map((doc) => {
+ return {doc.input.timezone || t('general:noValue')}
+ }),
+ },
]
if (localization) {
diff --git a/packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx b/packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx
index d5ae08882e..20de068e5d 100644
--- a/packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx
+++ b/packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx
@@ -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 = ({ 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 = ({ slug }) => {
const { schedulePublish } = useServerFunctions()
const [type, setType] = React.useState('publish')
const [date, setDate] = React.useState()
+ const [timezone, setTimezone] = React.useState(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 = ({ slug }) => {
const [upcomingColumns, setUpcomingColumns] = React.useState()
const deleteHandlerRef = React.useRef<((id: number | string) => Promise) | 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 = ({ 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 = ({ 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 = ({ slug }) => {
setDate(e)}
+ onChange={(e) => onChangeDate(e)}
pickerAppearance="dayAndTime"
readOnly={processing}
timeIntervals={5}
- value={date}
+ value={displayedValue}
/>
+ {supportedTimezones.length > 0 && (
+
+ )}
{localeOptions.length > 0 && type === 'publish' && (
diff --git a/packages/ui/src/elements/PublishButton/ScheduleDrawer/types.ts b/packages/ui/src/elements/PublishButton/ScheduleDrawer/types.ts
index 01de0570ae..6bebcb5583 100644
--- a/packages/ui/src/elements/PublishButton/ScheduleDrawer/types.ts
+++ b/packages/ui/src/elements/PublishButton/ScheduleDrawer/types.ts
@@ -4,6 +4,7 @@ export type UpcomingEvent = {
id: number | string
input: {
locale?: string
+ timezone?: string
type: PublishType
}
waitUntil: Date
diff --git a/packages/ui/src/utilities/formatDate.ts b/packages/ui/src/utilities/formatDate.ts
index 2716abe395..cb08a84553 100644
--- a/packages/ui/src/utilities/formatDate.ts
+++ b/packages/ui/src/utilities/formatDate.ts
@@ -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
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')}...`
diff --git a/packages/ui/src/utilities/schedulePublishHandler.ts b/packages/ui/src/utilities/schedulePublishHandler.ts
index a44b5f2be7..896ee895c7 100644
--- a/packages/ui/src/utilities/schedulePublishHandler.ts
+++ b/packages/ui/src/utilities/schedulePublishHandler.ts
@@ -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',
diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts
index 885aa40fb5..ef9154f149 100644
--- a/test/versions/e2e.spec.ts
+++ b/test/versions/e2e.spec.ts
@@ -72,6 +72,8 @@ const { beforeAll, beforeEach, describe } = test
let payload: PayloadTestSDK
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')
+ })
+ })
})