To read the article online, visit

Creating a Dynamic Data-Driven User Interface (Part 3)

By Scott Mitchell


This article is the third installment of a four-part series that examines how to build a data-driven web applications that offers dynamic user interfaces. Over the past two articles we created a sample web application that allows for numerous law firms to log in to the site and manage their clientele. The application's data model contains a Clients table that defines the fixed attributes for a client - ClientId, CustomerId, FirstName, LastName, and so on. All law firms have these fixed attributes available to them. Each law firm can also define dynamic attributes. For example, a law firm that specializes in personal injury might need to capture client information like type of injury, whether the injury occurred on a job site, and so forth. The custom client attributes for each law firm are stored in a database table named DynamicAttributesForClients.

Part 1 examined the scope of the project and created the data model, while Part 2 showed how to allow customers (law firms) to define their custom client attributes. In this installment we create the web pages for managing clients. This includes two pages: one page to create new clients and manage their fixed attributes, and a second page to manage their custom attributes. Read on to learn more!

Getting Started...

Our web application models client information using three database tables:
  • Clients - captures fixed client attributes, which are attributes common to all clients across all law firms. This includes attributes like ClientId, CustomerId, FirstName, and LastName.
  • DynamicAttributesForClients - captures the set of custom client attributes for each law firm. Part 2 looked at how to create a web interface for creating and managing these custom attributes.
  • DynamicValuesForClients - this table captures the values of the custom client attributes for a particular client. For instance, if a law firm has three custom client attributes - Date of Injury, Injured On Job Site, and Cannot Work Because of Injury - a particular client might have three records in DynamicValuesForClients, storing the values 4/1/2008, True, and False for the three custom client attributes.
We need to create a web-based user interface for customers to use to managing their clients. This includes the ability to add a new client to the system, delete a client, and edit a client's fixed and custom attributes. Ideally, this interface would seamlessly meld the fixed and custom attributes, making it look like there was no difference between the universal attributes (ClientId, FirstName, etc.) and the custom ones (Date of Injury, Injured On Job Site, etc.). However, this adds more complexity. For the time being I'm going to make a very simple user interface that separates the fixed and custom attributes into two separate pages. On one page users can create new clients by specifying the values for their fixed attributes. Moreover, they can edit these fixed attributes and delete clients. To manage a client's custom attributes the user will need to visit a second page, which enumerates the custom attributes, loads the current client's information, and allows this data to be edited. (With a little work you could meld the fixed and custom attributes into a single, unified page.)

Creating a New Client

The demo application (available for download at the end of this article) has been updated to include a new page named ~/Customers/Default.aspx. This page uses a DetailsView and SqlDataSource control to allow visitors to add a new client to the system. As aforementioned, when adding a new client to the system the user is prompted for just the fixed data attributes, after which a new record is added to the fixed data model table, Clients.

I started building this page by adding a SqlDataSource control with the following INSERT statement:

INSERT INTO [Clients] ([CustomerId], [FirstName], [LastName], [Email])
VALUES (@CustomerId, @FirstName, @LastName, @Email)

Note that the user will be prompted to enter values for the FirstName, LastName, and Email parameters. The CustomerId value is based on what customer (law firm) the currently logged on user is associated with. In Part 2 we built a Helpers class with a method named GetCustomerIdForLoggedOnUser that returns the CustomerId value for the currently logged on user. The value for the CustomerId parameter is set in the DetailsView control's ItemInserting event:

Protected Sub dvAddClient_ItemInserting(ByVal sender As Object, ByVal e As System.Web.UI.WebControls.DetailsViewInsertEventArgs) Handles dvAddClient.ItemInserting e.Values("CustomerId") = Helpers.GetCustomerIdForLoggedOnUser() End Sub

After creating and configuring the SqlDataSource, I bound it to the DetailsView and configured the DetailsView to support inserting. This DetailsView is used solely for adding new clients; in the next section we'll add a GridView to the page to display the clientele. To have the DetailsView permanently render its inserting interface I set its DefaultMode property to Insert. Finally, I customized the DetailsView control's inserting interface to include validation controls for the FirstName, LastName, and Email fields.

The following screen shot shows the DetailsView control when viewed through a browser.

When adding a new client a user supplies the fixed attribute values.

Editing and Deleting Clients

