Cutting Edge

Creating a Multi-table DataGrid in ASP.NET

Dino Esposito

Code download available at:CuttingEdge0308.exe(116 KB)

Contents

Binding a Multi-table DataSet
The New TabStrip Component
The DataSetGrid Component
A Real-world Example
A Master/Detail DataGrid Control
Conclusion

Like many other data-bound Web controls, the ASP.NET DataGrid can be bound to a variety of data sources, including the ADO.NET DataTable and DataView objects. The grid can also be bound to custom collection classes and arrays, even though in these cases you might want use template columns to gain better control over the output. Unlike the Windows® Forms DataGrid (which is discussed in "DataGrid: Tailor Your DataGrid Apps Using Table Style and Custom Column Style Objects" by Kristy Saunders in this issue), the ASP.NET DataGrid control cannot be bound directly to a DataSet object. You can bind it to a DataSet object through the DataGrid's DataSource property, but you also need to set the DataMember property to specify which table in the DataSet to bind to. As a result, the ASP.NET DataGrid control works on a per-table basis and displays the contents of a single DataTable object, or the contents of a view built atop a particular table.

Figure 1 Table Links

Figure 1** Table Links **

The Windows Forms DataGrid control doesn't suffer from the same design limitation. If you bind a Windows Forms DataGrid to a multi-table DataSet, the control automatically adapts its user interface to allow for navigation between the tables (see Figure 1). The control displays a list of links, each one corresponding to a particular table in the DataSet. You click on the table and the grid gets populated with the table's content. This just doesn't happen with Web grids. This column will discuss a way to enrich the user interface of the ASP.NET DataGrid control with a tabbed menu so that the developer can bind a DataSet to the grid and the user can select a particular table within the DataSet to be displayed.

Binding a Multi-table DataSet

If you bind a multi-table DataSet to a Web Forms DataGrid only the first table found in the Tables collection is noticed; all the others are ignored. The DataMember property can be used to select a particular table in the grid, but you're limited to this one table. Nothing in the default DataGrid programming interface allows for a multi-table representation of the data. The pager bar is the only user interface element that can be used as a table selector. The pager bar can also be used to select pages of data from the currently displayed table. If you adapt it to work as a table selector, you lose the ability to page through content of the current table. There is a way to adapt the pager bar to work as a menu with all the available tables. You simply need to hook up the ItemCreated event, change the default text displayed for page links, and write an appropriate handler for the PageIndexChanged event to physically control the process of table selection.

Figure 2 Tabbed Control Idea

Figure 2** Tabbed Control Idea **

If you don't want to modify the pager bar—and you normally shouldn't—the only viable alternative for multi-table selection is to use an extra component that lists the tables, creating a tabstrip component associated with the grid. The graphical layout of this new selector control is depicted in Figure 2. In this month's column, I'll build such a selector control, bind it to the DataGrid, and then I'll create a new user control.

The New TabStrip Component

The easiest way to create a component similar to the one shown in Figure 2 is to use a Repeater control to generate a bunch of command buttons, where each button corresponds to a particular table in the DataSet. Each button click will be intercepted and the related DataTable object bound to the grid. However, let's tackle one problem at a time by creating a button list component.

The TabStrip control provides a user interface made of a sequence of buttons, each corresponding to a bound element. The list of buttons is created using a data-bound Repeater control, and the button clicks are handled through the ItemCommand event handler of the Repeater. The TabStrip control also features a number of color properties that let you define the background and foreground color for unselected buttons and even for the button that represents the selected table. The CurrentTabIndex property indicates the zero-based position of the currently selected element. Figure 3 shows the full source of the control.

Figure 3 The New Control

<%@ Control Language="C#" ClassName="TabStrip" %> <%@ Import Namespace="System.Drawing" %> <script runat="server"> // Tabs to display public ArrayList Tabs = new ArrayList(); // Current Tab public int CurrentTabIndex { get {return (int) ViewState["CurrentTabIndex"];} } // Background color public Color BackColor { get {return (Color) ViewState["BackColor"];} set {ViewState["BackColor"] = value;} } // Selected background color public Color SelectedBackColor { get {return (Color) ViewState["SelectedBackColor"];} set {ViewState["SelectedBackColor"] = value;} } // Foreground color public Color ForeColor { get {return (Color) ViewState["ForeColor"];} set {ViewState["ForeColor"] = value;} } // Selected foreground color public Color SelectedForeColor { get {return (Color) ViewState["SelectedForeColor"];} set {ViewState["SelectedForeColor"] = value;} } // Select method public void Select(int index) { // Ensure the index is a valid value if (index <0 || index >=Tabs.Count) index = 0; // Updates the current index. Must write to the view state // because the CurrentTabIndex property is read-only ViewState["CurrentTabIndex"] = index; // Refresh the UI BindData(); // Fire the event to the client SelectionChangedEventArgs ev = new SelectionChangedEventArgs(); ev.Position = CurrentTabIndex; OnSelectionChanged(ev); } // Custom event class public class SelectionChangedEventArgs : EventArgs { public int Position; } public delegate void SelectionChangedEventHandler(object sender, SelectionChangedEventArgs e); public event SelectionChangedEventHandler SelectionChanged; // Helper function that fires the event by executing user-defined code void OnSelectionChanged(SelectionChangedEventArgs e) { // SelectionChanged is the event property if (SelectionChanged != null) SelectionChanged(this, e); } ////////////////////////////////////////////////////////////////////// private void Page_Init(object sender, EventArgs e) { if (ViewState["SelectedBackColor"] == null) SelectedBackColor = Color.White; if (ViewState["SelectedForeColor"] == null) SelectedForeColor = Color.Blue; if (ViewState["BackColor"] == null) BackColor = Color.Gray; if (ViewState["ForeColor"] == null) ForeColor = Color.White; if (ViewState["CurrentTabIndex"] == null) ViewState["CurrentTabIndex"] = 0; } private void Page_Load(object sender, EventArgs e) { if (!IsPostBack) { BindData(); } } private void BindData() { __theTabStrip.DataSource = Tabs; __theTabStrip.DataBind(); } private Color SetBackColor(object elem) { RepeaterItem item = (RepeaterItem) elem; if (item.ItemIndex == CurrentTabIndex) return SelectedBackColor; return BackColor; } private Color SetForeColor(object elem) { RepeaterItem item = (RepeaterItem) elem; if (item.ItemIndex == CurrentTabIndex) return SelectedForeColor; return ForeColor; } private Color SetBorderColor(object elem) { RepeaterItem item = (RepeaterItem) elem; if (item.ItemIndex == CurrentTabIndex) return SelectedBackColor; return Color.Black; } private void ItemCommand(object sender, RepeaterCommandEventArgs e) { Select(e.Item.ItemIndex); } </script> <asp:Repeater runat="server" id="__theTabStrip" OnItemCommand="ItemCommand"> <headertemplate> <table cellpadding="0" cellspacing="0" border="0" ><tr> </headertemplate> <itemtemplate> <td> <asp:button runat="server" id="__theTab" BorderWidth="1px" BorderStyle="solid" BorderColor='<%# SetBorderColor(Container) %>' text='<%# Container.DataItem %>' font-bold='<%# (Container.ItemIndex == CurrentTabIndex) %>' backcolor='<%# SetBackColor(Container) %>' forecolor='<%# SetForeColor(Container) %>' /> </td> </itemtemplate> <footertemplate> </tr></table> </footertemplate> </asp:Repeater>

Whenever a button in the list is clicked, the control fires a custom event to the host page. This event, SelectionChanged, requires the following delegate:

public delegate void SelectionChangedEventHandler(object sender, SelectionChangedEventArgs e);

Since the event needs to carry some custom data, a custom delegate is required. The event-specific data is packed into the user-defined SelectionChangedEventArgs class. The class inherits from the base event class, EventArgs, and extends it by adding an integer property named Position. The property denotes the zero-based index of the clicked button:

public class SelectionChangedEventArgs : EventArgs { public int Position; }

