Skip to content

Commit 8b34e40

Browse files
authored
feat: add support for UTC timezone in date fields (#14586)
Adds support for 'UTC' as a value for timezones. This was not previously supported because we run a check on the values used against the runtime of Intl API. ```ts import { buildConfig } from 'payload' const config = buildConfig({ // ... admin: { timezones: { supportedTimezones: [ { label: 'UTC', value: 'UTC', }, // ...other timezones ], defaultTimezone: 'UTC', }, }, }) ``` Also fixes an issue when having only one timezone making it selected by default.
1 parent 6fda71a commit 8b34e40

File tree

6 files changed

+141
-7
lines changed

6 files changed

+141
-7
lines changed

docs/admin/overview.mdx

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,12 +286,14 @@ Users in the Admin Panel have the ability to choose between light mode and dark
286286

287287
The `admin.timezones` configuration allows you to configure timezone settings for the Admin Panel. You can customise the available list of timezones and in the future configure the default timezone for the Admin Panel and for all users.
288288

289+
Dates in Payload are always stored in UTC in the database. The timezone settings in the Admin Panel affect only how dates are displayed to editors to help ensure consistency for multi-region editorial teams.
290+
289291
The following options are available:
290292

291-
| Option | Description |
292-
| -------------------- | --------------------------------------------------------------------------------------------------------------- |
293-
| `supportedTimezones` | An array of label/value options for selectable timezones where the value is the IANA name eg. `America/Detroit` |
294-
| `defaultTimezone` | The `value` of the default selected timezone. eg. `America/Los_Angeles` |
293+
| Option | Description |
294+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
295+
| `supportedTimezones` | An array of label/value options for selectable timezones where the value is the IANA name eg. `America/Detroit`. Also supports a function that is given the defaultTimezones list. |
296+
| `defaultTimezone` | The `value` of the default selected timezone. eg. `America/Los_Angeles` |
295297

296298
We validate the supported timezones array by checking the value against the list of IANA timezones supported via the Intl API, specifically `Intl.supportedValuesOf('timeZone')`.
297299

@@ -330,6 +332,63 @@ const config = buildConfig({
330332
})
331333
```
332334

335+
### Extending supported timezones
336+
337+
For `supportedTimezones` we also support using a function that is given the defaultTimezones list. This allows you to easily extend the default list of timezones rather than replacing it completely.
338+
339+
```ts
340+
import { buildConfig } from 'payload'
341+
342+
const config = buildConfig({
343+
// ...
344+
admin: {
345+
timezones: {
346+
supportedTimezones: ({ defaultTimezones }) => [
347+
...defaultTimezones, // list provided by Payload
348+
{
349+
label: 'Europe/Dublin',
350+
value: 'Europe/Dublin',
351+
},
352+
{
353+
label: 'Europe/Amsterdam',
354+
value: 'Europe/Amsterdam',
355+
},
356+
{
357+
label: 'Europe/Bucharest',
358+
value: 'Europe/Bucharest',
359+
},
360+
],
361+
defaultTimezone: 'Europe/Amsterdam',
362+
},
363+
},
364+
})
365+
```
366+
367+
### Using a UTC timezone
368+
369+
In some situations you may want the displayed date and time to match exactly what's being stored in the database where we always store values in UTC. You can do this by adding UTC as a valid timezone option.
370+
Using a UTC timezone means that an editor inputing for example '1pm' will always see '1pm' and the stored value will be '13:00:00Z'.
371+
372+
```ts
373+
import { buildConfig } from 'payload'
374+
375+
const config = buildConfig({
376+
// ...
377+
admin: {
378+
timezones: {
379+
supportedTimezones: [
380+
{
381+
label: 'UTC',
382+
value: 'UTC',
383+
},
384+
// ...other timezones
385+
],
386+
defaultTimezone: 'UTC',
387+
},
388+
},
389+
})
390+
```
391+
333392
## Toast
334393

335394
The `admin.toast` configuration allows you to customize the handling of toast messages within the Admin Panel, such as increasing the duration they are displayed and limiting the number of visible toasts at once.

packages/payload/src/config/sanitize.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig>
9494

9595
// We're casting here because it's already been sanitised above but TS still thinks it could be a function
9696
;(sanitizedConfig.admin!.timezones.supportedTimezones as Timezone[]).forEach((timezone) => {
97-
if (!_internalSupportedTimezones.includes(timezone.value)) {
97+
if (timezone.value !== 'UTC' && !_internalSupportedTimezones.includes(timezone.value)) {
9898
throw new InvalidConfiguration(
9999
`Timezone ${timezone.value} is not supported by the current runtime via the Intl API.`,
100100
)

packages/payload/src/fields/config/sanitize.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ export const sanitizeFields = async ({
408408
if (field.type === 'date' && field.timezone) {
409409
const name = field.name + '_tz'
410410

411-
const defaultTimezone =
411+
let defaultTimezone =
412412
field.timezone && typeof field.timezone === 'object'
413413
? field.timezone.defaultTimezone
414414
: config.admin?.timezones?.defaultTimezone
@@ -427,6 +427,10 @@ export const sanitizeFields = async ({
427427
? supportedTimezones({ defaultTimezones })
428428
: supportedTimezones
429429

430+
if (options && options.length === 1 && options[0]?.value) {
431+
defaultTimezone = options[0].value
432+
}
433+
430434
// Need to set the options here manually so that any database enums are generated correctly
431435
// The UI component will import the options from the config
432436
const timezoneField = baseTimezoneField({

test/fields/baseConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export const baseConfig: Partial<Config> = {
148148
supportedTimezones: ({ defaultTimezones }) => [
149149
...defaultTimezones,
150150
{ label: '(GMT-6) Monterrey, Nuevo Leon', value: 'America/Monterrey' },
151+
{ label: 'Custom UTC', value: 'UTC' },
151152
],
152153
defaultTimezone: 'America/Monterrey',
153154
},

test/fields/collections/Date/e2e.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,48 @@ const createTimezoneContextTests = (contextName: string, timezoneId: string) =>
682682
}).toPass({ timeout: 10000, intervals: [100] })
683683
})
684684

685+
test('creates the expected UTC value when the selected timezone is UTC', async () => {
686+
const expectedDateInput = 'Jan 1, 2025 6:00 PM'
687+
const expectedUTCValue = '2025-01-01T18:00:00.000Z'
688+
689+
await page.goto(url.create)
690+
691+
const dateField = page.locator('#field-default input')
692+
await dateField.fill('01/01/2025')
693+
694+
const dateTimeLocator = page.locator(
695+
'#field-dayAndTimeWithTimezone .react-datepicker-wrapper input',
696+
)
697+
698+
const dropdownControlSelector = `#field-dayAndTimeWithTimezone .rs__control`
699+
const timezoneOptionSelector = `#field-dayAndTimeWithTimezone .rs__menu .rs__option:has-text("Custom UTC")`
700+
701+
await page.click(dropdownControlSelector)
702+
await page.click(timezoneOptionSelector)
703+
await dateTimeLocator.fill(expectedDateInput)
704+
705+
await saveDocAndAssert(page)
706+
707+
const docID = page.url().split('/').pop()
708+
709+
// eslint-disable-next-line payload/no-flaky-assertions
710+
expect(docID).toBeTruthy()
711+
712+
const {
713+
docs: [existingDoc],
714+
} = await payload.find({
715+
collection: dateFieldsSlug,
716+
where: {
717+
id: {
718+
equals: docID,
719+
},
720+
},
721+
})
722+
723+
// eslint-disable-next-line payload/no-flaky-assertions
724+
expect(existingDoc?.dayAndTimeWithTimezone).toEqual(expectedUTCValue)
725+
})
726+
685727
test('creates the expected UTC value when the selected timezone is Paris - no daylight savings', async () => {
686728
const expectedDateInput = 'Jan 1, 2025 6:00 PM'
687729
const expectedUTCValue = '2025-01-01T17:00:00.000Z'

test/fields/payload-types.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ export type SupportedTimezones =
6060
| 'Pacific/Noumea'
6161
| 'Pacific/Auckland'
6262
| 'Pacific/Fiji'
63-
| 'America/Monterrey';
63+
| 'America/Monterrey'
64+
| 'UTC';
6465

6566
export interface Config {
6667
auth: {
@@ -106,6 +107,7 @@ export interface Config {
106107
'uploads-multi-poly': UploadsMultiPoly;
107108
'uploads-restricted': UploadsRestricted;
108109
'ui-fields': UiField;
110+
'payload-kv': PayloadKv;
109111
'payload-locked-documents': PayloadLockedDocument;
110112
'payload-preferences': PayloadPreference;
111113
'payload-migrations': PayloadMigration;
@@ -146,6 +148,7 @@ export interface Config {
146148
'uploads-multi-poly': UploadsMultiPolySelect<false> | UploadsMultiPolySelect<true>;
147149
'uploads-restricted': UploadsRestrictedSelect<false> | UploadsRestrictedSelect<true>;
148150
'ui-fields': UiFieldsSelect<false> | UiFieldsSelect<true>;
151+
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
149152
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
150153
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
151154
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -1796,6 +1799,23 @@ export interface UiField {
17961799
updatedAt: string;
17971800
createdAt: string;
17981801
}
1802+
/**
1803+
* This interface was referenced by `Config`'s JSON-Schema
1804+
* via the `definition` "payload-kv".
1805+
*/
1806+
export interface PayloadKv {
1807+
id: string;
1808+
key: string;
1809+
data:
1810+
| {
1811+
[k: string]: unknown;
1812+
}
1813+
| unknown[]
1814+
| string
1815+
| number
1816+
| boolean
1817+
| null;
1818+
}
17991819
/**
18001820
* This interface was referenced by `Config`'s JSON-Schema
18011821
* via the `definition` "payload-locked-documents".
@@ -3477,6 +3497,14 @@ export interface UiFieldsSelect<T extends boolean = true> {
34773497
updatedAt?: T;
34783498
createdAt?: T;
34793499
}
3500+
/**
3501+
* This interface was referenced by `Config`'s JSON-Schema
3502+
* via the `definition` "payload-kv_select".
3503+
*/
3504+
export interface PayloadKvSelect<T extends boolean = true> {
3505+
key?: T;
3506+
data?: T;
3507+
}
34803508
/**
34813509
* This interface was referenced by `Config`'s JSON-Schema
34823510
* via the `definition` "payload-locked-documents_select".

0 commit comments

Comments
 (0)