Extending search field with suggestion box

Posted on September 17, 2009 by Frederik Vig in ASP.NET, Code Snippet, EPiServer, JavaScript

Disclaimer: In this example I’ve kept the code simple to make it easier to read and to give you an idea of how you might approach something like this with EPiServer. This code should not be used in production scenarios since it will use a lot of resources. Use at own risk :).

Update – 12.10.2009

Updated production code with a new custom filter that removes pages that the Everyone role doesn’t have access to.
Also updated the Get caching method. See comments below for further details.

Update – 19.09.2009

Added example of production code you might consider.

We’ve all used Google Suggest before. When you start typing a word or sentence, Google comes up with suggestions to what we might be searching for.
Example of Google Suggest
This is to help users quickly find what they want.

In this blog post we’re going to do the same by extending QuickSearch that ships with the EPiServer Public Templates.

I decided to use jQuery and the AutoComplete plugin for this, but you can use any script you like (most are pretty similar).

The result

Example of search suggest

The Code

First start by modifying the QuickSearch.ascx:

<asp:Panel runat="server" CssClass="QuickSearchArea" DefaultButton="SearchButton">
	<asp:Label ID="SearchLabel" runat="server" AssociatedControlID="SearchText" CssClass="hidden" Text="<%$ Resources: EPiServer, navigation.search %>" />
    <asp:TextBox ID="SearchText" TabIndex="1" runat="server" CssClass="quickSearchField" autocomplete="off" />
    <asp:ImageButton ID="SearchButton" runat="server" ImageUrl="~/Templates/Public/Images/MainMenuSearchButton.png" ToolTip="<%$ Resources: EPiServer, navigation.search %>" CausesValidation="false" CssClass="quickSearchButton" OnClick="Search_Click" />
</asp:Panel>
 
<script type="text/javascript">
//<![CDATA[ 
    $("#<%=SearchText.ClientID %>").autocomplete("/path/to/file/SearchSuggest.aspx", {
        minChars: 2,
        max: 10
    });
//]]> 
</script>

A few things to note. I removed the default text from the TextBox and added the attribute autocomplete=”off”, this is to ensure that the users browser does not store the information (How to Turn Off Form Autocompletion). I then attached the AutoComplete plugin to the TextBox and changed a few of its default properties.

As you can see there is not much code that is needed when mostly using the default settings. You can of course do a lot more. Browse through the AutoComplete plugin documentation and see the demos to get a few ideas.

Now for the main part, SearchSuggestion.aspx. This is just an empty web forms page with all the code in the code-behind:

using System;
using System.Text;
using System.Web;
using EPiServer;
using EPiServer.Core;
using EPiServer.Filters;
using EPiServer.Security;
 
namespace FV.Templates.FV.Pages
{
    public partial class SearchSuggest : TemplatePage
    {
        protected override void OnLoad(EventArgs e)
        {
            // #1
            string query = HttpUtility.HtmlEncode(Request.QueryString["q"]);
            string limit = HttpUtility.HtmlEncode(Request.QueryString["limit"]);
 
            if (!string.IsNullOrEmpty(query))
            {
                var sb = new StringBuilder();
 
                // #2
                var criterias = new PropertyCriteriaCollection();
 
                var queryCriteria = new PropertyCriteria
                                        {
                                            Condition = CompareCondition.StartsWith,
                                            Name = "PageName",
                                            Value = query,
                                            Type = PropertyDataType.String,
                                            Required = true
                                        };
 
                var pubCriteria = new PropertyCriteria
                                      {
                                          Condition = CompareCondition.Equal,
                                          Name = "PagePendingPublish",
                                          Value = false.ToString(),
                                          Type = PropertyDataType.Boolean,
                                          Required = true
                                      };
 
                criterias.Add(queryCriteria);
                criterias.Add(pubCriteria);
 
                // #3
                var pages = DataFactory.Instance.FindPagesWithCriteria(PageReference.StartPage, criterias, CurrentPage.LanguageBranch);
 
                int tempLimit = Convert.ToInt32(limit);
                int pagesCount = pages.Count;
 
                // #4
                if (tempLimit > pagesCount)
                {
                    tempLimit = pagesCount;
                }
 
                // #5
                for (int i = 0; i < tempLimit; i++)
                {
                    sb.Append(pages[i].PageName);
                    sb.Append(Environment.NewLine);
                }
 
                // #6
                Response.AddHeader("Content-Type", "text/html");
                Response.Write(sb.ToString());
 
                Response.End();
            }
        }
    }
}
  • In #1 we retrieve the user input and the limit (which we set earlier with the max property in QuickSearch.ascx), we then HTML encode it (something you should always do when receiving user input).
  • In #2 we build the criteria collection with our search options. We search for pages that start with what the user typed in and then make sure that only published pages are returned.
  • In #3 we do the actual searching with the FindPagesWithCriteria method where we also set the current language branch (only return pages in that language).
  • In #4 we make sure that we don’t have less pages than the limit, if so we set the pages count to be the limit.
  • In #5 we go through pages (PageDataCollection) and add the PageName to sb (StringBuilder).
  • In #6 we return the result back

Below is the code for SearchSuggest.aspx.cs updated to use the SearchDataSource instead of FindPagesWithCriteria (this will not only search the PageName but all properties that are search able):

using System;
using System.Text;
using System.Web;
using System.Web.UI;
using EPiServer;
using EPiServer.Core;
using EPiServer.Security;
using EPiServer.Web.WebControls;
 
