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

AJAX-enabled Name Selector User Control

5.00/5 (38 votes)
24 Aug 2009CPOL11 min read 116.6K   2.2K  
Fully AJAX-enabled user control used to select names from a database in a drop-down format.

Introduction

The more I use sites like Facebook, the more fascinated I become with the controls they use. One control that I've been obsessed about is the one used to find other users. As you type, a very well formatted drop-down shows names with other information. Selecting one of those names then triggers more server-side work. This article is my attempt to develop a similar experience with the Microsoft stack: SQL for the database, ASP.NET 2.0 for the code, the AJAX Control Kit for the sizzle, and a splash of JavaScript because there's just no way around it.

Namechooser.gif

Background

I have made an attempt at this type of control before, but was largely unhappy with the result. The biggest problem with my past attempt was that it used the CallBack technology which leans heavily on JavaScript and sending text across the wire. There are many issues with this approach... and while admittedly leaner, offers less control than I prefer. Instead, I wanted an approach were the user experience felt client-side, but in fact I was able to manipulate things on the server and deliver meaningful and well-formatted results. The only way I could figure to accomplish this is to use Asynchronous Postbacks. One major draw-back to using postbacks, however, is that controls tend to lose their focus after a postback (asynch or not!), which is a disaster in a text box where the user expects to be able to type freely without having to re-select the box, so I needed to overcome that as well.

One other goal of this control is something completely portable in my application. This control is designed to be dropped on any page of my application in any location without any code configuration required.

Key Point #1 - The Database Stuff

Certainly, a search like this could work in a lot of scenarios. The example attached to this article focuses on people. In SQL Server 2005, I have three tables.

  • A Person table with Firstname, Lastname, JobTitleID, and LocationID.
  • A JobTitle table with JobTitleID, and JobTitle.
  • A Location table with LocationID and Location.

I then use the following Stored Procedure to search for matching people. The important part of this Stored Procedure is the WHERE clause. I am matching the string entered by the user by First Name, OR Last Name, OR First <space> Last, OR Last <comma><space> First.

SQL
CREATE PROCEDURE usp_searchPeople (@namecontains nvarchar(20))
AS

    SELECT
        P.FirstName,
        P.LastName,
        J.JobTitle,
        L.Location
    FROM
        Person as P 
        INNER JOIN JobTitle as J on P.JobTitleID = J.JobTitleID
        INNER JOIN Location as L on P.LocationID = L.LocationID
    WHERE
        @namecontains <> ''
        AND
        P.LastName like N'%' + @namecontains + '%'
        OR P.Firstname like N'%' + @namecontains + '%'
        or ((P.LastName + ', ' + P.FirstName) like N'%' + @namecontains + '%')
        OR ((P.FirstName + ' ' + P.LastName) like N'%' + @namecontains + '%')
    ORDER BY 
        P.Lastname, P.FirstName

Key Point #2 - The Classes

I really like using ObjectDataSources on my designer pages. Using them sort of forces you to abstract the database interaction into classes - which is a good thing in the world of OO. So, here are my two People classes: one is just the Person class, while the People class handles getting the data and returning a DataSet (a requirement if we want an ObjectDataSource to retrieve and ultimately populate a GridView). Nothing ground-breaking about these classes. However, in the People class, I am using the Application.Data DLL to fetch the data from SQL Server. If you are not familiar...you really should be. It is really nice. Also, I have set the database connection in my Web.Config (something you will have to adjust if you are going to test this).

VB
Public Class Person

    Private _Firstname As String

    Public Property Firstname() As String
        Get
            Return _Firstname
        End Get
        Set(ByVal value As String)
            _Firstname = value
        End Set
    End Property


    Private _Lastname As String

    Public Property Lastname() As String
        Get
            Return _Lastname
        End Get
        Set(ByVal value As String)
            _Lastname = value
        End Set
    End Property


    Private _JobTitle As String

    Public Property JobTitle() As String
        Get
            Return _JobTitle
        End Get
        Set(ByVal value As String)
            _JobTitle = value
        End Set
    End Property


    Private _Location As String

    Public Property Location() As String
        Get
            Return _Location
        End Get
        Set(ByVal value As String)
            _Location = value
        End Set
    End Property


End Class

