There are many ways to optimise your SharePoint search results. Here's a list of common optimisations I'll look at for any Intranet or Document Portal:

  • Search Verticals
  • Best Bets
  • Promoted Results
  • Search Refiners
  • Customised Display Templates
  • Exclusion Query Rules

Each of these categories is worthy of a detailed post, but today I really want to focus on the Customised Display Templates approach. Display Templates are great because you can debug them in the Browser and do a whole host of things that you couldn't with the old XSL approach in the Content Query Web Part.

TL:DR;

Optimising search can be done in many ways. This post shows how a Custom Display Template and Result Type can add extra information to your Search Results, improving the User Experience by populating it with relevant related info. The process involves using jQuery to look for the results and inject the related data after the results have rendered.

Search Post Processor

My definition of a SharePoint Search Results Post Processor is:

a JavaScript function that executes after the search results have rendered to the page and alters them to provide an improved user experience.

A typical search result is about 1 item, it could be a document, list item, person or web page. What we are going to accomplish here is to bring related information into the results. Here's a before and after of the search results for Orders in my Office 365 site:

The Before results from a list of Orders (Documents with an Order Content Type), which happens to have a lookup to a Customers list.

In the After results I've merged information from the Customers list:

So if we take a closer look at an order, we can see that the result for Order 100 has the customer data included.

To make this happen the following things needed to be done:

  • Create a new Display Template called "Item_Orders.html"
  • Create a Result Type and link it to the Orders Content Type and the Item_Orders.html
  • Create an Orders-SearchProcessor.js JavaScript file
  • Update the Group_Default.html Display Template and hook into the After Render call back

When this is running, the flow of events go like this:

  1. The user executes a search
  2. The Group_Default Display Template executes
  3. All the Item Display Templates render
  4. The Group_Default uses AddPostRenderCallback when all the results have finished rendering to the page.
  5. Inside the AddPostRenderCallback method we call out to our processing script.
  6. Our processing script will look for all the results to update, uses JSOM to retrieve extra information from the Customers list.
  7. After we have received the extra info we use jQuery to search the DOM and add inject the data.

Why do we do it this way?

  • We make one call to fetch the extra data instead of 1 call per result which would impact performance.
  • It's quick and light weight.

Create a new Display Template

Using SharePoint Designer, take a copy of an Existing Display Template, I Used the Item_Default and then updated it to call the PDF Hover Panel. You'll find these in {SiteCollection}/_catalogs/masterpage/Display Templates/Search
I renamed it to Item_Order.html.

 <div id="Item_Order">
