Server side validation for property editors (integer, decimal and slider) (#18428)

* Server side validation for integer property value editor.

* Apply suggestions from code review

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Applied further changes from code review.

* Localized validation messages.

* Added server-side validation for decimal property editor.

* Added server-side validation for slider property editor.

* Moved repeated step validation into a helper method.

* Add danish translations

---------

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>
This commit is contained in:
Andy Butland
2025-02-25 13:33:20 +01:00
committed by GitHub
parent fe71f571bb
commit ebc38f4cb3
14 changed files with 1007 additions and 116 deletions

View File

@@ -116,6 +116,11 @@ Mange hilsner fra Umbraco robotten
<key alias="invalidMediaType">Den valgte medie type er ugyldig.</key>
<key alias="multipleMediaNotAllowed">Det er kun tilladt at vælge ét medie.</key>
<key alias="invalidStartNode">Valgt medie kommer fra en ugyldig mappe.</key>
<key alias="outOfRangeMinimum">Værdien %0% er mindre end det tilladte minimum af %1%.</key>
<key alias="outOfRangeMaximum">Værdien %0% er større end det tilladte maksimum af %1%.</key>
<key alias="invalidStep">Værdien %0% passer ikke med den konfigureret trin værdi af %1% og mindste værdi af %2%.</key>
<key alias="unexpectedRange">Værdien %0% forventes ikke at indeholde et spænd.</key>
<key alias="invalidRange">Værdien %0% forventes at have en værdi der er større end fra værdien.</key>
</area>
<area alias="recycleBin">
<key alias="contentTrashed">Slettet indhold med Id: {0} Relateret til original "parent" med id: {1}</key>

View File

@@ -387,6 +387,11 @@
<key alias="duplicateUserGroupName">User group name '%0%' is already taken</key>
<key alias="duplicateMemberGroupName">Member group name '%0%' is already taken</key>
<key alias="duplicateUsername">Username '%0%' is already taken</key>
<key alias="outOfRangeMinimum">The value %0% is less than the allowed minimum value of %1%</key>
<key alias="outOfRangeMaximum">The value %0% is greater than the allowed maximum value of %1%</key>
<key alias="invalidStep">The value %0% does not correspond with the configured step value of %1% and minimum value of %2%</key>
<key alias="unexpectedRange">The value %0% is not expected to contain a range</key>
<key alias="invalidRange">The value %0% is not expected to have a to value less than the from value</key>
<key alias="invalidMediaType">The chosen media type is invalid.</key>
<key alias="multipleMediaNotAllowed">Multiple selected media is not allowed.</key>
<key alias="invalidStartNode">The selected media is from the wrong folder.</key>

View File

@@ -385,6 +385,11 @@
<key alias="duplicateUserGroupName">User group name '%0%' is already taken</key>
<key alias="duplicateMemberGroupName">Member group name '%0%' is already taken</key>
<key alias="duplicateUsername">Username '%0%' is already taken</key>
<key alias="outOfRangeMinimum">The value %0% is less than the allowed minimum value of %1%</key>
<key alias="outOfRangeMaximum">The value %0% is greater than the allowed maximum value of %1%</key>
<key alias="invalidStep">The value %0% does not correspond with the configured step value of %1% and minimum value of %2%</key>
<key alias="unexpectedRange">The value %0% is not expected to contain a range</key>
<key alias="invalidRange">The value %0% is not expected to have a to value less than the from value</key>
<key alias="entriesShort"><![CDATA[Minimum %0% entries, requires <strong>%1%</strong> more.]]></key>
<key alias="entriesExceed"><![CDATA[Maximum %0% entries, <strong>%1%</strong> too many.]]></key>
<key alias="entriesAreasMismatch">The content amount requirements are not met for one or more areas.</key>

View File

@@ -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;
/// <summary>
/// Represents a decimal property and parameter editor.
/// Represents a decimal property editor.
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.Decimal,
@@ -32,19 +37,29 @@ public class DecimalPropertyEditor : DataEditor
/// <inheritdoc />
protected override IConfigurationEditor CreateConfigurationEditor() => new DecimalConfigurationEditor();
/// <summary>
/// Defines the value editor for the decimal property editor.
/// </summary>
internal class DecimalPropertyValueEditor : DataValueEditor
{
/// <summary>
/// Initializes a new instance of the <see cref="DecimalPropertyValueEditor"/> class.
/// </summary>
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)]);
/// <inheritdoc/>
public override object? ToEditor(IProperty property, string? culture = null, string? segment = null)
=> TryParsePropertyValue(property.GetValue(culture, segment));
/// <inheritdoc/>
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;
/// <summary>
/// Base validator for the decimal property editor validation against data type configured values.
/// </summary>
internal abstract class DecimalPropertyConfigurationValidatorBase : SimplePropertyConfigurationValidatorBase<double>
{
/// <summary>
/// The configuration key for the minimum value.
/// </summary>
protected const string ConfigurationKeyMinValue = "min";
/// <summary>
/// The configuration key for the maximum value.
/// </summary>
protected const string ConfigurationKeyMaxValue = "max";
/// <summary>
/// The configuration key for the step value.
/// </summary>
protected const string ConfigurationKeyStepValue = "step";
/// <summary>
/// Initializes a new instance of the <see cref="DecimalPropertyConfigurationValidatorBase"/> class.
/// </summary>
protected DecimalPropertyConfigurationValidatorBase(ILocalizedTextService localizedTextService) => LocalizedTextService = localizedTextService;
/// <summary>
/// Gets the <see cref="ILocalizedTextService"/>.
/// </summary>
protected ILocalizedTextService LocalizedTextService { get; }
/// <inheritdoc/>
protected override bool TryParsePropertyValue(object? value, out double parsedDecimalValue)
=> double.TryParse(value?.ToString(), CultureInfo.InvariantCulture, out parsedDecimalValue);
}
/// <summary>
/// Validates the min/max configuration for the decimal property editor.
/// </summary>
internal class MinMaxValidator : DecimalPropertyConfigurationValidatorBase, IValueValidator
{
/// <summary>
/// Initializes a new instance of the <see cref="MinMaxValidator"/> class.
/// </summary>
public MinMaxValidator(ILocalizedTextService localizedTextService)
: base(localizedTextService)
{
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> 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"]);
}
}
}
/// <summary>
/// Validates the step configuration for the decimal property editor.
/// </summary>
internal class StepValidator : DecimalPropertyConfigurationValidatorBase, IValueValidator
{
/// <summary>
/// Initializes a new instance of the <see cref="StepValidator"/> class.
/// </summary>
public StepValidator(ILocalizedTextService localizedTextService)
: base(localizedTextService)
{
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> 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"]);
}
}
}
}
}