namespace FV.Templates.FV.Pages
{
    public partial class SearchSuggest : TemplatePage
    {
        protected override void OnLoad(EventArgs e)
        {
            string query = HttpUtility.HtmlEncode(Request.QueryString["q"]);
            string limit = HttpUtility.HtmlEncode(Request.QueryString["limit"]);
 
            if (!string.IsNullOrEmpty(query))
            {
                var sb = new StringBuilder();
 
                var searchDataSource = new SearchDataSource
                {
                    SearchQuery = query,
                    AccessLevel = AccessLevel.Read,
                    PublishedStatus = PagePublishedStatus.Published,
                    PageLink = PageReference.StartPage,
                    LanguageBranches = CurrentPage.LanguageBranch,
                    MaxCount = Convert.ToInt32(limit)
                };
 
                foreach (PageData page in searchDataSource.Select(DataSourceSelectArguments.Empty))
                {
                    sb.Append(page.PageName);
                    sb.Append(Environment.NewLine);
                }
 
                Response.AddHeader("Content-Type", "text/html");
                Response.Write(sb.ToString());
 
                Response.End();
            }
        }
    }
}

Production code

Like I said in the beginning, this code should not be used in production scenarios since it goes through the entire site searching for matching PageNames each time there is a request.

I’ve added some example code of how you might approach this in a production scenario. Basically I just use the FindPagesWithCriteria method to find all the pages and then store the PageName of each in an array that I then store in the cache for easy access.

using System;
using System.Linq;
using System.Text;
using System.Web;
using EPiServer;
using EPiServer.Core;
using EPiServer.Filters;
using EPiServer.Security;
using FV.Templates.FV.Filters;
 
namespace FV.Templates.FV.Pages
{
    public partial class ProductionSearchSuggestion : TemplatePage
    {
        protected override void OnLoad(EventArgs e)
        {
            string query = HttpUtility.HtmlEncode(Request.QueryString["q"]);
            string limit = HttpUtility.HtmlEncode(Request.QueryString["limit"]);
 
            if (!string.IsNullOrEmpty(query))
            {
                var sb = new StringBuilder();
                string[] pageNames;
 
                const string pageNamesCacheKey = "AllPageNames";
 
                if (!Get(pageNamesCacheKey, out pageNames))
                {
                    var criterias = new PropertyCriteriaCollection();
 
                    var pubCriteria = new PropertyCriteria
                    {
                        Condition = CompareCondition.Equal,
                        Name = "PagePendingPublish",
                        Value = false.ToString(),
                        Type = PropertyDataType.Boolean,
                        Required = true
                    };
 
                    criterias.Add(pubCriteria);
 
                    var pages = DataFactory.Instance.FindPagesWithCriteria(PageReference.StartPage, criterias, CurrentPage.LanguageBranch);
 
                    new FilterRole("Everyone").Filter(pages);
 
                    var pagesCount = pages.Count;
                    pageNames = new string[pagesCount];
 
                    for (int i = 0; i < pagesCount; i++)
                    {
                        pageNames[i] = pages[i].PageName;
                    }
 
                    Add(pageNames, pageNamesCacheKey);
                }
 
                int tempLimit = Convert.ToInt32(limit);
 
                var result = new string[tempLimit];
                int resultCounter = 0;
 
                for (int i = 0; i < pageNames.Length; i++)
                {
                    if (resultCounter <= tempLimit && pageNames[i].ToLower().StartsWith(query.ToLower()) && !result.Contains(pageNames[i]))
                    {
                        result[resultCounter++] = pageNames[i];
                    }
                }
 
                for (int i = 0; i < resultCounter; i++)
                {
                    sb.Append(result[i]);
                    sb.Append(Environment.NewLine);
                }
 
                Response.AddHeader("Content-Type", "text/html");
                Response.Write(sb.ToString());
 
                Response.End();
            }
        }
 
        // Caching methods from http://johnnycoder.com/blog/2008/12/10/c-cache-helper-class/
        public bool Exists(string key)
        {
            return HttpContext.Current.Cache[key] != null;
        }
 
        public bool Get<T>(string key, out T value) where T:class
        {
            try
            {
                value = HttpContext.Current.Cache[key] as T;
                if (value == null)
                {
                    value = default(T);
                    return false;
                }
            }
            catch
            {
                value = default(T);
                return false;
            }
 
            return true;
        }
 
        public void Add<T>(T o, string key)
        {
            HttpContext.Current.Cache.Insert(
                key,
                o,
                null,
                DateTime.Now.AddMinutes(3600),
                System.Web.Caching.Cache.NoSlidingExpiration);
        }
    }
}

The FilterRole class

using System;
using EPiServer.Core;
using EPiServer.Filters;
 
namespace FV.Templates.FV.Filters
{
    public class FilterRole : IPageFilter
    {
        public string RoleName
        {
            get;
            set;
        }
 
        public FilterRole()
        {
        }
 
        public FilterRole(string roleName)
        {
            this.RoleName = roleName;
        }
 
        public void Filter(PageDataCollection pages)
        {
            for (int i = pages.Count - 1; i >= 0; i--)
            {
                bool isMatch = false;
                var acl = pages[i].ACL;
 
                if (acl != null)
                {
                    foreach (var role in acl)
                    {
                        if (role.Key == this.RoleName)
                        {
                            isMatch = true;
                        }
                    }
                }
 
                if (!isMatch)
                {
                    pages.RemoveAt(i);
                }
            }
        }
 
        public void Filter(object sender, FilterEventArgs e)
        {
            this.Filter(e.Pages);
        }
 
        public bool ShouldFilter(PageData page)
        {
            throw new NotImplementedException();
        }
    }
}

Download the code

Related Posts: