Cutting Edge

Flexible Custom Data Views

Dino Esposito

Code download available at:CuttingEdge0512.exe(129 KB)

Contents

Problems with Real World Data
Keeping Data Synched
Managing Foreign Key Lists
Validation Through Validators
Validation Through Events
The Best Approach?
Command Bar Manipulations
Conclusion

ASP.NET 1.x introduced some powerful and useful data-bound controls. However, none were designed specifically to manage the view of a single record. When you build master/detail views, you need to display the contents of a single record. Then when the user selects a master record from a list or a grid, you'll typically want the application to drill down to show all the available fields. You can use the DataGrid and other controls to display a single record, but this approach is just a workaround. For a true record-view control in ASP.NET 1.x, you have to buy one or roll your own.

Aware of this problem, Microsoft has added some new controls to ASP.NET 2.0 that enable single-record views. Specifically, you can use the DetailsView and FormView controls with the DataGrid and GridView to achieve this functionality. Functionally speaking, FormView and DetailsView are nearly identical. They differ mostly in the layout of the generated output. FormView allows full control of the layout, letting you define a custom form that is filled with controls to display and edit field values. DetailsView, on the other hand, is restricted to a tabular layout with two columns: header and value. The value cell, however, can be customized with templates.

These features provide important functionality that was previously lacking. And while they are still somewhat limited, there are workarounds—aren't there always? In this column, I delve into the programming interface of the DetailsView and FormView controls to explain the changes that are necessary to make these controls more versatile.

Problems with Real World Data

Figure 1 shows a sample master/detail ASP.NET page that uses DetailsView and GridView (for the actual code, see the code download). Note that the app provides only textboxes to edit data regardless of the type of data being edited. While that's okay for some sorts of data, it's not ideal for all. What if, for instance, the data was a date?

Figure 1 GridView and DetailsView in Action

Figure 1** GridView and DetailsView in Action **

Or what about the country field? The country name is going to be stored in a database that you may want to search, so consistency in the way a country name is expressed makes a difference. In this case, a dropdown list would be a better choice. It keeps the data consistent.

The page in Figure 1 is composed using the GridView and DetailsView controls. Bound to distinct data source controls, the GridView and DetailsView controls rely on the underlying data source controls to select and update the displayed records. Once the user has edited a record and clicks the Update button, the DetailsView control executes the associated update command and refreshes the view. So far so good.

In the same page, the GridView control displays a preview of the same record. However, the corresponding row is not updated to reflect changes. This isn't a surprise since there is no link between the GridView and DetailsView controls. When the user clicks to update the DetailsView, the page posts back. As a result, all page controls, including the GridView, are rebuilt. Typically, when the page is loading, the GridView, as well as other data-bound and composite controls, rebuild themselves from the view state. If the view state is not available (for example, if it's disabled), the GridView will access the bound data source.

The data source of the control is not stored in the view state, but its visual representation is. The visual representation of a data source is the part of the data source that is displayed in the grid rows. Grid row objects serialize all their contents to the view state, including the data item they embed.

After the page is loaded and all controls are initialized, the page can process the postback event. In this case, the postback event is the update of the detail record followed by the refresh of the detail view. As a result, the DetailsView control and its master grid are now out of sync. The GridView won't automatically reflect changes because its state is restored before the actual update is made.

You'll see a lot of activity if you look at SQL Profiler, tracking all database commands executed when the Update button is clicked to save changes on a DetailsView. The first command executed is "SELECT * FROM Customers", which fills out the GridView. Following that, there's an UPDATE command that updates the database. Finally, there's a SELECT statement, which reads back the modified record to update the DetailsView. (Note that this is an example where the GridView has ViewState disabled and the bound data source controls have caching disabled.)

Keeping Data Synched

The solution to data that's out of sync is straightforward—simply force the GridView to refresh its data source after the detail record has been updated. Fortunately, the DetailsView control fires a timely ItemUpdated event that can be used to keep master and detail views in sync, as done with the following event handler:

Sub OnItemUpdated(sender As Object, e As DetailsViewUpdatedEventArgs) GridView1.DataBind() End Sub

However, when the GridView's data source control has caching enabled, this may not work. The DataBind method correctly triggers the binding process but, rather than querying the database, the process actually reloads the old data from the cache. On data source controls, caching is disabled by default and can be enabled by setting the EnableCaching property to true. While caching is a best practice, it is only supported when DataSet or DataTable objects are used to return data (if you use ObjectDataSource with custom collections, the data source control infrastructure doesn't provide caching capabilities. Caching is still possible, but it must be coded within the custom object model bound to ObjectDataSource).

