Real-World Refactoring: Master/Detail Screens in ASP.NET

October 8th, 2018

Dynamic grids are hard to manage on the client-side, but in today's solution, I discuss how to use jQuery with MVC partials for a master/detail screen.

Master/Detail screens are always hard to code for a web UI.

They usually include a header record at the top with a list of records below related to the master record. Usually, the top record is more of a traditional form data (the master) where the detailed records are grid-based (the details).

The downside with these screens is the maintenance you need to implement on the client-side for a proper model binding to occur on postback.

Today, we'll cover a technique to make your master/detail screens a little easier to maintain.

First Thoughts: Client-side

For this sample project, we'll use some shopping cart data to explain each approach.

The first approach to creating our master/detail page is what most developers think in the land of JavaScript: perform everything client-side.

There are advantages/disadvantages to this approach.

Real-time Approach using JavaScript

Advantages

Disadvantages

I've implemented this approach too many times to count and I'm sure I'm not alone in this endeavor.

Here is one approach and the issues associated with it:

Again, you are doing everything client-side, but there are issues with this approach.

Issue 1

As I mentioned in Top Tips for Model Binding with an array or list, the rows in your WebGrid must meet a certain naming format if you are using form controls. Specifically, the path (or namespace) pertaining to your ViewModel when binding occurs.

Let's say you allow users to add or delete a row in their WebGrid. Keep in mind this is client-side. The name and id attribute for those rows won't be in numeric order when model binding occurs on submitting the record.

If you add a new row, the row won't have the last row number in your WebGrid. If deleted, you list/array in your WebGrid will lose it's order.

When you press submit to save changes, you will need to perform some additional model binding magic to make everything play nicely with your WebGrid rows.

Also, if your rows aren't in order or you are missing an element, it will crop the additional elements in the array.

As an example, if you had 10 rows in your grid and you delete row 3 and don't renumber your rows, on postback, the model binding will only bind row 1 and 2 to the ViewModel. The remaining rows in your grid (4 through 10) will be lost because of the number gap in the counting.

Issue 2

One other possible issue is adding a row on the client-side with a "template" of what the row looks like by creating a hard-coded string.

I'm pretty sure we've all seen this in some client-side JavaScript code.

var template = "<tr><td>"+product+"</td><td>"+qty+"</td><td>"+cost+"</td></tr>";

This complicates your code even more.

There are so many problems with these two issues, it could become a maintenance nightmare.

On Second Thought...

I understand developers find sanctuary in code, but when it comes to passing code to new developers (or devs new to the system), wouldn't it be easier to make modifications to HTML instead of code?

