Catching Up
If you haven't already and want an understanding of the underlying issues at hand I recommend reading
Phil Haack's and
Steve Sanderson's posts. To sum up, doing variable length lists in ASP.NET MVC is currently a fairly ugly process for some rather good reasons, and I wanted to see if I could take a stab at simplifying it.
Getting Started
I had previously written my own custom TextBoxFor<T> extensions. In them, I traversed the expression tree to generate an input name that would look something like this with a ViewPage<Person>:
<%= Html.TextBoxFor(p=>p.FirstName) %>
and would output:
<input type="text" value="Justin" name="person.FirstName" />
The benefits of this method is that you can traverse an arbitrary length object graph and have it output html that will automatically work with ASP.NET MVC's default model binding. For example (if a poor one):
<%= Html.TextBoxFor(p=>p.Address.State.Country) %>
which would produce:
<input type="text" value="United States" name="person.Address.State.Country" />
and we could then just get the entire Person back by simply defining a method on our controller like so:
public ActionResult SubmitPerson(Person person)
{
//do stuff
}
Inspiration
Now I was trying to think of a way to clean up the implementations that Phill and Steve used and was struck with one of those ah ha moments. Having previously been looking at an excellent, up-and-coming patterns framework for dataccess, validation, and business rules by
Ritesh Rao called
NCommon. I realized I could use the same pattern that he uses to implement his UnitOfWorkScope. The end result of it all is this:
With ViewPage<IList<Person>>
<%foreach (Person p in Model){%>
<%using(Html.CollectionScope()){ %>
<%Html.RenderPartial("~/Views/List/PersonControl.ascx", p); %>
<%} %>
<%}%>
and inside the person user control:
<%= Html.HiddenFor(p=>p.Id) %>
<%= Html.TextBoxFor(p=>p.FirstName) %>
<%= Html.TextBoxFor(p=>p.LastName) %>
<%foreach (Address address in Model.Addresses){%>
<%using(Html.CollectionScope()){ %>
<% Html.RenderPartial("~/Views/List/AddressControl.ascx", address); %>
<%} %>
<%} %>
and with a little extension method foo we can get to this:
<% Model.HtmlEach(p => Html.RenderPartial("~/Views/List/PersonControl.ascx", p));%>
And this small amount of display logic allows us to automatically get our list of Persons back like so:
public ActionResult SubmitPeople(IList<Person> people)
{
//do stuff
}
The Code
If you want to just jump right in and get the code you can access it
here. Otherwise read on.
Edit: the above download is an updated copy of the code as described here.
The CollectionScope class that I use here:
<% using(Html.CollectionScope()){ %>
<%} %>
is stored on a Stack<CollectionScope> in HttpContext.Current.Items. This allows us to keep track of all of our CollectionScopes and generate our object graph with or without indexers, based upon whether we are within a CollectionScope or not.
The implementation of our Html extension Method TextBoxFor<T> is where we access the CollectionScope stack and generate our input names.
28 public static string TextBoxFor<T>(this VariableList.HtmlHelper<T> helper, Expression<Func<T, string>> action, object htmlAttributes) where T : class
29 {
30 var body = action.Body as MemberExpression;
31 if(body != null)
32 {
33 string name = body.GetObjectGraph();
34 var function = action.Compile();
35 T model = (T)helper.ViewData.Model;
36 string value = function(model);
37
38 if (CollectionScope.Current != null && !CollectionScope.Current.HasSetIndexer)
39 {
40 CollectionScope.Current.HasSetIndexer = true;
41
42 string indexProp = name.Substring(0, name.LastIndexOf('['));
43 return helper.Hidden(indexProp + ".Index",
44 CollectionScope.Current.Indexer.ToString())
45 + helper.TextBox(name, value, htmlAttributes);
46 }
47
48 return helper.TextBox(name, value, htmlAttributes);
49 }
50
51 return string.Empty;
52 }
The most important line (33) is currently hidden in an extension method .GetObjectGraph().
This extension method basically recurses through the Expression tree and generates the name attribute that we then insert into our input tag. Here is an example input tag from the demo project.
<input type="text" value="City0:0" name="people[eb528441-c0b7-46fc-bd41-85dca72a86d6].addresses[2a269c54-1396-4804-a83c-dbde65b19a84].City" id="people[eb528441-c0b7-46fc-bd41-85dca72a86d6].addresses[2a269c54-1396-4804-a83c-dbde65b19a84].City"/>
As you can see I'm using a Guid to ensure uniqueness for each index. I'm not a huge fan of this and will probably rework it to keep page-size down and readability up but for now it works.
Things I Don't Like
As I stated above I'm using a Guid to ensure uniqueness. This makes the page a little ugly to read and could hurt performance. Another issue currently is this block of code in each For<T> extension:
38 if (CollectionScope.Current != null && !CollectionScope.Current.HasSetIndexer)
39 {
40 CollectionScope.Current.HasSetIndexer = true;
41
42 string indexProp = name.Substring(0, name.LastIndexOf('['));
43 return helper.Hidden(indexProp + ".Index",
44 CollectionScope.Current.Indexer.ToString())
45 + helper.TextBox(name, value, htmlAttributes);
46 }
This block is used to insert an input tag to define the indexer for each unique item in a collection. There are a couple of ways I could rework this to make it a lot less code for each Html extension method but levib mentions in this
thread that the requirement for the indexer definition is likely to go away in the next drop of ASP.NET MVC, so I'm not going to bother at the moment.
The Sappy Ending
Since this is my first post I'd like to say a big "Thank You!" to all the bloggers out there that helped me on my way. If you have any questions, comments, or constructive criticism please feel free to leave them in the comments.
Edit: An updated post on this topic can be found here.