Extending search field with suggestion box
Posted on September 17, 2009 by Frederik Vig in ASP.NET, Code Snippet, EPiServer, JavaScriptDisclaimer: 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.
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
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(); } } } |
Stefan Forsberg says:
Post Author October 6, 2009 at 10:58This is really offtopic and I realise you didn’t write the caching methods, but may I suggest you change the way it fetches the data from cache?
At the moment it’s something like this (pseudcode):
if(HttpContext.Current.Cache[key] == null) set default value, otherwise return HttpContext.Current.Cache[key] cast to T. The problem with that is that there’s a chance (however unlikely) for a race condition here if the item with key is removed from cache after you do the null check. You’ll then get a exception, which can be somewhat of a pain to localize.
I think it would be better to do something like this (this does requiere T to be ref type though):
var value = HttpContext.Current.Cache[key] as T. If value is null return the default otherwise return value.
Hope that made sense.
Magnus Aycox says:
Post Author October 9, 2009 at 09:31Nice post. But…
If you are to cache the resulting page names and you are filtering on the current users permissions you open up for faulty or worse, insecure, behaviour.
What if the user that visits the search page has got admin permissions thereby collecting all pages of the site including all the business secrets (which of course includes compromising secrets in the page name). When the average Joe user visits the site he gets served with all these juicy page names that is above his access level.
Even worse in case that you have used SearchDataSource because then it’s not merely the page names that are exposed.
These flaws corrected, this makes for a mean auto complete… (Of course even “meaner” if used with the access flaw ;o)
Frederik Vig says:
Post Author October 9, 2009 at 12:36Thanks for both your comments!
@Stefan – I think I understand what you mean. I’ll do a test and update the code accordingly.
@Magnus – You’re absolutely right! I’m going to update the code this weekend.
Frederik Vig says:
Post Author October 12, 2009 at 23:14@Stefan – Updated the Get method now.
@Magnus – Added a filter to remove all pages that the Everyone group doesn’t have access to.