How to search by document type and property in Umbraco

Posted written by Paul Seal on January 16, 2017 Umbraco

About this post

This post shows you how to do more of the advanced searching in Umbraco. You can search by specific document types and fields.

If you are just looking for a simple search then have a look at one of my previous posts which uses the built in Umbraco.Search() Simple Umbraco Search Example

A description of what this code will do:

  • This code allows you to search in your umbraco site telling it what fields to look for the search term in and you can specify which document types you want to return resuls for.
  • The form uses Ajax so you get results loading below the search box without reloading the page.
  • It uses paging so you don't have to return all results at once.

How this post is made up

There are several parts to this post, I will list them and link to each of them so you can go straight to them if you don't want to follow along.

A preview of it in action

This animated gif shows you what it can do:

1

Watch the video

If you want to see me explain the code, implement it on a site and also show you how to filter the search results by category, then watch this video.

Watch on YouTube

Models

SearchViewModel

This is the main model which you interact with when searching for something.

using System.Collections.Generic;

namespace CodeShare.Library.Models
{
    public class SearchViewModel
    {
        public string SearchTerm { get; set; }
        public string DocTypeAliases { get; set; }
        public string FieldPropertyAliases { get; set; }
        public int PageSize { get; set; }
        public int PagingGroupSize { get; set; }
        public List<SearchGroup> SearchGroups { get; set; }
        public SearchResultsModel SearchResults { get; set; }
    }
}

SearchGroup

This holds the group of fields and search terms to search for.

namespace CodeShare.Library.Models
{
    public class SearchGroup
    {
        public string[] FieldsToSearchIn { get; set; }
        public string[] SearchTerms { get; set; }

        public SearchGroup(string[] fieldsToSearchIn, string[] searchTerms)
        {
            FieldsToSearchIn = fieldsToSearchIn;
            SearchTerms = searchTerms;
        }
    }
}

SeachResultsModel

This model holds the search results and has a property on it for the paging controls

using System.Collections.Generic;
using System.Linq;
using Umbraco.Core.Models;

namespace CodeShare.Library.Models
{
    public class SearchResultsModel
    {
        public string SearchTerm { get; set; }
        public IEnumerable<IPublishedContent> Results { get; set; }
        public bool HasResults { get { return Results != null &amp;&amp; Results.Count() > 0; } }
        public int PageNumber { get; set; }
        public int PageCount { get; set; }
        public int TotalItemCount { get; set; }
        public PagingBoundsModel PagingBounds { get; set; }
    }
}

PagingBoundsModel

This holds the information about what paging buttons to show, i.e. first, last, prev, next, and what page numbers too.

namespace CodeShare.Library.Models
{
    public class PagingBoundsModel
    {
        public int StartPage { get; set; }
        public int EndPage { get; set; }
        public bool ShowFirstButton { get; set; }
        public bool ShowLastButton { get; set; }

        public PagingBoundsModel(int startPage, int endPage, bool showFirstButton, bool showLastButton)
        {
            StartPage = startPage;
            EndPage = endPage;
            ShowFirstButton = showFirstButton;
            ShowLastButton = showLastButton;
        }
    }
}

Views

You can put you own markup around it, this is just an example using bootstrap

@inherits Umbraco.Web.Mvc.UmbracoTemplatePage

@{
    Layout = "Master.cshtml";
}

@section Head
{
    <style>
        button.current { font-weight: 700; }
    </style>
}

    <section class="container">
        <div class="row">
            <div class="col-xs-12">
                @{ Html.RenderAction("RenderSearchForm", "Search", new { docTypeAliases = "blogPost,videoPost", fieldPropertyAliases = "nodeName,metaName,metaKeyWords,metaDescription,contentGrid", pageSize = 10, pagingGroupSize = 3 }); }
            </div>
        </div>
    </section>

@section ScriptsBottom{

    <!--You may not want to reference jQuery here if you already have it in your master template-->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
    <script src="/scripts/jquery.validate.min.js"></script>
    <script src="/scripts/jquery.validate.unobtrusive.min.js"></script>
    <script src="/scripts/jquery.unobtrusive-ajax.js"></script>

    <script type="text/javascript">

        function debounce(func, wait, immediate) {
            var timeout;
            return function () {
                var context = this, args = arguments;
                var later = function () {
                    timeout = null;
                    if (!immediate) func.apply(context, args);
                };
                var callNow = immediate &amp;&amp; !timeout;
                clearTimeout(timeout);
                timeout = setTimeout(later, wait);
                if (callNow) func.apply(context, args);
            };
        }

        $(document).on("click", "#submit-button", function (e) {
            e.preventDefault();
            var form = $(this).closest('form');
            $(form).submit();
        })

        $(document).on("keyup", "#SearchTerm", debounce(function (e) {
            e.preventDefault();
            var form = $(e.target).closest('form');
            $(form).submit();
        }, 300))

    </script>
}

