Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add timezone support on date fields #10896

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f2b1781
feat: add timezone support on date fields
paulpopus Jan 30, 2025
8911d79
update lock file
paulpopus Jan 30, 2025
356ed8c
add translations for 'Timezone'
paulpopus Jan 30, 2025
8523006
update id for picker
paulpopus Jan 30, 2025
4afee5a
rework field to be a select field with config threaded through to the…
paulpopus Jan 31, 2025
b73067d
export utilities and timezone picker element
paulpopus Jan 31, 2025
0bbaa1d
update tzdate import
paulpopus Jan 31, 2025
6589bfe
add more comments to date manipulation logic
paulpopus Jan 31, 2025
ebc5b44
Merge branch 'main' into feat/timezone-support-on-date-fields
paulpopus Jan 31, 2025
253a41b
move the config into admin.timezone and remove siblingField
paulpopus Jan 31, 2025
fe4da85
fix test and add case for blocks and arrays
paulpopus Jan 31, 2025
fb3400d
add docs
paulpopus Jan 31, 2025
7bd0583
add additional test
paulpopus Jan 31, 2025
febcdfa
Merge branch 'main' into feat/timezone-support-on-date-fields
paulpopus Jan 31, 2025
c1e38ca
changed to supportedTimezones and added generated types
paulpopus Jan 31, 2025
8fb2d4d
remove formatOptions export and fix date e2e
paulpopus Feb 3, 2025
b7a60b6
Merge branch 'main' into feat/timezone-support-on-date-fields
paulpopus Feb 3, 2025
2b00e94
add test for default timezone selection
paulpopus Feb 3, 2025
11cfd78
fixes enums not being generated for timezone options
paulpopus Feb 3, 2025
4cde0fc
update docs
paulpopus Feb 3, 2025
03f47ac
Update overview.mdx
paulpopus Feb 4, 2025
3eff08d
Merge branch 'main' into feat/timezone-support-on-date-fields
paulpopus Feb 7, 2025
4c70aad
update e2e test for flakyness asserts
paulpopus Feb 7, 2025
6574023
remove types for datepicker props
paulpopus Feb 7, 2025
7e5514a
make tz column _tz instead of _timezone
paulpopus Feb 7, 2025
674489e
make some changes
paulpopus Feb 7, 2025
9fecb43
add validation for required dates with timezones, removed defaulting …
paulpopus Feb 9, 2025
32061fc
add translations for Timezone is required error message
paulpopus Feb 9, 2025
feac32f
update docs
paulpopus Feb 9, 2025
d9e0bf8
add more tests
paulpopus Feb 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/admin/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ The following options are available:
| **`routes`** | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). |
| **`suppressHydrationWarning`** | If set to `true`, suppresses React hydration mismatch warnings during the hydration of the root `<html>` tag. Defaults to `false`. |
| **`theme`** | Restrict the Admin Panel theme to use only one of your choice. Default is `all`. |
| **`timezone`** | Configure the timezone settings for the admin panel. [More details](#timezones) |
| **`user`** | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). |

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

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.

## Timezones

The `admin.timezone` 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.

The following options are available:

| Option | Description |
| ----------------- | ----------------------------------------------- |
| `supportedTimezones` | An array of label/value options for selectable timezones where the value is the IANA name eg. `America/Detroit` |
| `defaultTimezone` | The `value` of the default selected timezone. eg. `America/Los_Angeles` |

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')`.

