diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditor.cs index 3c1e7cd683..50d06b725b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditor.cs @@ -1,8 +1,16 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Data.SqlTypes; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.PropertyEditors.Validators; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; @@ -25,8 +33,91 @@ public class DateTimePropertyEditor : DataEditor /// protected override IDataValueEditor CreateValueEditor() { - IDataValueEditor editor = base.CreateValueEditor(); + DateTimePropertyValueEditor editor = DataValueEditorFactory.Create(Attribute!); editor.Validators.Add(new DateTimeValidator()); return editor; } + + /// + /// Provides a value editor for the datetime property editor. + /// + internal sealed class DateTimePropertyValueEditor : DataValueEditor + { + /// + /// The key used to retrieve the date format from the data type configuration. + /// + internal const string DateTypeConfigurationFormatKey = "format"; + + /// + /// Initializes a new instance of the class. + /// + public DateTimePropertyValueEditor( + IShortStringHelper shortStringHelper, + IJsonSerializer jsonSerializer, + IIOHelper ioHelper, + DataEditorAttribute attribute) + : base(shortStringHelper, jsonSerializer, ioHelper, attribute) + { + } + + /// + public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) + { + if (editorValue.Value is null) + { + return base.FromEditor(editorValue, currentValue); + } + + if (TryGetConfiguredDateTimeFormat(editorValue, out string? format) is false) + { + return base.FromEditor(editorValue, currentValue); + } + + if (IsTimeOnlyFormat(format) is false) + { + return base.FromEditor(editorValue, currentValue); + } + + // We have a time-only format, so we need to ensure the date part is valid for SQL Server, so we can persist + // without error. + // If we have a date part that is less than the minimum date, we need to adjust it to be the minimum date. + Attempt dateConvertAttempt = editorValue.Value.TryConvertTo(typeof(DateTime?)); + if (dateConvertAttempt.Success is false || dateConvertAttempt.Result is null) + { + return base.FromEditor(editorValue, currentValue); + } + + var dateTimeValue = (DateTime)dateConvertAttempt.Result; + int yearValue = dateTimeValue.Year > SqlDateTime.MinValue.Value.Year + ? dateTimeValue.Year + : SqlDateTime.MinValue.Value.Year; + return new DateTime(yearValue, dateTimeValue.Month, dateTimeValue.Day, dateTimeValue.Hour, dateTimeValue.Minute, dateTimeValue.Second); + } + + private static bool TryGetConfiguredDateTimeFormat(ContentPropertyData editorValue, [NotNullWhen(true)] out string? format) + { + if (editorValue.DataTypeConfiguration is not Dictionary dataTypeConfigurationDictionary) + { + format = null; + return false; + } + + KeyValuePair keyValuePair = dataTypeConfigurationDictionary + .FirstOrDefault(kvp => kvp.Key is "format"); + format = keyValuePair.Value as string; + return string.IsNullOrWhiteSpace(format) is false; + } + + private static bool IsTimeOnlyFormat(string format) + { + DateTime testDate = DateTime.UtcNow; + var testDateFormatted = testDate.ToString(format); + if (DateTime.TryParseExact(testDateFormatted, format, CultureInfo.InvariantCulture, DateTimeStyles.NoCurrentDateDefault, out DateTime parsedDate) is false) + { + return false; + } + + return parsedDate.Year == 1 && parsedDate.Month == 1 && parsedDate.Day == 1; + } + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs new file mode 100644 index 0000000000..ba6adce308 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs @@ -0,0 +1,41 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors; + +public class DateTimePropertyEditorTests +{ + // Various time formats with years below the minimum, so we expect to increase the date to the minimum supported by SQL Server. + [TestCase("01/01/0001 10:00", "01/01/1753 10:00", "hh:mm")] + [TestCase("01/01/0001 10:00", "01/01/1753 10:00", "HH:mm")] + [TestCase("01/01/0001 10:00", "01/01/1753 10:00", "hh mm")] + [TestCase("10/10/1000 10:00", "10/10/1753 10:00", "hh:mm:ss")] + [TestCase("10/10/1000 10:00", "10/10/1753 10:00", "hh-mm-ss")] + + // Time format with year above the minimum, so we expect to not convert. + [TestCase("01/01/2000 10:00", "01/01/2000 10:00", "HH:mm")] + + // Date formats, so we don't convert even if the year is below the minimum. + [TestCase("01/01/0001 10:00", "01/01/0001 10:00", "dd-MM-yyyy hh:mm")] + [TestCase("01/01/0001 10:00", "01/01/0001 10:00", "dd-MM-yyyy")] + [TestCase("01/01/0001 10:00", "01/01/0001 10:00", "yyyy-MM-d")] + public void Time_Only_Format_Ensures_DateTime_Can_Be_Persisted(DateTime actualDateTime, DateTime expectedDateTime, string format) + { + var dateTimePropertyEditor = new DateTimePropertyEditor.DateTimePropertyValueEditor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + new DataEditorAttribute("Alias") { ValueType = ValueTypes.DateTime.ToString() }); + + Dictionary dictionary = new Dictionary { { DateTimePropertyEditor.DateTimePropertyValueEditor.DateTypeConfigurationFormatKey, format } }; + ContentPropertyData propertyData = new ContentPropertyData(actualDateTime, dictionary); + var value = (DateTime)dateTimePropertyEditor.FromEditor(propertyData, null); + + Assert.AreEqual(expectedDateTime, value); + } +}