When caching is enabled, it defaults to a time-based expiration policy. The default duration is set to 0, specifying an infinite time period. Unless you specify a nonzero duration (in seconds), the cached data will never expire and you'll have no way to refresh the view other than by binding to another data source.

There are two alternatives to consider here. The first is to set up a database cache dependency mechanism through the SqlCacheDependency property of the data source control. While smart and powerful, this approach requires changes to the physical database, adding triggers, stored procedures, and even a new table. Yup, this is just the kind of stuff that makes a database administrator jump for joy. Nonetheless, a database cache invalidation mechanism relieves the burden of having to do any further updates of cached data to keep views in sync.

The second approach is to use the CacheKeyDependency property of the data source controls, as shown here:

Sub Page_Load(sender As Object, e As EventArgs) If Not IsPostBack Then Cache(SqlDataSource1.CacheKeyDependency) = someData End If End Sub Sub OnItemUpdated(sender As Object, e As DetailsViewUpdatedEventArgs) Cache.Remove(SqlDataSource1.CacheKeyDependency) GridView1.DataBind() End Sub

This property indicates a user-defined key dependency that is linked to all data cache entries created by the data source control. All cache entries expire when the key expires. What's important is that by removing or modifying the key specified through the CacheKeyDependency property, you can programmatically invalidate the data cached by a data source control and keep views in sync.

Managing Foreign Key Lists

The DetailsView control doesn't support templates for entirely customizing the structure of the output. When editing the contents of a record, you either go with the standard layout of the user interface—a vertical list of header/value pairs—or use another control, such as the FormView or a custom control.

While the overall layout of DetailsView cannot be modified, you can change the way in which a particular field is displayed within the standard layout. For example, you can use a Calendar control to render a date field. To do this, you employ the TemplateField class in much the same way you do for certain columns of grid controls. By using a TemplateField class to render a data field, you are free to use any layout you like for view, edit, and insert operations, as I've done here:

<asp:TemplateField HeaderText="Country"> <ItemTemplate> <asp:literal runat="server" Text='<%# Eval("country") %>' /> </ItemTemplate> <EditItemTemplate> <asp:dropdownlist ID="DropDownList1" runat="server" DataValueField="country" DataTextField="country" DatasourceID="CountriesDataSource" SelectedValue='<%# Bind("country") %>' /> </EditItemTemplate> </asp:TemplateField>

In this code, the field Country is rendered through a literal control in view mode, and turns to a data-bound dropdown list control in edit mode. The dropdown list is bound to a data source control which provides the list of countries, as done with this snippet of code:

<asp:SqlDataSource ID="CountriesDataSource" runat="server" ConnectionString='<%$ ConnectionStrings:LocalNWind %>' SelectCommand="SELECT DISTINCT country FROM customers"> </asp:SqlDataSource>

In the templates, note the use of two distinct operators for binding: Eval and Bind. Eval is an operator you use in data-binding expressions to access a public property on the bound data item. Functionally speaking, it is nearly equivalent to the DataBinder.Eval method you would have used in ASP.NET 1.x in the same situation. As used in the preceding code, Eval is an ASP.NET 2.0-only feature and will generate a compile error if used in an ASP.NET 1.x application. Furthermore, it will throw an exception if used in an ASP.NET 2.0 page outside of a template.

The Bind operator is like Eval, except that it adds the ability to write data back to the original data source field. In the previous code snippet, notice that at display time the Bind operator sets the SelectedValue property of the DropDownList control with the value of the country field from the data source. So, how are values passed back to the control for updating the data source?

When the page is compiled on the first hit, the EditItemTemplate property of the DetailsView control is set to an instance of a special internal template builder class. In addition to the methods of the ITemplate interface—an essential requirement for being bound to a template property—this class exposes a public method named ExtractValues. The body of the method consists of a simple call to a delegate object that looks like this:

Delegate Function ExtractTemplateValuesMethod( _ ctl As Control) As IOrderedDictionary

