January 2010

Volume 25 Number 01

Cutting Edge - Master-Detail Views with the ASP.NET Ajax Library

By Dino Esposito | January 2010

Download the Code Sample

In the past few columns, I explored a number of data-related features of the upcoming ASP.NET Ajax Library beta, which is now part of the CodePlex Foundation (CodePlex.org). The journey began last September with a look at some of the new features in Web Forms and continued with some other stops in the territory of ASP.NET AJAX. In particular, I touched on client-side templates and data binding, conditional rendering and live binding.

When you think of data-driven Web pages, most of the time what you really have in mind is a master-detail view of some cross-related data. Master-detail views are ideal for rendering one-to-many relationships, and such relationships are so common in the real world that a Web platform that doesn’t provide an effective set of tools for that functionality is inadequate.

ASP.NET Web Forms has always provided strong support for data binding and a powerful set of data-source and data-bound server controls. In Web Forms, server controls do a great job of rendering hierarchies of data using nearly any possible combination of grids, lists, and drop-down boxes and supporting multiple levels of nesting.

The drawback of the views you get out of Web Forms server controls is not the effectiveness of the rendering, but the static condition.

Users who navigate within a master-detail view typically switch among master records and drill down into the details of the records that are of interest. This interaction is the essence of a master-detail view.

In a classic Web Forms scenario, each drill-down operation may trigger a postback. Many postbacks—and subsequent page reloads— are not what makes users happy these days.

An alternative exists, but it’s not free of issues either. It basically consists of preloading any possible data the user might want to see. The data is then downloaded with the standard page and kept hidden using CSS styles. At the same time, any handler of user actions is rewritten to unveil hidden content rather than triggering a postback. As you can see, this is not an easy way to go.

The ASP.NET Ajax Library, in collaboration with jQuery, offers a much more powerful toolset and makes it possible to write smooth and effective master-detail views that post back asynchronously and only when strictly needed.

The Hidden Power of the DataView Control

The DataView client control is the fundamental tool for building master-detail views in the ASP.NET Ajax Library. Combined with the sys-attach feature of the ASP.NET Ajax Library and live binding, the control offers an unprecedented level of flexibility as far as functionality and layout are concerned. In particular, the DataView control can serve to generate both the master and detail views.

To arrange a master-detail view with the ASP.NET Ajax Library,  you need to follow three basic steps. First, create the markup for the master and detail views. Second, attach an instance of the DataView control to each view as a behavior. Finally, use live binding (or just plain data-binding expressions) to populate with fresh data the visual layout of the detail view. Note that all the templates, binding and component creation can be done both declaratively and imperatively in code. Let’s start with a simple example that serves the purpose of making you familiar with the approach and the tools available.

Building a Plain Master-Detail View

Here’s a simple layout for the master-detail view. It basically consists of two DIV tags. The master DIV contains an unordered list of items; the detail DIV, instead, contains a child table:

<div id="masterView">
  <ul class="sys-template">
  ...
  </ul>
</div>
<div id="detailView">
  <table>
    <tr>
      <td> ... </td>
      <td> ... </td>
    </tr>
    ... 
  </table>
</div>

More often than not, the data to show in an AJAX page is retrieved from the Web server using a Web service, a Windows Communication Foundation (WCF) service and, of course, any services that can return JavaScript Object Notation (JSON). The data is commonly sent over the wire as a JSON stream. You can choose to manage the request to the data source yourself and use your own AJAX framework of choice such as Microsoft AJAX, jQuery or raw XmlHttpRequest calls.

The DataView control, however, also offers a sort of all-inclusive service. You point it to a remote data provider such as a Web service, indicate the operation to invoke and list the parameters. Any fetched data is automatically ready for display anywhere in the HTML page where the DataView is attached. Figure 1 shows the markup code that’s necessary for the master view.

Figure 1 The Master View

<div>
  <ul class="sys-template" sys:attach="dataview"
    id="masterView"
    dataview:autofetch="true"
    dataview:dataprovider="/ajax40/mydataservice.asmx"
    dataview:fetchoperation="LookupCustomers"
    dataview:fetchparameters="{{ {query: ‘A’} }}"
    dataview:selecteditemclass="selecteditem"             
    dataview:initialselectedindex="0">
    <li>
      <span sys:command="Select">
      <span>{binding CompanyName}</span>
      ,&nbsp;&nbsp;
      <span>{binding Country}</span>
      </span>        
    </li>
  </ul>
</div>

The sys:attach attribute attaches a new instance of the DataView control to the UL tag. The code snippet doesn’t show that, but it is necessary that you declare the “dataview” name and associate it to the JavaScript object that represents the behavior to attach. Typically, you declare the JavaScript objects you intend to use in the BODY tag:

