Render `maxlength` attribute for an input tag, when MaxLength or StringLength validation attributes are applied to the model class.

This commit is contained in:
Artak Mkrtchyan 2018-07-17 14:28:53 -07:00
parent b7335ac768
commit 6d9aa281c5
No known key found for this signature in database
GPG Key ID: 64D580ACBA8CA645
4 changed files with 266 additions and 1 deletions

View File

@ -4,6 +4,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.AspNetCore.Mvc.ViewEngines;
@ -17,15 +18,18 @@ namespace Microsoft.AspNetCore.Mvc
public class MvcViewOptions : IEnumerable<ICompatibilitySwitch>
{
private readonly CompatibilitySwitch<bool> _suppressTempDataAttributePrefix;
private readonly CompatibilitySwitch<bool> _allowRenderingMaxLengthAttribute;
private readonly ICompatibilitySwitch[] _switches;
private HtmlHelperOptions _htmlHelperOptions = new HtmlHelperOptions();
public MvcViewOptions()
{
_suppressTempDataAttributePrefix = new CompatibilitySwitch<bool>(nameof(SuppressTempDataAttributePrefix));
_allowRenderingMaxLengthAttribute = new CompatibilitySwitch<bool>(nameof(AllowRenderingMaxLengthAttribute));
_switches = new[]
{
_suppressTempDataAttributePrefix,
_allowRenderingMaxLengthAttribute
};
}
@ -87,6 +91,18 @@ namespace Microsoft.AspNetCore.Mvc
set => _suppressTempDataAttributePrefix.Value = value;
}
/// <summary>
/// Gets or sets a value that indicates whether the maxlength attribute should be rendered for compatible HTML elements,
/// when they're bound to models marked with either
/// <see cref="StringLengthAttribute"/> or <see cref="MaxLengthAttribute"/> attributes.
/// </summary>
/// <remarks>If both attributes are specified, the one with the smaller value will be used for the rendered `maxlength` attribute.</remarks>
public bool AllowRenderingMaxLengthAttribute
{
get => _allowRenderingMaxLengthAttribute.Value;
set => _allowRenderingMaxLengthAttribute.Value = value;
}
/// <summary>
/// Gets a list <see cref="IViewEngine"/>s used by this application.
/// </summary>

View File

@ -4,6 +4,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
@ -30,6 +31,10 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
private static readonly string[] _placeholderInputTypes =
new[] { "text", "search", "url", "tel", "email", "password", "number" };
// See: (http://www.w3.org/TR/html5/sec-forms.html#apply)
private static readonly string[] _maxLengthInputTypes =
new[] { "text", "search", "url", "tel", "email", "password" };
private readonly IAntiforgery _antiforgery;
private readonly IModelMetadataProvider _metadataProvider;
private readonly IUrlHelperFactory _urlHelperFactory;
@ -92,8 +97,18 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
// Underscores are fine characters in id's.
IdAttributeDotReplacement = optionsAccessor.Value.HtmlHelperOptions.IdAttributeDotReplacement;
AllowRenderingMaxLengthAttribute = optionsAccessor.Value.AllowRenderingMaxLengthAttribute;
}
/// <summary>
/// Gets or sets a value that indicates whether the maxlength attribute should be rendered for compatible HTML input elements,
/// when they're bound to models marked with either
/// <see cref="StringLengthAttribute"/> or <see cref="MaxLengthAttribute"/> attributes.
/// </summary>
/// <remarks>If both attributes are specified, the one with the smaller value will be used for the rendered `maxlength` attribute.</remarks>
protected bool AllowRenderingMaxLengthAttribute { get; }
/// <inheritdoc />
public string IdAttributeDotReplacement { get; }
@ -724,6 +739,11 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
}
AddPlaceholderAttribute(viewContext.ViewData, tagBuilder, modelExplorer, expression);
if (AllowRenderingMaxLengthAttribute)
{
AddMaxLengthAttribute(viewContext.ViewData, tagBuilder, modelExplorer, expression);
}
AddValidationAttributes(viewContext, tagBuilder, modelExplorer, expression);
// If there are any errors for a named field, we add this CSS attribute.
@ -1239,6 +1259,11 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
AddPlaceholderAttribute(viewContext.ViewData, tagBuilder, modelExplorer, expression);
}
if (AllowRenderingMaxLengthAttribute && _maxLengthInputTypes.Contains(suppliedTypeString))
{
AddMaxLengthAttribute(viewContext.ViewData, tagBuilder, modelExplorer, expression);
}
var valueParameter = FormatValue(value, format);
var usedModelState = false;
switch (inputType)
@ -1373,6 +1398,43 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
}
}
/// <summary>
/// Adds a maxlength attribute to the <paramref name="tagBuilder" />.
/// </summary>
/// <param name="viewData">A <see cref="ViewDataDictionary"/> instance for the current scope.</param>
/// <param name="tagBuilder">A <see cref="TagBuilder"/> instance.</param>
/// <param name="modelExplorer">The <see cref="ModelExplorer"/> for the <paramref name="expression"/>.</param>
/// <param name="expression">Expression name, relative to the current model.</param>
protected virtual void AddMaxLengthAttribute(
ViewDataDictionary viewData,
TagBuilder tagBuilder,
ModelExplorer modelExplorer,
string expression)
{
modelExplorer = modelExplorer ?? ExpressionMetadataProvider.FromStringExpression(
expression,
viewData,
_metadataProvider);
int? maxLengthValue = null;
foreach (var attribute in modelExplorer.Metadata.ValidatorMetadata)
{
if (attribute is MaxLengthAttribute maxLengthAttribute && (!maxLengthValue.HasValue || maxLengthValue.Value > maxLengthAttribute.Length))
{
maxLengthValue = maxLengthAttribute.Length;
}
else if (attribute is StringLengthAttribute stringLengthAttribute && (!maxLengthValue.HasValue || maxLengthValue.Value > stringLengthAttribute.MaximumLength))
{
maxLengthValue = stringLengthAttribute.MaximumLength;
}
}
if (maxLengthValue.HasValue)
{
tagBuilder.MergeAttribute("maxlength", maxLengthValue.Value.ToString());
}
}
/// <summary>
/// Adds validation attributes to the <paramref name="tagBuilder" /> if client validation
/// is enabled.

