Caffeine-Powered Life

Nested Collection Models in ASP.NET MVC 3

Scenario: We want to create form where I can save information about a person. That person has zero or more phone numbers and zero or more email addresses. The customer would like to edit everything about a person all at once, on a single form. This is a dynamic nested model problem. Suppose our person object looks like this:

public class Person {
  public string FirstName { get; set; }
  public string LastName { get; set; }
  public IList<PhoneNumber> PhoneNumbers { get; set; }
  public IList<EmailAddress> EmailAddresses { get; set; }
  public IList<Address> Addresses { get; set; }
}

Let’s start with the controller. When new is called, we need to create a new person, and we want to prepopulate the collections with two telephone numbers, one email address, and one address. I’m going to add a few methods to my Person class to help me out. I’m using C#’s new optional parameters feature here.


public class Person {

  /// <summary>

  /// Add a number of empty phone numbers to a person. The

  /// default is 1.

  /// </summary>

  public void BuildPhoneNumbers(int count = 1) {

    for (int i = 0; i < count; i++) {

      PhoneNumbers.Add(new PhoneNumber());

    }

  }

  /* etc */

}



public class PersonController {

  [HttpGet]

  public ActionResult New() {

    var person = new Person();

    person.BuildPhoneNumber(2); // Add 2 phone numbers

    person.BuildEmailAddress(); // Add an email address

    person.BuildAddress(); // Add one address

    return View(person);

  }

}

We’re also going to make use of the EditorFor HTML helper. This helper is smart enough to iterate over a collection, returning a collection of input elements for each object in the collection. From this point on, I’ll only work with phone numbers, but the example is the same. In New.cshtml, we’ll call EditorForModel(). This will look at the type of model (Person), then go looking in your EditorTemplates folder for a file named Person.cshtml. (Yes, this works the same with *.ascx and *.vbhtml.)

Inside of Person.cshtml, we’re going to call EditorFor(Expression). Again, this looks at the return type of the expression and looks for a file name that matches the type. In this case, that will be PhoneNumber.cshtml. Person.cshtml would do the exact same thing for email address and address collections. You would also build editor templates for EmailAddress.cshtml and Address.cshtml and place those in your EditorTemplates folder.


New.cshtml:

@using (Html.BeginForm("New", "Person", FormMethod.Post))

{

  @Html.EditorForModel()

  <p>

    <button type="submit">

      Create Person

    </button>

  </p>

}



Person.cshtml:

@Html.AntiForgeryToken()

@Html.HiddenFor(x => x.Id)



<p>

  <label>First Name</label>

  @Html.TextBoxFor(x => x.FirstName)

</p>

  

<p>

  <label>Last Name</label>

  @Html.TextBoxFor(x => x.LastName)

</p>



<div id="phoneNumbers">

  @Html.EditorFor(x => x.PhoneNumbers)

</div>



PhoneNumber.cshtml:

<div class="phoneNumber">

  <p>

    <label>Telephone Number</label>

    @Html.TextBoxFor(x => x.Number)

  </p>

  <br/>

</div>

If we were to stop there, we now how a completely dynamic form were the numbers of phone numbers, addresses, and email addresses were set in the controller. That’s a great start! Let’s look a little closer at what just got generated.

  • We have editors for FirstName and LastName properties in Person.cshtml.
  • We created a container div tag with the ID #phoneNumbers.
  • We called EditorFor(x => x.PhoneNumbers), and that looped through our phone numbers and created two nested editors.

Those nested editors are really important. Let’s inspect those input fields. See how MVC tells us their collection, uses a zero-based index, and tells us the property of the object.


<input type="text" id="PhoneNumbers_0__Number" name="PhoneNumbers[0].Number"/>

<input type="text" id="PhoneNumbers_1__Number" name="PhoneNumbers[1].Number"/>

This is the proper way to post collections back to MVC. We can also inspect the model on the submit. If you’ve done everything correctly so far, you should see POST data that looks like this:

Nested Form Post Data

Of course you know where we’re going with this. We want have some sort of user interaction to dynamically scale these values on the screen. This is usually done with links on the page (and then you can get some fancy CSS to make those links look pretty). Right now, forget the pretty.

Nested Form Screen

Removing a Phone Number