View File

@@ -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;
/// <summary>
/// Represents an integer property and parameter editor.
/// Represents a decimal property editor.
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.Integer,
@@ -17,6 +22,9 @@ namespace Umbraco.Cms.Core.PropertyEditors;
ValueEditorIsReusable = true)]
public class IntegerPropertyEditor : DataEditor
{
/// <summary>
/// Initializes a new instance of the <see cref="IntegerPropertyEditor"/> class.
/// </summary>
public IntegerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory)
: base(dataValueEditorFactory)
=> SupportsReadOnly = true;
@@ -28,19 +36,28 @@ public class IntegerPropertyEditor : DataEditor
/// <inheritdoc />
protected override IConfigurationEditor CreateConfigurationEditor() => new IntegerConfigurationEditor();
/// <summary>
/// Defines the value editor for the integer property editor.
/// </summary>
internal class IntegerPropertyValueEditor : DataValueEditor
{
/// <summary>
/// Initializes a new instance of the <see cref="IntegerPropertyValueEditor"/> class.
/// </summary>
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)]);
/// <inheritdoc/>
public override object? ToEditor(IProperty property, string? culture = null, string? segment = null)
=> TryParsePropertyValue(property.GetValue(culture, segment));
/// <inheritdoc/>
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;
/// <summary>
/// /// Base validator for the integer property editor validation against data type configured values.
/// </summary>
internal abstract class IntegerPropertyConfigurationValidatorBase : SimplePropertyConfigurationValidatorBase<int>
{
/// <summary>
/// The configuration key for the minimum value.
/// </summary>
protected const string ConfigurationKeyMinValue = "min";
/// <summary>
/// The configuration key for the maximum value.
/// </summary>
protected const string ConfigurationKeyMaxValue = "max";
/// <summary>
/// The configuration key for the step value.
/// </summary>
protected const string ConfigurationKeyStepValue = "step";
/// <summary>
/// Initializes a new instance of the <see cref="IntegerPropertyConfigurationValidatorBase"/> class.
/// </summary>
protected IntegerPropertyConfigurationValidatorBase(ILocalizedTextService localizedTextService) => LocalizedTextService = localizedTextService;
/// <summary>
/// Gets the <see cref="ILocalizedTextService"/>.
/// </summary>
protected ILocalizedTextService LocalizedTextService { get; }
/// <inheritdoc/>
protected override bool TryParsePropertyValue(object? value, out int parsedIntegerValue)
=> int.TryParse(value?.ToString(), CultureInfo.InvariantCulture, out parsedIntegerValue);
}
/// <summary>
/// Validates the min/max configuration for the integer property editor.
/// </summary>
internal class MinMaxValidator : IntegerPropertyConfigurationValidatorBase, IValueValidator
{
/// <summary>
/// Initializes a new instance of the <see cref="MinMaxValidator"/> class.
/// </summary>
public MinMaxValidator(ILocalizedTextService localizedTextService)
: base(localizedTextService)
{
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> 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"]);
}
}
}
/// <summary>
/// Validates the step configuration for the integer property editor.
/// </summary>
internal class StepValidator : IntegerPropertyConfigurationValidatorBase, IValueValidator
{
/// <summary>
/// Initializes a new instance of the <see cref="StepValidator"/> class.
/// </summary>
public StepValidator(ILocalizedTextService localizedTextService)
: base(localizedTextService)
{
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> 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"]);
}
}
}
}
}

