Extend ASP.NET

Simplify Data Binding In ASP.NET 2.0 With Our Custom Control

Rick Strahl

This article discusses:

  • Two-way data binding for ASP.NET
  • Implementing custom extender ASP.NET controls
  • Improving designer support for extender controls
  • Handling and displaying data binding errors consistently
This article uses the following technologies:
ASP.NET 2.0, Visual Studio 2005

Code download available at: Data binding 2006_12.exe(2021 KB)

Contents

The Problem with Stock Data Binding
Introducing a Two-Way Data Binding Control
Implementing an Extender Control
Workaround for Designer Notification
How DataBinding and Error Management Works
Handling Binding Errors
Wrapping Up

ASP.NET 2.0 includes many new data binding features that can significantly simplify a variety of data binding scenarios. Unfortunately, one very common scenario-simple control data binding-has changed very little. While this is a common scenario in forms-based Web applications, it remains for the most part a manual and time-consuming process.

Simple control data binding refers to the process of binding a single value to a property of a control-for instance, binding form controls like textboxes, checkboxes, radio buttons, or selected values of list controls to individual data or object values. This could benefit significantly from a consistent approach to handling data binding deterministically, as well as managing error trapping and display consistently. In this article, I introduce a custom extender control that can be used to bind any individual value from data or objects to any control property. It can extend and provide designer support for any existing control on a Web Form.

In the process, I'll demonstrate a flexible and intuitive approach that provides consistent behavior for data binding and unbinding, validation, error handling, and error display in forms-based Web interfaces. I'll also dig into the details of how the control is built and describe some of the interesting benefits.

The wwDataBinder control addresses simple control data binding. In more concrete terms, the control manages bindings that express concepts such as "bind the value of the DataRow's CompanyName field to the TextBox's Text property" or "bind the Item.Entity.Pk property to the SelectedValue property of a DropDown list." The control manages the process of binding the specified data values to the specified control property, as well as unbinding the value back into the underlying data item. This is true two-way data binding.

The Problem with Stock Data Binding

ASP.NET natively provides you with reasonable support for inbound data binding. Its native data binding expressions use <%# Eval("FieldName") %> or direct page level expressions like <%# this.Item.Entity.Description %> to bind data to control properties. You call the DataBind method on the page or any control of the page to initiate the data binding process. This works well for inbound binding.

However, unbinding and getting the data back into the underlying data fields or properties is not as readily available. ASP.NET 2.0 introduces the GridView, FormView, and DetailsView controls, which let you use <%# Bind("FieldName") %> syntax that provides two-way data binding. Unfortunately, these controls are closely tied to an underlying data source control, which must be IEnumerable-based. So binding to an individual entity object, for example, is not supported.

Perhaps the most serious problem with the native unbinding mechanisms is the way that unbinding errors are handled. If there's a problem with the formatting of an input field-say a numeric field that contains an alpha character or a DateTime value that sets a month of 22-you get a page-level exception when you try to save the value. While you can trap the exception in page-level error handling, you can't summarize this information because the first exception will stop the unbinding process. If you have two or three errors on the page, it will take multiple post backs to handle these errors, which is very unattractive from a UI perspective (though you could use validators to prevent, rather than handle, the problem).

Introducing a Two-Way Data Binding Control

I created the wwDataBinder control to manage simple data binding in a more flexible (and I hope more logical) way. The control provides a number of handy features and capabilities:

  • Support for deterministic two-way data binding
  • Binding single object or data values to a control property
  • Unbinding control properties to object or data values
  • Handling data conversion from text to typed data
  • Managing any binding errors in a BindingErrors collection
  • Support for custom validation events
  • Checking for required values in input
  • Displaying error icons next to controls
  • Showing message summary with links to controls
  • Adding binding errors programmatically

To use the control, you simply drop it into an ASP.NET page. The control acts as an extender control for any existing controls on the form, and it adds a DataBindingItem property to the extended control in the Visual Studio® 2005 Properties editor (as shown in Figure 1).

Figure 1 Extending Controls with Data Binding Functionality

Figure 1** Extending Controls with Data Binding Functionality **(Click the image for a larger view)

The control sits on the form as a nondescript grey container (you can see this at the bottom of Figure 1), which isn't rendered at run time. It's displayed on the form merely so you can set properties on the wwDataBinder control itself.

As an extender control, wwDataBinder contains a collection of DataBindingItems that hold the configuration values for each of the extended controls. The collection of items is maintained in the HTML markup and the control looks like this:

<ww:wwDataBinder ID="DataBinder" runat="server" 
           OnValidateControl="DataBinder_ValidateControl">
   <DataBindingItems>
      <ww:wwDataBindingItem runat="server" 
        ControlId="txtProductName"
        BindingSource="Product.Entity" 
        BindingSourceMember="ProductName" IsRequired="True" 
        ErrorMessageLocation="RedTextAndIconBelow">
      </ww:wwDataBindingItem>
      ... more DataBindingItems
   </DataBindingItems>
</ww:wwDataBinder>

The collection establishes the individual bindings that are applied by the DataBinder. While wwDataBinder extends existing controls, nothing is actually extended until you explicitly set properties on the DataBindingItem extender property of any given control on the form.

In design mode, the extender exposes a DataBindingItem property-this is for providing a mapping between a data item and a control property. Supported data items are ADO.NET data objects, such as ADO.NET DataRow fields. Alternatively, you can bind to an object and any simple property of an object that can be referenced through the Page object. It's possible to bind this.Customer.Entity.CompanyName, where this refers to the Page object (which is the base object from which any binding expression originates).

Binding is performed using a BindingSource to identify a data item. The BindingSource consists of two properties-BindingSource (which is a string property that describes the base object) and BindingSourceMember (which provides the mapping of the field or property to bind to). Programmatic access can also make use of the BindingSourceObject property to provide a reference instead of a BindingSource string.