As it turns out, the remove button is really easy, but we need to do some work on our model first. We need to add a boolean property called Delete to flag where or not a record has been marked for deletion. We could send DELETE request to the server as soon as the user clicks the Remove link, but that’s a hard problem. If the user cancels out of the form, what do you do? You’ve already sent the DELETE request. Instead, lets add the Delete property to our phone number. We also need to show this on the form as a hidden field.


PhoneNumber.cs

public class PhoneNumber {

  /* etc */

  public bool Delete { get; set; }

  /* etc */

}



PhoneNumber.cshtml

@Html.HiddenFor(x => x.Delete)

Your database/repository code will need to be smart enough to find the phone numbers marked as Delete == true and either not save (new records) or delete (existing records) when the flag has been set.

Next, we’re going to write an HTML helper for this link. First, here’s the helper.


public static class HtmlHelpers {

  public static IHtmlString LinkToRemoveNestedForm(this HtmlHelper htmlHelper, string linkText, string container, string deleteElement) {

      var js = string.Format("javascript:removeNestedForm(this,'{0}','{1}');return false;", container, deleteElement);

      TagBuilder tb = new TagBuilder("a");

      tb.Attributes.Add("href", "#");

      tb.Attributes.Add("onclick", js);

      tb.InnerHtml = linkText;

      var tag = tb.ToString(TagRenderMode.Normal);

      return MvcHtmlString.Create(tag);

  }

}  

This is what PhoneNumber.cshtml will now look like.


<div class="phoneNumber">

  <p>

    <label>Phone Number</label>

    @Html.TextBoxFor(x => x.Number) 

    @Html.HiddenFor(x => x.Delete, new { @class = "mark-for-delete" })

    @Html.LinkToRemoveNestedForm("Remove", "div.phoneNumber", "input.mark-for-delete")

  </p>

  <hr />

</div>

It looks like we need a JavaScript method called removeNestedForm() here. I usually create an application.js file where my site-wide JavaScript goes.


function removeNestedForm(element, container, deleteElement) {

  $container = $(element).parents(container);

  $container.find(deleteElement).val('True');

  $container.hide();

}

The code here is pretty simple. First, find the parent container of the element. That’s why we had to wrap our phone number with <div class="phoneNumber"></div>. We’re going to find the delete element inside that container and set it’s value to ‘True’. Finally, we’re going to hide the container. After clicking this link, we should see some POST data that demonstrates that this phone number has been marked for deletion. It’s important that we .hide() the block, and not .remove(). If we remove it, it’s gone, and it won’t get POSTed with the form.

Good, we’re making progress. Just one more piece, and that’s the add link. It’s not quite as easy as the remove link, and there’s a little bit of work to do here. I’m going to store the template content in the add link. There are other ways I could do this. I could send a GET request to the server, but this works and allows for maximum code reuse. First, I need to be able to encode my a string to make it JavaScript safe. Not HTML safe – we already have Html.Encode() for that. I want something similar to what Rick Strahl posted back in 2007. Here’s my version.


public static class HtmlHelpers {

  private static string JsEncode(this string s)

  {

    if (string.IsNullOrEmpty(s)) return "";

    int i;

    int len = s.Length;

    StringBuilder sb = new StringBuilder(len + 4);

    string t;



    for (i = 0; i < len; i += 1)

    {

      char c = s[i];

      switch (c)

      {

        case '>':

        case '"':

        case '\\':

          sb.Append('\\');

          sb.Append(c);

          break;

        case '\b':

          sb.Append("\\b");

          break;

        case '\t':

          sb.Append("\\t");

          break;

        case '\n':

          //sb.Append("\\n");

          break;

        case '\f':

          sb.Append("\\f");

          break;

        case '\r':

          //sb.Append("\\r");

          break;

        default:

          if (c < ' ')

          {

            //t = "000" + Integer.toHexString(c); 

            string tmp = new string(c, 1);

            t = "000" + int.Parse(tmp, System.Globalization.NumberStyles.HexNumber);

            sb.Append("\\u" + t.Substring(t.Length - 4));

          }

          else

          {

            sb.Append(c);

          }

          break;

      }

    }    

    return sb.ToString();

  } 

}

I don’t do too much differently. I ignore CRLF, because HTML doesn’t care about newlines. If you care about newlines, then you’ll need to uncomment the stuff at \n and \r. I’ve also compacted some of the redundant operators and removed the leading and trailing quotes. I guess (for now), you’ll have to trust me that my code works. Still, if you want other JavaScript encoding methods, that’s what search engines are for.

Adding a New Phone Number

