Add `InputTagHelper`

- also make `TagHelperOutputExtensions.MergeAttributes()` case-insensitive

nits:
- deliniate attribute names in all resource strings
- update validation messages in TagHelperSample.Web
This commit is contained in:
Doug Bunting 2014-10-16 23:34:10 -07:00
parent f8f08f0903
commit 012e03e5d0
10 changed files with 715 additions and 21 deletions

View File

@ -37,7 +37,7 @@
<div class="col-md-10">
@* will automatically infer type="date" (reused HTML attribute) and format="{0:d}" (optional bound attribute) *@
<input for="DateOfBirth" />
<span validation-for="DateOfBirth">How old are you?</span>
<span validation-for="DateOfBirth">When were you born?</span>
</div>
</div>
<div class="form-group">

View File

@ -20,7 +20,7 @@
<label for="DateOfBirth" class="control-label col-md-2" />
<div class="col-md-10">
<input type="date" for="DateOfBirth" format="{0:d}" />
<span validation-for="DateOfBirth">How old are you?</span>
<span validation-for="DateOfBirth" />
</div>
</div>
<div class="form-group">

View File

@ -0,0 +1,415 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNet.Mvc.ModelBinding;
using Microsoft.AspNet.Mvc.Rendering;
using Microsoft.AspNet.Razor.Runtime.TagHelpers;
using Microsoft.AspNet.Razor.TagHelpers;
namespace Microsoft.AspNet.Mvc.TagHelpers
{
/// <summary>
/// <see cref="ITagHelper"/> implementation targeting &lt;input&gt; elements.
/// </summary>
[ContentBehavior(ContentBehavior.Replace)]
public class InputTagHelper : TagHelper
{
// Mapping from datatype names and data annotation hints to values for the <input/> element's "type" attribute.
private static readonly Dictionary<string, string> _defaultInputTypes =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "HiddenInput", InputType.Hidden.ToString().ToLowerInvariant() },
{ "Password", InputType.Password.ToString().ToLowerInvariant() },
{ "Text", InputType.Text.ToString() },
{ "PhoneNumber", "tel" },
{ "Url", "url" },
{ "EmailAddress", "email" },
{ "Date", "date" },
{ "DateTime", "datetime" },
{ "DateTime-local", "datetime-local" },
{ "Time", "time" },
{ nameof(Byte), "number" },
{ nameof(SByte), "number" },
{ nameof(Int32), "number" },
{ nameof(UInt32), "number" },
{ nameof(Int64), "number" },
{ nameof(UInt64), "number" },
{ nameof(Boolean), InputType.CheckBox.ToString().ToLowerInvariant() },
{ nameof(Decimal), InputType.Text.ToString().ToLowerInvariant() },
{ nameof(String), InputType.Text.ToString().ToLowerInvariant() },
};
// Mapping from <input/> element's type to RFC 3339 date and time formats.
private static readonly Dictionary<string, string> _rfc3339Formats =
new Dictionary<string, string>(StringComparer.Ordinal)
{
{ "date", "{0:yyyy-MM-dd}" },
{ "datetime", "{0:yyyy-MM-ddTHH:mm:ss.fffK}" },
{ "datetime-local", "{0:yyyy-MM-ddTHH:mm:ss.fff}" },
{ "time", "{0:HH:mm:ss.fff}" },
};
// Protected to ensure subclasses are correctly activated. Internal for ease of use when testing.
[Activate]
protected internal IHtmlGenerator Generator { get; set; }
// Protected to ensure subclasses are correctly activated. Internal for ease of use when testing.
[Activate]
protected internal ViewContext ViewContext { get; set; }
/// <summary>
/// An expression to be evaluated against the current model.
/// </summary>
public ModelExpression For { get; set; }
/// <summary>
/// The composite format <see cref="string"/> (see http://msdn.microsoft.com/en-us/library/txafckwd.aspx) to
/// apply when converting the <see cref="For"/> result to a <see cref="string"/>. Sets the generated "value"
/// attribute to that formatted <see cref="string"/>.
/// </summary>
/// <remarks>
/// Used only the calculated "type" attribute is "text" (the most common value) e.g.
/// <see cref="InputTypeName"/> is "String". That is, <see cref="Format"/> is used when calling
/// <see cref="IHtmlGenerator.GenerateTextBox"/>.
/// </remarks>
public string Format { get; set; }
/// <summary>
/// The type of the &lt;input&gt; element.
/// </summary>
/// <remarks>
/// Passed through to the generated HTML in all cases. Also used to determine the <see cref="IHtmlGenerator"/>
/// helper to call and the default <see cref="Format"/> value (when calling
/// <see cref="IHtmlGenerator.GenerateTextBox"/>).
/// </remarks>
[HtmlAttributeName("type")]
public string InputTypeName { get; set; }
/// <summary>
/// The value of the &lt;input&gt; element.
/// </summary>
/// <remarks>
/// Passed through to the generated HTML in all cases. Also used to determine the generated "checked" attribute
/// if <see cref="InputTypeName"/> is "radio". Must not be <c>null</c> in that case.
/// </remarks>
public string Value { get; set; }
/// <inheritdoc />
/// <remarks>Does nothing if <see cref="For"/> is <c>null</c></remarks>
public override void Process(TagHelperContext context, TagHelperOutput output)
{
// Pass through attributes that are also well-known HTML attributes. Must be done prior to any copying
// from a TagBuilder.
if (!string.IsNullOrEmpty(InputTypeName))
{
output.CopyHtmlAttribute("type", context);
}
if (Value != null)
{
output.CopyHtmlAttribute(nameof(Value), context);
}
if (For == null)
{
// Regular HTML <input/> element. Just make sure Format wasn't specified.
if (Format != null)
{
throw new InvalidOperationException(Resources.FormatInputTagHelper_UnableToFormat(
"<input>",
nameof(For).ToLowerInvariant(),
nameof(Format).ToLowerInvariant()));
}
}
else
{
// Note null or empty For.Name is allowed because TemplateInfo.HtmlFieldPrefix may be sufficient.
// IHtmlGenerator will enforce name requirements.
var metadata = For.Metadata;
if (metadata == null)
{
throw new InvalidOperationException(Resources.FormatTagHelpers_NoProvidedMetadata(
"<input>",
nameof(For).ToLowerInvariant(),
nameof(IModelMetadataProvider),
For.Name));
}
string inputType;
string inputTypeHint;
if (string.IsNullOrEmpty(InputTypeName))
{
inputType = GetInputType(metadata, out inputTypeHint);
}
else
{
inputType = InputTypeName.ToLowerInvariant();
inputTypeHint = null;
}
if (!string.IsNullOrEmpty(inputType))
{
// inputType may be more specific than default the generator chooses below.
// TODO: Use Attributes.ContainsKey once aspnet/Razor#186 is fixed.
if (!output.Attributes.Any(
item => string.Equals("type", item.Key, StringComparison.OrdinalIgnoreCase)))
{
output.Attributes["type"] = inputType;
}
}
TagBuilder tagBuilder;
switch (inputType)
{
case "checkbox":
GenerateCheckBox(metadata, output);
return;
case "hidden":
tagBuilder = Generator.GenerateHidden(
ViewContext,
metadata,
For.Name,
value: metadata.Model,
useViewData: false,
htmlAttributes: null);
break;
case "password":
tagBuilder = Generator.GeneratePassword(
ViewContext,
metadata,
For.Name,
value: null,
htmlAttributes: null);
break;
case "radio":
tagBuilder = GenerateRadio(metadata);
break;
default:
tagBuilder = GenerateTextBox(metadata, inputTypeHint, inputType);
break;
}
if (tagBuilder != null)
{
// This TagBuilder contains the one <input/> element of interest. Since this is not the "checkbox"
// special-case, output is a self-closing element and can merge the TagBuilder in directly.
output.SelfClosing = true;
output.Merge(tagBuilder);
}
}
}
private void GenerateCheckBox(ModelMetadata metadata, TagHelperOutput output)
{
if (typeof(bool) != metadata.RealModelType)
{
throw new InvalidOperationException(Resources.FormatInputTagHelper_InvalidExpressionResult(
"<input>",
nameof(For).ToLowerInvariant(),
metadata.RealModelType.FullName,
typeof(bool).FullName,
"type",
"checkbox"));
}
// Prepare to move attributes from current element to <input type="checkbox"/> generated just below.
var htmlAttributes = output.Attributes.ToDictionary(
attribute => attribute.Key,
attribute => (object)attribute.Value);
var tagBuilder = Generator.GenerateCheckBox(
ViewContext,
metadata,
For.Name,
isChecked: null,
htmlAttributes: htmlAttributes);
if (tagBuilder != null)
{
// Do not generate current element's attributes or tags. Instead put both <input type="checkbox"/> and
// <input type="hidden"/> into the output's Content.
output.Attributes.Clear();
output.SelfClosing = false; // Otherwise Content will be ignored.
output.TagName = null;
output.Content += tagBuilder.ToString(TagRenderMode.SelfClosing);
tagBuilder = Generator.GenerateHiddenForCheckbox(ViewContext, metadata, For.Name);
if (tagBuilder != null)
{
output.Content += tagBuilder.ToString(TagRenderMode.SelfClosing);
}
}
}
private TagBuilder GenerateRadio(ModelMetadata metadata)
{
// Note empty string is allowed.
if (Value == null)
{
throw new InvalidOperationException(Resources.FormatInputTagHelper_ValueRequired(
"<input>",
nameof(Value).ToLowerInvariant(),
"type",
"radio"));
}
return Generator.GenerateRadioButton(
ViewContext,
metadata,
For.Name,
Value,
isChecked: null,
htmlAttributes: null);
}
private TagBuilder GenerateTextBox(ModelMetadata metadata, string inputTypeHint, string inputType)
{
var format = Format;
if (string.IsNullOrEmpty(format))
{
format = GetFormat(metadata, inputTypeHint, inputType);
}
return Generator.GenerateTextBox(
ViewContext,
metadata,
For.Name,
value: metadata.Model,
format: Format,
htmlAttributes: null);
}
// Get a fall-back format based on the metadata.
private string GetFormat(ModelMetadata metadata, string inputTypeHint, string inputType)
{
string format;
string rfc3339Format;
if (string.Equals("decimal", inputTypeHint, StringComparison.OrdinalIgnoreCase) &&
string.Equals("text", inputType, StringComparison.Ordinal) &&
string.IsNullOrEmpty(metadata.EditFormatString))
{
// Decimal data is edited using an <input type="text"/> element, with a reasonable format.
// EditFormatString has precedence over this fall-back format.
format = "{0:0.00}";
}
else if (_rfc3339Formats.TryGetValue(inputType, out rfc3339Format) &&
ViewContext.Html5DateRenderingMode == Html5DateRenderingMode.Rfc3339 &&
!metadata.HasNonDefaultEditFormat &&
(typeof(DateTime) == metadata.RealModelType || typeof(DateTimeOffset) == metadata.RealModelType))
{
// Rfc3339 mode _may_ override EditFormatString in a limited number of cases e.g. EditFormatString
// must be a default format (i.e. came from a built-in [DataType] attribute).
format = rfc3339Format;
}
else
{
// Otherwise use EditFormatString, if any.
format = metadata.EditFormatString;
}
return format;
}
private string GetInputType(ModelMetadata metadata, out string inputTypeHint)
{
foreach (var hint in GetInputTypeHints(metadata))
{
string inputType;
if (_defaultInputTypes.TryGetValue(hint, out inputType))
{
inputTypeHint = hint;
return inputType;
}
}
inputTypeHint = InputType.Text.ToString().ToLowerInvariant();
return inputTypeHint;
}
// A variant of TemplateRenderer.GetViewNames(). Main change relates to bool? handling.
private static IEnumerable<string> GetInputTypeHints(ModelMetadata metadata)
{
var inputTypeHints = new string[]
{
metadata.TemplateHint,
metadata.DataTypeName,
};
foreach (string inputTypeHint in inputTypeHints.Where(s => !string.IsNullOrEmpty(s)))
{
yield return inputTypeHint;
}
// In most cases, we don't want to search for Nullable<T>. We want to search for T, which should handle
// both T and Nullable<T>. However we special-case bool? to avoid turning an <input/> into a <select/>.
var fieldType = metadata.RealModelType;
if (typeof(bool?) != fieldType)
{
var underlyingType = Nullable.GetUnderlyingType(fieldType);
if (underlyingType != null)
{
fieldType = underlyingType;
}
}
yield return fieldType.Name;
if (fieldType == typeof(string))
{
// Nothing more to provide
yield break;
}
else if (!metadata.IsComplexType)
{
// IsEnum is false for the Enum class itself
if (fieldType.IsEnum())
{
// Same as fieldType.BaseType.Name in this case
yield return "Enum";
}
else if (fieldType == typeof(DateTimeOffset))
{
yield return "DateTime";
}
yield return "String";
}
else if (fieldType.IsInterface())
{
if (typeof(IEnumerable).IsAssignableFrom(fieldType))
{
yield return "Collection";
}
yield return "Object";
}
else
{
var isEnumerable = typeof(IEnumerable).IsAssignableFrom(fieldType);
while (true)
{
fieldType = fieldType.BaseType();
if (fieldType == null)
{
break;
}
if (isEnumerable && fieldType == typeof(Object))
{
yield return "Collection";
}
yield return fieldType.Name;
}
}
}
}
}