Suppose you want to bind to a DataRow object. In order for wwDataBinder to bind to the DataRow, the DataRow needs to be accessible through a Page reference. So this.MyDataRow works. In this case, you'd bind with:

BindingSource: MyDataRow
BindingSourceMember: CompanyName

As shown here, CompanyName is the name of the field to which you should bind.

Also, this.Customer.DataRow will work, where Customer is a business object that has a DataRow property. Here, you'd bind with:

BindingSource: Customer.DataRow
BindingSourceMember: CompanyName

You can bind to a DataTable in the same way. When a DataTable or DataSet is used as the BindingSource object, wwDataBinder assumes the first DataRow for a table or the first table and first DataRow for a DataSet. You can also bind an ADO.NET object more directly, like so:

BindingSource: MyDataSet.Tables["Products"].Rows[0]
BindingSourceMember: CompanyName

Just remember that in order for the wwDataBinder to be able to bind, whatever object you're binding to must be referenced through a property of the Page that is marked as protected or public. Public is preferred since public reflection is supported in Medium Trust, whereas reflection will fail with protected properties. Private properties are not supported for binding.

In addition to ADO.NET types, you can also bind to business entities or types from the .NET Base Class Library (BCL). For example, if you have a business object that lets you access CompanyName with this.Customer.Entity.CompanyName, you can express the BindingSource as:

BindingSource: Customer.Entity
BindingSourceMember: CompanyName

You can also bind directly to Page object members. If you have a DateTime RightNow property on the Page then binding looks like the following:

BindingSource: this
BindingSourceMember: RightNow

Any BindingSource implicitly uses this or me to signify the Page object. But unless you are using the Page itself as the binding source, these are optional and don't need to be specified. A BindingSource is relative to a top-level container object-typically the ASP.NET Page object-but you can supply the top-level container as part of the DataBind and Unbind methods as a parameter. This is useful if you want to use the wwDataBinder in a user control where the top-level container is the UserControl rather than the page.

BindingSourceMembers are always simple types like ints, strings, DateTime, bool, and so on. They can also be Enum values, which are translated into strings and work for two-way binding.

The job of a BindingSource is to establish a binding between a data item or property and the control property to which the value is bound. This mapping is used both for binding to a control property and from a control property back into the data item or property. Inbound binding is established by calling the wwDataBinder.DataBind method, and unbinding is done by calling wwDataBinder.Unbind. The process is deterministic in that you explicitly call these methods from your code-this provides a lot of flexibility in terms of how binding is handled.

A DataBindingItem exposes additional properties that are associated with the data binding process, as shown in Figure 1. The BindingProperty determines the field of the control you are binding to-the default is Text, but if you're binding to a CheckBox, you likely bind to Checked or if you're binding to a list control to SelectedValue.

Data binding can be one-way, two-way, or none. Input controls like textboxes generally use two-way binding, while display controls like labels use one-way binding. None is useful if you want a control to participate in binding error management, but you don't want to bind the control to a value.

The wwDataBinder control automatically manages both binding and unbinding errors. When a control cannot be bound or unbound, the control generates a BindingError in the BindingErrors collection, but no exception fires. You can query this collection for individual binding errors and retrieve a summary of errors with the ToHtml method. By default, each control with an error also displays an error icon next to it. Figure 2 shows an example of the form with a few binding errors.

Figure 2 Displaying Binding Errors

Figure 2** Displaying Binding Errors **(Click the image for a larger view)

BindingErrors can either be automatically generated by the control during the binding or unbinding process or can be manually added to the BindingErrors collection from your own code. Automatic BindingErrors are generated when an input value cannot be converted back to the bound data item or property, usually due to a data conversion error. For example, the Reorder Expected date shown in Figure 2 is an invalid date and the error is automatically captured by the control since the date conversion fails. Each DataBindingItem also has an IsRequired property and, not surprisingly, an item marked as IsRequired cannot be left blank. If one is left empty, a binding error is automatically generated and added to BindingErrors.

The other two errors on this form are manual errors. The Dairy Products error is handled through a ValidateControl event that is hooked up to the wwDataBinder. The event fires for each control bound and the event handler allows for checking each control after it has been unbound. If you decide a value is not valid in your code, you can set the BindingItem's BindingErrorMessage programmatically and return false to let the binder know that this DataBindingItem is in error and needs to display on the error list.

The last error is a purely programmatic error that is added by using wwDataBinder.AddBindingError and specifying the error message and Control instance or ID in the Page code. This allows assigning an arbitrary error message to a control that is data bound and makes the control show up both with the icon and in the error listing.

All of this provides for automatic and fully programmatic binding error generation so there's lots of flexibility on how errors and error icons are displayed. In addition, the control automatically picks up any Validator controls on the page and picks up their error messages and control IDs and incorporates them into the binding error mechanism.

If a control has an error, an icon is displayed next to it by default. The icon display is optional and you can turn off the warning icons for the DataBinder as a whole, or you can change the location and format for each of the individual DataBindingItems through the ErrorMessageLocation property. By default, a WebResource is used-it points to an embedded image resource in the compiled control assembly. Or you can override the image explicitly with the ErrorIconUrl property on wwDataBinder.

The assembly also includes a wwErrorDisplay control, which is used to display the error summary shown in Figure 2. This control is optional, but I like to use it for consistency. In the example, I have simply assigned the BindingErrors.ToHtml method's string output to the text property. The control also has easy message display methods, like ShowError and ShowMessage, which display one-line messages with an icon, providing a consistent way to display messages on a Page.

You'll notice that the error summary has links for each error displayed. These links jump to the individual control that is in error and highlight it, making it easier to spot and fix errors.

Let's take a look at the code required to run the form I've already shown you. The pattern used with the wwDataBinder control works something like the following. To bind, you hook up control bindings, mapping data to control properties. You then load your data into page level object references and call the DataBind method to bind the controls on the form.

