Introduction
Building a hierarchical UI in MVC is most of the time very hard when you want to edit it and submit data to server. Depending on how complicated is it can be quite hard
to post data to server. The solution which I'll present will show an example of how we can edit a hierarchical structure like:
Having this kind of structure on UI, it is hard to dynamically add controls and POST the data to server. Usually in this cases you need
to manually iterate all UI controls and create the object you want to POST it to server. The target for this solution is to have the object (the ViewModel for that view) automatically created and POST to server, so we don’t need to do any parsing. Basically we will be able to add items (e.g. Category item, SubCategory item or Product item) on UI and when the form is posted to server then the ViewModel is automatically created for us. The server will receive the object like:
Solution
The View Model classes used for this example are:
public class OrderViewModel
{
public OrderViewModel()
{
Categories = new List<CategoryViewModel>();
}
public string Name { get; set; }
public List<CategoryViewModel> Categories { get; set; }
}
public class CategoryViewModel
{
public CategoryViewModel()
{
Subcategories = new List<SubCategoryViewModel>();
}
public string Name { get; set; }
public List<SubCategoryViewModel> Subcategories { get; set; }
}
public class SubCategoryViewModel
{
public SubCategoryViewModel()
{
Products = new List<ProductViewModel>();
}
public string Name { get; set; }
public List<ProductViewModel> Products { get; set; }
public string PreviousFieldId { get; set; }
}
public class ProductViewModel
{
public string Name { get; set; }
public double Price { get; set; }
public string PreviousFieldId { get; set; }
}
For UI we need to create PartialViews for each of those ViewModels. Until now everything is simple and straight forward, but the problem will start to appear when you do
those partial views. For example: for Category View you would do something like:
<div>
<div class="editor-label">
<div>
Categories
</div>
</div>
<fieldset>
<div>
Name:@Html.TextBoxFor(t => t.Name)
</div>
@Html.Partial("SubcategoriesPartial", Model.Subcategories)
</fieldset>
</div>
For the SubcategoriesPartial partial view you would do something like:
<div>
<fieldset>
<div>
<div class="editor-label">
<div>
Subcategories
</div>
</div>
<div class="editor-label">
<div>
@Html.ActionLink("Add another subcategory...",
"AddSubCategory", null, new { @class = "addSubcategoryItem" })
@foreach (var subcategory in Model)
{
@Html.Partial("SubcategoryPartial", subcategory)
}
</div>
</div>
</div>
</fieldset>
</div>
And SubcategoryPartial partial view is:
<fieldset>
<div>
<div class="editor-label" style="float: left; width: 70px;">
Name:
</div>
<div style="float: left">
@Html.TextBoxFor(m => m.Name)
</div>
</div>
<div style="clear: both">
<div class="editor-label" style="float: left; width: 70px">
<div>
Products:
</div>
</div>
<div style="float: left">
<fieldset>
<div>
@Html.ActionLink("Add product...", "AddProduct", null, new { @class = "addProductItem" })
@foreach (var prod in @Model.Products)
{
@Html.Partial("ProductPartial", prod)
}
</div>
</fieldset>
</div>
</div>
</fieldset>
And so on you will do the UI. Unfortunately this is OK only if you want to display the data. When you want to allow the user to edit this data (adding more/editing categories, subcategories or products) then those views are not done well. If you have those Views then when you submit the form to server, the MVC framework won’t be able to recreate the OrderViewModel because the id’s for each controls are not generated correctly.
In this case the input tags generated for category and subcategory name will look like:
For Category Name:
<input id="Name" type="text" value="category 0" name="Name">
For SubCategory Name:
<input id="Name" type="text" value="SubCategory 0" name="Name">
As you can see there is no way to differentiate between category name and subcategory name because both generated input controls have the same name and id. In order to differentiate between those, each input control must have a proper id which will look like:
For Category Name:
<input id="Categories_0__Name" type="text" value="category 0" name="Categories[0].Name">
For SubCategory Name:
<input id="Categories_0__Subcategories_0__Name" type="text" value="SubCategory 0" name="Categories[0].Subcategories[0].Name">
As you can see in this case you can differentiate between those input controls and the ViewModel can be generated when you submit the form to server. This approach is good only when you allow edit of existing items (not adding ore removing items from those view model collections). In case of you want to add or remove items you will need to generate the correct index for each control which is very hard to keep track of all used indexes for each collection.
Another approach will be to replace the index with a GUID and use the generated GUID as it is an index from the collection. Using this latest approach the input controls will look like:
For Category:
<input type="hidden" value="8b9309b2-ad7b-45c0-9725-efd1cc56a514" autocomplete="off" name="Categories.index">
<input id="Categories_8b9309b2-ad7b-45c0-9725-efd1cc56a514__Name" type="text"
value="category 0" name="Categories[8b9309b2-ad7b-45c0-9725-efd1cc56a514].Name">
For SubCategory:
<input type="hidden" value="ecafcc7c-f1e1-4a2c-af3f-fd1b1ff376cc"
autocomplete="off" name="Categories[8b9309b2-ad7b-45c0-9725-efd1cc56a514].Subcategories.index">
<input id="Categories_8b9309b2-ad7b-45c0-9725-efd1cc56a514__Subcategories_ecafcc7c-f1e1-4a2c-af3f-fd1b1ff376cc__Name"
type="text" value="SubCategory 0"
name="Categories[8b9309b2-ad7b-45c0-9725-efd1cc56a514].Subcategories[ecafcc7c-f1e1-4a2c-af3f-fd1b1ff376cc].Name">
Basically you need to generate a hidden field for each generated GUID to specify where that Id will be used and use that Id as it is an index. With this approach you can add/edit/remove items and the ViewModel will be generated correctly when you submit the data to server.
Now we need to create a Html helper (Html extension method) which will be able to generate those GUIDs and will put those Ids on correct elements. To do this we need to modify the TemplateInfo.HtmlFieldPrefix only for elements within a code block (within each collection items).
The code for those helper methods is:
public static class HtmlModelBindingHelperExtensions
{
private const string IdsToReuseKey = "__HtmlModelBindingHelperExtensions_IdsToReuse_";
public static IDisposable BeginCollectionItem(this HtmlHelper html, string collectionName)
{
var idsToReuse = GetIdsToReuse(html.ViewContext.HttpContext, collectionName);
string itemIndex = idsToReuse.Count > 0 ? idsToReuse.Dequeue() : Guid.NewGuid().ToString();
var previousPrefix = html.ViewData.TemplateInfo.HtmlFieldPrefix;
html.ViewContext.Writer.WriteLine(string.Format("<input " +
"type=\"hidden\" name=\"{0}.index\" autocomplete=\"off\"
value=\"{1}\" />", !string.IsNullOrEmpty(previousPrefix) ?
string.Concat(previousPrefix.Trim('.'), ".", collectionName) :
collectionName, html.Encode(itemIndex)));
return BeginHtmlFieldPrefix(html, string.Format("{0}[{1}]", collectionName, itemIndex));
}
public static IDisposable BeginHtmlFieldPrefix(this HtmlHelper html, string htmlFieldPrefix)
{
return new HtmlFieldPrefix(html.ViewData.TemplateInfo, htmlFieldPrefix);
}
private static Queue<string> GetIdsToReuse(HttpContextBase httpContext, string collectionName)
{
string key = IdsToReuseKey + collectionName;
var queue = (Queue<string>)httpContext.Items[key];
if (queue == null)
{
httpContext.Items[key] = queue = new Queue<string>();
var previouslyUsedIds = httpContext.Request[collectionName + ".index"];
if (!string.IsNullOrEmpty(previouslyUsedIds))
foreach (string previouslyUsedId in previouslyUsedIds.Split(','))
queue.Enqueue(previouslyUsedId);
}
return queue;
}
private class HtmlFieldPrefix : IDisposable
{
private readonly TemplateInfo _templateInfo;
private readonly string _previousHtmlFieldPrefix;
public HtmlFieldPrefix(TemplateInfo templateInfo, string htmlFieldPrefix)
{
this._templateInfo = templateInfo;
_previousHtmlFieldPrefix = templateInfo.HtmlFieldPrefix;
if (!string.IsNullOrEmpty(htmlFieldPrefix))
{
templateInfo.HtmlFieldPrefix = string.Format("{0}.{1}", templateInfo.HtmlFieldPrefix,
htmlFieldPrefix);
}
}
public void Dispose()
{
_templateInfo.HtmlFieldPrefix = _previousHtmlFieldPrefix;
}
}
}
All what you need to do is to use those helper methods when you build your partial views. Note that HtmlFieldPrefix implements IDisposable and each helper method returns an IDisposable object because we need to change the HtmlFieldPrefix only for the code which will be executed within our using block.
An example of how you can use those helper methods is:
For Category items:
@using (@Html.BeginCollectionItem("Categories"))
{
<div>
<div>
<div>
Categories
</div>
</div>
<fieldset>
<div>
Name:@Html.TextBoxFor(t => t.Name)
</div>
@Html.Partial("SubcategoriesPartial", Model.Subcategories)
</fieldset>
</div>
}
For SubCategories items:
<fieldset>
@using (@Html.BeginHtmlFieldPrefix(Model.PreviousFieldId))
{
using (@Html.BeginCollectionItem("Subcategories"))
{
<div>
<div style="float: left; width: 70px;">
Name:
</div>
<div style="float: left">
@Html.TextBoxFor(m => m.Name)
</div>
</div>
<div style="clear: both">
<div style="float: left; width: 70px">
<div>
Products:
</div>
</div>
<div style="float: left">
<fieldset>
<div id="@Html.ViewData.TemplateInfo.HtmlFieldPrefix.Trim('.')">
@Html.ActionLink("Add product...",
"AddProduct", null, new { @class = "addProductItem" })
@foreach (var prod in @Model.Products)
{
@Html.Partial("ProductPartial", prod)
}
</div>
</fieldset>
</div>
</div>
}
}
</fieldset>
Now we only need to make the Add links working. That is very straight forward. As you can see the links have either Id or class attributed, so we can attach a java script method which will call the server and will append the response to the correct div. The code which does that for Add Category link is:
$("#addCategoryItem").click(function () {
$.ajax({
url: this.href,
cache: false,
success: function (html) {
$("#div_Categories").append(html);
},
error: function (html) {
alert(html);
}
});
return false;
});
For the other two links (Add Sub Category and Add Product) we need to send the parent div Id because in controller we need to set the parent Id to the model in order to have the correct sequence of Id for the added item. So the code which does that is:
$(".addSubcategoryItem").click(handleNewItemClick);
$(".addProductItem").click(handleNewItemClick);
function handleNewItemClick() {
var parent = this.parentElement;
var formData = {
id: parent.id
};
$.ajax({
type: "POST",
url: this.href,
data: JSON.stringify(formData),
contentType: "application/json; charset=utf-8",
success: function (data) {
$(parent).append(data);
},
error: function (data) {
alert(data);
}
});
return false;
}
That was all. Try this solution and let me know if you have any questions.
Using the code
The source zip file includes the MVC project. You only need to extract and open the solution with Visual Studio. To see it in action you need to put a breakpoint on OrderManagerController
on method public ActionResult Edit(OrderViewModel viewModel)
( [HttpPost]) to see that all changes which you do on UI are automaticaly reflected on viewModel object.
References
The initial aproach can be found here or here. I've enhanced his approach to work with hierarchical data.
History
- 5 Aug 2012: Initial post to CodeProject