_SearchForm.cshtml

This partial view has the main form which the user interacts with. This form uses ajax so it loads the results without reloading the page.
You will see in the controller further down that I save this in an extra folder inside Partials.  ~/Views/Partials/Search/

@model CodeShare.Library.Models.SearchViewModel

@*@using (Html.BeginUmbracoForm("SubmitSearchForm", "Search", FormMethod.Post))*@
@using (Ajax.BeginForm("SubmitSearchForm", "Search", null, new AjaxOptions
{
    HttpMethod = "POST",
    InsertionMode = InsertionMode.Replace,
    UpdateTargetId = "search-results"
}))
{
    @Html.HiddenFor(m => m.DocTypeAliases)
    @Html.HiddenFor(m => m.FieldPropertyAliases)
    @Html.HiddenFor(m => m.PageSize)
    @Html.HiddenFor(m => m.PagingGroupSize)

    @Html.TextBoxFor(m => m.SearchTerm, new { placeholder = "Search..." })
    <button id="submit-button">Search</button>

    <div id="search-results">
        @{ Html.RenderAction("RenderSearchResults", "Search", new { Model = Model.SearchResults });}
    </div>
}

_SearchResults.cshtml

This partial view displays the search results. You can use your own markup around it, this is just an example using bootstrap.
Notice the helper method at the bottom. This renders the paging controls.
You will see in the controller further down that I save this in an extra folder inside Partials.  ~/Views/Partials/Search/

@inherits UmbracoViewPage<CodeShare.Library.Models.SearchResultsModel>

@if (Model != null)
{
    if (Model.HasResults)
    {
        <p>Your search for <strong>@Model.SearchTerm</strong> returned @Model.TotalItemCount result@(Model.TotalItemCount != 1 ? "s" : null)</p>

        <div class="row">
            <div class="col-xs-12">
                @RenderPagingButtons(Model.PagingBounds, Model.PageNumber, Model.PageCount)
            </div>
        </div>
        
        <div class="row">
            @foreach (IPublishedContent result in Model.Results)
            {
                <div class="col-xs-12">
                    <a href="@(result.Url)" target="_blank">@(result.Name)</a>
                    <p>@(result.GetPropertyValue<string>("metaDescription"))</p>
                </div>
            }
        </div>
    }
    else
    {
        <p>No results to display</p>
    }
}

@helper RenderPagingButtons(CodeShare.Library.Models.PagingBoundsModel pagingBounds, int pageNumber, int pageCount)
{
    if(pagingBounds.ShowFirstButton)
    {
        <button name="page-1">First</button>
    }

    if (pageNumber > 1)
    {
        <button name="page-@(pageNumber - 1)">Prev</button>
    }

    if(pagingBounds.StartPage != pagingBounds.EndPage)
    {
        for(int i = pagingBounds.StartPage; i <= pagingBounds.EndPage; i++)
        {
            <button name="page-@i" class="@(i == pageNumber ? "current" : null)">@i</button>
        }        
    }

    if (pageNumber < pageCount)
    {
        <button name="page-@(pageNumber + 1)">Next</button>
    }

    if(pagingBounds.ShowLastButton)
    {
        <button name="page-@(pageCount)">Last</button>
    }
}

Controller

SearchController

This controller handles all actions for the search

using System.Web.Mvc;
using Umbraco.Web.Mvc;
using CodeShare.Library.Models;
using Umbraco.Web;
using CodeShare.Library.Helpers;
using System.Collections.Generic;

namespace CodeShare.Web.Controllers
{
    public class SearchController : SurfaceController
    {
        #region Private Variables and Methods

        private SearchHelper _searchHelper { get { return new SearchHelper(new UmbracoHelper(UmbracoContext.Current)); } }

        private string PartialViewPath(string name)
        {
            return $"~/Views/Partials/Search/{name}.cshtml";
        }

