Collection Editing with MVC

In most systems at some point there will be a need to add/edit a collection of child objects on a parent. With ASP.NET Web Forms this was relatively easy to implement:

  • Place a repeater on a page
  • Bind it to an empty or existing list of child objects
  • Add some buttons with server side events to add or remove elements

By the power of ViewState the button events for adding or removing items will post back to the server and manipulation of the data can happen server side without losing state. It may not be efficient or user friendly but it works.

Moving to ASP.NET MVC and Razor templates we can bind a collection using strongly typed partial views. At first most developers will try the following, assuming the partial view is appropriately named in the EditorTemplates folder:

foreach(var child in Model.Children)
{
    @Html.EditorFor(child => child.Name)
}

If this is part of a form on post back it will not re-bind to the model as expected, looking at the generated markup the id and name fields are duplicated for each item:

<input type="text" id="child_Name" name="child.Name" />

MVC uses a set of predefined model binders to work out how to rebind form data to a model. If the binder cannot distinguish between elements then it will not work. The next example will work as expected and also bind correctly on post back:

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

The id and name attributes are now unique and also have a prefix for the parent object property, this allows the default collection model binder to re-bind the form post to the model.

<input type="text" id="Children__0_Name" name="Children[0].Name">

The Model Binder relies on convention and the syntax must follow a non-interrupted, zero based, numeric sequence. This means if the control collection started at Children[1].Name for example the model's child collection will not bind. Similarly if the sequence is interrupted by a missing element the collection will not bind correctly either.

Separate pages for child objects

By far the easiest option is to avoid creating large multi-row editors all together. Instead divide and conquer and have a new route and view for editing, adding and removing child objects on a collection.

In most cases this will be the best option, but persisting the parent before adding children may not be possible or the user might insist they want to be able to edit multiple rows in a single form.

Web Forms like full post-back

This is an example of 'just because we can, does not mean we should', but it is an interesting experiment. The input formaction attribute can be used to postback to different controller actions, assuming the entire model is within a single form this effectively replicates what web forms do by posting back and rebinding the entire model allowing server side manipulation without losing state on round trips.

<input type="submit" formaction="/MyController/AddChildItem" value="Add Item" />

On the controller the child row is simply added then the previous view returned for re-binding.

public ActionResult AddChildItem(MyModel model) 
{
    model.Children.Add(new ChildItem());
    return View("Edit", model);
}

Although this 'works' the full post-back is awful and the route/url will change on each post-back with the different formaction, not to mention issues with validation triggering when it should not, user losing scroll position and the performance hit of large round trip form posts etc etc.

Prototype template with java-script

If options 1 and 2 are a no go then we need to fix it with JavaScript. An often suggested solution is to use a prototype editor row that is always present on the form. Client side script is then used to clone the row and append it for new elements. 

There are a number of drawbacks with this; any form inputs would need to be cleared after cloning as would any client side validation state which is a whole bunch of extra script to handle possible different input types.

The collection id sequence will also need to be maintained so that the model binder works as expected on post back, if an element is removed mid sequence then the numbering will be off.

There also needs to be at least one element in the collection at all times, this would need to be removed if empty server side and also have various hacks to get around validation issues.

Better java-script solution

Taking the above approach further a best of both approach can be taken. This involves using ajax to fetch the template from the server rather than cloning client side. Issues such as clearing state or validation errors are avoided and the form can also start with an empty collection if required.

The key is having a method to replicate the editor-for razor helper, meaning the same editor template can be re-used. Client side script can then update the control name attributes on add or remove elements so that the model binder works as expected on post back.

Controller Methods

The script needs to be able to fetch the equivalent of @Html.EditorForModel server side and outside of a razor template, this can be done with a PartialViewResult, this assumes there is a strongly typed and appropriately named partial view in a EditorTemplates folder.

protected PartialViewResult EditorFor<TModel>(TModel model)
{
    return PartialView(_editorTemplatePath + typeof(TModel).Name, model);
}

However this is not within a collection context so we lose the naming convention. So a slight modification using TemplateInfo ensures the correct naming structure.

protected PartialViewResult EditorForCollectionItem<TModel>(TModel model, string parentName, int index = 0)
{
    ViewData.TemplateInfo.HtmlFieldPrefix = (parentName.Length == 0) ? "[" + index + "]" : parentName + "[" + index + "]";
    return PartialView(_editorTemplatePath + typeof(TModel).Name, model);
}

An interesting point is that the HtmlFieldPrefix only needs the square bracket name syntax, it will automatically generate the id syntax which uses underscores instead.

These methods are generic so they can be re-used, a specific controller action is still needed for the add new item function for the script to fetch using ajax. Given the above this can be quite simple but gives the option to do specific things like populate ViewBag data for drop downs for example.

public ActionResult AddChildItem()
{
    var viewModel = new ChildViewModel();
    // Code to re-populate lookups etc. Perform route specific model initialization
    return EditorForCollectionItem(viewModel, "Children");
}

In the Views data attributes are added for the script to bind to. This does rely on a certain html structure but it could be wrapped in an html helper extension for consistency. The script needs to know what the main container is, which controller action to call for adding new items and the item container attribute. It also needs to know what buttons to bind the add and remove functions to:

<div data-collection data-action="/MyController/AddChildItem">
<!-- BeginEditorPartial --> <div data-collection-item> <!-- Input Controls --> <button type="button" data-collection-remove>Remove Item</button> </div>
<!-- EndEditorPartial--> <button type="button" data-collection-add>Add Item</button> </div> 

JQuery plugin example

This is not a full example but it gives a starting point for the basic concept it should be fairly easy to fill in the blanks.

First the remove function, assuming this is bound to the appropriate button it looks for the container element with the data attribute and removes it. Then the call is made to an update indexes method to ensure we do not have a broken input naming or id sequence:

removeItem: function (sender) {
    var self = this;
    var closest = sender.closest("[data-collection-item]");
    closest.remove();
    self.updateIndexes();
}

The add item function calls the controller action indicated by the attribute, this will return the editor template html generated by the controller action and editor template so it can be inserted into the collection container. It uses the attributes to work out where to insert the element. The initial case where there are no elements currently in the collection also needs to be considered so there is a check to see if elements are already present. The controller always returns item 0 within the collection so update indexes is called again to ensure correct naming sequence.

addItem: function () {
    var self = this;
    var url = $(self.element).attr("data-action");
    $.ajax({
        url: url,
        success: function (template) {
            var last = $(self.element).find("[data-collection-item]").last();
            
            if (last.length > 0) {
                $(template).insertAfter(last);
            }
            else {
                $(template).prependTo($(self.element));
            }
            self.updateIndexes();
        }
    });
}

The update indexes function uses the data attribute to loop through the collection items, although the name attribute is the important one for binding the id attribute still needs to be updated for consistency and any validators on the controls.

updateIndexes: function () {
    var self = this;
    var collectionItems = $(self.element).find("[data-collection-item]");
    collectionItems.each(function (index) {
        $(this).find("[name]").each(function () {
            $(this).attr("id", $(this).attr("id").replace(self.idRegEx, "$1" + index + "$2")); 
            $(this).attr("name", $(this).attr("name").replace(self.nameRegEx, "$1" + index + "$2"));
        });
    });
}

Now everything is in place on Post Back the model binder should correctly bind the form values.