Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / ASP.NET

GroupingView

4.88/5 (21 votes)
16 Jul 2008CPOL11 min read 3   4.1K  
A templated, data-bound ASP.NET 2.0 control that groups data according to a field in the source, with support for aggregations.
GroupingView_help

Introduction

Much of my work in building Web sites involves data reporting. For a recent project, several reports shared the same need for grouping and computing aggregations. To support these kinds of requirements, and assuming a dedicated reporting engine isn't in use, a developer is typically forced to make a choice between more complicated ASP.NET coding, or more complicated SQL coding.

For example, for a given report we could return a distinct list of categories and use rollups or additional aggregation queries all within the same underlying SQL stored procedure. This may allow for simpler ASP.NET code, but requires a degree of complexity in the SQL that I was hoping to avoid.

A much simpler SQL stored procedure would return a flat tabular data set, with the first column representing group categories and other columns potentially requiring aggregation. With this type of result set, one can imagine the kind of repetitive category- and aggregation-tracking required in the ASP.NET application, most likely in a RowDataBound event of a GridView. It can be effective, but for multiple pages this approach is again somewhat tedious as each page typically has to be coded individually.

To keep cleaner SQL coding, but simplify the ASP.NET application, I created the GroupingView ASP.NET control presented in this article. It inherits from CompositeDataBoundControl and thus may be bound to any ASP.NET 2.0+ data source control or IEnumerable source. The GroupingView automatically recognizes groups based on a user-defined data field in the source, and applies display logic based on user-defined templates. Multiple aggregation functions are supported through the use of a supplemental Aggregation control, which can provide summations at a group level or over the complete data source.

Using the Control

The GroupingView control is similar to a Repeater, in that user-defined templates are used to provide the display layout. Unlike the Repeater, the GroupingView automatically categorizes the display according to a user-specified field in the data source. Set the GroupingDataField property of the GroupingView to the name of the field within the data source by which you wish to group. If GroupingDataField is left blank, then the data source is treated as a single group of records.

To define the display layout, specify markup in the GroupTemplate property. GroupTemplate is the only required template for the GroupingView control. During databinding, the GroupTemplate is instantiated once for each group, using the first data item in the group for resolving binding expressions. For example, assuming a data source with the field "Region", the following markup will render a list of distinct region names:

HTML
<cc1:GroupingView id="gv1" runat="server"
                  GroupingDataField="Region"
                  >
    <GroupTemplate>
        <%# Eval("Region") %>

        <br />
    </GroupTemplate>

</cc1:GroupingView>

There are two ways to display individual items within a group. One is to use a databound control such as a GridView or BulletedList in the GroupTemplate. The GroupingView exposes the boolean property AutobindDataSourceChildren, which if set to true (the default) will automatically bind any databound control in the GroupTemplate (those that expose a DataSource property) to the subset of data items from the source that represent the group. For example, we can expand the above markup to list items in a region through a GridView with the following addition:

HTML
<cc1:GroupingView id="gv1" runat="server"
                  GroupingDataField="Region"
                  AutobindDataSourceChildren="true"
                  >
      <GroupTemplate>

           <%# Eval("Region") %>
           <br />
        <asp:GridView id="grid1" runat="server" />

    </GroupTemplate>

</cc1:GroupingView>

Note that the child GridView control that lists individual items is not declaratively bound to the data source. The GroupingView control automatically performs the binding if AutobindDataSourceChildren is set to true. To explicitly specify which controls should be included in or excluded from the automatic binding, set either the AutobindInclusions or AutobindExceptions property of the GroupingView control respectively.

A second way to display individual items within a group is to define an ItemTemplate for the GroupingView. The ItemTemplate defines the layout for individual items. To position the items within the group, it is necessary to include a placeholder control with an ID of itemPlaceholder in the GroupTemplate. This can be a bonafide PlaceHolder control, or other container control. To use a different ID for this control, set the ItemPlaceholderID property of the GroupingView (again, this property defaults to itemPlaceholder).

