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.
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.
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 ObjectDataSource
s 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).
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: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: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: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.
<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.
var intervalTimerId = 0;
var currenttext = "";
<script language="javascript" type="text/javascript">
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.
function startSearching(cid) {
intervalTimerId = setInterval ( "doPostBackAsync( '" + cid + "', '' )", 1000 );
}
endSearching - That's enough of that! Kill the interval.
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:
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:
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: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.
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.
Private Sub gvPeopleList_RowDataBound(ByVal sender As Object, _
ByVal e As System.Web.UI.WebControls.GridViewRowEventArgs) _
Handles gvPeopleList.RowDataBound
Dim searchStr As String = txtSearchStr.Text
If e.Row.RowType = DataControlRowType.DataRow Then
Dim peep As New Person()
Dim Namestr As String = ""
Dim strArr As String() = Split(searchStr, ",")
If UBound(strArr) > 0 Then
peep.Lastname = IIf(Trim(strArr(0)).Length > 0, _
Utilities.Highlight(Trim(strArr(0)), _
e.Row.DataItem("LastName")), _
e.Row.DataItem("LastName"))
peep.Firstname = IIf(Trim(strArr(1)).Length > 0, _
Utilities.Highlight(Trim(strArr(1)), _
e.Row.DataItem("Firstname")), _
e.Row.DataItem("Firstname"))
Namestr = peep.Lastname & ", " & peep.Firstname
Else
strArr = Split(searchStr)
If UBound(strArr) > 0 Then
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
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
peep.Location = e.Row.DataItem("Location")
peep.JobTitle = IIf(e.Row.DataItem("JobTitle") _
Is DBNull.Value, "", e.Row.DataItem("JobTitle"))
Dim btn As LinkButton = TryCast(e.Row.FindControl("btn1"), LinkButton)
btn.Text = "<b>" & Namestr & "</b>" & _
"<br />" & peep.JobTitle & _
" - " & peep.Location
ElseIf e.Row.RowType = DataControlRowType.EmptyDataRow Then
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>."
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.
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.
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:
<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:
<%@ 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:
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
End Sub
End Class
Heck, you could even have an UpdatePanel
and use the PersonSelected
event as a trigger like this:
<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.