Skip to content

Commit c18c58e

Browse files
authored
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
1 parent 3616818 commit c18c58e

File tree

6 files changed

+201
-12
lines changed

6 files changed

+201
-12
lines changed

packages/ui/src/elements/PublishButton/ScheduleDrawer/buildUpcomingColumns.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,28 @@ export const buildUpcomingColumns = ({
5656
},
5757
Heading: <span>{t('general:time')}</span>,
5858
renderedCells: docs.map((doc) => (
59-
<span key={doc.id}>{formatDate({ date: doc.waitUntil, i18n, pattern: dateFormat })}</span>
59+
<span key={doc.id}>
60+
{formatDate({
61+
date: doc.waitUntil,
62+
i18n,
63+
pattern: dateFormat,
64+
timezone: doc.input.timezone,
65+
})}
66+
</span>
6067
)),
6168
},
69+
{
70+
accessor: 'input.timezone',
71+
active: true,
72+
field: {
73+
name: '',
74+
type: 'text',
75+
},
76+
Heading: <span>{t('general:timezone')}</span>,
77+
renderedCells: docs.map((doc) => {
78+
return <span key={doc.id}>{doc.input.timezone || t('general:noValue')}</span>
79+
}),
80+
},
6281
]
6382

6483
if (localization) {

packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33

44
import type { Where } from 'payload'
55

6+
import { TZDateMini as TZDate } from '@date-fns/tz/date/mini'
67
import { useModal } from '@faceless-ui/modal'
78
import { getTranslation } from '@payloadcms/translations'
9+
import { transpose } from 'date-fns/transpose'
810
import * as qs from 'qs-esm'
9-
import React from 'react'
11+
import React, { useCallback, useMemo } from 'react'
1012
import { toast } from 'sonner'
1113

1214
import type { Column } from '../../Table/index.js'
@@ -28,8 +30,9 @@ import { Gutter } from '../../Gutter/index.js'
2830
import { ReactSelect } from '../../ReactSelect/index.js'
2931
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
3032
import { Table } from '../../Table/index.js'
31-
import { buildUpcomingColumns } from './buildUpcomingColumns.js'
3233
import './index.scss'
34+
import { TimezonePicker } from '../../TimezonePicker/index.js'
35+
import { buildUpcomingColumns } from './buildUpcomingColumns.js'
3336

3437
const baseClass = 'schedule-publish'
3538

@@ -46,7 +49,10 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
4649
const { toggleModal } = useModal()
4750
const {
4851
config: {
49-
admin: { dateFormat },
52+
admin: {
53+
dateFormat,
54+
timezones: { defaultTimezone, supportedTimezones },
55+
},
5056
localization,
5157
routes: { api },
5258
serverURL,
@@ -57,13 +63,17 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
5763
const { schedulePublish } = useServerFunctions()
5864
const [type, setType] = React.useState<PublishType>('publish')
5965
const [date, setDate] = React.useState<Date>()
66+
const [timezone, setTimezone] = React.useState<string>(defaultTimezone)
6067
const [locale, setLocale] = React.useState<{ label: string; value: string }>(defaultLocaleOption)
6168
const [processing, setProcessing] = React.useState(false)
6269
const modalTitle = t('general:schedulePublishFor', { title })
6370
const [upcoming, setUpcoming] = React.useState<UpcomingEvent[]>()
6471
const [upcomingColumns, setUpcomingColumns] = React.useState<Column[]>()
6572
const deleteHandlerRef = React.useRef<((id: number | string) => Promise<void>) | null>(() => null)
6673

74+
// Get the user timezone so we can adjust the displayed value against it
75+
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
76+
6777
const localeOptions = React.useMemo(() => {
6878
if (localization) {
6979
const options = localization.locales.map(({ code, label }) => ({
@@ -189,8 +199,10 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
189199
: undefined,
190200
global: globalSlug || undefined,
191201
locale: publishSpecificLocale,
202+
timezone,
192203
})
193204

205+
setTimezone(defaultTimezone)
194206
setDate(undefined)
195207
toast.success(t('version:scheduledSuccessfully'))
196208
void fetchUpcoming()
@@ -200,7 +212,60 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
200212
}
201213

202214
setProcessing(false)
203-
}, [date, t, schedulePublish, type, locale, collectionSlug, id, globalSlug, fetchUpcoming])
215+
}, [
216+
date,
217+
locale,
218+
type,
219+
t,
220+
schedulePublish,
221+
collectionSlug,
222+
id,
223+
globalSlug,
224+
timezone,
225+
defaultTimezone,
226+
fetchUpcoming,
227+
])
228+
229+
const displayedValue = useMemo(() => {
230+
if (timezone && userTimezone && date) {
231+
// Create TZDate instances for the selected timezone and the user's timezone
232+
// These instances allow us to transpose the date between timezones while keeping the same time value
233+
const DateWithOriginalTz = TZDate.tz(timezone)
234+
const DateWithUserTz = TZDate.tz(userTimezone)
235+
236+
const modifiedDate = new TZDate(date).withTimeZone(timezone)
237+
238+
// Transpose the date to the selected timezone
239+
const dateWithTimezone = transpose(modifiedDate, DateWithOriginalTz)
240+
241+
// 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
242+
const dateWithUserTimezone = transpose(dateWithTimezone, DateWithUserTz)
243+
244+
return dateWithUserTimezone.toISOString()
245+
}
246+
247+
return date
248+
}, [timezone, date, userTimezone])
249+
250+
const onChangeDate = useCallback(
251+
(incomingDate: Date) => {
252+
if (timezone && incomingDate) {
253+
// Create TZDate instances for the selected timezone
254+
const tzDateWithUTC = TZDate.tz(timezone)
255+
256+
// Creates a TZDate instance for the user's timezone — this is default behaviour of TZDate as it wraps the Date constructor
257+
const dateToUserTz = new TZDate(incomingDate)
258+
259+
// Transpose the date to the selected timezone
260+
const dateWithTimezone = transpose(dateToUserTz, tzDateWithUTC)
261+
262+
setDate(dateWithTimezone || null)
263+
} else {
264+
setDate(incomingDate || null)
265+
}
266+
},
267+
[setDate, timezone],
268+
)
204269

205270
React.useEffect(() => {
206271
if (!upcoming) {
@@ -252,12 +317,20 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug }) => {
252317
<FieldLabel label={t('general:time')} required />
253318
<DatePickerField
254319
minDate={new Date()}
255-
onChange={(e) => setDate(e)}
320+
onChange={(e) => onChangeDate(e)}
256321
pickerAppearance="dayAndTime"
257322
readOnly={processing}
258323
timeIntervals={5}
259-
value={date}
324+
value={displayedValue}
260325
/>
326+
{supportedTimezones.length > 0 && (
327+
<TimezonePicker
328+
id={`timezone-picker`}
329+
onChange={setTimezone}
330+
options={supportedTimezones}
331+
selectedTimezone={timezone}
332+
/>
333+
)}
261334
<br />
262335
{localeOptions.length > 0 && type === 'publish' && (
263336
<React.Fragment>

packages/ui/src/elements/PublishButton/ScheduleDrawer/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type UpcomingEvent = {
44
id: number | string
55
input: {
66
locale?: string
7+
timezone?: string
78
type: PublishType
89
}
910
waitUntil: Date

packages/ui/src/utilities/formatDate.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,32 @@
11
import type { I18n } from '@payloadcms/translations'
22

3-
import { format, formatDistanceToNow } from 'date-fns'
3+
import { TZDateMini as TZDate } from '@date-fns/tz/date/mini'
4+
import { format, formatDistanceToNow, transpose } from 'date-fns'
45

56
type FormatDateArgs = {
67
date: Date | number | string | undefined
78
i18n: I18n<any, any>
89
pattern: string
10+
timezone?: string
911
}
1012

11-
export const formatDate = ({ date, i18n, pattern }: FormatDateArgs): string => {
12-
const theDate = new Date(date)
13+
export const formatDate = ({ date, i18n, pattern, timezone }: FormatDateArgs): string => {
14+
const theDate = new TZDate(new Date(date))
15+
16+
if (timezone) {
17+
const DateWithOriginalTz = TZDate.tz(timezone)
18+
19+
const modifiedDate = theDate.withTimeZone(timezone)
20+
21+
// Transpose the date to the selected timezone
22+
const dateWithTimezone = transpose(modifiedDate, DateWithOriginalTz)
23+
24+
// 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
25+
return i18n.dateFNS
26+
? format(dateWithTimezone, pattern, { locale: i18n.dateFNS })
27+
: `${i18n.t('general:loading')}...`
28+
}
29+
1330
return i18n.dateFNS
1431
? format(theDate, pattern, { locale: i18n.dateFNS })
1532
: `${i18n.t('general:loading')}...`

packages/ui/src/utilities/schedulePublishHandler.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type SchedulePublishHandlerArgs = {
77
*/
88
deleteID?: number | string
99
req: PayloadRequest
10+
timezone?: string
1011
} & SchedulePublishTaskInput
1112

1213
export const schedulePublishHandler = async ({
@@ -17,6 +18,7 @@ export const schedulePublishHandler = async ({
1718
global,
1819
locale,
1920
req,
21+
timezone,
2022
}: SchedulePublishHandlerArgs) => {
2123
const { i18n, payload, user } = req
2224

@@ -57,6 +59,7 @@ export const schedulePublishHandler = async ({
5759
doc,
5860
global,
5961
locale,
62+
timezone,
6063
user: user.id,
6164
},
6265
task: 'schedulePublish',

test/versions/e2e.spec.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ const { beforeAll, beforeEach, describe } = test
7272
let payload: PayloadTestSDK<Config>
7373
let context: BrowserContext
7474

75+
const londonTimezone = 'Europe/London'
76+
7577
describe('Versions', () => {
7678
let page: Page
7779
let url: AdminUrlUtil
@@ -283,7 +285,9 @@ describe('Versions', () => {
283285
await page.locator('tbody tr .cell-title a').first().click()
284286
await page.waitForSelector('.doc-header__title', { state: 'visible' })
285287
await page.goto(`${page.url()}/versions`)
286-
expect(page.url()).toMatch(/\/versions/)
288+
await expect(() => {
289+
expect(page.url()).toMatch(/\/versions/)
290+
}).toPass({ timeout: 10000, intervals: [100] })
287291
})
288292

289293
test('should show collection versions view level action in collection versions view', async () => {
@@ -388,7 +392,9 @@ describe('Versions', () => {
388392
const global = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
389393
const versionsURL = `${global.global(autoSaveGlobalSlug)}/versions`
390394
await page.goto(versionsURL)
391-
expect(page.url()).toMatch(/\/versions$/)
395+
await expect(() => {
396+
expect(page.url()).toMatch(/\/versions/)
397+
}).toPass({ timeout: 10000, intervals: [100] })
392398
})
393399

394400
test('collection - should autosave', async () => {
@@ -1237,4 +1243,74 @@ describe('Versions', () => {
12371243
)
12381244
})
12391245
})
1246+
1247+
describe('Scheduled publish', () => {
1248+
test.use({
1249+
timezoneId: londonTimezone,
1250+
})
1251+
1252+
test('correctly sets a UTC date for the chosen timezone', async () => {
1253+
const post = await payload.create({
1254+
collection: draftCollectionSlug,
1255+
data: {
1256+
title: 'new post',
1257+
description: 'new description',
1258+
},
1259+
})
1260+
1261+
await page.goto(`${serverURL}/admin/collections/${draftCollectionSlug}/${post.id}`)
1262+
1263+
const publishDropdown = page.locator('.doc-controls__controls .popup-button')
1264+
await publishDropdown.click()
1265+
1266+
const schedulePublishButton = page.locator(
1267+
'.popup-button-list__button:has-text("Schedule Publish")',
1268+
)
1269+
await schedulePublishButton.click()
1270+
1271+
const drawerContent = page.locator('.schedule-publish__scheduler')
1272+
1273+
const dropdownControlSelector = drawerContent.locator(`.timezone-picker .rs__control`)
1274+
const timezoneOptionSelector = drawerContent.locator(
1275+
`.timezone-picker .rs__menu .rs__option:has-text("Paris")`,
1276+
)
1277+
await dropdownControlSelector.click()
1278+
await timezoneOptionSelector.click()
1279+
1280+
const dateInput = drawerContent.locator('.date-time-picker__input-wrapper input')
1281+
// Create a date for 2049-01-01 18:00:00
1282+
const date = new Date(2049, 0, 1, 18, 0)
1283+
1284+
await dateInput.fill(date.toISOString())
1285+
await page.keyboard.press('Enter') // formats the date to the correct format
1286+
1287+
const saveButton = drawerContent.locator('.schedule-publish__actions button')
1288+
1289+
await saveButton.click()
1290+
1291+
const upcomingContent = page.locator('.schedule-publish__upcoming')
1292+
const createdDate = await upcomingContent.locator('.row-1 .cell-waitUntil').textContent()
1293+
1294+
await expect(() => {
1295+
expect(createdDate).toContain('6:00:00 PM')
1296+
}).toPass({ timeout: 10000, intervals: [100] })
1297+
1298+
const {
1299+
docs: [createdJob],
1300+
} = await payload.find({
1301+
collection: 'payload-jobs',
1302+
where: {
1303+
'input.doc.value': {
1304+
equals: String(post.id),
1305+
},
1306+
},
1307+
})
1308+
1309+
// eslint-disable-next-line payload/no-flaky-assertions
1310+
expect(createdJob).toBeTruthy()
1311+
1312+
// eslint-disable-next-line payload/no-flaky-assertions
1313+
expect(createdJob?.waitUntil).toEqual('2049-01-01T17:00:00.000Z')
1314+
})
1315+
})
12401316
})

0 commit comments

Comments
 (0)