View File

@ -11,7 +11,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
= new ResourceManager("Microsoft.AspNet.Mvc.TagHelpers.Resources", typeof(Resources).GetTypeInfo().Assembly);
/// <summary>
/// Cannot determine an {4} for {0}. An {0} with a specified {1} must not have an {2} or {3} attribute.
/// Cannot determine an '{4}' for {0}. An {0} with a specified '{1}' must not have an '{2}' or '{3}' attribute.
/// </summary>
internal static string AnchorTagHelper_CannotDetermineHrefRouteActionOrControllerSpecified
{
@ -19,7 +19,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
}
/// <summary>
/// Cannot determine an {4} for {0}. An {0} with a specified {1} must not have an {2} or {3} attribute.
/// Cannot determine an '{4}' for {0}. An {0} with a specified '{1}' must not have an '{2}' or '{3}' attribute.
/// </summary>
internal static string FormatAnchorTagHelper_CannotDetermineHrefRouteActionOrControllerSpecified(object p0, object p1, object p2, object p3, object p4)
{
@ -27,7 +27,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
}
/// <summary>
/// Cannot determine an {8} for {0}. An {0} with a specified {8} must not have attributes starting with {7} or an {1}, {2}, {3}, {4}, {5} or {6} attribute.
/// Cannot determine an '{8}' for {0}. An {0} with a specified '{8}' must not have attributes starting with '{7}' or an '{1}', '{2}', '{3}', '{4}', '{5}', or '{6}' attribute.
/// </summary>
internal static string AnchorTagHelper_CannotOverrideSpecifiedHref
{
@ -35,7 +35,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
}
/// <summary>
/// Cannot determine an {8} for {0}. An {0} with a specified {8} must not have attributes starting with {7} or an {1}, {2}, {3}, {4}, {5} or {6} attribute.
/// Cannot determine an '{8}' for {0}. An {0} with a specified '{8}' must not have attributes starting with '{7}' or an '{1}', '{2}', '{3}', '{4}', '{5}', or '{6}' attribute.
/// </summary>
internal static string FormatAnchorTagHelper_CannotOverrideSpecifiedHref(object p0, object p1, object p2, object p3, object p4, object p5, object p6, object p7, object p8)
{
@ -43,7 +43,55 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
}
/// <summary>
/// Cannot determine an {1} for {0}. A {0} with a URL-based {1} must not have attributes starting with {3} or a {2} attribute.
/// Unexpected '{1}' expression result type '{2}' for {0}. '{1}' must be of type '{3}' if '{4}' is '{5}'.
/// </summary>
internal static string InputTagHelper_InvalidExpressionResult
{
get { return GetString("InputTagHelper_InvalidExpressionResult"); }
}
/// <summary>
/// Unexpected '{1}' expression result type '{2}' for {0}. '{1}' must be of type '{3}' if '{4}' is '{5}'.
/// </summary>
internal static string FormatInputTagHelper_InvalidExpressionResult(object p0, object p1, object p2, object p3, object p4, object p5)
{
return string.Format(CultureInfo.CurrentCulture, GetString("InputTagHelper_InvalidExpressionResult"), p0, p1, p2, p3, p4, p5);
}
/// <summary>
/// Unable to format without a '{1}' expression for {0}. '{2}' must be null if '{1}' is null.
/// </summary>
internal static string InputTagHelper_UnableToFormat
{
get { return GetString("InputTagHelper_UnableToFormat"); }
}
/// <summary>
/// Unable to format without a '{1}' expression for {0}. '{2}' must be null if '{1}' is null.
/// </summary>
internal static string FormatInputTagHelper_UnableToFormat(object p0, object p1, object p2)
{
return string.Format(CultureInfo.CurrentCulture, GetString("InputTagHelper_UnableToFormat"), p0, p1, p2);
}
/// <summary>
/// '{1}' must not be null for {0} if '{2}' is '{3}'.
/// </summary>
internal static string InputTagHelper_ValueRequired
{
get { return GetString("InputTagHelper_ValueRequired"); }
}
/// <summary>
/// '{1}' must not be null for {0} if '{2}' is '{3}'.
/// </summary>
internal static string FormatInputTagHelper_ValueRequired(object p0, object p1, object p2, object p3)
{
return string.Format(CultureInfo.CurrentCulture, GetString("InputTagHelper_ValueRequired"), p0, p1, p2, p3);
}
/// <summary>
/// Cannot determine an '{1}' for {0}. A {0} with a URL-based '{1}' must not have attributes starting with '{3}' or a '{2}' attribute.
/// </summary>
internal static string FormTagHelper_CannotDetermineAction
{
@ -51,7 +99,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
}
/// <summary>
/// Cannot determine an {1} for {0}. A {0} with a URL-based {1} must not have attributes starting with {3} or a {2} attribute.
/// Cannot determine an '{1}' for {0}. A {0} with a URL-based '{1}' must not have attributes starting with '{3}' or a '{2}' attribute.
/// </summary>
internal static string FormatFormTagHelper_CannotDetermineAction(object p0, object p1, object p2, object p3)
{
@ -74,6 +122,22 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
return string.Format(CultureInfo.CurrentCulture, GetString("ValidationSummaryTagHelper_InvalidValidationSummaryValue"), p0, p1, p2, p3, p4, p5);
}
/// <summary>
/// The {2} was unable to provide metadata about '{1}' expression value '{3}' for {0}.
/// </summary>
internal static string TagHelpers_NoProvidedMetadata
{
get { return GetString("TagHelpers_NoProvidedMetadata"); }
}
/// <summary>
/// The {2} was unable to provide metadata about '{1}' expression value '{3}' for {0}.
/// </summary>
internal static string FormatTagHelpers_NoProvidedMetadata(object p0, object p1, object p2, object p3)
{
return string.Format(CultureInfo.CurrentCulture, GetString("TagHelpers_NoProvidedMetadata"), p0, p1, p2, p3);
}
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name);