The following example demonstrates the use of the itemPlaceholder control and an ItemTemplate definition. Each region name is displayed as before, followed by a bulleted list of cities within the region:

C#
<cc1:GroupingView id="gv1" runat="server"
                  GroupingDataField="Region"

                  AutobindDataSourceChildren="true"
                  >
      <GroupTemplate>
          <%# Eval("Region") %>
          <ul>

            <asp:PlaceHolder id="itemPlaceholder" runat="server" />
          </ul>
    </GroupTemplate>
    <ItemTemplate>

        <li><%# Eval("City") %>, <%# Eval("State") %></li>
    </ItemTemplate>

</cc1:GroupingView>

Use of the itemPlaceholder and ItemTemplate affords the developer complete control over the rendering markup.

In addition to GroupTemplate and ItemTemplate, the GroupingView provides the GroupSeparatorTemplate and ItemSeparatorTemplate properties. If used, their literal markup is rendered between groups and items respectively.

Aggregations

To support aggregations, the GroupingView assembly contains a supplemental Aggregation control. Like GroupingView, Aggregation is a databound control and may be used independently to compute and display summarizations when bound to a data source. The Aggregation control exposes the Function property, which defines the aggregation operation, and the DataField property, which defines the field in the data source to summarize. The Aggregation control renders its computed value in the same fashion as a Label control, and exposes the FormatString property to control the numeric formatting display.

When used in conjunction with a GroupingView control, the Aggregation control supports a number of different aggregation functions that may be applied across a group of records, such as Count, Sum, Avg, Min, Max, and others. To display an aggregation for a group, place an Aggregation control in the GroupTemplate of the GroupingView control, set its Function property to the desired operation (one of the AggregationFunction options), and specify the data field to aggregate in the DataField property. As long as the GroupingView's AutobindDataSourceChildren is set to true, Aggregation controls are databound and evaluated using the appropriate subset of the data source.

The following example demonstrates the use of Aggregation controls to compute sales totals and averages for each region.

C#
<cc1:GroupingView id="gv1" runat="server"
                  GroupingDataField="Region"
                  AutobindDataSourceChildren="true"

                  >
   <GroupTemplate>
       <%# Eval("Region") %>
       <table>
           <%-- table header --%>
           <tr><th>City</th><th>Sales</th></tr>

           <%-- data items --%>
           <asp:PlaceHolder id="itemPlaceholder" runat="server" />

           <%-- computed totals and averages for the group --%>

           <tr>
               <td>Total:</td>
               <td>
                <cc1:Aggregation runat="server" Function="Sum"

                        DataField="Sales" FormatString="{0:#,##0.00}" />
               </td>
           </tr>
           <tr>

               <td>Average:</td>
               <td>
                 <cc1:Aggregation runat="server" Function="Avg" 
                           DataField="Sales" FormatString="{0:#,##0.00}" />

               </td>
            </tr>

        </table>
    </GroupTemplate>

    <ItemTemplate>
        <tr>

            <td><%# Eval("City") %></td>
            <td><%# Eval("Sales","{0:#,##0.00}") %></td>
        </tr>

    </ItemTemplate>

</cc1:GroupingView>

Nesting GroupingViews

For additional levels of grouping based on secondary fields in the data source, a GroupingView control may be nested within the GroupTemplate of a parent. A child GroupingView is just another data source control that will be automatically bound to group data provided AutobindDataSourceChildren is true.

The following demonstrates the rendering of a three-level hierarchy, with “Region” providing the primary grouping and “State” a secondary grouping.

