Skip to content

Commit 430ebd4

Browse files
authored
feat: add timezone support on date fields (#10896)
Adds support for timezone selection on date fields. ### Summary New `admin.timezones` config: ```ts { // ... admin: { // ... timezones: { supportedTimezones: ({ defaultTimezones }) => [ ...defaultTimezones, { label: '(GMT-6) Monterrey, Nuevo Leon', value: 'America/Monterrey' }, ], defaultTimezone: 'America/Monterrey', }, } } ``` New `timezone` property on date fields: ```ts { type: 'date', name: 'date', timezone: true, } ``` ### Configuration All date fields now accept `timezone: true` to enable this feature, which will inject a new field into the configuration using the date field's name to construct the name for the timezone column. So `publishingDate` will have `publishingDate_tz` as an accompanying column. This new field is inserted during config sanitisation. Dates continue to be stored in UTC, this will help maintain dates without needing a migration and it makes it easier for data to be manipulated as needed. Mongodb also has a restriction around storing dates only as UTC. All timezones are stored by their IANA names so it's compatible with browser APIs. There is a newly generated type for `SupportedTimezones` which is reused across fields. We handle timezone calculations via a new package `@date-fns/tz` which we will be using in the future for handling timezone aware scheduled publishing/unpublishing and more. ### UI Dark mode ![image](https://github.com/user-attachments/assets/fcebdb7f-be01-4382-a1ce-3369f72b4309) Light mode ![image](https://github.com/user-attachments/assets/dee2f1c6-4d0c-49e9-b6c8-a51a83a5e864)
1 parent 3415ba8 commit 430ebd4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+1214
-48
lines changed

docs/admin/overview.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ The following options are available:
9999
| **`routes`** | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). |
100100
| **`suppressHydrationWarning`** | If set to `true`, suppresses React hydration mismatch warnings during the hydration of the root `<html>` tag. Defaults to `false`. |
101101
| **`theme`** | Restrict the Admin Panel theme to use only one of your choice. Default is `all`. |
102+
| **`timezones`** | Configure the timezone settings for the admin panel. [More details](#timezones) |
102103
| **`user`** | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). |
103104