The caption text values for the various buttons are stored in a public member of ArrayList named Tabs. The following code snippet shows how to populate the Tabs property with the tables included in a DataSet object:

foreach(DataTable dt in dataSet.Tables) tableSelector.Tabs.Add(dt.TableName);

Figure 4 illustrates a typical button list created with the TabStrip user control. Because the constituent elements of the control are buttons, clicks are signaled through the ItemCommand event whose embedded handler updates the CurrentTabIndex property, refreshes the tab colors, and fires the SelectionChanged event to the host page for further processing.

Figure 4 TabStrip Control

Figure 4** TabStrip Control **

The TabStrip control is important when setting up a multi-table DataGrid because it provides the selection mechanism for the various tables included in the DataSet. Each button represents a table, and clicking on a button causes the corresponding table within the grid to be selected. Let's take a look at a sample page that hosts a TabStrip component and DataGrid bound to a multi-table DataSet with the following code:

<table><tr><td> <msdn:tabstrip runat="server" id="__tableSelector" SelectedBackColor="cyan" OnSelectionChanged="SelectionChanged" /> <asp:datagrid runat="server" id="__theGrid" AllowPaging="true" OnPageIndexChanged="PageIndexChanged"> <pagerstyle position="top" /> <alternatingitemstyle backcolor="ivory" /> <itemstyle backcolor="#eeeeee" /> </asp:datagrid> </td></tr></table>

The DataGrid is located just below the TabStrip and, if you properly manipulate the colors, you can create the illusion that they form a single control. In Figure 5, you can see the source code necessary to handle the SelectionChanged event and bind the corresponding table of the DataSet to the grid. Once the binding takes place, the code also adjusts the colors of the pager bar and the header so that there is a sense of continuity between the selected button and the grid underneath. (See Figure 6 for a screenshot of the sample application.) Since the DataGrid control is independent from the TabStrip user control, it can be navigated autonomously using the links on the pager bar.

Figure 5 Handle SelectionChanged and Bind to Grid

<script runat="server"> void SelectionChanged(object sender, TabStrip.SelectionChangedEventArgs e) { __theGrid.CurrentPageIndex = 0; BindData(); } void BindData() { __theGrid.DataSource = data.Tables[__tableSelector.CurrentTabIndex]; __theGrid.DataBind(); SetupGrid(); } void PageIndexChanged(object sender, DataGridPageChangedEventArgs e) { __theGrid.CurrentPageIndex = e.NewPageIndex; BindData(); } void SetupGrid() { __theGrid.HeaderStyle.BackColor = __tableSelector.BackColor; __theGrid.HeaderStyle.ForeColor = __tableSelector.ForeColor; __theGrid.PagerStyle.BackColor = __tableSelector.SelectedBackColor; } ••• </script>

Figure 6 Two Grids Acting As One

Figure 6** Two Grids Acting As One **

Although it works, this example is still as an aggregation of server controls and code mixed together on a Web page. With this implementation, code reusability is difficult to achieve. To make the solution reusable, you need to combine the TabStrip, the DataGrid, and the code that glues them together into a single control.

The DataSetGrid Component

So far you have a working ASP.NET page that you want to transform into a reusable control that future pages can include. ASP.NET Web user controls provide what you're looking for. With a few simple steps, virtually any Web page can be converted into a user control, saved as an .ascx resource, and become a reusable component.

User controls and ASP.NET pages have so much in common that transforming one into the other is easy. To start out, save the original .aspx file into a .ascx file and open it in your favorite code editor. Next, remove the HTML tags: <html>, <body>, and <form>. This ensures that you avoid conflicts and parsing errors at rendering time, when the layout of the control is merged with the layout of the hosting page, with its own <html> and <body> tags.

Not all <form> tags need to be removed. In ASP.NET you can have as many form elements as needed, but only one can be marked with the runat attribute and set to visible. (For more information on server-side forms, take a look at the May 2003 installment of Cutting Edge.) In light of this, remove from the original page any existing <form runat="server"> elements. If you have HTML forms (<form> tags without the runat attribute), you can leave them in.