The delegate object is passed to the template builder's constructor. The page parser is responsible for generating the code that sets the EditItemTemplate property of the DetailsView control and for emitting the source of the function bound to the delegate. This function simply populates an ordered collection with all the values of the properties bound to the Bind operator within the template. The code in Figure 2 gives you an idea of how the extractor method works in the template. There will be just one such method in each template class that handles all controls in the template bound to the Bind operator.

Figure 2 The Extractor Method

Function ExtractValues_DetailsView1(c As Control) As IOrderedDictionary Dim table As New OrderedDictionary Dim DropDownList1 As DropDownList DropDownList1 = CType(c.FindControl("DropDownList1"), DropDownList1) If Not (table("country") Is Nothing) Then table("country") = DropDownList1.SelectedValue End If ... ' Repeat for each control in the template using Bind Return table End Function

Figure 3 Details View Control

Figure 3** Details View Control **

As shown in Figure 3, the ordered collection is accessed by the DetailsView control through the ExtractValues public method on the class that represents EditItemTemplate. When an update is performed, the DetailsView control retrieves the current values from bound input controls and uses them to compose the parameter list of the insert or edit command to run against the data source. The argument passed to Bind must match the name of a parameter in the command. What I have demonstrated for the DetailsView control works in the same way for templates used inside a FormView or a GridView control.

Bear in mind that for calendars, in particular, the property to bind for correct data exchange is SelectedDate. You need to use Eval with VisibleDate, instead, to ensure that the currently set date is displayed in the control's user interface.

Validation Through Validators

Any real-world user interface will require validation. Since version 1.0, ASP.NET has provided a number of validation controls—for required fields, regular expressions, and specific ranges of values. In ASP.NET 2.0, you can group some validators and associate them with a button so that only those controls will be used to validate the contents of the page being posted.

There are two ways in which you can use validators to perform a preliminary test on the input values a user is entering via a DetailsView control. In the first approach, you use templates for each displayed field, and add the validator controls directly in the template. This method is easy and effective, but requires you to write a bit of boilerplate code. Figure 4 shows how to expand a BoundField element to an editable TemplateField that uses validators.

Figure 4 Expand a BoundField Element

<asp:TemplateField HeaderText="ID"> <ItemTemplate> <asp:Literal runat="server" Text='<%# Eval("CustomerID") %>' /> </ItemTemplate> <EditItemTemplate> <asp:TextBox ID="CustomerID_TextBox" runat="server" Text='<%# Bind("CustomerID") %>' /> <asp:RequiredFieldValidator runat="server" ControlToValidate="CustomerID_TextBox" Text="*" /> </EditItemTemplate> <InsertItemTemplate> <asp:TextBox ID="CustomerID_TextBox" runat="server" Text='<%# Bind("CustomerID") %>' /> </InsertItemTemplate> </asp:TemplateField>

You remove the DataField attribute, which is not supported by a template field, and pass its value to any Eval and Bind operators you insert. The InsertItemTemplate in Figure 4 is used when the user attempts to add a new record to the table. In the code, I have only a RequiredFieldValidator control; you are free to add any combination of validators anywhere it makes sense for your app.

Note that a Literal control is preferable to Label because it is more lightweight. But, as a result, the Literal control cannot be styled. To use the Eval operator—a required feature here—you need to employ a server control, which eliminates the possibility of using an even lighter HTML <span> tag to render the value of the field, at least in view mode.

In addition to requiring more code, a template field is also heavier than a simpler BoundField element. If you don't feel comfortable with templated fields, you can try the following approach instead. You use an external piece of code (a separate and reusable component) that walks its way through the DetailsView object model and injects validators wherever needed. All fields will remain BoundField controls, and you can validate the input at will. Figure 5 shows the code needed to add to the page's code file (this is basically a handler for the ItemCreated event and a helper routine).

Figure 5 Adding Validators Programmatically

Public Partial Class MasterDetail1 Inherits System.Web.UI.Page Private Sub Page_Load(ByVal sender As Object, ByVal e As EventArgs) End Sub Protected Sub DetailsView1_ItemCreated( _ ByVal sender As Object, ByVal e As EventArgs) If (DetailsView1.CurrentMode = DetailsViewMode.ReadOnly) Then Return End If ' Let's assume we know which fields we want to modify and ' their EXACT location (reasonable assumptions most of the time) AddRequiredFieldValidator(1, "Company name required") AddRequiredFieldValidator(2, "Contact name required") End Sub Private Sub AddRequiredFieldValidator( _ ByVal rowIndex As Integer, ByVal msg As String) Const DataCellIndex As Integer = 1 Dim row As DetailsViewRow = DetailsView1.Rows(rowIndex) Dim cell As DataControlFieldCell = CType(row.Cells(DataCellIndex), DataControlFieldCell) Dim req As New RequiredFieldValidator req.Text = String.Format("<span title='{0}'>*</span>", msg) Dim ctlID As String = cell.Controls(0).UniqueID Dim pos As Integer = ctlID.LastIndexOf("$") If (pos > 0) Then req.ControlToValidate = ctlID.Substring((pos + 1)) cell.Controls.Add(req) End If End Sub End Class