104105
<Banner type="success">
@@ -242,3 +243,21 @@ The Payload Admin Panel is translated in over [30 languages and counting](https:
242243
## Light and Dark Modes
243244

244245
Users in the Admin Panel have the ability to choose between light mode and dark mode for their editing experience. Users can select their preferred theme from their account page. Once selected, it is saved to their user's preferences and persisted across sessions and devices. If no theme was selected, the Admin Panel will automatically detect the operation system's theme and use that as the default.
246+
247+
## Timezones
248+
249+
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.
250+
251+
The following options are available:
252+
253+
| Option | Description |
254+
| ----------------- | ----------------------------------------------- |
255+
| `supportedTimezones` | An array of label/value options for selectable timezones where the value is the IANA name eg. `America/Detroit` |
256+
| `defaultTimezone` | The `value` of the default selected timezone. eg. `America/Los_Angeles` |
257+
258+
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')`.
259+
260+
<Banner type="info">
261+
**Important**
262+
You must enable timezones on each individual date field via `timezone: true`. See [Date Fields](../fields/overview#date) for more information.
263+
</Banner>

docs/fields/date.mdx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const MyDateField: Field = {
4343
| **`required`** | Require this field to have a value. |
4444
| **`admin`** | Admin-specific configuration. [More details](#admin-options). |
4545
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
46+
| **`timezone`** * | Set to `true` to enable timezone selection on this field. [More details](#timezones). |
4647
| **`typescriptSchema`** | Override field type generation with providing a JSON schema |
4748
| **`virtual`** | Provide `true` to disable field in the database. See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) |
4849

@@ -222,3 +223,23 @@ export const CustomDateFieldLabelClient: DateFieldLabelClientComponent = ({
222223
}
223224
```
224225

226+
## Timezones
227+
228+
To enable timezone selection on a Date field, set the `timezone` property to `true`:
229+
230+
```ts
231+
{
232+
name: 'date',
233+
type: 'date',
234+
timezone: true,
235+
}
236+
```
237+
238+
This will add a dropdown to the date picker that allows users to select a timezone. The selected timezone will be saved in the database along with the date in a new column named `date_tz`.
239+
240+
You can customise the available list of timezones in the [global admin config](../admin/overview#timezones).
241+
242+
<Banner type='info'>
243+
**Good to know:**
244+
The date itself will be stored in UTC so it's up to you to handle the conversion to the user's timezone when displaying the date in your frontend.
245+
</Banner>

packages/payload/src/config/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export const createClientConfig = ({
9797
meta: config.admin.meta,
9898
routes: config.admin.routes,
9999
theme: config.admin.theme,
100+
timezones: config.admin.timezones,
100101
user: config.admin.user,
101102
}
102103
if (config.admin.livePreview) {

packages/payload/src/config/sanitize.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import type {
99
LocalizationConfigWithLabels,
1010
LocalizationConfigWithNoLabels,
1111
SanitizedConfig,
12+
Timezone,
1213
} from './types.js'
1314

1415
import { defaultUserCollection } from '../auth/defaultUser.js'
1516
import { authRootEndpoints } from '../auth/endpoints/index.js'
1617
import { sanitizeCollection } from '../collections/config/sanitize.js'
1718
import { migrationsCollection } from '../database/migrations/migrationsCollection.js'
1819
import { DuplicateCollection, InvalidConfiguration } from '../errors/index.js'
20+
import { defaultTimezones } from '../fields/baseFields/timezone/defaultTimezones.js'
1921
import { sanitizeGlobal } from '../globals/config/sanitize.js'
2022
import { getLockedDocumentsCollection } from '../lockedDocuments/lockedDocumentsCollection.js'
2123
import getPreferencesCollection from '../preferences/preferencesCollection.js'
@@ -56,6 +58,32 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig>
5658
)
5759
}
5860

61+
if (sanitizedConfig?.admin?.timezones) {
62+
if (typeof sanitizedConfig?.admin?.timezones?.supportedTimezones === 'function') {
63+
sanitizedConfig.admin.timezones.supportedTimezones =
64+
sanitizedConfig.admin.timezones.supportedTimezones({ defaultTimezones })
65+
}
66+
67+
if (!sanitizedConfig?.admin?.timezones?.supportedTimezones) {
68+
sanitizedConfig.admin.timezones.supportedTimezones = defaultTimezones
69+
}
70+
} else {
71+
sanitizedConfig.admin.timezones = {
72+
supportedTimezones: defaultTimezones,
73+
}
74+
}
75+
// Timezones supported by the Intl API
76+
const _internalSupportedTimezones = Intl.supportedValuesOf('timeZone')
77+
78+
// We're casting here because it's already been sanitised above but TS still thinks it could be a function
79+
;(sanitizedConfig.admin.timezones.supportedTimezones as Timezone[]).forEach((timezone) => {
80+
if (!_internalSupportedTimezones.includes(timezone.value)) {
81+
throw new InvalidConfiguration(
82+
`Timezone ${timezone.value} is not supported by the current runtime via the Intl API.`,
83+
)
84+
}
85+
})
86+
5987
return sanitizedConfig as unknown as Partial<SanitizedConfig>
6088
}
6189

packages/payload/src/config/types.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,32 @@ export const serverProps: (keyof ServerProps)[] = [
426426
'permissions',
427427
]
428428

429+
export type Timezone = {
430+
label: string
431+
value: string
432+
}
433+
434+
type SupportedTimezonesFn = (args: { defaultTimezones: Timezone[] }) => Timezone[]
435+
436+
type TimezonesConfig = {
437+
/**
438+
* The default timezone to use for the admin panel.
439+
*/
440+
defaultTimezone?: string
441+
/**
442+
* Provide your own list of supported timezones for the admin panel
443+
*
444+
* Values should be IANA timezone names, eg. `America/New_York`
445+
*
446+
* We use `@date-fns/tz` to handle timezones
447+
*/
448+
supportedTimezones?: SupportedTimezonesFn | Timezone[]
449+
}
450+
451+
type SanitizedTimezoneConfig = {
452+
supportedTimezones: Timezone[]
453+
} & Omit<TimezonesConfig, 'supportedTimezones'>
454+
429455
export type CustomComponent<TAdditionalProps extends object = Record<string, any>> =
430456
PayloadComponent<ServerProps & TAdditionalProps, TAdditionalProps>
431457

@@ -880,6 +906,10 @@ export type Config = {
880906
* @default 'all' // The theme can be configured by users
881907
*/
882908
theme?: 'all' | 'dark' | 'light'
909+
/**
910+
* Configure timezone related settings for the admin panel.
911+
*/
912+
timezones?: TimezonesConfig
883913
/** The slug of a Collection that you want to be used to log in to the Admin dashboard. */
884914
user?: string
885915
}
@@ -1149,6 +1179,9 @@ export type Config = {
11491179
}
11501180

11511181
export type SanitizedConfig = {
1182+
admin: {
1183+
timezones: SanitizedTimezoneConfig
1184+
} & DeepRequired<Config['admin']>
11521185
collections: SanitizedCollectionConfig[]
11531186
/** Default richtext editor to use for richText fields */
11541187
editor?: RichTextAdapter<any, any, any>
@@ -1173,7 +1206,7 @@ export type SanitizedConfig = {
11731206
// E.g. in packages/ui/src/graphics/Account/index.tsx in getComponent, if avatar.Component is casted to what it's supposed to be,
11741207
// the result type is different
11751208
DeepRequired<Config>,
1176-
'collections' | 'editor' | 'endpoint' | 'globals' | 'i18n' | 'localization' | 'upload'
1209+
'admin' | 'collections' | 'editor' | 'endpoint' | 'globals' | 'i18n' | 'localization' | 'upload'
11771210
>
11781211

11791212
export type EditConfig = EditConfigWithoutRoot | EditConfigWithRoot

packages/payload/src/exports/shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export { defaults as collectionDefaults } from '../collections/config/defaults.j
1212

1313
export { serverProps } from '../config/types.js'
1414

15+
export { defaultTimezones } from '../fields/baseFields/timezone/defaultTimezones.js'
16+
1517
export {
1618
fieldAffectsData,
1719
fieldHasMaxDepth,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { SelectField } from '../../config/types.js'
2+
3+
export const baseTimezoneField: (args: Partial<SelectField>) => SelectField = ({
4+
name,
5+
defaultValue,
6+
options,
7+
required,
8+
}) => {
9+
return {
10+
name,
11+
type: 'select',
12+
admin: {
13+
hidden: true,
14+
},
15+
defaultValue,
16+
options,
17+
required,
18+
}
19+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Timezone } from '../../../config/types.js'
2+
3+
/**
4+
* List of supported timezones
5+
*
6+
* label: UTC offset and location
7+
* value: IANA timezone name
8+
*
9+
* @example
10+
* { label: '(UTC-12:00) International Date Line West', value: 'Dateline Standard Time' }
11+
*/
12+
export const defaultTimezones: Timezone[] = [
13+
{ label: '(UTC-11:00) Midway Island, Samoa', value: 'Pacific/Midway' },
14+
{ label: '(UTC-11:00) Niue', value: 'Pacific/Niue' },
15+
{ label: '(UTC-10:00) Hawaii', value: 'Pacific/Honolulu' },
16+
{ label: '(UTC-10:00) Cook Islands', value: 'Pacific/Rarotonga' },
17+
{ label: '(UTC-09:00) Alaska', value: 'America/Anchorage' },
18+
{ label: '(UTC-09:00) Gambier Islands', value: 'Pacific/Gambier' },
19+
{ label: '(UTC-08:00) Pacific Time (US & Canada)', value: 'America/Los_Angeles' },
20+
{ label: '(UTC-08:00) Tijuana, Baja California', value: 'America/Tijuana' },
21+
{ label: '(UTC-07:00) Mountain Time (US & Canada)', value: 'America/Denver' },
22+
{ label: '(UTC-07:00) Arizona (No DST)', value: 'America/Phoenix' },
23+
{ label: '(UTC-06:00) Central Time (US & Canada)', value: 'America/Chicago' },
24+
{ label: '(UTC-06:00) Central America', value: 'America/Guatemala' },
25+
{ label: '(UTC-05:00) Eastern Time (US & Canada)', value: 'America/New_York' },
26+
{ label: '(UTC-05:00) Bogota, Lima, Quito', value: 'America/Bogota' },
27+
{ label: '(UTC-04:00) Caracas', value: 'America/Caracas' },
28+
{ label: '(UTC-04:00) Santiago', value: 'America/Santiago' },
29+
{ label: '(UTC-03:00) Buenos Aires', value: 'America/Buenos_Aires' },
30+
{ label: '(UTC-03:00) Brasilia', value: 'America/Sao_Paulo' },
31+
{ label: '(UTC-02:00) South Georgia', value: 'Atlantic/South_Georgia' },
32+
{ label: '(UTC-01:00) Azores', value: 'Atlantic/Azores' },
33+
{ label: '(UTC-01:00) Cape Verde', value: 'Atlantic/Cape_Verde' },
34+
{ label: '(UTC+00:00) London (GMT)', value: 'Europe/London' },
35+
{ label: '(UTC+01:00) Berlin, Paris', value: 'Europe/Berlin' },
36+
{ label: '(UTC+01:00) Lagos', value: 'Africa/Lagos' },
37+
{ label: '(UTC+02:00) Athens, Bucharest', value: 'Europe/Athens' },
38+
{ label: '(UTC+02:00) Cairo', value: 'Africa/Cairo' },
39+
{ label: '(UTC+03:00) Moscow, St. Petersburg', value: 'Europe/Moscow' },
40+
{ label: '(UTC+03:00) Riyadh', value: 'Asia/Riyadh' },
41+
{ label: '(UTC+04:00) Dubai', value: 'Asia/Dubai' },
42+
{ label: '(UTC+04:00) Baku', value: 'Asia/Baku' },
43+
{ label: '(UTC+05:00) Islamabad, Karachi', value: 'Asia/Karachi' },
44+
{ label: '(UTC+05:00) Tashkent', value: 'Asia/Tashkent' },
45+
{ label: '(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi', value: 'Asia/Calcutta' },
46+
{ label: '(UTC+06:00) Dhaka', value: 'Asia/Dhaka' },
47+
{ label: '(UTC+06:00) Almaty', value: 'Asia/Almaty' },
48+
{ label: '(UTC+07:00) Jakarta', value: 'Asia/Jakarta' },
49+
{ label: '(UTC+07:00) Bangkok', value: 'Asia/Bangkok' },
50+
{ label: '(UTC+08:00) Beijing, Shanghai', value: 'Asia/Shanghai' },
51+
{ label: '(UTC+08:00) Singapore', value: 'Asia/Singapore' },
52+
{ label: '(UTC+09:00) Tokyo, Osaka, Sapporo', value: 'Asia/Tokyo' },
53+
{ label: '(UTC+09:00) Seoul', value: 'Asia/Seoul' },
54+
{ label: '(UTC+10:00) Sydney, Melbourne', value: 'Australia/Sydney' },
55+
{ label: '(UTC+10:00) Guam, Port Moresby', value: 'Pacific/Guam' },
56+
{ label: '(UTC+11:00) New Caledonia', value: 'Pacific/Noumea' },
57+
{ label: '(UTC+12:00) Auckland, Wellington', value: 'Pacific/Auckland' },
58+
{ label: '(UTC+12:00) Fiji', value: 'Pacific/Fiji' },
59+
]

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
import { formatLabels, toWords } from '../../utilities/formatLabels.js'
1515
import { baseBlockFields } from '../baseFields/baseBlockFields.js'
1616
import { baseIDField } from '../baseFields/baseIDField.js'
17+
import { baseTimezoneField } from '../baseFields/timezone/baseField.js'
18+
import { defaultTimezones } from '../baseFields/timezone/defaultTimezones.js'
1719
import { setDefaultBeforeDuplicate } from '../setDefaultBeforeDuplicate.js'
1820
import { validations } from '../validations.js'
1921
import { sanitizeJoinField } from './sanitizeJoinField.js'
@@ -287,6 +289,30 @@ export const sanitizeFields = async ({
287289
}
288290

289291
fields[i] = field
292+
293+
// Insert our field after assignment
294+
if (field.type === 'date' && field.timezone) {
295+
const name = field.name + '_tz'
296+
const defaultTimezone = config.admin.timezones.defaultTimezone
297+
298+
const supportedTimezones = config.admin.timezones.supportedTimezones
299+
300+
const options =
301+
typeof supportedTimezones === 'function'
302+
? supportedTimezones({ defaultTimezones })
303+
: supportedTimezones
304+
305+
// Need to set the options here manually so that any database enums are generated correctly
306+
// The UI component will import the options from the config
307+
const timezoneField = baseTimezoneField({
308+
name,
309+
defaultValue: defaultTimezone,
310+
options,
311+
required: field.required,
312+
})
313+
314+
fields.splice(++i, 0, timezoneField)
315+
}
290316
}
291317

292318
return fields

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -671,14 +671,18 @@ export type DateField = {
671671
date?: ConditionalDateProps
672672
placeholder?: Record<string, string> | string
673673
} & Admin
674+
/**
675+
* Enable timezone selection in the admin interface.
676+
*/
677+
timezone?: true
674678
type: 'date'
675679
validate?: DateFieldValidation
676680
} & Omit<FieldBase, 'validate'>
677681

678682
export type DateFieldClient = {
679683
admin?: AdminClient & Pick<DateField['admin'], 'date' | 'placeholder'>
680684
} & FieldBaseClient &
681-
Pick<DateField, 'type'>
685+
Pick<DateField, 'timezone' | 'type'>
682686

683687
export type GroupField = {
684688
admin?: {

0 commit comments

Comments
 (0)