You should note that saving the file with an .ascx extension is necessary for proper execution by the ASP.NET HTTP runtime. Finally, make sure that if the page you're converting contains an @Page directive, you change it to an @Control directive. The @Control and @Page directives share several common attributes, a list of which is shown in Figure 7.

Figure 7 Page and Control Common Attributes

Attribute Description
AutoEventWireup Indicates whether the control's events are automatically bound to methods with a particular name (for example, Page_Load); the default is true
ClassName Indicates an alias for the name of the class that will be created to render the user control; this value can be any valid class name, but should not include a namespace
CompilerOptions A sequence of compiler command-line switches used to compile the control's class
Debug Indicates whether the control should be compiled with debug symbols; if true, the source code of the class is not deleted from the Temporary ASP.NET Files folder
Description Provides a text description of the control
EnableViewState Indicates whether view state for the user control is maintained across page requests; the default is true
Explicit Determines whether the page is compiled using the Visual Basic Option Explicit mode; ignored by languages other than Visual Basic .NET; false by default
Inherits Defines a codebehind class for the user control to inherit; can be any class derived from UserControl
Language Specifies the language used throughout the control
Strict Determines whether the page is compiled using the Visual Basic Option Strict mode; ignored by languages other than Visual Basic .NET; false by default
Src Specifies the source file name of the codebehind class to dynamically compile when the user control is requested
WarningLevel Indicates the compiler warning level at which you want the compiler to abort compilation for the user control

Note that from within a user control, you cannot set any property that affects the overall behavior of the page. For example, you cannot enable or disable tracing, nor can you enable or disable session-state management. You can create the user control using either an inline-code or by specifying codebehind mode. If you prefer inline, you can place all the control-specific code inside the server-side <script> tag; for codebehind, you write the code to the C# or Visual Basic® .NET class file pointed to by the "Src" attribute of the @Control directive. You should note, though, that the Src attribute is not currently recognized and supported by RAD designers such as Visual Studio® .NET. It is, however, fully supported in Web Matrix (https://www.asp.net/webmatrix).

If you develop the user control with Visual Studio .NET, the codebehind class is bound to the source file in a different way. The class code is compiled in the project assembly and made available to the ASP.NET runtime through the Inherits attribute. For editing purposes only, Visual Studio .NET tracks the name of the codebehind file using the custom "CodeBehind" attribute. If you use Web Matrix, the Src attribute can be used to let the ASP.NET runtime know where the code of the component should be read from and dynamically compiled, as can also be achieved with ordinary pages.

The source code of the new DataSetGrid user control is not much different from that of the page displayed in Figure 6. (The core part of the control's source code appears in Figure 5; for the full source, download this month's code archive from the link at the top of this article.) However, the page that makes use of the control is radically different, as you can see in Figure 8.

Figure 8 Using the New Control

<%@ Page Language="C#" %> <%@ Register TagPrefix="msdn" TagName="DataSetGrid" Src="DataSetGrid.ascx" %> <%@ Import Namespace="System.Data" %> <%@ Import Namespace="System.Data.SqlClient" %> <script runat="server"> void Page_Load(object sender, EventArgs e) { SqlDataAdapter adapter = new SqlDataAdapter( "SELECT lastname, firstname FROM employees;SELECT customerid, companyname FROM customers;", "SERVER=localhost;DATABASE=northwind;UID=sa;"); DataSet data = new DataSet(); adapter.Fill(data); data.Tables[0].TableName = "Employees"; data.Tables[1].TableName = "Customers"; dataGrid.DataSource = data; dataGrid.DataBind(); } </script> <html> <title>Multi-table DataGrid</title> <body style="font-family:verdana;font-size:8pt;"> <form runat="server"> <msdn:datasetgrid runat="server" id="dataGrid" /> </form> </body> </html>

You register the new control through the @Register directive:

<%@ Register TagPrefix="msdn" TagName="DataSetGrid" Src="DataSetGrid.ascx" %>

Next, you use the control within the page using the namespace prefix and the tag name defined in the @Register directive:

<form runat="server"> <msdn:datasetgrid runat="server" id="dataGrid" /> </form>

