From 6d9aa281c57b0781d8024d8e1739b2aeb97cc6f7 Mon Sep 17 00:00:00 2001 From: Artak Mkrtchyan Date: Tue, 17 Jul 2018 14:28:53 -0700 Subject: [PATCH] Render `maxlength` attribute for an input tag, when MaxLength or StringLength validation attributes are applied to the model class. --- .../MvcViewOptions.cs | 16 ++ .../ViewFeatures/DefaultHtmlGenerator.cs | 62 ++++++ ...iewOptionsConfigureCompatibilityOptions.cs | 5 + .../ViewFeatures/DefaultHtmlGeneratorTest.cs | 184 +++++++++++++++++- 4 files changed, 266 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/MvcViewOptions.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/MvcViewOptions.cs index 9d0627da96..237777da23 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/MvcViewOptions.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/MvcViewOptions.cs @@ -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 { private readonly CompatibilitySwitch _suppressTempDataAttributePrefix; + private readonly CompatibilitySwitch _allowRenderingMaxLengthAttribute; private readonly ICompatibilitySwitch[] _switches; private HtmlHelperOptions _htmlHelperOptions = new HtmlHelperOptions(); public MvcViewOptions() { _suppressTempDataAttributePrefix = new CompatibilitySwitch(nameof(SuppressTempDataAttributePrefix)); + _allowRenderingMaxLengthAttribute = new CompatibilitySwitch(nameof(AllowRenderingMaxLengthAttribute)); _switches = new[] { _suppressTempDataAttributePrefix, + _allowRenderingMaxLengthAttribute }; } @@ -87,6 +91,18 @@ namespace Microsoft.AspNetCore.Mvc set => _suppressTempDataAttributePrefix.Value = value; } + /// + /// 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 + /// or attributes. + /// + /// If both attributes are specified, the one with the smaller value will be used for the rendered `maxlength` attribute. + public bool AllowRenderingMaxLengthAttribute + { + get => _allowRenderingMaxLengthAttribute.Value; + set => _allowRenderingMaxLengthAttribute.Value = value; + } + /// /// Gets a list s used by this application. /// diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs index e502341da4..d0121241b6 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs @@ -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; } + /// + /// 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 + /// or attributes. + /// + /// If both attributes are specified, the one with the smaller value will be used for the rendered `maxlength` attribute. + protected bool AllowRenderingMaxLengthAttribute { get; } + /// 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 } } + /// + /// Adds a maxlength attribute to the . + /// + /// A instance for the current scope. + /// A instance. + /// The for the . + /// Expression name, relative to the current model. + 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()); + } + } + /// /// Adds validation attributes to the if client validation /// is enabled. diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MvcViewOptionsConfigureCompatibilityOptions.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MvcViewOptionsConfigureCompatibilityOptions.cs index 81de59fe12..b17210f7de 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MvcViewOptionsConfigureCompatibilityOptions.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/MvcViewOptionsConfigureCompatibilityOptions.cs @@ -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; } } diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs index d4e5477aab..0d38ca3674 100644 --- a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs @@ -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(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "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(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "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(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "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(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), nameof(ModelWithMaxLengthMetadata.FieldWithBothAttributes)); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "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(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), nameof(ModelWithMaxLengthMetadata.FieldWithoutAttributes)); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "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(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "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(model: null, metadataProvider: metadataProvider); + var modelMetadata = metadataProvider.GetMetadataForProperty(typeof(ModelWithMaxLengthMetadata), expression); + var modelExplorer = new ModelExplorer(metadataProvider, modelMetadata, null); + var htmlAttributes = new Dictionary + { + { "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>(); - mvcViewOptionsAccessor.SetupGet(accessor => accessor.Value).Returns(new MvcViewOptions()); + mvcViewOptionsAccessor.SetupGet(accessor => accessor.Value).Returns(new MvcViewOptions() { AllowRenderingMaxLengthAttribute = true }); var htmlEncoder = Mock.Of(); var antiforgery = new Mock(); @@ -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; }