Along with the DetailsView control the ~/Customers/Default.aspx page also includes a GridView that lists the customer's clientele. The GridView lists only the fixed attribute values, and these values are editable from the GridView interface. When a client is edited from the GridView the SqlDataSource control's UpdateCommand is sent to the database. The UPDATE statement used here simply updates the corresponding record in the Clients table; it does not modify any of the custom attribute values for the client.

The GridView also includes the ability to delete clients. The SqlDataSource's DELETE statement deletes the corresponding record from the Clients table. If there are any custom attribute values for the client then there will be records in the DynamicValuesForClients table. When defining the data model in Part 1 we created a foreign key constraint between the Clients.ClientId and DynamicValuesForClients.ClientId columns, and instructed the relationship to cascade deletes. If you are following along by creating your own web application and did not configure the constraint to cascade deletes you will get an exception if you attempt to delete a client that has custom attribute values. To remedy this you can either configure the constraint to cascade deletes or proactively delete the corresponding custom attribute values before deleting the client. This can be done through a stored procedure.

The GridView allows the visitor to only edit the fixed client attributes. To manage the custom attributes the visitor must go to another page. To help the user reach this page I added a HyperLinkField to the GridView as its far right column. This renders a hyperlink that points to ~/Customers/ClientCustomAttributes.aspx?ID=ClientID, where ClientID is the value of the ClientId column of the client whose custom attributes are to be viewed or edited.

The following screen shot shows the GridView in action. Note that the Edit and Delete buttons enable the visitor to edit a client's fixed attribute values and delete a client. The View/Edit Custom Attributes link, when clicked, whisks the user to a second page where they can view and edit the selected client's custom attributes.

The GridView enables visitors to edit fixed attributes and delete customers, along with a link to view and edit custom attributes.

The Challenges Behind Building the Custom Client Attributes User Interface

Because each customer can define their own custom client attributes, the ~/Customers/ClientCustomAttributes.aspx page, which displays a user interface for viewing and editing these attributes, needs to be able to dynamically generate the user interface for the attributes defined for the customer to which the currently logged on user is associated with. The good news is that ASP.NET enables page developers to programmatically add Web controls to the page's control hierarchy. In other words, we can write code to programmatically construct TextBoxes, CheckBoxes, and other controls so as to dynamically generate the appropriate user interface for the customer's own unique client attributes. In this article we will skim over the code for dynamically adding controls to a page; for a more in-depth look see Dynamic Controls in ASP.NET and Creating Dynamic Data Entry User Interfaces.

Generating the appropriate user interface is one challenge. Another is displaying the custom attribute values for the client being edited. Yet another challenge is saving these custom attribute values back to the database. These challenges are explored over the next two sections.

Creating the Custom Client Attribute UI and Loading the Client's Current Values from the Database

When programmatically adding Web controls to an ASP.NET page it is essential that the controls are added to the page on each and every page visit. This includes the first page visit and all subsequent postbacks. For the demo application this user interface is created during the Init event. In the Page_Init event handler I get all custom client attributes from the DynamicAttributesForClients table for the currently logged on custom and LEFT JOIN that with the DynamicValuesForClients table, which returns the values (if any) for the custom client attributes for the client whose ClientId value was passed through the querystring.

Protected Sub Page_Init(ByVal sender As Object, ByVal e As System.EventArgs) Handles Me.Init
   'Create the custom attributes user interface
   Using myConnection As New SqlConnection
      myConnection.ConnectionString = ConfigurationManager.ConnectionStrings("LawFirmConnectionString").ConnectionString

      Dim myCommand As New SqlCommand
      myCommand.Connection = myConnection
      myCommand.CommandText = "SELECT a.DynamicAttributeId, a.DataTypeId, a.AttributeName, v.DynamicValue " & _
                        "FROM DynamicAttributesForClients a " & _
                        "LEFT JOIN DynamicValuesForClients v ON a.DynamicAttributeId = v.AttributeId AND v.ClientId = @ClientId " & _
                        "WHERE a.CustomerId = @CustomerId " & _
                        "ORDER BY a.SortOrder"
      myCommand.Parameters.AddWithValue("@ClientId", Request.QueryString("ID"))
      myCommand.Parameters.AddWithValue("@CustomerId", Helpers.GetCustomerIdForLoggedOnUser())

      Dim myReader As SqlDataReader = myCommand.ExecuteReader

      While myReader.Read()
         Dim DynamicAttributeId As Guid = CType(myReader("DynamicAttributeId"), Guid)
         Dim DataTypeId As DataTypeIdEnum = CType(Convert.ToInt32(myReader("DataTypeId")), DataTypeIdEnum)
         Dim AttributeName As String = myReader("AttributeName").ToString()
         Dim AttributeValue As Object = myReader("DynamicValue")

         AddCustomAttribute(DynamicAttributeId, DataTypeId, AttributeName, AttributeValue)
      End While

   End Using