DataSetGrid provides a pair of public members—the DataSource property and the DataBind method. Admittedly, these names can recall members of the Microsoft® .NET Framework classes and built-in controls. I opted for these names just for the sake of consistency, but their implementations are wholly local to the .ascx resource file:

public DataSet DataSource; public void DataBind() { foreach(DataTable dt in DataSource.Tables) __tableSelector.Tabs.Add(dt.TableName); BindData(); }

The BindData internal method is responsible for binding the selected table to the embedded DataGrid control.

The DataGrid control retains some default settings. In particular, it needs its AutoGenerateColumns property set to true. Since the grid component is not publicly exposed, there's no way for the page author to indicate which columns should be bound or how. The same holds true for column headers, which default to the name of the column returned by the resultset. A simple way to customize the text on the header of the grid's columns is to alias the data columns in the SQL query, as shown here:

SELECT lastname AS 'Family Name' FROM employees

The names of the tables in the bound DataSet should be customized. The ADO.NET table-mapping engine assigns default names to the various resultsets that a query generates. These names follow a standard naming convention such as Table, Table1, Table2. Although you can customize the prefix (change it from Table to Product, for example), additional resultsets are named with a progressive index. To change the names of tables in the DataSet, you have two options, the simplest of which is to rename the tables before the data binding occurs:

DataSet data = new DataSet(); adapter.Fill(data); data.Tables[0].TableName = "Employees"; data.Tables[1].TableName = "Customers"; dataGrid.DataSource = data; dataGrid.DataBind();

In this case, the table renaming takes place after the data has been retrieved. The second option entails configuring the ADO.NET table-mapping mechanism so that the DataSet's tables are automatically generated with the desired name. As you'll see in a moment, customizing the table-mapping engine also results in a slight performance improvement.

The table mapping is the process that controls how ADO.NET data adapters create in-memory tables and columns from a physical data source. A DataAdapter object utilizes the Fill method to populate a DataSet or a DataTable object with data retrieved with a SELECT command. Internally, the Fill method makes use of a DataReader to get to the data and metadata that describe the structure and content of the source tables. The DataReader is then copied into a memory container (the DataSet). The table-mapping mechanism is the set of rules and configurable parameters that let you control how a resultset is mapped onto in-memory objects.

Mapping a resultset to a DataSet is a process that comprises two phases: table mapping and column mapping. During the table-mapping step, the data adapter has to find a name for the DataTable that will contain the rows in the resultset being processed. Each resultset is given a default name that the programmer can change at will; this default name depends on the signature of the Fill method that has been used for the call. For example, let's consider the two overloads here:

adapter.Fill(ds); adapter.Fill(ds, "MyTable");

In this case the first overload names the first resultset Table, while the second names it MyTable. The DataAdapter object looks in its TableMappings collection for an entry that matches the default name of the resultset. In other words, the TableMappings property of the DataAdapter class should contain name/value pairs in which the name matches one of the default resultset names (Table, Table1, MyTable2, and the like), and the value is the new name the developer wants the table to have:

adapter.TableMappings.Add("Table", "Employees"); adapter.TableMappings.Add("Table1", "Products"); adapter.TableMappings.Add("Table2", "Orders"); // Second arg unspecified, default to TableX names adapter.Fill(ds);

If the table-mapping collection contains a match, then the data adapter uses the specified name for the corresponding DataTable in the target DataSet (named ds in the previous code snippet). If no mapping is found, then the behavior of data adapter will depend on the value of the MissingMappingAction property. The adapter may skip the unmapped resultset, throw an exception, or just stick to the default name.