View File

@@ -13,4 +13,7 @@ public class SliderConfiguration
[ConfigurationField("maxVal")]
public decimal MaximumValue { get; set; }
[ConfigurationField("step")]
public decimal Step { get; set; }
}

View File

@@ -0,0 +1,24 @@
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// <summary>
/// Provides helper methods for validation of property editor values based on data type configuration.
/// </summary>
public static class ValidationHelper
{
/// <summary>
/// Checks if a provided value is valid based on the configured step and minimum values.
/// </summary>
/// <param name="value">The provided value.</param>
/// <param name="min">The configured minimum value.</param>
/// <param name="step">The configured step value.</param>
/// <returns>True if the value is valid otherwise false.</returns>
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;
}
}

View File

@@ -0,0 +1,33 @@
using System.Diagnostics.CodeAnalysis;
namespace Umbraco.Cms.Core.PropertyEditors.Validators;
/// <summary>
/// Provides common functionality to validators that rely on data type configuration.
/// </summary>
public abstract class DictionaryConfigurationValidatorBase
{
/// <summary>
/// Retrieves a typed value from data type dictionary configuration for the provided key.
/// </summary>
/// <param name="dataTypeConfiguration">The data type configuration.</param>
/// <param name="key">The configuration key.</param>
/// <param name="value">The configuration value (if found), otherwise zero.</param>
/// <returns>True if the configured value was found.</returns>
protected static bool TryGetConfiguredValue<TValue>(object? dataTypeConfiguration, string key, [NotNullWhen(true)] out TValue? value)
{
if (dataTypeConfiguration is not Dictionary<string, object> configuration)
{
value = default;
return false;
}
if (configuration.TryGetValue(key, out object? obj) && obj is TValue castValue)
{
value = castValue;
return true;
}
value = default;
return false;
}
}

View File

@@ -0,0 +1,18 @@
using Umbraco.Cms.Core.Configuration.Models.Validation;
namespace Umbraco.Cms.Core.PropertyEditors.Validators;
/// <summary>
/// Provides common functionality to validators that rely on data type configuration.
/// </summary>
/// <typeparam name="TValue">The type to parse to.</typeparam>
public abstract class SimplePropertyConfigurationValidatorBase<TValue> : DictionaryConfigurationValidatorBase
{
/// <summary>
/// Parses the raw property value into it's typed equivalent.
/// </summary>
/// <param name="value">The property value as a nullable object.</param>
/// <param name="parsedValue">The parsed value.</param>
/// <returns>True if the parse succeeded, otherwise false.</returns>
protected abstract bool TryParsePropertyValue(object? value, out TValue parsedValue);
}

