From 51575e5e3643987bd152e8d6e5ae2216b13af218 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:21:09 +0200 Subject: [PATCH] Property Editors: New Date Time property editors (#19915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Started the implementation of the new date time property editor * Display picked time in local and UTC * Adjustments to the way the timezones are displayed and the picker is configured * Filter out `Etc/` (offset) timezones from the list * Additional adjustments * Introduced date format and time zone options (all, local or custom) * Adjustments to the property editor configuration and value converter * Use UUICombobox instead of UUISelect for displaying time zone options. Display UTC offset instead of short offset name in label. * Allow searching by offset * Ignore case when searching for time zone * Store dates consistently (always same format) * Add custom PropertyIndexValueFactory for the new property editor * Adjustments when switching between time zone modes * Small fixes and cleanup * Started improving time zone config selection * Small adjustments * Remove selected time zones from the list + display label instead of value * Localizing labels * Remove unwanted character * Fix incorrect order of custom time zones list * Small fixes (mostly validation) * Rename input time zone component * Small adjustments * Using model for stored value * Save examine value as ISO format * Adjusting class names for consistency * Small fixes * Add default data type configuration * Rename `TimeZone` to `UmbTimeZone` * Fix failing tests * Started adding unit tests for DateWithTimeZonePropertyEditor * Additional tests * Additional tests * Additional tests * Fixed searches with regex special characters throwing errors * Remove offset from generic UmbTimeZone type and added new type specific for the property editor * Adjust property editor to show error when selected time zone is no longer available, instead of pre-selecting another one * Do not preselect a time zone if a date is stored without time zone This most likely means that the configuration of the editor changed to add time zone support. In this case we want to force the editor to select the applicable time zone. * Fix failing backoffice build * Added tests for DateTimeWithTimeZonePropertyIndexValueFactory * Improved picker validation * Remove unused code * Move models to their corresponding places * Renaming `DateTimeWithTimeZone` to `DateTime2` * Fix data type count tests * Simplifying code + adjusting value converter to support old picker value * Adjustments to property editor unit tests * Fix validation issue * Fix default configuration for 'Date Time (Unspecified)' * Rename validator * Fix comment * Adjust database creator default DateTime2 data types * Update tests after adjusting default data types * Add integration test for DateTime2 returned value type * Apply suggestions from code review Co-authored-by: Andy Butland * Aligning DateTime2Validator with other JSON validators. Added new model for API. * Removed unused code and updated tests * Fix validation error message * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Splitting the new date time editor into multiple (per output type) * Adjust tests in DateTime2PropertyIndexValueFactoryTest * Update value converter tests * Group the new date time tests * Adjust new property editor tests * Adjust property editor integration tests * Update data editor count tests * Naming adjustments * Small fixes * Cleanup - Remove unused files - Remove 'None' option from configuration and update all the tests * Update luxon depedencies * Move GetValueFromSource to the value converter * Add new property editor examples to mock data * Re-organizing the code * Adjustments from code review * Place the date time property index value factories in their own files * Small adjustments for code consistency * Small adjustments * Minor adjustment * Small fix from copilot review * Completed the set of XML header comments. * use already existing query property * fail is form control element is null or undefined * using lit ref for querying and form control registration * state for timeZonePickerValue and remove _disableAddButton * Adjustments to form control registration * Remove unused declaration --------- Co-authored-by: Andy Butland Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Niels Lyngsø Co-authored-by: Niels Lyngsø --- src/Umbraco.Core/Constants-DataTypes.cs | 10 + src/Umbraco.Core/Constants-PropertyEditors.cs | 20 + .../EmbeddedResources/Lang/en.xml | 1 + .../PropertyEditors/DateTimeConfiguration.cs | 49 ++ .../IDateOnlyPropertyIndexValueFactory.cs | 6 + ...imeUnspecifiedPropertyIndexValueFactory.cs | 6 + ...meWithTimeZonePropertyIndexValueFactory.cs | 6 + .../ITimeOnlyPropertyIndexValueFactory.cs | 6 + .../UmbracoBuilder.CoreServices.cs | 4 + .../Migrations/Install/DatabaseDataCreator.cs | 35 ++ .../Models/DateTimeEditorValue.cs | 13 + .../PropertyEditors/DateOnlyPropertyEditor.cs | 36 ++ .../DateOnlyPropertyIndexValueFactory.cs | 28 ++ .../DateTimeConfigurationEditor.cs | 14 + .../DateTimePropertyEditorBase.cs | 192 ++++++++ .../DateTimePropertyEditorHelper.cs | 42 ++ .../DateTimePropertyIndexValueFactory.cs | 60 +++ .../DateTimeUnspecifiedPropertyEditor.cs | 36 ++ ...imeUnspecifiedPropertyIndexValueFactory.cs | 28 ++ .../DateTimeWithTimeZonePropertyEditor.cs | 36 ++ ...meWithTimeZonePropertyIndexValueFactory.cs | 28 ++ .../PropertyEditors/TimeOnlyPropertyEditor.cs | 36 ++ .../TimeOnlyPropertyIndexValueFactory.cs | 28 ++ .../ValueConverters/DateOnlyValueConverter.cs | 34 ++ .../DateTimeUnspecifiedValueConverter.cs | 35 ++ .../DateTimeValueConverterBase.cs | 75 +++ .../DateTimeWithTimeZoneValueConverter.cs | 34 ++ .../ValueConverters/TimeOnlyValueConverter.cs | 34 ++ src/Umbraco.Web.UI.Client/package-lock.json | 29 ++ src/Umbraco.Web.UI.Client/package.json | 1 + .../src/assets/lang/en.ts | 18 + .../src/assets/lang/pt.ts | 18 + .../src/external/luxon/index.ts | 1 + .../src/external/luxon/package.json | 14 + .../src/external/luxon/vite.config.ts | 18 + .../mocks/data/data-type/data-type.data.ts | 60 +++ .../data/document-type/document-type.data.ts | 80 ++++ .../src/mocks/data/document/document.data.ts | 28 ++ .../src/packages/core/components/index.ts | 1 + .../input-radio-button-list.element.ts | 6 +- .../core/components/input-time-zone/index.ts | 3 + .../input-time-zone-item.element.ts | 99 ++++ .../input-time-zone-picker.element.ts | 114 +++++ .../input-time-zone.element.ts | 272 +++++++++++ .../packages/core/utils/date/date.timezone.ts | 85 ++++ .../src/packages/core/utils/date/index.ts | 1 + .../src/packages/core/utils/index.ts | 1 + .../date-only-picker/Umbraco.DateOnly.ts | 10 + .../date-time/date-only-picker/manifests.ts | 18 + ...erty-editor-ui-date-only-picker.element.ts | 20 + .../Umbraco.DateTimeUnspecified.ts | 10 + .../date-time/date-time-picker/manifests.ts | 42 ++ ...erty-editor-ui-date-time-picker.element.ts | 20 + .../Umbraco.DateTimeWithTimeZone.ts | 10 + .../manifests.ts | 56 +++ ...date-time-with-time-zone-picker.element.ts | 20 + .../property-editors/date-time/manifests.ts | 11 + ...roperty-editor-ui-date-time-picker-base.ts | 446 ++++++++++++++++++ ...roperty-editor-ui-date-time-picker.test.ts | 79 ++++ .../time-only-picker/Umbraco.TimeOnly.ts | 10 + .../date-time/time-only-picker/manifests.ts | 42 ++ ...erty-editor-ui-time-only-picker.element.ts | 20 + .../packages/property-editors/manifests.ts | 4 + .../time-zone-picker/manifests.ts | 13 + ...erty-editor-ui-time-zone-picker.element.ts | 141 ++++++ src/Umbraco.Web.UI.Client/tsconfig.json | 1 + .../Builders/ContentBuilder.cs | 1 + .../Builders/ContentTypeBuilder.cs | 8 + .../Services/ContentServiceTests.cs | 3 + .../DataTypeDefinitionRepositoryTest.cs | 4 +- .../DateTimePropertyEditorTests.cs | 138 ++++++ .../Umbraco.Core/Composing/TypeLoaderTests.cs | 2 +- .../DateOnlyPropertyEditorTests.cs | 164 +++++++ .../DateTimeUnspecifiedPropertyEditorTests.cs | 167 +++++++ ...DateTimeWithTimeZonePropertyEditorTests.cs | 205 ++++++++ .../TimeOnlyPropertyEditorTests.cs | 164 +++++++ .../DateOnlyValueConverterTests.cs | 122 +++++ .../DateTimeUnspecifiedValueConverterTests.cs | 122 +++++ ...DateTimeWithTimeZoneValueConverterTests.cs | 127 +++++ .../TimeOnlyValueConverterTests.cs | 122 +++++ .../DateOnlyPropertyIndexValueFactoryTests.cs | 69 +++ ...specifiedPropertyIndexValueFactoryTests.cs | 69 +++ ...hTimeZonePropertyIndexValueFactoryTests.cs | 69 +++ .../TimeOnlyPropertyIndexValueFactoryTests.cs | 69 +++ 84 files changed, 4374 insertions(+), 5 deletions(-) create mode 100644 src/Umbraco.Core/PropertyEditors/DateTimeConfiguration.cs create mode 100644 src/Umbraco.Core/PropertyEditors/IDateOnlyPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Core/PropertyEditors/IDateTimeUnspecifiedPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Core/PropertyEditors/IDateTimeWithTimeZonePropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Core/PropertyEditors/ITimeOnlyPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Infrastructure/Models/DateTimeEditorValue.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/DateOnlyPropertyEditor.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/DateOnlyPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/DateTimeConfigurationEditor.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorBase.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorHelper.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/DateTimeUnspecifiedPropertyEditor.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/DateTimeUnspecifiedPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/DateTimeWithTimeZonePropertyEditor.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/DateTimeWithTimeZonePropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/TimeOnlyPropertyEditor.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/TimeOnlyPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateOnlyValueConverter.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeValueConverterBase.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeWithTimeZoneValueConverter.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs create mode 100644 src/Umbraco.Web.UI.Client/src/external/luxon/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/external/luxon/package.json create mode 100644 src/Umbraco.Web.UI.Client/src/external/luxon/vite.config.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/input-time-zone-item.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/input-time-zone-picker.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/components/input-time-zone/input-time-zone.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/utils/date/date.timezone.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/utils/date/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/Umbraco.DateOnly.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-only-picker/property-editor-ui-date-only-picker.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/Umbraco.DateTimeUnspecified.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-picker/property-editor-ui-date-time-picker.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/Umbraco.DateTimeWithTimeZone.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/date-time-with-time-zone-picker/manifests.ts create mode 100644 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 create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker.test.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/Umbraco.TimeOnly.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/time-only-picker/property-editor-ui-time-only-picker.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/time-zone-picker/property-editor-ui-time-zone-picker.element.ts create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateOnlyPropertyEditorTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeUnspecifiedPropertyEditorTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DateTimeWithTimeZonePropertyEditorTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/TimeOnlyPropertyEditorTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateOnlyValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeWithTimeZoneValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateOnlyPropertyIndexValueFactoryTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimeUnspecifiedPropertyIndexValueFactoryTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/DateTimeWithTimeZonePropertyIndexValueFactoryTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/TimeOnlyPropertyIndexValueFactoryTests.cs 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); + } +}