Server side validation for property editors (multiple text strings) (#18581)

* Server-side validation for multiple text strings property editor.

* Added unit tests for block list min/max server validation.

* Add danish translations

* Add test showing issue

* Fix issue

---------

Co-authored-by: mole <nikolajlauridsen@protonmail.ch>
This commit is contained in:
Andy Butland
2025-03-06 10:48:48 +01:00
committed by GitHub
parent 6d0dd82781
commit 45ea6a3cfc
13 changed files with 392 additions and 40 deletions

View File

@@ -1,4 +1,4 @@
// Copyright (c) Umbraco.
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.ComponentModel.DataAnnotations;
@@ -16,12 +16,22 @@ internal abstract class BlockEditorMinMaxValidatorBase<TValue, TLayout> : IValue
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
/// <summary>
/// Initializes a new instance of the <see cref="BlockEditorMinMaxValidatorBase{TValue, TLayout}"/> class.
/// </summary>
protected BlockEditorMinMaxValidatorBase(ILocalizedTextService textService) => TextService = textService;
/// <summary>
/// Gets the <see cref="ILocalizedTextService"/>
/// </summary>
protected ILocalizedTextService TextService { get; }
/// <inheritdoc/>
public abstract IEnumerable<ValidationResult> Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext);
/// <summary>
/// Validates the number of blocks are within the configured minimum and maximum values.
/// </summary>
protected IEnumerable<ValidationResult> ValidateNumberOfBlocks(BlockEditorData<TValue, TLayout>? blockEditorData, int? min, int? max)
{
var numberOfBlocks = blockEditorData?.Layout?.Count() ?? 0;
@@ -35,8 +45,8 @@ internal abstract class BlockEditorMinMaxValidatorBase<TValue, TLayout> : IValue
TextService.Localize(
"validation",
"entriesShort",
new[] { min.ToString(), (min - numberOfBlocks).ToString(), }),
new[] { "minCount" });
[min.ToString(), (min - numberOfBlocks).ToString(),]),
["value"]);
}
}
@@ -46,8 +56,8 @@ internal abstract class BlockEditorMinMaxValidatorBase<TValue, TLayout> : IValue
TextService.Localize(
"validation",
"entriesExceed",
new[] { max.ToString(), (numberOfBlocks - max).ToString(), }),
new[] { "maxCount" });
[max.ToString(), (numberOfBlocks - max).ToString(),]),
["value"]);
}
}
}

View File

