开发者

mvc3 ValidationSummary exclude property errors IValidatableObject

开发者 https://www.devze.com 2023-03-14 08:01 出处:网络
My model ( class A ) has a property ( called b ) of type B with IValidatableObject implemented. View has got @Html.ValidationSummary(true)

My model ( class A ) has a property ( called b ) of type B with IValidatableObject implemented.

View has got @Html.ValidationSummary(true)

In the validation summary I want to exclude errors related to properties. In class B IValidatableObject implementation is returning ValidationResult with no memberNames

But class B valiadtion errors from IValidatableObject are not displayed since class B is 开发者_StackOverflow中文版a property on class A

How to display class B non-property validation errors?


I think this is pretty much straight forward, Let me explain with an example. First let me create the problem you are facing, then i will explain how to solve.

1) Declare My models.

public class ClassA
{
    [Required]
    public string Name { get; set; }
    public ClassB CBProp { get; set; }
}

public class ClassB:IValidatableObject
{
    [Required]
    public string MyProperty { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (!string.IsNullOrWhiteSpace(MyProperty) && MyProperty.Length > 10)
            yield return new ValidationResult("MaxLength reached");
    }
}

2) Declare simple actions.

public class HomeController : Controller
{       
    [HttpGet]
    public ActionResult Test()
    {
        ClassA ca = new ClassA();
        return View(ca);
    }

    [HttpPost]
    public ActionResult Test(ClassA ca)
    {            
        return View(ca);
    }
}

3) Let me create a simple view and an editor template for ClassB.

Test View:

@using (Html.BeginForm()) {
    @Html.ValidationSummary(true)
    <fieldset>
        <legend>ClassA</legend>

        <div class="editor-label">
            @Html.LabelFor(model => model.Name)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.Name)
            @Html.ValidationMessageFor(model => model.Name)
        </div>
            @Html.EditorFor(m => m.CBProp, "TestB")    
        <p>
            <input type="submit" value="Create" />
        </p>
    </fieldset>
}

EditorTemplate

<div class="editor-label">
        @Html.LabelFor(model => model.MyProperty)
    </div>
    <div class="editor-field">
        @Html.EditorFor(model => model.MyProperty)
        @Html.ValidationMessageFor(model => model.MyProperty)
    </div>

4) Now the view will look like,

mvc3 ValidationSummary exclude property errors IValidatableObject

5) Now if we will click on Submit. with out entering any data. It will show property validation error as expected.

mvc3 ValidationSummary exclude property errors IValidatableObject

6) Now if we will enter a string with length > 10 in MyProperty, it should show the error message "MaxLength reached", But the error will not be displayed :) :)

mvc3 ValidationSummary exclude property errors IValidatableObject

Reason for this

So, If we see the code for the view, we can find the line

 @Html.ValidationSummary(true)  /// true, will excludePropertyErrors

Since, CBProp is a property in ClassA, ValidationSummary(true) will exclude any error in CBProp. So you will not find the error message being displayed. How ever there are several options available for this.

Options

1) Set @Html.ValidationSummary()

This will display the error message, But it will also display any other error message(which is redundant),like,

mvc3 ValidationSummary exclude property errors IValidatableObject

2) Set @Html.ValidationSummary(true) in the Editor Template. But it will show the error message like,

mvc3 ValidationSummary exclude property errors IValidatableObject

3) In Validate method of ClassB, specify property name along with error message in ValidationResult.Now it will be treated as a validation error for the property and will be displayed by @Html.ValidationMessageFor(model => model.MyProperty) in Editor Template.

Code

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (!string.IsNullOrWhiteSpace(MyProperty) && MyProperty.Length > 10)
            yield return new ValidationResult("MaxLength reached", new List<string> { "MyProperty" });
    }

Error looks like

mvc3 ValidationSummary exclude property errors IValidatableObject

I think its now clear why the error message was not displayed, and what are the options available. Its up to you to decide the approach best suitable for you.

Cheers


While this behaviour of the validation summary may be inappropriate in your case, it has to be considered "correct" in general. When errors are created in sub-objects contained in the model, the right prefix is added to the errors. That is, if a sub-object is contained in a property MyProp then the prefix MyProp is automatically added to all errors. This is necessary to give the right name to all errors that are created - without this neither the Validationsummary nor the ValidationMessageFor would work properly - because they refer to the full names (the one including the whole prefixes). This is the only way to avoid ambiguities, because you might have two Name properties in two different sub-objects.

However, often, this "correct" behaviour is inappropriate when the errors generated in the sub-object are not simple properties level errors but "whole object" level errors. In such cases you might wish they appear in the general validation summary.

You can face this problems in two ways:

  1. Using another validation summary specific for the sub-object
  2. Error Bubbling - I often use errors bubbling to signal that a sub-parts of the model - not shown on the screen - contains errors, so the user can open a detail window (jQuery Dialog or similar) to see them. Basically, error bubbling consists in processing all errors in the ModelState with a foreach, and then promoting some of them up. Promoting up means deleting the last part of the error prefix. When promoting an error you may keep the original error or not - keeping the original error too is easier and in most of the cases it is the correct thing to do. Note that you can't remove an entry while looping through all entries - you have to put it in a list and then remove it after the loop is terminated.