HTML
<ul>
  <cc1:GroupingView id="gv1" runat="server"
                  GroupingDataField="Region"
                  AutobindDataSourceChildren="true"

                  >
    <GroupTemplate>
      <li>
        <%# Eval("Region") %>
        <cc1:GroupingView id="gv2" runat=""server""

                          GroupingDataField="State"
                          AutobindDataSourceChildren="true"
                          >
            <GroupTemplate>
                <ul>
                    <li>

                      <%# Eval("State") %>
                      <asp:BulletedList id="bul1" runat=""server""
                                        DataTextField="City"
                                        />
                    </li>

                </ul>

            </GroupTemplate>

        </cc1:GroupingView>
        </li>

    </GroupTemplate>

  </cc1:GroupingView>
</ul>

Events

The GroupingView control provides the following events:

Event

Description

GroupCreated

Occurs when a new group is created in the GroupingView control (i.e. when a GroupTemplate is instantiated). This event is often used to modify the content of a group when it is created.

GroupDataBound

Occurs when a data item is bound to a group in a GroupingView control. This event is often used to modify the content of a group after it is bound to data.

ItemCreated

Occurs when a new item is created within a group in the GroupingView control (i.e. when an ItemTemplate is instantiated). This event is often used to modify the content of a group's item when it is created.

ItemDataBound

Occurs when a data item is bound to an item within a group in a GroupingView control. This event is often used to modify the content of a group's item after it is bound to data.

Command

Occurs when a button is clicked in the GroupingView control. This event is often used to perform a custom task when a button is clicked in the control.

For event-handling and additional GroupingView examples, see the demo and control documentation download links at the top of the article.

About the Code

The GroupingView control inherits from CompositeDataBoundControl, which manages the association of data sources and provides common properties such as DataSource, DataSourceID, and DataMember. To support categorization, GroupingView adds the GroupingDataField property.

C#
[ToolboxData("<{0}:GroupingView runat="server"></{0}:GroupingView>")]
[Designer(typeof(GroupingViewDesigner))]
public class GroupingView : CompositeDataBoundControl
{
    ...
    private string _groupingDataField = "";
    ...
    public string GroupingDataField
    {
        get { return _groupingDataField; }
        set { _groupingDataField = value; }
    }
    ...
}

The only requirement for inheriting from the CompositeDataBoundControl class is to provide an implementation of the CreateChildControls method. The method takes two arguments: an IEnumerable data source, and a Boolean flag that indicates the context in which the method is called.

C#
protected override int CreateChildControls
  (System.Collections.IEnumerable dataSource, bool dataBinding)

It is important to pay attention to the dataBinding flag, as the context in which the method is called will distinguish how the control should create its children. A value of true indicates a databinding context, with the supplied IEnumerable data source representing the data to bind. A value of false indicates a postback context. In this case, the control must recreate its children without binding to a data source. The IEnumerable passed in is not valid data, but rather an array whose length matches the number of previously enumerated data items.

Generally speaking then, these are the steps for addressing a databinding context:

  • Enumerate the passed-in IEnumerable data source.
  • For each individual data item, use the data to create appropriate child controls, adding each to the Controls collection of the main control.
  • Return as an int the number of data items iterated. This is important to allow the control to recreate its children in postback contexts. The base CompositeDataBoundControl stores this value in ViewState.

These are the general steps for addressing a postback context:

  • The passed in IEnumerable is a dummy array. Inspect its Length – this value is the same as what was previously returned in the databinding context, and represents the total number of data items previously iterated.
  • Create child controls Length number of times and add them to the Controls collection of the main control. There is no need to assign data to the children, as each will each use ViewState to restore its own state.

Addressing a Databinding Context

If a databinding context is presented (the databinding parameter passed to CreateChildControls is true) our control has the responsibility of enumerating the passed in data source to create our display. For the GroupingView, we first enumerate through the complete source to retrieve the list of distinct GroupingDataField values which define each group.