<body xmlns:sys="javascript:Sys" 
      xmlns:dataview="javascript:Sys.UI.DataView">
  ...
</body>

Properties you set declaratively on the automatically created instance of the DataView define the remote call that will fetch data. In the example, I call the method LookupCustomers on MyDataService.asmx, passing a string parameter with the value of A.

In addition, the DataView control can accept a few properties specific to the master-detail scenario. The selectedItemClass property indicates the CSS style to be used for the elements in the item template that’s currently marked as selected. The initialSelectedIndex property refers to the item in the view that must be marked as selected when the DataView first renders out its data.

The body of the UL tag contains the item template and it binds to fetched data via the ASP.NET Ajax Library live-binding syntax:

<span>{binding CompanyName}</span>

You could actually use a simpler data-binding expression here:

<span>{{ CompanyName }}</span>

A simple binding expression is enough if you have data to display that’s read-only. If your code modifies displayed data and you need to see changes in real time, then you should opt for live binding. Figure 2 shows the detail view.

Figure 2 The Detail View

<div class="sys-template" sys:attach="dataview"
     dataview:data="{binding selectedData, source=$masterView}">
  <table>
    <tr>
       <td><b>Contact</b></td>
       <td><input id="contact" type="text" 
                  sys:value="{{ContactName}}"/></td>
    </tr>
    <tr>
       <td><b>Address</b></td>
       <td><input id="address" type="text" 
                  sys:value="{binding Street}"/></td>
    </tr>
    <tr>
       <td><b>City</b></td>
       <td><input id="city" type="text" 
                  sys:value="{binding City}"/></td>
    </tr>
    <tr>
       <td><b>Phone</b></td>
       <td><input id="phone" type="text" 
                  sys:value="{binding Phone}"/></td>      
    </tr>
  </table>
</div>

You may have noticed that the value attribute is namespaced. Starting with ASP.NET Ajax Library beta, all attributes that contain {{expression}} or {binding ...} must include the Sys prefix to not be ignored.

The most interesting part of the code in Figure 2 is the expression assigned to the data property of the DataView:

{binding selectedData, source=$masterView}

The syntax indicates that the values for the elements in the view will come from the object named masterView. The property that physically provides data to the detail view is selectedData. Needless to say, the object named masterView will have a property named selectedData, otherwise an error occurs. Figure 3 shows the sample page in action.

Figure 3 A Master-Detail View Based on the DataView Control

image: A Master-Detail View Based on the DataView Control

More Control over the Fetch Process

In the first example, I configured the master DataView to support auto-fetching. This means the DataView object is responsible for triggering the specified fetch operation right after initialization.

This is definitely a good choice for many applications, but some scenarios require more control over the fetch process. In particular, it’s often required that you start the fetch following a user action. The next example will rework the previous code to add a button bar where you choose the first initial of the name of the customers you want to see listed. Figure 4 shows the final screen.

Figure 4 Starting Data Binding On-Demand

image: Starting Data Binding On-Demand

There are many ways to generate a bunch of similar DOM elements on the fly. You can go with the raw DOM API or perhaps resort to the more abstract programming interface of the Microsoft AJAX library. Or you can opt for jQuery. Here’s a code snippet using the services of the jQuery library to generate a button for each possible initial of the customer name:

 

for(var i=0; i<26; i++) {
  var btn = $(‘<input type="button" 
    onclick="filterQuery(this)" />’);
  var text = String.fromCharCode(‘A’.charCodeAt(0) + i);
  btn.attr("value", text).appendTo("#menuBar").show();
}

The $ function returns a DOM element resulting from the specified markup string. You then set its value property to an uppercase letter of the alphabet and append the DOM object to a placeholder down the page. The code runs from within the pageLoad function.

Each input button added in this way is bound to the same click handler. The click handler takes a reference to the DOM object and updates the master view. Here’s the source code of the click handler:

function filterQuery(button) {
  // Enables live binding on the internal object that contains 
  // the current filter
  Sys.Observer.setValue(currentQuery, "Selection", button.value);

  // Update the master view
  fillMasterView(currentQuery);
}

Note that, in this example, you need to track the current filter applied to select only a subset of customers. To avoid pain with the binding, and also to leave room for future enhancement, I opted for a custom object with a single property. Global to the page, the object is initialized as follows:

var currentQuery = { Selection: "A" };

The current selection is also displayed through a data-bound label in the page. Note the use of the namespace with the innerHTML attribute of the SPAN tag:

<h3>
  Selected customers: 
  <span sys:innerhtml=
    "{binding Selection, source={{currentQuery}}}">
  </span>
</h3>

