Use `[Display(Order=x)]` to sort validation messages and properties

- #964
- compute `ModelMetadata.Order` based on `[Display]` attribute
 - property affects e.g. `@Html.DisplayFor()` generation for complex objects
 - also affects order of messages in validation summaries
- test new scenarios involving `ModelMetadata.Order`
 - per-property `ModelMetadata` and related tests
 - validation and `HtmlHelper` tests
 - add `HtmlHelperValidationSummaryTest` (which touches on #453)
- update ModelBinding functional test to show use of `[Display(Order = x)]`

nits:
- move more `NullDisplayText` bits into proper slots (just above `Order`)
 - add doc comments for `ComputeNullDisplayText()`
- add more assertions in tests using `ModelStateDictionary.HasReachedMaxErrors`
- remove some trailing whitespace
- avoid `Assert.True()` & `Assert.False()`; split some assertions up
- `""` -> `string.Empty` in affected test classes
- rename "DefaultEditorTemplatesTest~~s~~" class and file to follow guidelines
- rename "ModelBindingTest~~s~~" class and file to follow guidelines

FYI #1888 covers a predictable (or even just stable) order in the UI
This commit is contained in:
Doug Bunting 2015-01-29 09:31:05 -08:00
parent c6de763a6c
commit 8779cafbab
18 changed files with 1367 additions and 61 deletions

View File

@ -41,7 +41,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
{
// We want to respect the value set by the parameter (if any), and use the value specifed
// on the type as a fallback.
//
//
// We generalize this process, in case someone adds ordered providers (with count > 2) through
// extensibility.
foreach (var provider in PrototypeCache.BinderTypeProviders)
@ -84,13 +84,6 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
: base.ComputeConvertEmptyStringToNull();
}
protected override string ComputeNullDisplayText()
{
return PrototypeCache.DisplayFormat != null
? PrototypeCache.DisplayFormat.NullDisplayText
: base.ComputeNullDisplayText();
}
/// <summary>
/// Calculate <see cref="ModelMetadata.DataTypeName"/> based on presence of a <see cref="DataTypeAttribute"/>
/// and its <see cref="DataTypeAttribute.GetDataTypeName()"/> method.
@ -274,6 +267,38 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
return (PrototypeCache.Required != null) || base.ComputeIsRequired();
}
/// <summary>
/// Calculate the <see cref="ModelMetadata.NullDisplayText"/> value based on the presence of a
/// <see cref="DisplayFormatAttribute"/> and its <see cref="DisplayFormatAttribute.NullDisplayText"/> value.
/// </summary>
/// <returns>
/// Calculated <see cref="ModelMetadata.NullDisplayText"/> value.
/// <see cref="DisplayFormatAttribute.NullDisplayText"/> if a <see cref="DisplayFormatAttribute"/> exists;
/// <c>null</c> otherwise.
/// </returns>
protected override string ComputeNullDisplayText()
{
return PrototypeCache.DisplayFormat != null
? PrototypeCache.DisplayFormat.NullDisplayText
: base.ComputeNullDisplayText();
}
/// <summary>
/// Calculate the <see cref="ModelMetadata.Order"/> value based on presence of a <see cref="DisplayAttribute"/>
/// and its <see cref="DisplayAttribute.Order"/> value.
/// </summary>
/// <returns>
/// Calculated <see cref="ModelMetadata.Order"/> value. <see cref="DisplayAttribute.GetOrder"/> if a
/// <see cref="DisplayAttribute"/> exists and its <see cref="DisplayAttribute.Order"/> has been set;
/// <c>10000</c> otherwise.
/// </returns>
protected override int ComputeOrder()
{
var result = PrototypeCache.Display?.GetOrder();
return result ?? base.ComputeOrder();
}
protected override string ComputeSimpleDisplayText()
{
if (Model != null &&

View File

@ -2,7 +2,6 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
namespace Microsoft.AspNet.Mvc.ModelBinding
{
@ -17,7 +16,6 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
public abstract class CachedModelMetadata<TPrototypeCache> : ModelMetadata
{
private bool _convertEmptyStringToNull;
private string _nullDisplayText;
private string _dataTypeName;
private string _description;
private string _displayFormatString;
@ -29,6 +27,8 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
private bool _isReadOnly;
private bool _isComplexType;
private bool _isRequired;
private string _nullDisplayText;
private int _order;
private bool _showForDisplay;
private bool _showForEdit;
private IBinderMetadata _binderMetadata;
@ -37,7 +37,6 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
private Type _binderType;
private bool _convertEmptyStringToNullComputed;
private bool _nullDisplayTextComputed;
private bool _dataTypeNameComputed;
private bool _descriptionComputed;
private bool _displayFormatStringComputed;
@ -49,6 +48,8 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
private bool _isReadOnlyComputed;
private bool _isComplexTypeComputed;
private bool _isRequiredComputed;
private bool _nullDisplayTextComputed;
private bool _orderComputed;
private bool _showForDisplayComputed;
private bool _showForEditComputed;
private bool _isBinderMetadataComputed;
@ -378,6 +379,27 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
}
}
/// <inheritdoc />
public sealed override int Order
{
get
{
if (!_orderComputed)
{
_order = ComputeOrder();
_orderComputed = true;
}
return _order;
}
set
{
_order = value;
_orderComputed = true;
}
}
public sealed override IPropertyBindingPredicateProvider PropertyBindingPredicateProvider
{
get
@ -575,11 +597,24 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
return base.IsComplexType;
}
/// <summary>
/// Calculate the <see cref="NullDisplayText"/> value.
/// </summary>
/// <returns>Calculated <see cref="NullDisplayText"/> value.</returns>
protected virtual string ComputeNullDisplayText()
{
return base.NullDisplayText;
}
/// <summary>
/// Calculate the <see cref="Order"/> value.
/// </summary>
/// <returns>Calculated <see cref="Order"/> value.</returns>
protected virtual int ComputeOrder()
{
return base.Order;
}
protected virtual bool ComputeShowForDisplay()
{
return base.ShowForDisplay;

View File

@ -160,6 +160,15 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
set { _isRequired = value; }
}
/// <summary>
/// Gets or sets a value indicating where the current metadata should be ordered relative to other properties
/// in its containing type.
/// </summary>
/// <remarks>
/// <para>For example this property is used to order items in <see cref="Properties"/>.</para>
/// <para>The default order is <c>10000</c>.</para>
/// </remarks>
/// <value>The order value of the current metadata.</value>
public virtual int Order
{
get { return _order; }

View File

@ -2,7 +2,11 @@
// 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.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNet.Mvc.ModelBinding;
using Microsoft.AspNet.Mvc.Rendering;
@ -152,6 +156,48 @@ namespace Microsoft.AspNet.Mvc.Core
Assert.Equal(expected, result);
}
[Fact]
public void ObjectTemplate_OrdersProperties_AsExpected()
{
// Arrange
var model = new OrderedModel();
var html = DefaultTemplatesUtilities.GetHtmlHelper(model);
var expectedProperties = new List<string>
{
"OrderedProperty3",
"OrderedProperty2",
"OrderedProperty1",
"Property3",
"Property1",
"Property2",
"LastProperty",
};
var stringBuilder = new StringBuilder();
foreach (var property in expectedProperties)
{
var label = string.Format(
CultureInfo.InvariantCulture,
"<div class=\"display-label\">{0}</div>",
property);
stringBuilder.AppendLine(label);
var value = string.Format(
CultureInfo.InvariantCulture,
"<div class=\"display-field\">Model = (null), ModelType = System.String, PropertyName = {0}, " +
"SimpleDisplayText = (null)</div>",
property);
stringBuilder.AppendLine(value);
}
var expected = stringBuilder.ToString();
// Act
var result = DefaultDisplayTemplates.ObjectTemplate(html);
// Assert
Assert.Equal(expected, result);
}
[Fact]
public void HiddenInputTemplate_ReturnsValue()
{
@ -309,5 +355,22 @@ namespace Microsoft.AspNet.Mvc.Core
html.Display(expression: string.Empty, templateName: null, htmlFieldName: null, additionalViewData: null);
viewEngine.Verify();
}
private class OrderedModel
{
[Display(Order = 10001)]
public string LastProperty { get; set; }
public string Property3 { get; set; }
public string Property1 { get; set; }
public string Property2 { get; set; }
[Display(Order = 23)]
public string OrderedProperty3 { get; set; }
[Display(Order = 23)]
public string OrderedProperty2 { get; set; }
[Display(Order = 23)]
public string OrderedProperty1 { get; set; }
}
}
}

