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
This commit is contained in:
Laura Neto
2025-10-21 09:29:46 +02:00
committed by GitHub
parent 1efdde2473
commit e6f48799a1
7 changed files with 78 additions and 41 deletions

View File

@@ -30,5 +30,5 @@ public class DateOnlyValueConverter : DateTimeValueConverterBase
/// <inheritdoc/>
protected override object ConvertToObject(DateTimeDto dateTimeDto)
=> DateOnly.FromDateTime(dateTimeDto.Date.UtcDateTime);
=> DateOnly.FromDateTime(dateTimeDto.Date.DateTime);
}

View File

@@ -30,6 +30,5 @@ public class DateTimeUnspecifiedValueConverter : DateTimeValueConverterBase
/// <inheritdoc/>
protected override object ConvertToObject(DateTimeDto dateTimeDto)
=> DateTime.SpecifyKind(dateTimeDto.Date.UtcDateTime, DateTimeKind.Unspecified);
=> DateTime.SpecifyKind(dateTimeDto.Date.DateTime, DateTimeKind.Unspecified);
}

View File

@@ -30,5 +30,5 @@ public class TimeOnlyValueConverter : DateTimeValueConverterBase
/// <inheritdoc/>
protected override object ConvertToObject(DateTimeDto dateTimeDto)
=> TimeOnly.FromDateTime(dateTimeDto.Date.UtcDateTime);
=> TimeOnly.FromDateTime(dateTimeDto.Date.DateTime);
}

View File

@@ -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<UmbTimeZonePickerOption> = [];
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<UmbTimeZonePickerValue>('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) {

View File

@@ -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()

View File

@@ -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))]

View File

@@ -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))]