Once the name has been determined, the adapter attempts to locate a corresponding DataTable object in the destination DataSet. If such a table already exists in the DataSet, then its current contents are merged with the new resultset, otherwise a missing schema action is required. The MissingSchemaAction property dictates the next action. The adapter can just ignore the table (which won't be loaded in the DataSet), raise an exception, or create an empty table to fill with the resultset (the default action). A similar mapping mechanism exists for the columns in the table. The column-mapping property is named ColumnMappings and is defined on the TableMappings class. The following code snippet shows how to use it:

DataTableMapping dtm1; dtm1 = da.TableMappings.Add("Table", "Employees"); dtm1.ColumnMappings.Add("employeeid", "ID"); dtm1.ColumnMappings.Add("firstname", "Name"); dtm1.ColumnMappings.Add("lastname", "Surname"); da.Fill(ds);

The table and column mapping takes place transparently whenever you call the Fill method on a DataAdapter object. The mapping is an important part of the DataSet population stage performed by adapter objects in ADO.NET. If you configure the table- and column-mappings infrastructure so that the tables in the DataSet have the names they are expected to have, you'll get better adapter performance. When the adapter finds a non-empty mapping collection, it works faster because it doesn't have to figure things out. Since the DataSet already contains properly named tables, you save the extra instructions that would otherwise be needed for late table renaming.

A Real-world Example

Admittedly, the DataSetGrid control is not exactly the type of control you'd use in every application. It turns out to be more useful for those quick applications that developers often need to build on the back end of a Web site to allow for easier customization and maintainance. In such a scenario, you have no scalability concerns that would prevent the caching of large chunks of data into the Session object. You can pack everything into a DataSet object and bind it to the DataSetGrid control. Quick and effective.

The DataSetGrid control can also be used to provide a more attractive representation of data that would normally fit into a single table. The idea is to subdivide the contents of the original query in properly named sub-tables—for example, by a person's initials or by month. Let's suppose that you need to retrieve the list of all customers. Instead of displaying the customers in a unique list that may run to several pages, you could partition the records into various sub-tables in the same DataSet. Displayed through the DataSetGrid control, the tabbed resultset provides users with a more rational search interface. If the user needs to access a customer that starts with M, there's no need to force her to scroll through lots of pages until the customer is found. With just one click, you can display a sub-table that groups all the customers whose name begins with M. The following code runs a query that returns four tables, each containing a subset of customers:

SqlDataAdapter adapter = new SqlDataAdapter( "SELECT * FROM customers WHERE companyname >= 'A' AND companyname < 'F';" + "SELECT * FROM customers WHERE companyname >= 'F' AND companyname < 'M';" + "SELECT * FROM customers WHERE companyname >= 'M' AND companyname < 'S';" + "SELECT * FROM customers WHERE companyname >= 'S'", "SERVER=localhost;DATABASE=northwind;UID=sa;"); DataSet data = new DataSet(); adapter.Fill(data);

If you name the various tables to reflect the initials of the stored customers, you will create a really cool effect (see Figure 9):

data.Tables[0].TableName = "A-E"; data.Tables[1].TableName = "F-L"; data.Tables[2].TableName = "M-R"; data.Tables[3].TableName = "S-Z"; dataGrid.DataSource = data; dataGrid.DataBind();

Figure 9 Alphabetical Tabs

Figure 9** Alphabetical Tabs **

So far so good. However, can you really say that this relatively simple feature makes the Web Forms DataGrid control similar to the Windows Forms version? Certainly not. One aspect of the Windows Forms DataGrid control that I especially like is the control's ability to represent any parent/child relationship that has been imposed on the tables. If two tables in the bound DataSet are tied by a relationship (through an ADO.NET DataRelation object), the Windows Forms grid adds an extra button to all rows from the parent table. When you click on that button, the DataGrid row expands, revealing all the child records for that row. For example, suppose that you have Customers and Orders tables and a parent/child relationship set between the two. In the Windows Forms grid, clicking the extra button on a customer row would display all the orders issued by that customer. Let's see if it's possible to implement the same feature with the Web Forms DataGrid.

A Master/Detail DataGrid Control

To support data relationships in the DataSet, the DataSetGrid control must support being able to detect when a table that is about to be displayed has children. If it does, you must add an extra button column to activate the selection. The layout of the DataSetGrid user control must be extended too; at a minimum, it has to include a second DataGrid for displaying the detail rows. The following code determines whether a select button column is needed:

DataTable __currentTable = (DataTable)__theGrid.DataSource; if (__currentTable.ChildRelations.Count <=0) return; // Enable selection on the grid AddSelectColumn();

The button column is an instance of the ButtonColumn class. It's important that you set the CommandName property of the column to SELECT because of the special meaning it has to the DataGrid infrastructure. When the button is clicked, the whole grid row is repainted using the styles defined through the <selecteditemstyle> tag. At a minimum, this style node will define a different background color:

<selecteditemstyle backcolor="yellow" />

Clicking a button marked with the SELECT command also causes the SelectedIndexChanged event to fire. The DataGrid embedded in the DataSetGrid control must provide an adequate event handler, as shown here:

void SelectedIndexChanged(object sender, EventArgs e) { SelectedItemIndex = __theGrid.SelectedItem.DataSetIndex; BindDetails(); }

Using the GetChildRows method on the DataRow object isn't the most efficient way to retrieve the child rows of a parent row within a DataGrid. While it remains the only built-in way to get at the related child rows, using that method in the context of a DataGrid is not sufficient. The method, in fact, returns an array of DataRow objects—a data structure that is not directly bindable to a grid. A much better approach is to create a child view of relations based on the selected record. For this to happen, you need the DataRowView object that corresponds to the selected row. A DataRowView object can be obtained only by position from a DataView object. Subsequently, you must first create a DataView object based on the parent table and then track the absolute index of the clicked row within the table. Fortunately, this information is quite easy to obtain thanks to the DataSetIndex property of the DataGridItem class. DataGrid's programming interface offers the SelectedItem property to access the DataGridItem that represents the clicked row. The following code snippet summarizes the changes needed in the source:

void BindDetails() { DataTable table; table = DataSource.Tables[__tableSelector.CurrentTabIndex]; DataView view = new DataView(table); DataRowView drv = view[SelectedItemIndex]; DataView detailsView = drv.CreateChildView("Customer2Orders"); theDetailsGrid.DataSource = detailsView; theDetailsGrid.DataBind(); }

The code first retrieves the current table and creates a DataView instance on top of it. Next, the corresponding DataRowView object is retrieved. The CreateChildView method takes the name of the relation as an argument and returns a child view. Finally, the child view is bound to the child DataGrid. Figure 10 shows the output.

Figure 10 The Final Product

Figure 10** The Final Product **

It is worth noting that a large part of the output you see in the figure is automatically generated by the DataSetGrid user control. The calling page is only responsible for preparing the input DataSet with its tables and relations. If the bound DataSet has no relations, the control works as discussed in the first part of this column, acting as merely a table selector and viewer.

You can close the child view by simply clicking on the Select button once more. When the SELECT button of a grid is clicked, two events are fired sequentially—ItemCommand and SelectedIndexChanged. The first event arrives when the SelectedIndex property of the DataGrid has not been updated yet. Cache the value of SelectedIndex in a global variable at this time, and compare it with the property's value when the SelectedIndexChanged event is fired. If the two values match, then the user clicked twice on the same row; the first click opens the child view while the second closes it.

Conclusion

The DataSetGrid control I presented here is far from perfect. There are a couple of limitations. First, the control has a lot of user interface features locked down. Both the master and the detail grids are embedded and not configurable at the page level. This means that you can't control which columns are displayed and how they are formatted. You can't further customize the appearance of the grids using tooltips, templates, and summary rows.

In addition, the control is architected and implemented as a Web Forms user control, meaning that two .ascx files should be deployed—tabstrip.ascx and datasetgrid.ascx. These user controls can't be inherited and provide poor support for design-time configuration in RAD environments such as Visual Studio .NET. These drawbacks disappear if you re-architect the control as a custom server control. In this discussion I used user controls because I find them ideal for quickly prototyping solutions. Send me your feedback about this and I just might use them in future columns to rebuild the DataSetGrid as a custom ASP.NET control.

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

Dino Esposito is an instructor and consultant based in Rome, Italy. He is the author of Building Web Solutions with ASP.NET and ADO.NET and Applied XML Programming for Microsoft .NET, both from Microsoft Press (2002). Reach Dino at dinoe@wintellect.com.