Search code examples
c#asp.net-mvcienumerableview-templatesrazor-3

Correct, idiomatic way to use custom editor templates with IEnumerable models in ASP.NET MVC


This question is a follow-up for Why is my DisplayFor not looping through my IEnumerable<DateTime>?


A quick refresh.

When:

  • the model has a property of type IEnumerable<T>
  • you pass this property to Html.EditorFor() using the overload that only accepts the lambda expression
  • you have an editor template for the type T under Views/Shared/EditorTemplates

then the MVC engine will automatically invoke the editor template for each item in the enumerable sequence, producing a list of the results.

E.g., when there is a model class Order with property Lines:

public class Order
{
    public IEnumerable<OrderLine> Lines { get; set; }
}

public class OrderLine
{
    public string Prop1 { get; set; }
    public int Prop2 { get; set; }
}

And there is a view Views/Shared/EditorTemplates/OrderLine.cshtml:

@model TestEditorFor.Models.OrderLine

@Html.EditorFor(m => m.Prop1)
@Html.EditorFor(m => m.Prop2)

Then, when you invoke @Html.EditorFor(m => m.Lines) from the top-level view, you will get a page with text boxes for each order line, not just one.


However, as you can see in the linked question, this only works when you use that particular overload of EditorFor. If you provide a template name (in order to use a template that is not named after the OrderLine class), then the automatic sequence handling will not happen, and a runtime error will happen instead.

At which point you will have to declare your custom template's model as IEnumebrable<OrderLine> and manually iterate over its items in some way or another to output all of them, e.g.

@foreach (var line in Model.Lines) {
    @Html.EditorFor(m => line)
}

And that is where problems begin.

The HTML controls generated in this way all have same ids and names. When you later POST them, the model binder will not be able to construct an array of OrderLines, and the model object you get in the HttpPost method in the controller will be null.
This makes sense if you look at the lambda expression - it does not really link the object being constructed to a place in the model from which it comes.

I have tried various ways of iterating over the items, and it would seem the only way is to redeclare the template's model as IList<T> and enumerate it with for:

@model IList<OrderLine>

@for (int i = 0; i < Model.Count(); i++)
{ 
    @Html.EditorFor(m => m[i].Prop1)
    @Html.EditorFor(m => m[i].Prop2)
}

Then in the top-level view:

@model TestEditorFor.Models.Order

@using (Html.BeginForm()) {
    @Html.EditorFor(m => m.Lines, "CustomTemplateName")
}

which gives properly named HTML controls that are properly recognized by the model binder on a submit.


While this works, it feels very wrong.

What is the correct, idiomatic way to use a custom editor template with EditorFor, while preserving all the logical links that allow the engine to generate HTML suitable for the model binder?


Solution

  • After discussion with Erik Funkenbusch, which led to looking into the MVC source code, it would appear there are two nicer (correct and idiomatic?) ways to do it.

    Both involve providing correct html name prefix to the helper, and generate HTML identical to the output of the default EditorFor.

    I'll just leave it here for now, will do more testing to make sure it works in deeply nested scenarios.

    For the following examples, suppose you already have two templates for OrderLine class: OrderLine.cshtml and DifferentOrderLine.cshtml.


    Method 1 - Using an intermediate template for IEnumerable<T>

    Create a helper template, saving it under any name (e.g. "ManyDifferentOrderLines.cshtml"):

    @model IEnumerable<OrderLine>
    
    @{
        int i = 0;
    
        foreach (var line in Model)
        { 
            @Html.EditorFor(m => line, "DifferentOrderLine", "[" + i++ + "]")
        }
    }
    

    Then call it from the main Order template:

    @model Order
    
    @Html.EditorFor(m => m.Lines, "ManyDifferentOrderLines")
    

    Method 2 - Without an intermediate template for IEnumerable<T>

    In the main Order template:

    @model Order
    
    @{
        int i = 0;
    
        foreach (var line in Model.Lines)
        {
            @Html.EditorFor(m => line, "DifferentOrderLine", "Lines[" + i++ + "]")
        }
    }