From dad87c52396726b22288f8bc4b6cf81bbf5071ce Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Mon, 31 Mar 2014 15:01:50 -0700 Subject: [PATCH] Add ValidationSummary helper. This enables Html.ValidationSummary. This is in reference to WEBFX-97. --- samples/MvcSample.Web/HomeController.cs | 7 ++ .../Views/Home/ValidationSummary.cshtml | 23 +++++ .../Metadata/ModelMetadata.cs | 9 ++ .../Html/HtmlHelper.cs | 94 +++++++++++++++++++ .../Html/HtmlString.cs | 10 ++ .../Html/Validation/ValidationExtensions.cs | 42 +++++++++ .../Html/Validation/ValidationHelpers.cs | 65 +++++++++++++ .../IHtmlHelperOfT.cs | 10 ++ .../Properties/Resources.Designer.cs | 16 ++++ .../Properties/Resources.resx | 3 + .../View/ViewContext.cs | 11 ++- 11 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 samples/MvcSample.Web/Views/Home/ValidationSummary.cshtml create mode 100644 src/Microsoft.AspNet.Mvc.Rendering/Html/Validation/ValidationExtensions.cs create mode 100644 src/Microsoft.AspNet.Mvc.Rendering/Html/Validation/ValidationHelpers.cs diff --git a/samples/MvcSample.Web/HomeController.cs b/samples/MvcSample.Web/HomeController.cs index 8b33fe2aee..ef1d3bcaec 100644 --- a/samples/MvcSample.Web/HomeController.cs +++ b/samples/MvcSample.Web/HomeController.cs @@ -10,6 +10,13 @@ namespace MvcSample.Web return View("MyView", User()); } + public IActionResult ValidationSummary() + { + ModelState.AddModelError("something", "Something happened, show up in validation summary."); + + return View("ValidationSummary"); + } + /// /// Action that shows metadata when model is null. /// diff --git a/samples/MvcSample.Web/Views/Home/ValidationSummary.cshtml b/samples/MvcSample.Web/Views/Home/ValidationSummary.cshtml new file mode 100644 index 0000000000..9188ca196c --- /dev/null +++ b/samples/MvcSample.Web/Views/Home/ValidationSummary.cshtml @@ -0,0 +1,23 @@ + + +

ValidationSummary Test Page.

+

Below are all overloads for Html.ValidationSummary. You should see 5 validation summary titles and 4 validation summary error messages.

