Editing Variable Length Reorderable Collections in ASP.NET MVC – Part 2: jQuery Templates

In Part 1 of this series I discussed the problems that we face when implementing an editor for a variable length, reorderable collection and reuse our server-side ASP.NET MVC views, model binding and validation. And while the solution provided in Part 1 is nice and clean (and I personally use it in a few projects) one field for improvement in true Agile Blogging fashion is to find a way to remove the AJAX request we use for fetching new editor rows.

One way to bring this completely to the client side is by the use of jQuery Template (or any other JavaScript templating engine/library).

A reminder of what our editor looks like before I begin and a second remind that the source code is available on GitHub:

To start with I have added a second link to our sample’s home page titled “Edit with jQuery templates”:

Corresponding controller actions:

public ActionResult EditJQueryTemplate()
{
    return View(CurrentUser);
}

[HttpPost]
public ActionResult EditJQueryTemplate(User user)
{
    if (!this.ModelState.IsValid)
        return View(user);

    CurrentUser = user;
    return RedirectToAction("Display");
}

And a new editor view (EditJQueryTemplate.cshtml) which is exactly the same as the one from Part 1 apart from the core editor bit:

<legend>My Favourite Movies</legend>

@if (Model.FavouriteMovies == null || Model.FavouriteMovies.Count == 0) {
    <p>None.</p>
}
<ul id="moviesEditor" style="list-style-type: none">
    @if (Model.FavouriteMovies != null) {
        foreach (Movie movie in Model.FavouriteMovies) {
            Html.RenderPartial("MovieEntryEditor", movie);
        }
    }
</ul>
<script src="@Url.Content("~/Scripts/jQuery.tmpl.min.js")" type="text/javascript"></script>

<script type="text/x-jquery-tmpl" id="movieTemplate">
    @Html.CollectionItemJQueryTemplate("MovieEntryEditor", new Movie())
</script>

<script type="text/javascript">
    $(function () {
        $("#moviesEditor").sortable();
    });

    var viewModel = {
        addNew: function () {
            $("#moviesEditor").append($("#movieTemplate").tmpl({ index: viewModel._generateGuid() }));
        },

        _generateGuid: function () {
            // Source: http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/105074#105074
            return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
                var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
                return v.toString(16);
            });
        }
    };
</script>
            
<a id="addAnother" href="#" onclick="viewModel.addNew();">Add another</a>

Note the new @Html.CollectionItemJQueryTemplate helper, which takes a collection item instance with default values and the unchanged partial view MovieEntryEditor from Part 1:

@model CollectionEditing.Models.Movie
           
<li style="padding-bottom:15px">
    @using (Html.BeginCollectionItem("FavouriteMovies")) {
        <img src="@Url.Content("~/Content/images/draggable-icon.png")" style="cursor: move" alt=""/>

        @Html.LabelFor(model => model.Title)
        @Html.EditorFor(model => model.Title)
        @Html.ValidationMessageFor(model => model.Title)

        @Html.LabelFor(model => model.Rating)
        @Html.EditorFor(model => model.Rating)
        @Html.ValidationMessageFor(model => model.Rating)

        <a href="#" onclick="$(this).parent().remove();">Delete</a>
    }
</li>

Basically the helper renders the partial with an ${$index} jQuery template placeholder for which we supply a GUID at run-time on the client-side when we render the template. The output of the helper looks like this:

<script type="text/x-jquery-tmpl" id="movieTemplate">
<li style="padding-bottom:15px">

    <input autocomplete="off" name="FavouriteMovies.Index" type="hidden" value="${index}" />

    <img src="/Content/images/draggable-icon.png" style="cursor: move" alt=""/>

    <label>Title</label>
    <input name="FavouriteMovies[${index}].Title" type="text" value="" />

    <label>Rating</label>
    <input name="FavouriteMovies[${index}].Rating" type="text" value="0" />

    <a href="#" onclick="$(this).parent().remove();">Delete</a>