View File

@ -3,7 +3,10 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNet.Mvc.ModelBinding;
using Microsoft.AspNet.Mvc.Rendering;
@ -13,7 +16,7 @@ using Xunit;
namespace Microsoft.AspNet.Mvc.Core
{
public class DefaultEditorTemplatesTests
public class DefaultEditorTemplatesTest
{
// Mappings from templateName to expected result when using StubbyHtmlHelper.
public static TheoryData<string, string> TemplateNameData
@ -187,7 +190,7 @@ Environment.NewLine;
var model = new DefaultTemplatesUtilities.ObjectTemplateModel { Property1 = "p1", Property2 = null };
var html = DefaultTemplatesUtilities.GetHtmlHelper(model);
var metadata = html.ViewData.ModelMetadata.Properties["Property1"];
metadata.HideSurroundingHtml = true;
@ -198,6 +201,50 @@ Environment.NewLine;
Assert.Equal(expected, result);
}
[Fact]
public void ObjectTemplate_OrdersProperties_AsExpected()
{
// Arrange
var model = new OrderedModel();
var html = DefaultTemplatesUtilities.GetHtmlHelper(model);
var expectedProperties = new List<string>
{
"OrderedProperty3",
"OrderedProperty2",
"OrderedProperty1",
"Property3",
"Property1",
"Property2",
"LastProperty",
};
var stringBuilder = new StringBuilder();
foreach (var property in expectedProperties)
{
var label = string.Format(
CultureInfo.InvariantCulture,
"<div class=\"editor-label\"><label for=\"{0}\">{0}</label></div>",
property);
stringBuilder.AppendLine(label);
var value = string.Format(
CultureInfo.InvariantCulture,
"<div class=\"editor-field\">Model = (null), ModelType = System.String, PropertyName = {0}, " +
"SimpleDisplayText = (null) " +
"<span class=\"field-validation-valid\" data-valmsg-for=\"{0}\" data-valmsg-replace=\"true\">" +
"</span></div>",
property);
stringBuilder.AppendLine(value);
}
var expected = stringBuilder.ToString();
// Act
var result = DefaultEditorTemplates.ObjectTemplate(html);
// Assert
Assert.Equal(expected, result);
}
[Fact]
public void HiddenInputTemplate_ReturnsValueAndHiddenInput()
{
@ -702,6 +749,23 @@ Environment.NewLine;
viewEngine.Verify();
}
private class OrderedModel
{
[Display(Order = 10001)]
public string LastProperty { get; set; }
public string Property3 { get; set; }
public string Property1 { get; set; }
public string Property2 { get; set; }
[Display(Order = 23)]
public string OrderedProperty3 { get; set; }
[Display(Order = 23)]
public string OrderedProperty2 { get; set; }
[Display(Order = 23)]
public string OrderedProperty1 { get; set; }
}
private class StubbyHtmlHelper : IHtmlHelper, ICanHasViewContext
{
private readonly IHtmlHelper _innerHelper;

View File

@ -0,0 +1,403 @@
// 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.ComponentModel.DataAnnotations;
using Microsoft.AspNet.Mvc.ModelBinding;
using Xunit;
namespace Microsoft.AspNet.Mvc.Rendering
{
public class HtmlHelperValidationSummaryTest
{
// Message, HTML attributes, tag -> expected result.
public static TheoryData<string, object, string, string> ValidValidationSummaryData
{
get
{
var attributes = new { @class = "wood smoke", attribute_name = "attribute-value", };
var dictionary = new Dictionary<string, object>
{
{ "class", "wood smoke" },
{ "attribute-name", "attribute-value" },
};
var basicDiv = "<div class=\"validation-summary-valid\" data-valmsg-summary=\"true\">" +
"<ul><li style=\"display:none\"></li>" + Environment.NewLine +
"</ul></div>";
var divWithAttributes = "<div attribute-name=\"attribute-value\" " +
"class=\"validation-summary-valid wood smoke\" data-valmsg-summary=\"true\"><ul>" +
"<li style=\"display:none\"></li>" + Environment.NewLine +
"</ul></div>";
var divWithMessage = "<div class=\"validation-summary-valid\" data-valmsg-summary=\"true\">" +
"<span>This is my message</span>" + Environment.NewLine +
"<ul><li style=\"display:none\"></li>" + Environment.NewLine +
"</ul></div>";
var divWithH3Message = "<div class=\"validation-summary-valid\" data-valmsg-summary=\"true\">" +
"<h3>This is my message</h3>" + Environment.NewLine +
"<ul><li style=\"display:none\"></li>" + Environment.NewLine +
"</ul></div>";
var divWithMessageAndAttributes = "<div attribute-name=\"attribute-value\" " +
"class=\"validation-summary-valid wood smoke\" data-valmsg-summary=\"true\">" +
"<span>This is my message</span>" + Environment.NewLine +
"<ul><li style=\"display:none\"></li>" + Environment.NewLine +
"</ul></div>";
var divWithH3MessageAndAttributes = "<div attribute-name=\"attribute-value\" " +
"class=\"validation-summary-valid wood smoke\" data-valmsg-summary=\"true\">" +
"<h3>This is my message</h3>" + Environment.NewLine +
"<ul><li style=\"display:none\"></li>" + Environment.NewLine +
"</ul></div>";
return new TheoryData<string, object, string, string>
{
{ null, null, null, basicDiv },
{ null, null, "h3", basicDiv },
{ null, attributes, null, divWithAttributes },
{ null, attributes, "h3", divWithAttributes },
{ null, dictionary, null, divWithAttributes },
{ null, dictionary, "h3", divWithAttributes },
{ "This is my message", null, null, divWithMessage },
{ "This is my message", null, "h3", divWithH3Message },
{ "This is my message", attributes, null, divWithMessageAndAttributes },
{ "This is my message", attributes, "h3", divWithH3MessageAndAttributes },
{ "This is my message", dictionary, null, divWithMessageAndAttributes },
{ "This is my message", dictionary, "h3", divWithH3MessageAndAttributes },
};
}
}
// Exclude property errors, client validation enabled -> expected result with model error, with property error.
public static TheoryData<bool, bool, string, string> OneErrorValidationSummaryData
{
get
{
var basicDiv = "<div class=\"validation-summary-errors\"><ul>" +
"<li style=\"display:none\"></li>" + Environment.NewLine +
"</ul></div>";
var divWithError = "<div class=\"validation-summary-errors\"><ul>" +
"<li>This is my validation message</li>" + Environment.NewLine +
"</ul></div>";
var divWithErrorAndSummary = "<div class=\"validation-summary-errors\" data-valmsg-summary=\"true\"><ul>" +
"<li>This is my validation message</li>" + Environment.NewLine +
"</ul></div>";
return new TheoryData<bool, bool, string, string>
{
{ false, false, divWithError, divWithError },
{ false, true, divWithErrorAndSummary, divWithErrorAndSummary },
{ true, false, divWithError, basicDiv },
{ true, true, divWithError, basicDiv },
};
}
}
// Exclude property errors, prefix -> expected result
public static TheoryData<bool, string, string> MultipleErrorsValidationSummaryData
{
get
{
var basicDiv = "<div class=\"validation-summary-errors\"><ul>" +
"<li style=\"display:none\"></li>" + Environment.NewLine +
"</ul></div>";
var divWithRootError = "<div class=\"validation-summary-errors\"><ul>" +
"<li>This is an error for the model root.</li>" + Environment.NewLine +
"<li>This is another error for the model root.</li>" + Environment.NewLine +
"</ul></div>";
var divWithProperty3Error = "<div class=\"validation-summary-errors\"><ul>" +
"<li>This is an error for Property3.</li>" + Environment.NewLine +
"</ul></div>";
var divWithAllErrors = "<div class=\"validation-summary-errors\" data-valmsg-summary=\"true\"><ul>" +
"<li>This is an error for Property3.Property2.</li>" + Environment.NewLine +
"<li>This is an error for Property3.OrderedProperty3.</li>" + Environment.NewLine +
"<li>This is an error for Property3.OrderedProperty2.</li>" + Environment.NewLine +
"<li>This is an error for Property3.</li>" + Environment.NewLine +
"<li>This is an error for Property2.</li>" + Environment.NewLine +
"<li>This is another error for Property2.</li>" + Environment.NewLine +
"<li>This is an error for the model root.</li>" + Environment.NewLine +
"<li>This is another error for the model root.</li>" + Environment.NewLine +
"</ul></div>";
return new TheoryData<bool, string, string>
{
{ false, string.Empty, divWithAllErrors },
{ false, "Property2", divWithAllErrors },
{ false, "some.unrelated.prefix", divWithAllErrors },
{ true, string.Empty, divWithRootError },
{ true, "Property3", divWithProperty3Error },
{ true, "some.unrelated.prefix", basicDiv },
};
}
}
[Theory]
[MemberData(nameof(ValidValidationSummaryData))]
public void ValidationSummary_AllValid_ReturnsExpectedDiv(
string message,
object htmlAttributes,
string tag,
string expected)
{
// Arrange
var model = new ValidationModel();
var html = DefaultTemplatesUtilities.GetHtmlHelper(model);
// Act
var result = html.ValidationSummary(
excludePropertyErrors: false,
message: message,
htmlAttributes: htmlAttributes,
tag: tag);
// Assert
Assert.Equal(expected, result.ToString());
}
[Theory]
[MemberData(nameof(ValidValidationSummaryData))]
public void ValidationSummary_ExcludePropertyErrorsAllValid_ReturnsEmpty(
string message,
object htmlAttributes,
string tag,
string ignored)
{
// Arrange
var model = new ValidationModel();
var html = DefaultTemplatesUtilities.GetHtmlHelper(model);
// Act
var result = html.ValidationSummary(
excludePropertyErrors: true,
message: message,
htmlAttributes: htmlAttributes,
tag: tag);
// Assert
Assert.Equal(HtmlString.Empty, result);
}
[Theory]
[MemberData(nameof(ValidValidationSummaryData))]
public void ValidationSummary_ClientValidationDisabledAllValid_ReturnsEmpty(
string message,
object htmlAttributes,
string tag,
string ignored)
{
// Arrange
var model = new ValidationModel();
var html = DefaultTemplatesUtilities.GetHtmlHelper(model);
html.ViewContext.ClientValidationEnabled = false;
// Act
var result = html.ValidationSummary(
excludePropertyErrors: false,
message: message,
htmlAttributes: htmlAttributes,
tag: tag);
// Assert
Assert.Equal(HtmlString.Empty, result);
}
[Theory]
[MemberData(nameof(OneErrorValidationSummaryData))]
public void ValidationSummary_InvalidModel_ReturnsExpectedDiv(
bool excludePropertyErrors,
bool clientValidationEnabled,
string expected,
string ignored)
{
// Arrange
var model = new ValidationModel();
var html = DefaultTemplatesUtilities.GetHtmlHelper(model);
html.ViewContext.ClientValidationEnabled = clientValidationEnabled;
html.ViewData.ModelState.AddModelError(string.Empty, "This is my validation message");
// Act
var result = html.ValidationSummary(
excludePropertyErrors: excludePropertyErrors,
message: null,
htmlAttributes: null,
tag: null);
// Assert
Assert.Equal(expected, result.ToString());
}
[Theory]
[MemberData(nameof(OneErrorValidationSummaryData))]
public void ValidationSummary_InvalidModelWithPrefix_ReturnsExpectedDiv(
bool excludePropertyErrors,
bool clientValidationEnabled,
string expected,
string ignored)
{
// Arrange
var model = new ValidationModel();
var html = DefaultTemplatesUtilities.GetHtmlHelper(model);
html.ViewContext.ClientValidationEnabled = clientValidationEnabled;
html.ViewData.TemplateInfo.HtmlFieldPrefix = "this.is.my.prefix";
html.ViewData.ModelState.AddModelError("this.is.my.prefix", "This is my validation message");
// Act
var result = html.ValidationSummary(
excludePropertyErrors,
message: null,
htmlAttributes: null,
tag: null);
// Assert
Assert.Equal(expected, result.ToString());
}
[Theory]
[MemberData(nameof(OneErrorValidationSummaryData))]
public void ValidationSummary_OneInvalidProperty_ReturnsExpectedDiv(
bool excludePropertyErrors,
bool clientValidationEnabled,
string ignored,
string expected)
{
// Arrange
var model = new ValidationModel();
var html = DefaultTemplatesUtilities.GetHtmlHelper(model);
html.ViewContext.ClientValidationEnabled = clientValidationEnabled;
html.ViewData.ModelState.AddModelError("Property1", "This is my validation message");
// Act
var result = html.ValidationSummary(
excludePropertyErrors,
message: null,
htmlAttributes: null,
tag: null);
// Assert
Assert.Equal(expected, result.ToString());
}
[Theory]
[MemberData(nameof(MultipleErrorsValidationSummaryData))]
public void ValidationSummary_MultipleErrors_ReturnsExpectedDiv(
bool excludePropertyErrors,
string prefix,
string expected)
{
// Arrange
var model = new ValidationModel();
var html = DefaultTemplatesUtilities.GetHtmlHelper(model);
html.ViewData.TemplateInfo.HtmlFieldPrefix = prefix;
AddMultipleErrors(html.ViewData.ModelState);
// Act
var result = html.ValidationSummary(
excludePropertyErrors,
message: null,
htmlAttributes: null,
tag: null);
// Assert
Assert.Equal(expected, result.ToString());
}
[Fact]
public void ValidationSummary_ErrorsInModelUsingOrder_SortsErrorsAsExpected()
{
// Arrange
var expected = "<div class=\"validation-summary-errors\" data-valmsg-summary=\"true\"><ul>" +
"<li>This is an error for OrderedProperty3.</li>" + Environment.NewLine +
"<li>This is an error for OrderedProperty2.</li>" + Environment.NewLine +
"<li>This is another error for OrderedProperty2.</li>" + Environment.NewLine +
"<li>This is yet-another error for OrderedProperty2.</li>" + Environment.NewLine +
"<li>This is an error for OrderedProperty1.</li>" + Environment.NewLine +
"<li>This is an error for Property3.</li>" + Environment.NewLine +
"<li>This is an error for Property2.</li>" + Environment.NewLine +
"<li>This is another error for Property2.</li>" + Environment.NewLine +
"<li>This is an error for Property1.</li>" + Environment.NewLine +
"<li>This is another error for Property1.</li>" + Environment.NewLine +
"<li>This is an error for LastProperty.</li>" + Environment.NewLine +
"</ul></div>";
var model = new OrderedModel();
var html = DefaultTemplatesUtilities.GetHtmlHelper(model);
AddOrderedErrors(html.ViewData.ModelState);
// Act
var result = html.ValidationSummary(
excludePropertyErrors: false,
message: null,
htmlAttributes: null,
tag: null);
// Assert
Assert.Equal(expected, result.ToString());
}
// Adds errors for various parts of the model, including the root.
private void AddMultipleErrors(ModelStateDictionary modelState)
{
modelState.AddModelError("Property3.Property2", "This is an error for Property3.Property2.");
modelState.AddModelError("Property3.OrderedProperty3", "This is an error for Property3.OrderedProperty3.");
modelState.AddModelError("Property3.OrderedProperty2", "This is an error for Property3.OrderedProperty2.");
modelState.AddModelError("Property3", "This is an error for Property3.");
modelState.AddModelError("Property3", new InvalidCastException("Exception will be ignored."));
modelState.AddModelError("Property2", "This is an error for Property2.");
modelState.AddModelError("Property2", "This is another error for Property2.");
modelState.AddModelError(string.Empty, "This is an error for the model root.");
modelState.AddModelError(string.Empty, "This is another error for the model root.");
modelState.AddModelError(string.Empty, new InvalidOperationException("Another ignored Exception."));
}
// Adds one or more errors for all properties in OrderedModel. But adds errors out of order.
private void AddOrderedErrors(ModelStateDictionary modelState)
{
modelState.AddModelError("Property3", "This is an error for Property3.");
modelState.AddModelError("Property3", new InvalidCastException("An ignored Exception."));
modelState.AddModelError("Property2", "This is an error for Property2.");
modelState.AddModelError("Property2", "This is another error for Property2.");
modelState.AddModelError("OrderedProperty3", "This is an error for OrderedProperty3.");
modelState.AddModelError("OrderedProperty2", "This is an error for OrderedProperty2.");
modelState.AddModelError("OrderedProperty2", "This is another error for OrderedProperty2.");
modelState.AddModelError("LastProperty", "This is an error for LastProperty.");
modelState.AddModelError("Property1", "This is an error for Property1.");
modelState.AddModelError("Property1", "This is another error for Property1.");
modelState.AddModelError("OrderedProperty1", "This is an error for OrderedProperty1.");
modelState.AddModelError("OrderedProperty2", "This is yet-another error for OrderedProperty2.");
}
private class ValidationModel
{
public string Property1 { get; set; }
public string Property2 { get; set; }
public OrderedModel Property3 { get; set; }
}
private class OrderedModel
{
[Display(Order = 10001)]
public string LastProperty { get; set; }
public string Property3 { get; set; }
public string Property1 { get; set; }
public string Property2 { get; set; }
[Display(Order = 23)]
public string OrderedProperty3 { get; set; }
[Display(Order = 23)]
public string OrderedProperty2 { get; set; }
[Display(Order = 23)]
public string OrderedProperty1 { get; set; }
}
}
}

View File

@ -0,0 +1,21 @@
<h3>Vehicle details</h3>
<div class="left">
<div class="display-label">Model</div>
<div class="display-field">the Fastener</div>
<div class="display-label">Make</div>
<div class="display-field">Fast Cars</div>
<div class="display-label">Vin</div>
<div class="display-field">87654321</div>
<div class="display-label">Year</div>
<div class="display-field">2013</div>
<div class="display-label">LastUpdatedTrackingId</div>
<div class="display-field"></div>
<div class="display-label">
InspectedDates
</div>
<div class="display-field">
01/04/2001 00:00:00 -08:00
</div>
</div>

View File

@ -0,0 +1,22 @@
<div class="left">
<form action="/vehicles/42/edit" method="post"><div class="validation-summary-errors" data-valmsg-summary="true"><ul><li>The field Vin must be a string with a maximum length of 8.</li>
<li>The field Year must be between 1980 and 2034.</li>
<li>The field InspectedDates must be a string or array type with a maximum length of &#39;10&#39;.</li>
</ul></div><div class="editor-label"><label for="Model">Model</label></div>
<div class="editor-field"><input class="text-box single-line" id="Model" name="Model" type="text" value="the Fastener" /> <span class="field-validation-valid" data-valmsg-for="Model" data-valmsg-replace="true"></span></div>
<div class="editor-label"><label for="Make">Make</label></div>
<div class="editor-field"><input class="text-box single-line" id="Make" name="Make" type="text" value="Fast Cars" /> <span class="field-validation-valid" data-valmsg-for="Make" data-valmsg-replace="true"></span></div>
<div class="editor-label"><label for="Vin">Vin</label></div>
<div class="editor-field"><input class="input-validation-error text-box single-line" data-val="true" data-val-length="The field Vin must be a string with a maximum length of 8." data-val-length-max="8" data-val-required="The Vin field is required." id="Vin" name="Vin" type="text" value="8765432112345678" /> <span class="field-validation-error" data-valmsg-for="Vin" data-valmsg-replace="true">The field Vin must be a string with a maximum length of 8.</span></div>
<div class="editor-label"><label for="Year">Year</label></div>
<div class="editor-field"><input class="input-validation-error text-box single-line" data-val="true" data-val-range="The field Year must be between 1980 and 2034." data-val-range-max="2034" data-val-range-min="1980" id="Year" name="Year" type="number" value="1979" /> <span class="field-validation-error" data-valmsg-for="Year" data-valmsg-replace="true">The field Year must be between 1980 and 2034.</span></div>
<div class="editor-label"><label for="LastUpdatedTrackingId">LastUpdatedTrackingId</label></div>
<div class="editor-field"><input class="text-box single-line" id="LastUpdatedTrackingId" name="LastUpdatedTrackingId" type="text" value="" /> <span class="field-validation-valid" data-valmsg-for="LastUpdatedTrackingId" data-valmsg-replace="true"></span></div>
<div class="editor-label">
<label for="InspectedDates">InspectedDates</label>
</div>
<div class="editor-field">
<input class="text-box single-line" id="InspectedDates_0_" name="InspectedDates[0]" type="datetime" value="14/10/1979 00:00:00 -08:00" /><input class="text-box single-line" id="InspectedDates_1_" name="InspectedDates[1]" type="datetime" value="16/10/1979 00:00:00 -08:00" /><input class="text-box single-line" id="InspectedDates_2_" name="InspectedDates[2]" type="datetime" value="02/11/1979 00:00:00 -08:00" /><input class="text-box single-line" id="InspectedDates_3_" name="InspectedDates[3]" type="datetime" value="13/11/1979 00:00:00 -08:00" /><input class="text-box single-line" id="InspectedDates_4_" name="InspectedDates[4]" type="datetime" value="05/12/1979 00:00:00 -08:00" /><input class="text-box single-line" id="InspectedDates_5_" name="InspectedDates[5]" type="datetime" value="12/12/1979 00:00:00 -08:00" /><input class="text-box single-line" id="InspectedDates_6_" name="InspectedDates[6]" type="datetime" value="19/12/1979 00:00:00 -08:00" /><input class="text-box single-line" id="InspectedDates_7_" name="InspectedDates[7]" type="datetime" value="26/12/1979 00:00:00 -08:00" /><input class="text-box single-line" id="InspectedDates_8_" name="InspectedDates[8]" type="datetime" value="28/12/1979 00:00:00 -08:00" /><input class="text-box single-line" id="InspectedDates_9_" name="InspectedDates[9]" type="datetime" value="29/12/1979 00:00:00 -08:00" /><input class="text-box single-line" id="InspectedDates_10_" name="InspectedDates[10]" type="datetime" value="01/04/1980 00:00:00 -08:00" />
</div>
<input type="submit" value="Update" />
</form></div>

View File

@ -0,0 +1,20 @@
<div class="left">
<form action="/vehicles/42/edit" method="post"><div class="validation-summary-valid" data-valmsg-summary="true"><ul><li style="display:none"></li>
</ul></div><div class="editor-label"><label for="Model">Model</label></div>
<div class="editor-field"><input class="text-box single-line" id="Model" name="Model" type="text" value="the Fastener" /> <span class="field-validation-valid" data-valmsg-for="Model" data-valmsg-replace="true"></span></div>
<div class="editor-label"><label for="Make">Make</label></div>
<div class="editor-field"><input class="text-box single-line" id="Make" name="Make" type="text" value="Fast Cars" /> <span class="field-validation-valid" data-valmsg-for="Make" data-valmsg-replace="true"></span></div>
<div class="editor-label"><label for="Vin">Vin</label></div>
<div class="editor-field"><input class="text-box single-line" data-val="true" data-val-length="The field Vin must be a string with a maximum length of 8." data-val-length-max="8" data-val-required="The Vin field is required." id="Vin" name="Vin" type="text" value="87654321" /> <span class="field-validation-valid" data-valmsg-for="Vin" data-valmsg-replace="true"></span></div>
<div class="editor-label"><label for="Year">Year</label></div>
<div class="editor-field"><input class="text-box single-line" data-val="true" data-val-range="The field Year must be between 1980 and 2034." data-val-range-max="2034" data-val-range-min="1980" id="Year" name="Year" type="number" value="2013" /> <span class="field-validation-valid" data-valmsg-for="Year" data-valmsg-replace="true"></span></div>
<div class="editor-label"><label for="LastUpdatedTrackingId">LastUpdatedTrackingId</label></div>
<div class="editor-field"><input class="text-box single-line" id="LastUpdatedTrackingId" name="LastUpdatedTrackingId" type="text" value="" /> <span class="field-validation-valid" data-valmsg-for="LastUpdatedTrackingId" data-valmsg-replace="true"></span></div>
<div class="editor-label">
<label for="InspectedDates">InspectedDates</label>
</div>
<div class="editor-field">
<input class="text-box single-line" id="InspectedDates_0_" name="InspectedDates[0]" type="datetime" value="01/04/2001 00:00:00 -08:00" /><input class="text-box single-line" id="InspectedDates_1_" name="InspectedDates[1]" type="datetime" value="01/01/0001 00:00:00 +00:00" />
</div>
<input type="submit" value="Update" />
</form></div>

View File

@ -19,7 +19,7 @@ using Xunit;
namespace Microsoft.AspNet.Mvc.FunctionalTests
{
public class ModelBindingTests
public class ModelBindingTest
{
private readonly IServiceProvider _services = TestHelper.CreateServices("ModelBindingWebSite");
private readonly Action<IApplicationBuilder> _app = new ModelBindingWebSite.Startup().Configure;
@ -1450,5 +1450,84 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
// Round-tripped value includes descendent instances for all properties with data in the request.
Assert.Equal("grandFatherName", employee.Parent.Parent.Name);
}
[Fact]
public async Task HtmlHelper_DisplayFor_ShowsPropertiesInModelMetadataOrder()
{
// Arrange
var expectedContent = await GetType().GetTypeInfo().Assembly.ReadResourceAsStringAsync(
"compiler/resources/ModelBindingWebSite.Vehicle.Details.html");
var server = TestServer.Create(_services, _app);
var client = server.CreateClient();
var url = "http://localhost/vehicles/42";
// Act
var response = await client.GetAsync(url);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(expectedContent, body);
}
[Fact]
public async Task HtmlHelper_EditorFor_ShowsPropertiesInModelMetadataOrder()
{
// Arrange
var expectedContent = await GetType().GetTypeInfo().Assembly.ReadResourceAsStringAsync(
"compiler/resources/ModelBindingWebSite.Vehicle.Edit.html");
var server = TestServer.Create(_services, _app);
var client = server.CreateClient();
var url = "http://localhost/vehicles/42/edit";
// Act
var response = await client.GetAsync(url);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(expectedContent, body);
}
[Fact]
public async Task HtmlHelper_EditorFor_ShowsPropertiesAndErrorsInModelMetadataOrder()
{
// Arrange
var expectedContent = await GetType().GetTypeInfo().Assembly.ReadResourceAsStringAsync(
"compiler/resources/ModelBindingWebSite.Vehicle.Edit.Invalid.html");
var server = TestServer.Create(_services, _app);
var client = server.CreateClient();
var url = "http://localhost/vehicles/42/edit";
var contentDictionary = new Dictionary<string, string>
{
{ "Make", "Fast Cars" },
{ "Model", "the Fastener" },
{ "InspectedDates[0]", "14/10/1979 00:00:00 -08:00" },
{ "InspectedDates[1]", "16/10/1979 00:00:00 -08:00" },
{ "InspectedDates[2]", "02/11/1979 00:00:00 -08:00" },
{ "InspectedDates[3]", "13/11/1979 00:00:00 -08:00" },
{ "InspectedDates[4]", "05/12/1979 00:00:00 -08:00" },
{ "InspectedDates[5]", "12/12/1979 00:00:00 -08:00" },
{ "InspectedDates[6]", "19/12/1979 00:00:00 -08:00" },
{ "InspectedDates[7]", "26/12/1979 00:00:00 -08:00" },
{ "InspectedDates[8]", "28/12/1979 00:00:00 -08:00" },
{ "InspectedDates[9]", "29/12/1979 00:00:00 -08:00" },
{ "InspectedDates[10]", "01/04/1980 00:00:00 -08:00" },
{ "Vin", "8765432112345678" },
{ "Year", "1979" },
};
var requestContent = new FormUrlEncodedContent(contentDictionary);
// Act
var response = await client.PostAsync(url, requestContent);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(expectedContent, body);
}
}
}

View File

@ -229,6 +229,61 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
Assert.Equal(expectedResult, result);
}
public static TheoryData<DisplayAttribute, int> DisplayAttribute_OverridesOrderData
{
get
{
return new TheoryData<DisplayAttribute, int>
{
{
new DisplayAttribute(), ModelMetadata.DefaultOrder
},
{
new DisplayAttribute { Order = int.MinValue }, int.MinValue
},
{
new DisplayAttribute { Order = -100 }, -100
},
{
new DisplayAttribute { Order = -1 }, -1
},
{
new DisplayAttribute { Order = 0 }, 0
},
{
new DisplayAttribute { Order = 1 }, 1
},
{
new DisplayAttribute { Order = 200 }, 200
},
{
new DisplayAttribute { Order = int.MaxValue }, int.MaxValue
},
};
}
}
[Theory]
[MemberData(nameof(DisplayAttribute_OverridesOrderData))]
public void DisplayAttribute_OverridesOrder(DisplayAttribute attribute, int expectedOrder)
{
// Arrange
var attributes = new[] { attribute };
var provider = new DataAnnotationsModelMetadataProvider();
var metadata = new CachedDataAnnotationsModelMetadata(
provider,
containerType: null,
modelType: typeof(object),
propertyName: null,
attributes: attributes);
// Act
var result = metadata.Order;
// Assert
Assert.Equal(expectedOrder, result);
}
[Fact]
public void BinderMetadataIfPresent_Overrides_DefaultBinderMetadata()
{

View File

@ -7,9 +7,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
#if ASPNET50
using Moq;
#endif
using System.Reflection;
using Xunit;
namespace Microsoft.AspNet.Mvc.ModelBinding
@ -26,8 +24,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
var binderMetadata = new TestBinderMetadata();
var predicateProvider = new DummyPropertyBindingPredicateProvider();
var emptyPropertyList = new List<string>();
var nonEmptyPropertyList = new List<string>() { "SomeProperty" };
return new TheoryData<Action<ModelMetadata>, Func<ModelMetadata, object>, object>
{
{ m => m.ConvertEmptyStringToNull = false, m => m.ConvertEmptyStringToNull, false },
@ -108,6 +105,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
Assert.Equal(typeof(string), metadata.ModelType);
Assert.Equal("propertyName", metadata.PropertyName);
Assert.Equal(10000, ModelMetadata.DefaultOrder);
Assert.Equal(ModelMetadata.DefaultOrder, metadata.Order);
Assert.Null(metadata.BinderModelName);
@ -279,27 +277,168 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
}
// Properties
#if ASPNET50
[Fact]
public void PropertiesCallsProvider()
public void PropertiesProperty_CallsProvider()
{
// Arrange
var modelType = typeof(string);
var propertyMetadata = new List<ModelMetadata>();
var provider = new Mock<IModelMetadataProvider>();
var metadata = new ModelMetadata(provider.Object, null, null, modelType, null);
provider.Setup(p => p.GetMetadataForProperties(null, modelType))
.Returns(propertyMetadata)
.Verifiable();
var modelType = typeof(object);
var provider = new PropertiesModelMetadataProvider(new List<string>());
var metadata = new ModelMetadata(
provider,
containerType: null,
modelAccessor: null,
modelType: modelType,
propertyName: null);
// Act
var result = metadata.Properties;
// Assert
Assert.Equal(propertyMetadata, result.ToList());
provider.Verify();
Assert.Empty(result);
Assert.Equal(1, provider.GetMetadataForPropertiesCalls);
}
// Input (original) property names and expected (ordered) property names.
public static TheoryData<IEnumerable<string>, IEnumerable<string>> PropertyNamesTheoryData
{
get
{
// ModelMetadata does not reorder properties Reflection returns without an Order override.
return new TheoryData<IEnumerable<string>, IEnumerable<string>>
{
{
new List<string> { "Property1", "Property2", "Property3", "Property4", },
new List<string> { "Property1", "Property2", "Property3", "Property4", }
},
{
new List<string> { "Property4", "Property3", "Property2", "Property1", },
new List<string> { "Property4", "Property3", "Property2", "Property1", }
},
{
new List<string> { "Delta", "Bravo", "Charlie", "Alpha", },
new List<string> { "Delta", "Bravo", "Charlie", "Alpha", }
},
{
new List<string> { "John", "Jonathan", "Jon", "Joan", },
new List<string> { "John", "Jonathan", "Jon", "Joan", }
},
};
}
}
[Theory]
[MemberData(nameof(PropertyNamesTheoryData))]
public void PropertiesProperty_WithDefaultOrder_OrdersPropertyNamesAlphabetically(
IEnumerable<string> originalNames,
IEnumerable<string> expectedNames)
{
// Arrange
var modelType = typeof(object);
var provider = new PropertiesModelMetadataProvider(originalNames);
var metadata = new ModelMetadata(
provider,
containerType: null,
modelAccessor: null,
modelType: modelType,
propertyName: null);
// Act
var result = metadata.Properties;
// Assert
var propertyNames = result.Select(property => property.PropertyName);
Assert.Equal(expectedNames, propertyNames);
}
// Input (original) property names, Order values, and expected (ordered) property names.
public static TheoryData<IEnumerable<KeyValuePair<string, int>>, IEnumerable<string>>
PropertyNamesAndOrdersTheoryData
{
get
{
return new TheoryData<IEnumerable<KeyValuePair<string, int>>, IEnumerable<string>>
{
{
new List<KeyValuePair<string, int>>
{
new KeyValuePair<string, int>("Property1", 23),
new KeyValuePair<string, int>("Property2", 23),
new KeyValuePair<string, int>("Property3", 23),
new KeyValuePair<string, int>("Property4", 23),
},
new List<string> { "Property1", "Property2", "Property3", "Property4", }
},
// Same order if already ordered using Order.
{
new List<KeyValuePair<string, int>>
{
new KeyValuePair<string, int>("Property4", 23),
new KeyValuePair<string, int>("Property3", 24),
new KeyValuePair<string, int>("Property2", 25),
new KeyValuePair<string, int>("Property1", 26),
},
new List<string> { "Property4", "Property3", "Property2", "Property1", }
},
// Rest of the orderings get updated within ModelMetadata.
{
new List<KeyValuePair<string, int>>
{
new KeyValuePair<string, int>("Property1", 26),
new KeyValuePair<string, int>("Property2", 25),
new KeyValuePair<string, int>("Property3", 24),
new KeyValuePair<string, int>("Property4", 23),
},
new List<string> { "Property4", "Property3", "Property2", "Property1", }
},
{
new List<KeyValuePair<string, int>>
{
new KeyValuePair<string, int>("Alpha", 26),
new KeyValuePair<string, int>("Bravo", 24),
new KeyValuePair<string, int>("Charlie", 23),
new KeyValuePair<string, int>("Delta", 25),
},
new List<string> { "Charlie", "Bravo", "Delta", "Alpha", }
},
// Jonathan and Jon will not be reordered.
{
new List<KeyValuePair<string, int>>
{
new KeyValuePair<string, int>("Joan", 1),
new KeyValuePair<string, int>("Jonathan", 0),
new KeyValuePair<string, int>("Jon", 0),
new KeyValuePair<string, int>("John", -1),
},
new List<string> { "John", "Jonathan", "Jon", "Joan", }
},
};
}
}
[Theory]
[MemberData(nameof(PropertyNamesAndOrdersTheoryData))]
public void PropertiesProperty_OrdersPropertyNamesUsingOrder_ThenAlphabetically(
IEnumerable<KeyValuePair<string, int>> originalNamesAndOrders,
IEnumerable<string> expectedNames)
{
// Arrange
var modelType = typeof(object);
var provider = new PropertiesModelMetadataProvider(originalNamesAndOrders);
var metadata = new ModelMetadata(
provider,
containerType: null,
modelAccessor: null,
modelType: modelType,
propertyName: null);
// Act
var result = metadata.Properties;
// Assert
var propertyNames = result.Select(property => property.PropertyName);
Assert.Equal(expectedNames, propertyNames);
}
#endif
[Theory]
[MemberData(nameof(MetadataModifierData))]
@ -568,5 +707,76 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
{
public Func<ModelBindingContext, string, bool> PropertyFilter { get; set; }
}
// Gives object type properties with provided names or names and Order values.
private class PropertiesModelMetadataProvider : IModelMetadataProvider
{
private List<ModelMetadata> _properties = new List<ModelMetadata>();
public PropertiesModelMetadataProvider(IEnumerable<string> propertyNames)
{
foreach (var propertyName in propertyNames)
{
var metadata = new ModelMetadata(
this,
containerType: typeof(DummyContactModel),
modelAccessor: null,
modelType: typeof(string),
propertyName: propertyName);
_properties.Add(metadata);
}
}
public PropertiesModelMetadataProvider(IEnumerable<KeyValuePair<string, int>> propertyNamesAndOrders)
{
foreach (var keyValuePair in propertyNamesAndOrders)
{
var metadata = new ModelMetadata(
this,
containerType: typeof(DummyContactModel),
modelAccessor: null,
modelType: typeof(string),
propertyName: keyValuePair.Key)
{
Order = keyValuePair.Value,
};
_properties.Add(metadata);
}
}
public int GetMetadataForPropertiesCalls { get; private set; }
public ModelMetadata GetMetadataForParameter(
Func<object> modelAccessor,
[NotNull] MethodInfo methodInfo,
[NotNull] string parameterName)
{
throw new NotImplementedException();
}
public IEnumerable<ModelMetadata> GetMetadataForProperties(object container, [NotNull] Type containerType)
{
Assert.Null(container);
Assert.Equal(typeof(object), containerType);
GetMetadataForPropertiesCalls++;
return _properties;
}
public ModelMetadata GetMetadataForProperty(
Func<object> modelAccessor,
[NotNull] Type containerType,
[NotNull] string propertyName)
{
throw new NotImplementedException();
}
public ModelMetadata GetMetadataForType(Func<object> modelAccessor, [NotNull] Type modelType)
{
throw new NotImplementedException();
}
}
}
}

