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.
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 – 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.
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.
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.
Edit the component properties as below.
Set the Distance facet in the data source of the Custom Location Filter.
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
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 🙂