@@ -9,7 +9,7 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Represents a block list property editor.
/// Represents a block list property editor.
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.BlockList,
@@ -19,6 +19,9 @@ public class BlockListPropertyEditor : BlockListPropertyEditorBase
{
private readonly IIOHelper _ioHelper;
/// <summary>
/// Initializes a new instance of the <see cref="BlockListPropertyEditor"/> class.
/// </summary>
public BlockListPropertyEditor(
IDataValueEditorFactory dataValueEditorFactory,
IIOHelper ioHelper,
@@ -27,6 +30,9 @@ public class BlockListPropertyEditor : BlockListPropertyEditorBase
: base(dataValueEditorFactory, blockValuePropertyIndexValueFactory, jsonSerializer)
=> _ioHelper = ioHelper;
/// <summary>
/// Initializes a new instance of the <see cref="BlockListPropertyEditor"/> class.
/// </summary>
[Obsolete("Use constructor that doesn't take PropertyEditorCollection, scheduled for removal in V15")]
public BlockListPropertyEditor(
IDataValueEditorFactory dataValueEditorFactory,
@@ -38,6 +44,7 @@ public class BlockListPropertyEditor : BlockListPropertyEditorBase
{
}
/// <inheritdoc/>
public override bool SupportsConfigurableElements => true;
/// <inheritdoc />
@@ -50,6 +57,7 @@ public class BlockListPropertyEditor : BlockListPropertyEditorBase
return valueEditor.MergePartialPropertyValueForCulture(sourceValue, targetValue, culture);
}
/// <inheritdoc/>
public override object? MergeVariantInvariantPropertyValue(
object? sourceValue,
object? targetValue,
@@ -60,10 +68,7 @@ public class BlockListPropertyEditor : BlockListPropertyEditorBase
return valueEditor.MergeVariantInvariantPropertyValue(sourceValue, targetValue, canUpdateInvariantData, allowedCultures);
}
#region Pre Value Editor
/// <inheritdoc/>
protected override IConfigurationEditor CreateConfigurationEditor() =>
new BlockListConfigurationEditor(_ioHelper);
#endregion
}

View File

@@ -24,12 +24,18 @@ public abstract class BlockListPropertyEditorBase : DataEditor
private readonly IBlockValuePropertyIndexValueFactory _blockValuePropertyIndexValueFactory;
private readonly IJsonSerializer _jsonSerializer;
/// <summary>
/// Initializes a new instance of the <see cref="BlockListPropertyEditorBase"/> class.
/// </summary>
[Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 15.")]
protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory, IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory)
: this(dataValueEditorFactory,blockValuePropertyIndexValueFactory, StaticServiceProvider.Instance.GetRequiredService<IJsonSerializer>())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="BlockListPropertyEditorBase"/> class.
/// </summary>
protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory, IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory, IJsonSerializer jsonSerializer)
: base(dataValueEditorFactory)
{
@@ -38,21 +44,27 @@ public abstract class BlockListPropertyEditorBase : DataEditor
SupportsReadOnly = true;
}
/// <inheritdoc/>
public override IPropertyIndexValueFactory PropertyIndexValueFactory => _blockValuePropertyIndexValueFactory;
#region Value Editor
/// <summary>
/// Instantiates a new <see cref="BlockEditorDataConverter"/> for use with the block list editor property value editor.
/// Instantiates a new <see cref="BlockEditorDataConverter{BlockListValue, BlockListLayoutItem}"/> for use with the block list editor property value editor.
/// </summary>
/// <returns>A new instance of <see cref="BlockListEditorDataConverter"/>.</returns>
protected virtual BlockEditorDataConverter<BlockListValue, BlockListLayoutItem> CreateBlockEditorDataConverter() => new BlockListEditorDataConverter(_jsonSerializer);
/// <inheritdoc/>
protected override IDataValueEditor CreateValueEditor() =>
DataValueEditorFactory.Create<BlockListEditorPropertyValueEditor>(Attribute!, CreateBlockEditorDataConverter());
/// <summary>
/// Defines the value editor for the block list property editors.
/// </summary>
internal class BlockListEditorPropertyValueEditor : BlockEditorPropertyValueEditor<BlockListValue, BlockListLayoutItem>
{
/// <summary>
/// Initializes a new instance of the <see cref="BlockListEditorPropertyValueEditor"/> class.
/// </summary>
public BlockListEditorPropertyValueEditor(
DataEditorAttribute attribute,
BlockEditorDataConverter<BlockListValue, BlockListLayoutItem> blockEditorDataConverter,
@@ -78,8 +90,12 @@ public abstract class BlockListPropertyEditorBase : DataEditor
/// <inheritdoc />
public override IValueRequiredValidator RequiredValidator => new BlockListValueRequiredValidator(JsonSerializer);
/// <inheritdoc/>
protected override BlockListValue CreateWithLayout(IEnumerable<BlockListLayoutItem> layout) => new(layout);
/// <summary>
/// Validates the min/max configuration for block list property editors.
/// </summary>
private class MinMaxValidator : BlockEditorMinMaxValidatorBase<BlockListValue, BlockListLayoutItem>
{
private readonly BlockEditorValues<BlockListValue, BlockListLayoutItem> _blockEditorValues;
@@ -104,12 +120,11 @@ public abstract class BlockListPropertyEditorBase : DataEditor
}
}
/// <inheritdoc/>
public override IEnumerable<Guid> ConfiguredElementTypeKeys()
{
var configuration = ConfigurationObject as BlockListConfiguration;
return configuration?.Blocks.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty<Guid>();
}
}
#endregion
}

View File

@@ -6,14 +6,17 @@ using Umbraco.Cms.Core.Exceptions;
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.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 multiple text string property editor.
/// Represents a multiple text string property editor.
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.MultipleTextstring,
@@ -24,7 +27,7 @@ public class MultipleTextStringPropertyEditor : DataEditor
private readonly IIOHelper _ioHelper;
/// <summary>
/// Initializes a new instance of the <see cref="MultipleTextStringPropertyEditor" /> class.
/// Initializes a new instance of the <see cref="MultipleTextStringPropertyEditor" /> class.
/// </summary>
public MultipleTextStringPropertyEditor(IIOHelper ioHelper, IDataValueEditorFactory dataValueEditorFactory)
: base(dataValueEditorFactory)
@@ -42,37 +45,42 @@ public class MultipleTextStringPropertyEditor : DataEditor
new MultipleTextStringConfigurationEditor(_ioHelper);
/// <summary>
/// Custom value editor so we can format the value for the editor and the database
/// Defines the value editor for the multiple text string property editor.
/// </summary>
internal class MultipleTextStringPropertyValueEditor : DataValueEditor
{
private static readonly string NewLine = "\n";
private static readonly string[] NewLineDelimiters = { "\r\n", "\r", "\n" };
private static readonly string _newLine = "\n";
private static readonly string[] _newLineDelimiters = { "\r\n", "\r", "\n" };
/// <summary>
/// Initializes a new instance of the <see cref="MultipleTextStringPropertyValueEditor"/> class.
/// </summary>
public MultipleTextStringPropertyValueEditor(
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
DataEditorAttribute attribute)
DataEditorAttribute attribute,
ILocalizedTextService localizedTextService)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
{
Validators.AddRange(new MinMaxValidator(localizedTextService));
}
/// <summary>
/// A custom FormatValidator is used as for multiple text strings, each string should individually be checked
/// against the configured regular expression, rather than the JSON representing all the strings as a whole.
/// A custom <see href="IValueFormatValidator" /> is used as for multiple text strings, each string should individually
/// be checked against the configured regular expression, rather than the JSON representing all the strings as a whole.
/// </summary>
public override IValueFormatValidator FormatValidator => new MultipleTextStringFormatValidator();
/// <summary>
/// The value passed in from the editor will be an array of simple objects so we'll need to parse them to get the
/// string
/// The value passed in from the editor will be an array of simple objects so we'll need to parse them to get the
/// string.
/// </summary>
/// <param name="editorValue"></param>
/// <param name="currentValue"></param>
/// <returns></returns>
/// <remarks>
/// We will also check the pre-values here, if there are more items than what is allowed we'll just trim the end
/// We will also check the pre-values here, if there are more items than what is allowed we'll just trim the end.
/// </remarks>
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
{
@@ -93,30 +101,35 @@ public class MultipleTextStringPropertyEditor : DataEditor
// only allow the max if over 0
if (max > 0)
{
return string.Join(NewLine, value.Take(max));
return string.Join(_newLine, value.Take(max));
}
return string.Join(NewLine, value);
return string.Join(_newLine, value);
}
/// <inheritdoc/>
public override object ToEditor(IProperty property, string? culture = null, string? segment = null)
{
var value = property.GetValue(culture, segment);
// The legacy property editor saved this data as new line delimited! strange but we have to maintain that.
return value is string stringValue
? stringValue.Split(NewLineDelimiters, StringSplitOptions.None)
? stringValue.Split(_newLineDelimiters, StringSplitOptions.None)
: Array.Empty<string>();
}
}
/// <summary>
/// A custom <see href="IValueFormatValidator" /> to check each string against the configured format.
/// </summary>
internal class MultipleTextStringFormatValidator : IValueFormatValidator
{
/// <inheritdoc/>
public IEnumerable<ValidationResult> ValidateFormat(object? value, string valueType, string format)
{
if (value is not IEnumerable<string> textStrings)
{
return Enumerable.Empty<ValidationResult>();
return [];
}
var textStringValidator = new RegexValidator();
@@ -129,7 +142,60 @@ public class MultipleTextStringPropertyEditor : DataEditor
}
}
return Enumerable.Empty<ValidationResult>();
return [];
}
}
/// <summary>
/// Validates the min/max configuration for the multiple text strings property editor.
/// </summary>
internal class MinMaxValidator : IValueValidator
{
private readonly ILocalizedTextService _localizedTextService;
/// <summary>
/// Initializes a new instance of the <see cref="MinMaxValidator"/> class.
/// </summary>
public MinMaxValidator(ILocalizedTextService localizedTextService) => _localizedTextService = localizedTextService;
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext)
{
if (dataTypeConfiguration is not MultipleTextStringConfiguration multipleTextStringConfiguration)
{
yield break;
}
// If we have a null value, treat as an empty collection for minimum number validation.
if (value is not IEnumerable<string> stringValues)
{
stringValues = [];
}
var stringCount = stringValues.Count();
if (stringCount < multipleTextStringConfiguration.Min)
{
if (stringCount == 1)
{
yield return new ValidationResult(
_localizedTextService.Localize("validation", "outOfRangeSingleItemMinimum", [multipleTextStringConfiguration.Min.ToString()]),
["value"]);
}
else
{
yield return new ValidationResult(
_localizedTextService.Localize("validation", "outOfRangeMultipleItemsMinimum", [stringCount.ToString(), multipleTextStringConfiguration.Min.ToString()]),
["value"]);
}
}
if (multipleTextStringConfiguration.Max > 0 && stringCount > multipleTextStringConfiguration.Max)
{
yield return new ValidationResult(
_localizedTextService.Localize("validation", "outOfRangeMultipleItemsMaximum", [stringCount.ToString(), multipleTextStringConfiguration.Max.ToString()]),
["value"]);
}
}
}
}