From c18c58e1fb2da105ecb1b8ab355c5938e9f7d15a Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 11 Feb 2025 00:48:52 +0000 Subject: [PATCH] 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 --- .../ScheduleDrawer/buildUpcomingColumns.tsx | 21 ++++- .../PublishButton/ScheduleDrawer/index.tsx | 85 +++++++++++++++++-- .../PublishButton/ScheduleDrawer/types.ts | 1 + packages/ui/src/utilities/formatDate.ts | 23 ++++- .../src/utilities/schedulePublishHandler.ts | 3 + test/versions/e2e.spec.ts | 80 ++++++++++++++++- 6 files changed, 201 insertions(+), 12 deletions(-) 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') + }) + }) })