Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
211 changes: 153 additions & 58 deletions packages/volto/src/components/manage/Widgets/DatetimeWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,23 @@ import loadable from '@loadable/component';
import cx from 'classnames';
import Icon from '@plone/volto/components/theme/Icon/Icon';
import FormFieldWrapper from '@plone/volto/components/manage/Widgets/FormFieldWrapper';
import { parseDateTime, toBackendLang } from '@plone/volto/helpers/Utils/Utils';
import {
parseDateTime,
toBackendLang,
getTimeZoneOffset,
} from '@plone/volto/helpers/Utils/Utils';
import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable';
import {
customSelectStyles,
DropdownIndicator,
Option,
selectTheme,
} from '@plone/volto/components/manage/Widgets/SelectStyling';

import leftKey from '@plone/volto/icons/left-key.svg';
import rightKey from '@plone/volto/icons/right-key.svg';
import clearSVG from '@plone/volto/icons/clear.svg';
import clockSVG from '@plone/volto/icons/clock.svg';

import 'rc-time-picker/assets/index.css';
import 'react-dates/initialize';
Expand All @@ -27,6 +38,14 @@ const messages = defineMessages({
id: 'Time',
defaultMessage: 'Time',
},
timezone: {
id: 'timezone',
defaultMessage: 'Time zone',
},
editTimezone: {
id: 'editTimezone',
defaultMessage: 'Edit time zone',
},
clearDateTime: {
id: 'Clear date/time',
defaultMessage: 'Clear date and time',
Expand Down Expand Up @@ -71,6 +90,52 @@ const defaultTimeDateOnly = {
second: 0,
};

const TimezoneSelector = injectLazyLibs(['reactSelect'])(({
value,
onChange,
reactSelect,
}) => {
const intl = useIntl();
const [open, setOpen] = useState(false);
const Select = reactSelect.default;

return (
<div className="date-time-widget-timezone">
<button
type="button"
aria-label={intl.formatMessage(messages.editTimezone)}
onClick={() => setOpen(!open)}
>
<Icon name={clockSVG} size="16px" /> {open ? null : value}
</button>
{open ? (
<div className="date-time-widget-timezone-selector">
<Select
placeholder={intl.formatMessage(messages.editTimezone)}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={{ value, label: value }}
onChange={(option) => {
onChange(option.value);
setOpen(false);
}}
onBlur={() => setOpen(false)}
options={Intl.supportedValuesOf('timeZone').map((tz) => ({
value: tz,
label: tz,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the future:‌ we should translate this - TIL from Gemini - using CLDR: https://en.wikipedia.org/wiki/Common_Locale_Data_Repository
For example "Europe/Vienna" -> "Mitteleuropäische Zeit - Wien (GMT+2)".

An example Python Code:

from babel.dates import get_timezone_name
from zoneinfo import ZoneInfo

tz = ZoneInfo("Europe/Vienna")
# Liefert "Mitteleuropäische Sommerzeit" für das aktuelle Datum
print(get_timezone_name(tz, locale="de_AT", width="long"))

However, I'd still like to find timezones by it's English identifier, as I'm used to do so.

But this is definitely for a future improvement. I'm just mentioning this here.

}))}
styles={customSelectStyles}
theme={selectTheme}
components={{
DropdownIndicator,
Option,
}}
/>
</div>
) : null}
</div>
);
});
const DatetimeWidgetComponent = (props) => {
const {
id,
Expand Down Expand Up @@ -101,20 +166,30 @@ const DatetimeWidgetComponent = (props) => {

const renderWidget = !(id === 'end' && formData?.open_end);

const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;

@thet thet Jun 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was chewing on the Intl timezone, because we have a portal timezone and potentially a timezone set by the user in the @@personal-preferences.
But using Intl here makes a lot of sense!

I think the timezone setting in the @@personal-preferences is obsolete and should be removed.
I have created a draft PLIP here: plone/Products.CMFPlone#4337

const selectedTimezone = formData?.[id + '_timezone'] || localTimezone;

useEffect(() => {
const parsedDateTime = parseDateTime(
toBackendLang(lang),
value,
undefined,
moment.default,
selectedTimezone,
);
setIsDefault(
parsedDateTime?.toISOString() === moment.default().utc().toISOString(),
);
}, [value, lang, moment]);
}, [value, lang, moment, selectedTimezone]);

const getInternalValue = () => {
return parseDateTime(toBackendLang(lang), value, undefined, moment.default);
return parseDateTime(
toBackendLang(lang),
value,
undefined,
moment.default,
selectedTimezone,
);
};

const getDateOnly = () => {
Expand Down Expand Up @@ -154,6 +229,18 @@ const DatetimeWidgetComponent = (props) => {
}
};

const onTimezoneChange = (timezone) => {
if (timezone) {
let value = getInternalValue();
value = value.utcOffset(
getTimeZoneOffset(value.toDate(), timezone),
true,
);
onChange(id, value.toISOString());
onChange(id + '_timezone', timezone);
}
};

const onResetDates = () => {
setIsDefault(false);
onChange(id, null);
Expand Down Expand Up @@ -198,68 +285,76 @@ const DatetimeWidgetComponent = (props) => {
return (
<FormFieldWrapper {...props}>
{renderWidget && (
<div className="date-time-widget-wrapper">
<div
className={cx('ui input date-input', {
'default-date': isDefault,
})}
>
<SingleDatePicker
date={datetime}
disabled={isDisabled}
onDateChange={onDateChange}
focused={focused}
numberOfMonths={1}
{...(noPastDates ? {} : { isOutsideRange: () => false })}
onFocusChange={onFocusChange}
noBorder
required={required}
displayFormat={moment.default
.localeData(toBackendLang(lang))
.longDateFormat('L')}
navPrev={<PrevIcon />}
navNext={<NextIcon />}
id={`${id}-date`}
placeholder={intl.formatMessage(messages.date)}
/>
</div>
{!isDateOnly && (
<>
<div className="date-time-widget-wrapper">
<div
ref={timeInputRef}
className={cx('ui input time-input', {
className={cx('ui input date-input', {
'default-date': isDefault,
})}
>
<TimePicker
<SingleDatePicker
date={datetime}
disabled={isDisabled}
defaultValue={datetime}
value={datetime}
onChange={onTimeChange}
allowEmpty={false}
showSecond={false}
use12Hours={lang === 'en'}
id={`${id}-time`}
format={moment.default
onDateChange={onDateChange}
focused={focused}
numberOfMonths={1}
{...(noPastDates ? {} : { isOutsideRange: () => false })}
onFocusChange={onFocusChange}
noBorder
required={required}
displayFormat={moment.default
.localeData(toBackendLang(lang))
.longDateFormat('LT')}
placeholder={intl.formatMessage(messages.time)}
focusOnOpen
placement="bottomRight"
.longDateFormat('L')}
navPrev={<PrevIcon />}
navNext={<NextIcon />}
id={`${id}-date`}
placeholder={intl.formatMessage(messages.date)}
/>
</div>
)}
{resettable && (
<button
type="button"
disabled={isDisabled || !datetime}
onClick={onResetDates}
className="item ui noborder button"
aria-label={intl.formatMessage(messages.clearDateTime)}
>
<Icon name={clearSVG} size="24px" className="close" />
</button>
)}
</div>
{!isDateOnly && (
<div
ref={timeInputRef}
className={cx('ui input time-input', {
'default-date': isDefault,
})}
>
<TimePicker
disabled={isDisabled}
defaultValue={datetime}
value={datetime}
onChange={onTimeChange}
allowEmpty={false}
showSecond={false}
use12Hours={lang === 'en'}
id={`${id}-time`}
format={moment.default
.localeData(toBackendLang(lang))
.longDateFormat('LT')}
placeholder={intl.formatMessage(messages.time)}
focusOnOpen
placement="bottomRight"
/>
</div>
)}
{resettable && (
<button
type="button"
disabled={isDisabled || !datetime}
onClick={onResetDates}
className="item ui noborder button"
aria-label={intl.formatMessage(messages.clearDateTime)}
>
<Icon name={clearSVG} size="24px" className="close" />
</button>
)}
</div>
{id === 'start' || id === 'end' ? (
<TimezoneSelector
value={selectedTimezone}
onChange={onTimezoneChange}
/>
) : null}
</>
)}
</FormFieldWrapper>
);
Expand Down Expand Up @@ -289,6 +384,6 @@ DatetimeWidgetComponent.defaultProps = {
resettable: true,
};

export default injectLazyLibs(['reactDates', 'moment'])(
export default injectLazyLibs(['reactDates', 'reactSelect', 'moment'])(
DatetimeWidgetComponent,
);
11 changes: 10 additions & 1 deletion packages/volto/src/helpers/Utils/Utils.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ export const getColor = (name) => {
return namedColor;
};

export const getTimeZoneOffset = (date, timeZone) => {
const toTimeZone = (z) =>
new Date(date.toLocaleString('sv', { timeZone: z }).replace(' ', 'T'));
return (toTimeZone(timeZone) - toTimeZone('UTC')) / 60_000;
};

/**
* Fixes TimeZones issues on moment date objects
* Parses a DateTime and sets correct moment locale
Expand All @@ -173,7 +179,7 @@ export const getColor = (name) => {
* @param {string} format Date format of choice
* @returns {Object|string} Moment object or string if format is set
*/
export const parseDateTime = (locale, value, format, moment) => {
export const parseDateTime = (locale, value, format, moment, timezone) => {
// Used to set a server timezone or UTC as default
moment.updateLocale(locale, moment.localeData(locale)._config); // copy locale to moment-timezone
let datetime = null;
Expand All @@ -186,6 +192,9 @@ export const parseDateTime = (locale, value, format, moment) => {
moment(value)
: // This might happen in old Plone versions dates
moment(`${value}Z`);
if (timezone) {
datetime.utcOffset(getTimeZoneOffset(datetime.toDate(), timezone));
}
}

if (format && datetime) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,28 @@ ul.DayPicker_weekHeader_ul {
align-items: center;
}

.date-time-widget-timezone {
display: flex;
justify-content: flex-end;
margin: 5px 16px 0 0;
font-size: 0.9rem;

button {
cursor: pointer;
opacity: 0.5;

& > svg {
display: inline-block;
vertical-align: bottom;
}
}
}

.date-time-widget-timezone-selector {
position: relative;
flex-grow: 1;
}

.sidebar-container:not(.full-size) #sidebar-metadata {
.DateInput {
width: 110px !important;
Expand Down