View File

@ -118,15 +118,27 @@
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="AnchorTagHelper_CannotDetermineHrefRouteActionOrControllerSpecified" xml:space="preserve">
<value>Cannot determine an {4} for {0}. An {0} with a specified {1} must not have an {2} or {3} attribute.</value>
<value>Cannot determine an '{4}' for {0}. An {0} with a specified '{1}' must not have an '{2}' or '{3}' attribute.</value>
</data>
<data name="AnchorTagHelper_CannotOverrideSpecifiedHref" xml:space="preserve">
<value>Cannot determine an {8} for {0}. An {0} with a specified {8} must not have attributes starting with {7} or an {1}, {2}, {3}, {4}, {5} or {6} attribute.</value>
<value>Cannot determine an '{8}' for {0}. An {0} with a specified '{8}' must not have attributes starting with '{7}' or an '{1}', '{2}', '{3}', '{4}', '{5}', or '{6}' attribute.</value>
</data>
<data name="InputTagHelper_InvalidExpressionResult" xml:space="preserve">
<value>Unexpected '{1}' expression result type '{2}' for {0}. '{1}' must be of type '{3}' if '{4}' is '{5}'.</value>
</data>
<data name="InputTagHelper_UnableToFormat" xml:space="preserve">
<value>Unable to format without a '{1}' expression for {0}. '{2}' must be null if '{1}' is null.</value>
</data>
<data name="InputTagHelper_ValueRequired" xml:space="preserve">
<value>'{1}' must not be null for {0} if '{2}' is '{3}'.</value>
</data>
<data name="FormTagHelper_CannotDetermineAction" xml:space="preserve">
<value>Cannot determine an {1} for {0}. A {0} with a URL-based {1} must not have attributes starting with {3} or a {2} attribute.</value>
<value>Cannot determine an '{1}' for {0}. A {0} with a URL-based '{1}' must not have attributes starting with '{3}' or a '{2}' attribute.</value>
</data>
<data name="ValidationSummaryTagHelper_InvalidValidationSummaryValue" xml:space="preserve">
<value>Cannot parse '{1}' value '{2}' for {0}. Acceptable values are '{3}', '{4}' and '{5}'.</value>
</data>
<data name="TagHelpers_NoProvidedMetadata" xml:space="preserve">
<value>The {2} was unable to provide metadata about '{1}' expression value '{3}' for {0}.</value>
</data>
</root>

