Skip to main content

Sitecore

SXA Sort search results by distance for mapped multiple locations

Modern,pendant,light,with,vintage,light,bulb

Challenge:

We have seen how to sort the locations i.e., POI items by distance based on the current user location or the location given by the user. (Refer to the post – SXA Map component Part 3 for more detail.) In this post, we will explore how to sort non-POI items’ search results by distance. Basically, the content items where each item refers to one or more POI location items.

Now consider a requirement like as a property buyer I want to view the Builders group companies result with the cities where they have launched their real estate projects and the list be sorted by the distance.

Solution:

A quick look at the following output of the implementation discussed in this post, so we get more clarity.

Expected output quick demo

Implementation output

Here, we have used a custom Location Finder and Search results components.

Note: Both Location Finder and Location Filter are the same components. It is just the display name is Location Finder. So, from here on, if I refer to Location Filter, it is the same Location Finder component 🙂

High-level idea

Since we want to sort the Builder Group list by nearest cities, we will introduce the computed field for the Builder group item. Let’s call it “multiplecoordinates”. This computed field will store all the POI items coordinates referred to by the corresponding Builder Group item. We will customize the Sitecore sorting service to support sorting based on “multiplecoordinates” computed field.

Implementation

Original Location Finder passes the user input location’s coordinate in the “g” hash parameter. Sitecore search service backend implementation relies on this g parameter to decide whether the search request is for the POI items or the non-POI items. (Refer to the post – SXA Map component Part 2 for more detail). As we want to show the Builders group items results i.e., non-POI items, we need to pass the user-given location coordinate in the new parameter. Let’s call it cg. So, we have this coordinate and use it for our custom implementation.

To achieve this part, clone the existing Location Finder rendering /sitecore/layout/Renderings/Feature/Experience Accelerator/Search/Location Filter. Refer to the beautiful Sitecore documentation on Copy and customize a rendering.

Clone The Location Finder

Clone the Location Finder

Clone the Location Finder - Setup

Clone the Location Finder – Update the above fields and the rest can remain unchanged.
Rendering CSS Class value “custom-location-filter” is important because this will be used in our new JS file and SCSS file.

Add an item of template /Feature/Custom Experience Accelerator/Custom Location Finder/Custom Location Filter Folder, under /sitecore/content/{tenant}/{site}/Data/Search.

If no item of this template found under /sitecore/content/{tenant}/{site}/Data then upon adding the rendering in the page editor it does not prompt “Select the Associated Content” dialog to set the data source.

Download the CustomLocationFinder.js and upload to your site theme script folder /sitecore/media library/Themes/{tenant}/{site}/{theme}/Scripts.

Custom Location Finder JS difference

Custom Location Finder JS difference

We have a JS file for original Location Finder /sitecore/media library/Base Themes/SearchTheme/Scripts/component-search-location-filter. We have used the same code with some modifications. Here, CSS class name, the component name for registration are modified, and “g” and “_g” is replaced with “cg” and “_cg”.

We can inherit the same styling from /sitecore/media library/Themes/Wireframe/sass/base/search/_component-location. Add component-custom-location.scss file code for the site. Refer to the Sitecore documentation on Style SXA sites with Sass. Without this, it will not render properly.

@import "base/search/_component-location";
.custom-location-filter {
   @extend .location-filter;
}

Backend implementation

Please check the comments for more detail.

Multiplecoordinates computed field code

using Sitecore.ContentSearch;
using Sitecore.ContentSearch.ComputedFields;
using Sitecore.Data;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Xml;

namespace CustomSXA.Foundation.Search.ComputedFields
{
    public class Multiplecoordinates : AbstractComputedIndexField
    {
        public string TemplateName { get; set; }
        public string ItemFieldName { get; set; }
        public Multiplecoordinates() : base()
        { }
        public Multiplecoordinates(XmlNode node) : base(node)
        {
            if (node == null)
                return;
            this.TemplateName = node.Attributes?["templateName"]?.Value;
            this.ItemFieldName = node.Attributes?["itemFieldName"]?.Value;
        }
        public override object ComputeFieldValue(IIndexable indexable)
        {
            Item obj = (Item)(indexable as SitecoreIndexableItem);
            if (obj == null)
                return (object)null;
            if (string.Equals(obj.TemplateName, TemplateName, StringComparison.OrdinalIgnoreCase))
            {
                List<string> multipleCoordinates = new List<string>();
                MultilistField locations = obj.Fields[ItemFieldName];
                foreach (var item in locations.GetItems())
                {
                    string coordinate = GetCoordinate(item) as string;
                    if (coordinate != null)
                        multipleCoordinates.Add(coordinate);
                }

                if (multipleCoordinates.Count > 0)
                    return multipleCoordinates;
            }
            return null;
        }

