Categories
Bloggers
Blogs RSS feed

Developing JavaScript Widgets for Sitefinity CMS: Async Search Results

by Patrick Dunn

If you haven’t already you should read part one of this series where I describe how to get web API up and running within your Sitefinity project.

Everyone knows that a large part of the user experience is performance. You want your pages to load as fast as possible and while there are many factors in achieving a quick page load, one of them, and the one that’s often missed, is processing large amounts of data. With any search engine this can be an issue as content grows on your site. In order to prevent your search widgets from appearing sluggish or unresponsive you can remove the data queries from the page load and use a technique like the one we will discuss below to query for the search results after the page has already been displayed for the user.

In this instance we are going to use the Sitefinity API to build a web api controller that serves a JavaScript based widget (fitted for Sitefinity’s widget system) that helps return search results. This widget can help improve page load speeds as well as provide you a mechanism for extending your search capabilities on your site. As an example, we are going to build this widget with search by categories in mind.

Building the model

In the case of this widget we are going to build a model. The model will stand to make sure we return only the data we need. We can also use it to collect additional information from our results that is not a part of the IEnumberable<IDocument> object that would normally be returned from a search result.

Our model will just be a class model. It doesn’t inherit from anything. In this case I’ve named it SearchResultsModel.cs and placed it in my models folder we created adjacent to the controllers folder.

 

namespace SitefinityWebApp.Data.Models
{
    public class SearchResultsModel
    {
        //default properties
        public string Title { get; set; }
        public string Summary { get; set; }
        public string Link { get; set; }
        public int Count { get; set; }
        public int Total { get; set; }
        public string Type { get; set; }
        public SearchResultsModel() { }
 
        public SearchResultsModel(IDocument searchResult, string query, int count, int total)
        {
            var sum = searchResult.GetValue("Content");
            this.Summary = Highlighter(sum, query);
            this.Title = Highlighter(searchResult.GetValue("Title"), query);
            this.Link = searchResult.GetValue("Link");
            this.Total = total;
            this.Count = count;
            this.Type = TypeResolutionService.ResolveType(searchResult.GetValue("ContentType")).Name;
        }
 
        private string Highlighter(string summary, string query)
        {
            var newSum = summary;
            foreach (var word in query.Split('+'))
            {
                var regex = new Regex(word, RegexOptions.IgnoreCase);
                newSum = regex.Replace(newSum, "<span class='pdSearchHighlight'>" + word + "</span>");
            }
            if (newSum.Length < 500)
            {
                return newSum;
            }
            else
            {
                return newSum.Remove(500) + "...";
            }
        }
    }
}

 

As you can see the model is fairly small but it has an important job. In the primary method SearchResultsModel(opt + 4) we take the incoming search result, the query, the count of the current document being passed (in order) and the total number of results. We then “map” the values we need to the model’s public variables. This is how we return the data we need in a format we can expect without the extra junk that comes along. Some information we will be returning other than the title/summary/link include the Type of the content item (News, Blogs, etc) as well as the counts. The type is handy to return so we can do extra frontend work such as mapping images to specific types, etc.

You will also notice the Highlight method. This method takes the summary and the query and appends an additional class around matching words. You can then use CSS to highlight these words however you’d like by using the “pdSearchHighlight” class.

Building the controller

Following the techniques demonstrated in my first post we are going to build a new controller that will handle all the data operations of our search.

Here’s what the main method looks like to implement search.

//primary search function
public List<SearchResultsModel> GetSearchResultsFiltered(string query, string index, int take, int skip, string filter)
{
    //if we are not filtering, use the generic method
    if (filter == "null" || filter == null || filter == String.Empty)
    {
        return GetSearchResults(query, index, take, skip);
    }
    else
    {
        //initialize the search manager and execute the search
        var sm = SearchManager.GetManager();
        var raw = sm.Find(index, query);
        //set content links
        var results = raw.SetContentLinks();
        var opl = new List<SearchResultsModel>();
        //get the filters that we need
        var categoryArray = BuildFilters(filter);
        //pass some information to our model
        int i = skip;
        int x = raw.Count();
        foreach (var doc in results)
        {
            //loop through the results and match items to the category in the filter
            //add the item if it matches to our list in the form of our model
            i++;
            if (ItemInCategory(doc, categoryArray["Categories"]))
            {
                opl.Add(new SearchResultsModel(doc, query, i, x));
            }
 
        }
        //return the result to the client
        return opl.Skip(skip).Take(take).ToList();
    }
}

 