The promoting criteria may depend on your needs. I give you some example:

  • Promoting up a property level error transforms it into an object level error.
  • Promoting a sub-object level error transforms it into an object level error of the outer object. This is the case that should interest you - just promote the object level errors associated to properties of the root ViewModel that contains whole objects instead of simple values!

The error processing can be performed within a custom ActionFilter that you may define, and re-use with several action methods.

Below is the code for a simple PromoteAttribute ActionFilter. Its use is:

[Promote("prop1.SubProp1 Prop2")]
public ActionResult MyMethod( ...

That is you pass it a list of expression errors you would like to promote to the Root model, and if it find errors matching them in ModelState it promotes them - obviously it is just a simple example - you can promote just one level up instead of the root, and you can use a complex criterion to locate the errors to promote instead of listing them:

public class PromoteAttribute : ActionFilterAttribute
{
    string[] expressions;
    public PromoteAttribute(string toPromote)
    {
        expressions = toPromote.Split(' ');
    }
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        ModelStateDictionary modelState=filterContext.Controller.ViewData.ModelState;
        foreach(var x in expressions)
        {
            if (modelState.ContainsKey(x))
            {
                var entry = modelState[x];
                if (entry.Errors.Count == 0) continue; 

                foreach (var error in entry.Errors) modelState.AddModelError("", error.ErrorMessage);

            }
        }
    }
}


Dug out MVC3's source code and edited to allow inclusion of properties.

@Html.ValidationSummary(new [] { "PropertyName" })

would include the property named PropertyName

@Html.ValidationSummary(new [] { "ArrayName[]" })

would include the properties ArrayName[0], ArrayName[1] etc.

@Html.ValidationSummary(new [] { "ArrayName[]", "PropertyName" })

would include both.

public static MvcHtmlString ValidationSummary(this HtmlHelper htmlHelper, string[] includePropertyErrors)
{
    return ValidationSummary(htmlHelper, includePropertyErrors, null, null);
}

public static MvcHtmlString ValidationSummary(this HtmlHelper htmlHelper, string[] includePropertyErrors, string message)
{
    return ValidationSummary(htmlHelper, includePropertyErrors, message, null);
}

public static MvcHtmlString ValidationSummary(this HtmlHelper htmlHelper, string[] includePropertyErrors, string message, IDictionary<string, object> htmlAttributes)
{
    if (htmlHelper == null)
    {
        throw new ArgumentNullException("htmlHelper");
    }

    FormContext formContext = htmlHelper.ViewContext.ClientValidationEnabled ? htmlHelper.ViewContext.FormContext : null;
    if (htmlHelper.ViewData.ModelState.IsValid)
    {
        if (formContext == null)
        {  // No client side validation
            return null;
        }

        // TODO: This isn't really about unobtrusive; can we fix up non-unobtrusive to get rid of this, too?
        if (htmlHelper.ViewContext.UnobtrusiveJavaScriptEnabled)
        {  // No client-side updates
            return null;
        }
    }

    string messageSpan;
    if (!string.IsNullOrEmpty(message))
    {
        TagBuilder spanTag = new TagBuilder("span");
        spanTag.SetInnerText(message);
        messageSpan = spanTag.ToString(TagRenderMode.Normal) + Environment.NewLine;
    }
    else
    {
        messageSpan = null;
    }

    StringBuilder htmlSummary = new StringBuilder();
    TagBuilder unorderedList = new TagBuilder("ul");

    IEnumerable<ModelState> modelStates = from ms in htmlHelper.ViewData.ModelState
                                            where ms.Key == htmlHelper.ViewData.TemplateInfo.HtmlFieldPrefix ||
                                                includePropertyErrors.Any(property =>
                                                {
                                                    string prefixedProperty = string.IsNullOrEmpty(htmlHelper.ViewData.TemplateInfo.HtmlFieldPrefix) ? property : htmlHelper.ViewData.TemplateInfo.HtmlFieldPrefix + "." + property;
                                                    if (property.EndsWith("[]"))
                                                    {
                                                        return prefixedProperty.Substring(0, property.Length - 2) == Regex.Replace(ms.Key, @"\[[^\]]+\]", string.Empty);
                                                    }
                                                    else
                                                    {
                                                        return property == ms.Key;
                                                    }
                                                })
                                            select ms.Value;

    if (modelStates != null)
    {
        foreach (ModelState modelState in modelStates)
        {
            foreach (ModelError modelError in modelState.Errors)
            {
                string errorText = GetUserErrorMessageOrDefault(htmlHelper.ViewContext.HttpContext, modelError);
                if (!String.IsNullOrEmpty(errorText))
                {
                    TagBuilder listItem = new TagBuilder("li");
                    listItem.SetInnerText(errorText);
                    htmlSummary.AppendLine(listItem.ToString(TagRenderMode.Normal));
                }
            }
        }
    }

    if (htmlSummary.Length == 0)
    {
        htmlSummary.AppendLine(@"<li style=""display:none""></li>");
    }

    unorderedList.InnerHtml = htmlSummary.ToString();

    TagBuilder divBuilder = new TagBuilder("div");
    divBuilder.MergeAttributes(htmlAttributes);
    divBuilder.AddCssClass((htmlHelper.ViewData.ModelState.IsValid) ? HtmlHelper.ValidationSummaryValidCssClassName : HtmlHelper.ValidationSummaryCssClassName);
    divBuilder.InnerHtml = messageSpan + unorderedList.ToString(TagRenderMode.Normal);

    if (formContext != null)
    {
        if (!htmlHelper.ViewContext.UnobtrusiveJavaScriptEnabled)
        {
            // client val summaries need an ID
            divBuilder.GenerateId("validationSummary");
            formContext.ValidationSummaryId = divBuilder.Attributes["id"];
            formContext.ReplaceValidationSummary = false;
        }
    }

    return new MvcHtmlString(divBuilder.ToString(TagRenderMode.Normal));
}