View File

@ -382,14 +382,16 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
dictionary.AddModelError("key3", new Exception());
dictionary.AddModelError("key4", "error4");
dictionary.AddModelError("key5", "error5");
dictionary.AddModelError("key6", "error6");
// Act and Assert
Assert.True(dictionary.HasReachedMaxErrors);
Assert.Equal(5, dictionary.ErrorCount);
var error = Assert.Single(dictionary[""].Errors);
var error = Assert.Single(dictionary[string.Empty].Errors);
Assert.IsType<TooManyModelErrorsException>(error.Exception);
Assert.Equal(expected, error.Exception.Message);
// TooManyModelErrorsException added instead of key5 error.
Assert.DoesNotContain("key5", dictionary.Keys);
}
[Fact]
@ -403,25 +405,35 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
};
// Act and Assert
Assert.False(dictionary.HasReachedMaxErrors);
var result = dictionary.TryAddModelError("key1", "error1");
Assert.True(result);
Assert.False(dictionary.HasReachedMaxErrors);
result = dictionary.TryAddModelError("key2", new Exception());
Assert.True(result);
Assert.False(dictionary.HasReachedMaxErrors); // Still room for TooManyModelErrorsException.
result = dictionary.TryAddModelError("key3", "error3");
Assert.False(result);
result = dictionary.TryAddModelError("key4", "error4");
Assert.True(dictionary.HasReachedMaxErrors);
result = dictionary.TryAddModelError("key4", "error4"); // no-op
Assert.False(result);
Assert.True(dictionary.HasReachedMaxErrors);
Assert.Equal(3, dictionary.ErrorCount);
Assert.Equal(3, dictionary.Count);
var error = Assert.Single(dictionary[""].Errors);
var error = Assert.Single(dictionary[string.Empty].Errors);
Assert.IsType<TooManyModelErrorsException>(error.Exception);
Assert.Equal(expected, error.Exception.Message);
// TooManyModelErrorsException added instead of key3 error.
Assert.DoesNotContain("key3", dictionary.Keys);
// Last addition did nothing.
Assert.DoesNotContain("key4", dictionary.Keys);
}
[Fact]
@ -437,16 +449,19 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
dictionary.AddModelError("key2", "error2");
dictionary.AddModelError("key3", "error3");
dictionary.AddModelError("key3", new Exception());
dictionary.AddModelError("key4", new InvalidOperationException());
dictionary.AddModelError("key5", new FormatException());
// Act and Assert
Assert.True(dictionary.HasReachedMaxErrors);
Assert.Equal(4, dictionary.ErrorCount);
Assert.Equal(4, dictionary.Count);
var error = Assert.Single(dictionary[""].Errors);
var error = Assert.Single(dictionary[string.Empty].Errors);
Assert.IsType<TooManyModelErrorsException>(error.Exception);
Assert.Equal(expected, error.Exception.Message);
// Second key3 model error resulted in TooManyModelErrorsException instead.
error = Assert.Single(dictionary["key3"].Errors);
Assert.Null(error.Exception);
Assert.Equal("error3", error.ErrorMessage);
}
[Fact]
@ -470,7 +485,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
Assert.False(result);
Assert.Equal(3, dictionary.Count);
var error = Assert.Single(dictionary[""].Errors);
var error = Assert.Single(dictionary[string.Empty].Errors);
Assert.IsType<TooManyModelErrorsException>(error.Exception);
Assert.Equal(expected, error.Exception.Message);
}
@ -494,7 +509,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
// Assert
Assert.Equal(3, copy.Count);
var error = Assert.Single(copy[""].Errors);
var error = Assert.Single(copy[string.Empty].Errors);
Assert.IsType<TooManyModelErrorsException>(error.Exception);
Assert.Equal(expected, error.Exception.Message);
}

