Search code examples
asp.net-mvc-3data-annotations

ASP.NET MVC3 Custom Validation Message Behaviour


Coming from the asp.net webforms model I'm used to using validators which display an error in the form <span title="Username is Required">*</span>.

I'm quite clear how the MVC3 validators work out of the box, so please no more answers explaining how validators work in MVC3 as I'm pretty sure I have that nailed. What I am trying to accomplish is have the validation error message showing as the title of the span tag as shown in the first paragraph.

I have managed to replicate this in MVC3 but am not sure if the way I have done it follows best practise. I would appreciate any input as to whether there is a better way to accomplish the same thing. It would be really great if it could be done without modifying jquery.validate.unobtrusive.js.

So what I have done is:

  1. Set the validation message to "*"
  2. Hidden the validation message while its valid
  3. Added a new attribute to determine whether to add the message as the title
  4. Added 2 lines of flagged code to onError to check whether to display the error message in the title, and if so to do so.
    [.cshtml]    @Html.ValidationMessageFor(m => m.Email, "*", new { data_val_usetitle = "true" })

    [.css]    .field-validation-valid {display:none;}

    .js]        function onError(error, inputElement) {  // 'this' is the form element
                var container = $(this).find("[data-valmsg-for='" + inputElement[0].name + "']"),
                    replace = $.parseJSON(container.attr("data-valmsg-replace")) !== false,
                    useTitle = $.parseJSON(container.attr("data-val-usetitle")) !== false; /* New Line */

                container.removeClass("field-validation-valid").addClass("field-validation-error");
                error.data("unobtrusiveContainer", container);

                if (replace) {
                    container.empty();
                    error.removeClass("input-validation-error").appendTo(container);
                }
                else {
                    if (useTitle) container.attr("title", error.text()); /* New Line */
                    error.hide();
                }
            }

