1

I have an MVC3 page with an object (Header) that has data and a list of objects (Details) that I want to update on a single page. On the details object I have custom validation (IValidatableObject) that also needs to run.

This appears to generally be working as expected, validations are running and returning ValidationResults and if I put an @Html.ValidationSummary(false); on the page it displays those validations. However I don't want a list of validations at the top, but rather next to the item being validated i.e. Html.ValidationMessageFor which is on the page, but not displaying the relevant message. Is there something I'm missing? This is working on other pages (that don't have this Master-Details situation), so i'm thinking it is something about how I'm going about setting up the list of items to be updated or the editor template for the item?

Edit.cshtml (the Header-Details edit view)

@foreach (var d in Model.Details.OrderBy(d => d.DetailId))
{
   @Html.EditorFor(item => d, "Detail")
}

Detail.ascx (the Details Editor Template)

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<Detail>" %>

<tr>            
    <td>
        <%= Model.Name %>
        <%= Html.HiddenFor(model => model.DetailId) %>
    </td>
    <td class="colDescription">
        <%= Html.EditorFor(model => model.Description) %>
        <%= Html.ValidationMessageFor(model => model.Description) %>
    </td>
    <td class="colAmount">
        <%= Html.EditorFor(model => model.Amount) %>
        <%= Html.ValidationMessageFor(model => model.Amount) %>
    </td>
</tr>

Model is Entity Framework with Header that has Name and HeaderId and Detail has DetailId, HeaderId, Description and Amount

Controller Code:

public ActionResult Edit(Header header, FormCollection formCollection)
{
   if (formCollection["saveButton"] != null)
   {
      header = this.ProcessFormCollectionHeader(header, formCollection);
      if (ModelState.IsValid)
      {
         return new RedirectResult("~/saveNotification");
      }
      else
      {
         return View("Edit", header);
      }
   }
   else
   {
      return View("Edit", header);
   }
}

[I know controller code can be cleaned up a bit, just at this state as a result of trying to determine what is occuring here]

IValidatableObject implementation:

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
   if (this.Name.Length < 5) && (this.Amount > 10))
   {
      yield return new ValidationResult("Item must have sensible name to have Amount larger than 10.", new[] { "Amount" });
   }
}
ChrisHDog
  • 4,473
  • 8
  • 51
  • 77
  • You say it works with the ValidationSummary? But not with the individual messages? If you view source can you see the messages? – Erik Funkenbusch Oct 09 '12 at 06:08
  • i can see the messages in the validation summary
    • Amount is invalid for Item.
    • ... but not for the individual item
    – ChrisHDog Oct 09 '12 at 06:23
  • What happens if you disable client-side validation? This is just a test to see if the problem is client-side or not. Set ClientValidationEnabled to false in Web.Config. – Erik Funkenbusch Oct 09 '12 at 06:31
  • same result: in summary, not on individual item – ChrisHDog Oct 09 '12 at 06:47
  • Can you edit your question to include the model definition and also show your controller code? – Erik Funkenbusch Oct 09 '12 at 06:49
  • edited question to include additional information as requested – ChrisHDog Oct 09 '12 at 06:59
  • i'm using partial classes to extend EF model (just added the IValidatableObject code to the question as well) ... this is working for items that are not master-detail type items (i.e. other UI items) – ChrisHDog Oct 09 '12 at 07:04
  • I don't see how that code would work, since you are not posting the Name property (it's not in any input element), which means it will post null. Thus your item will always fail validation. In fact, that code would likely throw an exception because it's dereferencing Name and Name would be null. – Erik Funkenbusch Oct 09 '12 at 07:06
  • whoops, cut and paste issue - there is a helper class that takes the formCollection and recreates all the sub-objects ... validation runs and reports successfully, just only shows in the ValidationSummary rather than the ValidationMessageFor sections – ChrisHDog Oct 09 '12 at 07:10
  • And what exactly does that helper function do? I don't see how that would work, since validation occurs prior to your action method being called, which means it would still throw an exception because your helper method has not yet run. – Erik Funkenbusch Oct 09 '12 at 07:12
  • My point here is that the code you've shown wouldn't work. That leaves two possibilities, you're not showing the real code or the real code is not being executed (possibly you're doing something that is bypassing it). – Erik Funkenbusch Oct 09 '12 at 07:18

1 Answers1

3

I would recommend you to use real editor templates. The problem with your code is that you are writing a foreach loop inside your view to render the template which generates wrong names for the corresponding input fields. I guess that's the reason why you are doing some workarounds in your controller action to populate the model (header = this.ProcessFormCollectionHeader(header, formCollection);) instead of simply using the model binder to do the job.

So let me show you the correct way to achieve that.

Model:

public class Header
{
    public IEnumerable<Detail> Details { get; set; }
}

public class Detail : IValidatableObject
{
    public int DetailId { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public int Amount { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if ((this.Name ?? string.Empty).Length < 5 && this.Amount > 10)
        {
            yield return new ValidationResult(
                "Item must have sensible name to have Amount larger than 10.", 
                new[] { "Amount" }
            );
        }
    }
}

Controller:

public class HomeController : Controller
{
    public ActionResult Index()
    {
        var model = new Header
        {
            Details = Enumerable.Range(1, 5).Select(x => new Detail
            {
                DetailId = x,
                Name = "n" + x,
                Amount = 50
            }).OrderBy(d => d.DetailId)
        };
        return View(model);
    }

    [HttpPost]
    public ActionResult Index(Header model)
    {
        if (ModelState.IsValid)
        {
            return Redirect("~/saveNotification");
        }
        return View(model);
    }
}

View (~/Views/Home/Index.cshtml):

@model Header

@using (Html.BeginForm())
{
    <table>
        <thead>
            <tr>
                <th>Name</th>
                <th>Description</th>
                <th>Amount</th>
            </tr>
        </thead>
        <tbody>
            @Html.EditorFor(x => x.Details)
        </tbody>
    </table>
    <button type="submit">OK</button>
}

Editor template for the Detail type (~/Views/Shared/EditorTemplates/Detail.ascx or ~/Views/Shared/EditorTemplates/Detail.cshtml for Razor):

<%@ Control 
    Language="C#" 
    Inherits="System.Web.Mvc.ViewUserControl<MvcApplication1.Controllers.Detail>" 
%>

<tr>            
    <td>
        <%= Html.DisplayFor(model => model.Name) %>
        <%= Html.HiddenFor(model => model.DetailId) %>
        <%= Html.HiddenFor(model => model.Name) %>
    </td>
    <td class="colDescription">
        <%= Html.EditorFor(model => model.Description) %>
        <%= Html.ValidationMessageFor(model => model.Description) %>
    </td>
    <td class="colAmount">
        <%= Html.EditorFor(model => model.Amount) %>
        <%= Html.ValidationMessageFor(model => model.Amount) %>
    </td>
</tr>

Here are a couple of things that I did to improve your code:

  • I performed the ordering of the Details collection by DetailId at the controller level. It's the controller's responsibility to prepare the view model for display. The view should not be doing this ordering. All that the view should do is display the data
  • Thanks to the previous improvement I git rid of the foreach loop in the view that you were using to render the editor template and replaced it with a single @Html.EditorFor(x => x.Details) call. The way this works is that ASP.NET MVC detects that Details is a collection property (of type IEnumerable<Detail>) and it will automatically look for a custom editor templated inside the ~/Views/SomeController/EditorTemplates or ~/Views/Shared/EditorTemplates folders called Detail.ascx or Detail.cshtml (same name as the type of the collection). It will then render this template for each element of the collection so that you don't need to worry about it
  • Thanks to the previous improvement, inside the [HttpPost] action you no longer need any ProcessFormCollectionHeader hacks. The header action argument will be correctly bound from the request data by the model binder
  • Inside the Detail.ascx template I have replaced <%= Model.Name %> with <%= Html.DisplayFor(model => model.Name) %> in order to properly HTML encode the output and fill the XSS hole that was open on your site.
  • Inside the Validate method I ensured that the Name property is not null before testing against its length. By the way in your example you only had an input field for the Description field inside the template and didn't have a corresponding input field for the Name property, so when the form is submitted this property will always be null. As a consequence I have added a corresponding hidden input field for it.
Darin Dimitrov
  • 1,023,142
  • 271
  • 3,287
  • 2,928
  • Should note that you've removed the Description property and replaced it with Name. This solves the null reference exception he would otherwise get in the validator, but is not the output he's looking for. – Erik Funkenbusch Oct 09 '12 at 07:36
  • Oh yes indeed, I thought that was obvious. I have updated my answer to include this information. – Darin Dimitrov Oct 09 '12 at 07:42
  • I was actually going to get to the prefix issue after we solved why his code wasn't throwing an exception. that seemed to indicate that his validator wasn't actually executing. – Erik Funkenbusch Oct 09 '12 at 07:49
  • that looks great, the @Html.EditorFor(x => x.Details) seems to have fixed most (all) of the problems now. definitely removed the need for the ProcessFormCollectionHeader, previously (with the foreach loop) the header object wasn't being populated with the details automatically, that fixed that. – ChrisHDog Oct 10 '12 at 00:09
  • i am getting one more issue though, getting a InvalidOperationException of "The EntityCollection has already been initialized. The InitializeRelatedCollection method should only be called to initialize a new EntityCollection during deserialization of an object graph." ... looks like it occurs before the controller is hit on the savebutton click – ChrisHDog Oct 10 '12 at 00:58
  • was able to get around the entity collection issue using code found here http://go4answers.webhost4life.com/Example/updating-multi-level-entity-ef-mvc-2-41431.aspx to pass data back on post – ChrisHDog Oct 12 '12 at 05:21