Imports Microsoft.ApplicationBlocks.Data
Public Class People

    Public Function SearchPeople(ByVal searchstr As String) As DataSet
        Dim strSQL As String = "usp_SearchPeople"
        Dim params(0) As SqlClient.SqlParameter
        params(0) = New SqlClient.SqlParameter("@namecontains", searchstr)


        Try
            PeopleDS = SqlHelper.ExecuteDataset(_
              ConfigurationManager.AppSettings("MyDataBase"), _
              CommandType.StoredProcedure, strSQL, params)
        Catch ex As Exception
            _ErrMsg = ex.Message
        End Try

        Return PeopleDS

    End Function

    Private _ErrMsg As String

    Public Property ErrMsg() As String
        Get
            Return _ErrMsg
        End Get
        Set(ByVal value As String)
            _ErrMsg = value
        End Set
    End Property


    Private _PeopleDS As DataSet = New DataSet()
    Public Property PeopleDS() As DataSet
        Get
            Return _PeopleDS
        End Get
        Set(ByVal value As DataSet)
            _PeopleDS = value
        End Set
    End Property


    Private _Person As Person

    Public Property Person() As Person
        Get
            Return _Person
        End Get
        Set(ByVal value As Person)
            _Person = value
        End Set
    End Property

End Class

Key Point #3 - The User Control Designer Page

We now have enough to create our User Control designer page. The page will have a lot of stuff on it before we're done, but here are the important parts:

  • Search Box - a box where the user will type with a couple JavaScript references. I'll talk about those functions in section 4.
  • ASP.NET
    <asp:TextBox runat="server" ID="txtSearchStr" 
        Width="260" style="border:none;"
        onfocus="startSearching(this.id)" onblur="endSearching()" />
  • Update Panel - I wrap the rest of the control in an UpdatePanel so the grid can be updated without a full-page post-back as the user types in the search box. Notice that I've added a trigger to the panel. This will be fired from JavaScript eventually, but is critical to this working smoothly.
  • ASP.NET
    <asp:UpdatePanel runat="server" ID="upPersonSearch" UpdateMode="conditional">
        <Triggers>
            <asp:AsyncPostBackTrigger ControlID="txtSearchStr" EventName="TextChanged" />
        </Triggers>
        <ContentTemplate>
  • ObjectDataSource - this is the work-horse of the control that handles retrieving the data and passing it to the GridView. The only interesting piece here is setting the default value of the search parameter to some random characters so when the page first loads and the GridView asks for its data, nothing will be passed. Also note that the TextBox control is identified as the SelectParameter.
  • ASP.NET
    <asp:ObjectDataSource runat="server" ID="odsPeopleList" 
           TypeName="ASPNETPersonSearch.People" SelectMethod="SearchPeople">
        <SelectParameters>
            <asp:ControlParameter ControlID="txtSearchStr" 
                      Name="namecontains" DefaultValue="@@##$$" />
        </SelectParameters>
    </asp:ObjectDataSource>
  • GridView - the results are displayed in a GridView in my example. Of course, you could use any of the data objects in ASP.NET (ListView, DataView, etc.). The GridView is wired up to the ObjectDataSource and identifies PeopleID as the DataKey. This will be important when we get to handling the user selecting a search result.
  • XML
    <asp:GridView runat="server" ID="gvPeopleList" 
               DataSourceID="odsPeopleList" AutoGenerateColumns="false" 
               ShowFooter="false" ShowHeader="False" 
               DataKeyNames="PeopleID" >
        <Columns>
            <asp:TemplateField>
                <ItemTemplate>
                <div>
                    <asp:LinkButton runat="server" 
                       style="text-decoration:none;" ID="btn1" 
                       SkinID="plain" CommandName="Select" />
                </div>
                </ItemTemplate>
            </asp:TemplateField>
        </Columns>
        <EmptyDataTemplate>
            <asp:Label runat="server" ID="lbl1" />
        </EmptyDataTemplate>
    </asp:GridView>

Let's stop there and examine what we have. On the page, we have a TextBox, an UpdatePanel, an ObjectDataSource and a grid. We could run the page like this and it would work...sort of. The big problem I need to overcome is that the TextChanged property of the TextBox will only fire when the user clicks the <enter> or the <tab> key. But, that's not what I want. I want it to fire as they type. Sigh....JavaScript.

Key Point #4 - The JavaScript

To accomplish that on-the-fly feeling of typing and seeing the results, you need some sort of JavaScript. My first thought was to use the OnKeyUp or OnKeyPress JavaScript functions...and in fact, that is what I tried at first. But there is a problem. If a person types with any type of speed, and you trigger the post-back and update of your data, there is a lag behind what the person types and what search string is submitted. You could wrap all of the JavaScript in a timer function that keeps track of the time between key strokes and resets the clock if the user types some more...but the more I tested that approach, the more awkward it worked. That's when I hit on the following:

  • Start some function OnFocus of the text control, and stop it OnBlur.
  • Then, instead of responding to the user's keystrokes, just trigger the postback every second or so.