Next, when the user clicks a button to change the selection, you update the Selection property of the object. Note that the most straightforward code shown here won’t really work:

currentQuery.Selection = button.value;

You must enter observable changes only via the setValue method. This is demonstrated in the code snippet shown earlier using the Sys.Observer.setValue method.

What about the code that fills up the master view programmatically?

To start off, you need to get hold of the instance of the DataView control that operates behind the master view. As mentioned, the sys-key attribute is only used internally for data-binding purposes. To retrieve a component like DataView you need to access the list of registered application components as exposed by the $find method of the Microsoft AJAX library. You use the ID of the root tag as a selector:

var dataViewInstance = Sys.get("$masterView");

In my example, the masterDataView is intended to be the ID of the UL tag marked with the sys-template attribute that renders the master view:

<div>
  <ul class="sys-template" 
    sys:attach="dataview" 
    ID="masterDataView"
    ...>
  ...
  </ul>
</div>

Once you have the DataView instance that populates the master view, adjust the fetch parameters and tell the DataView to get fresher data. Here’s the code you need:

function fillMasterView(query) {
  // Retrieves the DataView object being used
  var dataViewInstance = Sys.get("$masterDataView");

  // DataView fetches fresh data to reflect current selection 
  var filterString = query.Selection;
  dataViewInstance.set_fetchParameters({ query: filterString });
  dataViewInstance.fetchData();
}

The fetchData method on the DataView control uses the currently set provider, operation and parameters to place a remote call and refresh the view with downloaded data.

Adding Caching Capabilities

Let’s consider the actual behavior of the page shown in Figure 4. A remote (and asynchronous) request is placed every time you click on a button to select a subset of customers. Subsequent selections to see details of a particular customer don’t require a roundtrip as long as the data to display is already available for binding.

That mostly depends on what the fetch operation really returns. In my example, the LookupCustomers method is designed as follows:

public IList<Customer> LookupCustomers(string query);

The properties of the Customer class form the set of data you have ready for binding at no extra cost. If you want to display, say, the list of orders for each customer, then you can do that without placing an additional request, but only if the orders are packed with the Customer class and are sent over the wire with the first request.

In a future article, I plan to tackle lazy-loading scenarios and mix that with the AJAX-specific Predictive Fetch pattern. For now, though, let’s simply assume that the data you get out of the DataView fetch operation is enough for you to craft an effective user interface.

With the solution arranged so far, if you request customers whose name begins with “A” twice or more, then distinct requests are placed to get you basically the same set of data. A possible way to improve this aspect is adding some client-side caching capabilities. The jQuery library comes in handy as it provides an excellent local, in-memory cache exposed via its core functions.

As one alternative, you can make it so the response is cached by the browser. Then, even though you’re issuing another XmlHttpRequest for the data, the browser doesn’t really make a new request for it.

The jQuery Cache API

Seen from the outside, the jQuery cache is nothing more than an initially empty array that gets populated with keys and values. You can work with the jQuery cache API at two levels: The low-level API is represented by the cache array property; the higher-level data function provides a bit more abstraction, which saves you from having to check the array against nullness.

Here’s the code to create an entry in the cache and store some data into it:

// Initializes the named cache if it doesn’t exist 
if (!jQuery.cache["YourNamedCache"])
   jQuery.cache["YourNamedCache"] = {};

// Stores a key/value pair into the named cache
jQuery.cache["YourNamedCache"][key] = value;

More precisely, the jQuery cache is organized as a set of named caches grouped under the global cache array. Reading a value from the cache requires the following code:

var cachedInfo;
if (jQuery.cache["YourNamedCache"])
  cachedInfo = jQuery.cache["YourNamedCache"][key];

In this way, you can gain total control over the organization of data within the cache. Each named cache is created on a strict on-demand basis and no duplication of data ever occurs.

The data method offers a slightly richer programming interface that encapsulates some of the preliminary checks about the existence of a given named cache. Moreover, the data method allows you to attach the named cache to one or more DOM elements. The data method offers a basic get/put interface for you to read and write data items to the cache.

Here’s a sample command to assign a given value to a key created in the cache associated with a given DOM element:

$('#elemID').data(key, value);

The named cache is created on-demand when the code attempts to access or manipulate the content. The library creates a named cache for the specified element and decides about its name. The name is a progressive number stored as an expando attribute to the DOM element.

The data method works on the content of a wrapped set. If you define a wrapped set that returns multiple nodes, then each element gets its own named cache that contains the same data. However, no real data duplication occurs because the same content is referenced—not cloned—across the various cache items.

Consider the following example:

$('div').data('A', fetchedData);