The ItemCreated event of a DetailsView control doesn't contain any additional information; it's merely a notification that the record has been created. This event occurs once per rendering of the control; it won't fire on rendering of each field.

To inject a new control in the standard layout, you must first get a reference to the row to extend. Each row corresponds to a record field. The Rows array contains a DetailsViewRow element for each bound field. This class inherits from TableRow and contains only two cells by design–the first cell is the header, the second is the value. The second cell is where you add a validator. Here, you'll find just one control: the textbox. The ID of this control is prefixed by the ID of the DetailsView, separated by a $. For example:

DetailsView1$CustomerID

To successfully bind the validator to the textbox, you need only the suffix, or textbox ID, as specified in the ASP.NET source. When the validator is all set, simply add the control to the Controls collection of the cell.

Validation Through Events

If none of these approaches work for you, I have another suggestion you can try for validating the data that users enter through a DetailsView control. Stop injecting validator controls into the layout! After all, the DetailsView control fires three pairs of pre/post events—Updating/Updated, Inserting/Inserted, and Deleting/Deleted. More importantly, all pre-action events (Updating, for instance) support a writable Boolean property—named Cancel—through which you can cancel the ongoing event. This means you can hook up the pre-action event for the operation (typically, insert or update), take a look at the data being posted, and cancel the event if something appears to be wrong or invalid.

To begin, add a handler for the ItemUpdating event. The event delegate has the prototype shown here:

Public Delegate Sub DetailsViewUpdatedEventHandler( _ ByVal sender As Object, ByVal e As DetailsViewUpdatedEventArgs)

And the DetailsViewUpdateEventArgs class has the following structure, inherent from CancelEventArgs:

Public Class class DetailsViewUpdateEventArgs Inherits CancelEventArgs Public ReadOnly Property CommandArgument As Object Public ReadOnly Property Keys As IOrderedDictionary Public ReadOnly Property NewValues As IOrderedDictionary Public ReadOnly Property OldValues As IorderedDictionary End Class

Two of the ordered collections in the listing are of particular interest here: OldValues and NewValues. The former contains the values that were originally displayed to the user in the update form. The latter contains the values the user posted for changes. You can do a number of things with this information, the most interesting of which can occur if you're writing a derived control. In this case, for example, you can compare old and new values and skip the update if no new value has really been specified. Doing the same from within a page that contains a DetailsView doesn't work because you have no way to stop the update if not canceling the event. Canceling the event, however, leaves the form in edit mode and, for consistency, should be used only if you detect an error in the posted data.

Intercepting the ItemUpdating event from within a page is useful for validation purposes. Here's a possible body for the handler that checks for a particular prohibited substring in the address:

Sub ItemUpdating(sender As Object, e As DetailsViewUpdateEventArgs) Dim address As String = CType(e.NewValues("Address"), string) If address Is Nothing OrElse address.IndexOf("Via") >= 0 Then e.Cancel = True Return End If End Sub

Here, you retrieve values from the NewValues collection using either 0-based indexes or strings. In this case, you need to use strings that exactly match the values assigned to DataKeyField attributes. It's important to note that case matters. The address variable in the preceding snippet will be null if, say, you have the following markup in the .aspx page, where the data field name is lowercase:

<asp:BoundField DataField="address" HeaderText="Address" />

I'm not sure how you will want to deal with this situation in the handler. What if the value to check is null for some reason? Would you rather default to unfiltered updates or entirely cancel the event? I, personally, would rather take the second, more aggressive route.

Is this solution good enough? Well, it doesn't force you to use templated fields, nor does it require you to add quirky code to some event handler. And still you're able to detect invalid data and cancel the update. However, users have no clue about what went wrong. Again, the DetailsView control is not designed to help you much with this. To clarify what went wrong, you need to modify the control's layout and display a tooltip with error information. Here's code to do this:

If address.IndexOf("Via") >= 0 Then Dim index As Integer = IndexOf(e.NewValues, "Address") Dim row As DetailsViewRow = DetailsView1.Rows(index) Dim msg As String = "<span title='{0}' style='color:red'>*</span>" msg = string.Format(msg, "The address is invalid. Please edit.") row.Cells(1).Controls.Add(New LiteralControl(msg)) e.Cancel = True Return End If

The OrderedDictionary class doesn't have a public IndexOf method, so you have to write one. There is one implemented in the sample code that is available for download. Don't be too worried when you find that my code simply loops through the collection—this is exactly what the private IndexOf method on the OrderedDictionary class does. The code retrieves the index of the row where an error was detected, gets its second cell, and then adds a Literal control with an asterisk and a more explanatory tooltip.

The Best Approach?

So what's the best approach for validating data entered in a DetailsView control? All in all, I believe that using templated fields is the most flexible solution. Validators check input data already on the client, which in some cases saves a round-trip action. This is more an optimization than a real performance gain. As for security, you might still want to perform server-side validation. With validators, you likely do a smaller number of round-trips. Events, however, are fired only after a postback is made. But if you opt for events, bear in mind that you should add extra visual elements to the DetailsView to inform the user of what's happening.

Command Bar Manipulations

To wrap things up, let's focus on the command bar—the bottom row where the DetailsView places predefined buttons to execute basic actions. There are two enhancements I'll show you how to make to these buttons. One is to add a tooltip to explain what's going to happen if the button is clicked. The other is to add a bit of JavaScript code to ask for confirmation before deleting or updating.

DetailsView and FormView controls support delete operations and delegate the execution to the underlying data source control. If the data source control is configured to execute the delete operation, all works fine. Otherwise, an exception is thrown.

The DetailsView generates command buttons automatically and doesn't expose them directly to page code. The bit of code that asks for confirmation is shown here:

Sub ItemCreated(sender As Object, e As EventArgs) If DetailsView1.FooterRow IsNot Nothing Then Dim commandRowIndex As Integer = DetailsView1.Rows.Count - 1 Dim commandRow As DetailsViewRow = DetailsView1.Rows(commandRowIndex) Dim cell As DataControlFieldCell = _ CType(commandRow.Controls(0), DataControlFieldCell) For Each ctl As Control In cell.Controls If TypeOf ctl Is LinkButton Dim link As LinkButton = CType(ctl, LinkButton) ... End If Next End If End Sub

The ItemCreated event doesn't provide any information about the row being created. However, you can assume that the footer row is always created and is always the last row to be created. If the FooterRow object is not null, you can conclude that all rows have been created, including the command bar. The command bar is the first row after the data rows and is stored in the Rows collection. It's the last element in the collection.

The command bar is a table row (type is DetailsViewRow) and contains a cell (type is DataControlFieldCell). The cell contains as many link buttons (of type DataControlLinkButton) as there are commands. Delete, Edit, New, Update, Insert, and Cancel are the command names used. They are useful for identifying the right button in code like this:

If link.CommandName = "Delete" Then Link.ToolTip = "Click here to delete" link.OnClientClick = "return confirm(msg);" End If

Edit and New are used to enter edit or insert mode. Update and Insert refer to the buttons used to save changes in those modes.

Conclusion

If you are working with ASP.NET 1.x and need a record-view control, you'll need to purchase one or roll your own. I actually wrote one for the ASP.NET Developer Center that mimics the behavior of the ASP.NET 2.0 DetailsView control, which you can download at A DetailsView Control for ASP.NET 1.x.

ASP.NET 2.0 offers features that help address this issue. In this column, I have illustrated enhancements that will let your users edit records more effectively. If you plan to use the DetailsView control extensively in your applications, you should build all these features into a new derived control. By the way, everything stated here for the DetailsView also applies to FormView, with the exception of injection of controls and code. Being fully templated, the FormView lets you do the same directly through its templates.

Send your questions and comments for Dino to  cutting@microsoft.com.

Dino Esposito is a mentor at Solid Quality Learning and the author of Programming Microsoft ASP.NET 2.0 (Microsoft Press, 2005). Based in Italy, Dino is a frequent speaker at industry events worldwide. Get in touch with Dino at cutting@microsoft.com or join the blog.