View File

@ -1,13 +1,11 @@
// 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.
#if ASPNET50
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.AspNet.Testing;
using Moq;
using Xunit;
namespace Microsoft.AspNet.Mvc.ModelBinding
@ -166,6 +164,97 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
Assert.Equal(expected, log);
}
// Validation order is primarily important when MaxAllowedErrors has been overridden.
[Fact]
public void Validate_OrdersUsingModelMetadata()
{
// Proper order of invocation:
// 1. OnValidating()
// 2. Child validators -- ordered using ModelMetadata.Order.
// 3. OnValidated()
// Arrange
var expected = new[]
{
"In OnValidating()",
"In LoggingValidatonAttribute.IsValid(OrderedProperty3)",
"In LoggingValidatonAttribute.IsValid(OrderedProperty2)",
"In LoggingValidatonAttribute.IsValid(OrderedProperty1)",
"In LoggingValidatonAttribute.IsValid(Property3)",
"In LoggingValidatonAttribute.IsValid(Property1)",
"In LoggingValidatonAttribute.IsValid(Property2)",
"In LoggingValidatonAttribute.IsValid(LastProperty)",
"In OnValidated()"
};
var log = new List<string>();
var model = new LoggingNonValidatableObject(log);
var provider = new DataAnnotationsModelMetadataProvider();
var modelMetadata = provider.GetMetadataForType(() => model, model.GetType());
var node = new ModelValidationNode(modelMetadata, "theKey")
{
ValidateAllProperties = true,
};
node.Validating += (sender, e) => log.Add("In OnValidating()");
node.Validated += (sender, e) => log.Add("In OnValidated()");
var context = CreateContext(modelMetadata, provider);
// Act
node.Validate(context);
// Assert
Assert.Equal(expected, log);
}
[Fact]
public void Validate_ChildNodes_OverridesOrdering()
{
// Proper order of invocation:
// 1. OnValidating()
// 2. Child validators -- ordered using ChildNodes, then ModelMetadata.Order.
// 3. OnValidated()
// Arrange
var expected = new[]
{
"In OnValidating()",
"In LoggingValidatonAttribute.IsValid(LastProperty)",
"In LoggingValidatonAttribute.IsValid(OrderedProperty3)",
"In LoggingValidatonAttribute.IsValid(OrderedProperty2)",
"In LoggingValidatonAttribute.IsValid(OrderedProperty1)",
"In LoggingValidatonAttribute.IsValid(Property3)",
"In LoggingValidatonAttribute.IsValid(Property1)",
"In LoggingValidatonAttribute.IsValid(Property2)",
"In OnValidated()"
};
var log = new List<string>();
var model = new LoggingNonValidatableObject(log);
var provider = new DataAnnotationsModelMetadataProvider();
var modelMetadata = provider.GetMetadataForType(() => model, model.GetType());
var childMetadata = modelMetadata.Properties.FirstOrDefault(
property => property.PropertyName == "LastProperty");
Assert.NotNull(childMetadata); // Guard
var node = new ModelValidationNode(modelMetadata, "theKey")
{
ChildNodes =
{
new ModelValidationNode(childMetadata, "theKey.LastProperty")
},
ValidateAllProperties = true,
};
node.Validating += (sender, e) => log.Add("In OnValidating()");
node.Validated += (sender, e) => log.Add("In OnValidated()");
var context = CreateContext(modelMetadata, provider);
// Act
node.Validate(context);
// Assert
Assert.Equal(expected, log);
}
[Fact]
public void Validate_SkipsRemainingValidationIfModelStateIsInvalid()
{
@ -291,13 +380,19 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
node.Validate(context);
// Assert
Assert.False(context.ModelState.ContainsKey("theKey.RequiredString"));
Assert.Equal("existing Error Text",
context.ModelState["theKey.RequiredString.Dummy"].Errors[0].ErrorMessage);
Assert.Equal("The field RangedInt must be between 10 and 30.",
context.ModelState["theKey.RangedInt"].Errors[0].ErrorMessage);
Assert.False(context.ModelState.ContainsKey("theKey.ValidString"));
Assert.False(context.ModelState.ContainsKey("theKey"));
var modelState = context.ModelState["theKey.RequiredString.Dummy"];
Assert.NotNull(modelState);
var error = Assert.Single(modelState.Errors);
Assert.Equal("existing Error Text", error.ErrorMessage);
modelState = context.ModelState["theKey.RangedInt"];
Assert.NotNull(modelState);
error = Assert.Single(modelState.Errors);
Assert.Equal("The field RangedInt must be between 10 and 30.", error.ErrorMessage);
Assert.DoesNotContain("theKey.RequiredString", context.ModelState.Keys);
Assert.DoesNotContain("theKey.ValidString", context.ModelState.Keys);
Assert.DoesNotContain("theKey", context.ModelState.Keys);
}
[Fact]
@ -311,6 +406,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
RangedInt = 0 /* error */,
ValidString = "cat" /* error */
};
var expectedMessage = ValidationAttributeUtil.GetRequiredErrorMessage("RequiredString");
var modelMetadata = GetModelMetadata(model);
var node = new ModelValidationNode(modelMetadata, "theKey")
@ -326,11 +422,20 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
// Assert
Assert.Equal(3, context.ModelState.Count);
Assert.IsType<TooManyModelErrorsException>(context.ModelState[""].Errors[0].Exception);
Assert.Equal(ValidationAttributeUtil.GetRequiredErrorMessage("RequiredString"),
context.ModelState["theKey.RequiredString"].Errors[0].ErrorMessage);
Assert.False(context.ModelState.ContainsKey("theKey.RangedInt"));
Assert.False(context.ModelState.ContainsKey("theKey.ValidString"));
var modelState = context.ModelState[string.Empty];
Assert.NotNull(modelState);
var error = Assert.Single(modelState.Errors);
Assert.IsType<TooManyModelErrorsException>(error.Exception);
// RequiredString is validated first due to ModelMetadata.Properties ordering (Reflection-based).
modelState = context.ModelState["theKey.RequiredString"];
Assert.NotNull(modelState);
error = Assert.Single(modelState.Errors);
Assert.Equal(expectedMessage, error.ErrorMessage);
// No room for the other validation errors.
Assert.DoesNotContain("theKey.RangedInt", context.ModelState.Keys);
Assert.DoesNotContain("theKey.ValidString", context.ModelState.Keys);
}
private static ModelMetadata GetModelMetadata()
@ -338,9 +443,9 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
return new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(object));
}
private static ModelMetadata GetModelMetadata(object o)
private static ModelMetadata GetModelMetadata(object model)
{
return new DataAnnotationsModelMetadataProvider().GetMetadataForType(() => o, o.GetType());
return new DataAnnotationsModelMetadataProvider().GetMetadataForType(() => model, model.GetType());
}
private static ModelMetadata GetModelMetadata(Type type)
@ -348,15 +453,21 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
return new DataAnnotationsModelMetadataProvider().GetMetadataForType(modelAccessor: null, modelType: type);
}
private static ModelValidationContext CreateContext(ModelMetadata metadata = null)
private static ModelValidationContext CreateContext(
ModelMetadata metadata = null,
IModelMetadataProvider metadataProvider = null)
{
var providers = new IModelValidatorProvider[]
{
new DataAnnotationsModelValidatorProvider(),
new DataMemberModelValidatorProvider()
};
if (metadataProvider == null)
{
metadataProvider = new EmptyModelMetadataProvider();
}
return new ModelValidationContext(new EmptyModelMetadataProvider(),
return new ModelValidationContext(metadataProvider,
new CompositeModelValidatorProvider(providers),
new ModelStateDictionary(),
metadata,
@ -378,6 +489,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
Assert.Null(validationContext.MemberName);
_log.Add("In IValidatableObject.Validate()");
yield return new ValidationResult("Sample error message", new[] { "InvalidStringProperty" });
}
@ -386,8 +498,56 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
{
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
LoggingValidatableObject lvo = (LoggingValidatableObject)value;
lvo._log.Add("In LoggingValidatonAttribute.IsValid()");
var validatableObject = Assert.IsType<LoggingValidatableObject>(value);
Assert.NotNull(validationContext);
Assert.Equal("ValidStringProperty", validationContext.MemberName);
validatableObject._log.Add("In LoggingValidatonAttribute.IsValid()");
return ValidationResult.Success;
}
}
}
private sealed class LoggingNonValidatableObject
{
private readonly IList<string> _log;
public LoggingNonValidatableObject(IList<string> log)
{
_log = log;
}
[LoggingValidation]
[Display(Order = 10001)]
public string LastProperty { get; set; }
[LoggingValidation]
public string Property3 { get; set; }
[LoggingValidation]
public string Property1 { get; set; }
[LoggingValidation]
public string Property2 { get; set; }
[LoggingValidation]
[Display(Order = 23)]
public string OrderedProperty3 { get; set; }
[LoggingValidation]
[Display(Order = 23)]
public string OrderedProperty2 { get; set; }
[LoggingValidation]
[Display(Order = 23)]
public string OrderedProperty1 { get; set; }
private sealed class LoggingValidationAttribute : ValidationAttribute
{
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
Assert.Null(value);
Assert.NotNull(validationContext);
var nonValidatableObject =
Assert.IsType<LoggingNonValidatableObject>(validationContext.ObjectInstance);
nonValidatableObject._log.Add(
string.Format("In LoggingValidatonAttribute.IsValid({0})", validationContext.MemberName));
return ValidationResult.Success;
}
}
@ -406,4 +566,3 @@ namespace Microsoft.AspNet.Mvc.ModelBinding
}
}
}
#endif