The code attempts to store an entry with a key of “A” in a named cache for each DIV tag you happen to have in the page. In this way, you can retrieve or set data from any of the DIV tags you have in the page. For example, the following two lines of code retrieve the same data:

// Data is actually stored in one place but referenced from many
var data1 = $('div').data('A');

// If the ID references a DIV in the same page, 
// the returned data is the same as with the previous code
var data2 = $('#ThisElementIsDiv').data('A');

A common way of using the jQuery cache API is storing data to the DOM element that’s really using it. In my example, the canonical solution would be caching customers in the DIV element bound to the master view.

Putting It All Together

Figure 5 shows the final version of the sample code that retrieves data from a remote service, caches it locally using jQuery and displays it via a DataView.

Figure 5 Caching Fetched Data

var currentQuery = { Selection: "A" };

function pageLoad() {
  // Build the button bar to select customers by initial
  for(var i=0; i<26; i++) {
    var btn = $('<input type="button" 
         onclick="filterQuery(this)" />');
    var text = String.fromCharCode('A'.charCodeAt(0) + i);
    btn.attr("value", text).appendTo("#menuBar").show();
  }

  // Refresh the list of customers
  fillMasterView(currentQuery);
}
function filterQuery(button) {
   Sys.Observer.setValue(currentQuery, "Selection", button.value);

   // Updates the master view
   fillMasterView(currentQuery);
}
function fillMasterView(query) {
   // Check cache first: if not, go through the data provider
   if (!reloadFromCache(query))
       reloadFromSource(query);
}
function reloadFromCache(query) {
   // Using the query string as the cache key
   var filterString = query.Selection; 
        
   // Check the jQuery cache and update
   var cachedInfo = $('#viewOfCustomers').data(filterString);
   if (typeof (cachedInfo) !== 'undefined') {
       var dataViewInstance = Sys.get("$masterView");
       dataViewInstance.set_data(cachedInfo);
       // Template automatically refreshed 
       return true;
   }
   return false;       
}
function reloadFromSource(query) {
   // Set the query string for the provider
   var filterString = query.Selection;
        
   // Tell the DataView to fetch
   var dataViewInstance = Sys.get("$masterView");
   dataViewInstance.set_fetchParameters({ query: filterString });
   dataViewInstance.fetchData(
          cacheOnFetchCompletion, null, null, filterString);
}
function cacheOnFetchCompletion(fetchedData, filterString) {
    if (fetchedData !== null) {
       $('#viewOfCustomers').data(filterString, fetchedData);
    }
}

The main refactoring regards the way in which the master view is filled. You first check whether the data is available in the local cache and proceed with a remote request if no data can be found.

The reloadFromCache method returns a Boolean value to signify that data has been successfully loaded from the cache. You use the DataView’s set_data method to assign the control a new data source. Next, you call the method refresh to update the view.

You store data in the cache after you retrieve it from the specified provider. The point is that the fetchData method works asynchronously. This means you can’t just place a call to the method get_data right after the method fetchData returns:

dataViewInstance.set_fetchParameters({ query: filterString });
dataViewInstance.fetchData();
// At this point the method get_data can't retrieve yet 
// data for the new selection.

However, the method fetchData accepts some parameters on the command line and all you have to do is pass a callback, as shown here:

dataViewInstance.fetchData(
   cacheOnFetchCompletion,   // success callback
   null,                     // failure callback
   null,                     // merge option: append/overwrite
   filterString);            // context

The first argument indicates the success callback that will be asynchronously invoked after the fetch terminates. The second argument is the callback to be invoked in case of failure. The third argument refers to the merge option. It is AppendOnly by default, or it can be OverwriteChanges. Both values are only relevant if you hook up the DataView to an AdoNetDataContext object. Finally, the fourth argument is the container for any data you want to be received by the success callback.

Here’s the signature of the success callback:

function onSucceededFetch(fetchedData, context)

The first argument the callback receives is the fetched data. The second argument is any context information you specified through the caller. In my example, the fetch completion callback is the ideal place where to cache fetched data. The context parameter will be just the query string to cache by.

Data binding is a delicate art and requires a lot of attention and resources to be effective in an AJAX scenario. ASP.NET Ajax Library offers a lot of tools for crafting a valid data binding and master-detail solution. The list includes observable collections, live-binding syntax and the DataView control. The ASP.NET Ajax Library beta can be downloaded from ajax.codeplex.com.


Dino Esposito is an architect at IDesign and the co-author of Microsoft .NET: Architecting Applications for the Enterprise (Microsoft Press, 2008). Based in Italy, Esposito is a frequent speaker at industry events worldwide. You can join his blog at weblogs.asp.net/despos.

Thanks to the following technical experts for reviewing this article: Dave Reed and Boris Rivers-Moore