        private List<SearchGroup> GetSearchGroups(SearchViewModel model)
        {
            List<SearchGroup> searchGroups = null;
            if (!string.IsNullOrEmpty(model.FieldPropertyAliases))
            {
                searchGroups = new List<SearchGroup>();
                searchGroups.Add(new SearchGroup(model.FieldPropertyAliases.Split(','), model.SearchTerm.Split(' ')));
            }
            return searchGroups;
        }

        #endregion

        #region Controller Actions

        [HttpGet]
        public ActionResult RenderSearchForm(string query, string docTypeAliases, string fieldPropertyAliases, int pageSize, int pagingGroupSize)
        {
            SearchViewModel model = new SearchViewModel();
            if (!string.IsNullOrEmpty(query))
            {
                model.SearchTerm = query;
                model.DocTypeAliases = docTypeAliases;
                model.FieldPropertyAliases = fieldPropertyAliases;
                model.PageSize = pageSize;
                model.PagingGroupSize = pagingGroupSize;
                model.SearchGroups = GetSearchGroups(model);
                model.SearchResults = _searchHelper.GetSearchResults(model, Request.Form.AllKeys);
            }
            return PartialView(PartialViewPath("_SearchForm"), model);
        }



        [HttpPost]
        public ActionResult SubmitSearchForm(SearchViewModel model)
        {
            if (ModelState.IsValid)
            {
                if (!string.IsNullOrEmpty(model.SearchTerm))
                {
                    model.SearchTerm = model.SearchTerm;
                    model.SearchGroups = GetSearchGroups(model);
                    model.SearchResults = _searchHelper.GetSearchResults(model, Request.Form.AllKeys);
                }
                return RenderSearchResults(model.SearchResults);
            }
            return null;
        }

        public ActionResult RenderSearchResults(SearchResultsModel model)
        {
            return PartialView(PartialViewPath("_SearchResults"), model);
        }

        #endregion

    }
}

Helper Class

SearchHelper

This class contains the main brains behind this whole search function.

using System.Collections.Generic;
using System.Linq;
using CodeShare.Library.Models;
using Examine;
using Examine.SearchCriteria;
using Umbraco.Web;
using System;
using Umbraco.Core.Models;
using UmbracoExamine;

namespace CodeShare.Library.Helpers
{
    /// <summary>
    /// A helper class giving you everything you need for searching with Examine.
    /// </summary>
    public class SearchHelper
    {
        private string _docTypeAliasFieldName { get { return "nodeTypeAlias"; } }
        private UmbracoHelper _uHelper { get; set; }

        /// <summary>
        /// Default constructor for SearchHelper
        /// </summary>
        /// <param name="uHelper">An umbraco helper to use in your class</param>
        public SearchHelper(UmbracoHelper uHelper)
        {
            _uHelper = uHelper;
        }
        
        /// <summary>
        /// Gets the search results model from the search term/// 
        /// </summary>
        /// <param name="searchModel">The search model with search term and other settings in it</param>
        /// <param name="allKeys">The form keys that were submitted</param>
        /// <returns>A SearchResultsModel object loaded with the results</returns>
        public SearchResultsModel GetSearchResults(SearchViewModel searchModel, string[] allKeys)
        {
            SearchResultsModel resultsModel = new SearchResultsModel();
            resultsModel.SearchTerm = searchModel.SearchTerm;
            resultsModel.PageNumber = GetPageNumber(allKeys);

            ISearchResults allResults = SearchUsingExamine(searchModel.DocTypeAliases.Split(','), searchModel.SearchGroups);
            resultsModel.TotalItemCount = allResults.TotalItemCount;
            resultsModel.Results = GetResultsForThisPage(allResults, resultsModel.PageNumber, searchModel.PageSize);

            resultsModel.PageCount = Convert.ToInt32(Math.Ceiling((decimal)resultsModel.TotalItemCount / (decimal)searchModel.PageSize));
            resultsModel.PagingBounds = GetPagingBounds(resultsModel.PageCount, resultsModel.PageNumber, searchModel.PagingGroupSize);
            return resultsModel;
        }

        /// <summary>
        /// Takes the examine search results and return the content for each page
        /// </summary>
        /// <param name="allResults">The examine search results</param>
        /// <param name="pageNumber">The page number of results to return</param>
        /// <param name="pageSize">The number of items per page</param>
        /// <returns>A collection of content pages for the page of results</returns>
        private IEnumerable<IPublishedContent> GetResultsForThisPage(ISearchResults allResults, int pageNumber, int pageSize)
        {
            return allResults.Skip((pageNumber - 1) * pageSize).Take(pageSize).Select(x => _uHelper.TypedContent(x.Id));
        }