Solution

  • I think what you've done is the cleanest way. There is no way around modifying jquery.validate.unobtrusive.js because MVC extensions don't follow the old school asp.net methodology of emitting javascript on the fly.

    I just finished creating my own custom validation extension calld ValidationIconFor() so that a single image is displayed with its title set to the error message and I've used a modified version of your code above.

    jquery.validate.unobtrusive.js:

    function onError(error, inputElement) {  // 'this' is the form element
        var container = $(this).find("[data-valmsg-for='" + inputElement[0].name + "']"),
            replace = $.parseJSON(container.attr("data-valmsg-replace")) !== false,
            useTitle = $.parseJSON(container.attr("data-val-usetitle")) !== false;
    
        container.removeClass("field-validation-valid").addClass("field-validation-error");
        error.data("unobtrusiveContainer", container);
    
        if (replace) {
            container.empty();
            if (useTitle)
                container.attr("title", error.text());
            else
                error.removeClass("input-validation-error").appendTo(container);
        }
        else {
            if (useTitle)
                container.attr("title", error.text());
            error.hide();
        }
    }
    

    ValidationExtensions.cs:

    public static class ValidationExtensions
    {
        private static string _resourceClassKey;
    
        public static string ResourceClassKey
        {
            get
            {
                return _resourceClassKey ?? String.Empty;
            }
            set
            {
                _resourceClassKey = value;
            }
        }
    
        private static FieldValidationMetadata ApplyFieldValidationMetadata(HtmlHelper htmlHelper, ModelMetadata modelMetadata, string modelName)
        {
            FormContext formContext = htmlHelper.ViewContext.FormContext;
            FieldValidationMetadata fieldMetadata = formContext.GetValidationMetadataForField(modelName, true /* createIfNotFound */);
    
            // write rules to context object
            IEnumerable<ModelValidator> validators = ModelValidatorProviders.Providers.GetValidators(modelMetadata, htmlHelper.ViewContext);
            foreach (ModelClientValidationRule rule in validators.SelectMany(v => v.GetClientValidationRules()))
            {
                fieldMetadata.ValidationRules.Add(rule);
            }
    
            return fieldMetadata;
        }
    
        private static string GetInvalidPropertyValueResource(HttpContextBase httpContext)
        {
            string resourceValue = null;
            if (!String.IsNullOrEmpty(ResourceClassKey) && (httpContext != null))
            {
                // If the user specified a ResourceClassKey try to load the resource they specified.
                // If the class key is invalid, an exception will be thrown.
                // If the class key is valid but the resource is not found, it returns null, in which
                // case it will fall back to the MVC default error message.
                resourceValue = httpContext.GetGlobalResourceObject(ResourceClassKey, "InvalidPropertyValue", CultureInfo.CurrentUICulture) as string;
            }
            return resourceValue ?? "The value '{0}' is invalid.";
        }
    
        private static string GetUserErrorMessageOrDefault(HttpContextBase httpContext, ModelError error, ModelState modelState)
        {
            if (!String.IsNullOrEmpty(error.ErrorMessage))
            {
                return error.ErrorMessage;
            }
            if (modelState == null)
            {
                return null;
            }
    
            string attemptedValue = (modelState.Value != null) ? modelState.Value.AttemptedValue : null;
            return String.Format(CultureInfo.CurrentCulture, GetInvalidPropertyValueResource(httpContext), attemptedValue);
        }
    
        [SuppressMessage("Microsoft.Design", "CA1006:DoNotNestGenericTypesInMemberSignatures", Justification = "This is an appropriate nesting of generic types")]
        public static MvcHtmlString ValidationIconFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression)
        {
            return ValidationIconFor(htmlHelper, expression, null /* validationMessage */, new RouteValueDictionary());
        }
    
        [SuppressMessage("Microsoft.Design", "CA1006:DoNotNestGenericTypesInMemberSignatures", Justification = "This is an appropriate nesting of generic types")]
        public static MvcHtmlString ValidationIconFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, string validationMessage)
        {
            return ValidationIconFor(htmlHelper, expression, validationMessage, new RouteValueDictionary());
        }
    
        [SuppressMessage("Microsoft.Design", "CA1006:DoNotNestGenericTypesInMemberSignatures", Justification = "This is an appropriate nesting of generic types")]
        public static MvcHtmlString ValidationIconFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, string validationMessage, object htmlAttributes)
        {
            return ValidationIconFor(htmlHelper, expression, validationMessage, HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
        }
    
        [SuppressMessage("Microsoft.Design", "CA1006:DoNotNestGenericTypesInMemberSignatures", Justification = "This is an appropriate nesting of generic types")]
        public static MvcHtmlString ValidationIconFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, string validationMessage, IDictionary<string, object> htmlAttributes)
        {
            return ValidationMessageHelper(htmlHelper,
                                           ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData),
                                           ExpressionHelper.GetExpressionText(expression),
                                           validationMessage,
                                           htmlAttributes);
        }
    
        [SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Justification = "Normalization to lowercase is a common requirement for JavaScript and HTML values")]
        private static MvcHtmlString ValidationMessageHelper(this HtmlHelper htmlHelper, ModelMetadata modelMetadata, string expression, string validationMessage, IDictionary<string, object> htmlAttributes)
        {
            string modelName = htmlHelper.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(expression);
            FormContext formContext = htmlHelper.ViewContext.FormContext;
    
            if (!htmlHelper.ViewData.ModelState.ContainsKey(modelName) && formContext == null)
            {
                return null;
            }
    
            ModelState modelState = htmlHelper.ViewData.ModelState[modelName];
            ModelErrorCollection modelErrors = (modelState == null) ? null : modelState.Errors;
            ModelError modelError = (((modelErrors == null) || (modelErrors.Count == 0)) ? null : modelErrors.FirstOrDefault(m => !String.IsNullOrEmpty(m.ErrorMessage)) ?? modelErrors[0]);
    
            if (modelError == null && formContext == null)
            {
                return null;
            }
    
            TagBuilder builder = new TagBuilder("img");
            builder.MergeAttributes(htmlAttributes);
            builder.AddCssClass((modelError != null) ? HtmlHelper.ValidationMessageCssClassName : HtmlHelper.ValidationMessageValidCssClassName);
    
            if (!String.IsNullOrEmpty(validationMessage))
            {
                builder.Attributes.Add("title", validationMessage);
            }
            else if (modelError != null)
            {
                builder.Attributes.Add("title", GetUserErrorMessageOrDefault(htmlHelper.ViewContext.HttpContext, modelError, modelState));
            }
    
            if (formContext != null)
            {
                bool replaceValidationMessageContents = String.IsNullOrEmpty(validationMessage);
    
                if (htmlHelper.ViewContext.UnobtrusiveJavaScriptEnabled)
                {
                    builder.MergeAttribute("data-valmsg-for", modelName);
                    builder.MergeAttribute("data-valmsg-replace", replaceValidationMessageContents.ToString().ToLowerInvariant());
                    builder.MergeAttribute("data-val-usetitle", "true");
                }
                else
                {
                    FieldValidationMetadata fieldMetadata = ApplyFieldValidationMetadata(htmlHelper, modelMetadata, modelName);
                    // rules will already have been written to the metadata object
                    fieldMetadata.ReplaceValidationMessageContents = replaceValidationMessageContents; // only replace contents if no explicit message was specified
    
                    // client validation always requires an ID
                    builder.GenerateId(modelName + "_validationMessage");
                    fieldMetadata.ValidationMessageId = builder.Attributes["id"];
                }
            }
    
            return builder.ToMvcHtmlString(TagRenderMode.Normal);
        }
    }
    
    internal static class TagBuilderExtensions
    {
        internal static MvcHtmlString ToMvcHtmlString(this TagBuilder tagBuilder, TagRenderMode renderMode)
        {
            return new MvcHtmlString(tagBuilder.ToString(renderMode));
        }
    }