When I took a step back and looked at this way of writing applications, I thought why not write declarative code (HTML) instead of program code (JavaScript/C#).

With ASP.NET MVC, a partial view would make more sense when creating our detail WebGrid, but how do we update it when adding a new entry?

In controllers, partial views act just like any other view, but return partial HTML.

All you need to do is perform a "mini-postback" to retrieve your updated WebGrid.

Time To Refactor!

For our example, we have an order screen with a customer, Smitty Werbenjagermanjensen, and the products in his shopping cart.

We can add additional products to his shopping cart by clicking the plus symbol at the top of the grid.

Let's set up our HTML for this cart.

@model MasterDetailExample.ViewModel.CartViewModel
@{
    ViewData["Title"] = "Home Page";
}

<div class="row">     <div class="col-md-5">         @using (Html.BeginForm("Index", "Home", FormMethod.Post, new { @class = "form-horizontal" }))         {             <div class="form-group">                 @Html.LabelFor(e => e.Customer.Name, "Customer")                 @Html.TextBoxFor(e => e.Customer.Name, new { @class = "form-control" })                 <a href="https://www.youtube.com/watch?v=RS7mk-UtdjQ" target="_blank">Who?</a>             </div>             <div class="form-group">                 <label for="exampleFormControlInput1">Email address</label>                 <input type="email" class="form-control" id="exampleFormControlInput1" placeholder="name@example.com">             </div>         }     </div>
</
div>
@
Html.Partial("_childGrid", Model)

<
div class="modal fade product-dialog" tabindex="-1" role="dialog" aria-labelledby="gridSystemModalLabel">     <div class="modal-dialog" role="document">         <div class="modal-content">             <div class="modal-header">                 <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>                 <h4 class="modal-title" id="gridSystemModalLabel">Select Your Products</h4>             </div>             <div class="modal-body">                 <!-- "Loading" spinner displayed while we get the product list. -->                 <div class="text-center"><i class="fa fa-fw fa-spin fa-spinner"></i> Loading...</div>             </div>             <div class="modal-footer">                 <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>                 <button type="button" class="btn btn-primary select-button">Select</button>             </div>         </div><!-- /.modal-content -->     </div><!-- /.modal-dialog --> </div><!-- /.modal -->

As you can see, it's not much HTML.

We need the partial "child-grid" because of it's component nature. This is where the details of your master/detail screen should reside. It's what makes this technique work properly.

The "product-dialog" HTML at the bottom is what we'll display when adding new products to Smitty's shopping cart.

Now we need some JavaScript for our postback and dialog box. We'll also be using Bootstrap for our dialog box.

Our JavaScript requires some modular thinking and needs some functions defined up front before we can proceed.

wwwroot\js\site.js

function productDialog() { return $(".product-dialog"); }
function productGrid() { return $("table", productDialog()); }

function
 orderGrid() { return $(".order-grid"); } function selectButton() { return $(".select-button", productDialog()); } function selectedProducts() { return $("input:checked", productDialog()); }
function
 displayProductGrid() {
    $.post("/Home/ProductGrid", {})         .done(function(html) {             var body = $(".modal-body", productDialog());             $(body).replaceWith(html);         }); }
// Setup the product dialog
productDialog().on("show.bs.modal", null, null, displayProductGrid);

At the top of the script, we define the following:

To display the product dialog box, the show.bs.modal event is necessary so we can prepare the dialog box when it displays the product grid.

To dig deeper into this, when the user presses the plus button, Bootstrap uses the data-target in the HTML to "open" the dialog.

<button type="button" data-toggle="modal" data-target=".product-dialog" class="btn btn-default"  title="Add Product"><i class="fa fa-fw fa-plus"></i></button>

However, we want some additional functionality with this product dialog. When we "show" the dialog, we want to display a spinner and postback to the HomeController and return a product grid back to us.

ProductGrid partial in Controllers\HomeController.cs

// For our Product Dialog
[HttpPost]
public ActionResult ProductGrid()
{
    return PartialView("_productGrid", new ProductViewModel
    {
        // create our DTOs
        Products = _productRepository
            .GetAll()
            .Select(t => new Product {ProductId = t.ProductId, Title = t.Title, Price = t.Price})
            .ToList()
    });
}

Once we receive the PartialResult from the postback, our JavaScript replaces the HTML in our product dialog making it easy for our users to select products.

Mini-Postback with JavaScript

Once the user selects their products, they click the Select button to add them to their cart.

Again, we need to add some additional JavaScript to finish the task.

selectButton().on("click",
    function() {
            // Grab the existing product ids
            var selectedIds = $("td:nth-child(1)", orderGrid())
            .map((index, elem) => {
                var row = $(elem).closest("tr");
                return { ProductId: $("td:nth-child(1) :input", row).val() };
            });

            // get the list of selected checkboxes.             var dialogProducts = selectedProducts()             .map((index, inputCtrl) => {                 var value = $(inputCtrl).val();                     // For each row that's "mapped", return an object that                     //  describes each <td> in the row.                     return {                     ProductId: value                 };             })             .get();
            
// Add the selected ids to the map.             selectedIds.map((index, value) => {             dialogProducts.push(value);         });
        var postData = {             Cart: {                 CustomerId: 1, // Strictly for demo purposes.                     CartId: 1,     // Ditto                     Items: Array.from(dialogProducts)             }         };
            
// Hide the dialog.             productDialog().modal("hide");
        $(".loading-status").removeClass("hidden").show();
        $.post("/Home/OrderGrid", postData).done(function(html) {
            $(".loading-status").hide();
            orderGrid().replaceWith(html);         });
    });

It may seem like a lot, but as we break it down, it becomes a simple postback with a HTML PartialResult in the end.

When the user clicks the Select button, we create an array of existing product id's from the order grid (the initial grid on the screen) and combine them with the selected products in the Product Dialog's grid.

The postback data is constructed to look like a ViewModel when posted back to the Partial in the Controller.

This is important. There are two reasons for this:

  1. When performing the POST from JavaScript, the data must be in the same structure as the model the controller is receiving. Not all properties are required, but you should include the most important ones to save data and reload if necessary on the return trip.
  2. You will be passing a model into a PartialView and then back to the JavaScript so you need all of the ViewModel data required to return a fully-populated PartialView. In this case, the CartViewModel. IMPORTANT: You won't see the error because of the JavaScript postback call, but you can place a breakpoint on the PartialView in your controller to see if it's functioning properly.
[HttpPost]
public PartialViewResult OrderGrid(Cart cart)
{
    var model = new CartViewModel
    {
        Customer = null,
        Cart = null
    };
 
    // Get existing items.
    var cartList = _cartItemRepository
        .GetCartItems(cart.CartId);
 
    // Add the Items NOT in the list.
    var newItems = cart.Items
        .Where(item => !cartList.Select(p=> p.ProductId).Contains(item.ProductId))
        .ToList();
    cartList.AddRange(newItems);
 
    // Save the new added items.
    foreach (var item in newItems)
    {
        _cartItemRepository.Add(new CartItem
        {
            ProductId = item.ProductId,
            Quantity = 1,
            CartId = 1
        });
    }
    _cartItemRepository.SaveChanges();
    
    // Use the new cartList to load the products and defaults
    model.CartItems = cartList
        .Select(e =>
        {
            e.Product = _productRepository.First(product => product.ProductId == e.ProductId);
            e.Quantity = e.Quantity == 0 ? 1 : e.Quantity;
            return e;
        })
        .ToList();
 
    return PartialView("_childGrid", model);
}

A couple things to keep in mind with this code:

When everything is done saving their new cart items, we return the PartialView back to the JavaScript, hide the Product Dialog, and replace the OrderGrid with the HTML from the PartialView.

Where does this make sense?

While this is an easier way of manipulating rows with tabular data, it may not work across all applications.

The first (and best) scenario for this technique to work is if the detail portion of your screen is simply selecting from a lookup table (like a product list) and adding additional data (like the quantity ordered) because it gives the user an easy way to pick items.

Another scenario is entering new data in a row. When adding items to a grid without selecting from a list, have a "Save/Cancel" action at the end of the grid. When the user is finished entering data on their row, gather their data and when they press Save, perform the "mini-post" to retrieve the updated WebGrid.

Finally, it may seem like a hassle, but having a delete button at the end of a row and clicking it to perform a "Delete" mini-postback to a PartialView Controller would make more sense than clicking the Delete button and using the DOM to remove the row. This approach would mean loss of data (see Issue 1 from above).

Sidenote: If you want to see an example of adding rows to a WebGrid using this technique, check out my WebGrid series.

Conclusion

While this technique has a lot of moving parts, it makes developing master/detail screens easier to implement because you are simply modifying the HTML, minimal JavaScript, and mini-postbacks in the controller.

Your detail screens become increasingly simple to maintain with no JavaScript string concatenation in sight.

Source is located at GitHub

How do you implement master/detail screens? Do you create large strings for your grids? Post your comments below and let's discuss.