Skip to content

Commit e6f4879

Browse files
authored
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
1 parent 1efdde2 commit e6f4879

File tree

7 files changed

+78
-41
lines changed

7 files changed

+78
-41
lines changed

src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,5 @@ public override bool IsConverter(IPublishedPropertyType propertyType)
3030

3131
/// <inheritdoc/>
3232
protected override object ConvertToObject(DateTimeDto dateTimeDto)
33-
=> DateOnly.FromDateTime(dateTimeDto.Date.UtcDateTime);
33+
=> DateOnly.FromDateTime(dateTimeDto.Date.DateTime);
3434
}

src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,5 @@ public override bool IsConverter(IPublishedPropertyType propertyType)
3030

3131
/// <inheritdoc/>
3232
protected override object ConvertToObject(DateTimeDto dateTimeDto)
33-
=> DateTime.SpecifyKind(dateTimeDto.Date.UtcDateTime, DateTimeKind.Unspecified);
34-
33+
=> DateTime.SpecifyKind(dateTimeDto.Date.DateTime, DateTimeKind.Unspecified);
3534
}

src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,5 @@ public override bool IsConverter(IPublishedPropertyType propertyType)
3030

3131
/// <inheritdoc/>
3232
protected override object ConvertToObject(DateTimeDto dateTimeDto)
33-
=> TimeOnly.FromDateTime(dateTimeDto.Date.UtcDateTime);
33+
=> TimeOnly.FromDateTime(dateTimeDto.Date.DateTime);
3434
}

src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts

Lines changed: 69 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
1919
import type { UUIComboboxElement, UUIComboboxEvent } from '@umbraco-cms/backoffice/external/uui';
2020

2121
interface UmbDateTime {
22-
date: string | undefined;
23-
timeZone: string | undefined;
22+
date: string | null;
23+
timeZone: string | null;
2424
}
2525

2626
interface UmbTimeZonePickerOption extends UmbTimeZone {
@@ -34,6 +34,7 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
3434
{
3535
private _timeZoneOptions: Array<UmbTimeZonePickerOption> = [];
3636
private _clientTimeZone: UmbTimeZone | undefined;
37+
private _timeZoneMode: UmbTimeZonePickerValue['mode'] | undefined;
3738

3839
@property({ type: Boolean, reflect: true })
3940
readonly = false;
@@ -104,6 +105,7 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
104105
() => {
105106
return (
106107
this._displayTimeZone &&
108+
this._timeZoneMode !== 'local' &&
107109
!!this.value?.timeZone &&
108110
!this._timeZoneOptions.some((opt) => opt.value === this.value?.timeZone && !opt.invalid)
109111
);
@@ -120,8 +122,13 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
120122
if (this._displayTimeZone) {
121123
timeZonePickerConfig = config.getValueByAlias<UmbTimeZonePickerValue>('timeZones');
122124
}
125+
123126
this.#setTimeInputStep(timeFormat);
124127
this.#prefillValue(timeZonePickerConfig);
128+
129+
// To ensure the expected value matches the prefilled value, we trigger an update.
130+
// If the values match, no change event will be fired.
131+
this.#updateValue(this._selectedDate?.toISO({ includeOffset: false }) ?? null);
125132
}
126133

127134
#prefillValue(timeZonePickerConfig: UmbTimeZonePickerValue | undefined) {
@@ -158,8 +165,8 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
158165
#prefillTimeZones(config: UmbTimeZonePickerValue | undefined, selectedDate: DateTime | undefined) {
159166
// Retrieve the time zones from the config
160167
this._clientTimeZone = getClientTimeZone();
168+
this._timeZoneMode = config?.mode;
161169

162-
// Retrieve the time zones from the config
163170
const dateToCalculateOffset = selectedDate ?? DateTime.now();
164171
switch (config?.mode) {
165172
case 'all':
@@ -219,7 +226,8 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
219226
return;
220227
}
221228
} else if (this.value?.date) {
222-
return; // If there is a date but no time zone, we don't preselect anything
229+
// If there is no time zone in the value, but there is a date, we leave the time zone unselected
230+
return;
223231
}
224232

225233
// Check if we can pre-select the client time zone
@@ -269,16 +277,8 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
269277
return;
270278
}
271279