<Banner type="info">
**Important**
You must enable timezones on each individual date field via `timezone: true`. See [Date Fields](../fields/overview#date) for more information.
</Banner>
21 changes: 21 additions & 0 deletions docs/fields/date.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const MyDateField: Field = {
| **`required`** | Require this field to have a value. |
| **`admin`** | Admin-specific configuration. [More details](#admin-options). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
| **`timezone`** * | Set to `true` to enable timezone selection on this field. [More details](#timezones). |
| **`typescriptSchema`** | Override field type generation with providing a JSON schema |
| **`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) |

Expand Down Expand Up @@ -222,3 +223,23 @@ export const CustomDateFieldLabelClient: DateFieldLabelClientComponent = ({
}
```

## Timezones

To enable timezone selection on a Date field, set the `timezone` property to `true`:

```ts
{
name: 'date',
type: 'date',
timezone: true,
}
```

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`.

You can customise the available list of timezones in the [global admin config](../admin/overview#timezones).

<Banner type='info'>
**Good to know:**
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.
</Banner>
1 change: 1 addition & 0 deletions packages/payload/src/config/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const createClientConfig = ({
meta: config.admin.meta,
routes: config.admin.routes,
theme: config.admin.theme,
timezone: config.admin.timezone,
user: config.admin.user,
}
if (config.admin.livePreview) {
Expand Down
28 changes: 28 additions & 0 deletions packages/payload/src/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import type {
LocalizationConfigWithLabels,
LocalizationConfigWithNoLabels,
SanitizedConfig,
Timezone,
} from './types.js'

import { defaultUserCollection } from '../auth/defaultUser.js'
import { authRootEndpoints } from '../auth/endpoints/index.js'
import { sanitizeCollection } from '../collections/config/sanitize.js'
import { migrationsCollection } from '../database/migrations/migrationsCollection.js'
import { DuplicateCollection, InvalidConfiguration } from '../errors/index.js'
import { defaultTimezones } from '../fields/baseFields/timezone/defaultTimezones.js'
import { sanitizeGlobal } from '../globals/config/sanitize.js'
import { getLockedDocumentsCollection } from '../lockedDocuments/lockedDocumentsCollection.js'
import getPreferencesCollection from '../preferences/preferencesCollection.js'
Expand Down Expand Up @@ -56,6 +58,32 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig>
)
}

if (sanitizedConfig?.admin?.timezone) {
if (typeof sanitizedConfig?.admin?.timezone?.supportedTimezones === 'function') {
sanitizedConfig.admin.timezone.supportedTimezones =
sanitizedConfig.admin.timezone.supportedTimezones({ defaultTimezones })
}

if (!sanitizedConfig?.admin?.timezone?.supportedTimezones) {
sanitizedConfig.admin.timezone.supportedTimezones = defaultTimezones
}
} else {
sanitizedConfig.admin.timezone = {
supportedTimezones: defaultTimezones,
}
}
// Timezones supported by the Intl API
const _internalSupportedTimezones = Intl.supportedValuesOf('timeZone')

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

return sanitizedConfig as unknown as Partial<SanitizedConfig>
}

Expand Down
35 changes: 34 additions & 1 deletion packages/payload/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,32 @@ export const serverProps: (keyof ServerProps)[] = [
'permissions',
]

export type Timezone = {
label: string
value: string
}

type SupportedTimezonesFn = (args: { defaultTimezones: Timezone[] }) => Timezone[]

type TimezoneConfig = {
/**
* The default timezone to use for the admin panel.
*/
defaultTimezone?: string
/**
* Provide your own list of supported timezones for the admin panel
*
* Values should be IANA timezone names, eg. `America/New_York`
*
* We use `@date-fns/tz` to handle timezones
*/
supportedTimezones?: SupportedTimezonesFn | Timezone[]
}

type SanitizedTimezoneConfig = {
supportedTimezones: Timezone[]
} & Omit<TimezoneConfig, 'supportedTimezones'>

export type CustomComponent<TAdditionalProps extends object = Record<string, any>> =
PayloadComponent<ServerProps & TAdditionalProps, TAdditionalProps>

Expand Down Expand Up @@ -880,6 +906,10 @@ export type Config = {
* @default 'all' // The theme can be configured by users
*/
theme?: 'all' | 'dark' | 'light'
/**
* Configure timezone related settings for the admin panel.
*/
timezone?: TimezoneConfig
/** The slug of a Collection that you want to be used to log in to the Admin dashboard. */
user?: string
}
Expand Down Expand Up @@ -1149,6 +1179,9 @@ export type Config = {
}

export type SanitizedConfig = {
admin: {
timezone: SanitizedTimezoneConfig
} & DeepRequired<Config['admin']>
collections: SanitizedCollectionConfig[]
/** Default richtext editor to use for richText fields */
editor?: RichTextAdapter<any, any, any>
Expand All @@ -1173,7 +1206,7 @@ export type SanitizedConfig = {
// E.g. in packages/ui/src/graphics/Account/index.tsx in getComponent, if avatar.Component is casted to what it's supposed to be,
// the result type is different
DeepRequired<Config>,
'collections' | 'editor' | 'endpoint' | 'globals' | 'i18n' | 'localization' | 'upload'
'admin' | 'collections' | 'editor' | 'endpoint' | 'globals' | 'i18n' | 'localization' | 'upload'
>

export type EditConfig = EditConfigWithoutRoot | EditConfigWithRoot
Expand Down
2 changes: 2 additions & 0 deletions packages/payload/src/exports/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export { defaults as collectionDefaults } from '../collections/config/defaults.j

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

export { defaultTimezones } from '../fields/baseFields/timezone/defaultTimezones.js'

export {
fieldAffectsData,
fieldHasMaxDepth,
Expand Down
19 changes: 19 additions & 0 deletions packages/payload/src/fields/baseFields/timezone/baseField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { SelectField } from '../../config/types.js'

export const baseTimezoneField: (args: Partial<SelectField>) => SelectField = ({
name,
defaultValue,
options,
required,
}) => {
return {
name,
type: 'select',
admin: {
hidden: true,
},
defaultValue,
options,
required,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Timezone } from '../../../config/types.js'

/**
* List of supported timezones
*
* label: UTC offset and location
* value: IANA timezone name
*
* @example
* { label: '(UTC-12:00) International Date Line West', value: 'Dateline Standard Time' }
*/
export const defaultTimezones: Timezone[] = [
{ label: '(UTC-11:00) Midway Island, Samoa', value: 'Pacific/Midway' },
{ label: '(UTC-11:00) Niue', value: 'Pacific/Niue' },
{ label: '(UTC-10:00) Hawaii', value: 'Pacific/Honolulu' },
{ label: '(UTC-10:00) Cook Islands', value: 'Pacific/Rarotonga' },
{ label: '(UTC-09:00) Alaska', value: 'America/Anchorage' },
{ label: '(UTC-09:00) Gambier Islands', value: 'Pacific/Gambier' },
{ label: '(UTC-08:00) Pacific Time (US & Canada)', value: 'America/Los_Angeles' },
{ label: '(UTC-08:00) Tijuana, Baja California', value: 'America/Tijuana' },
{ label: '(UTC-07:00) Mountain Time (US & Canada)', value: 'America/Denver' },
{ label: '(UTC-07:00) Arizona (No DST)', value: 'America/Phoenix' },
{ label: '(UTC-06:00) Central Time (US & Canada)', value: 'America/Chicago' },
{ label: '(UTC-06:00) Central America', value: 'America/Guatemala' },
{ label: '(UTC-05:00) Eastern Time (US & Canada)', value: 'America/New_York' },
{ label: '(UTC-05:00) Bogota, Lima, Quito', value: 'America/Bogota' },
{ label: '(UTC-04:00) Caracas', value: 'America/Caracas' },
{ label: '(UTC-04:00) Santiago', value: 'America/Santiago' },
{ label: '(UTC-03:00) Buenos Aires', value: 'America/Buenos_Aires' },
{ label: '(UTC-03:00) Brasilia', value: 'America/Sao_Paulo' },
{ label: '(UTC-02:00) South Georgia', value: 'Atlantic/South_Georgia' },
{ label: '(UTC-01:00) Azores', value: 'Atlantic/Azores' },
{ label: '(UTC-01:00) Cape Verde', value: 'Atlantic/Cape_Verde' },
{ label: '(UTC+00:00) London (GMT)', value: 'Europe/London' },
{ label: '(UTC+01:00) Berlin, Paris', value: 'Europe/Berlin' },
{ label: '(UTC+01:00) Lagos', value: 'Africa/Lagos' },
{ label: '(UTC+02:00) Athens, Bucharest', value: 'Europe/Athens' },
{ label: '(UTC+02:00) Cairo', value: 'Africa/Cairo' },
{ label: '(UTC+03:00) Moscow, St. Petersburg', value: 'Europe/Moscow' },
{ label: '(UTC+03:00) Riyadh', value: 'Asia/Riyadh' },
{ label: '(UTC+04:00) Dubai', value: 'Asia/Dubai' },
{ label: '(UTC+04:00) Baku', value: 'Asia/Baku' },
{ label: '(UTC+05:00) Islamabad, Karachi', value: 'Asia/Karachi' },
{ label: '(UTC+05:00) Tashkent', value: 'Asia/Tashkent' },
{ label: '(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi', value: 'Asia/Calcutta' },
{ label: '(UTC+06:00) Dhaka', value: 'Asia/Dhaka' },
{ label: '(UTC+06:00) Almaty', value: 'Asia/Almaty' },
{ label: '(UTC+07:00) Jakarta', value: 'Asia/Jakarta' },
{ label: '(UTC+07:00) Bangkok', value: 'Asia/Bangkok' },
{ label: '(UTC+08:00) Beijing, Shanghai', value: 'Asia/Shanghai' },
{ label: '(UTC+08:00) Singapore', value: 'Asia/Singapore' },
{ label: '(UTC+09:00) Tokyo, Osaka, Sapporo', value: 'Asia/Tokyo' },
{ label: '(UTC+09:00) Seoul', value: 'Asia/Seoul' },
{ label: '(UTC+10:00) Sydney, Melbourne', value: 'Australia/Sydney' },
{ label: '(UTC+10:00) Guam, Port Moresby', value: 'Pacific/Guam' },
{ label: '(UTC+11:00) New Caledonia', value: 'Pacific/Noumea' },
{ label: '(UTC+12:00) Auckland, Wellington', value: 'Pacific/Auckland' },
{ label: '(UTC+12:00) Fiji', value: 'Pacific/Fiji' },
]
26 changes: 26 additions & 0 deletions packages/payload/src/fields/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
import { formatLabels, toWords } from '../../utilities/formatLabels.js'
import { baseBlockFields } from '../baseFields/baseBlockFields.js'
import { baseIDField } from '../baseFields/baseIDField.js'
import { baseTimezoneField } from '../baseFields/timezone/baseField.js'
import { defaultTimezones } from '../baseFields/timezone/defaultTimezones.js'
import { setDefaultBeforeDuplicate } from '../setDefaultBeforeDuplicate.js'
import { validations } from '../validations.js'
import { sanitizeJoinField } from './sanitizeJoinField.js'
Expand Down Expand Up @@ -287,6 +289,30 @@ export const sanitizeFields = async ({
}

fields[i] = field

// Insert our field after assignment
if (field.type === 'date' && field.timezone) {
const name = field.name + '_tz'
const defaultTimezone = config.admin.timezone.defaultTimezone

const supportedTimezones = config.admin.timezone.supportedTimezones

const options =
typeof supportedTimezones === 'function'
? supportedTimezones({ defaultTimezones })
: supportedTimezones

// Need to set the options here manually so that any database enums are generated correctly
// The UI component will import the options from the config
const timezoneField = baseTimezoneField({
name,
defaultValue: defaultTimezone,
options,
required: field.required,
})

fields.splice(++i, 0, timezoneField)
}
}

return fields
Expand Down
6 changes: 5 additions & 1 deletion packages/payload/src/fields/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,14 +671,18 @@ export type DateField = {
date?: ConditionalDateProps
placeholder?: Record<string, string> | string
} & Admin
/**
* Enable timezone selection in the admin interface.
*/
timezone?: true
type: 'date'
validate?: DateFieldValidation
} & Omit<FieldBase, 'validate'>

export type DateFieldClient = {
admin?: AdminClient & Pick<DateField['admin'], 'date' | 'placeholder'>
} & FieldBaseClient &
Pick<DateField, 'type'>
Pick<DateField, 'timezone' | 'type'>

export type GroupField = {
admin?: {
Expand Down
20 changes: 18 additions & 2 deletions packages/payload/src/fields/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,11 +377,27 @@ export const checkbox: CheckboxFieldValidation = (value, { req: { t }, required

export type DateFieldValidation = Validate<Date, unknown, unknown, DateField>

export const date: DateFieldValidation = (value, { req: { t }, required }) => {
if (value && !isNaN(Date.parse(value.toString()))) {
export const date: DateFieldValidation = (
value,
{ name, req: { t }, required, siblingData, timezone },
) => {
const validDate = value && !isNaN(Date.parse(value.toString()))

// We need to also check for the timezone data based on this field's config
// We cannot do this inside the timezone field validation as it's visually hidden
const hasRequiredTimezone = timezone && required
const selectedTimezone: string = siblingData?.[`${name}_tz`]
// Always resolve to true if the field is not required, as timezone may be optional too then
const validTimezone = hasRequiredTimezone ? Boolean(selectedTimezone) : true

if (validDate && validTimezone) {
return true
}

if (validDate && !validTimezone) {
return t('validation:timezoneRequired')
}

if (value) {
return t('validation:notValidDate', { value })
}
Expand Down
Loading