diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml index 7bec4bb600..1fb0445bb1 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml @@ -116,6 +116,11 @@ Mange hilsner fra Umbraco robotten Den valgte medie type er ugyldig. Det er kun tilladt at vælge ét medie. Valgt medie kommer fra en ugyldig mappe. + Værdien %0% er mindre end det tilladte minimum af %1%. + Værdien %0% er større end det tilladte maksimum af %1%. + Værdien %0% passer ikke med den konfigureret trin værdi af %1% og mindste værdi af %2%. + Værdien %0% forventes ikke at indeholde et spænd. + Værdien %0% forventes at have en værdi der er større end fra værdien. Slettet indhold med Id: {0} Relateret til original "parent" med id: {1} diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index c3b71b5ab0..e1fdba85d4 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -387,6 +387,11 @@ User group name '%0%' is already taken Member group name '%0%' is already taken Username '%0%' is already taken + The value %0% is less than the allowed minimum value of %1% + The value %0% is greater than the allowed maximum value of %1% + The value %0% does not correspond with the configured step value of %1% and minimum value of %2% + The value %0% is not expected to contain a range + The value %0% is not expected to have a to value less than the from value The chosen media type is invalid. Multiple selected media is not allowed. The selected media is from the wrong folder. diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index 827f73a198..6434289b1b 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -385,6 +385,11 @@ User group name '%0%' is already taken Member group name '%0%' is already taken Username '%0%' is already taken + The value %0% is less than the allowed minimum value of %1% + The value %0% is greater than the allowed maximum value of %1% + The value %0% does not correspond with the configured step value of %1% and minimum value of %2% + The value %0% is not expected to contain a range + The value %0% is not expected to have a to value less than the from value %1% more.]]> %1% too many.]]> The content amount requirements are not met for one or more areas. diff --git a/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs index 1ecae6b0bd..fbce2b7a5d 100644 --- a/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs @@ -1,15 +1,20 @@ +using System.ComponentModel.DataAnnotations; using System.Globalization; 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.Validators; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; /// -/// Represents a decimal property and parameter editor. +/// Represents a decimal property editor. /// [DataEditor( Constants.PropertyEditors.Aliases.Decimal, @@ -32,19 +37,29 @@ public class DecimalPropertyEditor : DataEditor /// protected override IConfigurationEditor CreateConfigurationEditor() => new DecimalConfigurationEditor(); + + /// + /// Defines the value editor for the decimal property editor. + /// internal class DecimalPropertyValueEditor : DataValueEditor { + /// + /// Initializes a new instance of the class. + /// public DecimalPropertyValueEditor( IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper, - DataEditorAttribute attribute) - : base(shortStringHelper, jsonSerializer, ioHelper, attribute) => - Validators.Add(new DecimalValidator()); + DataEditorAttribute attribute, + ILocalizedTextService localizedTextService) + : base(shortStringHelper, jsonSerializer, ioHelper, attribute) + => Validators.AddRange([new DecimalValidator(), new MinMaxValidator(localizedTextService), new StepValidator(localizedTextService)]); + /// public override object? ToEditor(IProperty property, string? culture = null, string? segment = null) => TryParsePropertyValue(property.GetValue(culture, segment)); + /// public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) => TryParsePropertyValue(editorValue.Value); @@ -54,5 +69,109 @@ public class DecimalPropertyEditor : DataEditor : decimal.TryParse(value?.ToString(), CultureInfo.InvariantCulture, out var parsedDecimalValue) ? parsedDecimalValue : null; + + /// + /// Base validator for the decimal property editor validation against data type configured values. + /// + internal abstract class DecimalPropertyConfigurationValidatorBase : SimplePropertyConfigurationValidatorBase + { + /// + /// The configuration key for the minimum value. + /// + protected const string ConfigurationKeyMinValue = "min"; + + /// + /// The configuration key for the maximum value. + /// + protected const string ConfigurationKeyMaxValue = "max"; + + /// + /// The configuration key for the step value. + /// + protected const string ConfigurationKeyStepValue = "step"; + + /// + /// Initializes a new instance of the class. + /// + protected DecimalPropertyConfigurationValidatorBase(ILocalizedTextService localizedTextService) => LocalizedTextService = localizedTextService; + + /// + /// Gets the . + /// + protected ILocalizedTextService LocalizedTextService { get; } + + /// + protected override bool TryParsePropertyValue(object? value, out double parsedDecimalValue) + => double.TryParse(value?.ToString(), CultureInfo.InvariantCulture, out parsedDecimalValue); + } + + /// + /// Validates the min/max configuration for the decimal property editor. + /// + internal class MinMaxValidator : DecimalPropertyConfigurationValidatorBase, IValueValidator + { + /// + /// Initializes a new instance of the class. + /// + public MinMaxValidator(ILocalizedTextService localizedTextService) + : base(localizedTextService) + { + } + + /// + public IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext) + { + if (TryParsePropertyValue(value, out var parsedDecimalValue) is false) + { + yield break; + } + + if (TryGetConfiguredValue(dataTypeConfiguration, ConfigurationKeyMinValue, out double min) && parsedDecimalValue < min) + { + yield return new ValidationResult( + LocalizedTextService.Localize("validation", "outOfRangeMinimum", [parsedDecimalValue.ToString(), min.ToString()]), + ["value"]); + } + + if (TryGetConfiguredValue(dataTypeConfiguration, ConfigurationKeyMaxValue, out double max) && parsedDecimalValue > max) + { + yield return new ValidationResult( + LocalizedTextService.Localize("validation", "outOfRangeMaximum", [parsedDecimalValue.ToString(), max.ToString()]), + ["value"]); + } + } + } + + /// + /// Validates the step configuration for the decimal property editor. + /// + internal class StepValidator : DecimalPropertyConfigurationValidatorBase, IValueValidator + { + /// + /// Initializes a new instance of the class. + /// + public StepValidator(ILocalizedTextService localizedTextService) + : base(localizedTextService) + { + } + + /// + public IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext) + { + if (TryParsePropertyValue(value, out var parsedDecimalValue) is false) + { + yield break; + } + + if (TryGetConfiguredValue(dataTypeConfiguration, ConfigurationKeyMinValue, out double min) && + TryGetConfiguredValue(dataTypeConfiguration, ConfigurationKeyStepValue, out double step) && + ValidationHelper.IsValueValidForStep((decimal)parsedDecimalValue, (decimal)min, (decimal)step) is false) + { + yield return new ValidationResult( + LocalizedTextService.Localize("validation", "invalidStep", [parsedDecimalValue.ToString(), step.ToString(), min.ToString()]), + ["value"]); + } + } + } } } diff --git a/src/Umbraco.Core/PropertyEditors/IntegerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/IntegerPropertyEditor.cs index 216a82f348..5f282061b0 100644 --- a/src/Umbraco.Core/PropertyEditors/IntegerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/IntegerPropertyEditor.cs @@ -1,15 +1,20 @@ +using System.ComponentModel.DataAnnotations; using System.Globalization; 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.Validators; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; /// -/// Represents an integer property and parameter editor. +/// Represents a decimal property editor. /// [DataEditor( Constants.PropertyEditors.Aliases.Integer, @@ -17,6 +22,9 @@ namespace Umbraco.Cms.Core.PropertyEditors; ValueEditorIsReusable = true)] public class IntegerPropertyEditor : DataEditor { + /// + /// Initializes a new instance of the class. + /// public IntegerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; @@ -28,19 +36,28 @@ public class IntegerPropertyEditor : DataEditor /// protected override IConfigurationEditor CreateConfigurationEditor() => new IntegerConfigurationEditor(); + /// + /// Defines the value editor for the integer property editor. + /// internal class IntegerPropertyValueEditor : DataValueEditor { + /// + /// Initializes a new instance of the class. + /// public IntegerPropertyValueEditor( IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper, - DataEditorAttribute attribute) + DataEditorAttribute attribute, + ILocalizedTextService localizedTextService) : base(shortStringHelper, jsonSerializer, ioHelper, attribute) - => Validators.Add(new IntegerValidator()); + => Validators.AddRange([new IntegerValidator(), new MinMaxValidator(localizedTextService), new StepValidator(localizedTextService)]); + /// public override object? ToEditor(IProperty property, string? culture = null, string? segment = null) => TryParsePropertyValue(property.GetValue(culture, segment)); + /// public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) => TryParsePropertyValue(editorValue.Value); @@ -50,5 +67,109 @@ public class IntegerPropertyEditor : DataEditor : int.TryParse(value?.ToString(), CultureInfo.InvariantCulture, out var parsedIntegerValue) ? parsedIntegerValue : null; + + /// + /// /// Base validator for the integer property editor validation against data type configured values. + /// + internal abstract class IntegerPropertyConfigurationValidatorBase : SimplePropertyConfigurationValidatorBase + { + /// + /// The configuration key for the minimum value. + /// + protected const string ConfigurationKeyMinValue = "min"; + + /// + /// The configuration key for the maximum value. + /// + protected const string ConfigurationKeyMaxValue = "max"; + + /// + /// The configuration key for the step value. + /// + protected const string ConfigurationKeyStepValue = "step"; + + /// + /// Initializes a new instance of the class. + /// + protected IntegerPropertyConfigurationValidatorBase(ILocalizedTextService localizedTextService) => LocalizedTextService = localizedTextService; + + /// + /// Gets the . + /// + protected ILocalizedTextService LocalizedTextService { get; } + + /// + protected override bool TryParsePropertyValue(object? value, out int parsedIntegerValue) + => int.TryParse(value?.ToString(), CultureInfo.InvariantCulture, out parsedIntegerValue); + } + + /// + /// Validates the min/max configuration for the integer property editor. + /// + internal class MinMaxValidator : IntegerPropertyConfigurationValidatorBase, IValueValidator + { + /// + /// Initializes a new instance of the class. + /// + public MinMaxValidator(ILocalizedTextService localizedTextService) + : base(localizedTextService) + { + } + + /// + public IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext) + { + if (TryParsePropertyValue(value, out var parsedIntegerValue) is false) + { + yield break; + } + + if (TryGetConfiguredValue(dataTypeConfiguration, ConfigurationKeyMinValue, out int min) && parsedIntegerValue < min) + { + yield return new ValidationResult( + LocalizedTextService.Localize("validation", "outOfRangeMinimum", [parsedIntegerValue.ToString(), min.ToString()]), + ["value"]); + } + + if (TryGetConfiguredValue(dataTypeConfiguration, ConfigurationKeyMaxValue, out int max) && parsedIntegerValue > max) + { + yield return new ValidationResult( + LocalizedTextService.Localize("validation", "outOfRangeMaximum", [parsedIntegerValue.ToString(), max.ToString()]), + ["value"]); + } + } + } + + /// + /// Validates the step configuration for the integer property editor. + /// + internal class StepValidator : IntegerPropertyConfigurationValidatorBase, IValueValidator + { + /// + /// Initializes a new instance of the class. + /// + public StepValidator(ILocalizedTextService localizedTextService) + : base(localizedTextService) + { + } + + /// + public IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext) + { + if (TryParsePropertyValue(value, out var parsedIntegerValue) is false) + { + yield break; + } + + if (TryGetConfiguredValue(dataTypeConfiguration, ConfigurationKeyMinValue, out int min) && + TryGetConfiguredValue(dataTypeConfiguration, ConfigurationKeyStepValue, out int step) && + ValidationHelper.IsValueValidForStep(parsedIntegerValue, min, step) is false) + { + yield return new ValidationResult( + LocalizedTextService.Localize("validation", "invalidStep", [parsedIntegerValue.ToString(), step.ToString(), min.ToString()]), + ["value"]); + } + } + } } } diff --git a/src/Umbraco.Core/PropertyEditors/SliderConfiguration.cs b/src/Umbraco.Core/PropertyEditors/SliderConfiguration.cs index 68b90c7403..12ca9f461c 100644 --- a/src/Umbraco.Core/PropertyEditors/SliderConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/SliderConfiguration.cs @@ -13,4 +13,7 @@ public class SliderConfiguration [ConfigurationField("maxVal")] public decimal MaximumValue { get; set; } + + [ConfigurationField("step")] + public decimal Step { get; set; } } diff --git a/src/Umbraco.Core/PropertyEditors/Validation/ValidationHelper.cs b/src/Umbraco.Core/PropertyEditors/Validation/ValidationHelper.cs new file mode 100644 index 0000000000..afaba8fd57 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Validation/ValidationHelper.cs @@ -0,0 +1,24 @@ +namespace Umbraco.Cms.Core.PropertyEditors.Validation; + +/// +/// Provides helper methods for validation of property editor values based on data type configuration. +/// +public static class ValidationHelper +{ + /// + /// Checks if a provided value is valid based on the configured step and minimum values. + /// + /// The provided value. + /// The configured minimum value. + /// The configured step value. + /// True if the value is valid otherwise false. + public static bool IsValueValidForStep(decimal value, decimal min, decimal step) + { + if (value < min) + { + return true; // Outside of the range, so we expect another validator will have picked this up. + } + + return (value - min) % step == 0; + } +} diff --git a/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs b/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs new file mode 100644 index 0000000000..0ed0af57e2 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +namespace Umbraco.Cms.Core.PropertyEditors.Validators; + +/// +/// Provides common functionality to validators that rely on data type configuration. +/// +public abstract class DictionaryConfigurationValidatorBase +{ + /// + /// Retrieves a typed value from data type dictionary configuration for the provided key. + /// + /// The data type configuration. + /// The configuration key. + /// The configuration value (if found), otherwise zero. + /// True if the configured value was found. + protected static bool TryGetConfiguredValue(object? dataTypeConfiguration, string key, [NotNullWhen(true)] out TValue? value) + { + if (dataTypeConfiguration is not Dictionary configuration) + { + value = default; + return false; + } + + if (configuration.TryGetValue(key, out object? obj) && obj is TValue castValue) + { + value = castValue; + return true; + } + + value = default; + return false; + } +} diff --git a/src/Umbraco.Core/PropertyEditors/Validators/SimplePropertyConfigurationValidatorBase.cs b/src/Umbraco.Core/PropertyEditors/Validators/SimplePropertyConfigurationValidatorBase.cs new file mode 100644 index 0000000000..debf8664dd --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Validators/SimplePropertyConfigurationValidatorBase.cs @@ -0,0 +1,18 @@ +using Umbraco.Cms.Core.Configuration.Models.Validation; + +namespace Umbraco.Cms.Core.PropertyEditors.Validators; + +/// +/// Provides common functionality to validators that rely on data type configuration. +/// +/// The type to parse to. +public abstract class SimplePropertyConfigurationValidatorBase : DictionaryConfigurationValidatorBase +{ + /// + /// Parses the raw property value into it's typed equivalent. + /// + /// The property value as a nullable object. + /// The parsed value. + /// True if the parse succeeded, otherwise false. + protected abstract bool TryParsePropertyValue(object? value, out TValue parsedValue); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/SliderPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/SliderPropertyEditor.cs index b67e4af0a2..5277a2f552 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/SliderPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/SliderPropertyEditor.cs @@ -1,17 +1,25 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Text.Json.Nodes; 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.Validators; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; /// -/// Represents a slider editor. +/// Represents a slider editor. /// [DataEditor( Constants.PropertyEditors.Aliases.Slider, @@ -38,18 +46,29 @@ public class SliderPropertyEditor : DataEditor protected override IConfigurationEditor CreateConfigurationEditor() => new SliderConfigurationEditor(_ioHelper); + /// + /// Defines the value editor for the slider property editor. + /// internal class SliderPropertyValueEditor : DataValueEditor { private readonly IJsonSerializer _jsonSerializer; + /// + /// Initializes a new instance of the class. + /// public SliderPropertyValueEditor( IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper, - DataEditorAttribute attribute) - : base(shortStringHelper, jsonSerializer, ioHelper, attribute) => + DataEditorAttribute attribute, + ILocalizedTextService localizedTextService) + : base(shortStringHelper, jsonSerializer, ioHelper, attribute) + { _jsonSerializer = jsonSerializer; + Validators.AddRange(new RangeValidator(localizedTextService), new MinMaxValidator(localizedTextService), new StepValidator(localizedTextService)); + } + /// public override object? ToEditor(IProperty property, string? culture = null, string? segment = null) { // value is stored as a string - either a single decimal value @@ -72,18 +91,219 @@ public class SliderPropertyEditor : DataEditor : null; } + /// public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) => editorValue.Value is not null && _jsonSerializer.TryDeserialize(editorValue.Value, out SliderRange? sliderRange) ? sliderRange.ToString() : null; + /// + /// Represents a slider value. + /// internal class SliderRange { + /// + /// Gets or sets the slider range from value. + /// public decimal From { get; set; } + /// + /// Gets or sets the slide range to value. + /// public decimal To { get; set; } - public override string ToString() => From == To ? $"{From.ToString(CultureInfo.InvariantCulture)}" : $"{From.ToString(CultureInfo.InvariantCulture)},{To.ToString(CultureInfo.InvariantCulture)}"; + /// + public override string ToString() + => From == To + ? $"{From.ToString(CultureInfo.InvariantCulture)}" + : $"{From.ToString(CultureInfo.InvariantCulture)},{To.ToString(CultureInfo.InvariantCulture)}"; + } + + /// + /// Base validator for the slider property editor validation against data type configured values. + /// + internal abstract class SliderPropertyConfigurationValidatorBase + { + /// + /// The configuration key for the minimum value. + /// + protected const string ConfigurationKeyMinValue = "minVal"; + + /// + /// The configuration key for the maximum value. + /// + protected const string ConfigurationKeyMaxValue = "maxVal"; + + /// + /// The configuration key for the step value. + /// + protected const string ConfigurationKeyStepValue = "step"; + + /// + /// The configuration key for the enable range value. + /// + protected const string ConfigurationKeyEnableRangeValue = "enableRange"; + + /// + /// Initializes a new instance of the class. + /// + protected SliderPropertyConfigurationValidatorBase(ILocalizedTextService localizedTextService) => LocalizedTextService = localizedTextService; + + /// + /// Gets the . + /// + protected ILocalizedTextService LocalizedTextService { get; } + + /// + /// Parses a from the provided value. + /// + protected bool TryParsePropertyValue(object? value, [NotNullWhen(true)] out SliderRange? parsedValue) + { + if (value is null || value is not JsonObject valueAsJsonObject) + { + parsedValue = null; + return false; + } + + var from = GetDecimalValue(valueAsJsonObject, nameof(SliderRange.From).ToLowerInvariant()); + var to = GetDecimalValue(valueAsJsonObject, nameof(SliderRange.To).ToLowerInvariant()); + if (from.HasValue is false || to.HasValue is false) + { + parsedValue = null; + return false; + } + + parsedValue = new SliderRange + { + From = from.Value, + To = to.Value, + }; + + return true; + } + + private static decimal? GetDecimalValue(JsonObject valueAsJsonObject, string key) + => valueAsJsonObject[key]?.GetValue(); + } + + /// + /// Validates the range configuration for the slider property editor. + /// + internal class RangeValidator : SliderPropertyConfigurationValidatorBase, IValueValidator + { + /// + /// Initializes a new instance of the class. + /// + public RangeValidator(ILocalizedTextService localizedTextService) + : base(localizedTextService) + { + } + + /// + public IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext) + { + if (TryParsePropertyValue(value, out SliderRange? sliderRange) is false) + { + yield break; + } + + if (dataTypeConfiguration is not SliderConfiguration sliderConfiguration) + { + yield break; + } + + if (sliderConfiguration.EnableRange is false && sliderRange.From != sliderRange.To) + { + yield return new ValidationResult( + LocalizedTextService.Localize("validation", "unexpectedRange", [sliderRange.ToString()]), + ["value"]); + } + + if (sliderConfiguration.EnableRange && sliderRange.To < sliderRange.From) + { + yield return new ValidationResult( + LocalizedTextService.Localize("validation", "invalidRange", [sliderRange.ToString()]), + ["value"]); + } + } + } + + /// + /// Validates the min/max configuration for the slider property editor. + /// + internal class MinMaxValidator : SliderPropertyConfigurationValidatorBase, IValueValidator + { + /// + /// Initializes a new instance of the class. + /// + public MinMaxValidator(ILocalizedTextService localizedTextService) + : base(localizedTextService) + { + } + + /// + public IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext) + { + if (TryParsePropertyValue(value, out SliderRange? sliderRange) is false) + { + yield break; + } + + if (dataTypeConfiguration is not SliderConfiguration sliderConfiguration) + { + yield break; + } + + if (sliderRange.From < sliderConfiguration.MinimumValue) + { + yield return new ValidationResult( + LocalizedTextService.Localize("validation", "outOfRangeMinimum", [sliderRange.From.ToString(), sliderConfiguration.MinimumValue.ToString()]), + ["value"]); + } + + if (sliderRange.To > sliderConfiguration.MaximumValue) + { + yield return new ValidationResult( + LocalizedTextService.Localize("validation", "outOfRangeMaximum", [sliderRange.To.ToString(), sliderConfiguration.MaximumValue.ToString()]), + ["value"]); + } + } + } + + /// + /// Validates the step configuration for the slider property editor. + /// + internal class StepValidator : SliderPropertyConfigurationValidatorBase, IValueValidator + { + /// + /// Initializes a new instance of the class. + /// + public StepValidator(ILocalizedTextService localizedTextService) + : base(localizedTextService) + { + } + + /// + public IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext) + { + if (TryParsePropertyValue(value, out SliderRange? sliderRange) is false) + { + yield break; + } + + if (dataTypeConfiguration is not SliderConfiguration sliderConfiguration) + { + yield break; + } + + if (ValidationHelper.IsValueValidForStep(sliderRange.From, sliderConfiguration.MinimumValue, sliderConfiguration.Step) is false || + ValidationHelper.IsValueValidForStep(sliderRange.To, sliderConfiguration.MinimumValue, sliderConfiguration.Step) is false) + { + yield return new ValidationResult( + LocalizedTextService.Localize("validation", "invalidStep", [sliderRange.ToString(), sliderConfiguration.Step.ToString(), sliderConfiguration.MinimumValue.ToString()]), + ["value"]); + } + } } } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs index 18254d923e..59569c7583 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs @@ -1,9 +1,11 @@ -using Moq; +using System.Globalization; +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.Serialization; using Umbraco.Cms.Core.Services; @@ -71,6 +73,84 @@ public class DecimalValueEditorTests Assert.IsNull(result); } + [TestCase("x", false)] + [TestCase(1.5, true)] + public void Validates_Is_Decimal(object value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual(validationResult.ErrorMessage, $"The value {value} is not a valid decimal"); + } + } + + [TestCase(0.9, false)] + [TestCase(1.1, true)] + [TestCase(1.3, true)] + public void Validates_Is_Greater_Than_Or_Equal_To_Configured_Min(object value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual(validationResult.ErrorMessage, "validation_outOfRangeMinimum"); + } + } + + [TestCase(1.7, true)] + [TestCase(1.9, true)] + [TestCase(2.1, false)] + public void Validates_Is_Less_Than_Or_Equal_To_Configured_Max(object value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual(validationResult.ErrorMessage, "validation_outOfRangeMaximum"); + } + } + + [TestCase(1.4, false)] + [TestCase(1.5, true)] + public void Validates_Matches_Configured_Step(object value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual(validationResult.ErrorMessage, "validation_invalidStep"); + } + } + private static object? FromEditor(object? value) => CreateValueEditor().FromEditor(new ContentPropertyData(value, null), null); @@ -86,11 +166,26 @@ public class DecimalValueEditorTests private static DecimalPropertyEditor.DecimalPropertyValueEditor CreateValueEditor() { - var valueEditor = new DecimalPropertyEditor.DecimalPropertyValueEditor( + var localizedTextServiceMock = new Mock(); + localizedTextServiceMock.Setup(x => x.Localize( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns((string key, string alias, CultureInfo culture, IDictionary args) => $"{key}_{alias}"); + return new DecimalPropertyEditor.DecimalPropertyValueEditor( Mock.Of(), Mock.Of(), Mock.Of(), - new DataEditorAttribute("alias")); - return valueEditor; + new DataEditorAttribute("alias"), + localizedTextServiceMock.Object) + { + ConfigurationObject = new Dictionary + { + { "min", 1.1 }, + { "max", 1.9 }, + { "step", 0.2 } + } + }; } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/IntegerValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/IntegerValueEditorTests.cs new file mode 100644 index 0000000000..d9545b8126 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/IntegerValueEditorTests.cs @@ -0,0 +1,191 @@ +using System.Globalization; +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.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class IntegerValueEditorTests +{ + // annoyingly we can't use decimals etc. in attributes, so we can't turn these into test cases :( + private Dictionary _valuesAndExpectedResults = new(); + + [SetUp] + public void SetUp() => _valuesAndExpectedResults = new Dictionary + { + { 123m, 123 }, + { 123, 123 }, + { -123, -123 }, + { 123.45d, null }, + { "123.45", null }, + { "1234.56", null }, + { "123,45", null }, + { "1.234,56", null }, + { "123 45", null }, + { "something", null }, + { true, null }, + { new object(), null }, + { new List { "some", "values" }, null }, + { Guid.NewGuid(), null }, + { new GuidUdi(Constants.UdiEntityType.Document, Guid.NewGuid()), null } + }; + + [Test] + public void Can_Parse_Values_From_Editor() + { + foreach (var (value, expected) in _valuesAndExpectedResults) + { + var fromEditor = FromEditor(value); + Assert.AreEqual(expected, fromEditor, message: $"Failed for: {value}"); + } + } + + [Test] + public void Can_Parse_Values_To_Editor() + { + foreach (var (value, expected) in _valuesAndExpectedResults) + { + var toEditor = ToEditor(value); + Assert.AreEqual(expected, toEditor, message: $"Failed for: {value}"); + } + } + + [Test] + public void Null_From_Editor_Yields_Null() + { + var result = FromEditor(null); + Assert.IsNull(result); + } + + [Test] + public void Null_To_Editor_Yields_Null() + { + var result = ToEditor(null); + Assert.IsNull(result); + } + + [TestCase("x", false)] + [TestCase(10, true)] + public void Validates_Is_Integer(object value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual($"The value {value} is not a valid integer", validationResult.ErrorMessage); + } + } + + [TestCase(8, false)] + [TestCase(10, true)] + [TestCase(12, true)] + public void Validates_Is_Greater_Than_Or_Equal_To_Configured_Min(object value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual("validation_outOfRangeMinimum", validationResult.ErrorMessage); + } + } + + [TestCase(18, true)] + [TestCase(20, true)] + [TestCase(22, false)] + public void Validates_Is_Less_Than_Or_Equal_To_Configured_Max(object value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual("validation_outOfRangeMaximum", validationResult.ErrorMessage); + } + } + + [TestCase(17, false)] + [TestCase(18, true)] + public void Validates_Matches_Configured_Step(object value, bool expectedSuccess) + { + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual("validation_invalidStep", validationResult.ErrorMessage); + } + } + + private static object? FromEditor(object? value) + => CreateValueEditor().FromEditor(new ContentPropertyData(value, null), null); + + private static object? ToEditor(object? value) + { + var property = new Mock(); + property + .Setup(p => p.GetValue(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(value); + + return CreateValueEditor().ToEditor(property.Object); + } + + private static IntegerPropertyEditor.IntegerPropertyValueEditor CreateValueEditor() + { + var localizedTextServiceMock = new Mock(); + localizedTextServiceMock.Setup(x => x.Localize( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns((string key, string alias, CultureInfo culture, IDictionary args) => $"{key}_{alias}"); + return new IntegerPropertyEditor.IntegerPropertyValueEditor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + new DataEditorAttribute("alias"), + localizedTextServiceMock.Object) + { + ConfigurationObject = new Dictionary + { + { "min", 10 }, + { "max", 20 }, + { "step", 2 } + } + }; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/NumericValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/NumericValueEditorTests.cs deleted file mode 100644 index 683be6dce2..0000000000 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/NumericValueEditorTests.cs +++ /dev/null @@ -1,96 +0,0 @@ -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.PropertyEditors; -using Umbraco.Cms.Core.Serialization; -using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Strings; - -namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; - -[TestFixture] -public class NumericValueEditorTests -{ - // annoyingly we can't use decimals etc. in attributes, so we can't turn these into test cases :( - private Dictionary _valuesAndExpectedResults = new(); - - [SetUp] - public void SetUp() => _valuesAndExpectedResults = new Dictionary - { - { 123m, 123 }, - { 123, 123 }, - { -123, -123 }, - { 123.45d, null }, - { "123.45", null }, - { "1234.56", null }, - { "123,45", null }, - { "1.234,56", null }, - { "123 45", null }, - { "something", null }, - { true, null }, - { new object(), null }, - { new List { "some", "values" }, null }, - { Guid.NewGuid(), null }, - { new GuidUdi(Constants.UdiEntityType.Document, Guid.NewGuid()), null } - }; - - [Test] - public void Can_Parse_Values_From_Editor() - { - foreach (var (value, expected) in _valuesAndExpectedResults) - { - var fromEditor = FromEditor(value); - Assert.AreEqual(expected, fromEditor, message: $"Failed for: {value}"); - } - } - - [Test] - public void Can_Parse_Values_To_Editor() - { - foreach (var (value, expected) in _valuesAndExpectedResults) - { - var toEditor = ToEditor(value); - Assert.AreEqual(expected, toEditor, message: $"Failed for: {value}"); - } - } - - [Test] - public void Null_From_Editor_Yields_Null() - { - var result = FromEditor(null); - Assert.IsNull(result); - } - - [Test] - public void Null_To_Editor_Yields_Null() - { - var result = ToEditor(null); - Assert.IsNull(result); - } - - private static object? FromEditor(object? value) - => CreateValueEditor().FromEditor(new ContentPropertyData(value, null), null); - - private static object? ToEditor(object? value) - { - var property = new Mock(); - property - .Setup(p => p.GetValue(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(value); - - return CreateValueEditor().ToEditor(property.Object); - } - - private static IntegerPropertyEditor.IntegerPropertyValueEditor CreateValueEditor() - { - var valueEditor = new IntegerPropertyEditor.IntegerPropertyValueEditor( - Mock.Of(), - Mock.Of(), - Mock.Of(), - new DataEditorAttribute("alias")); - return valueEditor; - } -} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/SliderValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/SliderValueEditorTests.cs index 6ec212c02e..7adda7a525 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/SliderValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/SliderValueEditorTests.cs @@ -1,11 +1,15 @@ -using System.Text.Json.Nodes; +using System.Globalization; +using System.Text.Json.Nodes; 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.Serialization; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Infrastructure.Serialization; @@ -14,7 +18,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; [TestFixture] public class SliderValueEditorTests { +#pragma warning disable IDE1006 // Naming Styles public static object[] InvalidCaseData = new object[] +#pragma warning restore IDE1006 // Naming Styles { 123m, 123, @@ -99,6 +105,132 @@ public class SliderValueEditorTests Assert.IsNull(result); } + [TestCase(true, 1.1, 1.1, true)] + [TestCase(true, 1.1, 1.3, true)] + [TestCase(false, 1.1, 1.1, true)] + [TestCase(false, 1.1, 1.3, false)] + public void Validates_Contains_Range_Only_When_Enabled(bool enableRange, decimal from, decimal to, bool expectedSuccess) + { + var value = new JsonObject + { + { "from", from }, + { "to", to }, + }; + var editor = CreateValueEditor(enableRange: enableRange); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual(validationResult.ErrorMessage, "validation_unexpectedRange"); + } + } + + [TestCase(1.1, 1.1, true)] + [TestCase(1.1, 1.3, true)] + [TestCase(1.3, 1.1, false)] + public void Validates_Contains_Valid_Range_Only_When_Enabled(decimal from, decimal to, bool expectedSuccess) + { + var value = new JsonObject + { + { "from", from }, + { "to", to }, + }; + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual(validationResult.ErrorMessage, "validation_invalidRange"); + } + } + + [TestCase(0.9, 1.1, false)] + [TestCase(1.1, 1.1, true)] + [TestCase(1.3, 1.7, true)] + public void Validates_Is_Greater_Than_Or_Equal_To_Configured_Min(decimal from, decimal to, bool expectedSuccess) + { + var value = new JsonObject + { + { "from", from }, + { "to", to }, + }; + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual(validationResult.ErrorMessage, "validation_outOfRangeMinimum"); + } + } + + [TestCase(1.3, 1.7, true)] + [TestCase(1.9, 1.9, true)] + [TestCase(1.9, 2.1, false)] + public void Validates_Is_Less_Than_Or_Equal_To_Configured_Max(decimal from, decimal to, bool expectedSuccess) + { + var value = new JsonObject + { + { "from", from }, + { "to", to }, + }; + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual(validationResult.ErrorMessage, "validation_outOfRangeMaximum"); + } + } + + [TestCase(1.3, 1.7, true)] + [TestCase(1.4, 1.7, false)] + [TestCase(1.3, 1.6, false)] + public void Validates_Matches_Configured_Step(decimal from, decimal to, bool expectedSuccess) + { + var value = new JsonObject + { + { "from", from }, + { "to", to }, + }; + var editor = CreateValueEditor(); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual(validationResult.ErrorMessage, "validation_invalidStep"); + } + } + private static object? FromEditor(object? value) => CreateValueEditor().FromEditor(new ContentPropertyData(value, null), null); @@ -112,13 +244,29 @@ public class SliderValueEditorTests return CreateValueEditor().ToEditor(property.Object); } - private static SliderPropertyEditor.SliderPropertyValueEditor CreateValueEditor() + private static SliderPropertyEditor.SliderPropertyValueEditor CreateValueEditor(bool enableRange = true) { - var valueEditor = new SliderPropertyEditor.SliderPropertyValueEditor( + var localizedTextServiceMock = new Mock(); + localizedTextServiceMock.Setup(x => x.Localize( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns((string key, string alias, CultureInfo culture, IDictionary args) => $"{key}_{alias}"); + return new SliderPropertyEditor.SliderPropertyValueEditor( Mock.Of(), new SystemTextJsonSerializer(), Mock.Of(), - new DataEditorAttribute("alias")); - return valueEditor; + new DataEditorAttribute("alias"), + localizedTextServiceMock.Object) + { + ConfigurationObject = new SliderConfiguration + { + EnableRange = enableRange, + MinimumValue = 1.1m, + MaximumValue = 1.9m, + Step = 0.2m + }, + }; } }