MVC ValidationSummary ignores model level validation errors when bound using TryUpdateModel
Asked Answered
B

1

6

This is a very similar problem to one already posted here: ASP.NET MVC: Validation messages set in TryUpdateModel not showning ValidationSummary

I'm not sure whether that old topic was in reference to an earlier version of MVC, but in MVC3 i'm experiencing some odd behaviour along similar lines.

I have model class called Trade. This inherits from IValidatableObject, so therefore implements a Validate method. Within this we have some validation of the model as a whole (as opposed to data annotations that enforce validation of properties). The validation is as follows:

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

     if (this.EndDate < this.StartDate)
     {
        validationResults.Add(new ValidationResult("End date must be greater than start date"));
     }

     return validationResults;
  }

We have a view model to help with the display of a trade. This contains a reference to a trade model via a TradeModel property. So basically the view model is a Trade model, plus some extra info for the population of drop-down lists such as Counterparties, etc.

Our CSHTML class contains a ValidationSummary, with "true" as the argument, meaning that it will only show model errors.

If i implement my HttpPost controller method for creating a new Trade as follows...

  [HttpPost]
  public ActionResult Create(FormCollection collection)
  {
     var trade = new Trade();

     if (this.TryUpdateModel(trade))
     {
        if (this.SaveChanges(this.ModelState, trade))
        {
           return this.RedirectToAction("Index");
        }
     }

     return this.View(trade);
  }

...when i enter a trade with StartDate > EndDate, I am finding that TryUpdateModel returns false and the user is directed back to their trade. This seems logical. Unfortunately ValidationSummary does not show any error messages.

If i put a breakpoint in the Create method and investigate the ModelState, i can see that there is an error message in the dictionary. It is against a Key of "TradeModel", rather than against any of the properties. Again, this seems logical.

One theory as to why this is, is that ValidationSummary presumes that any validation errors against a key that is not String.Empty must be property validation errors, it's ignoring our validation errors because we have a view model that contains a reference to a model, therefore resulting in the Key being "TradeModel".

What blows this theory out of the water is this: if i rewrite the controller's Create function as follows...

  [HttpPost]
  public ActionResult Create(Trade trade, FormCollection collection)
  {
     if (this.SaveChanges(this.ModelState, trade))
     {
        return this.RedirectToAction("Index");
     }

     return this.View(trade);
  }

...and therefore rely on MVC performing the binding "automatically", and re-run the same test scenario, the user is presented with the intended error message!

If i add a breakpoint and look at ModelState, i'm seeing identical error messages against the same keys as before, but this time ValidationSummary picks them up!

If i amend the validation as follows then it works with the controller's Create function written in either way:

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

     if (this.EndDate < this.StartDate)
     {
        validationResults.Add(new ValidationResult("End date must be greater than start date", new[] { "StartDate" }));
     }

     return validationResults;
  }

So clearly it's only an issue with model level validation errors.

Any help with this would be greatly appreciated! There are reasons (which i won't go into now) why we need to create the instance of our view model manually and call the binding using TryUpdateModel.

Thanks in advance!

UPDATE

It appears that the theory of ValidationSummary only displaying errors with a key in the ModelState of String.Empty when told to exclude property errors is actually true. I've looked at the source code and it actually uses ViewData.TemplateInfo.HtmlFieldPrefix to find the validation errors at the model-level. By default this is String.Empty.

Changing this value to "TradeModel" therefore seems logical, but it causes every HTML id or name to be prefixed, so the binding then fails!

If the view model contains a reference to a business model, any errors added to the ModelState by IValidatableObject are added with a key including a prefix equal to the business model property name in the view model (in our case "TradeModel"), resulting in keys such as "TradeModel.CounterpartyId", etc. Model-level errors are added with a key equal to the business model property name of the view model ("TradeModel").

So it appears that the business cannot add model-level validation errors if the view model is constructed in this way.

What's puzzling me is why this does actually work in our "real" project when the controller's Create function is written so that it takes a Trade view model object as an argument. I must have missed this yesterday, but looking at it today, when MVC binds and the validation is triggered, it appears to add an extra key at the end of the dictionary with a value of String.Empty. This contains a duplicate of the errors added with a key of TradeModel. As you'd expect, ValidationSummary then picks them up!

So why does MVC do this in our live project, but not in the simple test app?

I've seen validation triggered twice when the controller function is written to take the view model as an argument. Maybe this is doing something subtly different each time?

UPDATE...AGAIN

The reason it works in our real project is there is some code buried in our base controller (from which all others inherit) that copies all errors found in the model state to a new entry with a key of String.Empty. This code was only being called in one out of the two scenarios. So there is no actual difference between the real and test apps.

I now understand what's going in and why ValidationSummary is behaving like this.

Br answered 4/7, 2012 at 14:45 Comment(3)
I've tried to repro your issue without success. For me in a new MVC3 project your second action public ActionResult Create(Trade trade, FormCollection collection) also not displaying the validation message as expected. Can you create a repro yourself in an empty project to verify the behavior, and maybe upload somewhere?Customs
Interesting. Thanks for trying to reproduce it. I will create a simple example in a new project as you suggest.Br
I have created a simple project and, as you said, it doesn't work with either version of the controller's Create function.Br
B
5

Ok. I'm now at a point where i can answer this myself.

If ValidationSummary is used with the ExcludePropertyErrors argument set to True, it will look for errors in the model state using a key equal to ViewData.TemplateInfo.HtmlFieldPrefix. By default this is String.Empty.

If you have a view model which exposes your business model, something like this:

namespace ValidationSummary.Models
{
   using System;
   using System.Collections.Generic;
   using System.ComponentModel.DataAnnotations;

   public class TradeModel : IValidatableObject
   {
      public DateTime StartDate { get; set; }

      public DateTime EndDate { get; set; }

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

         if (EndDate < StartDate)
         {
            validationResults.Add(new ValidationResult("End date must not be before start date"));
         }

         return validationResults;
      }
   }
}

namespace ValidationSummary.ViewModels
{
   public class Trade
   {
      public Trade()
      {
         this.TradeModel = new Models.TradeModel();
      }

      public Models.TradeModel TradeModel { get; private set; }
   }
}

When the validation takes place, errors (ValidationResults) that are added at the model-level (where there is no further argument to the ValidationResult constructor that takes the property name(s), are added to the ModelState with a prefix of the property name of the view model - in this example "TradeModel".

There are a few ways around this that we're currently considering.

  1. Do not expose the business model outside the view model. This essentially involves binding to the view model, rather than to the business model of the view model. This could be done two ways: make the view model a subclass of the business model; or duplicate properties from business model to the view model. I prefer the former.
  2. Write code to copy model-level errors into a new ModelState dictionary key of String.Empty. Unfortunately this might not be possible to do elegantly since the property name of the view model that exposes the business model is what's used as the key. This may be different per controller/view-model.
  3. Use the following in the view. This will display error messages that are for the business model. Essentially this pretending that model-level errors for the business model are in fact property errors. The display of these is not the same as for ValidationSummary, but maybe this could be cured with CSS:

    @Html.ValidationMessageFor(m => m.TradeModel)

  4. Subclass ValidationSummary. This would involve changing it so that it knows which keys in ModelState refer to business model properties of the view model.

Br answered 5/7, 2012 at 13:14 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.