272-
if (!newPickerValue) {
273-
this._datePickerValue = '';
274-
this.value = undefined;
275-
this._selectedDate = undefined;
276-
this.dispatchEvent(new UmbChangeEvent());
277-
return;
278-
}
279-
280280
this._datePickerValue = newPickerValue;
281-
this.#updateValue(value, true);
281+
this.#updateValue(value);
282282
}
283283

284284
#onTimeZoneChange(event: UUIComboboxEvent) {
@@ -291,7 +291,7 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
291291

292292
if (!this._selectedTimeZone) {
293293
if (this.value?.date) {
294-
this.value = { date: this.value.date, timeZone: undefined };
294+
this.value = { date: this.value.date, timeZone: null };
295295
} else {
296296
this.value = undefined;
297297
}
@@ -303,46 +303,84 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase
303303
return;
304304
}
305305

306-
this.#updateValue(this._selectedDate.toISO({ includeOffset: false }) || '');
306+
this.#updateValue(this._selectedDate.toISO({ includeOffset: false }));
307307
}
308308

309-
#updateValue(date: string, updateOffsets = false) {
309+
#updateValue(date: string | null) {
310310
// Try to parse the date with the selected time zone
311-
const newDate = DateTime.fromISO(date, { zone: this._selectedTimeZone ?? 'UTC' });
311+
const newDate = date ? DateTime.fromISO(date, { zone: this._selectedTimeZone || 'UTC' }) : null;
312312

313313
// If the date is invalid, we reset the value
314-
if (!newDate.isValid) {
314+
if (!newDate || !newDate.isValid) {
315+
if (!this.value) {
316+
return; // No change
317+
}
315318
this.value = undefined;
316319
this._selectedDate = undefined;
317320
this.dispatchEvent(new UmbChangeEvent());
321+
this.#updateOffsets(DateTime.now());
318322
return;
319323
}
320324

325+
const previousDate = this._selectedDate;
321326
this._selectedDate = newDate;
322-
this.value = {
323-
date: this.#getCurrentDateValue(),
324-
timeZone: this._selectedTimeZone,
327+
328+
let timeZoneToStore = null;
329+
if (!this._displayTimeZone || !this._timeZoneMode) {
330+
timeZoneToStore = null;
331+
} else if (this._timeZoneMode === 'local') {
332+
timeZoneToStore = 'UTC';
333+
} else {
334+
timeZoneToStore = this._selectedTimeZone ?? null;
335+
}
336+
337+
const dateToStore =
338+
timeZoneToStore && this._selectedTimeZone !== timeZoneToStore ? newDate.setZone(timeZoneToStore) : newDate;
339+
340+
const newValue = {
341+
date: this.#formatDateValue(dateToStore),
342+
timeZone: timeZoneToStore,
325343
};
326344

327-
if (updateOffsets) {
328-
this._timeZoneOptions.forEach((opt) => {
329-
opt.offset = getTimeZoneOffset(opt.value, newDate);
330-
});
331-
// Update the time zone options (mostly for the offset)
332-
this._filteredTimeZoneOptions = this._timeZoneOptions;
345+
// Only update the stored data if it has actually changed to avoid firing unnecessary change events
346+
const previousValue = this.value;
347+
if (previousValue?.date === newValue.date && previousValue?.timeZone === newValue.timeZone) {
348+
return;
333349
}
350+
351+
this.value = newValue;
334352
this.dispatchEvent(new UmbChangeEvent());
353+
354+
// Only update offsets if the date timestamp has changed
355+
if (previousDate?.toUnixInteger() !== newDate.toUnixInteger()) {
356+
this.#updateOffsets(newDate);
357+
}
335358
}
336359

337-
#getCurrentDateValue(): string | undefined {
360+
#updateOffsets(date: DateTime) {
361+
if (!this._displayTimeZone) return;
362+
this._timeZoneOptions.forEach((opt) => {
363+
opt.offset = getTimeZoneOffset(opt.value, date);
364+
});
365+
// Update the time zone options (mostly for the offset)
366+
this._filteredTimeZoneOptions = this._timeZoneOptions;
367+
}
368+
369+
#formatDateValue(date: DateTime): string | null {
370+
let formattedDate: string | undefined;
338371
switch (this._dateInputType) {
339372
case 'date':
340-
return this._selectedDate?.toISODate() ?? undefined;
373+
formattedDate = date.toFormat('yyyy-MM-dd');
374+
break;
341375
case 'time':
342-
return this._selectedDate?.toISOTime({ includeOffset: false }) ?? undefined;
376+
formattedDate = date.toFormat('HH:mm:ss');
377+
break;
343378
default:
344-
return this._selectedDate?.toISO({ includeOffset: !!this._selectedTimeZone }) ?? undefined;
379+
formattedDate = date.toFormat(`yyyy-MM-dd'T'HH:mm:ss${this._timeZoneMode ? 'ZZ' : ''}`);
380+
break;
345381
}
382+
383+
return formattedDate ?? null;
346384
}
347385