Of course, that may seem excessive, so I set a variable to hold the last-used search value. If it matches what is already in the search box (that is, the person has typed a value, the search has triggered, and the they sat there for 20 seconds looking through the names), the JavaScript just skips over the postback, saving us a roundtrip to the server.

Here are the highlights from the JavaScript:

  • Global variables - used as reference in functions. I actually didn't put the prm variable in the PeopleSearch.js file of the project. Instead, I placed it on the User Control designer page. I also wrapped it in logic that inspects whether it already exists - either created for another purpose, or because I have placed two of these user controls on the same page. This is because I needed to reference something in the DOM of a rendered page, rather than create a new object.
  • JavaScript
    //global variables defined in the PeopleSearch.vb file
    var intervalTimerId = 0;
    var currenttext = "";
    
    //script reference at the top of the PeopleSearch.ascx page
    <script language="javascript" type="text/javascript">
        //in case there are two User controls on the same page, 
        //we only set this variable once
        if(!prm) {var prm = Sys.WebForms.PageRequestManager.getInstance();}
    </script>
  • StartSearching - get's the party started. There really isn't much to this. Simply tells JavaScript to start executing the doPostBackAsynch function every second. This function is triggered from the TextBox's OnFocus event. When referenced in the TextBox, notice that I set the ID of the control programmatically (with this.id). This is critical to being able to reuse this control anywhere - and multiple times on the same page.
  • JavaScript
    function startSearching(cid) {
        intervalTimerId = setInterval ( "doPostBackAsync( '" + cid + "', '' )", 1000 );
    }
  • endSearching - That's enough of that! Kill the interval.
  • JavaScript
    function endSearching() {
        clearInterval (intervalTimerId) ;
    }
  • doPostBackAsync - Ah, the magic. First, collect the search string from the text box. Then, make sure the user has input a reasonable-length search string (greater than two) and also that the search string in the text box does not match the global variable currenttext. This is the trick I used to ensure we don't hit a bazillion server calls when someone parks in the text box then goes to get a coffee. Of course, the JavaScript interval will keep firing, but, shoot, that's on the client-side....so, who cares :)
  • The real good stuff in this function is the following code:

    JavaScript
    prm._asyncPostBackControlClientIDs.push(eventName);

    I ran in to this through some Googling and it's genius. This is how to trigger an UpdatePanel from JavaScript. The .push method simulates the __doPostBack event. Here is the full code:

    JavaScript
    function doPostBackAsync( eventName, eventArgs )
        {  var tbox = document.getElementById(eventName);
            if(tbox.value.length > 2 && tbox.value != currenttext )
            {
                if( !Array.contains( prm._asyncPostBackControlIDs, eventName) )
                {prm._asyncPostBackControlIDs.push(eventName);}
             
                if( !Array.contains( prm._asyncPostBackControlClientIDs, eventName) )
                {
                    prm._asyncPostBackControlClientIDs.push(eventName);
                }
                __doPostBack( eventName, eventArgs );
                currenttext = tbox.value;
            }
            
        }

OK, we're almost done. The last part is registering all of this JavaScript. I chose to use a ScriptManagerProxy on the designer page of the control. This allows you to reference and register the functions of a .js file where you need it, instead of putting the reference on the consuming .aspx page or worse, in a Master page.

ASP.NET
<asp:ScriptManagerProxy runat="server" ID="sm1">
    <Scripts>
        <asp:ScriptReference Path="~/js/EmployeeSearch.js" />
    </Scripts>
</asp:ScriptManagerProxy>

Key Point #5 - The Code-Behind

Now that we've done all this work to allow us to control things on the server-side...let's look at what we can do. First, since this is a user control, it's reasonable to assume that you will want to bind data and pass data back out to the consuming page. So, you have to import the Web.UI and ComponentModel namespaces.

VB
Imports System.Web.UI
Imports System.ComponentModel

Next, let's consider what our results will look like in our GridView. I could just leave it as First Name and Last Name, but that's just boring. Instead, as the row is bound, I chose to get the search string, inspect it for a comma (the person is entering Lastname, Firstname), and then display the results with highlights to show them what the results matched in their search. This seems a lot of work, but it makes the user experience very natural. Check out the looping image at the top of the article again. Notice that when I enter my first name, then last, the results are appearing as I'm typing. Then, when I go to last <comma> first, the results switch to the way I'm entering the search. Also, I use the People object in this method which makes handling the names a snap. Finally, I'm choosing to just show the results as delivered by the People.SearchPeople method. You could easily add pictures, links, or other things about this person in this method to punch up the search results.