View File

@ -28,6 +28,11 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
values[nameof(MvcViewOptions.SuppressTempDataAttributePrefix)] = true;
}
if (Version >= CompatibilityVersion.Version_2_2)
{
values[nameof(MvcViewOptions.AllowRenderingMaxLengthAttribute)] = true;
}
return values;
}
}

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
@ -190,6 +191,169 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
Assert.Equal(expected, attribute.Value);
}
[Theory]
[InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithMaxLength), ModelWithMaxLengthMetadata.MaxLengthAttributeValue)]
[InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithStringLength), ModelWithMaxLengthMetadata.StringLengthAttributeValue)]
public void GenerateTextArea_RendersMaxLength(string expression, int expectedValue)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var viewContext = GetViewContext<ModelWithMaxLengthMetadata>(model: null, metadataProvider: metadataProvider);
var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression);
var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null);
var htmlAttributes = new Dictionary<string, object>
{
{ "name", "testElement" },
};
// Act
var tagBuilder = htmlGenerator.GenerateTextArea(viewContext, modelExplorer, expression, rows: 1, columns: 1, htmlAttributes);
// Assert
var attribute = Assert.Single(tagBuilder.Attributes, a => a.Key == "maxlength");
Assert.Equal(expectedValue, Int32.Parse(attribute.Value));
}
[Theory]
[InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithMaxLength), ModelWithMaxLengthMetadata.MaxLengthAttributeValue)]
[InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithStringLength), ModelWithMaxLengthMetadata.StringLengthAttributeValue)]
public void GeneratePassword_RendersMaxLength(string expression, int expectedValue)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var viewContext = GetViewContext<ModelWithMaxLengthMetadata>(model: null, metadataProvider: metadataProvider);
var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression);
var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null);
var htmlAttributes = new Dictionary<string, object>
{
{ "name", "testElement" },
};
// Act
var tagBuilder = htmlGenerator.GeneratePassword(viewContext, modelExplorer, expression, null, htmlAttributes);
// Assert
var attribute = Assert.Single(tagBuilder.Attributes, a => a.Key == "maxlength");
Assert.Equal(expectedValue, Int32.Parse(attribute.Value));
}
[Theory]
[InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithMaxLength), ModelWithMaxLengthMetadata.MaxLengthAttributeValue)]
[InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithStringLength), ModelWithMaxLengthMetadata.StringLengthAttributeValue)]
public void GenerateTextBox_RendersMaxLength(string expression, int expectedValue)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var viewContext = GetViewContext<ModelWithMaxLengthMetadata>(model: null, metadataProvider: metadataProvider);
var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression);
var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null);
var htmlAttributes = new Dictionary<string, object>
{
{ "name", "testElement" },
};
// Act
var tagBuilder = htmlGenerator.GenerateTextBox(viewContext, modelExplorer, expression, null, null, htmlAttributes);
// Assert
var attribute = Assert.Single(tagBuilder.Attributes, a => a.Key == "maxlength");
Assert.Equal(expectedValue, Int32.Parse(attribute.Value));
}
[Fact]
public void GenerateTextBox_RendersMaxLength_WithMinimumValueFromBothAttributes()
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var viewContext = GetViewContext<ModelWithMaxLengthMetadata>(model: null, metadataProvider: metadataProvider);
var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), nameof(ModelWithMaxLengthMetadata.FieldWithBothAttributes));
var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null);
var htmlAttributes = new Dictionary<string, object>
{
{ "name", "testElement" },
};
// Act
var tagBuilder = htmlGenerator.GenerateTextBox(viewContext, modelExplorer, nameof(ModelWithMaxLengthMetadata.FieldWithBothAttributes), null, null, htmlAttributes);
// Assert
var attribute = Assert.Single(tagBuilder.Attributes, a => a.Key == "maxlength");
Assert.Equal(Math.Min(ModelWithMaxLengthMetadata.MaxLengthAttributeValue, ModelWithMaxLengthMetadata.StringLengthAttributeValue), Int32.Parse(attribute.Value));
}
[Fact]
public void GenerateTextBox_DoesNotRenderMaxLength_WhenNoAttributesPresent()
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var viewContext = GetViewContext<ModelWithMaxLengthMetadata>(model: null, metadataProvider: metadataProvider);
var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), nameof(ModelWithMaxLengthMetadata.FieldWithoutAttributes));
var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null);
var htmlAttributes = new Dictionary<string, object>
{
{ "name", "testElement" },
};
// Act
var tagBuilder = htmlGenerator.GenerateTextBox(viewContext, modelExplorer, nameof(ModelWithMaxLengthMetadata.FieldWithoutAttributes), null, null, htmlAttributes);
// Assert
Assert.DoesNotContain(tagBuilder.Attributes, a => a.Key == "maxlength");
}
[Theory]
[InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithMaxLength), ModelWithMaxLengthMetadata.MaxLengthAttributeValue)]
[InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithStringLength), ModelWithMaxLengthMetadata.StringLengthAttributeValue)]
public void GenerateTextBox_SearchType_RendersMaxLength(string expression, int expectedValue)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var viewContext = GetViewContext<ModelWithMaxLengthMetadata>(model: null, metadataProvider: metadataProvider);
var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression);
var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null);
var htmlAttributes = new Dictionary<string, object>
{
{ "name", "testElement" },
{ "type", "search"}
};
// Act
var tagBuilder = htmlGenerator.GenerateTextBox(viewContext, modelExplorer, expression, null, null, htmlAttributes);
// Assert
var attribute = Assert.Single(tagBuilder.Attributes, a => a.Key == "maxlength");
Assert.Equal(expectedValue, Int32.Parse(attribute.Value));
}
[Theory]
[InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithMaxLength))]
[InlineData(nameof(ModelWithMaxLengthMetadata.FieldWithStringLength))]
public void GenerateHidden_DoesNotRenderMaxLength(string expression)
{
// Arrange
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = GetGenerator(metadataProvider);
var viewContext = GetViewContext<ModelWithMaxLengthMetadata>(model: null, metadataProvider: metadataProvider);
var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression);
var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null);
var htmlAttributes = new Dictionary<string, object>
{
{ "name", "testElement" },
};
// Act
var tagBuilder = htmlGenerator.GenerateHidden(viewContext, modelExplorer, expression, null, false, htmlAttributes);
// Assert
Assert.DoesNotContain(tagBuilder.Attributes, a => a.Key == "maxlength");
}
[Fact]
public void GenerateValidationMessage_WithNullExpression_Throws()
{
@ -759,7 +923,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
private static IHtmlGenerator GetGenerator(IModelMetadataProvider metadataProvider)
{
var mvcViewOptionsAccessor = new Mock<IOptions<MvcViewOptions>>();
mvcViewOptionsAccessor.SetupGet(accessor => accessor.Value).Returns(new MvcViewOptions());
mvcViewOptionsAccessor.SetupGet(accessor => accessor.Value).Returns(new MvcViewOptions() { AllowRenderingMaxLengthAttribute = true });
var htmlEncoder = Mock.Of<HtmlEncoder>();
var antiforgery = new Mock<IAntiforgery>();
@ -820,6 +984,24 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
All = -1,
}
private class ModelWithMaxLengthMetadata
{
internal const int MaxLengthAttributeValue = 77;
internal const int StringLengthAttributeValue = 7;
[MaxLength(MaxLengthAttributeValue)]
public string FieldWithMaxLength { get; set; }
[StringLength(StringLengthAttributeValue)]
public string FieldWithStringLength { get; set; }
public string FieldWithoutAttributes { get; set; }
[MaxLength(MaxLengthAttributeValue)]
[StringLength(StringLengthAttributeValue)]
public string FieldWithBothAttributes { get; set; }
}
private class Model
{
public int Id { get; set; }