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

30.8k views Asked by At

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?

5

There are 5 answers

0
GSerg On BEST ANSWER

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++ + "]")
    }
}
1
solidau On

You can use an UIHint attribute to direct MVC which view you would like to load for the editor. So your Order object would look like this using the UIHint

public class Order
{
    [UIHint("Non-Standard-Named-View")]
    public IEnumerable<OrderLine> Lines { get; set; }
}
7
Apache On

Use FilterUIHint instead of the regular UIHint, on the IEnumerable<T> property.

public class Order
{
    [FilterUIHint("OrderLine")]
    public IEnumerable<OrderLine> Lines { get; set; }
}

No need for anything else.

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

This now displays an "OrderLine" EditorTemplate for each OrderLine in Lines.

18
Erik Funkenbusch On

There are a number of ways to address this problem. There is no way to get default IEnumerable support in editor templates when specifying a template name in the EditorFor. First, i'd suggest that if you have multiple templates for the same type in the same controller, your controller probably has too many responsibilities and you should consider refactoring it.

Having said that, the easiest solution is a custom DataType. MVC uses DataTypes in addition to UIHints and typenames. See:

Custom EditorTemplate not being used in MVC4 for DataType.Date

So, you need only say:

[DataType("MyCustomType")]
public IEnumerable<MyOtherType> {get;set;}

Then you can use MyCustomType.cshtml in your editor templates. Unlike UIHint, this does not suffer from the lack of IEnuerable support. If your usage supports a default type (say, Phone or Email, then prefer to use the existing type enumeration instead). Alternatively, you could derive your own DataType attribute and use DataType.Custom as the base.

You can also simply wrap your type in another type to create a different template. For example:

public class MyType {...}
public class MyType2 : MyType {}

Then you can create a MyType.cshtml and MyType2.cshtml quite easily, and you can always treat a MyType2 as a MyType for most purposes.

If this is too "hackish" for you, you can always build your template to render differently based on parameters passed via the "additionalViewData" parameter of the editor template.

Another option would be to use the version where you pass the template name to do "setup" of the type, such as create table tags, or other kinds of formatting, then use the more generic type version to render just the line items in a more generic form from inside the named template.

This allows you to have a CreateMyType template and an EditMyType template which are different except for the individual line items (which you can combine with the previous suggestion).

One other option is, if you're not using DisplayTemplates for this type, you can use DisplayTempates for your alternate template (when creating a custom template, this is just a convention.. when using the built-in template then it will just create display versions). Granted, this is counter-intuitive but it does solve the problem if you only have two templates for the same type you need to use, with no corresponding Display template.

Of course, you could always just convert the IEnumerable to an array in the template, which does not require redeclaring the model type.

@model IEnumerable<MyType>
@{ var model = Model.ToArray();}
@for(int i = 0; i < model.Length; i++)
{
    <p>@Html.TextBoxFor(x => model[i].MyProperty)</p>
}

I could probably think of a dozen other ways to solve this problem, but in all reality, any time I've ever had it, I've found that if I think about it, I can simply redesign my model or views in such a way as to no longer require it to be solved.

In other words, I consider having this problem to be a "code smell" and a sign that i'm probably doing something wrong, and rethinking the process usually yields a better design that doesn't have the problem.

So to answer your question. The correct, idiomatic way would be to redesign your controllers and views so that this problem does not exist. barring that, choose the least offensive "hack" to achieve what you want.

0
Anders On

There seems to be no easier way of achieving this than described in the answer by @GSerg. Strange that the MVC Team has not come up with a less messy way of doing it. I've made this Extension Method to encapsulate it at least to some extent:

public static MvcHtmlString EditorForEnumerable<TModel, TValue>(this HtmlHelper<TModel> html, Expression<Func<TModel, IEnumerable<TValue>>> expression, string templateName)
{
    var fieldName = html.NameFor(expression).ToString();
    var items = expression.Compile()(html.ViewData.Model);
    return new MvcHtmlString(string.Concat(items.Select((item, i) => html.EditorFor(m => item, templateName, fieldName + '[' + i + ']'))));
}