View File

@@ -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;
/// <summary>
/// Represents a slider editor.
/// Represents a slider editor.
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.Slider,
@@ -38,18 +46,29 @@ public class SliderPropertyEditor : DataEditor
protected override IConfigurationEditor CreateConfigurationEditor() =>
new SliderConfigurationEditor(_ioHelper);
/// <summary>
/// Defines the value editor for the slider property editor.
/// </summary>
internal class SliderPropertyValueEditor : DataValueEditor
{
private readonly IJsonSerializer _jsonSerializer;
/// <summary>
/// Initializes a new instance of the <see cref="SliderPropertyValueEditor"/> class.
/// </summary>
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));
}
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
=> editorValue.Value is not null && _jsonSerializer.TryDeserialize(editorValue.Value, out SliderRange? sliderRange)
? sliderRange.ToString()
: null;
/// <summary>
/// Represents a slider value.
/// </summary>
internal class SliderRange
{
/// <summary>
/// Gets or sets the slider range from value.
/// </summary>
public decimal From { get; set; }
/// <summary>
/// Gets or sets the slide range to value.
/// </summary>
public decimal To { get; set; }
public override string ToString() => From == To ? $"{From.ToString(CultureInfo.InvariantCulture)}" : $"{From.ToString(CultureInfo.InvariantCulture)},{To.ToString(CultureInfo.InvariantCulture)}";
/// <inheritdoc/>
public override string ToString()
=> From == To
? $"{From.ToString(CultureInfo.InvariantCulture)}"
: $"{From.ToString(CultureInfo.InvariantCulture)},{To.ToString(CultureInfo.InvariantCulture)}";
}
/// <summary>
/// Base validator for the slider property editor validation against data type configured values.
/// </summary>
internal abstract class SliderPropertyConfigurationValidatorBase
{
/// <summary>
/// The configuration key for the minimum value.
/// </summary>
protected const string ConfigurationKeyMinValue = "minVal";
/// <summary>
/// The configuration key for the maximum value.
/// </summary>
protected const string ConfigurationKeyMaxValue = "maxVal";
/// <summary>
/// The configuration key for the step value.
/// </summary>
protected const string ConfigurationKeyStepValue = "step";
/// <summary>
/// The configuration key for the enable range value.
/// </summary>
protected const string ConfigurationKeyEnableRangeValue = "enableRange";
/// <summary>
/// Initializes a new instance of the <see cref="SliderPropertyConfigurationValidatorBase"/> class.
/// </summary>
protected SliderPropertyConfigurationValidatorBase(ILocalizedTextService localizedTextService) => LocalizedTextService = localizedTextService;
/// <summary>
/// Gets the <see cref="ILocalizedTextService"/>.
/// </summary>
protected ILocalizedTextService LocalizedTextService { get; }
/// <summary>
/// Parses a <see cref="SliderRange"/> from the provided value.
/// </summary>
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<decimal>();
}
/// <summary>
/// Validates the range configuration for the slider property editor.
/// </summary>
internal class RangeValidator : SliderPropertyConfigurationValidatorBase, IValueValidator
{
/// <summary>
/// Initializes a new instance of the <see cref="MinMaxValidator"/> class.
/// </summary>
public RangeValidator(ILocalizedTextService localizedTextService)
: base(localizedTextService)
{
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> 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"]);
}
}
}
/// <summary>
/// Validates the min/max configuration for the slider property editor.
/// </summary>
internal class MinMaxValidator : SliderPropertyConfigurationValidatorBase, IValueValidator
{
/// <summary>
/// Initializes a new instance of the <see cref="MinMaxValidator"/> class.
/// </summary>
public MinMaxValidator(ILocalizedTextService localizedTextService)
: base(localizedTextService)
{
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> 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"]);
}
}
}
/// <summary>
/// Validates the step configuration for the slider property editor.
/// </summary>
internal class StepValidator : SliderPropertyConfigurationValidatorBase, IValueValidator
{
/// <summary>
/// Initializes a new instance of the <see cref="StepValidator"/> class.
/// </summary>
public StepValidator(ILocalizedTextService localizedTextService)
: base(localizedTextService)
{
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> 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"]);
}
}
}
}
}