+
+ +
+ @Html.ValidationSummary() + @Html.ValidationSummary(excludePropertyErrors: true) + @Html.ValidationSummary(message: "Hello from validation message summary 1.") + @Html.ValidationSummary(excludePropertyErrors: true, message: "Hello from validation message summary 2") + @Html.ValidationSummary(message: "Hello from validation message summary 3", htmlAttributes: new { style = "color: red" }) + @Html.ValidationSummary(excludePropertyErrors: true, message: "Hello from validation message summary 4", htmlAttributes: new { style = "color: green" }) + @Html.ValidationSummary(message: "Hello from validation message summary 5", htmlAttributes: new Dictionary { { "style", "color: blue" } }) +
\ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs index 97b8f7f4cc..b77ed3699d 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Metadata/ModelMetadata.cs @@ -7,6 +7,8 @@ namespace Microsoft.AspNet.Mvc.ModelBinding { public class ModelMetadata { + public static readonly int DefaultOrder = 10000; + private readonly Type _containerType; private readonly Type _modelType; private readonly string _propertyName; @@ -15,6 +17,7 @@ namespace Microsoft.AspNet.Mvc.ModelBinding private bool _convertEmptyStringToNull = true; private object _model; private Func _modelAccessor; + private int _order = DefaultOrder; private IEnumerable _properties; private Type _realModelType; @@ -57,6 +60,12 @@ namespace Microsoft.AspNet.Mvc.ModelBinding public virtual bool IsReadOnly { get; set; } + public virtual int Order + { + get { return _order; } + set { _order = value; } + } + public object Model { get diff --git a/src/Microsoft.AspNet.Mvc.Rendering/Html/HtmlHelper.cs b/src/Microsoft.AspNet.Mvc.Rendering/Html/HtmlHelper.cs index 4cb44bf881..5101aace55 100644 --- a/src/Microsoft.AspNet.Mvc.Rendering/Html/HtmlHelper.cs +++ b/src/Microsoft.AspNet.Mvc.Rendering/Html/HtmlHelper.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.IO; using System.Net; using System.Reflection; +using System.Text; using System.Threading.Tasks; using Microsoft.AspNet.Abstractions; @@ -22,6 +23,8 @@ namespace Microsoft.AspNet.Mvc.Rendering public static readonly string ValidationSummaryCssClassName = "validation-summary-errors"; public static readonly string ValidationSummaryValidCssClassName = "validation-summary-valid"; + private const string HiddenListItem = @"
  • "; + private ViewContext _viewContext; private IViewEngine _viewEngine; @@ -175,6 +178,97 @@ namespace Microsoft.AspNet.Mvc.Rendering await viewEngineResult.View.RenderAsync(newViewContext); } + public virtual HtmlString ValidationSummary(bool excludePropertyErrors, string message, IDictionary htmlAttributes) + { + var formContext = ViewContext.ClientValidationEnabled ? ViewContext.FormContext : null; + + if (ViewData.ModelState.IsValid == true) + { + if (formContext == null || + ViewContext.UnobtrusiveJavaScriptEnabled && + excludePropertyErrors) + { + // No client side validation/updates + return HtmlString.Empty; + } + } + + string messageSpan; + if (!string.IsNullOrEmpty(message)) + { + var spanTag = new TagBuilder("span"); + spanTag.SetInnerText(message); + messageSpan = spanTag.ToString(TagRenderMode.Normal) + Environment.NewLine; + } + else + { + messageSpan = null; + } + + var htmlSummary = new StringBuilder(); + var modelStates = ValidationHelpers.GetModelStateList(ViewData, excludePropertyErrors); + + foreach (var modelState in modelStates) + { + foreach (var modelError in modelState.Errors) + { + string errorText = ValidationHelpers.GetUserErrorMessageOrDefault(modelError, modelState: null); + + if (!string.IsNullOrEmpty(errorText)) + { + var listItem = new TagBuilder("li"); + listItem.SetInnerText(errorText); + htmlSummary.AppendLine(listItem.ToString(TagRenderMode.Normal)); + } + } + } + + if (htmlSummary.Length == 0) + { + htmlSummary.AppendLine(HiddenListItem); + } + + var unorderedList = new TagBuilder("ul") + { + InnerHtml = htmlSummary.ToString() + }; + + var divBuilder = new TagBuilder("div"); + divBuilder.MergeAttributes(htmlAttributes); + + if (ViewData.ModelState.IsValid == true) + { + divBuilder.AddCssClass(HtmlHelper.ValidationSummaryValidCssClassName); + } + else + { + divBuilder.AddCssClass(HtmlHelper.ValidationSummaryCssClassName); + } + + divBuilder.InnerHtml = messageSpan + unorderedList.ToString(TagRenderMode.Normal); + + if (formContext != null) + { + if (ViewContext.UnobtrusiveJavaScriptEnabled) + { + if (!excludePropertyErrors) + { + // Only put errors in the validation summary if they're supposed to be included there + divBuilder.MergeAttribute("data-valmsg-summary", "true"); + } + } + else + { + // client validation summaries need an ID + divBuilder.GenerateId("validationSummary", IdAttributeDotReplacement); + formContext.ValidationSummaryId = divBuilder.Attributes["id"]; + formContext.ReplaceValidationSummary = !excludePropertyErrors; + } + } + + return divBuilder.ToHtmlString(TagRenderMode.Normal); + } + /// /// Returns the HTTP method that handles form input (GET or POST) as a string. /// diff --git a/src/Microsoft.AspNet.Mvc.Rendering/Html/HtmlString.cs b/src/Microsoft.AspNet.Mvc.Rendering/Html/HtmlString.cs index fe7291dcc4..2e762c3291 100644 --- a/src/Microsoft.AspNet.Mvc.Rendering/Html/HtmlString.cs +++ b/src/Microsoft.AspNet.Mvc.Rendering/Html/HtmlString.cs @@ -2,6 +2,8 @@ { public class HtmlString { + private static readonly HtmlString _empty = new HtmlString(string.Empty); + private readonly string _input; public HtmlString(string input) @@ -9,6 +11,14 @@ _input = input; } + public static HtmlString Empty + { + get + { + return _empty; + } + } + public override string ToString() { return _input; diff --git a/src/Microsoft.AspNet.Mvc.Rendering/Html/Validation/ValidationExtensions.cs b/src/Microsoft.AspNet.Mvc.Rendering/Html/Validation/ValidationExtensions.cs new file mode 100644 index 0000000000..033e777d0b --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Rendering/Html/Validation/ValidationExtensions.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.Rendering +{ + public static class ValidationExtensions + { + public static HtmlString ValidationSummary(this IHtmlHelper htmlHelper) + { + return ValidationSummary(htmlHelper, excludePropertyErrors: false); + } + + public static HtmlString ValidationSummary(this IHtmlHelper htmlHelper, bool excludePropertyErrors) + { + return ValidationSummary(htmlHelper, excludePropertyErrors, message: null); + } + + public static HtmlString ValidationSummary(this IHtmlHelper htmlHelper, string message) + { + return ValidationSummary(htmlHelper, excludePropertyErrors: false, message: message, htmlAttributes: (object)null); + } + + public static HtmlString ValidationSummary(this IHtmlHelper htmlHelper, bool excludePropertyErrors, string message) + { + return ValidationSummary(htmlHelper, excludePropertyErrors, message, htmlAttributes: (object)null); + } + + public static HtmlString ValidationSummary(this IHtmlHelper htmlHelper, string message, object htmlAttributes) + { + return ValidationSummary(htmlHelper, excludePropertyErrors: false, message: message, htmlAttributes: HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes)); + } + + public static HtmlString ValidationSummary(this IHtmlHelper htmlHelper, bool excludePropertyErrors, string message, object htmlAttributes) + { + return htmlHelper.ValidationSummary(excludePropertyErrors, message, HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes)); + } + + public static HtmlString ValidationSummary(this IHtmlHelper htmlHelper, string message, IDictionary htmlAttributes) + { + return htmlHelper.ValidationSummary(excludePropertyErrors: false, message: message, htmlAttributes: htmlAttributes); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.Rendering/Html/Validation/ValidationHelpers.cs b/src/Microsoft.AspNet.Mvc.Rendering/Html/Validation/ValidationHelpers.cs new file mode 100644 index 0000000000..24ac29d206 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Rendering/Html/Validation/ValidationHelpers.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.Mvc.ModelBinding; + +namespace Microsoft.AspNet.Mvc.Rendering +{ + internal static class ValidationHelpers + { + public static string GetUserErrorMessageOrDefault(ModelError modelError, ModelState modelState) + { + if (!string.IsNullOrEmpty(modelError.ErrorMessage)) + { + return modelError.ErrorMessage; + } + + if (modelState == null) + { + return string.Empty; + } + + var attemptedValue = (modelState.Value != null) ? modelState.Value.AttemptedValue : "null"; + + return Resources.FormatCommon_ValueNotValidForProperty(attemptedValue); + } + + // Returns non-null list of model states, which caller will render in order provided. + public static IEnumerable GetModelStateList(ViewDataDictionary viewData, bool excludePropertyErrors) + { + if (excludePropertyErrors) + { + ModelState ms; + viewData.ModelState.TryGetValue(viewData.TemplateInfo.HtmlFieldPrefix, out ms); + + if (ms != null) + { + return new[] { ms }; + } + + return Enumerable.Empty(); + } + else + { + // Sort modelStates to respect the ordering in the metadata. + // ModelState doesn't refer to ModelMetadata, but we can correlate via the property name. + var ordering = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var metadata = viewData.ModelMetadata; + if (metadata != null) + { + foreach (var data in metadata.Properties) + { + ordering[data.PropertyName] = data.Order; + } + + return viewData.ModelState + .OrderBy(data => ordering[data.Key]) + .Select(ms => ms.Value); + } + + return viewData.ModelState.Values; + } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.Rendering/IHtmlHelperOfT.cs b/src/Microsoft.AspNet.Mvc.Rendering/IHtmlHelperOfT.cs index 00fac6ff97..dd91b32d93 100644 --- a/src/Microsoft.AspNet.Mvc.Rendering/IHtmlHelperOfT.cs +++ b/src/Microsoft.AspNet.Mvc.Rendering/IHtmlHelperOfT.cs @@ -1,5 +1,6 @@ using System; using System.Linq.Expressions; +using System.Collections.Generic; using System.Threading.Tasks; namespace Microsoft.AspNet.Mvc.Rendering @@ -99,5 +100,14 @@ namespace Microsoft.AspNet.Mvc.Rendering /// A to pass into the partial view. /// A task that represents when rendering has completed. Task RenderPartialAsync([NotNull] string partialViewName, object model, ViewDataDictionary viewData); + + /// + /// Returns an unordered list (ul element) of validation messages that are in the object. + /// + /// true to have the summary display model-level errors only, or false to have the summary display all errors. + /// The message to display with the validation summary. + /// A dictionary that contains the HTML attributes for the element. + /// An that contains an unordered list (ul element) of validation messages. + HtmlString ValidationSummary(bool excludePropertyErrors, string message, IDictionary htmlAttributes); } } diff --git a/src/Microsoft.AspNet.Mvc.Rendering/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.Rendering/Properties/Resources.Designer.cs index b64358607e..a35bea7e88 100644 --- a/src/Microsoft.AspNet.Mvc.Rendering/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.Rendering/Properties/Resources.Designer.cs @@ -74,6 +74,22 @@ namespace Microsoft.AspNet.Mvc.Rendering return string.Format(CultureInfo.CurrentCulture, GetString("Common_PartialViewNotFound"), p0, p1); } + /// + /// The value '{0}' is invalid. + /// + internal static string Common_ValueNotValidForProperty + { + get { return GetString("Common_ValueNotValidForProperty"); } + } + + /// + /// The value '{0}' is invalid. + /// + internal static string FormatCommon_ValueNotValidForProperty(object p0) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Common_ValueNotValidForProperty"), p0); + } + /// /// ViewData value must not be null. /// diff --git a/src/Microsoft.AspNet.Mvc.Rendering/Properties/Resources.resx b/src/Microsoft.AspNet.Mvc.Rendering/Properties/Resources.resx index 107f5f213b..bb733023d8 100644 --- a/src/Microsoft.AspNet.Mvc.Rendering/Properties/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.Rendering/Properties/Resources.resx @@ -129,6 +129,9 @@ The partial view '{0}' was not found or no view engine supports the searched locations. The following locations were searched:{1} + + The value '{0}' is invalid. + ViewData value must not be null. diff --git a/src/Microsoft.AspNet.Mvc.Rendering/View/ViewContext.cs b/src/Microsoft.AspNet.Mvc.Rendering/View/ViewContext.cs index e729ede949..6b24a67a04 100644 --- a/src/Microsoft.AspNet.Mvc.Rendering/View/ViewContext.cs +++ b/src/Microsoft.AspNet.Mvc.Rendering/View/ViewContext.cs @@ -16,7 +16,10 @@ namespace Microsoft.AspNet.Mvc.Rendering public ViewContext([NotNull] ViewContext viewContext) : this(viewContext.ServiceProvider, viewContext.HttpContext, viewContext.ViewEngineContext) - { } + { + UnobtrusiveJavaScriptEnabled = viewContext.UnobtrusiveJavaScriptEnabled; + ClientValidationEnabled = viewContext.ClientValidationEnabled; + } public ViewContext(IServiceProvider serviceProvider, HttpContext httpContext, IDictionary viewEngineContext) @@ -25,6 +28,8 @@ namespace Microsoft.AspNet.Mvc.Rendering HttpContext = httpContext; ViewEngineContext = viewEngineContext; _formContext = _defaultFormContext; + UnobtrusiveJavaScriptEnabled = true; + ClientValidationEnabled = true; } public IViewComponentHelper Component { get; set; } @@ -48,6 +53,10 @@ namespace Microsoft.AspNet.Mvc.Rendering public IUrlHelper Url { get; set; } + public bool UnobtrusiveJavaScriptEnabled { get; set; } + + public bool ClientValidationEnabled { get; set; } + public dynamic ViewBag { get