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') + }) + }) })