In part 2 of this series on the Telerik MVC Grid control, we discussed the back-end code for supporting the master level of our grid. Here’s a list of tasks we need to take care of for the detail grid:

  1. Implementing the detail view within the grid component definition.
  2. Implementing additional JavaScript functions to handle the detail grid events.
  3. Implementing a View Model to support the detail grid.
  4. Implementing several controller actions to support grid CRUD functionality.
  5. Implementing helper methods.

If I don’t list all the code below (mainly, the controller actions), you can get all of it by downloading the full example, or keep up with any changes on GitHub.

Extending the Grid Declaration in the View

Realize that the detail grid generate detail grids (plural) at runtime, for each expanded master row. The way the detail level of a grid is handled, it’s pretty much another sophisticated “client template” hanging off the master row, built from another grid. That’s why the whole definition is wrapped in a ClientTemplate option:

.DetailView(details => details
    .ClientTemplate(Html.Telerik()
        .Grid<OrderViewModel>()
            .Name("Orders_<#= CustomerId #>")

Note the very explicit name we’re giving to each detail grid instance (via the Name option), making use of the master row’s CustomerId value. You’ll see its importance later on.

We’ll specify the detail columns next, starting with a column that contains our edit and delete buttons. Notice that we made sure only the DatePlaced column is filterable. In order to allow filtering at all, you must first apply this option to the grid (shown later), and then explicitly turn off filtering for the columns you don’t want it for. We’re also specifying a format for the DatePlaced column, and overriding some default column titles:

.Columns(columns =>
{
    columns.Command(commands =>
    {
        commands.Edit().ButtonType(GridButtonType.Image);
        commands.Delete().ButtonType(GridButtonType.Image);
    }).Width(80);

    columns.Bound(o => o.DatePlaced)
        .Format("{0:MM/dd/yyyy}");
    columns.Bound(o => o.OrderSubtotal)
        .Title("Subtotal")
        .Filterable(false);
    columns.Bound(o => o.OrderTax)
        .Title("Tax")
        .Filterable(false);
    columns.Bound(o => o.OrderTotal)
        .Title("Total")
        .Filterable(false);
    columns.Bound(o => o.OrderChannelName)
        .Title("Channel")
        .Filterable(false);
})

Similar to what we did in the master grid for customers, we’re going to want to support inserting new rows for orders at the detail level:

.ToolBar(commands => commands.Insert()
    .ButtonType(GridButtonType.ImageAndText)
        .ImageHtmlAttributes(new { style = "margin-left:0;" }))

As in the master grid, we need to specify the DataBinding options; declaring the AJAX actions that the grid will call when performing CRUD operations on the detail rows. We’re also passing in customerId, since that’s needed for each method.

  • In the Select method, the customerId is used for deciding which customer to load the orders for.
  • In the Insert method, the customerId is used for deciding which customer to add a new order for.
  • In the Update method, the order is an Entity Framework navigation property of a customer, so customerId is used for fetching the customer.
  • In the Delete method, the order is an Entity Framework navigation property of a customer, so customerId is used for fetching the customer.
.DataBinding(dataBinding => dataBinding.Ajax()
    .Select("AjaxOrdersForCustomerHierarchy", "Home", new { customerId = "<#= CustomerId #>" })
    .Insert("AjaxAddOrder", "Home", new { customerId = "<#= CustomerId #>" })
    .Update("AjaxSaveOrder", "Home", new { customerId = "<#= CustomerId #>" })
    .Delete("AjaxDeleteOrder", "Home", new { customerId = "<#= CustomerId #>" }))

Now, since orderId uniquely identifies an order, we need to specify that as a DataKeys parameter used by both the Update and Delete methods:

.DataKeys(keys => keys
    .Add(o => o.OrderId)
        .RouteKey("OrderId"))

We’ll wire up our grid events next (discussed later):

.ClientEvents(events => events
    .OnError("onError")
    .OnDataBound("onDataBoundOrders")
    .OnEdit("onEditOrders"))

We’ll finish off our grid definition by making it pageable, with 15 rows per page, support keyboard navigation, specify that the detail grid is editable using a popup window, and making it sortable and filterable (keeping in mind that we shut off most filtering at the column level). Note that since this is actually a ClientTemplate, the whole detail grid needs to converted to an HTML string. Finally, we need to tack on a Render command, otherwise the grid won’t get displayed at all. For some reason, some examples on Telerik’s site omit this.

        .Pageable(pagerAction => pagerAction.PageSize(15))
        .KeyboardNavigation()
        .Editable(editing => editing.Mode(GridEditMode.PopUp))
        .Sortable()
        .Filterable()
        .ToHtmlString()
    ))
.Render();

Slight Detour — Fixing a Validation Bug in the Master Grid

Before we get to the supporting detail grid code, I want to revisit an issue I alluded to in part 2. Again, here is the CustomerViewModel:

public class CustomerViewModel
{
    [ScaffoldColumn(false)]
    public int CustomerId { get; set; }

    [Required]
    [DisplayName("Account Number")]
    public string AccountNumber { get; set; }

    [Required]
    [Remote("CheckDuplicateCustomerName", 
    		"Home", 
    		AdditionalFields = "CustomerId, FirstName, MiddleName", 
    		ErrorMessage = "This name has already been used for a customer. Please choose another name.")]
    [DisplayName("Last Name")]
    public string LastName { get; set; }

    [Required]
    [Remote("CheckDuplicateCustomerName", 
    		"Home", 
    		AdditionalFields = "CustomerId, LastName, MiddleName", 
		ErrorMessage = "This name has already been used for a customer. Please choose another name.")]
    [DisplayName("First Name")]
    public string FirstName { get; set; }

    [DisplayName("Middle Name")]
    [Remote("CheckDuplicateCustomerName", 
    		"Home", 
    		AdditionalFields = "CustomerId, LastName, FirstName", 
    		ErrorMessage = "This name has already been used for a customer. Please choose another name.")]
    public string MiddleName { get; set; }

    [DisplayName("Middle Initial")]
    public string MiddleInitial { get; set; }
}

If you recall, I mentioned that any fields we mark with the [ScaffoldColumn(false)] attribute will not be displayed in the grid nor on the pop-up edit dialog used when we edit or add a customer. But there’s an additional side effect to us using this on the CustomerId field — our remote validation CheckDuplicateCustomerName method always returns a duplicate error, even if we’re editing an existing record. We’re passing CustomerId as an AdditionalFields field because we’re using it to allow us to ignore a duplicate error if the existing record is the current customer record. But, as it turns out, since we’re using [ScaffoldColumn(false)], it also hides CustomerId from the AdditionalFields parameter. Null is being passed into the validation method. So we have to do two things:

  1. Remove [ScaffoldColumn(false)] from CustomerId in the view model. Unfortunately, this causes CustomerId to be editable in the pop-up edit and add dialogs. So, we also need to…
  2. …add the following line to the onEditCustomers JavaScript function (the OnEdit master grid event handler):
$(e.form).find("#CustomerId").closest(".editor-field").prev().andSelf().hide();

Now we’ve forced CustomerId off of the pop-up, yet we can continue to use it in our remote validation method.

Detail Grid Events

Now that we solved that issue, here are the event handlers for the detail grid. I’m also including the replaceDeleteConfirmation helper function that’s shared with the master grid. The OnError event handler is also reproduced here, since it’s shared by both the master and detail grids as well. We’re using onError to display serious issues (normally caught in the catch blocks in controller actions), that we’re stuffing in the response header. I’d normally handle these more gracefully, but this is fine for a “quick & dirty”:

function onExpandCustomer() {
    $(".t-detail-cell").css({
        "padding-left": "80px",
        "padding-bottom": "30px"
    });
}

function onDataBoundOrders() {
    $(this).find(".t-grid-add").first().text("Add new Order").prepend("<span class='t-icon t-add'>");
    replaceDeleteConfirmation(this, "Order");
}

function onEditOrders(e) {
    var popup = $("#" + e.currentTarget.id + "PopUp");
    var popupDataWin = popup.data("tWindow");

    popup.css({ "left": "700px", "top": "400px" });
    //popupDataWin.center(); // Use if you'd rather center the dialog instead of explicitly position it.

    if (e.mode == "insert")
        popupDataWin.title("Add new Order");
    else
        popupDataWin.title("Edit Order");

    var url = '@Url.Action("GetOrderChannels", "Home")';
    var orderChannel = $('#OrderChannelId').data('tDropDownList');
    orderChannel.loader.showBusy();

    $.get(url, function (data) {
        orderChannel.dataBind(data);
        orderChannel.loader.hideBusy();
        orderChannel.select(function (dataItem) {
            if (e.mode == 'edit') {
                return dataItem.Value == e.dataItem['OrderChannelId'];
            } else {
                return dataItem.Value == 1; // Default to Phone.
            }
        });
    });
}

function replaceDeleteConfirmation(item, itemType) {
    var grid = $(item).data('tGrid');

    $(item).find('.t-grid-delete').click(function () {
        grid.localization.deleteConfirmation = "Are you sure you want to delete this " + itemType + "?";
    });
}

Note that dynamically changing the “Add” button text has to be done differently for the detail grid than we did for the master. If you recall, we were able to change the button text for the master grid directly in the $(document).ready function. That’s because the button only exists once in the entire page. But since each master row requires its own “Add” button for adding orders, we have to change the button text as we databind the order rows for each customer, in the onDataBoundOrders event handler for OnDataBound. We’re also dynamically changing the “Delete” confirmation text in this function.

