The advantage of ASP.NET MVC modelbinding is undisputed. But lately I faced a problem with a common usecase in a project for Tekaris. I´m simplifying the complexity of the project by using the following sample. Let´s say I had to display a list of products. The user should be able to add or remove items from the list (pure client logic). In the next step the user can save the list (post to the server) and the products are stored in the database. So on server side the logic compares the posted data with the persisted data, removes the not posted products and adds the new products.
The code is based on the following ViewModels.
public class ProductViewModel { public ProductViewModel() { } public ProductViewModel(int id, string name, string description, double price) { ID = id; Name = name; Description = description; Price = price; } public int ID { get; set; } public String Name { get; set; } public string Description { get; set; } public double Price { get; set; } } public class ProductCollectionViewModel { public ProductCollectionViewModel() : this(new List<ProductViewModel>()) { } public ProductCollectionViewModel(List<ProductViewModel> items) { Items = items; } public List<ProductViewModel> Items { get; set; } }
A simple ProductRepository class stores the data. New products are identified by the ID “0”:
public static class ProductRepository { private static ProductCollectionViewModel _products = new ProductCollectionViewModel { Items = new System.Collections.Generic.List<ProductViewModel> { new ProductViewModel(1, "Computer", "Macbook Pro", 2000), new ProductViewModel(2, "Smartphone", "Google Nexus 5", 380), new ProductViewModel(3, "Display", "Samsung SyncMaster", 289) } }; public static ProductCollectionViewModel Products { get { return _products; } } public static void Save(ProductCollectionViewModel products) { var maxId = _products.Items.Max(product => product.ID); products.Items.ForEach(product => { if (product.ID == 0) product.ID = ++maxId; }); _products = products; } }
And here is the ProductController for listing and saving the products:
public class ProductController : Controller { public ActionResult Index() { var model = ProductRepository.Products; return View(model); } [HttpPost] public ActionResult Save(ProductCollectionViewModel products) { ProductRepository.Save(products); return RedirectToAction("Index"); } }
In my first approach I rendered the products using a foreach-loop. After posting the unchanged list back to the server I noticed that the returned products list was empty
@using DynamicList.Models @model DynamicList.Models.ProductCollectionViewModel @{ ViewBag.Title = "Products"; } @using (Html.BeginForm("Save", "Product", FormMethod.Post)) { <div class="row"> <div class="col-md-8"> <table class="table" id="productsTable"> <tr> <th style="width: 25%">Name</th> <th style="width: 40%">Description</th> <th style="width: 15%">Price</th> </tr> @foreach (ProductViewModel product in Model.Items) { <tr> <td> @Html.HiddenFor(m => product.ID) @Html.EditorFor(m => product.Name, new { htmlAttributes = new { @class = "form-control", placeholder = "Name" } }) </td> <td>@Html.EditorFor(m => product.Description, new { htmlAttributes = new { @class = "form-control", placeholder = "Description" } })</td> <td>@Html.EditorFor(m => product.Price, new { htmlAttributes = new { @class = "form-control", placeholder = "Price" } })</td> </tr> } </table> </div> </div> <div class="row"> <div class="col-md-8"> <input type="submit" value="Save" class="btn btn-primary" /> </div> </div> }
After some investigation I found out that I had to use a for-loop. Accessing each product based on its index changes the way each element gets rendered. Every element is getting a “path” in the name attribute as you can see in the following snippet (based on the foreach-loop)
That enables the ModelBinder to map the posted data back to the appropriate property of the model. But that´s not working with a collection. So I used a for-loop and the data was returned correctly:
@model DynamicList.Models.ProductCollectionViewModel @{ ViewBag.Title = "Products"; } @using (Html.BeginForm("Save", "Product", FormMethod.Post)) { <div class="row"> <div class="col-md-8"> <table class="table" id="productsTable"> <tr> <th style="width: 25%">Name</th> <th style="width: 40%">Description</th> <th style="width: 15%">Price</th> <th></th> </tr> @for (var i = 0; i < Model.Items.Count; i++) { <tr> <td> @Html.HiddenFor(m => Model.Items[i].ID) @Html.EditorFor(m => Model.Items[i].Name, new { htmlAttributes = new { @class = "form-control", placeholder = "Name" } }) </td> <td>@Html.EditorFor(m => Model.Items[i].Description, new { htmlAttributes = new { @class = "form-control", placeholder = "Description" } })</td> <td>@Html.EditorFor(m => Model.Items[i].Price, new { htmlAttributes = new { @class = "form-control", placeholder = "Price" } })</td> <td><button class="btn btn-danger delete-link pull-right">Remove</button></td> </tr> } </table> </div> </div> <div class="row"> <div class="col-md-8"> <button class="btn btn-primary pull-right" id="btnAddProduct"><span class="glyphicon glyphicon-plus"></span> Add product</button> <input type="submit" value="Save" class="btn btn-primary" /> </div> </div> }
The name attribute now contains the index of the item in the list of the products.
The next step was to add some JavaScript logic for removing and adding products. In my case I removed the tr-element when removing a product. In order to add a product I created the necessary HTML and added it to the table. And now a requirement of the DefaultModelBinder takes place.
The index in the name attribute must be sequential
Well, that´s not the case when I remove a product which is “between” others. So for example the sequence 0-1-2-3 would change to 0-1-3 after removing the product on index 2. So the product with the index “3” wouldn´t be mapped. It seems that previous versions of the DefaultModelBinder allowed non-sequential indexes. But in ASP.NET MVC 5 it´s definetely not the case.
I decided to write some logic that recreates the index after adding or removing a product by updating every element that contains the appropriate name attribute.
This is the final /Products/Index.cshtml page. It contains a delete and an add button as well as two click handler.
- The first one removes the appropriate table row after removing a product and calls the function to update the indexes.
- The second one adds a new product by parsing a row template and replacing the INDEX-placeholders with maximum index plus 1
@model DynamicList.Models.ProductCollectionViewModel @{ ViewBag.Title = "Products"; } @using (Html.BeginForm("Save", "Product", FormMethod.Post)) { <div class="row"> <div class="col-md-8"> <table class="table" id="productsTable"> <tr> <th style="width: 25%">Name</th> <th style="width: 40%">Description</th> <th style="width: 15%">Price</th> <th></th> </tr> @for (var i = 0; i < Model.Items.Count; i++) { <tr> <td> @Html.HiddenFor(m => Model.Items[i].ID) @Html.EditorFor(m => Model.Items[i].Name, new { htmlAttributes = new { @class = "form-control", placeholder = "Name" } }) </td> <td>@Html.EditorFor(m => Model.Items[i].Description, new { htmlAttributes = new { @class = "form-control", placeholder = "Description" } })</td> <td>@Html.EditorFor(m => Model.Items[i].Price, new { htmlAttributes = new { @class = "form-control", placeholder = "Price" } })</td> <td><button class="btn btn-danger delete-link pull-right">Remove</button></td> </tr> } </table> </div> </div> <div class="row"> <div class="col-md-8"> <button class="btn btn-primary pull-right" id="btnAddProduct"><span class="glyphicon glyphicon-plus"></span> Add product</button> <input type="submit" value="Save" class="btn btn-primary" /> </div> </div> } @section Scripts{ <script type="text/javascript"> $(document).ready(function() { $(document).on('click', '.delete-link', function() { event.preventDefault(); var tr = $(this).closest('tr'); tr.addClass("bg-danger"); tr.fadeOut(500, function() { var table = tr.closest('table'); tr.remove(); updateIndexes(table); }); }); $("#btnAddProduct").on("click", function(event) { event.preventDefault(); addProductsRecord('productsTable'); }); }); </script> }
Additional content of the Site.css:
.delete-link { /* only a marker class */ }
The DynamicList.js contains the logic for adding a new product by parsing the rowTemplate and replacing the {INDEX} placeholders
function addProductsRecord(tableId) { var rowTemplate = '<tr>' + '<td>' + '<input data-val="true" data-val-number="The field ID must be a number." data-val-required="The ID field is required." id="Items_{INDEX}__ID" name="Items[{INDEX}].ID" type="hidden" value="0">' + '<input class="form-control text-box single-line" id="Items_{INDEX}__Name" name="Items[{INDEX}].Name" type="text" placeholder="Name">' + '</td>' + '<td><input class="form-control text-box single-line" id="Items_{INDEX}__Description" name="Items[{INDEX}].Description" type="text" placeholder="Description"></td>' + '<td>' + '<input class="form-control text-box single-line" data-val="true" data-val-number="The field Price must be a number." data-val-required="The Price field is required." id="Items_{INDEX}__Price" name="Items[{INDEX}].Price" type="text" placeholder="Price">' + '</td>' + '<td><button class="btn btn-danger delete-link pull-right">Remove</button></td>' + '</tr>'; addRecord(tableId, rowTemplate); } function addRecord(tableId, rowTemplate) { var table = $("#" + tableId); var newIndex = table.find("tr").length - 1; rowTemplate = rowTemplate.replace(/{INDEX}/g, newIndex); var newRow = $(rowTemplate); newRow.hide(); table.append(newRow); newRow.addClass("bg-success"); newRow.fadeIn(500, function () { newRow.removeClass("bg-success"); }); } // This is where the magic happens function updateIndexes(table) { // get every tr element except the header table.find("tr:gt(0)").each(function (i, row) { // get every input and select elements $(row).find('input, select').each(function (j, input) { // check whether the id-attribute is of type _[index]__ var id = input.id.match(/_\d+__/); // if it is an element necessary for the ModelBinder => update the name attribute if (id != null && id.length && id.length == 1) { var attr = $(input).attr("name"); // replace the old index of the name attribute with the calculated index var newName = attr.replace(attr.match(/\d+/), i); $(input).attr("name", newName); } }); }); }
Some other solutions with generating hidden fields or using GUIDs as index can be found in the net but nothing was really working for me. So I hope my solution can help you if you´re facing the same problem.