View File

@@ -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<ILocalizedTextService>();
localizedTextServiceMock.Setup(x => x.Localize(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CultureInfo>(),
It.IsAny<IDictionary<string, string>>()))
.Returns((string key, string alias, CultureInfo culture, IDictionary<string, string> args) => $"{key}_{alias}");
return new DecimalPropertyEditor.DecimalPropertyValueEditor(
Mock.Of<IShortStringHelper>(),
Mock.Of<IJsonSerializer>(),
Mock.Of<IIOHelper>(),
new DataEditorAttribute("alias"));
return valueEditor;
new DataEditorAttribute("alias"),
localizedTextServiceMock.Object)
{
ConfigurationObject = new Dictionary<string, object>
{
{ "min", 1.1 },
{ "max", 1.9 },
{ "step", 0.2 }
}
};
}
}

View File

@@ -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<object?,object?> _valuesAndExpectedResults = new();
[SetUp]
public void SetUp() => _valuesAndExpectedResults = new Dictionary<object?, object?>
{
{ 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<string> { "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<IProperty>();
property
.Setup(p => p.GetValue(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool>()))
.Returns(value);
return CreateValueEditor().ToEditor(property.Object);
}
private static IntegerPropertyEditor.IntegerPropertyValueEditor CreateValueEditor()
{
var localizedTextServiceMock = new Mock<ILocalizedTextService>();
localizedTextServiceMock.Setup(x => x.Localize(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CultureInfo>(),
It.IsAny<IDictionary<string, string>>()))
.Returns((string key, string alias, CultureInfo culture, IDictionary<string, string> args) => $"{key}_{alias}");
return new IntegerPropertyEditor.IntegerPropertyValueEditor(
Mock.Of<IShortStringHelper>(),
Mock.Of<IJsonSerializer>(),
Mock.Of<IIOHelper>(),
new DataEditorAttribute("alias"),
localizedTextServiceMock.Object)
{
ConfigurationObject = new Dictionary<string, object>
{
{ "min", 10 },
{ "max", 20 },
{ "step", 2 }
}
};
}
}

View File

@@ -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<object?,object?> _valuesAndExpectedResults = new();
[SetUp]
public void SetUp() => _valuesAndExpectedResults = new Dictionary<object?, object?>
{
{ 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<string> { "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<IProperty>();
property
.Setup(p => p.GetValue(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool>()))
.Returns(value);
return CreateValueEditor().ToEditor(property.Object);
}
private static IntegerPropertyEditor.IntegerPropertyValueEditor CreateValueEditor()
{
var valueEditor = new IntegerPropertyEditor.IntegerPropertyValueEditor(
Mock.Of<IShortStringHelper>(),
Mock.Of<IJsonSerializer>(),
Mock.Of<IIOHelper>(),
new DataEditorAttribute("alias"));
return valueEditor;
}
}

View File

@@ -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<ILocalizedTextService>();
localizedTextServiceMock.Setup(x => x.Localize(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CultureInfo>(),
It.IsAny<IDictionary<string, string>>()))
.Returns((string key, string alias, CultureInfo culture, IDictionary<string, string> args) => $"{key}_{alias}");
return new SliderPropertyEditor.SliderPropertyValueEditor(
Mock.Of<IShortStringHelper>(),
new SystemTextJsonSerializer(),
Mock.Of<IIOHelper>(),
new DataEditorAttribute("alias"));
return valueEditor;
new DataEditorAttribute("alias"),
localizedTextServiceMock.Object)
{
ConfigurationObject = new SliderConfiguration
{
EnableRange = enableRange,
MinimumValue = 1.1m,
MaximumValue = 1.9m,
Step = 0.2m
},
};
}
}