<!--#_ 
        if(!$isNull(ctx.CurrentItem) && !$isNull(ctx.ClientControl)){
            var id = ctx.ClientControl.get_nextUniqueId();
            var itemId = id + Srch.U.Ids.item;
			var hoverId = id + Srch.U.Ids.hover;
			var hoverUrl = "~sitecollection/_catalogs/masterpage/Display Templates/Search/Item_PDF_HoverPanel.js";
            $setResultItem(itemId, ctx.CurrentItem);
			if(ctx.CurrentItem.IsContainer){
				ctx.CurrentItem.csr_Icon = Srch.U.getFolderIconUrl();
			}
			ctx.currentItem_ShowHoverPanelCallback = Srch.U.getShowHoverPanelCallback(itemId, hoverId, hoverUrl);
            ctx.currentItem_HideHoverPanelCallback = Srch.U.getHideHoverPanelCallback();
            var customer = $getItemValue(ctx, "Customer");
            var deliveryDate = $getItemValue(ctx, "ExpectedDeliveryDate");
            var deliveryDateString = ""; 
            if(deliveryDate.value) {
	            deliveryDateString = deliveryDate.value.format("dd/MMM/yyyy");
	        }
	        var extension = $getItemValue(ctx, "FileExtension").value;
			var iconUrl = SP.Utilities.HttpUtility.htmlEncode(Srch.U.ensureAllowedProtocol(Srch.U.getIconUrlByFileExtension({ FileExtension: extension}, null)));
_#-->
            <div id="_#= $htmlEncode(itemId) =#_" name="Item" data-displaytemplate="DefaultItem" class="ms-srch-item" onmouseover="_#= ctx.currentItem_ShowHoverPanelCallback =#_" onmouseout="_#= ctx.currentItem_HideHoverPanelCallback =#_">
				<div data-customer="_#= customer =#_" class="card customer-order">

					<div class="customer-loader" style="float:right;">
						<i class="fa fa-spinner fa-pulse fa-fw"></i>
						<span class="sr-only">Loading...</span>
					</div>	
					<div class="customer-vip-status" style="display:none;"><i class="fa fa-star ra-2x customer-icon-vip"></i><br/> VIP</div>
					<h2><img src="_#=iconUrl=#_" alt="Icon"> Customer Order</h2>
					
					<h3 class="customer-title" style="float:left;">_#=customer=#_</h3>
					
					<div class="customer-contact" >
						<strong>Contact</strong> <i class="fa fa-user"></i> <span class="customer-contact-name"></span>
						<div>
							<strong>Address</strong> <span class="customer-contact-address"></span> 
							<i class="fa fa-map-marker customer-icon-map"></i> <a class="customer-contact-maplink" target="_blank" href="https://www.google.com/maps">View on Map</a>
						</div>
						<div>
							<strong>Phone</strong> 
							<i class="fa fa-mobile customer-icon-phone"></i> 
							<a class="customer-contact-phone"></a> 
						</div>
					</div>
					<strong>Delivery Date:</strong> <span class="customer-order-date">_#=deliveryDateString=#_</span>
					_#=ctx.RenderBody(ctx)=#_
        		</div>
            <div id="_#= $htmlEncode(hoverId) =#_" class="ms-srch-hover-outerContainer"></div>
         </div>
<!--#_ 
        } 
_#-->
    </div>

If you look closely you'll see that I've used the HTML5 data attribute to add some info that will be used by the processing script:

    <div data-customer="_#= customer =#_" class="card customer-order">

So, when the result is rendered to the DOM it looks like:

    <div data-customer="Footrot Flats" class="card customer-order">

This is important as the processing script will use this attribute.

I also updated the managed properties to include ExpectedDeliveryDate.

<mso:ManagedPropertyMapping msdt:dt="string">'ExpectedDeliveryDate':'ExpectedDeliveryDateOWSDATE','Customer','Title':'Title','Path':'Path','Description':'Description','EditorOWSUSER':'EditorOWSUSER','LastModifiedTime':'LastModifiedTime','CollapsingStatus':'CollapsingStatus','DocId':'DocId','HitHighlightedSummary':'HitHighlightedSummary','HitHighlightedProperties':'HitHighlightedProperties','FileExtension':'FileExtension','ViewsLifeTime':'ViewsLifeTime','ParentLink':'ParentLink','FileType':'FileType','IsContainer':'IsContainer','SecondaryFileExtension':'SecondaryFileExtension','DisplayAuthor':'DisplayAuthor'</mso:ManagedPropertyMapping>

Create a new Result Type

Result Types allow you to define a different Display Template that should be used to render results. This is good because we don't want to edit the built in Display Templates if we can avoid it.
To create a new Result Type:

  1. Click the Gear Icon -> Site Settings from the root of the Site Collection
  2. Under Site Collection Administration, click Search Result Types.
  3. Chose one of the existing ones and Click Copy, or Click New Result Type.

Here's how I configured the result type:

  • Called it Order Item
  • Match All Sources
  • I disabled the "What types of content should match" by selecting "Select a value". This is because I want to match against the Content Type instead.
  • Added a Condition to match ContentTypeId with an operator of Starts when any of and the value is content type id of my Order Content Type.
  • Select your Display Template from the Actions
  • Set Optimize for frequent use to checked

Note: I found that there is a good 10 seconds delay or more after creating the new Result Type and when it is actually applied in the Search Results so don't be too hasty if it doesn't work straight away.

Create a Search Processing JavaScript File

I uploaded my script to {SiteCollection}/Style Library/SearchProcessor/Orders-SearchProcessor.js.
Here's the script:

//Example SharePoint Search Results Post Processor - http://blog.timwheeler.io
class OrderSearchProcessor {
	process(){
		console.log("Order Search Processort Started");	
		//Find all the customers we need to load.
		var customers = [];
		$("[data-customer]").each(
			function(index, element){
				let customerName = $(element).attr("data-customer");
				if(customers.indexOf(customerName) === -1) {
					customers.push(customerName)
				}
				
			});
		if(!customers.length) {
			//Nothing to process
			return;
        }
        console.log("Looking up customers " + customers)
        var local = this;
        this.loadCustomers(customers)
            .done(function(customerValues){
                console.log(customerValues);
                local.render(customerValues);
            })
            .fail(function(error){
                console.error(error);
            });
    }  
    //This method updates the dom with jQuery, looking for the data- attributes
    render(customerValues) {
        console.log("Rendering order results");
        for(var i = 0; i < customerValues.length; i++) {
            let customer = customerValues[i];
            $("[data-customer=\"" + customerValues[i].Title + "\"").each(function(index, element){
                customer.CustomerVIP ? $(element).find(".customer-vip-status").show() : $(element).find(".customer-vip-status").hide();
                $(element).find(".customer-title").empty().append(customer.Title).hide().fadeIn();
                $(element).find(".customer-contact-name").empty().append(customer.ContactName).hide().fadeIn();
                $(element).find(".customer-contact-address").empty().append(customer.CustomerAddress).hide().fadeIn();
                $(element).find(".customer-contact-maplink").attr("href", customer.MapLink.get_url());
                $(element).find(".customer-contact-phone").attr("href", "tel:" + customer.ContactPhone);
                $(element).find(".customer-contact-phone").empty().append(customer.ContactPhone).hide().fadeIn();
            });
        }
        //Turn off the spinning loader icon
        $(".customer-loader").hide();
    }
    //Using the Client Side Object Model, this method loads the data for all the customers in a single call and only brings back needed fields.  You could instead use the REST API as well.
    loadCustomers(customers) {
        var deferred = $.Deferred();
        var clientContext = SP.ClientContext.get_current();
        var list = clientContext.get_site().get_rootWeb().get_lists().getByTitle("Customers");
        
        //Use the CamlBuilder to simplify creating a Caml Query with a dynamic number of where clauses
        var camlBuilder = new SpData.CamlBuilder(); //Caml Helper Library - https://github.com/TjWheeler/sp-clientapps/tree/master/Client%20Apps/Common/Style%20Library/Client%20Apps/Common/js					
        camlBuilder.begin(false);  //will using an OR where clause
        camlBuilder.addViewFields(["Title", "ContactName", "ContactPhone", "CustomerAddress", "CustomerVIP", "MapLink"]);
        camlBuilder.rowLimit = 10;
        for(var i = 0; i < customers.length; i ++) {
            camlBuilder.addTextClause(SpData.CamlOperator.Eq, "Title", customers[i]);
        }
        //Now create a caml query and populate it using the CamlBuilder
        var query = new SP.CamlQuery();
        query.set_viewXml(camlBuilder.viewXml);
        //This is the output of Camlbuilder.viewXml "<View><ViewFields><FieldRef Name="Title"/><FieldRef Name="ContactName"/><FieldRef Name="ContactPhone"/><FieldRef Name="CustomerAddress"/><FieldRef Name="CustomerVIP"/><FieldRef Name="MapLink"/></ViewFields><Query><Where><And><And><Eq><FieldRef Name="Title" /><Value Type="Text">Footrot Flats</Value></Eq><Eq><FieldRef Name="Title" /><Value Type="Text">Bob's Hardware</Value></Eq></And><Eq><FieldRef Name="Title" /><Value Type="Text">Hair Studio 9</Value></Eq></And></Where></Query><RowLimit>10</RowLimit></View>"
        var listItems = list.getItems(query);
        clientContext.load(listItems, 'Include(Title, ContactName, ContactPhone, CustomerAddress, CustomerVIP, MapLink)');
        this.executeQuery(clientContext)
            .done((sender, args) => {
                var customerValues = [];
                var enumerator = listItems.getEnumerator();
                var count = 0;
                while (enumerator.moveNext()) {
                    count++;
                    var li = enumerator.get_current();
                    var values = li.get_fieldValues();
                    customerValues.push(values);
                }
                deferred.resolve(customerValues);
            })
            .fail((sender, args) => {
                console.error("Error: " + args.get_message());
                deferred.reject(args.get_message());
            });
        return deferred.promise();
    }
    //Convert SP Client Promise into jQuery Deferred Promise, so now all our promises use a single framework
    executeQuery(clientContext) {
        var deferred = $.Deferred();
        clientContext.executeQueryAsync(
            (sender, args) => {
                deferred.resolve(sender, args);
            },
            (sender, args) => {
                deferred.reject(sender, args);
            }
        );
        return deferred.promise();
    }
}