        private static object GetCoordinate(Item obj)
        {
            double result1;
            double result2;
            return !double.TryParse(obj[new ID(Sitecore.ContentSearch.Utilities.Constants.Latitude)], NumberStyles.Any, (IFormatProvider)CultureInfo.InvariantCulture, out result1)
                || !double.TryParse(obj[new ID(Sitecore.ContentSearch.Utilities.Constants.Longitude)], NumberStyles.Any, (IFormatProvider)CultureInfo.InvariantCulture, out result2)
                ? (object)null : (object)new Sitecore.ContentSearch.Data.Coordinate(result1, result2).ToString();
        }
    }
}

Add a custom field to a Solr schema for this multiplecoordinates.

CustomPopulateHelper code

using System.Collections.Generic;
using System.Linq;
using System.Xml.Linq;
using Sitecore.ContentSearch.SolrProvider.Pipelines.PopulateSolrSchema;
using SolrNet.Schema;

namespace CustomSXA.Foundation.Search.PopulateHelper
{
    public class CustomPopulateHelper : SchemaPopulateHelper
    {
        public CustomPopulateHelper(SolrSchema schema) : base(schema)
        {}

        public override IEnumerable<XElement> GetAllFields()
        {
            return base.GetAllFields().Union(GetAddCustomFields());
        }

        private IEnumerable<XElement> GetAddCustomFields()
        {
            //Adds below field in the managed-schema file.
            //<field name="multiplecoordinates" type="location" multiValued="true"/>
            yield return CreateField("multiplecoordinates",
                "location",
                isDynamic: false,
                required: false,
                indexed: false,
                stored: false,
                multiValued: true,
                omitNorms: false,
                termOffsets: false,
                termPositions: false,
                termVectors: false);
        }
    }
}

CustomPopulateHelperFactory code

using Sitecore.ContentSearch.SolrProvider.Abstractions;
using Sitecore.ContentSearch.SolrProvider.Pipelines.PopulateSolrSchema;
using SolrNet.Schema;

namespace CustomSXA.Foundation.Search.PopulateHelper
{
    public class CustomPopulateHelperFactory : IPopulateHelperFactory
    {
        public ISchemaPopulateHelper GetPopulateHelper(SolrSchema solrSchema)
        {
            return new CustomPopulateHelper(solrSchema);
        }
    }
}

If we don’t add this field to a SOLR schema and test with the rest of the implementation, then we get the below error.

ERROR Solr Error : [sort param could not be parsed as a query, and is not a field that exists in the index: geodist()]

The Sorting Service code has the same implementation as Sitecore.XA.Foundation.Search.Services. SortingService with the addition of the else part as below in the Order method. Check out SortingService for the complete code.

if (sortingFacet.Facet.DoesItemInheritFrom(Sitecore.XA.Foundation.Search.Templates.DistanceFacet.ID))
{
    if (center != null)
        query = (IQueryable<ContentPage>)query.OrderByDistance<ContentPage, Coordinate>((Expression<Func<ContentPage, Coordinate>>)(i => i.Location), center);
    else //This else part added to enhance the Sorting service with sort by distance for multiple coordinates i.e. POI items mapped for a non POI item.
    {
        center = Helper.GeoLocationHelper.GetLocationCentre();
        if (center != null)
            query = (IQueryable<ContentPage>)query.OrderByDistance<ContentPage, string>((Expression<Func<ContentPage, string>>)(i => i["multiplecoordinates"]), center); //sorting based on the computed field multiplecoordinates.
        return query;
    }
}

GeoLocationHelper classes – Provides the user-given location coordinate on the server side. This is used by several custom classes used in our implementation.

using Microsoft.Extensions.DependencyInjection;
using Sitecore.ContentSearch.Data;
using Sitecore.DependencyInjection;
using System;
using System.Linq;
using System.Web;
using Sitecore.XA.Foundation.SitecoreExtensions.Interfaces;

