diff --git a/src/Umbraco.Core/Constants-DataTypes.cs b/src/Umbraco.Core/Constants-DataTypes.cs index e7c44eff13..654c884dbf 100644 --- a/src/Umbraco.Core/Constants-DataTypes.cs +++ b/src/Umbraco.Core/Constants-DataTypes.cs @@ -111,6 +111,11 @@ public static partial class Constants /// public const string DatePickerWithTime = "e4d66c0f-b935-4200-81f0-025f7256b89a"; + /// + /// Guid for Date Time Picker (with Timezone) as string + /// + public const string DateTimePickerWithTimeZone = "88E8A052-30EE-4D44-A507-59F2CDFC769C"; + /// /// Guid for Approved Color as string /// @@ -296,6 +301,11 @@ public static partial class Constants /// public static readonly Guid DatePickerWithTimeGuid = new(DatePickerWithTime); + /// + /// Guid for Date Time Picker (with Timezone). + /// + public static readonly Guid DateTimePickerWithTimeZoneGuid = new(DateTimePickerWithTimeZone); + /// /// Guid for Approved Color /// diff --git a/src/Umbraco.Core/Constants-PropertyEditors.cs b/src/Umbraco.Core/Constants-PropertyEditors.cs index 5f670a1761..f1264d5050 100644 --- a/src/Umbraco.Core/Constants-PropertyEditors.cs +++ b/src/Umbraco.Core/Constants-PropertyEditors.cs @@ -75,6 +75,26 @@ public static partial class Constants /// public const string DateTime = "Umbraco.DateTime"; + /// + /// Date Time (unspecified). + /// + public const string DateTimeUnspecified = "Umbraco.DateTimeUnspecified"; + + /// + /// Date Time (with time zone). + /// + public const string DateTimeWithTimeZone = "Umbraco.DateTimeWithTimeZone"; + + /// + /// Date Only. + /// + public const string DateOnly = "Umbraco.DateOnly"; + + /// + /// Time Only. + /// + public const string TimeOnly = "Umbraco.TimeOnly"; + /// /// DropDown List. /// diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index a45789182a..0732bfe771 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -376,6 +376,7 @@ Value cannot be null Value cannot be empty Value is invalid, it does not match the correct pattern + Invalid date %1% more.]]> %1% too many.]]> The string length exceeds the maximum length of %0% characters, %1% too many. diff --git a/src/Umbraco.Core/PropertyEditors/DateTimeConfiguration.cs b/src/Umbraco.Core/PropertyEditors/DateTimeConfiguration.cs new file mode 100644 index 0000000000..0198e94630 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/DateTimeConfiguration.cs @@ -0,0 +1,49 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Text.Json.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors; + +public class DateTimeConfiguration +{ + /// + /// Gets or sets the time zones configuration. + /// + [ConfigurationField("timeZones")] + public TimeZonesConfiguration? TimeZones { get; set; } + + public class TimeZonesConfiguration + { + /// + /// The mode for time zones. + /// + public TimeZoneMode Mode { get; set; } + + /// + /// A list of time zones to use when the mode is set to Custom. + /// + public List TimeZones { get; set; } = []; + } + + public enum TimeZoneMode + { + /// + /// Display all time zones. + /// + [JsonStringEnumMemberName("all")] + All, + + /// + /// Display only the local time zone of the user. + /// + [JsonStringEnumMemberName("local")] + Local, + + /// + /// Display a custom list of time zones defined in the configuration. + /// + [JsonStringEnumMemberName("custom")] + Custom, + } +} diff --git a/src/Umbraco.Core/PropertyEditors/IDateOnlyPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/IDateOnlyPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..c6cfdd7ee3 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IDateOnlyPropertyIndexValueFactory.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Factory for creating index values for Date Only properties. +/// +public interface IDateOnlyPropertyIndexValueFactory : IPropertyIndexValueFactory; diff --git a/src/Umbraco.Core/PropertyEditors/IDateTimeUnspecifiedPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/IDateTimeUnspecifiedPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..73b81e97db --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IDateTimeUnspecifiedPropertyIndexValueFactory.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Factory for creating index values for Date Time Unspecified properties. +/// +public interface IDateTimeUnspecifiedPropertyIndexValueFactory : IPropertyIndexValueFactory; diff --git a/src/Umbraco.Core/PropertyEditors/IDateTimeWithTimeZonePropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/IDateTimeWithTimeZonePropertyIndexValueFactory.cs new file mode 100644 index 0000000000..5adb4bb3df --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IDateTimeWithTimeZonePropertyIndexValueFactory.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Factory for creating index values for Date Time with Time Zone properties. +/// +public interface IDateTimeWithTimeZonePropertyIndexValueFactory : IPropertyIndexValueFactory; diff --git a/src/Umbraco.Core/PropertyEditors/ITimeOnlyPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/ITimeOnlyPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..e84e3f06db --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/ITimeOnlyPropertyIndexValueFactory.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Factory for creating index values for Time Only properties. +/// +public interface ITimeOnlyPropertyIndexValueFactory : IPropertyIndexValueFactory; diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index a3b5f3113a..c9f8db1154 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -263,6 +263,10 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); return builder; } diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs index 0b1417f699..652827a609 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs @@ -863,6 +863,25 @@ internal sealed class DatabaseDataCreator }, Constants.DatabaseSchema.Tables.Node, "id"); + ConditionalInsert( + Constants.Configuration.NamedOptions.InstallDefaultData.DataTypes, + Constants.DataTypes.Guids.DateTimePickerWithTimeZone, + new NodeDto + { + NodeId = 1055, + Trashed = false, + ParentId = -1, + UserId = -1, + Level = 1, + Path = "-1,1055", + SortOrder = 2, + UniqueId = Constants.DataTypes.Guids.DateTimePickerWithTimeZoneGuid, + Text = "Date Time Picker (with time zone)", + NodeObjectType = Constants.ObjectTypes.DataType, + CreateDate = DateTime.UtcNow, + }, + Constants.DatabaseSchema.Tables.Node, + "id"); } private void CreateNodeDataForMediaTypes() @@ -2302,6 +2321,22 @@ internal sealed class DatabaseDataCreator "\", \"multiple\": true}", }); } + + if (_database.Exists(1055)) + { + _database.Insert( + Constants.DatabaseSchema.Tables.DataType, + "pk", + false, + new DataTypeDto + { + NodeId = 1055, + EditorAlias = Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, + EditorUiAlias = "Umb.PropertyEditorUi.DateTimeWithTimeZonePicker", + DbType = "Ntext", + Configuration = "{\"timeFormat\": \"HH:mm\", \"timeZones\": {\"mode\": \"all\"}}", + }); + } } private void CreateRelationTypeData() diff --git a/src/Umbraco.Infrastructure/Models/DateTimeEditorValue.cs b/src/Umbraco.Infrastructure/Models/DateTimeEditorValue.cs new file mode 100644 index 0000000000..e9f20fe40e --- /dev/null +++ b/src/Umbraco.Infrastructure/Models/DateTimeEditorValue.cs @@ -0,0 +1,13 @@ +using System.Runtime.Serialization; + +namespace Umbraco.Cms.Infrastructure.Models; + +[DataContract] +public class DateTimeEditorValue +{ + [DataMember(Name = "date")] + public string? Date { get; set; } + + [DataMember(Name = "timeZone")] + public string? TimeZone { get; set; } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateOnlyPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateOnlyPropertyEditor.cs new file mode 100644 index 0000000000..ffac61710c --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateOnlyPropertyEditor.cs @@ -0,0 +1,36 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Globalization; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Represents a property editor for editing date-only values. +/// +/// +/// This is one of four property editors derived from and storing their value as JSON with timezone information. +/// +[DataEditor( + Constants.PropertyEditors.Aliases.DateOnly, + ValueType = ValueTypes.Json, + ValueEditorIsReusable = true)] +public sealed class DateOnlyPropertyEditor : DateTimePropertyEditorBase +{ + /// + /// Initializes a new instance of the class. + /// + public DateOnlyPropertyEditor( + IDataValueEditorFactory dataValueEditorFactory, + IIOHelper ioHelper, + IDateOnlyPropertyIndexValueFactory propertyIndexValueFactory) + : base(dataValueEditorFactory, ioHelper, propertyIndexValueFactory) + { + } + + /// + protected override string MapDateToEditorFormat(DateTimeValueConverterBase.DateTimeDto dateTimeDto) + => dateTimeDto.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateOnlyPropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateOnlyPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..72655dcfe2 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateOnlyPropertyIndexValueFactory.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Provides a factory for creating property index values for date only datetimes. +/// +/// +/// This is one of four property index value factories derived from and storing their +/// value as JSON with timezone information. +/// +internal class DateOnlyPropertyIndexValueFactory : DateTimePropertyIndexValueFactory, IDateOnlyPropertyIndexValueFactory +{ + /// + /// Initializes a new instance of the class. + /// + public DateOnlyPropertyIndexValueFactory( + IJsonSerializer jsonSerializer, + ILogger logger) + : base(jsonSerializer, logger) + { + } + + /// + protected override string MapDateToIndexValueFormat(DateTimeOffset date) + => date.UtcDateTime.ToString("yyyy-MM-dd"); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimeConfigurationEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimeConfigurationEditor.cs new file mode 100644 index 0000000000..7dcf1251df --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimeConfigurationEditor.cs @@ -0,0 +1,14 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Core.PropertyEditors; + +internal class DateTimeConfigurationEditor : ConfigurationEditor +{ + public DateTimeConfigurationEditor(IIOHelper ioHelper) + : base(ioHelper) + { + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorBase.cs new file mode 100644 index 0000000000..4db36db387 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorBase.cs @@ -0,0 +1,192 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors.Validation; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.Models; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Provides base functionality for date time property editors that store their value as a JSON string with timezone information. +/// +public abstract class DateTimePropertyEditorBase : DataEditor +{ + private readonly IIOHelper _ioHelper; + private readonly IPropertyIndexValueFactory _propertyIndexValueFactory; + + /// + /// Initializes a new instance of the class. + /// + protected DateTimePropertyEditorBase( + IDataValueEditorFactory dataValueEditorFactory, + IIOHelper ioHelper, + IPropertyIndexValueFactory propertyIndexValueFactory) + : base(dataValueEditorFactory) + { + _ioHelper = ioHelper; + _propertyIndexValueFactory = propertyIndexValueFactory; + SupportsReadOnly = true; + } + + /// + protected override IConfigurationEditor CreateConfigurationEditor() => + new DateTimeConfigurationEditor(_ioHelper); + + /// + protected override IDataValueEditor CreateValueEditor() => + DataValueEditorFactory.Create(Attribute!, MapDateToEditorFormat); + + /// + /// Converts the specified date and time value to a string formatted for use in the property editor. + /// + /// An object containing the date and time components to be formatted. + /// A string representation of the date and time, formatted for use in the property editor. + protected abstract string MapDateToEditorFormat(DateTimeValueConverterBase.DateTimeDto dateTimeDto); + + /// + public override IPropertyIndexValueFactory PropertyIndexValueFactory => _propertyIndexValueFactory; + + /// + /// Provides a data value editor for date and time properties, supporting conversion between editor values and + /// persisted values for date/time property editors. + /// + internal class DateTimeDataValueEditor : DataValueEditor + { + private readonly IJsonSerializer _jsonSerializer; + private readonly ILogger _logger; + private readonly Func _mappingFunc; + private readonly string _editorAlias; + + /// + /// Initializes a new instance of the class. + /// + public DateTimeDataValueEditor( + IShortStringHelper shortStringHelper, + IJsonSerializer jsonSerializer, + IIOHelper ioHelper, + DataEditorAttribute attribute, + ILocalizedTextService localizedTextService, + ILogger logger, + Func mappingFunc) + : base(shortStringHelper, jsonSerializer, ioHelper, attribute) + { + _jsonSerializer = jsonSerializer; + _logger = logger; + _mappingFunc = mappingFunc; + _editorAlias = attribute.Alias; + var validators = new TypedJsonValidatorRunner( + jsonSerializer, + new DateTimeValidator(localizedTextService)); + + Validators.Add(validators); + } + + /// + public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) + { + if (editorValue.Value is null || + _jsonSerializer.TryDeserialize(editorValue.Value, out DateTimeEditorValue? dateTimeEditorValue) is false) + { + return base.FromEditor(editorValue, currentValue); + } + + var selectedDate = dateTimeEditorValue.Date; + if (selectedDate.IsNullOrWhiteSpace() + || DateTimeOffset.TryParse(selectedDate, null, DateTimeStyles.AssumeUniversal, out DateTimeOffset dateTimeOffset) is false) + { + return null; + } + + if (_editorAlias == Constants.PropertyEditors.Aliases.TimeOnly) + { + // Clear the date part if the format is TimeOnly. + // This is needed because `DateTimeOffset.TryParse` does not support `DateTimeStyles.NoCurrentDateDefault`. + dateTimeOffset = new DateTimeOffset(DateOnly.MinValue, TimeOnly.FromTimeSpan(dateTimeOffset.TimeOfDay), dateTimeOffset.Offset); + } + + var value = new DateTimeValueConverterBase.DateTimeDto + { + Date = dateTimeOffset, + TimeZone = dateTimeEditorValue.TimeZone, + }; + + return _jsonSerializer.Serialize(value); + } + + /// + public override object? ToEditor(IProperty property, string? culture = null, string? segment = null) + { + var value = property.GetValue(culture, segment); + + DateTimeValueConverterBase.DateTimeDto? dateTimeDto = DateTimePropertyEditorHelper.TryParseToIntermediateValue(value, _jsonSerializer, _logger, out DateTimeValueConverterBase.DateTimeDto? dateTimeDtoObj) + ? dateTimeDtoObj + : null; + + if (dateTimeDto is null) + { + return null; + } + + return new DateTimeEditorValue + { + Date = _mappingFunc(dateTimeDto), + TimeZone = dateTimeDto.TimeZone, + }; + } + + /// + /// Validates the date time selection for the DateTime2 property editor. + /// + private class DateTimeValidator : ITypedJsonValidator + { + private readonly ILocalizedTextService _localizedTextService; + + public DateTimeValidator(ILocalizedTextService localizedTextService) + => _localizedTextService = localizedTextService; + + public IEnumerable Validate( + DateTimeEditorValue? value, + DateTimeConfiguration? configuration, + string? valueType, + PropertyValidationContext validationContext) + { + if (value is null) + { + yield break; + } + + if (value.Date is null || DateTimeOffset.TryParse(value.Date, out DateTimeOffset _) is false) + { + yield return new ValidationResult( + _localizedTextService.Localize("validation", "invalidDate"), + ["value"]); + } + + if (configuration?.TimeZones?.Mode is not { } mode) + { + yield break; + } + + if (mode == DateTimeConfiguration.TimeZoneMode.Custom + && configuration.TimeZones?.TimeZones.Any(t => t.Equals(value.TimeZone, StringComparison.InvariantCultureIgnoreCase)) != true) + { + yield return new ValidationResult( + _localizedTextService.Localize("validation", "notOneOfOptions", [value.TimeZone]), + ["value"]); + } + } + } + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorHelper.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorHelper.cs new file mode 100644 index 0000000000..cf8c8ce853 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorHelper.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Helper class for the DateTime property editors. +/// +public static class DateTimePropertyEditorHelper +{ + internal static bool TryParseToIntermediateValue(object? value, IJsonSerializer jsonSerializer, ILogger logger, out DateTimeValueConverterBase.DateTimeDto? intermediateValue) + { + if (value is null) + { + intermediateValue = null; + return true; + } + + try + { + intermediateValue = value switch + { + // This DateTime check is for compatibility with the "deprecated" `Umbraco.DateTime`. + // Once that is removed, this can be removed too. + DateTime dateTime => new DateTimeValueConverterBase.DateTimeDto + { + Date = new DateTimeOffset(dateTime, TimeSpan.Zero), + }, + string sourceStr => jsonSerializer.Deserialize(sourceStr), + _ => throw new InvalidOperationException($"Unsupported type {value.GetType()}"), + }; + return true; + } + catch (Exception exception) + { + logger.LogError(exception, "Could not parse date time editor value, see exception for details."); + intermediateValue = null; + return false; + } + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyIndexValueFactory.cs new file mode 100644 index 0000000000..17abd1abd0 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyIndexValueFactory.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Provides base functionality for date time property editors index value factories that store their value as a JSON string with timezone information. +/// +internal abstract class DateTimePropertyIndexValueFactory : IPropertyIndexValueFactory +{ + private readonly IJsonSerializer _jsonSerializer; + private readonly ILogger _logger; + + protected DateTimePropertyIndexValueFactory( + IJsonSerializer jsonSerializer, + ILogger logger) + { + _jsonSerializer = jsonSerializer; + _logger = logger; + } + + /// + public IEnumerable GetIndexValues( + IProperty property, + string? culture, + string? segment, + bool published, + IEnumerable availableCultures, + IDictionary contentTypeDictionary) + { + var indexValue = new IndexValue + { + Culture = culture, + FieldName = property.Alias, + Values = [], + }; + + var sourceValue = property.GetValue(culture, segment, published); + if (sourceValue is null + || DateTimePropertyEditorHelper.TryParseToIntermediateValue(sourceValue, _jsonSerializer, _logger, out DateTimeValueConverterBase.DateTimeDto? dateTimeDto) is false + || dateTimeDto is null) + { + return [indexValue]; + } + + var valueStr = MapDateToIndexValueFormat(dateTimeDto.Date); + indexValue.Values = [valueStr]; + + return [indexValue]; + } + + /// + /// Maps the date to the appropriate string format for indexing. + /// + /// The date to map. + /// The formatted date string. + protected abstract string MapDateToIndexValueFormat(DateTimeOffset date); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimeUnspecifiedPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimeUnspecifiedPropertyEditor.cs new file mode 100644 index 0000000000..fc95aec0ea --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimeUnspecifiedPropertyEditor.cs @@ -0,0 +1,36 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Globalization; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Represents a property editor for editing an unspecified date/time value. +/// +/// +/// This is one of four property editors derived from and storing their value as JSON with timezone information. +/// +[DataEditor( + Constants.PropertyEditors.Aliases.DateTimeUnspecified, + ValueType = ValueTypes.Json, + ValueEditorIsReusable = true)] +public class DateTimeUnspecifiedPropertyEditor : DateTimePropertyEditorBase +{ + /// + /// Initializes a new instance of the class. + /// + public DateTimeUnspecifiedPropertyEditor( + IDataValueEditorFactory dataValueEditorFactory, + IIOHelper ioHelper, + IDateTimeUnspecifiedPropertyIndexValueFactory propertyIndexValueFactory) + : base(dataValueEditorFactory, ioHelper, propertyIndexValueFactory) + { + } + + /// + protected override string MapDateToEditorFormat(DateTimeValueConverterBase.DateTimeDto dateTimeDto) + => dateTimeDto.Date.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimeUnspecifiedPropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimeUnspecifiedPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..04b1a9bef2 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimeUnspecifiedPropertyIndexValueFactory.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Provides a factory for creating property index values for unspecified dates. +/// +/// +/// This is one of four property index value factories derived from and storing their +/// value as JSON with timezone information. +/// +internal class DateTimeUnspecifiedPropertyIndexValueFactory : DateTimePropertyIndexValueFactory, IDateTimeUnspecifiedPropertyIndexValueFactory +{ + /// + /// Initializes a new instance of the class. + /// + public DateTimeUnspecifiedPropertyIndexValueFactory( + IJsonSerializer jsonSerializer, + ILogger logger) + : base(jsonSerializer, logger) + { + } + + /// + protected override string MapDateToIndexValueFormat(DateTimeOffset date) + => date.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss"); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimeWithTimeZonePropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimeWithTimeZonePropertyEditor.cs new file mode 100644 index 0000000000..2303246f85 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimeWithTimeZonePropertyEditor.cs @@ -0,0 +1,36 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Globalization; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Represents a property editor for editing a date/time value with timezone. +/// +/// +/// This is one of four property editors derived from and storing their value as JSON with timezone information. +/// +[DataEditor( + Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, + ValueType = ValueTypes.Json, + ValueEditorIsReusable = true)] +public class DateTimeWithTimeZonePropertyEditor : DateTimePropertyEditorBase +{ + /// + /// Initializes a new instance of the class. + /// + public DateTimeWithTimeZonePropertyEditor( + IDataValueEditorFactory dataValueEditorFactory, + IIOHelper ioHelper, + IDateTimeWithTimeZonePropertyIndexValueFactory propertyIndexValueFactory) + : base(dataValueEditorFactory, ioHelper, propertyIndexValueFactory) + { + } + + /// + protected override string MapDateToEditorFormat(DateTimeValueConverterBase.DateTimeDto dateTimeDto) + => dateTimeDto.Date.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimeWithTimeZonePropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimeWithTimeZonePropertyIndexValueFactory.cs new file mode 100644 index 0000000000..026a57a414 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimeWithTimeZonePropertyIndexValueFactory.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Provides a factory for creating property index values for datetimes stored with timezones. +/// +/// +/// This is one of four property index value factories derived from and storing their +/// value as JSON with timezone information. +/// +internal class DateTimeWithTimeZonePropertyIndexValueFactory : DateTimePropertyIndexValueFactory, IDateTimeWithTimeZonePropertyIndexValueFactory +{ + /// + /// Initializes a new instance of the class. + /// + public DateTimeWithTimeZonePropertyIndexValueFactory( + IJsonSerializer jsonSerializer, + ILogger logger) + : base(jsonSerializer, logger) + { + } + + /// + protected override string MapDateToIndexValueFormat(DateTimeOffset date) + => date.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TimeOnlyPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/TimeOnlyPropertyEditor.cs new file mode 100644 index 0000000000..518e3bf3d9 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/TimeOnlyPropertyEditor.cs @@ -0,0 +1,36 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Globalization; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Represents a property editor for editing time-only values. +/// +/// +/// This is one of four property editors derived from and storing their value as JSON with timezone information. +/// +[DataEditor( + Constants.PropertyEditors.Aliases.TimeOnly, + ValueType = ValueTypes.Json, + ValueEditorIsReusable = true)] +public class TimeOnlyPropertyEditor : DateTimePropertyEditorBase +{ + /// + /// Initializes a new instance of the class. + /// + public TimeOnlyPropertyEditor( + IDataValueEditorFactory dataValueEditorFactory, + IIOHelper ioHelper, + ITimeOnlyPropertyIndexValueFactory propertyIndexValueFactory) + : base(dataValueEditorFactory, ioHelper, propertyIndexValueFactory) + { + } + + /// + protected override string MapDateToEditorFormat(DateTimeValueConverterBase.DateTimeDto dateTimeDto) + => dateTimeDto.Date.ToString("HH:mm:ss", CultureInfo.InvariantCulture); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TimeOnlyPropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/TimeOnlyPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..2603b130d7 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/TimeOnlyPropertyIndexValueFactory.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Provides a factory for creating property index values for time only datetimes. +/// +/// +/// This is one of four property index value factories derived from and storing their +/// value as JSON with timezone information. +/// +internal class TimeOnlyPropertyIndexValueFactory : DateTimePropertyIndexValueFactory, ITimeOnlyPropertyIndexValueFactory +{ + /// + /// Initializes a new instance of the class. + /// + public TimeOnlyPropertyIndexValueFactory( + IJsonSerializer jsonSerializer, + ILogger logger) + : base(jsonSerializer, logger) + { + } + + /// + protected override string MapDateToIndexValueFormat(DateTimeOffset date) + => date.UtcDateTime.ToString("HH:mm:ss"); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs new file mode 100644 index 0000000000..cdb2339478 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +/// +/// Provides a property value converter for date only datetime property editors. +/// +/// +/// This is one of four property value converters derived from and storing their +/// value as JSON with timezone information. +/// +public class DateOnlyValueConverter : DateTimeValueConverterBase +{ + /// + /// Initializes a new instance of the class. + /// + public DateOnlyValueConverter(IJsonSerializer jsonSerializer, ILogger logger) + : base(jsonSerializer, logger) + { + } + + /// + public override bool IsConverter(IPublishedPropertyType propertyType) + => propertyType.EditorAlias == Constants.PropertyEditors.Aliases.DateOnly; + + /// + public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => typeof(DateOnly?); + + /// + protected override object ConvertToObject(DateTimeDto dateTimeDto) + => DateOnly.FromDateTime(dateTimeDto.Date.UtcDateTime); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs new file mode 100644 index 0000000000..da793aeb2f --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +/// +/// Provides a property value converter for unspecified datetime property editors. +/// +/// +/// This is one of four property value converters derived from and storing their +/// value as JSON with timezone information. +/// +public class DateTimeUnspecifiedValueConverter : DateTimeValueConverterBase +{ + /// + /// Initializes a new instance of the class. + /// + public DateTimeUnspecifiedValueConverter(IJsonSerializer jsonSerializer, ILogger logger) + : base(jsonSerializer, logger) + { + } + + /// + public override bool IsConverter(IPublishedPropertyType propertyType) + => propertyType.EditorAlias == Constants.PropertyEditors.Aliases.DateTimeUnspecified; + + /// + public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => typeof(DateTime?); + + /// + protected override object ConvertToObject(DateTimeDto dateTimeDto) + => DateTime.SpecifyKind(dateTimeDto.Date.UtcDateTime, DateTimeKind.Unspecified); + +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeValueConverterBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeValueConverterBase.cs new file mode 100644 index 0000000000..36640e7208 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeValueConverterBase.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +/// +/// Provides base functionality for date time property value converters that store their value as a JSON string with timezone information. +/// +[DefaultPropertyValueConverter(typeof(JsonValueConverter))] +public abstract class DateTimeValueConverterBase : PropertyValueConverterBase +{ + private readonly IJsonSerializer _jsonSerializer; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The JSON serializer. + /// The logger. + protected DateTimeValueConverterBase(IJsonSerializer jsonSerializer, ILogger logger) + { + _jsonSerializer = jsonSerializer; + _logger = logger; + } + + /// + public abstract override bool IsConverter(IPublishedPropertyType propertyType); + + /// + public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) + => PropertyCacheLevel.Element; + + /// + public override object? ConvertSourceToIntermediate( + IPublishedElement owner, + IPublishedPropertyType propertyType, + object? source, + bool preview) + => DateTimePropertyEditorHelper.TryParseToIntermediateValue(source, _jsonSerializer, _logger, out DateTimeDto? intermediateValue) ? intermediateValue : null; + + /// + public override object? ConvertIntermediateToObject( + IPublishedElement owner, + IPublishedPropertyType propertyType, + PropertyCacheLevel referenceCacheLevel, + object? inter, + bool preview) => inter is not DateTimeDto dateTime ? null : ConvertToObject(dateTime); + + /// + /// Convert the intermediate representation to the final object. + /// + /// The intermediate representation. + /// The final object. + protected abstract object ConvertToObject(DateTimeDto dateTimeDto); + + /// + /// Model/DTO that represents the JSON that date time property editors persisting as a JSON string stores. + /// + public class DateTimeDto + { + /// + /// Gets or sets the date time value. + /// + [JsonPropertyName("date")] + public DateTimeOffset Date { get; init; } + + /// + /// Gets or sets the (optional) timezone. + /// + [JsonPropertyName("timeZone")] + public string? TimeZone { get; init; } + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeWithTimeZoneValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeWithTimeZoneValueConverter.cs new file mode 100644 index 0000000000..37f2652ad8 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeWithTimeZoneValueConverter.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +/// +/// Provides a property value converter for timezone specified datetime property editors. +/// +/// +/// This is one of four property value converters derived from and storing their +/// value as JSON with timezone information. +/// +public class DateTimeWithTimeZoneValueConverter : DateTimeValueConverterBase +{ + /// + /// Initializes a new instance of the class. + /// + public DateTimeWithTimeZoneValueConverter(IJsonSerializer jsonSerializer, ILogger logger) + : base(jsonSerializer, logger) + { + } + + /// + public override bool IsConverter(IPublishedPropertyType propertyType) + => propertyType.EditorAlias == Constants.PropertyEditors.Aliases.DateTimeWithTimeZone; + + /// + public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => typeof(DateTimeOffset?); + + /// + protected override object ConvertToObject(DateTimeDto dateTimeDto) + => dateTimeDto.Date; +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs new file mode 100644 index 0000000000..f6862e5b6f --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +/// +/// Provides a property value converter for time only datetime property editors. +/// +/// +/// This is one of four property value converters derived from and storing their +/// value as JSON with timezone information. +/// +public class TimeOnlyValueConverter : DateTimeValueConverterBase +{ + /// + /// Initializes a new instance of the class. + /// + public TimeOnlyValueConverter(IJsonSerializer jsonSerializer, ILogger logger) + : base(jsonSerializer, logger) + { + } + + /// + public override bool IsConverter(IPublishedPropertyType propertyType) + => propertyType.EditorAlias == Constants.PropertyEditors.Aliases.TimeOnly; + + /// + public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => typeof(TimeOnly?); + + /// + protected override object ConvertToObject(DateTimeDto dateTimeDto) + => TimeOnly.FromDateTime(dateTimeDto.Date.UtcDateTime); +} diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index e1a6141893..9891579289 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -3045,6 +3045,13 @@ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", @@ -3547,6 +3554,10 @@ "resolved": "src/packages/extension-insights", "link": true }, + "node_modules/@umbraco-backoffice/external-luxon": { + "resolved": "src/external/luxon", + "link": true + }, "node_modules/@umbraco-backoffice/health-check": { "resolved": "src/packages/health-check", "link": true @@ -10482,6 +10493,15 @@ "dev": true, "license": "MIT" }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/madge": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/madge/-/madge-8.0.0.tgz", @@ -16996,6 +17016,15 @@ "lit": "^3.3.0" } }, + "src/external/luxon": { + "name": "@umbraco-backoffice/external-luxon", + "dependencies": { + "luxon": "^3.7.2" + }, + "devDependencies": { + "@types/luxon": "^3.7.1" + } + }, "src/external/marked": { "name": "@umbraco-backoffice/marked", "dependencies": { diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 620cdbe5a1..698bb23192 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -128,6 +128,7 @@ "./external/dompurify": "./dist-cms/external/dompurify/index.js", "./external/heximal-expressions": "./dist-cms/external/heximal-expressions/index.js", "./external/lit": "./dist-cms/external/lit/index.js", + "./external/luxon": "./dist-cms/external/luxon/index.js", "./external/marked": "./dist-cms/external/marked/index.js", "./external/monaco-editor": "./dist-cms/external/monaco-editor/index.js", "./external/openid": "./dist-cms/external/openid/index.js", diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index bb08d2c21e..30a374e516 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -2851,6 +2851,24 @@ export default { detailsHide: 'Hide details', detailsShow: 'Show details', }, + dateTimePicker: { + local: 'Local', + differentTimeZoneLabel: (offset: string, localDate: string) => + `The selected time (${offset}) is equivalent to ${localDate} in your local time.`, + config_format: 'Format', + config_format_datetime: 'Date and time', + config_format_dateOnly: 'Date only', + config_format_timeOnly: 'Time only', + config_timeFormat: 'Time format', + config_timeZones: 'Time zones', + config_timeZones_description: 'Select the time zones that the editor should be able to pick from.', + config_timeZones_all: 'All - Display all available time zones', + config_timeZones_local: 'Local - Display only the local time zone', + config_timeZones_custom: 'Custom - Display a pre-defined list of time zones', + emptyDate: 'Please select a date', + emptyTimeZone: 'Please select a time zone', + invalidTimeZone: 'The selected time zone is not valid', + }, uiCulture: { ar: 'العربية', bs: 'Bosanski', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts index a86836e70a..f0777b51cd 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts @@ -2846,4 +2846,22 @@ export default { detailsHide: 'Esconder detalhes', detailsShow: 'Mostrar detalhes', }, + dateTimePicker: { + local: 'Local', + differentTimeZoneLabel: (offset: string, localDate: string) => + `A hora selecionada (${offset}) é equivalente a ${localDate} no fuso horário local.`, + config_format: 'Formato', + config_format_datetime: 'Data e hora', + config_format_dateOnly: 'Apenas data', + config_format_timeOnly: 'Apenas hora', + config_timeFormat: 'Formato da hora', + config_timeZones: 'Fusos horários', + config_timeZones_description: 'Selecione os fusos horários que o editor deve poder escolher.', + config_timeZones_all: 'Todos - Mostrar todos os fusos horários disponíveis', + config_timeZones_local: 'Local - Mostrar apenas o fuso horário local', + config_timeZones_custom: 'Personalizado - Mostrar uma lista pré-definida de fusos horários', + emptyDate: 'Por favor, selecione uma data', + emptyTimeZone: 'Por favor, selecione um fuso horário', + invalidTimeZone: 'O fuso horário selecionado não é válido', + }, } as UmbLocalizationDictionary; diff --git a/src/Umbraco.Web.UI.Client/src/external/luxon/index.ts b/src/Umbraco.Web.UI.Client/src/external/luxon/index.ts new file mode 100644 index 0000000000..fea859b5fb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/luxon/index.ts @@ -0,0 +1 @@ +export * from 'luxon'; diff --git a/src/Umbraco.Web.UI.Client/src/external/luxon/package.json b/src/Umbraco.Web.UI.Client/src/external/luxon/package.json new file mode 100644 index 0000000000..ee3232695c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/luxon/package.json @@ -0,0 +1,14 @@ +{ + "name": "@umbraco-backoffice/external-luxon", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + }, + "dependencies": { + "luxon": "^3.7.2" + }, + "devDependencies": { + "@types/luxon": "^3.7.1" + } +} diff --git a/src/Umbraco.Web.UI.Client/src/external/luxon/vite.config.ts b/src/Umbraco.Web.UI.Client/src/external/luxon/vite.config.ts new file mode 100644 index 0000000000..a5c3bd73dd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/luxon/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/external/luxon'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ + dist, + base: '/umbraco/backoffice/external/luxon', + entry: { + index: './index.ts', + }, + }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts index 51b8407a88..23871cb000 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts @@ -1262,4 +1262,64 @@ export const data: Array = [ flags: [], values: [], }, + { + name: 'Date Only', + id: 'dt-dateOnly', + parent: null, + editorAlias: 'Umbraco.DateOnly', + editorUiAlias: 'Umb.PropertyEditorUi.DateOnlyPicker', + hasChildren: false, + isFolder: false, + isDeletable: true, + canIgnoreStartNodes: false, + signs: [], + values: [], + }, + { + name: 'Time Only', + id: 'dt-timeOnly', + parent: null, + editorAlias: 'Umbraco.TimeOnly', + editorUiAlias: 'Umb.PropertyEditorUi.TimeOnlyPicker', + hasChildren: false, + isFolder: false, + isDeletable: true, + canIgnoreStartNodes: false, + signs: [], + values: [], + }, + { + name: 'Date Time (Unspecified)', + id: 'dt-dateTimeUnspecified', + parent: null, + editorAlias: 'Umbraco.DateTimeUnspecified', + editorUiAlias: 'Umb.PropertyEditorUi.DateTimePicker', + hasChildren: false, + isFolder: false, + isDeletable: true, + canIgnoreStartNodes: false, + signs: [], + values: [], + }, + { + name: 'Date Time (with time zone)', + id: 'dt-dateTimeWithTimeZone', + parent: null, + editorAlias: 'Umbraco.DateTimeWithTimeZone', + editorUiAlias: 'Umb.PropertyEditorUi.DateTimeWithTimeZonePicker', + hasChildren: false, + isFolder: false, + isDeletable: true, + canIgnoreStartNodes: false, + signs: [], + values: [ + { + alias: 'timeZones', + value: { + mode: 'all', + timeZones: [], + }, + }, + ], + }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document-type/document-type.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document-type/document-type.data.ts index c1d1c0a36a..afa5eb3687 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document-type/document-type.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document-type/document-type.data.ts @@ -759,6 +759,86 @@ export const data: Array = [ labelOnTop: false, }, }, + { + id: '35', + container: { id: 'all-properties-group-key' }, + alias: 'dateOnly', + name: 'Date Only', + description: '', + dataType: { id: 'dt-dateOnly' }, + variesByCulture: false, + variesBySegment: false, + sortOrder: 33, + validation: { + mandatory: true, + mandatoryMessage: null, + regEx: null, + regExMessage: null, + }, + appearance: { + labelOnTop: false, + }, + }, + { + id: '36', + container: { id: 'all-properties-group-key' }, + alias: 'timeOnly', + name: 'Time Only', + description: '', + dataType: { id: 'dt-timeOnly' }, + variesByCulture: false, + variesBySegment: false, + sortOrder: 34, + validation: { + mandatory: true, + mandatoryMessage: null, + regEx: null, + regExMessage: null, + }, + appearance: { + labelOnTop: false, + }, + }, + { + id: '37', + container: { id: 'all-properties-group-key' }, + alias: 'dateTimeUnspecified', + name: 'Date Time (Unspecified)', + description: '', + dataType: { id: 'dt-dateTimeUnspecified' }, + variesByCulture: false, + variesBySegment: false, + sortOrder: 35, + validation: { + mandatory: true, + mandatoryMessage: null, + regEx: null, + regExMessage: null, + }, + appearance: { + labelOnTop: false, + }, + }, + { + id: '38', + container: { id: 'all-properties-group-key' }, + alias: 'dateTimeWithTimeZone', + name: 'Date Time (with Time Zone)', + description: '', + dataType: { id: 'dt-dateTimeWithTimeZone' }, + variesByCulture: false, + variesBySegment: false, + sortOrder: 36, + validation: { + mandatory: true, + mandatoryMessage: null, + regEx: null, + regExMessage: null, + }, + appearance: { + labelOnTop: false, + }, + }, ], containers: [ { diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts index 802517a560..ae1337b231 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts @@ -620,6 +620,34 @@ export const data: Array = [ segment: null, value: undefined, }, + { + editorAlias: 'Umbraco.DateOnly', + alias: 'dateOnly', + culture: null, + segment: null, + value: { date: '2025-09-19' }, + }, + { + editorAlias: 'Umbraco.TimeOnly', + alias: 'timeOnly', + culture: null, + segment: null, + value: { date: '16:30:00' }, + }, + { + editorAlias: 'Umbraco.DateTimeUnspecified', + alias: 'dateTimeUnspecified', + culture: null, + segment: null, + value: { date: '2025-09-19T16:30:00' }, + }, + { + editorAlias: 'Umbraco.DateTimeWithTimeZone', + alias: 'dateTimeWithTimeZone', + culture: null, + segment: null, + value: { date: '2025-09-19T16:30:00+02:00', timeZone: 'Europe/Copenhagen' }, + }, ], variants: [ { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts index f1d63a722e..d6e8c5d20f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts @@ -20,6 +20,7 @@ export * from './input-manifest/index.js'; export * from './input-number-range/index.js'; export * from './input-radio-button-list/index.js'; export * from './input-slider/index.js'; +export * from './input-time-zone/index.js'; export * from './input-toggle/index.js'; export * from './input-with-alias/input-with-alias.element.js'; export * from './multiple-color-picker-input/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-radio-button-list/input-radio-button-list.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-radio-button-list/input-radio-button-list.element.ts index 5840ae9081..b7d762413e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-radio-button-list/input-radio-button-list.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-radio-button-list/input-radio-button-list.element.ts @@ -80,8 +80,10 @@ export class UmbInputRadioButtonListElement extends UmbFormControlMixin + label=${this.localize.string(item.label) + + (item.invalid ? ` (${this.localize.term('validation_legacyOption')})` : '')} + title=${item.invalid ? this.localize.term('validation_legacyOptionDescription') : ''}> + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/index.ts new file mode 100644 index 0000000000..7031f55797 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/index.ts @@ -0,0 +1,3 @@ +export * from './input-time-zone.element.js'; +export * from './input-time-zone-item.element.js'; +export * from './input-time-zone-picker.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/input-time-zone-item.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/input-time-zone-item.element.ts new file mode 100644 index 0000000000..34fa20a278 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/input-time-zone-item.element.ts @@ -0,0 +1,99 @@ +import { html, customElement, css, property, nothing, when } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbDeleteEvent } from '@umbraco-cms/backoffice/event'; +import { getTimeZoneName } from '@umbraco-cms/backoffice/utils'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; + +/** + * @element umb-input-time-zone-item + */ +@customElement('umb-input-time-zone-item') +export class UmbInputTimeZoneItemElement extends UmbFormControlMixin< + string | undefined, + typeof UmbLitElement, + undefined +>(UmbLitElement) { + /** + * Disables the input + * @type {boolean} + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true }) + disabled: boolean = false; + + /** + * Sets the input to readonly mode, meaning value cannot be changed but still able to read and select its content. + * @type {boolean} + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true }) + readonly: boolean = false; + + override render() { + const label = (this.value && getTimeZoneName(this.value)) || ''; + return html` +
+ ${this.disabled || this.readonly ? nothing : html``} + + ${label} + + ${when( + !this.readonly, + () => html` + this.dispatchEvent(new UmbDeleteEvent())}> + + + `, + )} +
+ `; + } + + static override styles = [ + css` + :host { + display: flex; + align-items: center; + margin-bottom: var(--uui-size-space-2); + gap: var(--uui-size-space-3); + } + + .time-zone-item { + background-color: var(--uui-color-surface-alt); + display: flex; + align-items: center; + gap: var(--uui-size-6); + padding: var(--uui-size-2) var(--uui-size-6); + width: 100%; + } + + .time-zone-remove-button { + margin-left: auto; + } + + .handle { + cursor: grab; + } + + .handle:active { + cursor: grabbing; + } + `, + ]; +} + +export default UmbInputTimeZoneItemElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-input-time-zone-item': UmbInputTimeZoneItemElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/input-time-zone-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/input-time-zone-picker.element.ts new file mode 100644 index 0000000000..86be2c1a36 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/input-time-zone-picker.element.ts @@ -0,0 +1,114 @@ +import { html, customElement, css, property, query, until, repeat, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UUIComboboxElement, UUIComboboxEvent } from '@umbraco-cms/backoffice/external/uui'; +import type { UmbTimeZone } from '@umbraco-cms/backoffice/utils'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; + +export interface UmbTimeZoneOption extends UmbTimeZone { + offset: string; +} + +/** + * @element umb-input-time-zone-picker + */ +@customElement('umb-input-time-zone-picker') +export class UmbInputTimeZonePickerElement extends UmbFormControlMixin( + UmbLitElement, +) { + /** + * Disables the input + * @type {boolean} + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true }) + disabled: boolean = false; + + /** + * Sets the input to readonly mode, meaning value cannot be changed but still able to read and select its content. + * @type {boolean} + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true }) + readonly: boolean = false; + + @property({ type: Array }) + public set options(value) { + this.#options = value; + this._filteredOptions = value; + } + public get options() { + return this.#options; + } + #options: Array = []; + + @state() + private _filteredOptions: Array = []; + + @query('#input') + protected _input?: UUIComboboxElement; + + /** + * + */ + constructor() { + super(); + this._filteredOptions = this.options; + } + + public override async focus() { + await this.updateComplete; + this._input?.focus(); + } + + #onSearch(event: UUIComboboxEvent) { + const searchTerm = (event.currentTarget as UUIComboboxElement)?.search; + this._filteredOptions = this.options.filter( + (option) => new RegExp(searchTerm, 'i').test(option.name) || option.offset === searchTerm, + ); + } + + #onChange(event: UUIComboboxEvent) { + this.value = ((event.currentTarget as UUIComboboxElement)?.value as string) ?? ''; + this.dispatchEvent(new UmbChangeEvent()); + } + + #renderTimeZoneOption = (option: Option) => + html` + ${option.name} + `; + + override render() { + return html` + + ${until(repeat(this._filteredOptions, this.#renderTimeZoneOption))} + + `; + } + + static override styles = [ + css` + #input { + width: 100%; + } + `, + ]; +} + +export default UmbInputTimeZonePickerElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-input-time-zone-picker': UmbInputTimeZonePickerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/input-time-zone.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/input-time-zone.element.ts new file mode 100644 index 0000000000..ed80e25b4b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/input-time-zone.element.ts @@ -0,0 +1,272 @@ +import type { UmbInputTimeZonePickerElement, UmbTimeZoneOption } from './input-time-zone-picker.element.js'; +import { + html, + customElement, + property, + css, + repeat, + nothing, + when, + state, + ref, +} from '@umbraco-cms/backoffice/external/lit'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { getTimeZoneList, getTimeZoneOffset } from '@umbraco-cms/backoffice/utils'; +import { DateTime } from '@umbraco-cms/backoffice/external/luxon'; + +/** + * @element umb-input-time-zone + */ +@customElement('umb-input-time-zone') +export class UmbInputTimeZoneElement extends UmbFormControlMixin, typeof UmbLitElement, Array>( + UmbLitElement, + [], +) { + #sorter = new UmbSorterController(this, { + getUniqueOfElement: (element) => { + return element.getAttribute('data-sort-entry-id'); + }, + getUniqueOfModel: (modelEntry: string) => { + return modelEntry; + }, + identifier: 'Umb.SorterIdentifier.TimeZone', + itemSelector: 'umb-input-time-zone-item', + containerSelector: '#sorter-wrapper', + onChange: ({ model }) => { + this.value = model; + this.dispatchEvent(new UmbChangeEvent()); + }, + }); + + /** + * This is a minimum amount of selected items in this input. + * @type {number} + * @attr + */ + @property({ type: Number }) + public set min(value) { + this.#min = value; + } + public get min() { + if (this.required && this.#min < 1) { + return 1; + } + return this.#min; + } + #min = 0; + + /** + * This is a maximum amount of selected items in this input. + * @type {number} + * @attr + */ + @property({ type: Number }) + max?: number; + + /** + * Disables the input + * @type {boolean} + * @attr + * @default + */ + @property({ type: Boolean, reflect: true }) + public set disabled(value) { + this.#disabled = value; + if (value) { + this.#sorter.disable(); + } + } + public get disabled() { + return this.#disabled; + } + #disabled = false; + + /** + * Makes the input readonly + * @type {boolean} + * @attr + * @default + */ + @property({ type: Boolean, reflect: true }) + public set readonly(value) { + this.#readonly = value; + if (value) { + this.#sorter.disable(); + } + } + public get readonly() { + return this.#readonly; + } + #readonly = false; + + /** + * Sets the input to required, meaning validation will fail if the value is empty. + * @type {boolean} + */ + @property({ type: Boolean }) + required?: boolean; + + @property({ type: Array, reflect: false }) + override set value(value: Array) { + super.value = [...new Set(value.filter((v) => !!v))]; + this.#sorter.setModel(this.value); + } + + override get value(): Array { + return super.value; + } + + @state() + protected _timeZonePickerValue = ''; + + #timeZonePicker?: UmbInputTimeZonePickerElement; + #timeZoneList: Array = []; + + constructor() { + super(); + const now = DateTime.now(); + this.#timeZoneList = getTimeZoneList(undefined).map((tz) => ({ + ...tz, + offset: getTimeZoneOffset(tz.value, now), // Format offset as string + })); + + this.addValidator( + 'rangeUnderflow', + () => this.localize.term('validation_entriesShort', this.min, this.min - this.value.length), + () => this.value.length < this.min, + ); + + this.addValidator( + 'rangeOverflow', + () => this.localize.term('validation_entriesExceed', this.max, this.value.length - (this.max || 0)), + () => !!this.max && this.value.length > this.max, + ); + } + + protected override getFormElement() { + return undefined; + } + + #onAdd() { + if (this.#timeZonePicker) { + this.value = [...this.value, this.#timeZonePicker.value]; + this.#timeZonePicker.value = ''; + this._timeZonePickerValue = ''; + } + this.pristine = false; + this.dispatchEvent(new UmbChangeEvent()); + } + + #onDelete(itemIndex: number) { + this.value = this.value.filter((_item, index) => index !== itemIndex); + this.pristine = false; + this.dispatchEvent(new UmbChangeEvent()); + } + + override render() { + return html`
${this.#renderSelectedItems()}
+ ${this.#renderAddTimeZone()}`; + } + + #renderSelectedItems() { + return html` + ${repeat( + this.value, + (item) => item, + (item, index) => html` + this.#onDelete(index)}> + + `, + )} + `; + } + + #renderAddTimeZone() { + if (this.disabled || this.readonly) return nothing; + return html` +
+ !this.value.includes(tz.value))} + @change=${(event: UmbChangeEvent) => { + const target = event.target as UmbInputTimeZonePickerElement; + this._timeZonePickerValue = target?.value; + }} + ?disabled=${this.disabled} + ?readonly=${this.readonly} + ${ref(this.#onTimeZonePickerRefChanged)}> + + ${when( + !this.readonly, + () => html` + + + + `, + )} +
+ `; + } + + #onTimeZonePickerRefChanged(input?: Element) { + if (this.#timeZonePicker) { + this.removeFormControlElement(this.#timeZonePicker); + } + this.#timeZonePicker = input as UmbInputTimeZonePickerElement | undefined; + if (this.#timeZonePicker) { + this.addFormControlElement(this.#timeZonePicker); + } + } + + static override styles = [ + css` + #add-time-zone { + display: flex; + margin-bottom: var(--uui-size-space-3); + gap: var(--uui-size-space-1); + } + + #time-zone-picker { + width: 100%; + display: inline-flex; + --uui-input-height: var(--uui-size-12); + } + + .--umb-sorter-placeholder { + position: relative; + visibility: hidden; + } + .--umb-sorter-placeholder::after { + content: ''; + position: absolute; + inset: 0px; + border-radius: var(--uui-border-radius); + border: 1px dashed var(--uui-color-divider-emphasis); + } + `, + ]; +} + +export default UmbInputTimeZoneElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-input-time-zone': UmbInputTimeZoneElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/date/date.timezone.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/date/date.timezone.ts new file mode 100644 index 0000000000..00eb69b387 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/date/date.timezone.ts @@ -0,0 +1,85 @@ +import type { DateTime } from '@umbraco-cms/backoffice/external/luxon'; + +export interface UmbTimeZone { + value: string; + name: string; +} + +/** + * Retrieves a list of supported time zones in the browser. + * @param {Array} [filter] - An optional array of time zone identifiers to filter the result on. + * @returns {Array} An array of objects containing time zone values and names. + */ +export function getTimeZoneList(filter: Array | undefined = undefined): Array { + if (filter) { + return filter.map((tz) => ({ + value: tz, + name: getTimeZoneName(tz), + })); + } + + const timeZones = Intl.supportedValuesOf('timeZone') + // Exclude offset time zones, e.g. 'Etc/GMT+2', as they are not consistent between browsers + .filter((value) => value !== 'UTC' && !value.startsWith('Etc/')); + + // Add UTC to the top of the list + timeZones.unshift('UTC'); + + return timeZones.map((tz) => ({ + value: tz, + name: getTimeZoneName(tz), + })); +} + +/** + * Retrieves the client's time zone information. + * @param {DateTime} [selectedDate] - An optional Luxon DateTime object to format the offset of the time zone. + * @returns {UmbTimeZone} An object containing the client's time zone name and value. + */ +export function getClientTimeZone(): UmbTimeZone { + const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return { + value: clientTimeZone, + name: getTimeZoneName(clientTimeZone), + }; +} + +/** + * Returns the time zone offset for a given time zone ID and date. + * @param timeZoneId - The time zone identifier (e.g., 'America/New_York'). + * @param date - The Luxon DateTime object for which to get the offset. + * @returns {string} The time zone offset + */ +export function getTimeZoneOffset(timeZoneId: string, date: DateTime): string { + return date.setZone(timeZoneId).toFormat('Z'); +} + +/** + * Returns the browser's time zone name in a user-friendly format. + * @param {string} timeZoneId - The time zone identifier. + * @returns {string} A formatted time zone name. + */ +export function getTimeZoneName(timeZoneId: string) { + if (timeZoneId === 'UTC') { + return 'Coordinated Universal Time (UTC)'; + } + + return timeZoneId.replaceAll('_', ' '); +} + +/** + * Checks if two time zone identifiers are equivalent. + * This function compares the resolved time zone names to determine if they are equivalent. + * @param {string} tz1 - The first time zone identifier. + * @param {string} tz2 - The second time zone identifier. + * @returns {boolean} True if the time zones are equivalent, false otherwise. + */ +export function isEquivalentTimeZone(tz1: string, tz2: string): boolean { + if (tz1 === tz2) { + return true; + } + + const tz1Name = new Intl.DateTimeFormat(undefined, { timeZone: tz1 }).resolvedOptions().timeZone; + const tz2Name = new Intl.DateTimeFormat(undefined, { timeZone: tz2 }).resolvedOptions().timeZone; + return tz1Name === tz2Name; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/date/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/date/index.ts new file mode 100644 index 0000000000..75529f7da3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/date/index.ts @@ -0,0 +1 @@ +export * from './date.timezone.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts index 1fbed7af00..33deb40984 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts @@ -1,5 +1,6 @@ export * from './array/index.js'; export * from './bytes/bytes.function.js'; +export * from './date/index.js'; export * from './debounce/debounce.function.js'; export * from './deprecation/index.js'; export * from './diff/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/Umbraco.DateOnly.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/Umbraco.DateOnly.ts new file mode 100644 index 0000000000..ba8e29f0d1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/Umbraco.DateOnly.ts @@ -0,0 +1,10 @@ +import type { ManifestPropertyEditorSchema } from '@umbraco-cms/backoffice/property-editor'; + +export const manifest: ManifestPropertyEditorSchema = { + type: 'propertyEditorSchema', + name: 'Date Only', + alias: 'Umbraco.DateOnly', + meta: { + defaultPropertyEditorUiAlias: 'Umb.PropertyEditorUi.DateOnlyPicker', + }, +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/manifests.ts new file mode 100644 index 0000000000..c42c8a78e8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/manifests.ts @@ -0,0 +1,18 @@ +import { manifest as schemaManifest } from './Umbraco.DateOnly.js'; + +export const manifests: Array = [ + { + type: 'propertyEditorUi', + alias: 'Umb.PropertyEditorUi.DateOnlyPicker', + name: 'Date Only Picker Property Editor UI', + element: () => import('./property-editor-ui-date-only-picker.element.js'), + meta: { + label: 'Date Only', + propertyEditorSchemaAlias: 'Umbraco.DateOnly', + icon: 'icon-calendar-alt', + group: 'date', + supportsReadOnly: true, + }, + }, + schemaManifest, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/property-editor-ui-date-only-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/property-editor-ui-date-only-picker.element.ts new file mode 100644 index 0000000000..8f7e4ec538 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/property-editor-ui-date-only-picker.element.ts @@ -0,0 +1,20 @@ +import { UmbPropertyEditorUiDateTimePickerElementBase } from '../property-editor-ui-date-time-picker-base.js'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; + +/** + * @element umb-property-editor-ui-date-only-picker + */ +@customElement('umb-property-editor-ui-date-only-picker') +export class UmbPropertyEditorUIDateOnlyPickerElement extends UmbPropertyEditorUiDateTimePickerElementBase { + constructor() { + super('date', false); + } +} + +export default UmbPropertyEditorUIDateOnlyPickerElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-property-editor-ui-date-only-picker': UmbPropertyEditorUIDateOnlyPickerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/Umbraco.DateTimeUnspecified.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/Umbraco.DateTimeUnspecified.ts new file mode 100644 index 0000000000..d237b807df --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/Umbraco.DateTimeUnspecified.ts @@ -0,0 +1,10 @@ +import type { ManifestPropertyEditorSchema } from '@umbraco-cms/backoffice/property-editor'; + +export const manifest: ManifestPropertyEditorSchema = { + type: 'propertyEditorSchema', + name: 'Date Time (unspecified)', + alias: 'Umbraco.DateTimeUnspecified', + meta: { + defaultPropertyEditorUiAlias: 'Umb.PropertyEditorUi.DateTimePicker', + }, +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/manifests.ts new file mode 100644 index 0000000000..af73ef37f6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/manifests.ts @@ -0,0 +1,42 @@ +import { manifest as schemaManifest } from './Umbraco.DateTimeUnspecified.js'; + +export const manifests: Array = [ + { + type: 'propertyEditorUi', + alias: 'Umb.PropertyEditorUi.DateTimePicker', + name: 'Date Time Picker Property Editor UI', + element: () => import('./property-editor-ui-date-time-picker.element.js'), + meta: { + label: 'Date Time (unspecified)', + propertyEditorSchemaAlias: 'Umbraco.DateTimeUnspecified', + icon: 'icon-calendar-alt', + group: 'date', + supportsReadOnly: true, + settings: { + properties: [ + { + alias: 'timeFormat', + label: '#dateTimePicker_config_timeFormat', + propertyEditorUiAlias: 'Umb.PropertyEditorUi.RadioButtonList', + config: [ + { + alias: 'items', + value: [ + { name: 'HH:mm', value: 'HH:mm' }, + { name: 'HH:mm:ss', value: 'HH:mm:ss' }, + ], + }, + ], + }, + ], + defaultData: [ + { + alias: 'timeFormat', + value: 'HH:mm', + }, + ], + }, + }, + }, + schemaManifest, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/property-editor-ui-date-time-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/property-editor-ui-date-time-picker.element.ts new file mode 100644 index 0000000000..d3a9bd4c24 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/property-editor-ui-date-time-picker.element.ts @@ -0,0 +1,20 @@ +import { UmbPropertyEditorUiDateTimePickerElementBase } from '../property-editor-ui-date-time-picker-base.js'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; + +/** + * @element umb-property-editor-ui-date-time-picker + */ +@customElement('umb-property-editor-ui-date-time-picker') +export class UmbPropertyEditorUIDateTimePickerElement extends UmbPropertyEditorUiDateTimePickerElementBase { + constructor() { + super('datetime-local', false); + } +} + +export default UmbPropertyEditorUIDateTimePickerElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-property-editor-ui-date-time-picker': UmbPropertyEditorUIDateTimePickerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/Umbraco.DateTimeWithTimeZone.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/Umbraco.DateTimeWithTimeZone.ts new file mode 100644 index 0000000000..03fa0988c1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/Umbraco.DateTimeWithTimeZone.ts @@ -0,0 +1,10 @@ +import type { ManifestPropertyEditorSchema } from '@umbraco-cms/backoffice/property-editor'; + +export const manifest: ManifestPropertyEditorSchema = { + type: 'propertyEditorSchema', + name: 'Date Time (with time zone)', + alias: 'Umbraco.DateTimeWithTimeZone', + meta: { + defaultPropertyEditorUiAlias: 'Umb.PropertyEditorUi.DateTimeWithTimeZonePicker', + }, +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/manifests.ts new file mode 100644 index 0000000000..d514e0b9eb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/manifests.ts @@ -0,0 +1,56 @@ +import { manifest as schemaManifest } from './Umbraco.DateTimeWithTimeZone.js'; + +export const manifests: Array = [ + { + type: 'propertyEditorUi', + alias: 'Umb.PropertyEditorUi.DateTimeWithTimeZonePicker', + name: 'Date Time with Time Zone Picker Property Editor UI', + element: () => import('./property-editor-ui-date-time-with-time-zone-picker.element.js'), + meta: { + label: 'Date Time (with time zone)', + propertyEditorSchemaAlias: 'Umbraco.DateTimeWithTimeZone', + icon: 'icon-calendar-alt', + group: 'date', + supportsReadOnly: true, + settings: { + properties: [ + { + alias: 'timeFormat', + label: '#dateTimePicker_config_timeFormat', + propertyEditorUiAlias: 'Umb.PropertyEditorUi.RadioButtonList', + config: [ + { + alias: 'items', + value: [ + { name: 'HH:mm', value: 'HH:mm' }, + { name: 'HH:mm:ss', value: 'HH:mm:ss' }, + ], + }, + ], + }, + { + alias: 'timeZones', + label: '#dateTimePicker_config_timeZones', + description: '{#dateTimePicker_config_timeZones_description}', + propertyEditorUiAlias: 'Umb.PropertyEditorUi.TimeZonePicker', + config: [], + }, + ], + defaultData: [ + { + alias: 'timeFormat', + value: 'HH:mm', + }, + { + alias: 'timeZones', + value: { + mode: 'all', + timeZones: [], + }, + }, + ], + }, + }, + }, + schemaManifest, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/property-editor-ui-date-time-with-time-zone-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/property-editor-ui-date-time-with-time-zone-picker.element.ts new file mode 100644 index 0000000000..f87c7144b2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/property-editor-ui-date-time-with-time-zone-picker.element.ts @@ -0,0 +1,20 @@ +import { UmbPropertyEditorUiDateTimePickerElementBase } from '../property-editor-ui-date-time-picker-base.js'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; + +/** + * @element umb-property-editor-ui-date-time-with-time-zone-picker + */ +@customElement('umb-property-editor-ui-date-time-with-time-zone-picker') +export class UmbPropertyEditorUIDateTimeWithTimeZonePickerElement extends UmbPropertyEditorUiDateTimePickerElementBase { + constructor() { + super('datetime-local', true); + } +} + +export default UmbPropertyEditorUIDateTimeWithTimeZonePickerElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-property-editor-ui-date-time-with-time-zone-picker': UmbPropertyEditorUIDateTimeWithTimeZonePickerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/manifests.ts new file mode 100644 index 0000000000..ab7ceaa4d9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/manifests.ts @@ -0,0 +1,11 @@ +import { manifests as dateTimeManifests } from './date-time-picker/manifests.js'; +import { manifests as dateTimeWithTimeZoneManifests } from './date-time-with-time-zone-picker/manifests.js'; +import { manifests as dateOnlyManifests } from './date-only-picker/manifests.js'; +import { manifests as timeOnlyManifests } from './time-only-picker/manifests.js'; + +export const manifests: Array = [ + ...dateTimeManifests, + ...dateTimeWithTimeZoneManifests, + ...dateOnlyManifests, + ...timeOnlyManifests, +]; 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 new file mode 100644 index 0000000000..8c0b1ce9aa --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts @@ -0,0 +1,446 @@ +import type { UmbTimeZonePickerValue } from '../time-zone-picker/property-editor-ui-time-zone-picker.element.js'; +import type { InputDateType, UmbInputDateElement } from '@umbraco-cms/backoffice/components'; +import { css, html, nothing, property, repeat, state, until } from '@umbraco-cms/backoffice/external/lit'; +import { DateTime } from '@umbraco-cms/backoffice/external/luxon'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { + UmbPropertyEditorConfigCollection, + UmbPropertyEditorUiElement, +} from '@umbraco-cms/backoffice/property-editor'; +import { + getClientTimeZone, + getTimeZoneList, + getTimeZoneOffset, + isEquivalentTimeZone, + type UmbTimeZone, +} from '@umbraco-cms/backoffice/utils'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +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; +} + +interface UmbTimeZonePickerOption extends UmbTimeZone { + offset: string; + invalid: boolean; +} + +export abstract class UmbPropertyEditorUiDateTimePickerElementBase + extends UmbFormControlMixin(UmbLitElement) + implements UmbPropertyEditorUiElement +{ + private _timeZoneOptions: Array = []; + private _clientTimeZone: UmbTimeZone | undefined; + + @property({ type: Boolean, reflect: true }) + readonly = false; + + @property({ type: Boolean }) + mandatory?: boolean; + + @property({ type: String }) + mandatoryMessage?: string | undefined; + + @state() + protected _dateInputType: InputDateType = 'datetime-local'; + + @state() + protected _dateInputFormat: string = 'yyyy-MM-dd HH:mm:ss'; + + @state() + private _dateInputStep: number = 1; + + @state() + private _selectedDate?: DateTime; + + @state() + private _datePickerValue: string = ''; + + @state() + private _filteredTimeZoneOptions: Array = []; + + @state() + protected _displayTimeZone: boolean = true; + + @state() + private _selectedTimeZone: string | undefined; + + constructor(dateInputType: InputDateType, displayTimeZone: boolean) { + super(); + + this._dateInputType = dateInputType; + switch (dateInputType) { + case 'date': + this._dateInputFormat = 'yyyy-MM-dd'; + break; + case 'time': + this._dateInputFormat = 'HH:mm:ss'; + break; + } + this._displayTimeZone = displayTimeZone; + + this.addValidator( + 'customError', + () => this.localize.term('dateTimePicker_emptyDate'), + () => { + return !!this.mandatory && !this.value?.date; + }, + ); + + this.addValidator( + 'customError', + () => this.localize.term('dateTimePicker_emptyTimeZone'), + () => { + return !!this.value?.date && this._displayTimeZone && !this.value.timeZone; + }, + ); + + this.addValidator( + 'customError', + () => this.localize.term('dateTimePicker_invalidTimeZone'), + () => { + return ( + this._displayTimeZone && + !!this.value?.timeZone && + !this._timeZoneOptions.some((opt) => opt.value === this.value?.timeZone && !opt.invalid) + ); + }, + ); + } + + public set config(config: UmbPropertyEditorConfigCollection | undefined) { + if (!config) return; + + const timeFormat = config.getValueByAlias('timeFormat'); + let timeZonePickerConfig: UmbTimeZonePickerValue | undefined = undefined; + + if (this._displayTimeZone) { + timeZonePickerConfig = config.getValueByAlias('timeZones'); + } + this.#setTimeInputStep(timeFormat); + this.#prefillValue(timeZonePickerConfig); + } + + #prefillValue(timeZonePickerConfig: UmbTimeZonePickerValue | undefined) { + const date = this.value?.date; + const zone = this.value?.timeZone; + + if (!date) { + if (timeZonePickerConfig) { + // If the date is not provided, we prefill the time zones using the current date (used to retrieve the offsets) + this.#prefillTimeZones(timeZonePickerConfig, DateTime.now()); + } + return; + } + + // Load the date from the value + const dateTime = DateTime.fromISO(date, { zone: zone ?? 'UTC' }); // If no zone is provided, we default to UTC. + if (!dateTime.isValid) { + if (timeZonePickerConfig) { + // If the date is invalid, we prefill the time zones using the current date (used to retrieve the offsets) + this.#prefillTimeZones(timeZonePickerConfig, DateTime.now()); + } + console.warn(`[UmbPropertyEditorUIDateTimePickerElement] Invalid date format: ${date}`); + return; + } + + this._selectedDate = dateTime; + this._datePickerValue = dateTime.toFormat(this._dateInputFormat); + + if (timeZonePickerConfig) { + this.#prefillTimeZones(timeZonePickerConfig, dateTime); + } + } + + #prefillTimeZones(config: UmbTimeZonePickerValue | undefined, selectedDate: DateTime | undefined) { + // Retrieve the time zones from the config + this._clientTimeZone = getClientTimeZone(); + + // Retrieve the time zones from the config + const dateToCalculateOffset = selectedDate ?? DateTime.now(); + switch (config?.mode) { + case 'all': + this._timeZoneOptions = this._filteredTimeZoneOptions = getTimeZoneList(undefined).map((tz) => ({ + ...tz, + offset: getTimeZoneOffset(tz.value, dateToCalculateOffset), + invalid: false, + })); + break; + case 'local': { + this._timeZoneOptions = this._filteredTimeZoneOptions = [this._clientTimeZone].map((tz) => ({ + ...tz, + offset: getTimeZoneOffset(tz.value, dateToCalculateOffset), + invalid: false, + })); + break; + } + case 'custom': { + this._timeZoneOptions = this._filteredTimeZoneOptions = getTimeZoneList(config.timeZones).map((tz) => ({ + ...tz, + offset: getTimeZoneOffset(tz.value, dateToCalculateOffset), + invalid: false, + })); + const selectedTimeZone = this.value?.timeZone; + if ( + selectedTimeZone && + !this._timeZoneOptions.some((opt) => isEquivalentTimeZone(opt.value, selectedTimeZone)) + ) { + // If the selected time zone is not in the list, we add it to the options + const customTimeZone: UmbTimeZonePickerOption = { + value: selectedTimeZone, + name: selectedTimeZone, + offset: getTimeZoneOffset(selectedTimeZone, dateToCalculateOffset), + invalid: true, // Mark as invalid, as it is not in the list of supported time zones + }; + this._timeZoneOptions.push(customTimeZone); + } + break; + } + default: + return; + } + + this.#preselectTimeZone(); + } + + #preselectTimeZone() { + // Check whether there is a time zone in the value (stored previously) + const selectedTimezone = this.value?.timeZone; + if (selectedTimezone) { + const pickedTimeZone = this._timeZoneOptions.find( + // A time zone name can be different in different browsers, so we need extra logic to match the client name with the options + (option) => isEquivalentTimeZone(option.value, selectedTimezone), + ); + if (pickedTimeZone) { + this._selectedTimeZone = pickedTimeZone.value; + return; + } + } else if (this.value?.date) { + return; // If there is a date but no time zone, we don't preselect anything + } + + // Check if we can pre-select the client time zone + const clientTimeZone = this._clientTimeZone; + const clientTimeZoneOpt = + clientTimeZone && + this._timeZoneOptions.find( + // A time zone name can be different in different browsers, so we need extra logic to match the client name with the options + (option) => isEquivalentTimeZone(option.value, clientTimeZone.value), + ); + if (clientTimeZoneOpt) { + this._selectedTimeZone = clientTimeZoneOpt.value; + if (this._selectedDate) { + this._selectedDate = this._selectedDate.setZone(clientTimeZone.value); + this._datePickerValue = this._selectedDate.toFormat(this._dateInputFormat); + } + return; + } + + // If no time zone was selected still, we can default to the first option + const firstOption = this._timeZoneOptions[0]?.value; + this._selectedTimeZone = firstOption; + if (this._selectedDate) { + this._selectedDate = this._selectedDate.setZone(firstOption); + this._datePickerValue = this._selectedDate.toFormat(this._dateInputFormat); + } + } + + #setTimeInputStep(timeFormat: string | undefined) { + switch (timeFormat) { + case 'HH:mm': + this._dateInputStep = 60; // 1 hour + break; + case 'HH:mm:ss': + this._dateInputStep = 1; // 1 second + break; + default: + this._dateInputStep = 1; + break; + } + } + + #onValueChange(event: CustomEvent & { target: UmbInputDateElement }) { + const value = event.target.value.toString(); + const newPickerValue = value.replace('T', ' '); + if (newPickerValue === this._datePickerValue) { + return; + } + + if (!newPickerValue) { + this._datePickerValue = ''; + this.value = undefined; + this._selectedDate = undefined; + this.dispatchEvent(new UmbChangeEvent()); + return; + } + + this._datePickerValue = newPickerValue; + this.#updateValue(value, true); + } + + #onTimeZoneChange(event: UUIComboboxEvent) { + const timeZoneValue = (event.target as UUIComboboxElement).value.toString(); + if (timeZoneValue === this._selectedTimeZone) { + return; // No change in time zone selection + } + + this._selectedTimeZone = timeZoneValue; + + if (!this._selectedTimeZone) { + if (this.value?.date) { + this.value = { date: this.value.date, timeZone: undefined }; + } else { + this.value = undefined; + } + this.dispatchEvent(new UmbChangeEvent()); + return; + } + + if (!this._selectedDate) { + return; + } + + this.#updateValue(this._selectedDate.toISO({ includeOffset: false }) || ''); + } + + #updateValue(date: string, updateOffsets = false) { + // Try to parse the date with the selected time zone + const newDate = DateTime.fromISO(date, { zone: this._selectedTimeZone ?? 'UTC' }); + + // If the date is invalid, we reset the value + if (!newDate.isValid) { + this.value = undefined; + this._selectedDate = undefined; + this.dispatchEvent(new UmbChangeEvent()); + return; + } + + this._selectedDate = newDate; + this.value = { + date: this.#getCurrentDateValue(), + timeZone: this._selectedTimeZone, + }; + + 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; + } + this.dispatchEvent(new UmbChangeEvent()); + } + + #getCurrentDateValue(): string | undefined { + switch (this._dateInputType) { + case 'date': + return this._selectedDate?.toISODate() ?? undefined; + case 'time': + return this._selectedDate?.toISOTime({ includeOffset: false }) ?? undefined; + default: + return this._selectedDate?.toISO({ includeOffset: !!this._selectedTimeZone }) ?? undefined; + } + } + + #onTimeZoneSearch(event: UUIComboboxEvent) { + const searchTerm = (event.target as UUIComboboxElement)?.search; + this._filteredTimeZoneOptions = this._timeZoneOptions.filter( + (option) => option.name.toLowerCase().includes(searchTerm.toLowerCase()) || option.offset === searchTerm, + ); + } + + override render() { + return html` +
+ + + ${this.#renderTimeZones()} +
+ ${this.#renderTimeZoneInfo()} + `; + } + + #renderTimeZones() { + if (!this._displayTimeZone || this._timeZoneOptions.length === 0) { + return nothing; + } + + if (this._timeZoneOptions.length === 1) { + return html`${this._timeZoneOptions[0].name} ${this._timeZoneOptions[0].value === + this._clientTimeZone?.value + ? ` (${this.localize.term('dateTimePicker_local')})` + : nothing}`; + } + + return html` + + + ${until(repeat(this._filteredTimeZoneOptions, this.#renderTimeZoneOption))} + + + `; + } + + #renderTimeZoneOption = (option: UmbTimeZonePickerOption) => + html` + ${option.name + (option.invalid ? ` (${this.localize.term('validation_legacyOption')})` : '')} + `; + + #renderTimeZoneInfo() { + if ( + this._timeZoneOptions.length === 0 || + !this._selectedTimeZone || + !this._selectedDate || + this._selectedTimeZone === this._clientTimeZone?.value + ) { + return nothing; + } + + return html` ${this.localize.term( + 'dateTimePicker_differentTimeZoneLabel', + `UTC${this._selectedDate.toFormat('Z')}`, + this._selectedDate.toLocal().toFormat('ff'), + )}`; + } + + static override readonly styles = [ + css` + :host { + display: flex; + flex-direction: column; + } + .picker { + display: flex; + align-items: center; + gap: var(--uui-size-space-2); + } + .info { + color: var(--uui-color-text-alt); + font-size: var(--uui-type-small-size); + font-weight: normal; + } + .error { + color: var(--uui-color-invalid); + font-size: var(--uui-font-size-small); + } + `, + ]; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker.test.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker.test.ts new file mode 100644 index 0000000000..8cc2816813 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker.test.ts @@ -0,0 +1,79 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { type UmbTestRunnerWindow, defaultA11yConfig } from '@umbraco-cms/internal/test-utils'; +import UmbPropertyEditorUIDateTimePickerElement from './date-time-picker/property-editor-ui-date-time-picker.element.js'; +import UmbPropertyEditorUIDateOnlyPickerElement from './date-only-picker/property-editor-ui-date-only-picker.element.js'; +import UmbPropertyEditorUITimeOnlyPickerElement from './time-only-picker/property-editor-ui-time-only-picker.element.js'; +import UmbPropertyEditorUIDateTimeWithTimeZonePickerElement from './date-time-with-time-zone-picker/property-editor-ui-date-time-with-time-zone-picker.element.js'; + +describe('UmbPropertyEditorUIDateTimePickerElement', () => { + let element: UmbPropertyEditorUIDateTimePickerElement; + + beforeEach(async () => { + element = await fixture(html` `); + }); + + it('is defined with its own instance', () => { + expect(element).to.be.instanceOf(UmbPropertyEditorUIDateTimePickerElement); + }); + + if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) { + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); + }); + } +}); + +describe('UmbPropertyEditorUIDateTimeWithTimeZonePickerElement', () => { + let element: UmbPropertyEditorUIDateTimeWithTimeZonePickerElement; + + beforeEach(async () => { + element = await fixture(html` `); + }); + + it('is defined with its own instance', () => { + expect(element).to.be.instanceOf(UmbPropertyEditorUIDateTimeWithTimeZonePickerElement); + }); + + if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) { + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); + }); + } +}); + + +describe('UmbPropertyEditorUIDateOnlyPickerElement', () => { + let element: UmbPropertyEditorUIDateOnlyPickerElement; + + beforeEach(async () => { + element = await fixture(html` `); + }); + + it('is defined with its own instance', () => { + expect(element).to.be.instanceOf(UmbPropertyEditorUIDateOnlyPickerElement); + }); + + if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) { + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); + }); + } +}); + +describe('UmbPropertyEditorUITimeOnlyPickerElement', () => { + let element: UmbPropertyEditorUITimeOnlyPickerElement; + + beforeEach(async () => { + element = await fixture(html` `); + }); + + it('is defined with its own instance', () => { + expect(element).to.be.instanceOf(UmbPropertyEditorUITimeOnlyPickerElement); + }); + + if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) { + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); + }); + } +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/Umbraco.TimeOnly.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/Umbraco.TimeOnly.ts new file mode 100644 index 0000000000..3e6d6015d5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/Umbraco.TimeOnly.ts @@ -0,0 +1,10 @@ +import type { ManifestPropertyEditorSchema } from '@umbraco-cms/backoffice/property-editor'; + +export const manifest: ManifestPropertyEditorSchema = { + type: 'propertyEditorSchema', + name: 'Time Only', + alias: 'Umbraco.TimeOnly', + meta: { + defaultPropertyEditorUiAlias: 'Umb.PropertyEditorUi.TimeOnlyPicker', + }, +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/manifests.ts new file mode 100644 index 0000000000..b06aca52f2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/manifests.ts @@ -0,0 +1,42 @@ +import { manifest as schemaManifest } from './Umbraco.TimeOnly.js'; + +export const manifests: Array = [ + { + type: 'propertyEditorUi', + alias: 'Umb.PropertyEditorUi.TimeOnlyPicker', + name: 'Time Only Picker Property Editor UI', + element: () => import('./property-editor-ui-time-only-picker.element.js'), + meta: { + label: 'Time Only', + propertyEditorSchemaAlias: 'Umbraco.TimeOnly', + icon: 'icon-time', + group: 'date', + supportsReadOnly: true, + settings: { + properties: [ + { + alias: 'timeFormat', + label: '#dateTimePicker_config_timeFormat', + propertyEditorUiAlias: 'Umb.PropertyEditorUi.RadioButtonList', + config: [ + { + alias: 'items', + value: [ + { name: 'HH:mm', value: 'HH:mm' }, + { name: 'HH:mm:ss', value: 'HH:mm:ss' }, + ], + }, + ], + }, + ], + defaultData: [ + { + alias: 'timeFormat', + value: 'HH:mm', + }, + ], + }, + }, + }, + schemaManifest, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/property-editor-ui-time-only-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/property-editor-ui-time-only-picker.element.ts new file mode 100644 index 0000000000..76d7b4b011 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/property-editor-ui-time-only-picker.element.ts @@ -0,0 +1,20 @@ +import { UmbPropertyEditorUiDateTimePickerElementBase } from '../property-editor-ui-date-time-picker-base.js'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; + +/** + * @element umb-property-editor-ui-time-only-picker + */ +@customElement('umb-property-editor-ui-time-only-picker') +export class UmbPropertyEditorUITimeOnlyPickerElement extends UmbPropertyEditorUiDateTimePickerElementBase { + constructor() { + super('time', false); + } +} + +export default UmbPropertyEditorUITimeOnlyPickerElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-property-editor-ui-time-only-picker': UmbPropertyEditorUITimeOnlyPickerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/manifests.ts index a4c4dcf504..948ce907b6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/manifests.ts @@ -6,10 +6,12 @@ import { manifest as orderDirection } from './order-direction/manifests.js'; import { manifest as overlaySize } from './overlay-size/manifests.js'; import { manifest as select } from './select/manifests.js'; import { manifest as valueType } from './value-type/manifests.js'; +import { manifest as timeZonePicker } from './time-zone-picker/manifests.js'; import { manifests as checkboxListManifests } from './checkbox-list/manifests.js'; import { manifests as collectionManifests } from './collection/manifests.js'; import { manifests as colorPickerManifests } from './color-picker/manifests.js'; import { manifests as datePickerManifests } from './date-picker/manifests.js'; +import { manifests as dateTimeManifests } from './date-time/manifests.js'; import { manifests as dropdownManifests } from './dropdown/manifests.js'; import { manifests as eyeDropperManifests } from './eye-dropper/manifests.js'; import { manifests as iconPickerManifests } from './icon-picker/manifests.js'; @@ -29,6 +31,7 @@ export const manifests: Array = [ ...collectionManifests, ...colorPickerManifests, ...datePickerManifests, + ...dateTimeManifests, ...dropdownManifests, ...eyeDropperManifests, ...iconPickerManifests, @@ -50,4 +53,5 @@ export const manifests: Array = [ overlaySize, select, valueType, + timeZonePicker, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/manifests.ts new file mode 100644 index 0000000000..0a244d874b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/manifests.ts @@ -0,0 +1,13 @@ +import type { ManifestPropertyEditorUi } from '@umbraco-cms/backoffice/property-editor'; + +export const manifest: ManifestPropertyEditorUi = { + type: 'propertyEditorUi', + alias: 'Umb.PropertyEditorUi.TimeZonePicker', + name: 'Time Zone Picker Property Editor UI', + element: () => import('./property-editor-ui-time-zone-picker.element.js'), + meta: { + label: 'Time Zone Picker', + icon: 'icon-globe', + group: '', + }, +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/property-editor-ui-time-zone-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/property-editor-ui-time-zone-picker.element.ts new file mode 100644 index 0000000000..579735e3ee --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/property-editor-ui-time-zone-picker.element.ts @@ -0,0 +1,141 @@ +import { html, customElement, property, css, map, ref } from '@umbraco-cms/backoffice/external/lit'; +import type { + UmbPropertyEditorUiElement, + UmbPropertyEditorConfigCollection, +} from '@umbraco-cms/backoffice/property-editor'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import type { UUIRadioEvent } from '@umbraco-cms/backoffice/external/uui'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import type { UmbInputTimeZoneElement } from '@umbraco-cms/backoffice/components'; + +export interface UmbTimeZonePickerValue { + mode: string; + timeZones: Array; +} + +/** + * @element umb-property-editor-ui-time-zone-picker + */ +@customElement('umb-property-editor-ui-time-zone-picker') +export class UmbPropertyEditorUITimeZonePickerElement + extends UmbFormControlMixin(UmbLitElement) + implements UmbPropertyEditorUiElement +{ + private _supportedModes = ['all', 'local', 'custom']; + private _selectedTimeZones: Array = []; + + override set value(value: UmbTimeZonePickerValue | undefined) { + super.value = value; + this._selectedTimeZones = value?.timeZones ?? []; + } + + override get value(): UmbTimeZonePickerValue | undefined { + return super.value; + } + + @property({ type: Boolean, reflect: true }) + readonly: boolean = false; + + @property({ attribute: false }) + public config?: UmbPropertyEditorConfigCollection; + + #inputTimeZone?: UmbInputTimeZoneElement; + + constructor() { + super(); + + this.addValidator( + 'valueMissing', + () => UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, + () => !this.value?.mode || this._supportedModes.indexOf(this.value.mode) === -1, + ); + } + + #onModeInput(event: UUIRadioEvent) { + if (!this._supportedModes.includes(event.target.value)) throw new Error(`Unknown mode: ${event.target.value}`); + this.value = { + mode: event.target.value, + timeZones: this.#inputTimeZone?.value ? Array.from(this.#inputTimeZone?.value) : [], + }; + this.dispatchEvent(new UmbChangeEvent()); + } + + #onChange(event: UmbChangeEvent) { + const target = event.target as UmbInputTimeZoneElement; + const selectedOptions = target.value; + + if (this.value?.mode === 'custom') { + this.value = { mode: this.value.mode, timeZones: selectedOptions }; + } else { + this.value = { mode: this.value?.mode ?? 'all', timeZones: [] }; + } + + this.dispatchEvent(new UmbChangeEvent()); + } + + #inputTimeZoneRefChanged(input?: Element) { + if (this.#inputTimeZone) { + this.removeFormControlElement(this.#inputTimeZone); + } + this.#inputTimeZone = input as UmbInputTimeZoneElement | undefined; + if (this.#inputTimeZone) { + this.addFormControlElement(this.#inputTimeZone); + } + } + + override render() { + return html` + + ${map( + this._supportedModes, + (mode) => + html``, + )} + +
+ + +
+ `; + } + + static override readonly styles = [ + css` + :host { + display: grid; + gap: var(--uui-size-space-3); + } + + .timezone-picker { + display: flex; + flex-direction: row; + gap: var(--uui-size-space-6); + } + + .timezone-picker[hidden] { + display: none; + } + `, + ]; +} + +export default UmbPropertyEditorUITimeZonePickerElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-property-editor-ui-time-zone-picker': UmbPropertyEditorUITimeZonePickerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index 078dbfa93a..eebc431a10 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -157,6 +157,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/external/dompurify": ["./src/external/dompurify/index.ts"], "@umbraco-cms/backoffice/external/heximal-expressions": ["./src/external/heximal-expressions/index.ts"], "@umbraco-cms/backoffice/external/lit": ["./src/external/lit/index.ts"], + "@umbraco-cms/backoffice/external/luxon": ["./src/external/luxon/index.ts"], "@umbraco-cms/backoffice/external/marked": ["./src/external/marked/index.ts"], "@umbraco-cms/backoffice/external/monaco-editor": ["./src/external/monaco-editor/index.ts"], "@umbraco-cms/backoffice/external/openid": ["./src/external/openid/index.ts"], diff --git a/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs index 2767d26ce3..2a00680ab7 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs @@ -464,6 +464,7 @@ public class ContentBuilder content.SetValue("memberPicker", Udi.Create(Constants.UdiEntityType.Member, new Guid("9A50A448-59C0-4D42-8F93-4F1D55B0F47D")).ToString()); content.SetValue("multiUrlPicker", "[{\"name\":\"https://test.com\",\"url\":\"https://test.com\"}]"); content.SetValue("tags", "this,is,tags"); + content.SetValue("dateTimeWithTimeZone", "{\"date\":\"2025-01-22T18:33:01.0000000+01:00\",\"timeZone\":\"Europe/Copenhagen\"}"); return content; } diff --git a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs index 54e9090ddb..633604f1ba 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs @@ -531,6 +531,14 @@ public class ContentTypeBuilder .WithValueStorageType(ValueStorageType.Ntext) .WithSortOrder(20) .Done() + .AddPropertyType() + .WithAlias("dateTimeWithTimeZone") + .WithName("Date Time (with time zone)") + .WithDataTypeId(1055) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.DateTimeWithTimeZone) + .WithValueStorageType(ValueStorageType.Ntext) + .WithSortOrder(21) + .Done() .Done() .Build(); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs index 784638e594..0160d95fcf 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs @@ -2735,6 +2735,9 @@ internal sealed class ContentServiceTests : UmbracoIntegrationTestWithContent Assert.That(sut.GetValue("multiUrlPicker"), Is.EqualTo("[{\"name\":\"https://test.com\",\"url\":\"https://test.com\"}]")); Assert.That(sut.GetValue("tags"), Is.EqualTo("this,is,tags")); + Assert.That( + sut.GetValue("dateTimeWithTimeZone"), + Is.EqualTo("{\"date\":\"2025-01-22T18:33:01.0000000+01:00\",\"timeZone\":\"Europe/Copenhagen\"}")); } [Test] diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DataTypeDefinitionRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DataTypeDefinitionRepositoryTest.cs index 4ff77c86fa..7b2f3c3cc9 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DataTypeDefinitionRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DataTypeDefinitionRepositoryTest.cs @@ -250,7 +250,7 @@ internal sealed class DataTypeDefinitionRepositoryTest : UmbracoIntegrationTest Assert.That(dataTypeDefinitions, Is.Not.Null); Assert.That(dataTypeDefinitions.Any(), Is.True); Assert.That(dataTypeDefinitions.Any(x => x == null), Is.False); - Assert.That(dataTypeDefinitions.Length, Is.EqualTo(36)); + Assert.That(dataTypeDefinitions.Length, Is.EqualTo(37)); } } @@ -297,7 +297,7 @@ internal sealed class DataTypeDefinitionRepositoryTest : UmbracoIntegrationTest var count = DataTypeRepository.Count(query); // Assert - Assert.That(count, Is.EqualTo(4)); + Assert.That(count, Is.EqualTo(5)); } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs new file mode 100644 index 0000000000..7a1a5d0dac --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs @@ -0,0 +1,138 @@ +using System.Text.Json.Nodes; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.HybridCache; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class DateTimePropertyEditorTests : UmbracoIntegrationTest +{ + private IDataTypeService DataTypeService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IContentEditingService ContentEditingService => GetRequiredService(); + + private IContentPublishingService ContentPublishingService => GetRequiredService(); + + private IPublishedContentCache PublishedContentCache => GetRequiredService(); + + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + builder.AddNotificationHandler(); + builder.Services.AddUnique(); + } + + private static readonly object[] _sourceList1 = + [ + new object[] { Constants.PropertyEditors.Aliases.DateOnly, false, new DateOnly(2025, 1, 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) }, + ]; + + [TestCaseSource(nameof(_sourceList1))] + public async Task Returns_Correct_Type_Based_On_Configuration( + string editorAlias, + bool timeZone, + object expectedValue) + { + var dataType = new DataTypeBuilder() + .WithId(0) + .WithDatabaseType(ValueStorageType.Ntext) + .AddEditor() + .WithAlias(editorAlias) + .WithConfigurationEditor( + new DateTimeConfigurationEditor(IOHelper) + { + DefaultConfiguration = new Dictionary + { + ["timeFormat"] = "HH:mm", + ["timeZones"] = timeZone ? new { mode = "all" } : null, + }, + }) + .WithDefaultConfiguration( + new Dictionary + { + ["timeFormat"] = "HH:mm", + ["timeZones"] = timeZone ? new { mode = "all" } : null, + }) + .Done() + .Build(); + + var dataTypeCreateResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); + Assert.IsTrue(dataTypeCreateResult.Success); + + var contentType = new ContentTypeBuilder() + .WithAlias("contentType") + .WithName("Content Type") + .WithAllowAsRoot(true) + .AddPropertyGroup() + .WithAlias("content") + .WithName("Content") + .WithSupportsPublishing(true) + .AddPropertyType() + .WithAlias("dateTime") + .WithName("Date Time") + .WithDataTypeId(dataTypeCreateResult.Result.Id) + .Done() + .Done() + .Build(); + + var contentTypeCreateResult = await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + Assert.IsTrue(contentTypeCreateResult.Success); + + var content = new ContentEditingBuilder() + .WithContentTypeKey(contentType.Key) + .AddVariant() + .WithName("My Content") + .Done() + .AddProperty() + .WithAlias("dateTime") + .WithValue( + new JsonObject + { + ["date"] = "2025-01-22T18:33:01.0000000+00:00", + ["timeZone"] = "Europe/Copenhagen", + }) + .Done() + .Build(); + var createContentResult = await ContentEditingService.CreateAsync(content, Constants.Security.SuperUserKey); + Assert.IsTrue(createContentResult.Success); + Assert.IsNotNull(createContentResult.Result.Content); + var dateTimeProperty = createContentResult.Result.Content.Properties["dateTime"]; + Assert.IsNotNull(dateTimeProperty, "After content creation, the property should exist"); + Assert.IsNotNull(dateTimeProperty.GetValue(), "After content creation, the property value should not be null"); + + var publishResult = await ContentPublishingService.PublishBranchAsync( + createContentResult.Result.Content.Key, + [], + PublishBranchFilter.IncludeUnpublished, + Constants.Security.SuperUserKey, + false); + + Assert.IsTrue(publishResult.Success); + + var test = ((DocumentCache)PublishedContentCache).GetAtRoot(false); + var publishedContent = await PublishedContentCache.GetByIdAsync(createContentResult.Result.Content.Key, false); + Assert.IsNotNull(publishedContent); + + var value = publishedContent.GetProperty("dateTime")?.GetValue(); + Assert.IsNotNull(value); + Assert.AreEqual(expectedValue, value); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs index 4987432aea..253fcc2b4b 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs @@ -132,7 +132,7 @@ public class TypeLoaderTests public void GetDataEditors() { var types = _typeLoader.GetDataEditors(); - Assert.AreEqual(37, types.Count()); + Assert.AreEqual(41, types.Count()); } /// diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateOnlyPropertyEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateOnlyPropertyEditorTests.cs new file mode 100644 index 0000000000..1738df2590 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateOnlyPropertyEditorTests.cs @@ -0,0 +1,164 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.Models; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class DateOnlyPropertyEditorTests +{ + private static readonly IJsonSerializer _jsonSerializer = + new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + + private static readonly object[] _validateDateReceivedTestCases = + [ + new object[] { null, true }, + new object[] { JsonNode.Parse("{}"), false }, + new object[] { JsonNode.Parse("{\"test\": \"\"}"), false }, + new object[] { JsonNode.Parse("{\"date\": \"\"}"), false }, + new object[] { JsonNode.Parse("{\"date\": \"INVALID\"}"), false }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), true } + ]; + + [TestCaseSource(nameof(_validateDateReceivedTestCases))] + public void Validates_Date_Received(object? value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()).ToList(); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count); + + var validationResult = result.First(); + Assert.AreEqual("validation_invalidDate", validationResult.ErrorMessage); + } + } + + private static readonly object[] _dateOnlyParseValuesFromEditorTestCases = + [ + new object[] { null, null, null }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20\"}"), new DateTimeOffset(2025, 8, 20, 0, 0, 0, TimeSpan.Zero), null }, + ]; + + [TestCaseSource(nameof(_dateOnlyParseValuesFromEditorTestCases))] + public void Can_Parse_Values_From_Editor( + object? value, + DateTimeOffset? expectedDateTimeOffset, + string? expectedTimeZone) + { + var expectedJson = expectedDateTimeOffset is null ? null : _jsonSerializer.Serialize( + new DateTimeValueConverterBase.DateTimeDto + { + Date = expectedDateTimeOffset.Value, + TimeZone = expectedTimeZone, + }); + var result = CreateValueEditor().FromEditor( + new ContentPropertyData( + value, + new DateTimeConfiguration + { + TimeZones = null, + }), + null); + Assert.AreEqual(expectedJson, result); + } + + private static readonly object[][] _dateOnlyParseValuesToEditorTestCases = + [ + [null, null, null], + [0, null, new DateTimeEditorValue { Date = "2025-08-20", TimeZone = null }], + ]; + + [TestCaseSource(nameof(_dateOnlyParseValuesToEditorTestCases))] + public void Can_Parse_Values_To_Editor( + int? offset, + string? timeZone, + object? expectedResult) + { + var storedValue = offset is null + ? null + : new DateTimeValueConverterBase.DateTimeDto + { + Date = new DateTimeOffset(2025, 8, 20, 16, 30, 00, TimeSpan.FromHours(offset.Value)), + TimeZone = timeZone, + }; + var valueEditor = CreateValueEditor(timeZoneMode: null); + var storedValueJson = storedValue is null ? null : _jsonSerializer.Serialize(storedValue); + var result = valueEditor.ToEditor( + new Property(new PropertyType(Mock.Of(), "dataType", ValueStorageType.Ntext)) + { + Values = + [ + new Property.PropertyValue + { + EditedValue = storedValueJson, + PublishedValue = storedValueJson, + } + ], + }); + + if (expectedResult is null) + { + Assert.IsNull(result); + return; + } + + Assert.IsNotNull(result); + Assert.IsInstanceOf(result); + var apiModel = (DateTimeEditorValue)result; + Assert.AreEqual(((DateTimeEditorValue)expectedResult).Date, apiModel.Date); + Assert.AreEqual(((DateTimeEditorValue)expectedResult).TimeZone, apiModel.TimeZone); + } + + private DateTimePropertyEditorBase.DateTimeDataValueEditor CreateValueEditor( + DateTimeConfiguration.TimeZoneMode? timeZoneMode = null, + string[]? timeZones = null) + { + var localizedTextServiceMock = new Mock(); + localizedTextServiceMock.Setup(x => x.Localize( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns((string key, string alias, CultureInfo _, IDictionary _) => $"{key}_{alias}"); + var valueEditor = new DateTimePropertyEditorBase.DateTimeDataValueEditor( + Mock.Of(), + _jsonSerializer, + Mock.Of(), + new DataEditorAttribute(Constants.PropertyEditors.Aliases.DateOnly), + localizedTextServiceMock.Object, + Mock.Of>(), + dt => dt.Date.ToString("yyyy-MM-dd")) + { + ConfigurationObject = new DateTimeConfiguration + { + TimeZones = timeZoneMode is null + ? null + : new DateTimeConfiguration.TimeZonesConfiguration + { + Mode = timeZoneMode.Value, + TimeZones = timeZones?.ToList() ?? [], + }, + }, + }; + return valueEditor; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeUnspecifiedPropertyEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeUnspecifiedPropertyEditorTests.cs new file mode 100644 index 0000000000..726ea51157 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeUnspecifiedPropertyEditorTests.cs @@ -0,0 +1,167 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.Models; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class DateTimeUnspecifiedPropertyEditorTests +{ + private static readonly IJsonSerializer _jsonSerializer = + new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + + private static readonly object[] _validateDateReceivedTestCases = + [ + new object[] { null, true }, + new object[] { JsonNode.Parse("{}"), false }, + new object[] { JsonNode.Parse("{\"test\": \"\"}"), false }, + new object[] { JsonNode.Parse("{\"date\": \"\"}"), false }, + new object[] { JsonNode.Parse("{\"date\": \"INVALID\"}"), false }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), true } + ]; + + [TestCaseSource(nameof(_validateDateReceivedTestCases))] + public void Validates_Date_Received(object? value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()).ToList(); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count); + + var validationResult = result.First(); + Assert.AreEqual("validation_invalidDate", validationResult.ErrorMessage); + } + } + + private static readonly object[] _dateTimeUnspecifiedParseValuesFromEditorTestCases = + [ + new object[] { null, null, null }, + new object[] { JsonNode.Parse("{}"), null, null }, + new object[] { JsonNode.Parse("{\"INVALID\": \"\"}"), null, null }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T18:30:01\"}"), new DateTimeOffset(2025, 8, 20, 18, 30, 1, TimeSpan.Zero), null }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T18:30:01Z\"}"), new DateTimeOffset(2025, 8, 20, 18, 30, 1, TimeSpan.Zero), null }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T18:30:01-05:00\"}"), new DateTimeOffset(2025, 8, 20, 18, 30, 1, TimeSpan.FromHours(-5)), null }, + ]; + + [TestCaseSource(nameof(_dateTimeUnspecifiedParseValuesFromEditorTestCases))] + public void Can_Parse_Values_From_Editor( + object? value, + DateTimeOffset? expectedDateTimeOffset, + string? expectedTimeZone) + { + var expectedJson = expectedDateTimeOffset is null ? null : _jsonSerializer.Serialize( + new DateTimeValueConverterBase.DateTimeDto + { + Date = expectedDateTimeOffset.Value, + TimeZone = expectedTimeZone, + }); + var result = CreateValueEditor().FromEditor( + new ContentPropertyData( + value, + new DateTimeConfiguration + { + TimeZones = null, + }), + null); + Assert.AreEqual(expectedJson, result); + } + + private static readonly object[][] _dateTimeUnspecifiedParseValuesToEditorTestCases = + [ + [null, null], + [0, new DateTimeEditorValue { Date = "2025-08-20T16:30:00", TimeZone = null }], + [2, new DateTimeEditorValue { Date = "2025-08-20T16:30:00", TimeZone = null }], + ]; + + [TestCaseSource(nameof(_dateTimeUnspecifiedParseValuesToEditorTestCases))] + public void Can_Parse_Values_To_Editor( + int? offset, + object? expectedResult) + { + var storedValue = offset is null + ? null + : new DateTimeValueConverterBase.DateTimeDto + { + Date = new DateTimeOffset(2025, 8, 20, 16, 30, 00, TimeSpan.FromHours(offset.Value)), + }; + var valueEditor = CreateValueEditor(timeZoneMode: null); + var storedValueJson = storedValue is null ? null : _jsonSerializer.Serialize(storedValue); + var result = valueEditor.ToEditor( + new Property(new PropertyType(Mock.Of(), "dataType", ValueStorageType.Ntext)) + { + Values = + [ + new Property.PropertyValue + { + EditedValue = storedValueJson, + PublishedValue = storedValueJson, + } + ], + }); + + if (expectedResult is null) + { + Assert.IsNull(result); + return; + } + + Assert.IsNotNull(result); + Assert.IsInstanceOf(result); + var apiModel = (DateTimeEditorValue)result; + Assert.AreEqual(((DateTimeEditorValue)expectedResult).Date, apiModel.Date); + Assert.AreEqual(((DateTimeEditorValue)expectedResult).TimeZone, apiModel.TimeZone); + } + + private DateTimePropertyEditorBase.DateTimeDataValueEditor CreateValueEditor( + DateTimeConfiguration.TimeZoneMode? timeZoneMode = null, + string[]? timeZones = null) + { + var localizedTextServiceMock = new Mock(); + localizedTextServiceMock.Setup(x => x.Localize( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns((string key, string alias, CultureInfo _, IDictionary _) => $"{key}_{alias}"); + var valueEditor = new DateTimePropertyEditorBase.DateTimeDataValueEditor( + Mock.Of(), + _jsonSerializer, + Mock.Of(), + new DataEditorAttribute(Constants.PropertyEditors.Aliases.DateTimeUnspecified), + localizedTextServiceMock.Object, + Mock.Of>(), + dt => dt.Date.ToString("yyyy-MM-ddTHH:mm:ss")) + { + ConfigurationObject = new DateTimeConfiguration + { + TimeZones = timeZoneMode is null + ? null + : new DateTimeConfiguration.TimeZonesConfiguration + { + Mode = timeZoneMode.Value, + TimeZones = timeZones?.ToList() ?? [], + }, + }, + }; + return valueEditor; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeWithTimeZonePropertyEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeWithTimeZonePropertyEditorTests.cs new file mode 100644 index 0000000000..601cc8a472 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeWithTimeZonePropertyEditorTests.cs @@ -0,0 +1,205 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.Models; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class DateTimeWithTimeZonePropertyEditorTests +{ + private static readonly IJsonSerializer _jsonSerializer = + new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + + private static readonly object[] _validateDateReceivedTestCases = + [ + new object[] { null, true }, + new object[] { JsonNode.Parse("{}"), false }, + new object[] { JsonNode.Parse("{\"test\": \"\"}"), false }, + new object[] { JsonNode.Parse("{\"date\": \"\"}"), false }, + new object[] { JsonNode.Parse("{\"date\": \"INVALID\"}"), false }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), true } + ]; + + private static readonly object[] _sourceList2 = + [ + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), DateTimeConfiguration.TimeZoneMode.All, Array.Empty(), true }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), DateTimeConfiguration.TimeZoneMode.Local, Array.Empty(), true }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), DateTimeConfiguration.TimeZoneMode.Custom, new[] { "Europe/Copenhagen" }, false }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\", \"timeZone\": \"Europe/Copenhagen\"}"), DateTimeConfiguration.TimeZoneMode.All, Array.Empty(), true }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\", \"timeZone\": \"Europe/Copenhagen\"}"), DateTimeConfiguration.TimeZoneMode.Local, Array.Empty(), true }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\", \"timeZone\": \"Europe/Copenhagen\"}"), DateTimeConfiguration.TimeZoneMode.Custom, Array.Empty(), false }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\", \"timeZone\": \"Europe/Copenhagen\"}"), DateTimeConfiguration.TimeZoneMode.Custom, new[] { "Europe/Copenhagen" }, true }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\", \"timeZone\": \"Europe/Copenhagen\"}"), DateTimeConfiguration.TimeZoneMode.Custom, new[] { "Europe/Amsterdam", "Europe/Copenhagen" }, true }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\", \"timeZone\": \"Europe/Copenhagen\"}"), DateTimeConfiguration.TimeZoneMode.Custom, new[] { "Europe/Amsterdam" }, false }, + ]; + + [TestCaseSource(nameof(_sourceList2))] + public void Validates_TimeZone_Received( + object value, + DateTimeConfiguration.TimeZoneMode timeZoneMode, + string[] timeZones, + bool expectedSuccess) + { + var editor = CreateValueEditor(timeZoneMode: timeZoneMode, timeZones: timeZones); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()).ToList(); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count); + + var validationResult = result.First(); + Assert.AreEqual("validation_notOneOfOptions", validationResult.ErrorMessage); + } + } + + [TestCaseSource(nameof(_validateDateReceivedTestCases))] + public void Validates_Date_Received(object? value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()).ToList(); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count); + + var validationResult = result.First(); + Assert.AreEqual("validation_invalidDate", validationResult.ErrorMessage); + } + } + + private static readonly object[] _dateTimeWithTimeZoneParseValuesFromEditorTestCases = + [ + new object[] { null, null, null }, + new object[] { JsonNode.Parse("{}"), null, null }, + new object[] { JsonNode.Parse("{\"INVALID\": \"\"}"), null, null }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T18:30:01\"}"), new DateTimeOffset(2025, 8, 20, 18, 30, 1, TimeSpan.Zero), null }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T18:30:01Z\"}"), new DateTimeOffset(2025, 8, 20, 18, 30, 1, TimeSpan.Zero), null }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T18:30:01-05:00\"}"), new DateTimeOffset(2025, 8, 20, 18, 30, 1, TimeSpan.FromHours(-5)), null }, + ]; + + [TestCaseSource(nameof(_dateTimeWithTimeZoneParseValuesFromEditorTestCases))] + public void Can_Parse_Values_From_Editor( + object? value, + DateTimeOffset? expectedDateTimeOffset, + string? expectedTimeZone) + { + var expectedJson = expectedDateTimeOffset is null ? null : _jsonSerializer.Serialize( + new DateTimeValueConverterBase.DateTimeDto + { + Date = expectedDateTimeOffset.Value, + TimeZone = expectedTimeZone, + }); + var result = CreateValueEditor().FromEditor( + new ContentPropertyData( + value, + new DateTimeConfiguration + { + TimeZones = null, + }), + null); + Assert.AreEqual(expectedJson, result); + } + + private static readonly object[][] _dateTimeWithTimeZoneParseValuesToEditorTestCases = + [ + [null, null, DateTimeConfiguration.TimeZoneMode.All, null], + [0, null, DateTimeConfiguration.TimeZoneMode.All, new DateTimeEditorValue { Date = "2025-08-20T16:30:00+00:00", TimeZone = null }], + [0, null, DateTimeConfiguration.TimeZoneMode.Local, new DateTimeEditorValue { Date = "2025-08-20T16:30:00+00:00", TimeZone = null }], + [0, null, DateTimeConfiguration.TimeZoneMode.Custom, new DateTimeEditorValue { Date = "2025-08-20T16:30:00+00:00", TimeZone = null }], + [-5, "Europe/Copenhagen", DateTimeConfiguration.TimeZoneMode.All, new DateTimeEditorValue { Date = "2025-08-20T16:30:00-05:00", TimeZone = "Europe/Copenhagen" }], + ]; + + [TestCaseSource(nameof(_dateTimeWithTimeZoneParseValuesToEditorTestCases))] + public void Can_Parse_Values_To_Editor( + int? offset, + string? timeZone, + DateTimeConfiguration.TimeZoneMode timeZoneMode, + object? expectedResult) + { + var storedValue = offset is null + ? null + : new DateTimeValueConverterBase.DateTimeDto + { + Date = new DateTimeOffset(2025, 8, 20, 16, 30, 00, TimeSpan.FromHours(offset.Value)), + TimeZone = timeZone, + }; + var valueEditor = CreateValueEditor(timeZoneMode: timeZoneMode); + var storedValueJson = storedValue is null ? null : _jsonSerializer.Serialize(storedValue); + var result = valueEditor.ToEditor( + new Property(new PropertyType(Mock.Of(), "dataType", ValueStorageType.Ntext)) + { + Values = + [ + new Property.PropertyValue + { + EditedValue = storedValueJson, + PublishedValue = storedValueJson, + } + ], + }); + + if (expectedResult is null) + { + Assert.IsNull(result); + return; + } + + Assert.IsNotNull(result); + Assert.IsInstanceOf(result); + var apiModel = (DateTimeEditorValue)result; + Assert.AreEqual(((DateTimeEditorValue)expectedResult).Date, apiModel.Date); + Assert.AreEqual(((DateTimeEditorValue)expectedResult).TimeZone, apiModel.TimeZone); + } + + private DateTimePropertyEditorBase.DateTimeDataValueEditor CreateValueEditor( + DateTimeConfiguration.TimeZoneMode timeZoneMode = DateTimeConfiguration.TimeZoneMode.All, + string[]? timeZones = null) + { + var localizedTextServiceMock = new Mock(); + localizedTextServiceMock.Setup(x => x.Localize( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns((string key, string alias, CultureInfo _, IDictionary _) => $"{key}_{alias}"); + var valueEditor = new DateTimePropertyEditorBase.DateTimeDataValueEditor( + Mock.Of(), + _jsonSerializer, + Mock.Of(), + new DataEditorAttribute(Constants.PropertyEditors.Aliases.DateTimeWithTimeZone), + localizedTextServiceMock.Object, + Mock.Of>(), + dt => dt.Date.ToString("yyyy-MM-ddTHH:mm:sszzz")) + { + ConfigurationObject = new DateTimeConfiguration + { + TimeZones = new DateTimeConfiguration.TimeZonesConfiguration + { + Mode = timeZoneMode, + TimeZones = timeZones?.ToList() ?? [], + }, + }, + }; + return valueEditor; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/TimeOnlyPropertyEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/TimeOnlyPropertyEditorTests.cs new file mode 100644 index 0000000000..5cab5f51df --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/TimeOnlyPropertyEditorTests.cs @@ -0,0 +1,164 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.Models; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class TimeOnlyPropertyEditorTests +{ + private static readonly IJsonSerializer _jsonSerializer = + new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + + private static readonly object[] _validateDateReceivedTestCases = + [ + new object[] { null, true }, + new object[] { JsonNode.Parse("{}"), false }, + new object[] { JsonNode.Parse("{\"test\": \"\"}"), false }, + new object[] { JsonNode.Parse("{\"date\": \"\"}"), false }, + new object[] { JsonNode.Parse("{\"date\": \"INVALID\"}"), false }, + new object[] { JsonNode.Parse("{\"date\": \"2025-08-20T14:30:00\"}"), true } + ]; + + [TestCaseSource(nameof(_validateDateReceivedTestCases))] + public void Validates_Date_Received(object? value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()).ToList(); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count); + + var validationResult = result.First(); + Assert.AreEqual("validation_invalidDate", validationResult.ErrorMessage); + } + } + + private static readonly object[] _timeOnlyParseValuesFromEditorTestCases = + [ + new object[] { null, null, null }, + new object[] { JsonNode.Parse("{\"date\": \"16:34\"}"), new DateTimeOffset(1, 1, 1, 16, 34, 0, TimeSpan.Zero), null }, + ]; + + [TestCaseSource(nameof(_timeOnlyParseValuesFromEditorTestCases))] + public void Can_Parse_Values_From_Editor( + object? value, + DateTimeOffset? expectedDateTimeOffset, + string? expectedTimeZone) + { + var expectedJson = expectedDateTimeOffset is null ? null : _jsonSerializer.Serialize( + new DateTimeValueConverterBase.DateTimeDto + { + Date = expectedDateTimeOffset.Value, + TimeZone = expectedTimeZone, + }); + var result = CreateValueEditor().FromEditor( + new ContentPropertyData( + value, + new DateTimeConfiguration + { + TimeZones = null, + }), + null); + Assert.AreEqual(expectedJson, result); + } + + private static readonly object[][] _timeOnlyParseValuesToEditorTestCases = + [ + [null, null, null], + [0, null, new DateTimeEditorValue { Date = "16:30:00", TimeZone = null }], + ]; + + [TestCaseSource(nameof(_timeOnlyParseValuesToEditorTestCases))] + public void Can_Parse_Values_To_Editor( + int? offset, + string? timeZone, + object? expectedResult) + { + var storedValue = offset is null + ? null + : new DateTimeValueConverterBase.DateTimeDto + { + Date = new DateTimeOffset(2025, 8, 20, 16, 30, 00, TimeSpan.FromHours(offset.Value)), + TimeZone = timeZone, + }; + var valueEditor = CreateValueEditor(timeZoneMode: null); + var storedValueJson = storedValue is null ? null : _jsonSerializer.Serialize(storedValue); + var result = valueEditor.ToEditor( + new Property(new PropertyType(Mock.Of(), "dataType", ValueStorageType.Ntext)) + { + Values = + [ + new Property.PropertyValue + { + EditedValue = storedValueJson, + PublishedValue = storedValueJson, + } + ], + }); + + if (expectedResult is null) + { + Assert.IsNull(result); + return; + } + + Assert.IsNotNull(result); + Assert.IsInstanceOf(result); + var apiModel = (DateTimeEditorValue)result; + Assert.AreEqual(((DateTimeEditorValue)expectedResult).Date, apiModel.Date); + Assert.AreEqual(((DateTimeEditorValue)expectedResult).TimeZone, apiModel.TimeZone); + } + + private DateTimePropertyEditorBase.DateTimeDataValueEditor CreateValueEditor( + DateTimeConfiguration.TimeZoneMode? timeZoneMode = null, + string[]? timeZones = null) + { + var localizedTextServiceMock = new Mock(); + localizedTextServiceMock.Setup(x => x.Localize( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns((string key, string alias, CultureInfo _, IDictionary _) => $"{key}_{alias}"); + var valueEditor = new DateTimePropertyEditorBase.DateTimeDataValueEditor( + Mock.Of(), + _jsonSerializer, + Mock.Of(), + new DataEditorAttribute(Constants.PropertyEditors.Aliases.TimeOnly), + localizedTextServiceMock.Object, + Mock.Of>(), + dt => dt.Date.ToString("HH:mm:ss")) + { + ConfigurationObject = new DateTimeConfiguration + { + TimeZones = timeZoneMode is null + ? null + : new DateTimeConfiguration.TimeZonesConfiguration + { + Mode = timeZoneMode.Value, + TimeZones = timeZones?.ToList() ?? [], + }, + }, + }; + return valueEditor; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateOnlyValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateOnlyValueConverterTests.cs new file mode 100644 index 0000000000..4ef1f44a1d --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateOnlyValueConverterTests.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.ValueConverters; + +[TestFixture] +public class DateOnlyValueConverterTests +{ + private readonly IJsonSerializer _jsonSerializer = + new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + + private static readonly DateTimeValueConverterBase.DateTimeDto _convertToObjectInputDate = new() + { + Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.FromHours(-1)), + TimeZone = "Europe/Copenhagen", + }; + + [TestCase(Constants.PropertyEditors.Aliases.DateOnly, true)] + [TestCase(Constants.PropertyEditors.Aliases.DateTimeUnspecified, false)] + [TestCase(Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, false)] + [TestCase(Constants.PropertyEditors.Aliases.TimeOnly, false)] + [TestCase(Constants.PropertyEditors.Aliases.DateTime, false)] + public void IsConverter_For(string propertyEditorAlias, bool expected) + { + var propertyType = Mock.Of(x => x.EditorAlias == propertyEditorAlias); + var converter = new DateOnlyValueConverter(Mock.Of(MockBehavior.Strict), Mock.Of>()); + + var result = converter.IsConverter(propertyType); + + Assert.AreEqual(expected, result); + } + + [Test] + public void GetPropertyValueType_ReturnsExpectedType() + { + var converter = new DateOnlyValueConverter(Mock.Of(MockBehavior.Strict), Mock.Of>()); + var dataType = new PublishedDataType( + 0, + "test", + "test", + new Lazy(() => + new DateTimeConfiguration + { + TimeZones = null, + })); + var propertyType = Mock.Of(x => x.DataType == dataType); + + var result = converter.GetPropertyValueType(propertyType); + + Assert.AreEqual(typeof(DateOnly?), result); + } + + private static readonly object[] _convertToIntermediateCases = + [ + new object[] { null, null }, + new object[] { """{"date":"2025-08-20T16:30:00.0000000Z","timeZone":null}""", new DateTimeValueConverterBase.DateTimeDto { Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.Zero), TimeZone = null } }, + new object[] { """{"date":"2025-08-20T16:30:00.0000000Z","timeZone":"Europe/Copenhagen"}""", new DateTimeValueConverterBase.DateTimeDto { Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.Zero), TimeZone = "Europe/Copenhagen" } }, + new object[] { """{"date":"2025-08-20T16:30:00.0000000-05:00","timeZone":"Europe/Copenhagen"}""", new DateTimeValueConverterBase.DateTimeDto { Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.FromHours(-5)), TimeZone = "Europe/Copenhagen" } }, + ]; + + [TestCaseSource(nameof(_convertToIntermediateCases))] + public void Can_Convert_To_Intermediate_Value(string? input, object? expected) + { + var result = new DateOnlyValueConverter(_jsonSerializer, Mock.Of>()).ConvertSourceToIntermediate(null!, null!, input, false); + if (expected is null) + { + Assert.IsNull(result); + return; + } + + Assert.IsNotNull(result); + Assert.IsInstanceOf(result); + var dateTime = (DateTimeValueConverterBase.DateTimeDto)result; + Assert.IsInstanceOf(dateTime); + Assert.AreEqual(((DateTimeValueConverterBase.DateTimeDto)expected).Date, dateTime.Date); + Assert.AreEqual(((DateTimeValueConverterBase.DateTimeDto)expected).TimeZone, dateTime.TimeZone); + } + + private static object[] _dateOnlyConvertToObjectCases = + [ + new object[] { null, null }, + new object[] { _convertToObjectInputDate, DateOnly.Parse("2025-08-20") }, + ]; + + [TestCaseSource(nameof(_dateOnlyConvertToObjectCases))] + public void Can_Convert_To_Object( + object? input, + object? expected) + { + var dataType = new PublishedDataType( + 0, + "test", + "test", + new Lazy(() => + new DateTimeConfiguration + { + TimeZones = null, + })); + + var propertyType = new Mock(MockBehavior.Strict); + propertyType.SetupGet(x => x.DataType) + .Returns(dataType); + + var result = new DateOnlyValueConverter(_jsonSerializer, Mock.Of>()) + .ConvertIntermediateToObject(null!, propertyType.Object, PropertyCacheLevel.Unknown, input, false); + if (expected is null) + { + Assert.IsNull(result); + return; + } + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs new file mode 100644 index 0000000000..dd7395f5d0 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.ValueConverters; + +[TestFixture] +public class DateTimeUnspecifiedValueConverterTests +{ + private readonly IJsonSerializer _jsonSerializer = + new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + + private static readonly DateTimeValueConverterBase.DateTimeDto _convertToObjectInputDate = new() + { + Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.FromHours(-1)), + TimeZone = "Europe/Copenhagen", + }; + + [TestCase(Constants.PropertyEditors.Aliases.DateTimeUnspecified, true)] + [TestCase(Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, false)] + [TestCase(Constants.PropertyEditors.Aliases.DateOnly, false)] + [TestCase(Constants.PropertyEditors.Aliases.TimeOnly, false)] + [TestCase(Constants.PropertyEditors.Aliases.DateTime, false)] + public void IsConverter_For(string propertyEditorAlias, bool expected) + { + var propertyType = Mock.Of(x => x.EditorAlias == propertyEditorAlias); + var converter = new DateTimeUnspecifiedValueConverter(Mock.Of(MockBehavior.Strict), Mock.Of>()); + + var result = converter.IsConverter(propertyType); + + Assert.AreEqual(expected, result); + } + + [Test] + public void GetPropertyValueType_ReturnsExpectedType() + { + var converter = new DateTimeUnspecifiedValueConverter(Mock.Of(MockBehavior.Strict), Mock.Of>()); + var dataType = new PublishedDataType( + 0, + "test", + "test", + new Lazy(() => + new DateTimeConfiguration + { + TimeZones = null, + })); + var propertyType = Mock.Of(x => x.DataType == dataType); + + var result = converter.GetPropertyValueType(propertyType); + + Assert.AreEqual(typeof(DateTime?), result); + } + + private static readonly object[] _convertToIntermediateCases = + [ + new object[] { null, null }, + new object[] { """{"date":"2025-08-20T16:30:00.0000000Z","timeZone":null}""", new DateTimeValueConverterBase.DateTimeDto { Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.Zero), TimeZone = null } }, + new object[] { """{"date":"2025-08-20T16:30:00.0000000Z","timeZone":"Europe/Copenhagen"}""", new DateTimeValueConverterBase.DateTimeDto { Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.Zero), TimeZone = "Europe/Copenhagen" } }, + new object[] { """{"date":"2025-08-20T16:30:00.0000000-05:00","timeZone":"Europe/Copenhagen"}""", new DateTimeValueConverterBase.DateTimeDto { Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.FromHours(-5)), TimeZone = "Europe/Copenhagen" } }, + ]; + + [TestCaseSource(nameof(_convertToIntermediateCases))] + public void Can_Convert_To_Intermediate_Value(string? input, object? expected) + { + var result = new DateTimeUnspecifiedValueConverter(_jsonSerializer, Mock.Of>()).ConvertSourceToIntermediate(null!, null!, input, false); + if (expected is null) + { + Assert.IsNull(result); + return; + } + + Assert.IsNotNull(result); + Assert.IsInstanceOf(result); + var dateTime = (DateTimeValueConverterBase.DateTimeDto)result; + Assert.IsInstanceOf(dateTime); + Assert.AreEqual(((DateTimeValueConverterBase.DateTimeDto)expected).Date, dateTime.Date); + Assert.AreEqual(((DateTimeValueConverterBase.DateTimeDto)expected).TimeZone, dateTime.TimeZone); + } + + private static object[] _dateTimeUnspecifiedConvertToObjectCases = + [ + new object[] { null, null }, + new object[] { _convertToObjectInputDate, DateTime.Parse("2025-08-20T17:30:00") }, + ]; + + [TestCaseSource(nameof(_dateTimeUnspecifiedConvertToObjectCases))] + public void Can_Convert_To_Object( + object? input, + object? expected) + { + var dataType = new PublishedDataType( + 0, + "test", + "test", + new Lazy(() => + new DateTimeConfiguration + { + TimeZones = null, + })); + + var propertyType = new Mock(MockBehavior.Strict); + propertyType.SetupGet(x => x.DataType) + .Returns(dataType); + + var result = new DateTimeUnspecifiedValueConverter(_jsonSerializer, Mock.Of>()) + .ConvertIntermediateToObject(null!, propertyType.Object, PropertyCacheLevel.Unknown, input, false); + if (expected is null) + { + Assert.IsNull(result); + return; + } + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeWithTimeZoneValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeWithTimeZoneValueConverterTests.cs new file mode 100644 index 0000000000..93673d08bb --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeWithTimeZoneValueConverterTests.cs @@ -0,0 +1,127 @@ +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.ValueConverters; + +[TestFixture] +public class DateTimeWithTimeZoneValueConverterTests +{ + private readonly IJsonSerializer _jsonSerializer = + new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + + private static readonly DateTimeValueConverterBase.DateTimeDto _convertToObjectInputDate = new() + { + Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.FromHours(-1)), + TimeZone = "Europe/Copenhagen", + }; + + [TestCase(Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, true)] + [TestCase(Constants.PropertyEditors.Aliases.DateTimeUnspecified, false)] + [TestCase(Constants.PropertyEditors.Aliases.DateOnly, false)] + [TestCase(Constants.PropertyEditors.Aliases.TimeOnly, false)] + [TestCase(Constants.PropertyEditors.Aliases.DateTime, false)] + public void IsConverter_For(string propertyEditorAlias, bool expected) + { + var propertyType = Mock.Of(x => x.EditorAlias == propertyEditorAlias); + var converter = new DateTimeWithTimeZoneValueConverter(Mock.Of(MockBehavior.Strict), Mock.Of>()); + + var result = converter.IsConverter(propertyType); + + Assert.AreEqual(expected, result); + } + + [TestCase(DateTimeConfiguration.TimeZoneMode.All)] + [TestCase(DateTimeConfiguration.TimeZoneMode.Custom)] + [TestCase(DateTimeConfiguration.TimeZoneMode.Local)] + public void GetPropertyValueType_ReturnsExpectedType(DateTimeConfiguration.TimeZoneMode timeZoneMode) + { + var converter = new DateTimeWithTimeZoneValueConverter(Mock.Of(MockBehavior.Strict), Mock.Of>()); + var dataType = new PublishedDataType( + 0, + "test", + "test", + new Lazy(() => + new DateTimeConfiguration + { + TimeZones = new DateTimeConfiguration.TimeZonesConfiguration { Mode = timeZoneMode }, + })); + var propertyType = Mock.Of(x => x.DataType == dataType); + + var result = converter.GetPropertyValueType(propertyType); + + Assert.AreEqual(typeof(DateTimeOffset?), result); + } + + private static readonly object[] _convertToIntermediateCases = + [ + new object[] { null, null }, + new object[] { """{"date":"2025-08-20T16:30:00.0000000Z","timeZone":null}""", new DateTimeValueConverterBase.DateTimeDto { Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.Zero), TimeZone = null } }, + new object[] { """{"date":"2025-08-20T16:30:00.0000000Z","timeZone":"Europe/Copenhagen"}""", new DateTimeValueConverterBase.DateTimeDto { Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.Zero), TimeZone = "Europe/Copenhagen" } }, + new object[] { """{"date":"2025-08-20T16:30:00.0000000-05:00","timeZone":"Europe/Copenhagen"}""", new DateTimeValueConverterBase.DateTimeDto { Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.FromHours(-5)), TimeZone = "Europe/Copenhagen" } }, + ]; + + [TestCaseSource(nameof(_convertToIntermediateCases))] + public void Can_Convert_To_Intermediate_Value(string? input, object? expected) + { + var result = new DateTimeWithTimeZoneValueConverter(_jsonSerializer, Mock.Of>()).ConvertSourceToIntermediate(null!, null!, input, false); + if (expected is null) + { + Assert.IsNull(result); + return; + } + + Assert.IsNotNull(result); + Assert.IsInstanceOf(result); + var dateTime = (DateTimeValueConverterBase.DateTimeDto)result; + Assert.IsInstanceOf(dateTime); + Assert.AreEqual(((DateTimeValueConverterBase.DateTimeDto)expected).Date, dateTime.Date); + Assert.AreEqual(((DateTimeValueConverterBase.DateTimeDto)expected).TimeZone, dateTime.TimeZone); + } + + private static object[] _dateTimeWithTimeZoneConvertToObjectCases = + [ + new object[] { null, DateTimeConfiguration.TimeZoneMode.All, null }, + new object[] { _convertToObjectInputDate, DateTimeConfiguration.TimeZoneMode.All, _convertToObjectInputDate.Date }, + new object[] { _convertToObjectInputDate, DateTimeConfiguration.TimeZoneMode.Local, _convertToObjectInputDate.Date }, + new object[] { _convertToObjectInputDate, DateTimeConfiguration.TimeZoneMode.Custom, _convertToObjectInputDate.Date }, + ]; + + [TestCaseSource(nameof(_dateTimeWithTimeZoneConvertToObjectCases))] + public void Can_Convert_To_Object( + object? input, + DateTimeConfiguration.TimeZoneMode timeZoneMode, + object? expected) + { + var dataType = new PublishedDataType( + 0, + "test", + "test", + new Lazy(() => + new DateTimeConfiguration + { + TimeZones = new DateTimeConfiguration.TimeZonesConfiguration { Mode = timeZoneMode }, + })); + + var propertyType = new Mock(MockBehavior.Strict); + propertyType.SetupGet(x => x.DataType) + .Returns(dataType); + + var result = new DateTimeWithTimeZoneValueConverter(_jsonSerializer, Mock.Of>()) + .ConvertIntermediateToObject(null!, propertyType.Object, PropertyCacheLevel.Unknown, input, false); + if (expected is null) + { + Assert.IsNull(result); + return; + } + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs new file mode 100644 index 0000000000..a0bcd82ec9 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.ValueConverters; + +[TestFixture] +public class TimeOnlyValueConverterTests +{ + private readonly IJsonSerializer _jsonSerializer = + new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + + private static readonly DateTimeValueConverterBase.DateTimeDto _convertToObjectInputDate = new() + { + Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.FromHours(-1)), + TimeZone = "Europe/Copenhagen", + }; + + [TestCase(Constants.PropertyEditors.Aliases.TimeOnly, true)] + [TestCase(Constants.PropertyEditors.Aliases.DateTimeUnspecified, false)] + [TestCase(Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, false)] + [TestCase(Constants.PropertyEditors.Aliases.DateOnly, false)] + [TestCase(Constants.PropertyEditors.Aliases.DateTime, false)] + public void IsConverter_For(string propertyEditorAlias, bool expected) + { + var propertyType = Mock.Of(x => x.EditorAlias == propertyEditorAlias); + var converter = new TimeOnlyValueConverter(Mock.Of(MockBehavior.Strict), Mock.Of>()); + + var result = converter.IsConverter(propertyType); + + Assert.AreEqual(expected, result); + } + + [Test] + public void GetPropertyValueType_ReturnsExpectedType() + { + var converter = new TimeOnlyValueConverter(Mock.Of(MockBehavior.Strict), Mock.Of>()); + var dataType = new PublishedDataType( + 0, + "test", + "test", + new Lazy(() => + new DateTimeConfiguration + { + TimeZones = null, + })); + var propertyType = Mock.Of(x => x.DataType == dataType); + + var result = converter.GetPropertyValueType(propertyType); + + Assert.AreEqual(typeof(TimeOnly?), result); + } + + private static readonly object[] _convertToIntermediateCases = + [ + new object[] { null, null }, + new object[] { """{"date":"2025-08-20T16:30:00.0000000Z","timeZone":null}""", new DateTimeValueConverterBase.DateTimeDto { Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.Zero), TimeZone = null } }, + new object[] { """{"date":"2025-08-20T16:30:00.0000000Z","timeZone":"Europe/Copenhagen"}""", new DateTimeValueConverterBase.DateTimeDto { Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.Zero), TimeZone = "Europe/Copenhagen" } }, + new object[] { """{"date":"2025-08-20T16:30:00.0000000-05:00","timeZone":"Europe/Copenhagen"}""", new DateTimeValueConverterBase.DateTimeDto { Date = new DateTimeOffset(2025, 08, 20, 16, 30, 0, TimeSpan.FromHours(-5)), TimeZone = "Europe/Copenhagen" } }, + ]; + + [TestCaseSource(nameof(_convertToIntermediateCases))] + public void Can_Convert_To_Intermediate_Value(string? input, object? expected) + { + var result = new TimeOnlyValueConverter(_jsonSerializer, Mock.Of>()).ConvertSourceToIntermediate(null!, null!, input, false); + if (expected is null) + { + Assert.IsNull(result); + return; + } + + Assert.IsNotNull(result); + Assert.IsInstanceOf(result); + var dateTime = (DateTimeValueConverterBase.DateTimeDto)result; + Assert.IsInstanceOf(dateTime); + Assert.AreEqual(((DateTimeValueConverterBase.DateTimeDto)expected).Date, dateTime.Date); + Assert.AreEqual(((DateTimeValueConverterBase.DateTimeDto)expected).TimeZone, dateTime.TimeZone); + } + + private static object[] _timeOnlyConvertToObjectCases = + [ + new object[] { null, null }, + new object[] { _convertToObjectInputDate, TimeOnly.Parse("17:30") }, + ]; + + [TestCaseSource(nameof(_timeOnlyConvertToObjectCases))] + public void Can_Convert_To_Object( + object? input, + object? expected) + { + var dataType = new PublishedDataType( + 0, + "test", + "test", + new Lazy(() => + new DateTimeConfiguration + { + TimeZones = null, + })); + + var propertyType = new Mock(MockBehavior.Strict); + propertyType.SetupGet(x => x.DataType) + .Returns(dataType); + + var result = new TimeOnlyValueConverter(_jsonSerializer, Mock.Of>()) + .ConvertIntermediateToObject(null!, propertyType.Object, PropertyCacheLevel.Unknown, input, false); + if (expected is null) + { + Assert.IsNull(result); + return; + } + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateOnlyPropertyIndexValueFactoryTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateOnlyPropertyIndexValueFactoryTests.cs new file mode 100644 index 0000000000..9fc7b338d6 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateOnlyPropertyIndexValueFactoryTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors; + +[TestFixture] +[TestOf(typeof(DateOnlyPropertyIndexValueFactory))] +public class DateOnlyPropertyIndexValueFactoryTests +{ + private static readonly IJsonSerializer _jsonSerializer = new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + + [Test] + public void GetIndexValues_ReturnsEmptyValues_ForNullPropertyValue() + { + var propertyMock = new Mock(MockBehavior.Strict); + propertyMock.SetupGet(x => x.Alias) + .Returns("testAlias"); + propertyMock.Setup(x => x.GetValue("en-US", null, true)) + .Returns(null); + var factory = new DateOnlyPropertyIndexValueFactory(_jsonSerializer, Mock.Of>()); + + var result = factory.GetIndexValues( + propertyMock.Object, + "en-US", + null, + true, + [], + new Dictionary()) + .ToList(); + + Assert.AreEqual(1, result.Count); + var indexValue = result.First(); + Assert.AreEqual(indexValue.FieldName, "testAlias"); + Assert.IsEmpty(indexValue.Values); + } + + [Test] + public void GetIndexValues_ReturnsFormattedDateTime() + { + var propertyMock = new Mock(MockBehavior.Strict); + propertyMock.SetupGet(x => x.Alias) + .Returns("testAlias"); + propertyMock.Setup(x => x.GetValue("en-US", null, true)) + .Returns("{\"date\":\"2023-01-18T12:00:00+01:00\",\"timeZone\":\"Europe/Copenhagen\"}"); + + var factory = new DateOnlyPropertyIndexValueFactory(_jsonSerializer, Mock.Of>()); + + var result = factory.GetIndexValues( + propertyMock.Object, + "en-US", + null, + true, + [], + new Dictionary()) + .ToList(); + + Assert.AreEqual(1, result.Count); + var indexValue = result.First(); + Assert.AreEqual(indexValue.FieldName, "testAlias"); + Assert.AreEqual(1, indexValue.Values.Count()); + var value = indexValue.Values.First(); + Assert.AreEqual("2023-01-18", value); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimeUnspecifiedPropertyIndexValueFactoryTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimeUnspecifiedPropertyIndexValueFactoryTests.cs new file mode 100644 index 0000000000..541cd2b749 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimeUnspecifiedPropertyIndexValueFactoryTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors; + +[TestFixture] +[TestOf(typeof(DateTimeUnspecifiedPropertyIndexValueFactory))] +public class DateTimeUnspecifiedPropertyIndexValueFactoryTests +{ + private static readonly IJsonSerializer _jsonSerializer = new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + + [Test] + public void GetIndexValues_ReturnsEmptyValues_ForNullPropertyValue() + { + var propertyMock = new Mock(MockBehavior.Strict); + propertyMock.SetupGet(x => x.Alias) + .Returns("testAlias"); + propertyMock.Setup(x => x.GetValue("en-US", null, true)) + .Returns(null); + var factory = new DateTimeUnspecifiedPropertyIndexValueFactory(_jsonSerializer, Mock.Of>()); + + var result = factory.GetIndexValues( + propertyMock.Object, + "en-US", + null, + true, + [], + new Dictionary()) + .ToList(); + + Assert.AreEqual(1, result.Count); + var indexValue = result.First(); + Assert.AreEqual(indexValue.FieldName, "testAlias"); + Assert.IsEmpty(indexValue.Values); + } + + [Test] + public void GetIndexValues_ReturnsFormattedDateTime() + { + var propertyMock = new Mock(MockBehavior.Strict); + propertyMock.SetupGet(x => x.Alias) + .Returns("testAlias"); + propertyMock.Setup(x => x.GetValue("en-US", null, true)) + .Returns("{\"date\":\"2023-01-18T12:00:00+01:00\",\"timeZone\":\"Europe/Copenhagen\"}"); + + var factory = new DateTimeUnspecifiedPropertyIndexValueFactory(_jsonSerializer, Mock.Of>()); + + var result = factory.GetIndexValues( + propertyMock.Object, + "en-US", + null, + true, + [], + new Dictionary()) + .ToList(); + + Assert.AreEqual(1, result.Count); + var indexValue = result.First(); + Assert.AreEqual(indexValue.FieldName, "testAlias"); + Assert.AreEqual(1, indexValue.Values.Count()); + var value = indexValue.Values.First(); + Assert.AreEqual("2023-01-18T11:00:00", value); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimeWithTimeZonePropertyIndexValueFactoryTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimeWithTimeZonePropertyIndexValueFactoryTests.cs new file mode 100644 index 0000000000..bfdb51c995 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimeWithTimeZonePropertyIndexValueFactoryTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors; + +[TestFixture] +[TestOf(typeof(DateTimeWithTimeZonePropertyIndexValueFactory))] +public class DateTimeWithTimeZonePropertyIndexValueFactoryTests +{ + private static readonly IJsonSerializer _jsonSerializer = new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + + [Test] + public void GetIndexValues_ReturnsEmptyValues_ForNullPropertyValue() + { + var propertyMock = new Mock(MockBehavior.Strict); + propertyMock.SetupGet(x => x.Alias) + .Returns("testAlias"); + propertyMock.Setup(x => x.GetValue("en-US", null, true)) + .Returns(null); + var factory = new DateTimeWithTimeZonePropertyIndexValueFactory(_jsonSerializer, Mock.Of>()); + + var result = factory.GetIndexValues( + propertyMock.Object, + "en-US", + null, + true, + [], + new Dictionary()) + .ToList(); + + Assert.AreEqual(1, result.Count); + var indexValue = result.First(); + Assert.AreEqual(indexValue.FieldName, "testAlias"); + Assert.IsEmpty(indexValue.Values); + } + + [Test] + public void GetIndexValues_ReturnsFormattedDateTime() + { + var propertyMock = new Mock(MockBehavior.Strict); + propertyMock.SetupGet(x => x.Alias) + .Returns("testAlias"); + propertyMock.Setup(x => x.GetValue("en-US", null, true)) + .Returns("{\"date\":\"2023-01-18T12:00:00+01:00\",\"timeZone\":\"Europe/Copenhagen\"}"); + + var factory = new DateTimeWithTimeZonePropertyIndexValueFactory(_jsonSerializer, Mock.Of>()); + + var result = factory.GetIndexValues( + propertyMock.Object, + "en-US", + null, + true, + [], + new Dictionary()) + .ToList(); + + Assert.AreEqual(1, result.Count); + var indexValue = result.First(); + Assert.AreEqual(indexValue.FieldName, "testAlias"); + Assert.AreEqual(1, indexValue.Values.Count()); + var value = indexValue.Values.First(); + Assert.AreEqual("2023-01-18T11:00:00Z", value); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/TimeOnlyPropertyIndexValueFactoryTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/TimeOnlyPropertyIndexValueFactoryTests.cs new file mode 100644 index 0000000000..c9ac603827 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/TimeOnlyPropertyIndexValueFactoryTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors; + +[TestFixture] +[TestOf(typeof(TimeOnlyPropertyIndexValueFactory))] +public class TimeOnlyPropertyIndexValueFactoryTests +{ + private static readonly IJsonSerializer _jsonSerializer = new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()); + + [Test] + public void GetIndexValues_ReturnsEmptyValues_ForNullPropertyValue() + { + var propertyMock = new Mock(MockBehavior.Strict); + propertyMock.SetupGet(x => x.Alias) + .Returns("testAlias"); + propertyMock.Setup(x => x.GetValue("en-US", null, true)) + .Returns(null); + var factory = new TimeOnlyPropertyIndexValueFactory(_jsonSerializer, Mock.Of>()); + + var result = factory.GetIndexValues( + propertyMock.Object, + "en-US", + null, + true, + [], + new Dictionary()) + .ToList(); + + Assert.AreEqual(1, result.Count); + var indexValue = result.First(); + Assert.AreEqual(indexValue.FieldName, "testAlias"); + Assert.IsEmpty(indexValue.Values); + } + + [Test] + public void GetIndexValues_ReturnsFormattedDateTime() + { + var propertyMock = new Mock(MockBehavior.Strict); + propertyMock.SetupGet(x => x.Alias) + .Returns("testAlias"); + propertyMock.Setup(x => x.GetValue("en-US", null, true)) + .Returns("{\"date\":\"2023-01-18T12:00:00+01:00\",\"timeZone\":\"Europe/Copenhagen\"}"); + + var factory = new TimeOnlyPropertyIndexValueFactory(_jsonSerializer, Mock.Of>()); + + var result = factory.GetIndexValues( + propertyMock.Object, + "en-US", + null, + true, + [], + new Dictionary()) + .ToList(); + + Assert.AreEqual(1, result.Count); + var indexValue = result.First(); + Assert.AreEqual(indexValue.FieldName, "testAlias"); + Assert.AreEqual(1, indexValue.Values.Count()); + var value = indexValue.Values.First(); + Assert.AreEqual("11:00:00", value); + } +}