To unbind, first make sure the bound data objects are available and loaded. Call the Unbind method to unbind the control values into the data. Check for BindingErrors, handle any custom server side validation, and display any binding and validation errors on failures. Finally, save the data.

My example uses a few business objects that load internal DataSet objects to keep the sample code short and concise, so this code isn't littered with SQL syntax. The business objects return their data in DataSets. This is convenient for the examples here because I can bind the controls directly to these data items. Figure 3 shows the full page code for the sample.

The sample starts off in OnInit loading up the various lists displayed on the form by calling the LoadLists method. Note that the item, category, and supplier listings are all using standard list-based binding for list content, and this code is unrelated to the wwDataBinder.

The Item business object is the main object in the Page. I'll bind to an individual record that can be expressed either as this.Item.DataRow or this.Item.Entity, where DataRow is a standard ADO.NET DataRow and Entity is a simple managed object with single properties for each field. (The download for this article available on the MSDN®Magazine Web site provides two sample forms-one that binds against the DataRow and one that binds the Entity.)

In the Page_Load of the first page access, the first product in the dropdown list is selected by and used for the Item.Load method. Item.Load takes a primary key parameter for a ProductId and loads up a DataTable/DataRow with a single record. The business object that provides this functionality exposes an Item.DataRow property. Item.DataRow is the main BindingSourceObject on this form, so controls bind to a BindingSource Item.DataRow and BindingSourceMember of ProductName, StockOnHand, and so on. After the item is loaded, you can data bind the controls with the data from the DataRow, like this:

this.Item.Load(FirstItemPk);
this.DataBinde r.DataBind();

this.DataBinder contains a collection of DataBindingItems, which now bind the controls of the form.

The DataBinder loops through the collection of DataBindingItems and calls each individual DataBind method, binding the data item to the specified control property. Note that some controls on the form are dropdown list controls that are filled with list binding and are also bound by the DataBinder, which binds the SelectedValue to select an item in the list.

The data binding happens only when IsPostBack is false so that values are not rebound after a user has changed the value and saved the changes. The form is rebound only when a new item selection is made in the txtProductId_SelectedIndexChanged event handler, which again loads a new item and then DataBind on the DataBinder property to rebind all the controls.

If you have controls that do not post their values back-labels or other display-only controls-you can also bind individual items by explicitly calling DataBinder.GetDataBindingItem(Controlid).DataBind. Each DataBindingItem performs its own binding and unbinding, and this method call triggers the explicit binding.

At this point, the page is bound. The user can now make changes. When it's time to save the data from the page, you can unbind the control values back into the underlying binding sources. In my example, most controls bind back in the Item.DataRow object, but you can easily bind to multiple objects with a single DataBinder. Each DataBindingItem determines its own BindingSource.

The first step to unbind is to ensure the object you are binding back to is in the proper context. In my example, you need to make sure that the business object has the data for the current item loaded. And since you only bound the form on the first hit (!PostBack), you now have to reload the data so the DataBinder has something to bind back to. Once the item is loaded, you can call the DataBinder's Unbind method to retrieve the control values back into the data item or property. When the call returns, the properties have been unbound into the DataRow. Note that the data has not been saved yet-unbinding only restores the data values, it doesn't bind right back into the database.

Before the data can be saved, you should first check for BindingErrors and any validation errors in the business object. BindingErrors are automatically generated if there are invalid values in controls and when the DataBinder cannot convert the entered values back into their strongly typed counterparts. BindingErrors are also created when IsRequired is set to true on a field and the field is then left blank. Finally, BindingErrors are also created if you create custom validation handlers and the DataBinder_Validate method returns false for any DataBindingItem.

The typical steps for unbinding boil down to the following minimal code:

Item.Load(ItemPk);
DataBinder.Unbind();

if (DataBinder.BindingErrors > 0)
{
    ErrorDisplay.Text = DataBinder.ToHtml();
    return;
}

Item.Save();

The sample includes a bit more code to demonstrate BindingErrors related features-adding binding errors from custom code and even from business object validation. Pay close attention to the DataBinder.AddBindingError method, which allows you to add BindingErrors to the control so that you can have a consistent error display mechanism, both for binding errors as well as for application-generated errors. AddBindingError requires that the control is already bound to the DataBinder, but you can also dynamically add a control to the binder at run time and then add a binding error, as shown with the txtProductId code in Figure 3. In that case, use AddBinding to programmatically add a binding then call AddBindingError to attach an error message.

Figure 3 Sample Form Using wwDataBinder Control

public partial class NorthwindInventoryData : System.Web.UI.Page 
{
    public busItem Item = null;
    public busCategory Category = null;
    public busSupplier Supplier = null;

    public DateTime RightNow = DateTime.Now;

    bool PageError = false;

    protected override void OnInit(EventArgs e)
    {
        base.OnInit(e);

        this.Item = new busItem();
        this.Category = new busCategory();
        this.Supplier = new busSupplier();

        // *** This content goes into ViewState 
        if (!this.IsPostBack) this.LoadLists();
    }

    protected void Page_Load(object sender, EventArgs e)
    {
        if (this.PageError) return;

        // *** I only databind on initial load
        if (!this.IsPostBack)
        {
            int FirstItemPk = (int)Item.DataSet.Tables[
                "TItemList"].Rows[0]["ProductId"];

            if (!this.Item.Load(FirstItemPk))
            {
                this.ErrorDisplay.ShowError(Item.ErrorMessage);
                this.Item.New();
            }

            // *** Go do it: Bind the Binding Items to the Controls
            this.DataBinder.DataBind();
        }
    }

