Sunday, May 22, 2011

Master Detail Form in asp.net Mvc 3 - I

This blog post is basically inspired by steve senderson's blog post on editing variable length list in asp.net mvc and this blog entry is going to be on the same topic as well. in his post he introduced a collection helper that i had been using for long time but it had taken me long time before  i realized the power of this collection helper and things that it can do. we will discuss it later on but first let's have a look at our view model that we want to create in a master-detail format

public class Order
    {
        public int OrderID { get; set; }
        public DateTime OrderDate { get; set; }
        public int CustomerID { get; set; }
        public IEnumerable<customer> Customers { get; set; }
        public IEnumerable<orderline> OrderLines { get; set; }
    }
    public class OrderLine
    {
        public int ProductID { get; set; }
        public int Quantity { get; set; }

    }

    public class Customer 
    {
        public int CustomerID { get; set; }
        public string CustomerName { get; set; }
    }
Listing 1: View Models
Order class has two IEnumerable properties (Customers and OrderLines). Customer property is here to just help render the drop down list so customer ID property can be conveniently filled by the user. Other property (OrderLines) forms the detail portion and contain n entries each for one product. The problem with master detail (or even List binding scenarios explained by steve sanderson) the user can dynamically demand to add or remove rows from detail portion. Now, with the Order viewmodel rendering from for master portion is straight forward but rendring orderLines properties on the same page can get bit tricky. one way of doing this is to use Steve's Collection Helper. In this technique an ajax request is sent to the server to bring an empty form row whenever user wants another row of same type (OrderLine type here) to be filled.
In this post i will analyze how we can do the same task on client side without having to go to server for getting blank inputs (select lists, checkboxes etc.). one way of letting our form inputs to bind with list parameter of ActionResult (or list property of our mode e.g OrderLiens) is to use zero based index with no discontinuity. Using this scheme, if we delete rows from the middle of the form we will have to take care of broken idexes using javascript. Other way of doing this to use a hidden input with block of inputs that belong to the one row. For example
<input type="hidden" name="OrderLines.index"  value="ahdi2391-xyza-bcd0-ski540189xib" />
<input type="text" name = "OrderLines[ahdi2391-xyza-bcd0-ski540189xib].ProductID" id = "ProductID_sdlfk_1244">
<input type="text" name = "OrderLines[ahdi2391-xyza-bcd0-ski540189xib].Quantity" id = "Quantity_122343_skdsd">
Listing 2: Target Html
if html is generated in this way we don't have to bother about managing the index values when rows are deleted from the middle of the collection and i will be following this approach in rest of this writing. First let me reproduce the part HtmlCollection Helper that interests me the most
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();

    // 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 3: Collection Helper
Line 3 and 4 in above listing decide which value will be used as index in current item of the list.  Line 3 comes into play when form is rendered at least once and is posted to the controller but we have to re-render the form because of invalid inputs. without this line we will miss track of validtion errors that are added to the modelstate on server side (plz see steve's comments in the code).  Line 4 creates an new GUID to be used as index if the form is being rendered for the first time.
Note: we don't need GUID as index for model binding to work. Any random string that is unique within page's context will server the purpose.
Ok, lets move to the work we have to do to render empty rows on the page using javascript (without ajax calls). First, we'll have a look at Index ActionResult method that accepts Get verbs and is responsible for rendering the form
public ActionResult Index()
{
     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 4: Controller's Action Method (HttpGet)
In this method we are just filling couple of entries in OrderLines property and Customer list is retrieved from the Session that was stored there earlier on. Index view in listing bleow is accepting the Order object and rendering the form
<%Html.EnableClientValidation(); %>
    <%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)
          { 
              Html.RenderPartial("OrderLine",x);
        } %>
    </div>
    <a href="#" id="add">Add Another</a>
    <input type="submit" value="save" />
<%Html.EndForm(); %>
Listing 5: Index View
In detail portion of the form we are just iterating over the OrderLines property and rendering it using OrderLines partial view. Here is the code of OrderLines partial view
<div class="editorRow">
    <% using(Html.BeginCollectionItem("OrderLines")) { %>
        Item: <%= Html.TextBoxFor(x => x.ProductID) %> 
        Quantity: <%= Html.TextBoxFor(x => x.Quantity, new { size = 4 }) %> 
        <a href="#" class="deleteRow">delete</a>
        
        <%= Html.ValidationMessageFor(x => x.ProductID) %>
        <%= Html.ValidationMessageFor(x => x.Quantity) %>
    <% } %>
</div>
Listing 6: OrderLine Partial View
Textboxes are rendered for each property of the OrderLine object with their associated vlidationmessage containers and a delete link that will be used to remove the entire row from the form if needed.
In listing 5 we can see an anchor tag towards the end of the page which renders empty row for entering another OrderLine on click event. Listing 7 shows the js code associated with the click event of the anchor tag.
$("#add").live('click', function () {
    var gui = GetRandomGUI();
    var htm = '<div class="editorRow">';
    htm += '<input type="hidden" autoComplete = "off" name = "OrderLines.index" value = "' + gui + '"/>';
    htm += 'Item: <input type = "text" name = "OrderLines[' + gui + '].ProductID" id = "xyzy" />';
    htm += ' Quantity: <input type = "text" name = "OrderLines[' + gui + '].Quantity" size="4" id = "xyzy1" /> <a href="#" class="deleteRow">delete</a></div>';
    $('#orderDetail').append(htm);
    return false;
});
Listing 7: js function for rendering new row
This function is doing nothing special. its just calling a function to get a random number in js and using it to  render html  similar to what was generated on server side. New order lines that are created using this function are bound to the model when we post it back to the controller thus avoiding the need to go to server just to render empty inputs.
Conclusion: we have just discussed the way of creating master-detail form where rows belonging to the detail portion can automatically be added and deleted on client side. This works great for simple scenarios but what if we want ProductID to be selected through a dropdown list. Currently this is not possible with current approach. Moreover, newly added rows to the form do not get client validated.
I will write about these issues in my next post. Meanwhile, you can download the source code from google

9 comments:

  1. adeel,

    Nice example for client side method. you could also as a part II look at doing the same excercise using the new inbuilt jquery templates (instead of the logic in the $("#add").live() method), this would make your example very 'cutting edge' :)

    all the best

    ReplyDelete
  2. In Part II of this post i have incorporated the jquery templates to allow editing through select lists. please have a look at http://zahidadeel.blogspot.com/2011/05/master-detail-form-in-aspnet-mvc-3-ii.html

    ReplyDelete
  3. Adeel,

    Apologies, never noticed that - great minds think alike :)

    Excellent stuff...

    jim

    ReplyDelete
  4. Hi
    I like your solution but just 2 notes:
    1: Is it possible to create client side template from the partial view (i.e. by rendering it to a string) and inject it into the client to prevent 2 time template authoring?
    2: How is it possible to provide MoveUp and MoveDown on detail rows, in addition to Remove. I have a case that order is important and user can decide on it!

    ReplyDelete
  5. Hi Adeel,
    Please create a new version of this blog using MVC 4 and razor.
    Thanks,
    Corix

    ReplyDelete
  6. This comment has been removed by a blog administrator.

    ReplyDelete
  7. Any chance getting a solution download?

    ReplyDelete
    Replies
    1. You can download the code from google now. please see the link at the end of the post

      Delete