        /// <summary>
        /// Performs a lucene search using Examine.
        /// </summary>
        /// <param name="documentTypes">Array of document type aliases to search for.</param>
        /// <param name="searchGroups">A list of search groupings, if you have more than one group it will apply an and to the search criteria</param>
        /// <returns>Examine search results</returns>
        public Examine.ISearchResults SearchUsingExamine(string[] documentTypes, List<SearchGroup> searchGroups)
        {
            var searcher = ExamineManager.Instance.SearchProviderCollection["ExternalSearcher"];
            ISearchCriteria searchCriteria = searcher.CreateSearchCriteria(IndexTypes.Content);
            IBooleanOperation queryNodes = null;

            //only shows results for visible documents.
            queryNodes = searchCriteria.GroupedOr(new string[] { "umbracoNaviHide" }, "0", "");

            if (documentTypes != null &amp;&amp; documentTypes.Length > 0)
            {
                //only get results for documents of a certain type
                queryNodes = queryNodes.And().GroupedOr(new string[] { _docTypeAliasFieldName }, documentTypes);
            }

            if (searchGroups != null &amp;&amp; searchGroups.Any())
            {
                //in each search group it looks for a match where the specified fields contain any of the specified search terms
                //usually would only have 1 search group, unless you want to filter out further, i.e. using categories as well as search terms
                foreach (SearchGroup searchGroup in searchGroups)
                {
                    queryNodes = queryNodes.And().GroupedOr(searchGroup.FieldsToSearchIn, searchGroup.SearchTerms);
                }
            }

            //return the results of the search
            return ExamineManager.Instance.Search(queryNodes.Compile()); ;
        }

        /// <summary>
        /// Gets the page number from the form keys
        /// </summary>
        /// <param name="formKeys">All of the keys on the form</param>
        /// <returns>The page number</returns>
        public int GetPageNumber(string[] formKeys)
        {
            int pageNumber = 1;
            const string NAME_PREFIX = "page";
            const char NAME_SEPARATOR = '-';
            if (formKeys != null)
            {
                string pagingButtonName = formKeys.Where(x => x.Length > NAME_PREFIX.Length &amp;&amp; x.Substring(0, NAME_PREFIX.Length).ToLower() == NAME_PREFIX).FirstOrDefault();
                if (!string.IsNullOrEmpty(pagingButtonName))
                {
                    string[] pagingButtonNameParts = pagingButtonName.Split(NAME_SEPARATOR);
                    if (pagingButtonNameParts.Length > 1)
                    {
                        if (!int.TryParse(pagingButtonNameParts[1], out pageNumber))
                        {
                            //pageNumber already set in tryparse
                        }
                    }
                }
            }
            return pageNumber;
        }

        /// <summary>
        /// Works out which pages the paging should start and end on
        /// </summary>
        /// <param name="pageCount">The number of pages</param>
        /// <param name="pageNumber">The current page number</param>
        /// <param name="groupSize">The number of items per page</param>
        /// <returns>A PagingBoundsModel containing the paging bounds settings</returns>
        public PagingBoundsModel GetPagingBounds(int pageCount, int pageNumber, int groupSize)
        {
            int middlePageNumber = (int)(Math.Ceiling((decimal)groupSize / 2));
            int pagesBeforeMiddle = groupSize - (int)middlePageNumber;
            int pagesAfterMiddle = groupSize - (pagesBeforeMiddle + 1);
            int startPage = 1;
            if (pageNumber >= middlePageNumber)
            {
                startPage = pageNumber - pagesBeforeMiddle;
            }
            else
            {
                pagesAfterMiddle = groupSize - pageNumber;
            }
            int endPage = pageCount;
            if (pageCount >= (pageNumber + pagesAfterMiddle))
            {
                endPage = (pageNumber + pagesAfterMiddle);
            }
            bool showFirstButton = startPage > 1;
            bool showLastButton = endPage < pageCount;
            return new PagingBoundsModel(startPage, endPage, showFirstButton, showLastButton);
        }

    }
}

Now you have everything you need to do advanced searching with Umbraco. If you need help to see how to implement it, watch this video.

Now you can search in two ways. One by using the form on the page, or another by using the querystring at the end of your search page url like this codeshare.co.uk/search?query=umbraco