VB
Private Sub gvPeopleList_RowDataBound(ByVal sender As Object, _
            ByVal e As System.Web.UI.WebControls.GridViewRowEventArgs) _
            Handles gvPeopleList.RowDataBound
    'retrieve the text entered by the user
    Dim searchStr As String = txtSearchStr.Text

    If e.Row.RowType = DataControlRowType.DataRow Then

        'create new person object
        Dim peep As New Person()

        'when we are done inspecting the input text, 
        'this will hold the string to show users
        'in the result box
        Dim Namestr As String = ""

        'if they person is entering Lastname, Firstname
        Dim strArr As String() = Split(searchStr, ",")

        'if there is a comma in the search text
        If UBound(strArr) > 0 Then  'there was a comma
            'if the Last name text is more than just white space, 
            'show the matches using the Highlight function
            peep.Lastname = IIf(Trim(strArr(0)).Length > 0, _
                 Utilities.Highlight(Trim(strArr(0)), _
                 e.Row.DataItem("LastName")), _
                 e.Row.DataItem("LastName"))

            'if the first name text is more than just white space, 
            'show the matches using the Highlight function
            peep.Firstname = IIf(Trim(strArr(1)).Length > 0, _
                 Utilities.Highlight(Trim(strArr(1)), _
                 e.Row.DataItem("Firstname")), _
                 e.Row.DataItem("Firstname"))

            'set the presentation variable
            Namestr = peep.Lastname & ", " & peep.Firstname
        Else
            'if there was no comma, then search for a space
            strArr = Split(searchStr)
            If UBound(strArr) > 0 Then
                'if there was a space....
                peep.Lastname = IIf(Trim(strArr(1)).Length > 0, _
                     Utilities.Highlight(Trim(strArr(1)), _
                     e.Row.DataItem("LastName")), _
                     e.Row.DataItem("LastName"))
                peep.Firstname = IIf(Trim(strArr(0)).Length > 0, _
                     Utilities.Highlight(Trim(strArr(0)), _
                     e.Row.DataItem("Firstname")), _
                     e.Row.DataItem("Firstname"))
                Namestr = peep.Firstname & " " & peep.Lastname
                'if all the other options fail, just highlight 
                'the First and Last names from the search
                'results and set the presentation variable
            Else
                peep.Firstname = Utilities.Highlight(searchStr, _
                                     e.Row.DataItem("Firstname"))
                peep.Lastname = Utilities.Highlight(searchStr, _
                                     e.Row.DataItem("LastName"))
                Namestr = peep.Lastname & ", " & peep.Firstname
            End If
        End If


        'set the persons location
        peep.Location = e.Row.DataItem("Location")

        'if the person has a job title, set it
        peep.JobTitle = IIf(e.Row.DataItem("JobTitle") _
           Is DBNull.Value, "", e.Row.DataItem("JobTitle"))


        'Find the link button in the grid
        Dim btn As LinkButton = TryCast(e.Row.FindControl("btn1"), LinkButton)

        'set the text of the link button
        btn.Text = "<b>" & Namestr & "</b>" & _
                   "<br />" & peep.JobTitle & _
                   " - " & peep.Location

    ElseIf e.Row.RowType = DataControlRowType.EmptyDataRow Then
        'If there person has entered more than two characters, 
        'show them that there were no search results
        If txtSearchStr.Text.Length > 2 Then
            Dim lbl As Label = TryCast(e.Row.FindControl("lbl1"), Label)
            lbl.Text = "No matching records were found for the search string: <i>" & _
                       searchStr & "</i>."

            'otherwise, do not show the empty row template
        Else
            e.Row.Visible = False
        End If

    End If
End Sub

OK, so the results look pretty. Now, let's think about how we want to handle a user clicking on a result. First, I like setting up a Bindable property that always returns the DataKey PeopleID from the selected row of the GridView. This is a pretty elegant way of passing the selected value of your gvPeopleList GridView to your consuming page. In theory, you could also pass in a value for PeopleID, but I haven't covered that situation in this post.

VB
Private _PeopleID As Integer = 0
<Bindable(True)> <Browsable(True)> _
Public Property PeopleID() As Integer
    Get
        Return CType(gvPeopleList.DataKeys(_
          gvPeopleList.SelectedIndex).Item("PeopleID"), Integer)
    End Get
    Set(ByVal value As Integer)
        _PeopleID = value
    End Set