namespace CustomSXA.Foundation.Search.Helper
{
    /// <summary>
    /// Used by several classes used in our sort by distance for multiple locations case
    /// </summary>
    public static class GeoLocationHelper
    {
        //give cg priority if not found then g is looked up
        public static Coordinate GetLocationCentre(string key = "cg")
        {
            IRendering rendering = ServiceLocator.ServiceProvider.GetService<IRendering>();
            if (rendering == null)
                return null;
            string sign = rendering.Parameters["Signature"];
            string coordinates = string.Empty;
            double lat, lon;
            if (HttpContext.Current.Request[$"{sign}_{key}"] != null)
            {   //case 1 - component signature_g value from browser URL hash parameter
                coordinates = HttpContext.Current.Request[$"{sign}_{key}"];
            }
            else if (HttpContext.Current.Request[key] != null)
            {   //case 2 - regular default g value from browser URL hash parameter
                coordinates = HttpContext.Current.Request[key];
            }
            else if (GetURLRefererQueryStringParamValue($"{sign}_{key}") != null)
            {   //case 3 - component signature_g value from browser URL querystring parameter
                coordinates = GetURLRefererQueryStringParamValue($"{sign}_{key}");
            }
            else if (GetURLRefererQueryStringParamValue(key) != null)
            {   //case 4 - regular default g value from browser URL querystring parameter
                coordinates = GetURLRefererQueryStringParamValue(key);
            }

            if (!string.IsNullOrWhiteSpace(coordinates))
            {
                string[] coordinatesValues = coordinates.Split('|'); //split the coordinates value to create and return coordinate instance
                if (coordinatesValues.Length == 2)
                {
                    lat = Convert.ToDouble(coordinatesValues[0]);
                    lon = Convert.ToDouble(coordinatesValues[1]);
                    return new Coordinate(lat, lon);
                }
            }

            if (string.Equals(key, "g")) //end the recursive call
                return null;
            return GetLocationCentre("g"); //recursive call to check for g key value
        }

        private static string GetURLRefererQueryStringParamValue(string paramName)
        {
            if (HttpContext.Current.Request.UrlReferrer != null)
            {
                var queryCollection = HttpUtility.ParseQueryString(HttpContext.Current.Request.UrlReferrer.Query);
                if (queryCollection.AllKeys.Contains(paramName, StringComparer.OrdinalIgnoreCase))
                {
                    return queryCollection.Get(paramName) ?? queryCollection.Get(paramName.ToLower());
                }
            }
            return null;
        }
    }
}

LocationItemExtensions code

using Sitecore.Data.Items;
using Sitecore.XA.Foundation.Search.Models;
using Sitecore.XA.Foundation.SitecoreExtensions.Extensions;

namespace CustomSXA.Foundation.Search.Helper
{
    /// <summary>
    /// Provides the distance between the given user location coordinate and the POI item's coordinate
    /// </summary>
    public static class LocationItemExtensions
    {
        public static double? GetDistance(this Item item, Unit unit)
        {
            if (item != null)
            {
                if (item.InheritsFrom(Sitecore.XA.Foundation.Geospatial.Templates.IPoi.ID))
                {
                    var centre = Helper.GeoLocationHelper.GetLocationCentre();
                    if (centre != null)
                    {
                        return new Geospatial(item, centre, unit).Distance;
                    }
                }
            }
            return null;
        }
    }
}

AddFollowFunctionsLocations code

using Scriban.Runtime;
using Sitecore.Data.Items;
using Sitecore.XA.Foundation.Scriban.Pipelines.GenerateScribanContext;
using Sitecore.XA.Foundation.SitecoreExtensions.Services;
using System;
using System.Collections.Generic;
using System.Linq;
using Sitecore.XA.Foundation.Search.Models;
using CustomSXA.Foundation.Search.Helper;

namespace CustomSXA.Foundation.Search.RenderingVariants.Pipelines.GenerateScribanContext
{
    /// <summary>
    /// Provides scriban function sc_followlocations. This is used to get the POI items refered by a non POI item.
    /// The list is sorted by distance in ascending order.
    /// </summary>
    public class AddFollowFunctionsLocations : IGenerateScribanContextProcessor
    {
        private delegate IEnumerable<Item> GetItems(Item item, string fieldName, string distanceUnit);

        protected readonly IPassthroughService PassthroughService;

        public AddFollowFunctionsLocations(IPassthroughService passthroughService) => this.PassthroughService = passthroughService;

        public void Process(GenerateScribanContextPipelineArgs args)
        {
            var getItemsImplementation = new GetItems(GetItemsImplementation);
            args.GlobalScriptObject.Import("sc_followlocations", getItemsImplementation);
        }

        public IEnumerable<Item> GetItemsImplementation(Item item, string fieldName, string distanceUnit = "Miles")
        {
            IEnumerable<Item> items = this.PassthroughService.GetTargetItems(item, fieldName);
            return items?.OrderBy(loc => loc.GetDistance((Unit)Enum.Parse(typeof(Unit), distanceUnit))); //returns the list of POI items sorted by distance
        }
    }
}