View File

@ -89,7 +89,9 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
{
foreach (var attribute in tagBuilder.Attributes)
{
if (!tagHelperOutput.Attributes.ContainsKey(attribute.Key))
// TODO: Use Attributes.ContainsKey once aspnet/Razor#186 is fixed.
if (!tagHelperOutput.Attributes.Any(
item => string.Equals(attribute.Key, item.Key, StringComparison.OrdinalIgnoreCase)))
{
tagHelperOutput.Attributes.Add(attribute.Key, attribute.Value);
}

View File

@ -4,6 +4,7 @@
"warningsAsErrors": true
},
"dependencies": {
"Microsoft.AspNet.Mvc.Common": { "version": "6.0.0-*", "type": "build" },
"Microsoft.AspNet.Mvc.Razor": ""
},
"frameworks": {

View File

@ -176,9 +176,9 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
typeof(AnchorTagHelper).GetProperty(propertyName).SetValue(anchorTagHelper, "Home");
}
var expectedErrorMessage = "Cannot determine an href for <a>. An <a> with a specified href must not " +
"have attributes starting with route- or an action, controller, route, " +
"protocol, host or fragment attribute.";
var expectedErrorMessage = "Cannot determine an 'href' for <a>. An <a> with a specified 'href' must not " +
"have attributes starting with 'route-' or an 'action', 'controller', " +
"'route', 'protocol', 'host', or 'fragment' attribute.";
// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
@ -202,8 +202,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
"a",
attributes: new Dictionary<string, string>(),
content: string.Empty);
var expectedErrorMessage = "Cannot determine an href for <a>. An <a> with a " +
"specified route must not have an action or controller attribute.";
var expectedErrorMessage = "Cannot determine an 'href' for <a>. An <a> with a " +
"specified 'route' must not have an 'action' or 'controller' attribute.";
// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(