View File

@ -1,6 +1,7 @@
// 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.ComponentModel.DataAnnotations;
using System.Linq;
@ -13,6 +14,26 @@ namespace ModelBindingWebSite
{
public class VehicleController : Controller
{
private static VehicleViewModel _vehicle = new VehicleViewModel
{
InspectedDates = new[]
{
// 01/04/2001 00:00:00 -08:00
new DateTimeOffset(
year: 2001,
month: 4,
day: 1,
hour: 0,
minute: 0,
second: 0,
offset: TimeSpan.FromHours(-8)),
},
Make = "Fast Cars",
Model = "the Fastener",
Vin = "87654321",
Year = 2013,
};
[HttpPut("/api/vehicles/{id}")]
[Produces("application/json")]
public object UpdateVehicleApi(
@ -43,6 +64,73 @@ namespace ModelBindingWebSite
return PartialView("UpdateSuccessful", model);
}
[HttpGet("/vehicles/{id:int}")]
public IActionResult Details(int id)
{
if (id != 42)
{
return HttpNotFound();
}
return View(_vehicle);
}
[HttpGet("/vehicles/{id:int}/edit")]
public IActionResult Edit(int id)
{
if (id != 42)
{
return HttpNotFound();
}
// Provide room for one additional inspection if not already full.
var vehicle = _vehicle;
var length = vehicle.InspectedDates.Length;
if (length < 10)
{
var array = new DateTimeOffset[length + 1];
vehicle.InspectedDates.CopyTo(array, 0);
// Don't update the stored VehicleViewModel instance.
vehicle = new VehicleViewModel
{
InspectedDates = array,
LastUpdatedTrackingId = vehicle.LastUpdatedTrackingId,
Make = vehicle.Make,
Model = vehicle.Model,
Vin = vehicle.Vin,
Year = vehicle.Year,
};
}
return View(vehicle);
}
[HttpPost("/vehicles/{id:int}/edit")]
public IActionResult Edit(int id, VehicleViewModel vehicle)
{
if (id != 42)
{
return HttpNotFound();
}
if (!ModelState.IsValid)
{
return View(vehicle);
}
if (vehicle.InspectedDates != null)
{
// Ignore empty inspection values.
var nonEmptyDates = vehicle.InspectedDates.Where(date => date != default(DateTimeOffset)).ToArray();
vehicle.InspectedDates = nonEmptyDates;
}
_vehicle = vehicle;
return RedirectToAction(nameof(Details), new { id = id });
}
public IDictionary<string, IEnumerable<string>> SerializeModelState()
{
Response.StatusCode = (int)HttpStatusCode.BadRequest;

View File

@ -10,22 +10,28 @@ namespace ModelBindingWebSite.ViewModels
{
public class VehicleViewModel : IValidatableObject
{
// Placed using default Order (10000).
[Required]
[StringLength(8)]
public string Vin { get; set; }
[Display(Order = 1)]
public string Make { get; set; }
[Display(Order = 0)]
public string Model { get; set; }
// Placed using default Order (10000).
[Range(1980, 2034)]
[CustomValidation(typeof(VehicleViewModel), nameof(ValidateYear))]
public int Year { get; set; }
[Display(Order = 20000)]
[Required]
[MaxLength(10)]
public DateTimeOffset[] InspectedDates { get; set; }
[Display(Order = 20000)]
public string LastUpdatedTrackingId { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)

View File

@ -0,0 +1,13 @@
@model ModelBindingWebSite.ViewModels.VehicleViewModel
<h3>Vehicle details</h3>
<div class="left">
@Html.DisplayFor(m => m)
@* DisplayFor ignores properties in complex objects that don't have simple types. Add the collection explicitly. *@
<div class="display-label">
@Html.NameFor(m => m.InspectedDates)
</div>
<div class="display-field">
@Html.DisplayFor(m => m.InspectedDates)
</div>
</div>

View File

@ -0,0 +1,19 @@
@model ModelBindingWebSite.ViewModels.VehicleViewModel
<div class="left">
@using (Html.BeginForm())
{
@Html.ValidationSummary()
@Html.EditorFor(m => m)
@* EditorFor ignores properties in complex objects that don't have simple types. Add the collection explicitly. *@
<div class="editor-label">
@Html.LabelFor(m => m.InspectedDates)
</div>
<div class="editor-field">
@Html.EditorFor(m => m.InspectedDates)
</div>
<input type="submit" value="Update" />
}
</div>