End Property

We can now tell our consuming page what "value" has been selected, but that's not good enough. We also need to trigger a public event that the consuming page can watch for and react to - as if our User Control were a "normal" ASP control, like a DropDownList's SelectedIndexChanged event. In this case, I've wired up this public event (PersonSelected) to the GridView's SelectedIndexChanged event.

VB
Public Event PersonSelected(ByVal sender As Object, ByVal e As System.EventArgs)

Private Sub gvPeopleList_SelectedIndexChanged(ByVal sender As Object, _
        ByVal e As System.EventArgs) Handles gvPeopleList.SelectedIndexChanged
    RaiseEvent PersonSelected(sender, e)
End Sub

Phew. That's it. That is the control. You could go crazy on this code-behind and do all types of fancy things. In fact, I have several ideas for this control. But, as advertised, we now have a User Control we could drop on any page in my solution to search people and return the matching ID. One last thing about the control: I don't like to reference controls from the consuming page's header. Instead, I prefer to add the control to my web.config, like this:

XML
<system.web>
    <pages>
      <controls>
         <add tagPrefix="uc1" 
            src="~/UserControls/PersonSearch.ascx" 
            tagName="PersonSearch"/>
      </controls>
    </pages>
</system.web>

Key Point #6 - Using the Control

If you look at the solution I've attached to this post, you will see there are a lot of files that go in to this control.

  • Classes/People.vb, Classes/Person.vb
  • css/default.css
  • js/PersonSearch.js
  • UserControls/PeopleSearch.ascx, UserControls/PeopleSearch.ascx.vb

That seems like a ton of overhead, but it isn't really. And the best part is, now we can consume this pretty complex collection of items in a "normal" ASPX page without any trouble. Here is a sample designer and code-behind page to "use" this control:

ASP.NET
<%@ Page Language="vb" AutoEventWireup="false" 
    CodeBehind="Default.aspx.vb" Inherits="ASPNETPersonSearch._Default" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
    <title>Untitled Page</title>
    <link href="css/default.css" rel="stylesheet" type="text/css" />
</head>
<body>
    <form id="form1" runat="server">
        <asp:ScriptManager ID="ScriptManager1" runat="server" />
    <div>
        <table>
            <tr>
                <td>Choose a Person</td>
                <td><uc1:PersonSearch runat="server" id="ps1" /></td>
            </tr>
        </table>
    </div>
    </form>
</body>
</html>

Very clean...right? And the code-behind:

VB
Partial Public Class _Default
    Inherits System.Web.UI.Page

    Protected Sub Page_Load(ByVal sender As Object, _
                  ByVal e As System.EventArgs) Handles Me.Load

    End Sub

    Private Sub ps1_PersonSelected(ByVal sender As Object, _
            ByVal e As System.EventArgs) Handles ps1.PersonSelected
        Dim pid As Integer = ps1.PeopleID
        'do stuff with the selected person ID
    End Sub
End Class

Heck, you could even have an UpdatePanel and use the PersonSelected event as a trigger like this:

ASP.NET
<table>
    <tr>
        <td>Choose a Person</td>
        <td>
            <uc1:PersonSearch runat="server" id="ps1" />
        </td>
    </tr>
</table>
<asp:UpdatePanel runat="server" ID="upResult" UpdateMode="conditional">
    <Triggers>
        <asp:AsyncPostBackTrigger ControlID="ps1" EventName="PersonSelected" />
    </Triggers>
    <ContentTemplate>
        <asp:Label runat="server" ID="lblresult" />
    </ContentTemplate>
</asp:UpdatePanel>

You should know that if you just copy the solution attached to this post and try to run it, it will not work. You must change the database reference in the web.config file. If you just try to run this project, you will likely get this error:

The DataSet in data source 'odsPeopleList' does not contain any tables.

This error means that the SQL in your People.SearchPeople method is not filling up a DataSet (rather than returning no matching results). That only happens when there is a problem with the query itself.

Conclusion

There you have it. A really portable, pretty, easy-to-implement search ComboBox using AJAX, ASP.NET and a smidge of JavaScript. One thing I've found while using this control is if you place the control itself in an UpdatePanel, your user will lose focus on the control unless you configure the "parent" UpdatePanel to: ChildrenAsTriggers="false". However, you can throw this control on any .aspx page, or in the <content> of any child page, or heck, even in a master page if you'd like.

One thing I did not cover in this post is styling the results in the grid. I've included a .css file with the sample project, but it doesn't deal with the grid at all.

License

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