The other interesting function is the event handler for OnEdit, onEditOrders. We’re explicitly positioning the pop up dialog here, by first grabbing a reference to the pop up. You’l notice that we’re referencing the event parameter currentTarget.id. This is a reason why it’s important to uniquely name each detail grid, as mentioned earlier.

var popup = $("#" + e.currentTarget.id + "PopUp");

Once we have a reference to the pop up dialog, we need to grab a reference to its window (yes, although it appears redundant, the window is just a portion of the entire dialog).

var popupDataWin = popup.data("tWindow");

Now that we have a reference to each, we can dynamically position the pop up, either explicitly, or centering it by calling the undocumented center function of its window:

popup.css({ "left": "700px", "top": "400px" });
//popupDataWin.center(); // Use if you'd rather center the dialog instead of explicitly position it.

We’re also dynamically changing the pop up dialog’s title bar, depending upon the edit mode:

if (e.mode == "insert")
    popupDataWin.title("Add new Order");
else
    popupDataWin.title("Edit Order");

Using an Editor Template

Next, since we’re using a drop down list for the order channel, we’re dynamically populating the list. First, we build out the action we’re going to call via AJAX. Next, we create a reference to the drop down list. The next line of code displays an animated progress indicator for the AJAX call, which follows. Once the AJAX call completes, we bind the result to the list, get rid of the progress indicator, and initialize the currently selected order channel in the list:

var url = '@Url.Action("GetOrderChannels", "Home")';
var orderChannel = $('#OrderChannelId').data('tDropDownList');
orderChannel.loader.showBusy();

$.get(url, function (data) {
    orderChannel.dataBind(data);
    orderChannel.loader.hideBusy();
    orderChannel.select(function (dataItem) {
        if (e.mode == 'edit') {
            return dataItem.Value == e.dataItem['OrderChannelId'];
        } else {
            return dataItem.Value == 1; // Default to Phone.
        }
    });
});

The above loading of the order channel drop down implies the use of an editor template. We told the view to use an editor template for the Channel property by applying the [UIHint(“OrderChannel”)] attribute to it. Here’s the template code we’re using when displaying the editor pop up (which must be named OrderChannel.cshtml in order for the view and UIHint to find it):

@(Html.Telerik().DropDownList()
        .Name("OrderChannelId")
        .HtmlAttributes(new { style = "width:400px" })
)
<p />

We happen to be making use of Telerik’s drop down list in this same MVC extension library. If you have experience with this control, you may be wondering why we didn’t make use of the DataBinding method to load the channels into the list. Unfortunately, by the time the data is loaded, it’s too late to initialize the selected item. Therefore, we’re explicitly making the AJAX call within the onEditOrders event handler.

Here’s the order view model. Also note that I’m not validating DatePlaced, aside from making it a required field. I leave that as an exercise for you:

public class OrderViewModel
{
    [ScaffoldColumn(false)]
    public int OrderId { get; set; }

    [ScaffoldColumn(false)]
    public int CustomerId { get; set; }

    [Required]
    [DisplayName("Order Placed")]
    [DataType(DataType.Date)]
    public DateTime? DatePlaced { get; set; }

    [Required]
    [DisplayName("Subtotal")]
    public decimal? OrderSubtotal { get; set; }

    [Required]
    [DisplayName("Tax")]
    public decimal? OrderTax { get; set; }

    [Required]
    [DisplayName("Total")]
    public decimal? OrderTotal { get; set; }

    [ScaffoldColumn(false)]
    public int OrderChannelId { get; set; }

    [DisplayName("Channel")]
    [UIHint("OrderChannel")]
    public string OrderChannelName { get; set; }
}

Some Final Cosmetic Touches

There is one more event handler I added to show how you can dynamically position the detail grid. First, I added a declaration for an additional master grid event handler, OnDetailViewExpand. You may have seen a previous article I wrote on how to take advantage of this event in other ways:

.OnDetailViewExpand("onExpandCustomer")

This event handler simply adjusts the padding of the detail cell (which is actually the detail order grid for a customer):

function onExpandCustomer() {
    $(".t-detail-cell").css({
        "padding-left": "80px",
        "padding-bottom": "30px"
    });
}

Sample Application Download

Well, that completes my three part series. Again, you can download a full sample application, or keep up with possible changes on GitHub.

That’s the basics for creating a master / detail Telerik MVC grid, with a few extras thrown in to show you how to work around some idiosyncrasies. You can pretty much add additional detail levels in the same manner. Like I’ve mentioned, this is not the only way to go about it, but it has worked for me. If you have other ideas, please let me know.