    protected void btnSubmit_Click(object sender, EventArgs e)
    {
        // *** Reload Item record to override only data that's changed
        if (!this.Item.Load(int.Parse(this.txtProductId.SelectedValue)))
        {
            this.ErrorDisplay.ShowMessage("Invalid product id selected");
            return;
        }

        // *** Unbind Controls into the Item.DataRow
        bool IsError = this.DataBinder.Unbind(this);

        // *** Check for Business Rule validation Errors
        if (!Item.Validate())
        {
            // *** Add any Validation Errors to BindingErrors
            foreach (ValidationError Error in Item.ValidationErrors)
            {
                DataBinder.AddBindingError(
                    Error.Message, Error.ControlID);
            }
        }

        // *** Manually add a binding error to a non-bound control
        if (this.txtProductId.SelectedIndex == 0)
        {
            // *** If the item isn't already bound, I can create a binding
            // *** if already bound, this isn't necessary
            this.DataBinder.AddBinding(this.txtProductId);

            // Assign a binding error to it
            this.DataBinder.AddBindingError(
                "First Item in list can't be " +
                "updated (manual binding)", this.txtProductId);
        }

        // *** If there are BindingErrors display them
        if (DataBinder.BindingErrors.Count > 0)
        {
            this.ErrorDisplay.Text = DataBinder.BindingErrors.ToHtml();
            return;
        }

        if (!Item.Save())
            this.ErrorDisplay.ShowError(Item.ErrorMessage, 
                "Couldn't save Item");
        else
            this.ErrorDisplay.ShowMessage("Inventory saved.<br />" + 
                "Updated saved Time: " + this.RightNow.ToString());
    }

    protected void txtProductId_SelectedIndexChanged(object sender,
        EventArgs e)
    {
        int Pk = -1;
        int.TryParse(this.txtProductId.SelectedValue, out Pk);
        if (Pk == -1)
        {
            this.ErrorDisplay.ShowError("Invalid Selection");
            return;
        }

        // *** Rebind the item after change was made
        this.Item.Load(Pk);
        this.DataBinder.DataBind();
    }

    protected bool DataBinder_ValidateControl(wwDataBindingItem Item)
    {
        if (Item.ControlInstance == this.txtCategoryId)
        {
            DropDownList List = Item.ControlInstance as DropDownList;
            if (List.SelectedItem.Text == "Dairy Products")
            {
                // *** Add a BindingErrorMessage to the DataBindingItem
                Item.BindingErrorMessage = 
                    "Dairy Properties are bad for you";

                // *** Return false to tell that validation failed
                return false;
            }
        }

        return true;
    }

    protected void LoadLists()
    {
        if (this.Item.GetItemList("TItemList") < 0)
        {
            this.ErrorDisplay.ShowError(Item.ErrorMessage,
                                  "Couldn't load items");
            this.PageError = true;
            return;
        }

        if (this.Category.GetCategoryList("TCategoryList") < 0)
        {
            this.ErrorDisplay.ShowError(Item.ErrorMessage,
                "Couldn't load categories");
            this.PageError = true;
            return;
        }
        if (this.Supplier.GetSupplierList("TSupplierList") < 0)
        {
            this.ErrorDisplay.ShowError(Supplier.ErrorMessage,
                "Couldn't load suppliers");
            this.PageError = true;
            return;
        }

        // *** Bind the lists
        this.txtCategoryId.DataSource = 
           Category.DataSet.Tables["TCategoryList"];
        this.txtCategoryId.DataTextField = "CategoryName";
        this.txtCategoryId.DataValueField = "CategoryId";
        this.txtCategoryId.DataBind();

        this.txtProductId.DataSource = Item.DataSet.Tables["TItemList"];
        this.txtProductId.DataTextField = "ProductName";
        this.txtProductId.DataValueField = "ProductId";
        this.txtProductId.DataBind();

        this.txtSupplierId.DataSource = 
            Supplier.DataSet.Tables["TSupplierList"];
        this.txtSupplierId.DataTextField = "CompanyName";
        this.txtSupplierId.DataValueField = "SupplierId";
        this.txtSupplierId.DataBind();
    } 
}

        this.Item.Load(Pk);
        this.DataBinder.DataBind();
    }

    protected bool DataBinder_ValidateControl(wwDataBindingItem Item)
    {
        if (Item.ControlInstance == this.txtCategoryId)
        {
            DropDownList List = Item.ControlInstance as DropDownList;
            if (List.SelectedItem.Text == "Dairy Products")
            {
                // *** Add a BindingErrorMessage to the DataBindingItem
                Item.BindingErrorMessage = 
                    "Dairy Properties are bad for you";

                // *** Return false to tell that validation failed
                return false;
            }
        }

        return true;
    }

protected void LoadLists()
    {
        if (this.Item.GetItemList("TItemList") < 0)
        {
            this.ErrorDisplay.ShowError(Item.ErrorMessage,
                                  "Couldn’t load items");
            this.PageError = true;
            return;
        }

        if (this.Category.GetCategoryList("TCategoryList") < 0)
        {
            this.ErrorDisplay.ShowError(Item.ErrorMessage,
                "Couldn’t load categories");
            this.PageError = true;
            return;
        }
        if (this.Supplier.GetSupplierList("TSupplierList") < 0)
        {
            this.ErrorDisplay.ShowError(Supplier.ErrorMessage,
                "Couldn’t load suppliers");
            this.PageError = true;
            return;
        }

        // *** Bind the lists
        this.txtCategoryId.DataSource = 
           Category.DataSet.Tables["TCategoryList"];
        this.txtCategoryId.DataTextField = "CategoryName";
        this.txtCategoryId.DataValueField = "CategoryId";
        this.txtCategoryId.DataBind();

        this.txtProductId.DataSource = Item.DataSet.Tables["TItemList"];
        this.txtProductId.DataTextField = "ProductName";
        this.txtProductId.DataValueField = "ProductId";
        this.txtProductId.DataBind();

        this.txtSupplierId.DataSource = 
            Supplier.DataSet.Tables["TSupplierList"];
        this.txtSupplierId.DataTextField = "CompanyName";
        this.txtSupplierId.DataValueField = "SupplierId";
        this.txtSupplierId.DataBind();
    } 
}