</li>
</script>

Everytime the “Add another” button is pressed this template is rendered using a new GUID index value supplied by the viewModel class.

Here is the code of the @Html.CollectionItemJQueryTemplate helper:

public static MvcHtmlString CollectionItemJQueryTemplate<TModel, TCollectionItem>(this HtmlHelper<TModel> html, 
                                                                                    string partialViewName, 
                                                                                    TCollectionItem modelDefaultValues)
{
    ViewDataDictionary<TCollectionItem> viewData = new ViewDataDictionary<TCollectionItem>(modelDefaultValues);
    viewData.Add(JQueryTemplatingEnabledKey, true);
    return html.Partial(partialViewName, modelDefaultValues, viewData);
}

It renders the supplied partial view, but before that it sets a flag on the ViewContext’s ViewData dictionary to indicate that the view is to be rendered as jQuery collection item template. This is then handled by the @Html.BeginCollectionItem by generating an index template placeholder based .Index hidden field for model binding instead of one with an actual GUID value:

public static IDisposable BeginCollectionItem<TModel>(this HtmlHelper<TModel> html, string collectionName)
{
    string collectionIndexFieldName = String.Format("{0}.Index", collectionName);

    string itemIndex = null;
    if (html.ViewData.ContainsKey(JQueryTemplatingEnabledKey)) {
        itemIndex = "${index}";
    } else {
        itemIndex = GetCollectionItemIndex(collectionIndexFieldName);
    }

    string collectionItemName = String.Format("{0}[{1}]", collectionName, itemIndex);

    TagBuilder indexField = new TagBuilder("input");
    indexField.MergeAttributes(new Dictionary<string, string>() {
        { "name", collectionIndexFieldName },
        { "value", itemIndex },
        { "type", "hidden" },
        { "autocomplete", "off" }
    });
// snip---

That’s it! We are still reusing our ASP.NET views, model binding and validation, but no longer require an AJAX request to fetch a new editor row!.

In Part 3 I will look at Knockout JS templating, data binding and moving variable length reorderable collection editing completely on the client side with the MVVM pattern and using JavaScript form serialization and JSON data submission and model binding.

Next: Part 3 and Knockout JS client-side collection editing, JSON model binding and client-side validation.

  • James

    Looking forward to part 3. Thanks for sharing.

    • http://ivanz.com/ Ivan Zlatev

      Part 3 is out: http://ivanz.com/2011/06/29/editing-variable-length-reorderable-collections-in-asp-net-mvc-part-3/

  • Zahidmadeel

    Great article Ivan, waiting for part 3 like james. keep up the good work

    • http://ivanz.com/ Ivan Zlatev

      Part 3 is out: http://ivanz.com/2011/06/29/editing-variable-length-reorderable-collections-in-asp-net-mvc-part-3/

  • http://twitter.com/daniiel Daniel Velazquez

    Hey Ivan great work! I know it’s being a while since you did this, so I was wondering if you’ve used the ViewBag with jquery templates.

  • Olivier

    Hi That’s very good article but for my application the id of input stay with id=”Favorites__index__Title” but the attr is name=”Favorites[099758de-b5ef-4b1f-9b73-ef5aafd2000d].Title”

    have you an idea ?

  • Evgeni

    Hi Ivan, thank you for the great article, I’m using your pattern with jQuery Template. Now I have th e following issue:

    My model looks like:

    public class QuestionsModel
    {
    public ConfigurationIndexModel Configuration { get; set; }
    public IList Questions { get; set; }
    }

    Each element of the Questions List has a list of answeroptions:

    IList AnswerOptions { get; set; }

    Now I implemented the QuestionView as partial and inside this PartialView I would like integrate
    the PartielView of the Answers. The rendering of the Views is working but when I post back the complete model. The AnswerOption value of each question is null.

    Do you have an idea how the solution for this could looks that the binding of collections in collections is also possible?

    Thank you