Real-World Refactoring: Master/Detail Screens in ASP.NET
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
- While it is minimal, the processing is offloaded to the client
- Since we are on the client, everything is faster through JavaScript
Disadvantages
- There are a number of JavaScript workarounds to implement like client-side code to rework the numbering for the controls (as mentioned in Top Tips for Model Binding).
- We must create a way to chat with the server when we are finished editing our WebGrid. This could be a SignalR or Web API call to give the user a streamlined experience.
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:
- You create a web grid and serve it up through ASP.NET.
- If you want to add a row, you create a
<tfoot>
hidden "template" row in my WebGrid and use JavaScript (or jQuery) to copy that row with defaults into the<tbody>
. - if you want to edit a row, click Edit and replace your labels with controls (as shown in my WebGrid series).
- If you want to delete row, click Delete to remove the row.
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.
- This is another instance where a separation of concerns (or the Reese's Cup Dilemma as I call it) enters the picture and needs resolved ("You got your HTML in my JavaScript!").
- What if we need to add a new column? Where do we add it? In-between the first column? Second? Oh, and don't forget your heading for the new column...oh, and update your colspan columns...ARGHHH! Looking at this hard-coded template, it's hard to determine where to insert it and the human factor could "fat-finger" the tag.
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">×</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:
- The Product dialog
- Product grid in the dialog
- The order grid (cart)
- The Select button in the product dialog
- The selected products (by checkbox) in the product dialog
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:
- 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.
- 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:
- Even if the user selects the same product already in their cart, it won't add it again. Only new products will be added.
- For every Product in their cart, I load the Product object based on the ProductId using the ProductRepository.
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.