It’s first going to check whether or not a filter was passed. If there’s no filter it runs a more generic search method. Otherwise it continues and calls the search manager. The search manager then accepts an index and a query. It returns an IResult set which contains a bunch of IDocuments matching our search.

Our next step is to take our filters and build our categories to search for.

//Build the passed filters for use by Lucene
private Dictionary<string,string[]> BuildFilters(string query)
{
    var newMatch = new Dictionary<string, string[]>();
    var parsedQuery = HttpUtility.ParseQueryString(query);
    newMatch.Add("Categories", SetFilterItem(parsedQuery["inCategory"]));
    //We can add support for these later
    //newMatch.Add("Tags", SetFilterItem(parsedQuery["hasTag"]));
    //newMatch.Add("Types", SetFilterItem(parsedQuery["ofType"]));
    return newMatch;
}

Inside this method we initialize a new Dictionary object to store our matches. We then parse the querystring that we passed that contains our filters. We build the dictionary and return it to be used later.

Now that we have our filters we are going to loop through the raw results and find matches. The values x and i are sent to our model for counts. We are going to check if the item is a match by the ItemInCategory method.

//check if the found item is in the category
private bool ItemInCategory(IDocument resultItem, string[] categories)
{
    var type = TypeResolutionService.ResolveType(resultItem.GetValue("ContentType"));
    var manager = ManagerBase.GetMappedManager(type.FullName);
    var actualItem = manager.GetItem(type, Guid.Parse(resultItem.GetValue("Id")));
 
    //check for categories
    var tm = TaxonomyManager.GetManager();
    foreach (var category in categories)
    {
        category.Replace("-", " ");
        var actualCategory = GetCategoryByTitle(category);
        if (actualCategory != null)
        {
            var taxonId = tm.GetTaxa<HierarchicalTaxon>().Where(t => t.Id == actualCategory.Id).SingleOrDefault().Id;
            try
            {
                if (actualItem.FieldValue<TrackedList<Guid>>("Category").Contains(taxonId))
                {
                    return true;
                }
            }
            catch
            {
                //continue;
            }
        }
    }
    return false;
}

 

This method uses the Manager base to determine the content type and check if there’s a match on the category. If there’s a match, it returns true. Otherwise we return false which excludes the documents from the actual results we will be returning.

After we loop through all of the results we return the list after implementing our skip/take for paging.

Building the widget

Attached to this project is the full source for you to look at so we will just take a look at the important methods in the javascript for this widget. In this method we parse the query strings and we present the query to Sitefinity as close as possible to the default widget. This is so you can continue to use your default search widget with the async results.

function getSearchResults(query, index, skip, take) {
         
    if (getUrlVars()["searchQuery"] !== undefined) {
        //if the blogs have been loaded don't load again -> change of new blog during visit is low comp to performance
        $("#searchLoader").show();
        $('#searchResults').empty();
        var total = 0;
        var pages = 1;
        var filter = getUrlVars()["inCategory"];
        if (filter == undefined) {
            filter = "null";
        }
        else {
            filter = "inCategory=" + filter
        }
        $.getJSON('/api/searchresults/getsearchresultsfiltered/' + query + "/" + index + "/" + take + "/" + skip + "/"  + filter,
            function (data) {
                // Loop through the list of products.
                if (data.length > 0) {
                    $.each(data, function (key, val) {
                       
                        var newItem = $("#searchtemplate").html().replace(/{ Summary }/g, val.Summary).replace(/{ Link }/g, val.Link).replace(/{ Title }/g, val.Title);
                        $(newItem)  // Append to the list
                        .appendTo($('#searchResults'));
                        $("#searchLoader").hide();
                        total = val.Total;
                    });
                    if (total > take) {
                        pages = Math.round(total / take);
                        $("#pager").show();
                        $("#pager").html(displayPager(pages));
                    }
                }
                else {
                    $("#searchLoader").hide();
                    var listItem = '<li class="pdNoResults">Sorry, no matches were found.</li>';
                    $(listItem)  // Append to the list
                        .appendTo($('#searchResults'));
                }
            });
    }
    else
    {
            //do something because there was no search.
    }
}

 

You can find the template and the helper classes (for parsing querystrings, etc) in the full source example. The purpose of this function in the JavaScript file is to take the search data and call the web api we built. If it returns data we render the output based on our template. In the .ascx file there is a template that allows you to easily set up your results.

<%-- Template --%>
<div id="searchtemplate" data-type="template" style="display: none;">
    <li class="sfSearchResultItem">
        <div>
            <a href="{ Link }">{ Title }</a>
        </div>
        <div >
            <a class="sfSearchLink" href="{ Link }">{ Link }</a>
        </div>
        <div>
            { Summary }
        </div>
    </li>