GetGeospatial code

using System;
using Scriban.Runtime;
using Sitecore.Data.Items;
using Sitecore.XA.Foundation.Scriban.Pipelines.GenerateScribanContext;
using Sitecore.XA.Foundation.Search.Models;
using Sitecore.XA.Foundation.SitecoreExtensions.Extensions;

namespace CustomSXA.Foundation.Search.RenderingVariants.Pipelines.GenerateScribanContext
{
    /// <summary>
    /// Provides scriban function st_geospatial. This is used to provide the  Geospatial instance for a POI item.
    /// Using this Geospatial instance we get the distance.
    /// </summary>
    public class GetGeospatial : IGenerateScribanContextProcessor
    {
        private delegate Geospatial GetGeospatialModel(Item item, string distanceUnit);

        public void Process(GenerateScribanContextPipelineArgs args)
        {
            var getGetGeospatialModelImplementation = new GetGeospatialModel(GetGeospatialModelImplementation);
            args.GlobalScriptObject.Import("st_geospatial", getGetGeospatialModelImplementation);
        }

        public Geospatial GetGeospatialModelImplementation(Item item, string distanceUnit = "Miles")
        {
            if (item != null && item.InheritsFrom(Sitecore.XA.Foundation.Geospatial.Templates.IPoi.ID))
            {
                var centre = Helper.GeoLocationHelper.GetLocationCentre();
                if (centre != null)
                {
                    return new Geospatial(item, centre, (Unit)Enum.Parse(typeof(Unit), distanceUnit));
                }
            }
            return null;
        }
    }
}

Patch the above classes in a config file. Check out CustomSXA.Foundation.Search.SortByMultipleDistance.config.

<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <generateScribanContext>
        <!--scriban function to calculate distance-->
        <processor type="CustomSXA.Foundation.Search.RenderingVariants.Pipelines.GenerateScribanContext.GetGeospatial, CustomSXA.Foundation.Search" resolve="true" />
        <!--scriban function to sort by distance, the locations mapped for an item-->
        <processor type="CustomSXA.Foundation.Search.RenderingVariants.Pipelines.GenerateScribanContext.AddFollowFunctionsLocations, CustomSXA.Foundation.Search" resolve="true" />
      </generateScribanContext>
      <contentSearch.PopulateSolrSchema>
        <!--custom solr schema populate code to add multiplecoordinates field-->
        <processor type="Sitecore.ContentSearch.SolrProvider.Pipelines.PopulateSolrSchema.PopulateFields, Sitecore.ContentSearch.SolrProvider">
          <param type="CustomSXA.Foundation.Search.PopulateHelper.CustomPopulateHelperFactory, CustomSXA.Foundation.Search" patch:instead="*[@type='Sitecore.ContentSearch.SolrProvider.Factories.DefaultPopulateHelperFactory']"/>
        </processor>
      </contentSearch.PopulateSolrSchema>
    </pipelines>
    <services>
      <!--Sorting service enhanced to sort non-POI items by multiple locations mapped in each non-POI item-->
      <register patch:instead="register[@implementationType='Sitecore.XA.Foundation.Search.Services.SortingService, Sitecore.XA.Foundation.Search']" 
        serviceType="Sitecore.XA.Foundation.Search.Services.ISortingService, Sitecore.XA.Foundation.Search" implementationType="CustomSXA.Foundation.Search.Services.SortingService, CustomSXA.Foundation.Search" lifetime="Singleton"/>
    </services>
    <contentSearch>
      <indexConfigurations>
        <!--multiplecoordinates computed field and mapping -->
        <defaultSolrIndexConfiguration type="Sitecore.ContentSearch.SolrProvider.SolrIndexConfiguration, Sitecore.ContentSearch.SolrProvider">
          <fieldMap>
            <fieldNames hint="raw:AddFieldByFieldName">
              <field fieldName="multiplecoordinates" returnType="stringCollection" />
            </fieldNames>
          </fieldMap>
          <documentOptions type="Sitecore.ContentSearch.SolrProvider.SolrDocumentBuilderOptions, Sitecore.ContentSearch.SolrProvider">
            <fields hint="raw:AddComputedIndexField">
              <!--Do update the templateName and itemFieldName property values-->
              <!--templateName - Name of the non POI item template-->
              <!--itemFieldName - Name of the field which references the POI items-->
              <field fieldName="multiplecoordinates" templateName="Builder Group" itemFieldName="Real Estate Projects Locations" returnType="stringCollection">CustomSXA.Foundation.Search.ComputedFields.Multiplecoordinates, CustomSXA.Foundation.Search</field>
            </fields>
          </documentOptions>
        </defaultSolrIndexConfiguration>
      </indexConfigurations>
    </contentSearch>
  </sitecore>