C#
protected int CreateChildControls_BindingScenario(IEnumerable dataSource)
{
    // we're in a binding context; create the child controls
    // by enumerating over the given datasource; return the
    // total number of dataitems enumerated
    int totalItemCount = 0;
    int groupCount = 0;

    // for tracking the number of items per group
    int[] itemsPerGroup = null;

    // inspect the data source
    if (dataSource != null)
    {
        if (!string.IsNullOrEmpty(GroupingDataField))
        {
            // start by getting an array of grouping values
            string[] groups = GetArrayOfGroupingValues(dataSource);
            itemsPerGroup = new int[groups.Length];
            ...
        }
        else
        {
            ...
        }
    }
 ...
}

protected string[] GetArrayOfGroupingValues(IEnumerable dataSource)
{
    // return a string array of distinct values appearing
    // in the GroupingDataField for the given dataSource
    List<string> list = new List<string>();

    try
    {
        foreach (object dataItem in dataSource)
        {
            // use the databinder to get the grouping value
            string groupingValue
              = DataBinder.GetPropertyValue(
                  dataItem, GroupingDataField
                ).ToString();

            if (!list.Contains(groupingValue))
                list.Add(groupingValue);
        }
    }
    catch
    {...}

    return list.ToArray();
}

We then loop through this array of group values and create subsets of the original data source. The subset is an IEnumerable representing those items in the data source with a matching GroupingDataField value. The C# yield return statement is helpful in establishing the subset.

C#
protected int CreateChildControls_BindingScenario(IEnumerable dataSource)
{
    ...
    // inspect the data source
    if (dataSource != null)
    {
        if (!string.IsNullOrEmpty(GroupingDataField))
        {
            ...
            // then loop through each and create a subset datasource
            foreach (string groupValue in groups)
            {
                // apply a separator?
                if (groupCount > 0 && GroupSeparatorTemplate != null)
                    ApplyGroupSeparatorTemplate_BindingScenario();

                // apply the group
                IEnumerable groupSource
                   = DataSubsetForGroup(dataSource, groupValue);

                int iCount = CreateChildControlsForGroup_BindingScenario(
                      groupSource, groupCount, totalItemCount
                     );
                totalItemCount += iCount;
                itemsPerGroup[groupCount] = iCount;
                groupCount++;
            }
        }
        else
        {
            // if we don't have a GroupingDataField identified,
            // treat the entire listing as one group
            totalItemCount = CreateChildControlsForGroup_BindingScenario(
                dataSource, 0, 0
                );
            groupCount = 1;
            itemsPerGroup = new int[1];
            itemsPerGroup[0] = totalItemCount;
        }
    }
    ...
}

protected IEnumerable DataSubsetForGroup(
             IEnumerable dataSource, string groupingValue)
{
    try
    {
        foreach (object dataItem in dataSource)
        {
            // use the databinder to get the grouping value
            string itemValue
              = DataBinder.GetPropertyValue(
                 dataItem, GroupingDataField
                ).ToString();

            if (itemValue.ToLower() == groupingValue.ToLower())
                yield return dataItem;
        }
    }
    finally { }
}

As GroupingView is a templated control, we create child controls by instantiating the GroupTemplate once for each group. To support automatic binding of instantiated child controls to the group data source, we call the BindChildControlsToDataSource method which is shown at the end of the following code block:

C#
protected int CreateChildControlsForGroup_BindingScenario(
    System.Collections.IEnumerable groupDataSource
    , int groupIndex, int itemIndex)
{
    // create child controls for the given group datasource by
    // applying the GroupTemplate (and within that, ItemTemplates
    // are applied);
    // return the number of ItemTemplates applied;
    if (GroupTemplate != null)
        return ApplyGroupTemplate_BindingScenario(
            groupDataSource, groupIndex, itemIndex);
    else
        return 0;
}

