From eae6fd1ad80cf041ccfc773253e39dc539b04ebf Mon Sep 17 00:00:00 2001 From: Derek Gray Date: Tue, 26 Jan 2016 15:45:53 -0600 Subject: [PATCH] Allow the use of the Prompt property of DisplayAttribute for the placeholder attribute of input fields (addresses #3723) --- .../ModelBinding/ModelMetadata.cs | 5 ++ .../Metadata/DefaultModelMetadata.cs | 26 +++++-- .../ModelBinding/Metadata/DisplayMetadata.cs | 6 ++ .../DataAnnotationsMetadataProvider.cs | 1 + .../ViewFeatures/DefaultHtmlGenerator.cs | 18 ++++- .../ModelBinding/ModelMetadataTest.cs | 8 +++ .../Metadata/DefaultModelMetadataTest.cs | 1 + .../DataAnnotationsMetadataProviderTest.cs | 1 + .../Internal/ModelMetadataProviderTest.cs | 19 +++++ .../Rendering/HtmlHelperPasswordTest.cs | 18 +++++ .../Rendering/HtmlHelperTextBoxTest.cs | 69 +++++++++++++++++++ 11 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextBoxTest.cs diff --git a/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs b/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs index 7214156022..923bcd158c 100644 --- a/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs +++ b/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelMetadata.cs @@ -258,6 +258,11 @@ public string PropertyName /// The order value of the current metadata. public abstract int Order { get; } + /// + /// Gets the text to display as a placeholder value for an editor. + /// + public abstract string Placeholder { get; } + /// /// Gets the text to display when the model is null. /// diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadata.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadata.cs index 5e16176c41..36f4fb77bf 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadata.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DefaultModelMetadata.cs @@ -203,12 +203,12 @@ public override string Description { get { - if (DisplayMetadata.Description == null) + if (DisplayMetadata.Description != null) { - return null; + return DisplayMetadata.Description(); } - return DisplayMetadata.Description(); + return null; } } @@ -226,12 +226,12 @@ public override string DisplayName { get { - if (DisplayMetadata.DisplayName == null) + if (DisplayMetadata.DisplayName != null) { - return null; + return DisplayMetadata.DisplayName(); } - return DisplayMetadata.DisplayName(); + return null; } } @@ -432,6 +432,20 @@ public override int Order } } + /// + public override string Placeholder + { + get + { + if (DisplayMetadata.Placeholder != null) + { + return DisplayMetadata.Placeholder(); + } + + return null; + } + } + /// public override ModelPropertyCollection Properties { diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DisplayMetadata.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DisplayMetadata.cs index 503ace4b5d..4d02a152e8 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DisplayMetadata.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/DisplayMetadata.cs @@ -115,6 +115,12 @@ public class DisplayMetadata /// public int Order { get; set; } = 10000; + /// + /// Gets or sets a delegate which is used to get a value for the + /// model's placeholder text. See . + /// + public Func Placeholder { get; set; } + /// /// Gets or sets a value indicating whether or not to include in the model value in display. /// See diff --git a/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsMetadataProvider.cs b/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsMetadataProvider.cs index 20258331bb..65ad0f789e 100644 --- a/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsMetadataProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/DataAnnotationsMetadataProvider.cs @@ -84,6 +84,7 @@ public void GetDisplayMetadata(DisplayMetadataProviderContext context) if (displayAttribute != null) { displayMetadata.Description = () => displayAttribute.GetDescription(); + displayMetadata.Placeholder = () => displayAttribute.GetPrompt(); } // DisplayFormatString diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs index 419a5a3e15..1bffcadc2d 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs @@ -26,6 +26,10 @@ public class DefaultHtmlGenerator : IHtmlGenerator private static readonly MethodInfo ConvertEnumFromStringMethod = typeof(DefaultHtmlGenerator).GetTypeInfo().GetDeclaredMethod(nameof(ConvertEnumFromString)); + // See: (http://www.w3.org/TR/html5/forms.html#the-input-element) + private static readonly string[] _placeholderInputTypes = + new[] { "text", "search", "url", "tel", "email", "password", "number" }; + private readonly IAntiforgery _antiforgery; private readonly IClientModelValidatorProvider _clientModelValidatorProvider; private readonly IModelMetadataProvider _metadataProvider; @@ -1159,12 +1163,24 @@ protected virtual TagBuilder GenerateInput( nameof(expression)); } + var inputTypeString = GetInputTypeString(inputType); var tagBuilder = new TagBuilder("input"); tagBuilder.TagRenderMode = TagRenderMode.SelfClosing; tagBuilder.MergeAttributes(htmlAttributes); - tagBuilder.MergeAttribute("type", GetInputTypeString(inputType)); + tagBuilder.MergeAttribute("type", inputTypeString); tagBuilder.MergeAttribute("name", fullName, replaceExisting: true); + var suppliedTypeString = tagBuilder.Attributes["type"]; + if (_placeholderInputTypes.Contains(suppliedTypeString)) + { + modelExplorer = modelExplorer ?? ExpressionMetadataProvider.FromStringExpression(expression, viewContext.ViewData, _metadataProvider); + var placeholder = modelExplorer.Metadata.Placeholder; + if (placeholder != null) + { + tagBuilder.MergeAttribute("placeholder", placeholder); + } + } + var valueParameter = FormatValue(value, format); var usedModelState = false; switch (inputType) diff --git a/test/Microsoft.AspNetCore.Mvc.Abstractions.Test/ModelBinding/ModelMetadataTest.cs b/test/Microsoft.AspNetCore.Mvc.Abstractions.Test/ModelBinding/ModelMetadataTest.cs index 3ee5b90906..1abe824129 100644 --- a/test/Microsoft.AspNetCore.Mvc.Abstractions.Test/ModelBinding/ModelMetadataTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Abstractions.Test/ModelBinding/ModelMetadataTest.cs @@ -520,6 +520,14 @@ public override int Order } } + public override string Placeholder + { + get + { + throw new NotImplementedException(); + } + } + public override ModelPropertyCollection Properties { get diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/DefaultModelMetadataTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/DefaultModelMetadataTest.cs index 69ae9a55e0..806117d35a 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/DefaultModelMetadataTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Metadata/DefaultModelMetadataTest.cs @@ -62,6 +62,7 @@ public void DefaultValues() Assert.Null(metadata.NullDisplayText); Assert.Null(metadata.TemplateHint); Assert.Null(metadata.SimpleDisplayProperty); + Assert.Null(metadata.Placeholder); Assert.Equal(10000, ModelMetadata.DefaultOrder); Assert.Equal(ModelMetadata.DefaultOrder, metadata.Order); diff --git a/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsMetadataProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsMetadataProviderTest.cs index 18b214e466..b8e863c84d 100644 --- a/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsMetadataProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/DataAnnotationsMetadataProviderTest.cs @@ -26,6 +26,7 @@ public static TheoryData, object> DisplayD { new DisplayAttribute() { Description = "d" }, d => d.Description(), "d" }, { new DisplayAttribute() { Name = "DN" }, d => d.DisplayName(), "DN" }, { new DisplayAttribute() { Order = 3 }, d => d.Order, 3 }, + { new DisplayAttribute() { Prompt = "Enter Value" }, d => d.Placeholder(), "Enter Value" }, { new DisplayColumnAttribute("Property"), d => d.SimpleDisplayProperty, "Property" }, diff --git a/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ModelMetadataProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ModelMetadataProviderTest.cs index a15db6556a..7ce894697a 100644 --- a/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ModelMetadataProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ModelMetadataProviderTest.cs @@ -194,6 +194,9 @@ public static TheoryData> ExpectedAttributeD { new DisplayAttribute { Name = "value" }, metadata => metadata.DisplayName }, + { + new DisplayAttribute { Prompt = "value" }, metadata => metadata.Placeholder + }, { new DisplayFormatAttribute { DataFormatString = "value" }, metadata => metadata.DisplayFormatString @@ -422,6 +425,22 @@ public void DisplayAttribute_Description() Assert.Equal("description", result); } + [Fact] + public void DisplayAttribute_PromptAsPlaceholder() + { + // Arrange + var display = new DisplayAttribute() { Prompt = "prompt" }; + var provider = CreateProvider(new[] { display }); + + var metadata = provider.GetMetadataForType(typeof(string)); + + // Act + var result = metadata.Placeholder; + + // Assert + Assert.Equal("prompt", result); + } + [Fact] public void DisplayName_FromResources_GetsRecomputed() { diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperPasswordTest.cs b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperPasswordTest.cs index 1bb0db369a..bcbeadd133 100644 --- a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperPasswordTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperPasswordTest.cs @@ -399,6 +399,21 @@ public void Password_UsesSpecifiedValue() HtmlContentUtilities.HtmlContentToString(passwordResult)); } + [Fact] + public void PasswordFor_GeneratesPlaceholderAttribute_WhenDisplayAttributePromptIsSet() + { + // Arrange + var expected = @""; + var model = new PasswordModel(); + var helper = DefaultTemplatesUtilities.GetHtmlHelper(model); + + // Act + var result = helper.PasswordFor(m => m.Property7, htmlAttributes: null); + + // Assert + Assert.Equal(expected, HtmlContentUtilities.HtmlContentToString(result)); + } + private static ViewDataDictionary GetViewDataWithNullModelAndNonEmptyViewData() { return new ViewDataDictionary(new EmptyModelMetadataProvider()) @@ -443,6 +458,9 @@ public class PasswordModel public Dictionary Property3 { get; } = new Dictionary(); public NestedClass Property4 { get; } = new NestedClass(); + + [Display(Prompt = "placeholder")] + public string Property7 { get; set; } } public class NestedClass diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextBoxTest.cs b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextBoxTest.cs new file mode 100644 index 0000000000..fba2b72f6f --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/HtmlHelperTextBoxTest.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Mvc.TestCommon; +using System.ComponentModel.DataAnnotations; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Rendering +{ + public class HtmlHelperTextBoxTest + { + private const string InputWithPlaceholderAttribute = @""; + private const string InputWithoutPlaceholderAttribute = @""; + + [Theory] + [InlineData("text")] + [InlineData("search")] + [InlineData("url")] + [InlineData("tel")] + [InlineData("email")] + [InlineData("number")] + public void TextBoxFor_GeneratesPlaceholderAttribute_WhenDisplayAttributePromptIsSetAndTypeIsValid(string type) + { + // Arrange + var model = new TextBoxModel(); + var helper = DefaultTemplatesUtilities.GetHtmlHelper(model); + + // Act + var result = helper.TextBoxFor(m => m.Property1, new { type }); + + // Assert + Assert.Equal(string.Format(InputWithPlaceholderAttribute, type), HtmlContentUtilities.HtmlContentToString(result)); + } + + [Theory] + [InlineData("hidden")] + [InlineData("date")] + [InlineData("time")] + [InlineData("range")] + [InlineData("color")] + [InlineData("checkbox")] + [InlineData("radio")] + [InlineData("submit")] + [InlineData("reset")] + [InlineData("button")] + // Skipping these two because they won't match the constant string, + // only because their 'value' attribute also does not get set. + [InlineData("file")] + [InlineData("image")] + public void TextBoxFor_DoesNotGeneratePlaceholderAttribute_WhenDisplayAttributePromptIsSetAndTypeIsInvalid(string type) + { + // Arrange + var model = new TextBoxModel(); + var helper = DefaultTemplatesUtilities.GetHtmlHelper(model); + + // Act + var result = helper.TextBoxFor(m => m.Property1, new { type }); + + // Assert + Assert.Equal(string.Format(InputWithoutPlaceholderAttribute, type), HtmlContentUtilities.HtmlContentToString(result)); + } + + private class TextBoxModel + { + [Display(Prompt = "placeholder")] + public string Property1 { get; set; } + } + } +} \ No newline at end of file