From e6f48799a1ee836d033ed82397dd9ea1b1b87372 Mon Sep 17 00:00:00 2001
From: Laura Neto <12862535+lauraneto@users.noreply.github.com>
Date: Tue, 21 Oct 2025 09:29:46 +0200
Subject: [PATCH] Property Editors: DateTimeWithTimeZone - Changing timezone
mode to Local shows invalid time zone error (#20526)
* Store local time zone as UTC and do not throw validation error when stored time zone is different
* Additional fixes when switching between date time editors with and without time zone
* Additional fixes
* Ensure that an update is triggered when the expected value does not match the stored value
This will happen when switching between editors (with and without time zone) or switching between a specific time zone to the editor's local time zone.
* Fix inconsistencies with null and undefined
* Fix inconsistencies between date/time provided to the client and returned in the value converter (when switching between editors)
* Fix unit tests and small bug
* Adjust integration test
* Small improvement
* Update test data
* Adjust logic so that time zone offsets are updated every time the date value changes
* Do not pre-select time zone when switching between unspecified and time zone editors
---
.../ValueConverters/DateOnlyValueConverter.cs | 2 +-
.../DateTimeUnspecifiedValueConverter.cs | 3 +-
.../ValueConverters/TimeOnlyValueConverter.cs | 2 +-
...roperty-editor-ui-date-time-picker-base.ts | 100 ++++++++++++------
.../DateTimePropertyEditorTests.cs | 8 +-
.../DateTimeUnspecifiedValueConverterTests.cs | 2 +-
.../TimeOnlyValueConverterTests.cs | 2 +-
7 files changed, 78 insertions(+), 41 deletions(-)
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs
index cdb2339478..a01e39e6fe 100644
--- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs
+++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs
@@ -30,5 +30,5 @@ public class DateOnlyValueConverter : DateTimeValueConverterBase
///
protected override object ConvertToObject(DateTimeDto dateTimeDto)
- => DateOnly.FromDateTime(dateTimeDto.Date.UtcDateTime);
+ => DateOnly.FromDateTime(dateTimeDto.Date.DateTime);
}
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs
index da793aeb2f..9bd138e591 100644
--- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs
+++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs
@@ -30,6 +30,5 @@ public class DateTimeUnspecifiedValueConverter : DateTimeValueConverterBase
///
protected override object ConvertToObject(DateTimeDto dateTimeDto)
- => DateTime.SpecifyKind(dateTimeDto.Date.UtcDateTime, DateTimeKind.Unspecified);
-
+ => DateTime.SpecifyKind(dateTimeDto.Date.DateTime, DateTimeKind.Unspecified);
}
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs
index f6862e5b6f..777a20f048 100644
--- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs
+++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs
@@ -30,5 +30,5 @@ public class TimeOnlyValueConverter : DateTimeValueConverterBase
///
protected override object ConvertToObject(DateTimeDto dateTimeDto)
- => TimeOnly.FromDateTime(dateTimeDto.Date.UtcDateTime);
+ => TimeOnly.FromDateTime(dateTimeDto.Date.DateTime);
}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts
index 8c0b1ce9aa..07e196850a 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts
@@ -19,8 +19,8 @@ import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import type { UUIComboboxElement, UUIComboboxEvent } from '@umbraco-cms/backoffice/external/uui';
interface UmbDateTime {
- date: string | undefined;
- timeZone: string | undefined;
+ date: string | null;
+ timeZone: string | null;
}
interface UmbTimeZonePickerOption extends UmbTimeZone {
@@ -34,6 +34,7 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
{
private _timeZoneOptions: Array = [];
private _clientTimeZone: UmbTimeZone | undefined;
+ private _timeZoneMode: UmbTimeZonePickerValue['mode'] | undefined;
@property({ type: Boolean, reflect: true })
readonly = false;
@@ -104,6 +105,7 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
() => {
return (
this._displayTimeZone &&
+ this._timeZoneMode !== 'local' &&
!!this.value?.timeZone &&
!this._timeZoneOptions.some((opt) => opt.value === this.value?.timeZone && !opt.invalid)
);
@@ -120,8 +122,13 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
if (this._displayTimeZone) {
timeZonePickerConfig = config.getValueByAlias('timeZones');
}
+
this.#setTimeInputStep(timeFormat);
this.#prefillValue(timeZonePickerConfig);
+
+ // To ensure the expected value matches the prefilled value, we trigger an update.
+ // If the values match, no change event will be fired.
+ this.#updateValue(this._selectedDate?.toISO({ includeOffset: false }) ?? null);
}
#prefillValue(timeZonePickerConfig: UmbTimeZonePickerValue | undefined) {
@@ -158,8 +165,8 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
#prefillTimeZones(config: UmbTimeZonePickerValue | undefined, selectedDate: DateTime | undefined) {
// Retrieve the time zones from the config
this._clientTimeZone = getClientTimeZone();
+ this._timeZoneMode = config?.mode;
- // Retrieve the time zones from the config
const dateToCalculateOffset = selectedDate ?? DateTime.now();
switch (config?.mode) {
case 'all':
@@ -219,7 +226,8 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
return;
}
} else if (this.value?.date) {
- return; // If there is a date but no time zone, we don't preselect anything
+ // If there is no time zone in the value, but there is a date, we leave the time zone unselected
+ return;
}
// Check if we can pre-select the client time zone
@@ -269,16 +277,8 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
return;
}
- if (!newPickerValue) {
- this._datePickerValue = '';
- this.value = undefined;
- this._selectedDate = undefined;
- this.dispatchEvent(new UmbChangeEvent());
- return;
- }
-
this._datePickerValue = newPickerValue;
- this.#updateValue(value, true);
+ this.#updateValue(value);
}
#onTimeZoneChange(event: UUIComboboxEvent) {
@@ -291,7 +291,7 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
if (!this._selectedTimeZone) {
if (this.value?.date) {
- this.value = { date: this.value.date, timeZone: undefined };
+ this.value = { date: this.value.date, timeZone: null };
} else {
this.value = undefined;
}
@@ -303,46 +303,84 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
return;
}
- this.#updateValue(this._selectedDate.toISO({ includeOffset: false }) || '');
+ this.#updateValue(this._selectedDate.toISO({ includeOffset: false }));
}
- #updateValue(date: string, updateOffsets = false) {
+ #updateValue(date: string | null) {
// Try to parse the date with the selected time zone
- const newDate = DateTime.fromISO(date, { zone: this._selectedTimeZone ?? 'UTC' });
+ const newDate = date ? DateTime.fromISO(date, { zone: this._selectedTimeZone || 'UTC' }) : null;
// If the date is invalid, we reset the value
- if (!newDate.isValid) {
+ if (!newDate || !newDate.isValid) {
+ if (!this.value) {
+ return; // No change
+ }
this.value = undefined;
this._selectedDate = undefined;
this.dispatchEvent(new UmbChangeEvent());
+ this.#updateOffsets(DateTime.now());
return;
}
+ const previousDate = this._selectedDate;
this._selectedDate = newDate;
- this.value = {
- date: this.#getCurrentDateValue(),
- timeZone: this._selectedTimeZone,
+
+ let timeZoneToStore = null;
+ if (!this._displayTimeZone || !this._timeZoneMode) {
+ timeZoneToStore = null;
+ } else if (this._timeZoneMode === 'local') {
+ timeZoneToStore = 'UTC';
+ } else {
+ timeZoneToStore = this._selectedTimeZone ?? null;
+ }
+
+ const dateToStore =
+ timeZoneToStore && this._selectedTimeZone !== timeZoneToStore ? newDate.setZone(timeZoneToStore) : newDate;
+
+ const newValue = {
+ date: this.#formatDateValue(dateToStore),
+ timeZone: timeZoneToStore,
};
- if (updateOffsets) {
- this._timeZoneOptions.forEach((opt) => {
- opt.offset = getTimeZoneOffset(opt.value, newDate);
- });
- // Update the time zone options (mostly for the offset)
- this._filteredTimeZoneOptions = this._timeZoneOptions;
+ // Only update the stored data if it has actually changed to avoid firing unnecessary change events
+ const previousValue = this.value;
+ if (previousValue?.date === newValue.date && previousValue?.timeZone === newValue.timeZone) {
+ return;
}
+
+ this.value = newValue;
this.dispatchEvent(new UmbChangeEvent());
+
+ // Only update offsets if the date timestamp has changed
+ if (previousDate?.toUnixInteger() !== newDate.toUnixInteger()) {
+ this.#updateOffsets(newDate);
+ }
}
- #getCurrentDateValue(): string | undefined {
+ #updateOffsets(date: DateTime) {
+ if (!this._displayTimeZone) return;
+ this._timeZoneOptions.forEach((opt) => {
+ opt.offset = getTimeZoneOffset(opt.value, date);
+ });
+ // Update the time zone options (mostly for the offset)
+ this._filteredTimeZoneOptions = this._timeZoneOptions;
+ }
+
+ #formatDateValue(date: DateTime): string | null {
+ let formattedDate: string | undefined;
switch (this._dateInputType) {
case 'date':
- return this._selectedDate?.toISODate() ?? undefined;
+ formattedDate = date.toFormat('yyyy-MM-dd');
+ break;
case 'time':
- return this._selectedDate?.toISOTime({ includeOffset: false }) ?? undefined;
+ formattedDate = date.toFormat('HH:mm:ss');
+ break;
default:
- return this._selectedDate?.toISO({ includeOffset: !!this._selectedTimeZone }) ?? undefined;
+ formattedDate = date.toFormat(`yyyy-MM-dd'T'HH:mm:ss${this._timeZoneMode ? 'ZZ' : ''}`);
+ break;
}
+
+ return formattedDate ?? null;
}
#onTimeZoneSearch(event: UUIComboboxEvent) {
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs
index 2e5135f1ab..48f8a035be 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs
@@ -38,10 +38,10 @@ public class DateTimePropertyEditorTests : UmbracoIntegrationTest
private static readonly object[] _sourceList1 =
[
- new object[] { Constants.PropertyEditors.Aliases.DateOnly, false, new DateOnly(2025, 1, 22) },
+ new object[] { Constants.PropertyEditors.Aliases.DateOnly, false, new DateOnly(2025, 6, 22) },
new object[] { Constants.PropertyEditors.Aliases.TimeOnly, false, new TimeOnly(18, 33, 1) },
- new object[] { Constants.PropertyEditors.Aliases.DateTimeUnspecified, false, new DateTime(2025, 1, 22, 18, 33, 1) },
- new object[] { Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, true, new DateTimeOffset(2025, 1, 22, 18, 33, 1, TimeSpan.Zero) },
+ new object[] { Constants.PropertyEditors.Aliases.DateTimeUnspecified, false, new DateTime(2025, 6, 22, 18, 33, 1) },
+ new object[] { Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, true, new DateTimeOffset(2025, 6, 22, 18, 33, 1, TimeSpan.FromHours(2)) },
];
[TestCaseSource(nameof(_sourceList1))]
@@ -105,7 +105,7 @@ public class DateTimePropertyEditorTests : UmbracoIntegrationTest
.WithValue(
new JsonObject
{
- ["date"] = "2025-01-22T18:33:01.0000000+00:00",
+ ["date"] = "2025-06-22T18:33:01.0000000+02:00",
["timeZone"] = "Europe/Copenhagen",
})
.Done()
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs
index dd7395f5d0..1f38c034f2 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs
@@ -86,7 +86,7 @@ public class DateTimeUnspecifiedValueConverterTests
private static object[] _dateTimeUnspecifiedConvertToObjectCases =
[
new object[] { null, null },
- new object[] { _convertToObjectInputDate, DateTime.Parse("2025-08-20T17:30:00") },
+ new object[] { _convertToObjectInputDate, DateTime.Parse("2025-08-20T16:30:00") },
];
[TestCaseSource(nameof(_dateTimeUnspecifiedConvertToObjectCases))]
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs
index a0bcd82ec9..ee3da51671 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs
@@ -86,7 +86,7 @@ public class TimeOnlyValueConverterTests
private static object[] _timeOnlyConvertToObjectCases =
[
new object[] { null, null },
- new object[] { _convertToObjectInputDate, TimeOnly.Parse("17:30") },
+ new object[] { _convertToObjectInputDate, TimeOnly.Parse("16:30") },
];
[TestCaseSource(nameof(_timeOnlyConvertToObjectCases))]