Sunday, May 22, 2011

Master detail form in aspnet mvc-3 - II

In my previous post i discussed how we can add and delete rows in detail portion of master detail form without having to go to server side when new rows are inserted. In this post i will discuss adding new rows on client side for detail portion of the form, using select lists ( or  other editor elements) to insert a new row and client validate dynamically added rows. For this purpose, i will be using unobtrusive js that comes with asp.net mvc-3 and jquery templating engine. I will not reproduce the code from last entry if there is no change so, please go through the post if you haven't already done it.
So, first change is in our OrderLine view model where we want ProductID quantity to be selected through a select list. For this, its been decorated with UIHint attribute with name of the corresponding editor template.
public class OrderLine
{
    [UIHint("Product")]
     public int ProductID { get; set; }
     public int Quantity { get; set; }

}
Listing 1: OrderLine ViewModel
To make Steve's collection helper work with jquery templating engine , i have  made few changes in BeginCollectionItem method
public static IDisposable BeginCollectionItem(this HtmlHelper html, string collectionName, bool isTemplate = false)
{
    if (isTemplate)
    {
        var randomNumber = "${randomNumber}";
        html.ViewContext.Writer.WriteLine(string.Format("<input type=\"hidden\" name=\"{0}.index\" autocomplete=\"off\" value=\"{1}\" />", collectionName, randomNumber));
        return BeginHtmlFieldPrefixScope(html, string.Format("{0}[{1}]", collectionName, randomNumber));
    }
    else
    {
        var idsToReuse = GetIdsToReuse(html.ViewContext.HttpContext, collectionName);
        string itemIndex = idsToReuse.Count > 0 ? idsToReuse.Dequeue() : Guid.NewGuid().ToString();

        // autocomplete="off" is needed to work around a very annoying Chrome behaviour whereby it reuses old values after the user clicks "Back", which causes the xyz.index and xyz[...] values to get out of sync.
        html.ViewContext.Writer.WriteLine(string.Format("<input type=\"hidden\" name=\"{0}.index\" autocomplete=\"off\" value=\"{1}\" />", collectionName, html.Encode(itemIndex)));

        return BeginHtmlFieldPrefixScope(html, string.Format("{0}[{1}]", collectionName, itemIndex));
    }
}
Listing 2: Collection Helper
one optional parameter "isTemplate" is added to method's parameter list with default value set to false so it does not affect the code already using this method. when isTemplate is true ${randomNumber} string is used as index of collection item in place of GUID and will produce the output like
<input type= "text" name = "OrderLines[${randomNumber}]" id = "some_Generated_ID" data-*/>
it will help jquery insert the random number value on client side when new row is added. Please visit this article to learn how jquery templating engine works. If value of isTemplate is false or unspecified the ids are generated as usual.
Next, there is only one change in index ActionResult that renders the form
public ActionResult Index()
{
     ViewBag.Products = Session[_productKey] as List<Product>;
     Order _order = new Order { CustomerID = 1, OrderDate = DateTime.Now, OrderID = 1 };
     _order.OrderLines = new List<OrderLine> { new OrderLine { ProductID = 1, Quantity = 3 }, new OrderLine { ProductID = 2, Quantity = 56 } };
     _order.Customers = Session[_customerKey] as List<Customer>; 
     return View(_order);
}
Listing 3: Index ActionResult (HttpGet)
in this method ViewBag.Products property is set to list of Products. This will be used by Product Editor templage that we annotated ProductID property with. Some changes have also been made in index view and OrderLine partial view.
<%Html.BeginForm(); %>
    <div id="orderMaster">
        <div>
            <%:Html.LabelFor(x =>x.OrderID)%>
            <%:Html.TextBoxFor(x=>x.OrderID)%>
        </div>
        <div>
            <%:Html.LabelFor(x =>x.CustomerID)%>
            <%:Html.DropDownListFor(x => x.CustomerID, new SelectList(Model.Customers, "CustomerID", "CustomerName"), "Select Customer", new { @class = "xyz" })%>
        </div>
        <div>
            <%:Html.LabelFor(x =>x.OrderDate)%>
            <%:Html.TextBoxFor(x=>x.OrderDate)%>
        </div>
    </div>
    <div id="orderDetail">
        <%foreach (var x in Model.OrderLines)
          {%> 
             <div class="editorRow">
               <%using(Html.BeginCollectionItem("OrderLines")) {
                   Html.RenderPartial("OrderLine",x);
               }%>
               </div> 
        <%} %>
       
    </div>
    <a href="#" id="add">Add Another</a>
    <input type="submit" value="save" />
    <%Html.EndForm(); %>
    <script id="OrderLine" type="text/x-jQuery-tmpl">
    <div class="editorRow">
    <%using(Html.BeginCollectionItem("OrderLines",true)) { %>
        <%Html.RenderClientTemplate(typeof(MasterDetail.Models.OrderLine), "OrderLine"); %>
        <%} %>
    </div>
    </script>
Listing 4: Index View
First change in index view is at line 19 and 20. I have moved editor row div and BeginCollectionItem method call from partial view to the index view so we can set the value of isTemplate parameter of the method. At line 30 jquery client template that will be used to add rows dynamically on the detail portion of the form. Inside this template same BeginCollectionItem is called but this time with isTemplate value set to true. This will enable us to set values of index on client side. On line 33 RenderClientTemplate helper is called which produces all the inputs (selectlists etc.) required to add an OrderLine type object. Listing 5 shows the code of  RenderClientTemplate helper
public static void RenderClientTemplate(this HtmlHelper helper, Type _type, string _partialViewName) 
{
    object model = Activator.CreateInstance(_type);
    helper.RenderPartial(_partialViewName, model);
}
Listing 5: Client Template Helper
ClientTemplateHelper accepts two inputs (except html helper itself): first is the type of object that we want to render a template for and second is partial view that will be used to render the template. Inside the helper model object is created using reflection and passed to the partial view to render. When this method is called from index view it will render the same partial view that was used for the OrderLine objects present in the Order object. The only difference this time is that the value of index this time will be set to ${randomNumber} allowing us to use this template multiple times on client side.
There are of course some changes in click event handler of anchor tag used to render the new row.
$('#add').live('click', function () {
    obj = { randomNumber: GetRandomGUI() }
    $("#OrderLine").tmpl(obj).appendTo("#orderDetail");
    $.validator.unobtrusive.parseDynamicContent('div.editorRow:last');
    return false;
});
Listing 6: Adding New Row in Javascript
Just line 2 creates an object with property randomNumber that is acquired by a javascript function. Line 3 is generating actual html content by providing the value of obj and appending it to the end of orderDetail div element. Line 4 calls a function that parses newly added html to turn on client validation for them. Listing 7 shows pareseDynamicContent function

(function ($) {
    $.validator.unobtrusive.parseDynamicContent = function (selector) {
        //use the normal unobstrusive.parse method

        //$.validator.unobtrusive.parse(selector);
        $(selector).find('*[data-val = true]').each(function(){
            $.validator.unobtrusive.parseElement(this,false);
        });

        //get the relevant form
        var form = $(selector).first().closest('form');
        
        //get the collections of unobstrusive validators, and jquery validators
        //and compare the two
        var unobtrusiveValidation = form.data('unobtrusiveValidation');
        var validator = form.validate();
        $.each(unobtrusiveValidation.options.rules, function (elname, elrules) {
            if (validator.settings.rules[elname] == undefined) {
                var args = {};
                $.extend(args, elrules);
                args.messages = unobtrusiveValidation.options.messages[elname];
                //alert('here1');
                
                $('[name="' + elname + '"]').rules("add", args);
            } else {
                $.each(elrules, function (rulename, data) {
                    rulename = rulename;
                    data = data;
                    if (validator.settings.rules[elname][rulename] == undefined) {
                        var args = {};
                        args[rulename] = data;
                        args.messages = unobtrusiveValidation.options.messages[elname][rulename];
                        alert('here');
                        
                        $('[name="' + elname + '"]').rules("add", args);
                    }
                });
            }
        });
    }
})($);
Listing 7: JS Function for Switching on Client Validation
This function is taken from xhalent's blog and there you can find more explanation on switching on client validation on dynamically added fields. There is one change that has been made in his function. Line 5 was replaced by Line 6,7 and 8.
Note: I have found conflicting material on turning on client side validation for newly added fields. Brad Wilson says that calling Jquery.validator.unobtrusive.parse() will turn on client validation on newly added html but it did not work for me and i had to use xhalent's function  but it didn't work for me either until i changed line 5 with 6, 7 and 8 in listing 7
Pleas find Demo Code Here
Note: Duplicate ids are generated for similar elements of each row in detail portion of the form resulting in some DOM malfunctions. To address the issue pleas follow this link