348386
#onTimeZoneSearch(event: UUIComboboxEvent) {

tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ protected override void CustomTestSetup(IUmbracoBuilder builder)
3838

3939
private static readonly object[] _sourceList1 =
4040
[
41-
new object[] { Constants.PropertyEditors.Aliases.DateOnly, false, new DateOnly(2025, 1, 22) },
41+
new object[] { Constants.PropertyEditors.Aliases.DateOnly, false, new DateOnly(2025, 6, 22) },
4242
new object[] { Constants.PropertyEditors.Aliases.TimeOnly, false, new TimeOnly(18, 33, 1) },
43-
new object[] { Constants.PropertyEditors.Aliases.DateTimeUnspecified, false, new DateTime(2025, 1, 22, 18, 33, 1) },
44-
new object[] { Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, true, new DateTimeOffset(2025, 1, 22, 18, 33, 1, TimeSpan.Zero) },
43+
new object[] { Constants.PropertyEditors.Aliases.DateTimeUnspecified, false, new DateTime(2025, 6, 22, 18, 33, 1) },
44+
new object[] { Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, true, new DateTimeOffset(2025, 6, 22, 18, 33, 1, TimeSpan.FromHours(2)) },
4545
];
4646

4747
[TestCaseSource(nameof(_sourceList1))]
@@ -105,7 +105,7 @@ public async Task Returns_Correct_Type_Based_On_Configuration(
105105
.WithValue(
106106
new JsonObject
107107
{
108-
["date"] = "2025-01-22T18:33:01.0000000+00:00",
108+
["date"] = "2025-06-22T18:33:01.0000000+02:00",
109109
["timeZone"] = "Europe/Copenhagen",
110110
})
111111
.Done()

tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public void Can_Convert_To_Intermediate_Value(string? input, object? expected)
8686
private static object[] _dateTimeUnspecifiedConvertToObjectCases =
8787
[
8888
new object[] { null, null },
89-
new object[] { _convertToObjectInputDate, DateTime.Parse("2025-08-20T17:30:00") },
89+
new object[] { _convertToObjectInputDate, DateTime.Parse("2025-08-20T16:30:00") },
9090
];
9191

9292
[TestCaseSource(nameof(_dateTimeUnspecifiedConvertToObjectCases))]

tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public void Can_Convert_To_Intermediate_Value(string? input, object? expected)
8686
private static object[] _timeOnlyConvertToObjectCases =
8787
[
8888
new object[] { null, null },
89-
new object[] { _convertToObjectInputDate, TimeOnly.Parse("17:30") },
89+
new object[] { _convertToObjectInputDate, TimeOnly.Parse("16:30") },
9090
];
9191

9292
[TestCaseSource(nameof(_timeOnlyConvertToObjectCases))]

0 commit comments

Comments
 (0)