protected int ApplyGroupTemplate_BindingScenario(
    IEnumerable groupDataSource, int groupIndex, int itemIndex)
{
    // using the first record in groupDataSource, apply the GroupTemplate;
    // return the total number of ItemTemplates applied across
    // the groupDataSource
    int itemCount = 0;

    // use an if{} rather than a while{} as we'll only need
    // the first data item in the group for binding the group
    IEnumerator enumerator = groupDataSource.GetEnumerator();
    if (enumerator.MoveNext())
    {
        // instantiate the layout template using the first item
        // of the data source
        object firstDataItem = enumerator.Current;
        GroupingViewItem groupItem
          = new GroupingViewGroupItem(
                  firstDataItem, groupIndex, itemIndex, 0
                );
        GroupTemplate.InstantiateIn(groupItem);

        // add the instantiated groupItem to the controls collection
        this.Controls.Add(groupItem);

        // if we're databinding and want to autobind other children
        // controls, assign all child controls with a DataSource
        // property in the group layout to the same groupDataSource
        if (AutobindDataSourceChildren)
            BindChildControlsToDataSource(
                 groupItem, groupDataSource
                 , AutobindInclusionsArray
                 , AutobindExceptionsArray
                 );

        // the GroupingViewItem is instatiated; before binding,
        // fire the GroupCreated event
        OnGroupCreated(new GroupingViewEventArgs(groupItem
               , groupIndex, itemIndex, 0));

        // now evaluate databinding expressions in the group
        groupItem.DataBind();

        // finally, if the group contains an itemPlacholder, find it
        // and populate it for each dataItem in the group
        ...

        // fire the GroupDataBound event
        OnGroupDataBound(new GroupingViewEventArgs(
               groupItem, groupIndex, itemIndex, 0));
    }

    return itemCount;
}

protected void BindChildControlsToDataSource(
  Control parent, IEnumerable dataSource
  , string[] inclusions, string[] exceptions)
{
    // loop through all children of the given parent;
    // if any exposes a DataSource property, bind it to
    // the given datasource;
    // if exceptions != null, do not include those control IDs;
    // if inclusions != null, only include those control IDs;
    foreach (Control c in parent.Controls)
    {
        // use reflection to get the DataSource property if available
        PropertyInfo pi = c.GetType().GetProperty(
          "DataSource", BindingFlags.Public | BindingFlags.Instance
          );
        if (pi != null)
        {
            bool bApply = true;
            string id = c.ID;
            if (!string.IsNullOrEmpty(id))
            {
                id = id.ToLower();
                if (inclusions != null)
                {
                    if (Array.IndexOf<string>(inclusions, id) < 0)
                        bApply = false;
                }
                else if (exceptions != null)
                {
                    if (Array.IndexOf<string>(exceptions, id) >= 0)
                        bApply = false;
                }
            }
            // set the value to the given datasource
            if (bApply)
                pi.SetValue(c, dataSource, null);
        }

        // recursively check for children
        if (c.HasControls())
            BindChildControlsToDataSource(c, dataSource
              , inclusions, exceptions);
    }
}

If an itemPlaceholder control is supplied in the GroupTemplate, and an ItemTemplate defined, it is applied once for each data item in the group’s data subset in a similar manner. Also note the call to DataBind() after the group is instantiated. This is important as it allows the evaluation of databinding expressions within the template.

Typically a subclass of CompositeDataBoundControl will pass as the return value from CreateChildControls the total number of data items evaluated to assist in future postback contexts. Though the GroupingView follows this convention, it won't help us to recreate controls on a postback. We don't need the total number of items to recreate our controls, but rather the total number of distinct groups, and for each group, the number of items within that group. So in addition to returning the total number of items for the base class to store, we also explicitly store in ViewState our own array of integers. The length of this array is the total number of distinct groups, and each integer in the array is the number of subset items within a group.

C#
protected int CreateChildControls_BindingScenario(IEnumerable dataSource)
{
    ...

    // for tracking the number of items per group
    int[] itemsPerGroup = null;

    // inspect the data source
    ...

    // to support the re-creation of child controls upon postbacks,
    // store the itemsPerGroup array in ViewState
    ViewState[kViewState_ItemsPerGroup] = itemsPerGroup;

    // return the total number of data items iterated
    return totalItemCount;
}

Addressing a Postback Context