This script does the following:

  1. Uses jQuery to find all the Customers we need to load
  2. Loads the Customers with a JSOM SP.CamlQuery
  3. Uses a Promise to return the results
  4. Iterates through each Customer and updates the DOM by injecting the Customer Data
  5. Hides a Font Awesome spinning icon that is output by the Display Template and used to let the user know that information is still loading

Note: I've also hooked into a Helper library I built to dynamically create Caml queries as I really hate writing Caml myself. If you want to use it you can find it in this repository.

Update the Group_Default.html Display Template

Up till now I've been able to avoid customising the out of the box Display Templates, however this is the one we need to modify to hook into the callback when all results have been rendered.

Edit the Display Template {SiteCollection}/_catalogs/masterpage/Display Templates/Search/Group_Default.html
I've added the following code:

/* Custom Search Post Processor Begin */

			RegisterSod("order-searchprocessor.js", (_spPageContextInfo.siteServerRelativeUrl == "/" ? "" : _spPageContextInfo.siteServerRelativeUrl) + "/Style Library/SearchProcessor/order-searchprocessor.js");
			RegisterSod("camlbuilder.js", (_spPageContextInfo.siteServerRelativeUrl == "/" ? "" : _spPageContextInfo.siteServerRelativeUrl)  + "/Style Library/Client Apps/Common/js/client.camlbuilder.js");	//Caml Helper Library - https://github.com/TjWheeler/sp-clientapps/tree/master/Client%20Apps/Common/Style%20Library/Client%20Apps/Common/js					
			AddPostRenderCallback(ctx, function(){
				SP.SOD.loadMultiple(["order-searchprocessor.js","camlbuilder.js", "SP.js"], function () {
					console.log("Processing order search results");
			        var orderProcessor = new OrderSearchProcessor();
			        orderProcessor.process();
		        });
		   });
		   
         /* End Custom Search Post Processor */

What this is doing is using the Script on demand feature in the Client Side Object model to load up my Order-SearchProcessor.js and my helper library ClientCamlBuilder.js. I'm using the AddPostRenderCallback to hook into the call back, then I'm calling SP.SOD.loadMultiple to ensure all the required scripts are loaded before I try to process the results.

To see where I've added this in the Group_Default.html template:

A bit of style

To make things look better, I added a CSS reference using the script editor and placed it on my search center result pages. I also wanted to hook into Font Awesome and jQuery. This can be done in different ways, but a script editor is quick and simple. Here's the contents of the Script Editor.

<link rel="stylesheet" href="/Style%20Library/SearchProcessor/order-searchstyles.css">
<link rel="stylesheet" href="/style library/client apps/common/libs/font-awesome-4.7.0/css/font-awesome.min.css">
<script type="text/javascript" src="/Style Library/Client Apps/Common/libs/jquery-3.2.1/jquery.min.js"></script>

Final thoughts

I find many organisations I work with that have not optimised SharePoint Search. It's funny because in each instance, the users complain that SharePoint Search is crap. A better way to say this is that SharePoint search that hasn't been optimised for the company is crap. By doing a few small things to make the results richer, target with Best Bets, Refiners etc, you will find your users can get to content quickly, and enjoy the experience along the way. The approach I've outlined in this post is a more advanced option to improving search, but one that I've seen great results from.