</div>

Overall it's relatively easy to develop even a complex widget for search results using the jQuery and web api inside Sitefinity.

You can see the video demonstration here.

Download sample

9 comments

Leave a comment
  1. Mark Sep 11, 2013
    Thank you for the post Patrick. This, in conjunction with your last post has really helped me out with WebAPI with Sitefinity. My current problem is that I continue to run into authentication trouble with calls that I would like to be public and available to anonymous users. 

    I've decorated my controller class and methods with [AllowAnonymous] but continue to receive this message: {"Message":"User is not authenticated."}

    Have you found a way around this?
  2. Patrick Sep 11, 2013
    Hey Mark. I was actually thinking about making that my next post. Securing the web API with authentication, JWT tokens, etc. As far as anonymous access you have to make sure you treat the API with the same respect as you would in a widget, page, etc. It will return an authentication request or that error if you are using a part of the API that the user doesn't have access to. By default, if the objects are public, there shouldn't be an issue. I use this approach on my blog to load blog posts, search results, etc, for anonymous users.
  3. Mr Hand Sep 11, 2013
    Thanks Patrick, good stuff.

    Sure I'm doing something silly, but having an issue with my global.asax route not working for:
     - /api/searchresults/getsearchresultsfiltered/' + query + "/" + index + "/" + take + "/" + skip + "/"  + filter

    I'm assuiming it looks like this:

    routes.MapHttpRoute(
                    name: "ApiSearchResults",
                    routeTemplate: "api/{controller}/{action}/{query }/{index}/{take }/{skip }/{filter}",
                    defaults: new { query = RouteParameter.Optional, index= RouteParameter.Optional, take = RouteParameter.Optional, skip = RouteParameter.Optional, filter= RouteParameter.Optional }
                    );

    Am I on the right track or did I miss a step? Thanks!
  4. Mr Hand Sep 11, 2013
    Thanks Patrick, good stuff.

    Sure I'm doing something silly, but having an issue with my global.asax route not working for:
     - /api/searchresults/getsearchresultsfiltered/' + query + "/" + index + "/" + take + "/" + skip + "/"  + filter

    I'm assuiming it looks like this:

    routes.MapHttpRoute(
                    name: "ApiSearchResults",
                    routeTemplate: "api/{controller}/{action}/{query }/{index}/{take }/{skip }/{filter}",
                    defaults: new { query = RouteParameter.Optional, index= RouteParameter.Optional, take = RouteParameter.Optional, skip = RouteParameter.Optional, filter= RouteParameter.Optional }
                    );

    Am I on the right track or did I miss a step? Thanks!
  5. Patrick Sep 11, 2013
                routes.MapHttpRoute(
                   name: "FilterSearch",
                   routeTemplate: "api/{controller}/{action}/{query}/{index}/{take}/{skip}/{filter}",
                   defaults: new { id = RouteParameter.Optional }
                   );
                routes.MapHttpRoute(
                    name: "DefaultApi",
                    routeTemplate: "api/{controller}/",
                    defaults: new { id = RouteParameter.Optional }
                    );
    ---

    This is what I have for routes. If you want to use this make up then ensure you have provided values for each.
  6. Mr Hand Sep 11, 2013
    Thanks Patrick, good stuff.

    Sure I'm doing something silly, but having an issue with my global.asax route not working for:
     - /api/searchresults/getsearchresultsfiltered/' + query + "/" + index + "/" + take + "/" + skip + "/"  + filter

    I'm assuiming it looks like this:

    routes.MapHttpRoute(
                    name: "ApiSearchResults",
                    routeTemplate: "api/{controller}/{action}/{query }/{index}/{take }/{skip }/{filter}",
                    defaults: new { query = RouteParameter.Optional, index= RouteParameter.Optional, take = RouteParameter.Optional, skip = RouteParameter.Optional, filter= RouteParameter.Optional }
                    );

    Am I on the right track or did I miss a step? Thanks!
  7. Mr Hand Sep 11, 2013
    Thanks Patrick! Sorry for the double post, seems when I hit the back button it resubmitted my question.
  8. Milan Jan 09, 2014
    Hii,
    Extending Sitefinity Search and searching by category

    pls help with some code 
  9. Dmitry Jan 14, 2014
    For those who is in need of JavaScript widgets: http://perfectwidgets.com/Main. I just couldn’t but share this library of 300+ gauges.

    Leave a comment