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

42 comments:

  1. Hi Adeel,
    I was searching the web for master/detail in MVC but there are no examples but your. Demo code works fine but I am playing with it and having hard time to create master detail with data from database. If you can help and maybe blog about it in part III. Alen

    ReplyDelete
  2. Can you please tell me little bit about your schema so i can prepare demo code accordingly.

    ReplyDelete
  3. Please select Name/URL option from dropdown when commenting. Blogger's comment options are irritating. Sorry for inconvenience

    ReplyDelete
  4. please keep it simple like Order, Customers, OrderDetails, Products tables

    ReplyDelete
  5. @Alen i will respond with sample code as soon as possible

    ReplyDelete
  6. You are the man. Can't wait, thnx

    ReplyDelete
  7. Adeel, one more thing, can you create views in razor.

    I have problems to translate to razor syntax even with razor converter found at https://github.com/telerik/razor-converter

    ReplyDelete
  8. @Alen when changing to razor view engine i encountered a problem with client validation. it will probably take some time now

    ReplyDelete
  9. Adeel, I appreciate your effort and am patiently waiting :-)

    ReplyDelete
  10. Great article. I am waiting to see a demo with Razor syntax and more complex example like Alen asked for: Order, Customers, OrderDetails, Products tables.
    It would be good to see a solution Telerik MVC controls.

    Thanks,
    Rad

    ReplyDelete
  11. @Alen, Rad Here you go. please find master-detail example with asp.net mvc-3, razor and linq to sql at http://www.esnips.com/doc/63cc65e3-0bc9-409d-a6ef-795985e58d32/Master-Detail3. You will also find b scripts.
    @Rad i have been a user and a big fan of Telerik controls of asp.net mvc and its pleasure to contribute back a little tiny bit if it can help
    regards

    ReplyDelete
  12. Adeel, I am using entity framework not ling to sql so I am still looking my way around, my model is

    public class Order
    {
    public Orders Orders { get; set; }
    public ListOrderLines { get; set; }
    }

    I am new to mvc, and are not sure if above View Model is ok for master detail

    In my View I am unable to create dropdown lists for customer and product, otherwise with text boxes everything is working ok.

    ReplyDelete
  13. Model typo :-) it is:

    public class Order
    {
    public Orders Orders { get; set; }
    public List OrderLines { get; set; }
    }

    ReplyDelete
  14. public class Order
    {
    public Orders Orders { get; set; }
    public List"<"OrderLines">" OrderLines { get; set; }
    }

    ReplyDelete
  15. Alen Unfortunately, i have not used EF so far. Can u please upload ur code somewhere and send me the link. i will try to get it sorted

    ReplyDelete
  16. Adeel, code is at http://www.mediacom.hr/IBSmvc3EF.zip

    I used your MasterDetail database, but am unable to create DropDownLists for customer and product lookup, and also "add new" does not work. I was able to make "add new" to work but just in ascx not in razor

    ReplyDelete
  17. Alen sorry for long delay. please find Master Detail demo with EF at http://www.esnips.com/doc/517830f3-ce9d-479c-a8eb-5b5306b3dfeb/MasterDetail3-with-EF. A word of caution though, its my first effort with EF and is not to be followed on as is basis. It will give u fair idea of the concept however.

    ReplyDelete
  18. Thank you Adeel, I somehow passed that obstacle, but I have a new one :-)
    I was trying to put some javascript in OrderDetail partial view but without success. The idea is: based on product selected in dropdown show price in the textbox
    something like


    $("#Product").change(function () {
    $("#Product option:selected").each(function () {
    $.getJSON("/Dokument/ProductInfo/" + $(this)[0].value, function (data) {
    $("#Price")[0].value = data.Price;
    });
    });
    });


    it seems that I am unable to reference dropdown or text box from OrderLine partial view.
    Any ideas?

    ReplyDelete
  19. I really appreciate your sample. I'm very close to getting it to work in my situation, but I'm having trouble because it seems that any < script /> inside the template views causes malformed HTML. I'm not 100% sure that's my problem yet, but here are 2 walls I'm hitting that lead me to think that embedded script is the problem: 1) One of my editor templates (DateTime) contains a jQuery datepicker script block, and any content after the 1st control that uses that is rendered after the template, and then any script remaining is rendered simply as text in the page HTML; removing the datepicker script block allows the HTML to render properly. 2) I'm attempting to create a master-detail-subdetail page (with 3 levels instead of the 2 in your example), and the same thing happens with any script inside the embedded template-within-a-template. I hope that makes sense. If you have any ideas as to how it would work to have script inside the jQuery template and/or multiple template levels, I would greatly appreciate any hints!

    ReplyDelete
  20. @Alen i have experienced few problems with js inside jquery templates looking for its solution
    @JH i have just experienced this problem. i will try to get it sorted real soon

    ReplyDelete
  21. @Alen plz change Product.cshtml file and add a class to dropdown list at the end like
    new { @class = "products" }
    and in javascript plz bind change event like
    $('.products').live('change', function () {
    alert(this.value);
    });
    @JH i found that we can't use script within script tag so we have to put it outside the template somehow. Maybe assigning a class to your date input and calling datepicker in seperate js file. Moreover, if you want to extend master detail to 3 or 4 levels plz have a look at http://blog.reybango.com/2010/07/12/jquery-javascript-templates-tutorial-nesting-templates/ and change code accordingly

    ReplyDelete
  22. Hi Adeel. Excellent post. I'm kinda new to MVC so I'm trying to figure out what some things do. I Have a question: In this part of the code

    [HttpPost]
    public ActionResult Edit(int id, Order order)
    {
    if (ModelState.IsValid)
    {
    var orderToUpdate = orderRepository.GetOrder(id);
    orderRepository.DeleteOrderLines(orderToUpdate);
    UpdateModel(orderToUpdate);
    orderRepository.Save();
    return View("DisplayOrder", orderToUpdate);
    }
    return View(order);
    }

    in what part do you use variable "order" that comes as a parameter?? and, of course, since that variable comes with the modifications in the order, how does it get updated in the database?

    Again, thanks for posting this amazing solution and excuse my bad english

    ReplyDelete
  23. Hello German,
    order object that comes as parameter is not used in this method at all. I have used it just to make sure that model binding is working properly. You can omit the parameter and everything will work as it did before.
    your second question is how changed values are updated in the db. The answer is through UpdateModel which calls the modelbinding explicitly. Since orderToUpdate is also of type Order modelbinder is able to iterate through its properties and update its values from valuprovider. Notice that before calling UpdateModel i have deleted all orderlines objects associated with orderToUpdate. This is because when editing detail part of form (orderLines) user can add new lines, delete old lines and edit existing lines so the easiest solution is to delete old orderLines and then re-insert all values we receive from the form post
    HTH

    ReplyDelete
  24. Hi, Adeel. Thanks for answering so quickly. I partially understood what you told me (again, I'm a newbie to MVC). I've been debugging step by step that part of code to really see what is happening and I still don't see where orderToUpdate becomes populated with new values coming from the view. When you call "orderRepository.GetOrder(id)" it populates variable orderToUpdate with original values from the database (right?), and then when you call UpdateModel returns with the new values (those that are stored in parameter order)
    If I understand you correctly, you're saying that UpdateModel does that for itself automatically?

    Again, thanks for your attention, I really appreciate it.

    ReplyDelete
  25. Hello German, where values come from in order parameter: the answer is from form collection. The modelbinder gathers values from form collection and put it in order object. similarly when we call updatemodel(orderToUpdate), modelbinder comes into play once again and it gets values from form collection and updates the orderToUpdate object

    ReplyDelete
  26. Ok, now I got it. Thank you so much Adeel, you've been very helpful.

    ReplyDelete
  27. Hi... that was good example. But in your case you already coded the records. How will I do when I want to insert new record?

    ReplyDelete
  28. Kishan sorry for late response you can find a sample application that saves and fetches record from/to db at http://www.esnips.com/doc/63cc65e3-0bc9-409d-a6ef-795985e58d32/Master-Detail3. plz download and see if it solves your problem

    ReplyDelete
  29. I hid f5 but nothing happend

    ReplyDelete
  30. thank you very much for your effort

    ReplyDelete
  31. Hi
    I am trying to use your code in a ASP.NET MVC 3 app with Telerik MVC extensions. Telerik uses jquery 1.7.1, and your code uses 1.5.1. and add method does not work. I have upgraded it to 1.7.1 but it seems there is a problem with template as I get "object does not support tmpl method". From the other side Telerik does not work with any other version!
    Would you please help me to overcome this version mismatch problem!!

    ReplyDelete
    Replies
    1. Javad, are you sure you have added jquery templating engine js file in the page. please visit http://api.jquery.com/jquery.tmpl/ for more information

      Delete
  32. Yes, when I remove teleik js files especially jquery.min.1.7.1, everything works.

    ReplyDelete
  33. Hi Adeel
    I have incorporated your code into my app without any problem. It uses jquery 1.5.1 and jquery.tmpl beta.
    My site uses Teletik MVC extetions also, and I am using its menu currently. Teleik uses jquery 1.7.1. when I enable telerik, you code does not work anymore and says "object does not support tmpl method". when I remove jquery 1.7.1 from telerik scripts folder, then your add methods works again but not telerik menu. when I remove jquery 1.5.1 and reference jquery 1.7.1 in my "Order" page the add moethod stops working again.
    It seems that it does not work with jquery 1.7.1.
    would you please help me, I need both your code and Telerik together

    ReplyDelete
  34. Hi Adeel
    I think I have found a way to solve the problem
    @Html.Telerik().ScriptRegistrar().jQuery(false).DefaultGroup(g => g.Combined(true).Compress(true));
    using an article from here:
    http://steveclements.net/blog/Using-Telerik-MVC-with-your-own-custom-jQuery-and-or-other-plug-ins

    ReplyDelete
  35. Hi Adeel,

    How do I download the code from esnips? Have been battling with trying to download it, but not sure how.

    Great articles,
    Keith

    ReplyDelete
    Replies
    1. Hey Keith,
      Sorry for the late reply. I was not able to find the code my self :). There is one copy of code I could find. I have hosted it on google drive https://docs.google.com/open?id=0B5fJZ50PYETkenZjQlRKYTZRX0k. I hope you wouldn't have problem downloading now.

      Delete
    2. Thanks for the backup of the code!

      Good movement hosted it on google drive!

      Delete
  36. Hi Adeel,

    I wish to download your Razor-EF code sample but it is no longer available on esnips. Can you please upload it again? Or better yet do a step by step tutorial on it? Thanks!

    Corix

    ReplyDelete
  37. Hi Adeel, I would like to see your Razor-EF sample too. Reupload it please.
    Thanks

    ReplyDelete
  38. Razor-EF sample is missing, can you repost and alert us? Thanks!

    ReplyDelete
  39. Hi Adeel, thanks for this nice work. I'm looking for same kind of work but instead of controller, view directly calls WCF service via JSON so just thinking on that part and other than that as per current application architecture Model class library not to be called directly on view. Please let me know the way around to get this done via JSON call.

    ReplyDelete