View File

@ -301,8 +301,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
Controller = "Home",
Method = "POST"
};
var expectedErrorMessage = "Cannot determine an action for <form>. A <form> with a URL-based action " +
"must not have attributes starting with route- or a controller attribute.";
var expectedErrorMessage = "Cannot determine an 'action' for <form>. A <form> with a URL-based 'action' " +
"must not have attributes starting with 'route-' or a 'controller' attribute.";
var tagHelperOutput = new TagHelperOutput(
"form",
attributes: new Dictionary<string, string>(),
@ -324,8 +324,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
Action = "http://www.contoso.com",
Method = "POST"
};
var expectedErrorMessage = "Cannot determine an action for <form>. A <form> with a URL-based action " +
"must not have attributes starting with route- or a controller attribute.";
var expectedErrorMessage = "Cannot determine an 'action' for <form>. A <form> with a URL-based 'action' " +
"must not have attributes starting with 'route-' or a 'controller' attribute.";
var tagHelperOutput = new TagHelperOutput(
"form",
attributes: new Dictionary<string, string>

View File

@ -0,0 +1,200 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNet.Mvc.ModelBinding;
using Microsoft.AspNet.Mvc.Rendering;
using Microsoft.AspNet.Razor.Runtime.TagHelpers;
using Xunit;
namespace Microsoft.AspNet.Mvc.TagHelpers
{
public class InputTagHelperTest
{
// Model (List<Model> or Model instance), container type (Model or NestModel), model accessor,
// property path, expected value.
public static TheoryData<object, Type, Func<object>, string, string> TestDataSet
{
get
{
var modelWithNull = new Model
{
NestedModel = new NestedModel
{
Text = null,
},
Text = null,
};
var modelWithText = new Model
{
NestedModel = new NestedModel
{
Text = "inner text",
},
Text = "outer text",
};
var models = new List<Model>
{
modelWithNull,
modelWithText,
};
return new TheoryData<object, Type, Func<object>, string, string>
{
{ null, typeof(Model), () => null, "Text",
string.Empty },
{ modelWithNull, typeof(Model), () => modelWithNull.Text, "Text",
string.Empty },
{ modelWithText, typeof(Model), () => modelWithText.Text, "Text",
"outer text" },
{ modelWithNull, typeof(NestedModel), () => modelWithNull.NestedModel.Text, "NestedModel.Text",
string.Empty },
{ modelWithText, typeof(NestedModel), () => modelWithText.NestedModel.Text, "NestedModel.Text",
"inner text" },
// Top-level indexing does not work end-to-end due to code generation issue #1345.
// TODO: Remove above comment when #1345 is fixed.
{ models, typeof(Model), () => models[0].Text, "[0].Text",
string.Empty },
{ models, typeof(Model), () => models[1].Text, "[1].Text",
"outer text" },
{ models, typeof(NestedModel), () => models[0].NestedModel.Text, "[0].NestedModel.Text",
string.Empty },
{ models, typeof(NestedModel), () => models[1].NestedModel.Text, "[1].NestedModel.Text",
"inner text" },
};
}
}
[Theory]
[MemberData(nameof(TestDataSet))]
public async Task ProcessAsync_GeneratesExpectedOutput(
object model,
Type containerType,
Func<object> modelAccessor,
string propertyPath,
string expectedValue)
{
// Arrange
var expectedAttributes = new Dictionary<string, string>
{
{ "class", "form-control" },
{ "type", "text" },
{ "id", propertyPath },
{ "name", propertyPath },
{ "valid", "from validation attributes" },
{ "value", expectedValue },
};
var expectedContent = "original content";
var expectedTagName = "input";
var metadataProvider = new DataAnnotationsModelMetadataProvider();
// Property name is either nameof(Model.Text) or nameof(NestedModel.Text).
var metadata = metadataProvider.GetMetadataForProperty(modelAccessor, containerType, propertyName: "Text");
var modelExpression = new ModelExpression(propertyPath, metadata);
var tagHelperContext = new TagHelperContext(new Dictionary<string, object>());
var htmlAttributes = new Dictionary<string, string>
{
{ "class", "form-control" },
};
var output = new TagHelperOutput("original tag name", htmlAttributes, expectedContent)
{
SelfClosing = false,
};
var htmlGenerator = new TestableHtmlGenerator(metadataProvider)
{
ValidationAttributes =
{
{ "valid", "from validation attributes" },
}
};
var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator, metadataProvider);
var tagHelper = new InputTagHelper
{
Generator = htmlGenerator,
For = modelExpression,
ViewContext = viewContext,
};
// Act
await tagHelper.ProcessAsync(tagHelperContext, output);
// Assert
Assert.Equal(expectedAttributes, output.Attributes);
Assert.Equal(expectedContent, output.Content);
Assert.True(output.SelfClosing);
Assert.Equal(expectedTagName, output.TagName);
}
[Fact]
public async Task TagHelper_RestoresTypeAndValue_IfForNotBound()
{
// Arrange
var expectedAttributes = new Dictionary<string, string>
{
{ "class", "form-control" },
{ "type", "datetime" },
{ "value", "2014-10-15T23:24:19.000-7:00" },
};
var expectedContent = "original content";
var expectedTagName = "original tag name";
var metadataProvider = new DataAnnotationsModelMetadataProvider();
var metadata = metadataProvider.GetMetadataForProperty(
modelAccessor: () => null,
containerType: typeof(Model),
propertyName: nameof(Model.Text));
var modelExpression = new ModelExpression(nameof(Model.Text), metadata);
var tagHelperContext = new TagHelperContext(new Dictionary<string, object>());
var output = new TagHelperOutput(expectedTagName, expectedAttributes, expectedContent)
{
SelfClosing = false,
};
var htmlGenerator = new TestableHtmlGenerator(metadataProvider)
{
ValidationAttributes =
{
{ "valid", "from validation attributes" },
}
};
Model model = null;
var viewContext = TestableHtmlGenerator.GetViewContext(model, htmlGenerator, metadataProvider);
var tagHelper = new InputTagHelper
{
Generator = htmlGenerator,
ViewContext = viewContext,
};
// Act
await tagHelper.ProcessAsync(tagHelperContext, output);
// Assert
Assert.Equal(expectedAttributes, output.Attributes);
Assert.Equal(expectedContent, output.Content);
Assert.False(output.SelfClosing);
Assert.Equal(expectedTagName, output.TagName);
}
private class Model
{
public string Text { get; set; }
public NestedModel NestedModel { get; set; }
}
private class NestedModel
{
public string Text { get; set; }
}
}
}