</configuration>

Do update the templateName and itemFieldName property values for the computed field – multiplecoordinates, with the ones you have in your case.

Check out the complete code from the GitHub repository.

Build and deploy solutions to the Webroot.

Action in the CMS

Create the Builder Group template inheriting from the Page template and have fields as below.

Builder Group Template

Builder Group Template

The source query for the “Real Estate Projects Locations” field is as below.

query:$site/*[@@name='Data']//*[@@templatename='POIs']|query:$sharedSites/*[@@name='Data']//*[@@templatename='POIs']

Add a new Search Result variant at /sitecore/content/{tenant}/{site}/Presentation/Rendering Variants/Search Results and name it as Builders. Add a scriban on the Builder variant and update the template field with the below code.

<b>{{i_item.Title}}</b>

<div class="d-flex flex-wrap w-25 m-2" >  
{{ for i_location in (sc_followlocations i_item "Real Estate Projects Locations") }}
<br/>    
    {{o_geospatial = st_geospatial i_location 'Kilometers'}}
    {{ if o_geospatial }}
        <div class="m-1">
            <p> {{i_location.name}}, 
                Distance: {{ o_geospatial.distance | math.round 1 }} {{ o_geospatial.unit }}
            </p>
        </div>
    {{end}}
{{ end }}
</div>

Add a new scope at /sitecore/content/{tenant}/{site}/Settings/Scopes and name it “Builders Projects” and update the Scope query field with the below value. Replace the ID with your Builder Group template ID or the non-POI item template ID.

template:{0a2d3ea5-83c6-47f0-8f52-b44a6d5eaee4}

Create several POI items under /sitecore/content/{tenant}/{site}/Data/POIs.

Create a page item under the home item of your SXA tenant site, and name it “Builders Group Projects Listing”.
Under this page item, create several items from “Builder Group” template. Set the POI items in the field “Real Estate Projects Locations”.

Open “Builders Group Projects Listing” item in the experience editor. Add the rendering “Custom Location Filter” and provide the data source.

Add The Custom Location Finder

Add the Custom Location Finder

Edit the component properties as below.

Location Finder Properties

Location Finder Properties

Set the Distance facet in the data source of the Custom Location Filter.

Location Finder Datasource Set The Distance Facet

Location Finder Datasource – Set The Distance Facet

Add the Search Results rendering and set the “Builders” variant. Set the “Builders Projects” in the Search Scope field. Save the page.

Populate Solr Managed Schema from the Sitecore control panel. After this, please do verify the below entry in your SOLR index managed-schema file (SOLR_FolderPath\server\solr\your_sxa_database_index_core_name\conf\managed-schema). If not found then do check Sitecore logs or windows events log for more detail.

<field name="multiplecoordinates" type="location" multiValued="true" indexed="false" stored="false"/>

Rebuild search index (sitecore_sxa_master_index and sitecore_sxa_web_index) from Sitecore control panel.

Quick Demo

Quick Demo - Sort Builders Group items by the mapped multiple locations

Quick Demo – Sort Builders Group items by the mapped multiple locations

Note:

Initially, when the page is loaded, it prompts for user location while showing only the Builder Group listing and does not show the locations yet. This is because o_geospatial is null in the scriban code. So, when the user location or the user inputs the location in the location finder, the “cg” hash key has the location coordinate, and then o_geospatial is initialized by the custom scriban function st_geospatial in our implementation. o_geospatial is then used to get the distance. You can further customize the scriban code to show the locations without the distance on the initial page load.

You can manage the relationship between the non-POI item and the POI item based on your requirement. For instance, you can have the project detail item separately and a Builder Group item can refer to this project detail item instead of the POI item. Each project detail item can have a Location field to reference the POI item and can have more fields to manage the project details information. But then also do modify the code of search result variant and the computed field multiplecoordinates accordingly.

Special thanks to one of my awesome colleagues – Jitesh Tambekar for pairing on the key investigation area of this challenge.

Happy Sitecore and SOLR learning 🙂

Tags

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Sandeepkumar Gupta

Sandeepkumar Gupta is a Lead Technical Consultant at Perficient. He enjoys research and development work in Sitecore. He has contributed to Sitecore projects based on SXA, ASP.NET MVC, and Web Forms. He has also contributed to Sitecore upgrade projects.

More from this Author

Categories
Follow Us