private static string GetUserErrorMessageOrDefault(HttpContextBase httpContext, ModelError error)
{
    return string.IsNullOrEmpty(error.ErrorMessage) ? null : error.ErrorMessage;
}


Lets start with what we know:

As the description suggests, if we have our models:

Model A:

public class A
{
    public B ModelB { get; set; }
}

Model B:

public class B : IValidatableObject
{
    public string Name { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        List<ValidationResult> errors = new List<ValidationResult>();

        if (string.IsNullOrEmpty(Name)) {
            errors.Add(new ValidationResult("Please enter your name"));
        }

        return errors;
    }
}

And our view:

@model A

@Html.ValidationSummary(true)

@using (Html.BeginForm())
{
    @Html.EditorFor(model => model.ModelB.Name)

    <input type="submit" value="submit" />
}

Then the editor will output the line:

<input class="text-box single-line" id="ModelB_Name" name="ModelB.Name" type="text" value="" />

If we have our post action defined as:

[HttpPost]
public ActionResult Index(A model)
{
    if (ModelState.IsValid)
    {
        return RedirectToAction("NextAction");
    }
    return View();
}

Then when binding to the A model, the DefaultModelBinder will look for the property named ModelB.Name which it will find and bind to successfully.

However, the model validation performed by the DefaultModelBinder against the A model, will call the validation defined for Model B. This validation will return an error message which is not defined against a property but because this validation is part of a complex model it is added to the ModelState with the key of "ModelB".

When the ValidationSummary is called it looks for keys which are blank (i.e. defined for the model and not the property). As no blank keys exist, no error is displayed.

As a simple workaround, you could define an EditorTemplate for the B model.

In the folder containing your view, define a folder named EditorTemplates and in this create a view with the same name as the model (e.g. B.cshtml in my case). The contents of the Editor Template would be defined as:

@model MvcApplication14.Models.B

@Html.EditorFor(m => m.Name)

Then, modify the main view so that the EditorFor looks like:

@Html.EditorFor(model => model.ModelB, null, "")

The "" specifies that any fields output by our editor template will not have a name prefixed to the field. Therefore, the output will now be:

<input class="text-box single-line" id="Name" name="Name" type="text" value="" /> 

However, this will now prevent the binding of ModelB so this would need to be bound seperately in the post action and added to our A model:

[HttpPost]
public ActionResult Index(A modelA, B modelB)
{
    modelA.ModelB = modelB;
    if (ModelState.IsValid)
    {
        return RedirectToAction("NextAction");
    }
    return View();
}

Now, when the modelB is bound, the validation message will be written to the ModelState with the key "". Therefore, this can now be displayed with the @ValidationMessage() routine.

Warning: The above workaround assumes that modelB does not have the same field names as modelA. If both modelB and modelA had a Name field for example then the DefaultModelBinder may not bind the fields to their correct equivelents. For example, if the A model also has a field named Name then it would have to be written to the view as:

@Html.EditorFor(model => model.Name, null, "modelA.Name")

to ensure that it is bound correctly.

Hopefully, this should allow you to achieve the desired result using the methods already defined within the MVC3 framework.


For those who find this question, take a look at ViewData.TemplateInfo.HtmlFieldPrefix (as mentioned deep in other answers)...

If explicitly adding a summary level validation error you might usually do this (to the root object) ...

ModelState.AddModelError("", "This is a summary level error text for the model");

And when adding a summary-like validation error for a property of the model which is in itself an object you can do the following:

ModelState.AddModelError("b", "This is a 'summary' error for the property named b");

Where b is the name of the property which is in itself a property.

To explain, when adding a summary level validation error directly you can simply specify the HTML Prefix for the object property.

This can be obtained using ViewData.TemplateInfo.HtmlFieldPrefix.


ModelState modelState = default(ModelState); Model.TryGetValue(this.ViewData.TemplateInfo.HtmlFieldPrefix, out modelState);

var isExcludePropertyErrors = modelState != null;

0

精彩评论

暂无评论...
验证码 换一张
取 消