Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

MVC Model Binding with Dynamic Collection

According to the seminal Scott Hanselman article on the complexities of the ASP.NET Wire Format for Model Binding to Arrays, Lists, Collections, Dictionaries:

We read in the properties by looking for parameterName[index].PropertyName
The index must be zero-based and unbroke

So this HTML:

<input type="text" name="People[0].FirstName" value="George" />
<input type="text" name="People[1].FirstName" value="Abraham" />
<input type="text" name="People[2].FirstName" value="Thomas" />

Which will post like this:

Post Data - People%5B0%5D.FirstName=George&People%5B1%5D.FirstName=Abraham&People%5B2%5D.FirstName=Thomas

However, if I load a new person into my model over AJAX, I lose the context for building that person into the model and get the following output:

<input type="text" name="FirstName" value="New" />

Post Data - People%5B0%5D.FirstName=George&People%5B1%5D.FirstName=Abraham&People%5B2%5D.FirstName=Thomas&FirstName=John

Which won't get picked up by the model binder.

Q: How can I preserve the expression tree when dynamically adding new elements over AJAX?

Here's an MVCE

Model: /Model/Person.cs

public class PersonViewModel
{
    public List<Person> People { get; set; }
}
public class Person
{
    public String FirstName { get; set; }
    public String LastName { get; set; }
}

Controller: Controllers/PersonController.cs

[HttpGet]
public ActionResult Index()
{
    List<Person> people = new List<Person> {
        new Person { FirstName = "George" , LastName = "Washington"},
        new Person { FirstName = "Abraham" , LastName = "Lincoln"},
        new Person { FirstName = "Thomas" , LastName = "Jefferson"},
    };
    PersonViewModel model = new PersonViewModel() {People = people};
    return View(model);
}

[HttpPost]
public ActionResult Index(PersonViewModel model)
{
    return View(model);
}

public ActionResult AddPerson(String first, String last)
{
    Person newPerson = new Person { FirstName = first, LastName = last };
    return PartialView("~/Views/Person/EditorTemplates/Person.cshtml", newPerson);
}

View: Views/Person/Index.cshtml

@model PersonViewModel

@using (Html.BeginForm()) {
    <table id="table">
        <thead>
            <tr>
                <th>@Html.DisplayNameFor(model => model.People.First().FirstName)</th>
                <th>@Html.DisplayNameFor(model => model.People.First().LastName)</th>
            </tr>
        </thead>
        <tbody>
            @for (int i = 0; i < Model.People.Count; i++)
            {
                @Html.EditorFor(model => model.People[i])
            }
        </tbody>
    </table>

    <input type="button" value="Add Person" id="add"/>
    <input type="submit" value="Save" />
}

<script type="text/javascript">

    $("#add").click(function() {
        var url = "@Url.Action("AddPerson")?" + $.param({ first: "", last: "" });
        $.ajax({
            type: "GET",
            url: url,
            success: function(data) {
                $("#table tbody").append(data);
            }
        });
    });

</script>

View: Views/Person/EditorTemplates/Person.cshtml

@model Person

<tr>
    <td>@Html.EditorFor(model => model.FirstName)</td>
    <td>@Html.EditorFor(model => model.LastName)</td>
</tr>

NOTE: There are other complexities when deleting an item that I'm not looking to address here per se. I'd just like to add an element and know that it belongs in a nested context alongside other properties.

like image 778
KyleMit Avatar asked Nov 18 '16 16:11

KyleMit


1 Answers

You can install the Html.BeginCollectionItem utility like this:

PM> Install-Package BeginCollectionItem

Then wrap your collection item partial view like this:

@model Person
<tr>
    @using (Html.BeginCollectionItem("people"))
    {
        <td>@Html.EditorFor(model => model.FirstName)</td>
        <td>@Html.EditorFor(model => model.LastName)</td>
    }
</tr>

Which will generate a GUID-driven collection like this:

<tr>
    <input type="hidden" name="people.index" autocomplete="off" 
                     value="132bfe2c-75e2-4f17-b54b-07e011971d78">
    <td><input class="text-box single-line" type="text" value="Abraham"
                 id="people_132bfe2c-75e2-4f17-b54b-07e011971d78__FirstName" 
               name="people[132bfe2c-75e2-4f17-b54b-07e011971d78].FirstName"></td>
    <td><input class="text-box single-line"  type="text" value="Lincoln"
                 id="people_132bfe2c-75e2-4f17-b54b-07e011971d78__LastName"  
               name="people[132bfe2c-75e2-4f17-b54b-07e011971d78].LastName"></td>            
</tr>

Now we get posted form data that look like this:

Form Data - with GUIDs

This leverages the DefaultModelBinder which allows for Non-Sequential Indices as explained by Phil Haack:

The good news is that by introducing an extra hidden input, you can allow for arbitrary indices. Just provide a hidden input with the .Index suffix for each item we need to bind to the list. The name of each of these hidden inputs is the same, which will give the model binder a nice collection of indices to look for when binding to the list.

Right away, your model should build just fine, but you'll also be able to add and remove items as well.

Further Reading

  • A Partial View passing a collection using the Html.BeginCollectionItem helper
  • Submit same Partial View called multiple times data to controller?
  • Model Binding To A List - Phil Haack
like image 191
KyleMit Avatar answered Sep 21 '22 15:09

KyleMit