Given that we previously stored our integer array identifying group and item counts in ViewState, it is a simple matter to recreate child controls in a postback context (i.e. the databinding parameter passed to CreateChildControls is false). We don't have to worry about restoring the state of the child controls – ViewState allows the controls to manage that responsibility themselves. We just need to create the correct number of controls, in the correct order. Once for each group, we apply the GroupTemplate with the appropriate number of items. If necessary, we also apply the ItemTemplate within the group the correct number of times.

C#
protected int CreateChildControls_PostbackScenario()
{
    // we're in a postback scenario, in which case the dataSource
    // does not contain valid data; we're expected to recreate
    // child controls which will then repopulate themselves using
    // ViewState; in this case, dataSource is a dummy
    // int array of the same length as the total number of
    // dataitems initially bound;

    // since we're binding based on groups first, we need to
    // retrieve the ItemsPerGroup array we previously stored in
    // ViewState which gives both the number of groups, and the
    // number of items in each group.  With that, we'll reapply
    // Group and ItemTemplates and child controls will populate
    // themselves from ViewState accordingly.

    int[] itemsPerGroup = (ViewState[kViewState_ItemsPerGroup] as int[]);
    if (itemsPerGroup == null)
        return 0;
    else
    {
        // the array length indicates the number of groups; loop through
        // each and apply GroupTemplates
        int itemCount = 0;
        for (int i = 0; i < itemsPerGroup.Length; i++)
        {
            if (i > 0 && GroupSeparatorTemplate != null)
                ApplyGroupSeparatorTemplate_PostbackScenario();

            itemCount += ApplyGroupTemplate_PostbackScenario(
                 itemsPerGroup[i], i, itemCount);
        }

        return itemCount;
    }
}

protected int CreateChildControlsForGroup_PostbackScenario(
    int numItems, int groupIndex, int itemIndex)
{
    // create child controls for a group with the given number of items
    // (within the context of a Postback scenario)
    if (GroupTemplate != null)
        return ApplyGroupTemplate_PostbackScenario(
           numItems, groupIndex, itemIndex);
    else
        return 0;
}

protected int ApplyGroupTemplate_PostbackScenario(
   int numItems, int groupIndex, int itemIndex)
{
    // for a postback scenario, apply the GroupTemplate the given number
    // of times to create child controls; individual controls will then
    // repopulate themselves through their own viewStates.
    // if numItems > 0 then locate the itemPlaceholder within the
    // GroupTemplate and apply ItemTemplates within it; return the
    // number of items applied

    GroupingViewItem groupItem = new GroupingViewGroupItem(
         null, groupIndex, itemIndex, 0);
    GroupTemplate.InstantiateIn(groupItem);

    // add the instantiated groupItem to the controls collection
    this.Controls.Add(groupItem);

    // fire the GroupCreated event
    OnGroupCreated(new GroupingViewEventArgs(
         groupItem, groupIndex, itemIndex, 0));

    // if we have an itemPlaceholder and numItems > 0, apply ItemTemplates too
    if (numItems > 0)
    {
       ...
    }
    else
        return 0;
}

Summary

The GroupingView is a templated, data-bound control for ASP.NET 2.0 that groups data items according to a field in the source. It provides for a hierarchical display of an otherwise flat data set with the use of its GroupTemplate property. The developer has control over the display of individual items within a group through the use of an itemPlaceholder container control and the ItemTemplate property. Alternatively, the developer may take advantage of the AutobindDataSourceChildren property and use other databound controls such as a GridView in the GroupTemplate to render individual items. A number of aggregation operations are supported through the supplemental Aggregation control. As a subclass of CompositeDataSourceControl, the GroupingView manages the creation of its user interface in both databinding and postback contexts.

History

  • 11-Jul-2008: Updated to include .Groups, .Items, and .NestedItems properties for inspection of created items in a GroupingView control; also added the EmptyDataTemplate template property.
  • 04-Jul-2008: Initial submission to The Code Project

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)