The sample in Figure 3 uses a business object and binds to a DataRow. There's another example in the code download that instead binds to the business object using an Entity object. So rather than binding to the Item.DataRow it binds to Item.Entity where Entity is a simple .NET object that has properties to match the properties of the Item table. The process doesn't change at all; only the Data source string changes to Item.Entity instead of Item.DataRow. The wwDataBinder control figures out at run time which type it is dealing with and based on that binds and unbinds appropriately. If you're not using business objects, wwDataBinder will also work just fine directly against ADO.NET objects.

Implementing an Extender Control

wwDataBinder is implemented as an extender control, which means that it extends the functionality of existing controls on a form. Extender controls are a good mechanism to provide new functionality to existing controls without requiring subclassing. I've used a data binding scheme for many years that is similar to the one I've described in this article. But in the past, I used control subclasses to implement this functionality-I had wwTextBox, wwCheckBox, and wwDropDownList subclasses (and so on) and each of them needed to implement a special data binding interface that provided roughly the same functionality described here. This works fine, but it's not great for extensibility since it relies on adding code to every control that you want to extend. This results in unwelcome code manageability and code duplication issues.

An extender control is much more flexible in that it works with any existing control. Extenders drop onto a form as a single control and then have a collection of items that each track a specific control of the form with extended properties. In the case of the wwDataBinder, the extender property is a wwDataBindingItem object that contains the properties required to bind and unbind values to a control property. If you look again at Figure 1, the DataBindingItem property that is highlighted is the extension property that is provided by the wwDataBinder extender. While designer support for extender properties is nice, it is by no means required and that may be a good thing since designer support for extender controls in the Visual Studio Web designer is tricky to implement. (I'll go into that more in a bit.)

Figure 4 shows the wwDataBinder class hierarchy. wwDataBinder is the top-level control, which contains a collection of wwDataBindingItem objects. Each of these maps to a control instance on a form via the ControlId property. The ControlId property, in turn, holds the ID for the control that is tracked and provides the base reference for finding the control on a form.

Figure 4 wwDataBinder Extender Class Hierarchy

Figure 4** wwDataBinder Extender Class Hierarchy **(Click the image for a larger view)

The idea of the data binding extender is that data binding items are added to the collection. The collection can be filled in a variety of ways: using the Extender DataBindingItem property in the property sheet, using the stock Collection Editor on the DataBinder, using HTML markup for the wwDataBinder control, or calling wwDataBinder.AddBinding at run time.

The extender mechanism is the easiest to use, but it requires some extra implementation code on the control to provide this designer integration. The stock collection editor is also available to add items to the DataBinder and is available simply because the control implements a Collection property and provides a few attributes on the main class and the collection property:

[ProvideProperty("DataBindingItem", typeof(Control))]
[ParseChildren(true, "DataBindingItems")]
[PersistChildren(false)]
[NonVisualControl, Designer(typeof(wwDataBinderDesigner))]
public class wwDataBinder : Control, IExtenderProvider  
{
    [PersistenceMode(PersistenceMode.InnerProperty)]
    public wwDataBindingItemCollection DataBindingItems 
    {
        get  { return _DataBindingItems; }
    }
    wwDataBindingItemCollection _DataBindingItems = null;

    ...
}

This tells the designer to persist the collection of DataBindingItems as a property, which are automatically parsed and added to the collection when the designer loads the page. This also enables the default collection editor for the sub-controls as shown in Figure 5. All of the designer entry mechanisms results in Visual Studio persisting the output into the HTML markup shown in Figure 6.

Figure 6 Control Markup for a Variety of Binding Sources

<ww:wwDataBinder ID="DataBinder" runat="server"                  
                 OnValidateControl="DataBinder_ValidateControl">
    <DataBindingItems>
       <ww:wwDataBindingItem runat="server"
          BindingSource="Item.DataRow"
          BindingSourceMember="ProductName"
          ControlId="txtProductName" IsRequired="True"
          ErrorMessageLocation="RedTextAndIconBelow">
       </ww:wwDataBindingItem>
       <ww:wwDataBindingItem runat="server"
          BindingProperty="SelectedValue"
          BindingSource="Item.Entity"
          UserFieldName="Category" BindingSourceMember="CategoryId"
          ControlId="txtCategoryId">
       </ww:wwDataBindingItem>
       <ww:wwDataBindingItem runat="server"
          BindingSource="Item.DataRow"
          BindingSourceMember="UnitPrice"
          ControlId="txtUnitPrice" DisplayFormat="{0:f2}"
          IsRequired="True" UserFieldName="Unit Price">
       </ww:wwDataBindingItem>
       <ww:wwDataBindingItem runat="server"
          BindingProperty="Checked" 
          BindingSource='Item.DataSet.Tables["Products"].Rows[0]'
          BindingSourceMember="Discontinued"
          ControlId="chkDiscontinued"
          UserFieldName="Discontinued">
       </ww:wwDataBindingItem>
       <ww:wwDataBindingItem runat="server"
          BindingSource="this" BindingSourceMember="RightNow"
          ControlId="txtRightNow" UserFieldName="Right Now">
       </ww:wwDataBindingItem>
       <ww:wwDataBindingItem runat="server"
          BindingSource="Item.Entity"
          BindingSourceMember="UnitsInStock"
          ControlId="txtUnitsInStock"
          DisplayFormat="{0:f0}">
       </ww:wwDataBindingItem>
       <ww:wwDataBindingItem runat="server"
          ControlId="txtSupplierId" BindingSource="Item.DataRow"
          BindingSourceMember="SupplierId"
          BindingProperty="SelectedValue">
       </ww:wwDataBindingItem>
       <ww:wwDataBindingItem runat="server"
          BindingSource="Item.DataRow"
          BindingSourceMember="ReorderLevel"
          ControlId="txtReorderLevel">
       </ww:wwDataBindingItem>
       <ww:wwDataBindingItem runat="server"
          BindingSource="Item.DataRow"
          BindingSourceMember="ReorderExpected"
          ControlId="txtReorderExpected"
          DisplayFormat="{0:d}" UserFieldName="Reorder Expected">
       </ww:wwDataBindingItem>
    </DataBindingItems>
 </ww:wwDataBinder>

Figure 5 Stock Collection Editor

Figure 5** Stock Collection Editor **(Click the image for a larger view)

Of course, you can also manually enter this markup into the ASPX page so no designer support is necessary at all. However, while both manual markup and the collection editor work fine, entering the Control IDs manually and remembering which controls need to be data bound is a bit tedious. The problem is that the control to be extended is completely separate from the DataBinder. It's important to understand that regardless of their differences, all three mechanisms essentially end up with the same end result, which is the ASP.NET control markup in Figure 6. The markup is the persistence mechanism for the control.

Providing a designer extender provider is by far the best user experience, as it shows the data binding information in context with the control it extends. Realistically, without the designer support, I don't think this control would be user-friendly enough to be used efficiently. Creating this support in ASP.NET controls is tricky because the Web designer doesn't support extender controls as nicely as does the Windows® Forms designer. Specifically, the Web designer doesn't automatically generate startup code for the controls being extended and it doesn't call the Set<extended property> method when the property is updated. So a little bit of extra work is required to build an extender control that works with the Web designer. Let's take a look at how you do this.

Implement IExtenderProvider First, implement the IExtenderProviderInterface on the extender control so that Visual Studio can recognize the control as an extender. This interface consists of a single CanExtend method that returns true or false:

public bool CanExtend(object extendee)
{
   if (!this.IsExtender) return false;

   // *** Don't extend ourself <g>
   if (extendee is wwDataBinder) return false;

   if (extendee is Control) return true;

   return false;
}

Mark the Class with the ProvidePropertyAttribute The class should also be marked up with a ProvideProperty attribute for each property with which you want to extend controls. In this case, wwDataBinder extends with a single complex property called DataBindingItem:

[ProvideProperty("DataBindingItem", typeof(Control))]

Implement Get Method for the Provided Property The designer calls a Get<extender property name> method to get an instance of the property that will be provided to the control. The code for this method should retrieve either an existing instance or create a new blank instance if it can't be matched with a control:


public wwDataBindingItem GetDataBindingItem(Control control) 
{
    foreach (wwDataBindingItem Item in this.DataBindingItems) {
        if (Item.ControlId == control.ID) return Item;
    }
    wwDataBindingItem NewItem = new wwDataBindingItem(this);
    NewItem.ControlId = control.ID;
    NewItem.ControlInstance = control;
    this.DataBindingItems.Add(NewItem);
    return NewItem;
}

In theory, you should also implement a Set method. But, unfortunately, the Web designer never calls the Set method, which is why extender controls are difficult to work with in ASP.NET. The Set method is supposed to notify the application that the designer is finished editing a control and so can be used to finalize adding of the control. But since this method never fires, there's no easy way to detect when the extender property has been edited and should be persisted.

Workaround for Designer Notification

You have to go to a bit of extra effort to work around this notification problem. You need to notify the designer of a change every time a property value on the extender property is changed. You do this by implementing a method on the main extender control, like this:

internal void NotifyDesigner() 
{           
    IDesignerHost Host = this.Site.Container as IDesignerHost;
    ControlDesigner Designer = 
        Host.GetDesigner(this) as ControlDesigner;
    PropertyDescriptor Descriptor = 
        TypeDescriptor.GetProperties(this)["DataBindingItems"];

    ComponentChangedEventArgs ccea = 
        new ComponentChangedEventArgs(this, Descriptor, null, 
            this.DataBindingItems);
    Designer.OnComponentChanged(this, ccea);
}

This method forces the designer to persist the DataBindingItems collection into the HTML markup. The final step is to call this method every time a property is changed on one of the DataBindingItems. Here's an example of the ControlId property on wwDataBindingItem:

[NotifyParentProperty(true)]
[TypeConverter(typeof(ControlIDConverter))]
[Browsable(true)]
public string ControlId
{
    get { return _ControlId; }
    set
    {
        _ControlId = value;
        if (this.DesignMode && this.Binder != null)
            this.Binder.NotifyDesigner();
    }
}
private string _ControlId = "";

In this example, this.Binder is a reference of the wwDataBinder control. So every time a property is set, the designer is notified and the entire collection is persisted. This is inefficient and requires code in every property that the designer can set. But it ensures that the designer and HTML markup are always in sync. This proactive persistence is the only way I've found to ensure that the properties are properly written.

The lack of calls for the Set<property extender> method causes a few other minor problems. For starters, empty items get added to the collection because the designer initializes the extender property as soon as a control gets activated. Because there's no notification for a save operation, the value is added to the collection and persisted as soon as the designer accesses the extender properties and you end up with a blank item. There's some workaround code in the class that cleans out the list when items are added in design mode, so there's at most one empty item in the list. It's messy, but it doesn't interfere with anything as the binder ignores items that don't have a BindingSource set.

The second issue is slightly more serious. If you rename a control, the association between the DataBindingItem and the control gets lost and settings are cleared out for the control. Again, the problem is that the designer doesn't notify the name change properly, so there's no chance for the code to intercept the ControlId change and adjust the data binding item. Instead you end up with a new item and an old item that now points at an invalid ControlId. If you run the form, an error occurs during unbinding as the binding can't be resolved. It's easy to fix-you can change the HTML markup manually, rename the ControlId of the old item, and remove the newly added one.

These quirks are minor, but they are a reminder that the Web designer in Visual Studio has some issues with Extender properties and that you need to work around them for designer support.

How DataBinding and Error Management Works

The key features of the wwDataBinder control are data binding and error management. The control's functionality to handle the data binding is straightforward. It relies primarily on reflection. Each of the individual wwDataBindingItem objects has DataBind and Unbind methods, which are responsible for binding of an individual data or property value to an individual property of the target control. The main processing for binding, unbinding, and error detection occurs in these two methods.

The data binding process starts with filling the DataBindingItems collection, which is done automatically by ASP.NET when it reads the ASPX page and parses the definitions in the page into the DataBindingItems collection. You can also programmatically add items using AddBinding method. When all the data has been loaded and is ready for binding, the DataBind method is called on the wwDataBinder object.

wwDataBinder.DataBind is a high-level coordinator, but its core task is to loop through the collection of DataBindingItems and fire their DataBind methods. Most of the work occurs in the DataBind method of the individual wwDataBindingItem objects where reflection is used to parse the string-based values of the DataBindingItem into object references and properties.

The code to do this is tedious, but the source includes a few handy Reflection helper classes that make it easier to retrieve complex hierarchical object references. For example, the following code is used to retrieve an object reference and then assign a value to it:

object Source = wwUtils.GetPropertyEx(WebPage, 
    "Item.Entity.ContactInfo");
wwUtils.SetProperty(Source, "Address", "32 Kaiea Place");

Reflection doesn't natively support hierarchy syntax, so this helper function and its SetPropertyEx counterpart make short work of retrieving and assigning complex object references. You can find these helper classes in the wwUtils class provided in the wwDataBinder project and assembly.

Even with these helpers, the code for retrieving a reference to the source object, checking for the type of the object, and then assigning the value is lengthy. There are a number of additional checks for specific type of objects, such as ADO.NET DataRow, DataTable, and DataSet objects, which are handled differently than, for example, properties on an object.

The core process of the DataBind and Unbind methods is to read the value and then reassign it to the target. A lot of things can cause problems in that process too-controls or property names may be incorrect, or unbinding values can be incorrect and therefore won't convert back into the proper source types. Unbinding, in particular, needs to deal with the issue of turning input string values back into typed values. There are a number of elements to consider in this process, such as culture awareness and special types like Enums. The wwDataBindingItem DataBind and Unbind methods are too long to show here, but if you want to see the core of the binding activities, check them out in the provided source code.

When binding errors occur in either of these two methods, special exceptions are thrown up the hierarchy and back to the wwDataBinder.DataBind or Unbind methods. These methods handle the errors and convert them into BindingErrors and ultimately error icons/messages on the page. Figure 7 shows the wwDataBinder.Unbind method so you can get an idea of the high-level flow.

Figure 7 wwDataBinder.Unbind Method

public bool Unbind(Control Container)
{
    bool ResultFlag = true;
    foreach (wwDataBindingItem Item in this.DataBindingItems) 
    {
        try
        {
            if (this.BeforeUnbindControl != null &&
                !this.BeforeUnbindControl(Item)) continue;

            // *** This is where all the work happens!
            Item.Unbind(Container);

            // *** Run any validation logic - DataBinder Global first
            if (!OnValidateControl(Item) )
                this.HandleUnbindingError(Item, new 
                    ValidationErrorException(Item.BindingErrorMessage));

            // *** Run control specific validation
            if (!Item.OnValidate())
                this.HandleUnbindingError(Item, new         
                    ValidationErrorException(Item.BindingErrorMessage));
        }
        // *** This handles any unbinding errors
        catch (Exception ex)
        {
            this.HandleUnbindingError(Item, ex);
            ResultFlag = false;
        }

        // *** Notify that unbinding is done 
        if (this.AfterUnbindControl != null)
            this.AfterUnbindControl(Item);
    }

    return ResultFlag;
}

Handling Binding Errors

When errors occur, they are caught and passed to an internal HandleUnbindingError method. This looks for specific errors and tries to create a sensible error message. The data binding operations throw a few known exceptions, including BindingErrorException, which occurs when a value cannot be bound or unbound due to a formatting problem. RequiredFieldException and ValidationErrorException occur if a Validation is implemented and returns false. These errors provide detailed error information, which is used to format the message displayed.

The AddBindingError is a key method that is responsible for creating and adding a BindingError object to the BindingErrors collection, creating the error message text and injecting the error icon next to the control. The BindingErrors collection is an important part of the binding process, as it's the primary mechanism for the developer to check for errors (if DataBinder.BindingErrors.Count > 0), and it provides the user display for error information (DataBinder.BindingErrors.ToHtml).

I ran into a few snags trying to create the icons dynamically at run time. Initially, my code used Literal Control insertion to add the dynamic HTML markup into the page like this:

// *** Generate the HTML to be injected next to the control
string HtmlMarkup = this.GetBindingErrorMessageHtml(BindingItem);

if (!string.IsNullOrEmpty(HtmlMarkup))   
{
    LiteralControl Literal = new LiteralControl();
    Control Parent = BindingItem.ControlInstance.Parent;

    int CtlIdx = Parent.Controls.IndexOf(BindingItem.ControlInstance);
    try  
    {
        Parent.Controls.AddAt(CtlIdx + 1, Literal);
    }
    catch { }
}

This works fine most of the time, but not when there is <% %> script code on the page. If a container contains script code, you can't add any new controls to it dynamically. If you attempt to do this, you end up with the following error: The Controls collection cannot be modified because the control contains code blocks (such as <% .. %>).

The problem is that ASP.NET renders the results of a custom ParseTree method (that includes hard-coded control definitions, evaluation expressions, and literal text) into the Render code for the control container when script expressions are present in a container. In effect, the Controls list becomes unmovable and adding a new control to this list doesn't work reliably, so ASP.NET throws this error. The code I just showed you catches this error and simply doesn't render the icon next to the control. But the binding error is still added and displays on the error summary.

Since this control is supposed to be a generic data binding control, I created a workaround using client script. Rather than injecting a new control into the page, I created a JavaScript function that takes a ControlId and an HTML string as input and adds the HTML from client code. So rather than embedding a literal control, I embed a call to this JavaScript function in the page. The generated code for a single validation error is shown in Figure 8.

Figure 8 Dynamically Injecting HTML Using JavaScript

<script type="text/javascript">
<!--
AddHtmlAfterControl('txtCategoryId',
 ' <img src="images/warning.gif" alt="First item can\'t..." />');
AddHtmlAfterControl('txtUnitsInStock',
 ' <img src="images/warning.gif" alt="Invalid numeric..." />');
// -->
</script>
<script type="text/javascript">
<!--
function AddHtmlAfterControl(ControlId,HtmlMarkup)
{
   var Ctl = document.getElementById(ControlId);
   if (Ctl == null) return;
    
   var Insert = document.createElement('span');
   Insert.innerHTML = HtmlMarkup;

   var Sibling = Ctl.nextSibling;
   if (Sibling != null)
      Ctl.parentNode.insertBefore(Insert,Sibling);
   else
      Ctl.parentNode.appendChild(Insert);
}// -->
</script>

In ASP.NET, this is hooked up with the following code for each BindingError:

Message = this.GetErrorMessageHtmlMarkup();
if (!this._ClientScriptInjectionScriptAdded)
   Page.ClientScript.RegisterClientScriptBlock(
      this.GetType(), "AddHtmlAfterControl",
      @"function AddHtmlAfterControl(ControlId,HtmlMarkup){ ... }");

this.Page.ClientScript.RegisterStartupScript(
   this.GetType(), Item.ControlId,
   string.Format("AddHtmlAfterControl('{0}','{1}');\r\n",
   Item.ControlInstance.ClientID, Message), true);

This code generates the individual AddHtmlAfterControl client-side function calls, which get embedded as literal strings into the StartupScript block of the Page so they fire when the page loads. The message is a full HTML string that includes the image and alt text or other formatting depending on which format was chosen to display error messages. The code also renders the AddHtmlAfterControl function the first time it is hit, so the StartupCode calls can actually find the function. ASP.NET makes this sort of client-side code generation from the server quite easy.

Another interesting feature of the error display of the control is the error list. If you look again at Figure 2, you can see that all the errors displayed in the summary list have links to click on. These take you to the control in error. The code also highlights the control with a red border for three seconds.

All of this is handled with JavaScript, which is generated into the error messages with brute force. The generation of these errors is handled by the BindingErrors.ToHtml method (shown in Figure 9), which loops through all the binding errors and writes the list of errors as an unordered list. Each item's error message is then marked up as a link with HTML and a JavaScript script action that jumps to the ClientId of the control.

Figure 9 Creating Error Messages with Links

public string ToHtml()
{
    if (this.Count < 1) return "";

    StringBuilder sb = new StringBuilder("");
    sb.Append("\r\n<ul>");
    foreach (BindingError Error in this) 
    {    
        sb.Append("<li style='margin-left:0px;'>");
        if (Error.ClientID != null && Error.ClientID != "")
            sb.Append(

"<a href='javascript:;' onclick=\"var T = document.getElementById('" + Error.ClientID + "'); if(T == null) { return };" +
"T.style.borderWidth='2px';T.style.borderColor='Red';"  +
"try {T.focus();} catch(e) {;} " +
@"window.setTimeout('T=document.getElementById(\'" + Error.ClientID  + @"\');T.style.borderWidth=\'\';T.style.borderColor=\'\';',3000);" +
"\"">" + Error.Message + "</a>\r\n</li>");

        else sb.Append(Error.Message + "\r\n");
    }
    sb.Append("</ul>");
    return sb.ToString();
}

Consistent error handling and display is the sign of a polished application, so having a consistent mechanism to handle errors related to data binding is important. It's even nicer if you can indirectly apply errors from other areas of your application like business object validation errors into the same error display mechanism. For example, in my applications, I use business objects that produce validation errors when calling the Validate method on the business object. It's a simple matter to loop through the collection of validation errors and add them to the binding errors of the data binder:

if (!Item.Validate())
{
    foreach (ValidationError Error in Item.ValidationErrors)
    {
        DataBinder.AddBindingError(Error.Message, Error.ControlID);
    }
}

By doing so, validation errors from the business object now participate directly in the data binding process and I can produce errors from operations that are completely decoupled from the user interface. Well, almost decoupled-notice that there's a ControlId property on the ValidationError object returned from the business object. In the business object, I can choose to assign a common ControlId to my validation errors. Here's a business object Validate method:

public bool Validate()   
{
    this.ValidationErrors.Clear();

    if (this.Entity.UnitPrice < 0)
        this.ValidationErrors.Add(
            "Unit price can't be smaller than 0", 
            "txtUnitPrice");

    if (this.ValidationErrors.Count > 0)
        return false;

    return true;
}

I'm optionally adding a control name to the Validation error in the business object code. In my applications I usually use consistent control names that match underlying property names. While this introduces some coupling, it also brings together data binding and validation errors in a consistent manner. The coupling is purely optional and if the control name is omitted, the code for adding validation errors to binding errors still works. The summary still displays, but the message has no link to the control and there is no icon next to the control.

Wrapping Up

Data binding is an important part of most Web applications. The control I've described in this article provides a lot of functionality in an unobtrusive way. It also brings together several related tasks like data binding, error handling, and error display in a way that is not easily done with stock ASP.NET functionality. While there are other ways to approach simple control data binding, this is one that I've found to be most productive for my own uses.

I hope you'll find this control as useful as I have. It addresses a number of scenarios that ASP.NET 2.0 doesn't currently handle very well, and it does so in a non-intrusive manner so you don't have to change anything to use it. Extender controls like this one are a great way to extend the functionality of existing controls without requiring subclassing of existing controls. So go ahead and extend the functionality of what ASP.NET already provides.

Rick Strahl is president of West Wind Technologies on Maui, Hawaii. The company specializes in Web and distributed application development, tools, training, and mentoring with a focus on .NET, IIS, and Visual Studio. For more information, visit www.west-wind.com or contact Rick at rstrahl@west-wind.com.