OK, moving on. We also need a link and a helper for adding a new row. Here’s what Person.cshtml should look like now. Check out the last HTML helper.


@Html.AntiForgeryToken()

@Html.HiddenFor(x => x.Id)

<p>

  <label>First Name</label>

  @Html.TextBoxFor(x => x.FirstName)

</p>  

<p>

  <label>Last Name</label>

  @Html.TextBoxFor(x => x.LastName)

</p>

<hr />

<div id="phoneNumbers">

  @Html.EditorFor(x => x.PhoneNumbers)    

</div>

<p>

  @Html.LinkToAddNestedForm("Add Phone Number", "#phoneNumbers", ".phoneNumber", "PhoneNumbers", typeof(PhoneNumber))

</p>

Here’s what that HTML helper will look like. Yes, it’s got a few more input parameters. Let’s go through them. linkText is obvious. It’s the text that needs to appear on the link. The containerElement is where this block will be inserted in the HTML using a jQuery append method. The counterElement is how we’re going to count the number of items on the form. (Remember that we need to 0-index our properties.) collectionProperty tells us the prefix that needs to be added to the generated HTML elements. We didn’t need to worry about the prefix when calling the EditorFor method from the HTML source. Here, though, we’re not as lucky and need to do a little bit of MVC’s work ourselves. The helper method creates an instance of the object, generates the form using EditorFor.


public static IHtmlString LinkToAddNestedForm<TModel>(this HtmlHelper<TModel> htmlHelper, string linkText, string containerElement, string counterElement, string collectionProperty, Type nestedType) {

  var ticks = DateTime.UtcNow.Ticks;

  var nestedObject = Activator.CreateInstance(nestedType);

  var partial = htmlHelper.EditorFor(x => nestedObject).ToHtmlString().JsEncode();

  partial = partial.Replace("id=\\\"nestedObject", "id=\\\"" + collectionProperty + "_" + ticks + "_");

  partial = partial.Replace("name=\\\"nestedObject", "name=\\\"" + collectionProperty + "[" + ticks + "]");

  var js = string.Format("javascript:addNestedForm('{0}','{1}','{2}','{3}');return false;", containerElement, counterElement, ticks, partial);

  TagBuilder tb = new TagBuilder("a");

  tb.Attributes.Add("href", "#");

  tb.Attributes.Add("onclick", js);

  tb.InnerHtml = linkText;

  var tag = tb.ToString(TagRenderMode.Normal);

  return MvcHtmlString.Create(tag);

}

So now we’ve got our link that says “Add Phone Number” on our screen. Here’s the HTML link that gets rendered by the above code. We do need to do a little bit of string magic. The elements generated by the EditorFor will be prefixed with nestedObject, and not PhoneNumbers[index] like we want. Our helper can change the text, but not the index. The lines with the “replace” method take care of making sure that our collection properties are correctly named. Instead of an index, though, I’m putting the system clock time in there. We’ll let JavaScript take care of figuring out what the real index should be. That’s where the addNestedForm() method comes in.


function addNestedForm(container, counter, ticks, content) {

  var nextIndex = $(counter).length;

  var pattern = new RegExp(ticks, "gi");

  content = content.replace(pattern, nextIndex);

  $(container).append(content);

} 

This is why we needed a counter object. We know that if we count so many of that object on the screen, we can assign the appropriate 0-based index to the next form group being added. We want to replace that ticks number with the next index and append it to our container. After clicking that add button a few times and filling in a few phone numbers, we should see this.

When we POST our form, we should see 6 telephone numbers added to our person object.

And that’s what we get!

Grid It Up!

There’s a lot going on this blog post. Styling is left as an excercise for the reader. It’s not difficult to put the subform pieces inside a table, if you’d rather have something that looks like a grid. In that case, your collection container would be #phoneNumbers tbody (assuming you gave your table an ID of “phoneNumbers”). Appending to the table body would add a row. Each phone number would be wrapped with a tr.phoneNumber table row. If you want to make your remove links a button, that’s easy enough to do. Or if you want to give them a CSS class and image, that’s easy, too. These things, however, are left as exercises for the reader.

Validations on Nested Objects

If you’re using unobtrusive javascript validations, validating this is easy. You’ll need to add calls to your addNestedForm and removeNestedForm methods to remove your validators and reapply them by reparsing the document. (Check out Brad Wilson’s post for more on that.)

If you have questions, please ask.

Comments