diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs index cdb2339478cc..a01e39e6fe66 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs @@ -30,5 +30,5 @@ public override bool IsConverter(IPublishedPropertyType propertyType) /// 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 da793aeb2f8b..9bd138e591a9 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs @@ -30,6 +30,5 @@ public override bool IsConverter(IPublishedPropertyType propertyType) /// 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 f6862e5b6f7f..777a20f0489a 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs @@ -30,5 +30,5 @@ public override bool IsConverter(IPublishedPropertyType propertyType) /// 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 8c0b1ce9aa80..07e196850aba 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 2e5135f1ab73..48f8a035be6f 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 @@ protected override void CustomTestSetup(IUmbracoBuilder builder) 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 async Task Returns_Correct_Type_Based_On_Configuration( .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 dd7395f5d020..1f38c034f265 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 void Can_Convert_To_Intermediate_Value(string? input, object? expected) 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 a0bcd82ec9f8..ee3da5167168 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 void Can_Convert_To_Intermediate_Value(string? input, object? expected) 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))]