End Sub

After the custom attributes (and the client's corresponding values) the results are enumerated and the appropriate set of controls are created. The ~/Customers/ClientCustomAttributes.aspx page includes a Table Web control named CustomUITable. This Table has a row programmatically added for each custom client attribute. Each row includes two columns that contain:

  • The AttributeName value (i.e., the text, "Date of Injury").
  • The user interface associated with the attribute's data type.
The method AddCustomAttribute adds a two-column row to the CustomUITable table for each custom client attribute.

Private Sub AddCustomAttribute(ByVal DynamicAttributeId As Guid, ByVal DataTypeId As DataTypeIdEnum, ByVal AttributeName As String, ByVal AttributeValue As Object)
   'Add a row to CustomUITable
   Dim tr As New TableRow

   'Add the name as the left cell
   Dim tdName As New TableCell
   tdName.Text = AttributeName
   tdName.VerticalAlign = VerticalAlign.Top

   'Add the UI as the right cell
   Dim UIControls As List(Of Control) = CreateCustomAttributeUI(DynamicAttributeId, DataTypeId, AttributeValue)
   Dim tdUI As New TableCell
   tdUI.VerticalAlign = VerticalAlign.Top
   For Each ctrl As Control In UIControls

End Sub

The CreateCustomAttributeUI method generates the Web controls for the user interface for the current custom client attribute. This method returns a List of Control instances (each Web control in ASP.NET derives from the Control class). the appropriate user interface for each custom client attribute.

Recall that each attribute is assigned a data type, which dictates the type of information expected from the user. We defined four data types in Part 1. The following list enumerates these data types and specifies the user interface the demo application uses for each:

  • String - a multi-line TextBox Web control.
  • Boolean - a CheckBox Web control.
  • Numeric - a TextBox Web control and a CompareValidator that ensures that the user's input is a valid numeric value.
  • Date - a TextBox Web control and a CompareValidator that ensures that the user's input is a valid date value.
The user interface generation code for each data type is hard-coded in the CreateCustomAttributeUI method. An alternative approach would be to model the data types as classes in the web application that were responsible for their own UI rendering. This is the ideal approach, but it is easier to see and understand what's happening with the UI creation hard-coded in the code-behind, which is why I've gone with that approach here. In addition to creating the UI, the CreateCustomAttributeUI method also assigns the current value in the database to the Web control. For example, if there's a custom client attribute named "Date of Injury" and the edited client has a value of 4/1/2008 for this attribute, the CreateCustomAttributeUI method both creates the TextBox and CompareValidator controls associated with this UI and assigns the Text property of the TextBox to "4/1/2008".

The CreateCustomAttributeUI method and helper methods called from within follow.

Private Function CreateCustomAttributeUI(ByVal DynamicAttributeId As Guid, ByVal DataTypeId As DataTypeIdEnum, ByVal AttributeValue As Object) As List(Of Control)
   Dim ctrls As New List(Of Control)

   Select Case DataTypeId
      Case DataTypeIdEnum.String
         'Use a TextBox
         Dim stringValue As String = String.Empty
         If Not Convert.IsDBNull(AttributeValue) Then
            stringValue = AttributeValue.ToString()
         End If

         Dim tb As New TextBox
         tb.ID = GetID(DynamicAttributeId)
         tb.Text = stringValue
         tb.TextMode = TextBoxMode.MultiLine
         tb.Columns = 50
         tb.Rows = 5

      Case DataTypeIdEnum.Boolean
         'Use a checkbox
         Dim checkedState As Boolean = False
         If Not Convert.IsDBNull(AttributeValue) Then
            checkedState = Convert.ToBoolean(AttributeValue)
         End If

         Dim cb As New CheckBox
         cb.ID = GetID(DynamicAttributeId)
         cb.Checked = checkedState

      Case DataTypeIdEnum.Numeric
         'Use a TextBox
         Dim doubleValue As String = String.Empty
         If Not Convert.IsDBNull(AttributeValue) Then
            doubleValue = Convert.ToDouble(AttributeValue)
         End If

         Dim tb As New TextBox
         tb.ID = GetID(DynamicAttributeId)
         tb.Columns = 5
         tb.Text = doubleValue

         'And a numeric CompareValidator
         ctrls.Add(CreateDataTypeCheckCompareValidator(DynamicAttributeId, ValidationDataType.Double, "Invalid numeric value."))

      Case DataTypeIdEnum.Date
         'Use a TextBox
         Dim dateValue As String = String.Empty
         If Not Convert.IsDBNull(AttributeValue) Then
            dateValue = Convert.ToDateTime(AttributeValue).ToShortDateString()
         End If

         Dim tb As New TextBox
         tb.ID = GetID(DynamicAttributeId)
         tb.Columns = 10
         tb.Text = dateValue

         'And a date CompareValidator
         ctrls.Add(CreateDataTypeCheckCompareValidator(DynamicAttributeId, ValidationDataType.Date, "Invalid date value."))
   End Select

   Return ctrls
End Function

Private Function CreateDataTypeCheckCompareValidator(ByVal DynamicAttributeId As Guid, ByVal DataType As ValidationDataType, ByVal ErrorMessage As String) As CompareValidator
   Dim cv As New CompareValidator
   cv.ID = "CompVal_" & GetID(DynamicAttributeId)
   cv.ControlToValidate = GetID(DynamicAttributeId)
   cv.Display = ValidatorDisplay.Dynamic
   cv.Operator = ValidationCompareOperator.DataTypeCheck
   cv.Type = DataType
   cv.ErrorMessage = ErrorMessage

   Return cv
End Function

Private Function GetID(ByVal DynamicAttributeId As Guid) As String
   Return DynamicAttributeId.ToString().Replace("-", "_")
End Function

A couple of comments before we move on. First, note the each Web control added to the page has its ID property set. This is done because later, when we update the database with the user's input, we need to be able to programmatically access the controls. Therefore, the ID is set to the primary key of the DynamicAttributesForClients table, DynamicAttributeId. This column is a uniqueidentifier, which is equivalent to a Guid type in the .NET Framework. The Guid class has a ToString() method that turns the value into a string representation like 37146444-9d4c-4306-bc4a-fdab87911015. However, the hyphens cause problems with the client-side script emitted by the CompareValidator controls. Therefore, I created and use the GetID method when assigning a control's ID value. The GetID method converts the Guid to a string and then replaces all hyphens with underscores.

Another thing to take note of is that when working with the AttributeValue we need to first see if it is equivalent to a database NULL value before assigning the value to the control. This is because there may not be a corresponding record in DynamicValuesForClients for all of the custom client attributes. For example, if a client has just been created but has not yet had any custom attributes defined then the SELECT query used to return the custom client attributes will have a NULL for the custom attribute values.

The following screenshot shows the ~/Customers/ClientCustomAttributes.aspx page when visited for a client that does not yet have any custom attributes values specified. This customer - Hutz and Hutz - has five custom client attributes defined:

  • Birthdate (Date)
  • Employed (Boolean)
  • Married (Boolean)
  • Number of Years at Current Job (Numeric)
  • Reason for Law Suit (String)
Note that each custom attribute's user interface is specific to its data type. The Boolean attributes render as CheckBoxes, while the String attribute renders as a multi-line TextBox.

The user interface is dynamically created based on the customer's custom attributes.

Saving the Client's Custom Attribute Values

At this point the ~/Customers/ClientCustomAttributes.aspx page displays the user interfaces for the custom client attributes and includes the currently edited client's current values. The last piece of the puzzle is to update changes to the client's custom attribute values. The ~/Customers/ClientCustomAttributes.aspx page includes an Update button that, when clicked, updates the client's custom attribute values and then redirects the user back to the Manage Clients page (i.e., ~/Customers/Default.aspx).

Our first order of business is to get the values the user entered into the various custom attribute user interfaces. This is accomplished by querying the database to retrieve the list of custom client attributes for the client and then enumerating the attributes and using the FindControl(controlId) method to programmatically access the control and get its value.

In the following code snippet (which is found in the Update Button's Click event handler) gets the custom client attributes from the database, loops through them and for each attribute it calls the GetValueForCustomAttribute method to retrieve the user's input into the user interface. We'll examine this method momentarily.

'Get the custom attributes for this customer
Dim myCommand As New SqlCommand
myCommand.Connection = myConnection
myCommand.Transaction = myTransaction
myCommand.CommandText = "SELECT a.DynamicAttributeId, a.DataTypeId " & _
                  "FROM DynamicAttributesForClients a " & _
                  "WHERE a.CustomerId = @CustomerId "
myCommand.Parameters.AddWithValue("@CustomerId", Helpers.GetCustomerIdForLoggedOnUser())

Dim myReader As SqlDataReader = myCommand.ExecuteReader

Dim AttributeValues As New Dictionary(Of Guid, SqlParameter)
While myReader.Read()
   Dim DynamicAttributeId As Guid = CType(myReader("DynamicAttributeId"), Guid)
   Dim DataTypeId As DataTypeIdEnum = CType(Convert.ToInt32(myReader("DataTypeId")), DataTypeIdEnum)

   AttributeValues(DynamicAttributeId) = GetValueForCustomAttribute(DynamicAttributeId, DataTypeId)
End While

The values returned from the GetValueForCustomAttribute method are stored in a Dictionary object named AttributeValues. A Dictionary object is useful for storing a collection of items that are indexed by some value other than an ordinal number. In this case I want to have a collection of attributes, accessible by AttributeId (a Guid value), whose values are SqlParameter objects with the appropriate value entered by the user. Therefore, I created a Dictionary object with keys of type Guid and values of type SqlParameter - Dim AttributeValues As New Dictionary(Of Guid, SqlParameter). (If this doesn't make too much sense it should become clearer once we look at the code later on in the Click event handler that actually updates the database.)

The GetValueForCustomAttribute method returns a SqlParameter object with a ParameterName value of @DynamicValue and appropriately set Value and DbType properties. If the user does not enter any value, the SqlParameter's Value is set a database NULL value (DBNull.Value). Moreover, the DbType property is set according to the data type of the custom client attribute. The user interface used for a particular custom client attribute is retrieved via the FindControl(controlId) method (namely, CustomUITable.FindControl(controlId)). Recall that when creating the user interface controls for the custom client attributes we set the ID of the control to the value of the DynamicAttributeId column, formatted using the GetID method. This same logic is used to programmatically find the control so that the value entered by the user can be retrieved. That

Private Function GetValueForCustomAttribute(ByVal DynamicAttributeId As Guid, ByVal DataTypeId As DataTypeIdEnum) As SqlParameter
   Dim userInputParam As New SqlParameter
   userInputParam.ParameterName = "@DynamicValue"
   userInputParam.Value = DBNull.Value

   Dim ctrlId As String = GetID(DynamicAttributeId)
   Dim ctrl As Control = CustomUITable.FindControl(ctrlId)

   Select Case DataTypeId
      Case DataTypeIdEnum.String
         Dim tb As TextBox = CType(ctrl, TextBox)
         userInputParam.DbType = Data.DbType.String
         If Not String.IsNullOrEmpty(tb.Text) Then
            userInputParam.Value = tb.Text.Trim()
         End If

      Case DataTypeIdEnum.Boolean
         Dim cb As CheckBox = CType(ctrl, CheckBox)
         userInputParam.Value = cb.Checked
         userInputParam.DbType = Data.DbType.Boolean

      Case DataTypeIdEnum.Numeric
         Dim tb As TextBox = CType(ctrl, TextBox)
         userInputParam.DbType = Data.DbType.Double
         If Not String.IsNullOrEmpty(tb.Text) Then
            userInputParam.Value = tb.Text.Trim()
         End If

      Case DataTypeIdEnum.Date
         Dim tb As TextBox = CType(ctrl, TextBox)
         userInputParam.DbType = Data.DbType.Date
         If Not String.IsNullOrEmpty(tb.Text) Then
            userInputParam.Value = tb.Text.Trim()
         End If
   End Select

   Return userInputParam
End Function

Like with the code used to create the user interface, logic used in the GetValueForCustomAttribute method to retrieve the user's value is hard-coded in the ASP.NET code-behind class.

Once we have all of the custom client attribute values loaded into the AttributeValues Dictionary object we are ready to update the database. Each custom client attribute value is stored as a record in the DynamicValuesForClients table. If there are five custom attributes each client whose custom attribute values have been saved will have five corresponding records in DynamicValuesForClients. When saving a client's custom attribute values we may need to:

  • Insert records into DynamicValuesForClients - if the client has not yet had any custom attribute values specified, or if a new custom client attribute has been added to the system, then there is no corresponding record in DynamicValuesForClients for a particular attribute's value. Therefore we'll need to add a new record to DynamicValuesForClients.
  • Update records in DynamicValuesForClients - if a client already has a record in DynamicValuesForClients for a particular custom attribute, updating the attribute value requires updating the corresponding database record.
What approach we take depends on whether there already exists a record in the DynamicValuesForClients table for the ClientId and AttributeId pair. I created a stored procedure named lawfirm_AddOrUpdateDynamicValueForClient to handle this decision. This makes the ASP.NET code very simple: just loop through the AttributeValues Dictionary object and for each item call the lawfirm_AddOrUpdateDynamicValueForClient stored procedure, passing in the ClientId, AttributeId, and the value for that attribute. (Because the items in the AttributeValues Dictionary object are SqlParameter objects we can add them to the SqlCommand's Parameters collection using myCommand.Parameters.Add(AttributeValues(attributeId)).)

For Each AttributeId As Guid In AttributeValues.Keys
   myCommand.CommandText = "lawfirm_AddOrUpdateDynamicValueForClient"
   myCommand.CommandType = Data.CommandType.StoredProcedure
   myCommand.Parameters.AddWithValue("@ClientId", Request.QueryString("ID"))
   myCommand.Parameters.AddWithValue("@AttributeId", AttributeId)


The lawfirm_AddOrUpdateDynamicValueForClient stored procedure follows. It is pretty simple - it checks to see if there is already a record for the ClientId and AttributeId pair passed in. If so, then the record already exists in the table so an UPDATE statement is used; if not, the record needs to be added, so an INSERT statement is used instead.

CREATE PROCEDURE dbo.lawfirm_AddOrUpdateDynamicValueForClient
   @ClientId uniqueidentifier,
   @AttributeId uniqueidentifier,
   @DynamicValue sql_variant

IF EXISTS(SELECT 1 FROM DynamicValuesForClients WHERE ClientId = @ClientId AND AttributeId = @AttributeId)
   -- Record exists, so update
   UPDATE DynamicValuesForClients SET
      DynamicValue = @DynamicValue
   WHERE ClientId = @ClientId AND AttributeId = @AttributeId
   -- Record does not exist, so insert
   INSERT INTO DynamicValuesForClients(ClientId, AttributeId, DynamicValue)
   VALUES(@ClientId, @AttributeId, @DynamicValue)

That's it! I encourage you to download the code demo at the end of this article and closely examine the code in the page. One thing you'll notice is that the entire set of SQL statements in the Update Button's Click event handler are wrapped up in a transaction. This technique ensures that the set of INSERT and/or UPDATE statements that occur as each custom client attribute value is updated are treated as an atomic operation. For more on implementing transactional support in your .NET code, see Maintaining Database Consistency with Transactions.

The client's custom attribute values are saved to the database.

Looking Forward...

We now have a fully functional dynamic, data-drive web application! Customers can log on to the site and define custom client attributes and manage values for those custom attributes. Of course, there is much room for improvement and some lingering questions, like what happens if a customer redefines an existing custom attribute, and how can we extend the system to include more data types, and how do we add extra attributes to these data types, such as indicating that a particular string field is required or that a date value must be between some range of legal values? Part 4 discusses questions like these.

Until then... Happy Programming!

  • By Scott Mitchell


  • Download the code used in this article

    Further Reading

  • Creating a Dynamic Data-Driven User Interface (Part 1)
  • Creating a Dynamic Data-Driven User Interface (Part 2)
  • Accessing and Updating Data in ASP.NET
  • The sql_variant Data Type
  • Dynamic Controls in ASP.NET
  • Creating Dynamic Data Entry User Interfaces
  • Article Information
    Article Title: ASP.NET.Creating a Dynamic, Data-Driven User Interface (Part 3)
    Article Author: Scott Mitchell
    Published Date: September 3, 2008
    Article URL:

    Copyright 2021 QuinStreet Inc. All Rights Reserved.
    Legal Notices, Licensing, Permissions, Privacy Policy.
    Advertise | Newsletters | E-mail Offers