Sitecore: Multi-Root Search in 'Multilist with Search' Fields

Posted 29 Feb 2024 by Marek Musielak

sitecore: multi-root search in 'multilist with search' fields

Many developers have encountered the challenge of facilitating content authors who need to select items from various parts of the Sitecore content tree using the Multilist with Search field type. Out of the box, this feature restricts searches to a single root, posing limitations for content management. However, in this blog post, we'll explore how to enhance this functionality by enabling the field type to support query results in multiple items, thus allowing searches across multiple roots of the Sitecore content tree.

Many content authors and developers have long sought the ability to specify multiple start search locations in the Sitecore Multilist with Search field type. It's puzzling why Sitecore hasn't incorporated this functionality yet. Let's explore how we can address this issue. If you're short on time and prefer to use the code directly, you can find it on my GitHub repository: Sitecore Multi-Root Search in 'Multilist with Search' Field.

The first step is to replace the original SourceFilterBuilder class with a custom implementation. This custom implementation will execute all the queries defined in the source of a Multilist with Search field and concatenate the IDs of all the roots when the field is rendered in the Content Editor. To achieve this, we need the following:

  • A class with a custom implementation of SourceFilterBuilder.
  • A factory that will return our own implementation of SourceFilterBuilder.
  • A custom configurator class to register the factory.
using System;
using System.Linq;
using Sitecore.Buckets.FieldTypes;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;

namespace MyAssembly.MyNamespace
{
    /// <summary>
    /// If StartSearchLocation parameter contains '|' character, calculates all the roots from the query and puts them in `location` part
    /// </summary>
    public class MultiRootMultilistWithSearchSourceFilterBuilder : SourceFilterBuilder
    {
        private readonly Item _currentItem;

        public MultiRootMultilistWithSearchSourceFilterBuilder(Item targetCurrentItem, string fieldId, string fieldSource) : base(targetCurrentItem, fieldId, fieldSource)
        {
            _currentItem = targetCurrentItem;
        }

        public override void BuildLocationPart(
            string sourceName,
            string filterName,
            string defaultValue)
        {
            var completed = false;

            try
            {
                var locationValue = SourceParts["StartSearchLocation"];

                if (locationValue != null)
                {
                    GetResult().Add(filterName, MakeValueQueryableList(locationValue));
                    completed = true;
                }
            }
            catch (Exception exc)
            {
                Log.Error("Exception in BuildLocationPart", exc, this);
            }

            // In case of custom code was not executed or there was an exception, let's call default implementation
            if (!completed) 
                base.BuildLocationPart(sourceName, filterName, defaultValue);

        }

        private string MakeValueQueryableList(string filterValue)
        {
            var query = GetQuery(filterValue);

            if (string.IsNullOrEmpty(query))
                return filterValue;

            if (query.StartsWith("fast:", StringComparison.InvariantCultureIgnoreCase))
            {
                Log.Warn("Fast query are no longer supported. Sitecore Queries will be used instead. Query: " + query, this);
                query = query.Substring(5);
            }

            return string.Join("|", _currentItem.Axes.SelectItems(query).Select(item => item.ID.ToShortID().ToString().ToLower()));
        }

        private static string GetQuery(string filterValue) 
            => filterValue == null || !filterValue.StartsWith("query:") 
                ? null 
                : filterValue.Replace("->", "=").Substring(6);
    }
}
using Sitecore.Buckets.FieldTypes;
using Sitecore.Data.Items;

namespace MyAssembly.MyNamespace
{
    /// <summary>
    /// Returns custom builder in place of Sitecore default SourceFilterBuilder
    /// </summary>
    public class MultiRootMultilistWithSearchSourceFilterBuilderFactory : SourceFilterBuilderFactory
    {
        public override SourceFilterBuilder CreateSourceFilterBuilder(
            Item targetItem,
            string fieldId,
            string fieldSource)
        {
            return new MultiRootMultilistWithSearchSourceFilterBuilder(targetItem, fieldId, fieldSource);
        }
    }
}
using Microsoft.Extensions.DependencyInjection;
using Sitecore.Buckets.FieldTypes;
using Sitecore.DependencyInjection;

namespace MyAssembly.MyNamespace
{
    /// <summary>
    /// Registers custom factory in place of Sitecore default SourceFilterBuilderFactory
    /// </summary>
    public class MultiRootMultilistWithSearchServicesConfigurator : IServicesConfigurator
    {
        public void Configure(IServiceCollection serviceCollection) 
            => serviceCollection.AddSingleton<SourceFilterBuilderFactory, MultiRootMultilistWithSearchSourceFilterBuilderFactory>();
    }
}
<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:role="http://www.sitecore.net/xmlconfig/role/">
    <sitecore role:require="Standalone or ContentManagement">
        <services>
            <configurator type="MyAssembly.MyNamespace.MultiRootMultilistWithSearchServicesConfigurator, MyAssembly" />
        </services>
    </sitecore>
</configuration>

Now, when you use a query in the StartSearchLocation parameter that yields multiple results, such as:

StartSearchLocation=query:/sitecore/content/Data/Categories|/sitecore/content/Data/Colors|/sitecore/content/data//*[@@templateid->'{64598827-2F8C-4744-BD06-98DE99C86EFA}']

when you load an item with that field in the Content Editor, you can observe in the Network tab of your browser's developer tools a call to the handler:

/sitecore/shell/Applications/Buckets/Services/Search.ashx

This call includes pipe-separated locations in the request payload, for example:

StartSearchLocation: a35932a3f9cb4231816838a9265e7e3a|4ad375bd87f5423cb89d81ce4f1a16b1|10eb19265d0b43b8bde954e553e15333

The second part of the process involves updating the code to include multiple roots in the query sent to the search engine, such as Solr. While it would be easier if we could modify the Sitecore code directly, this isn't an option. Instead, I added processors to the buckets.resolveUIDocumentMapperFactoryRules and contentSearch.getGlobalSearchFilters pipelines:

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:role="http://www.sitecore.net/xmlconfig/role/">
    <sitecore role:require="Standalone or ContentManagement">
        <pipelines>
            <buckets.resolveUIDocumentMapperFactoryRules>
                <processor 
                    patch:before="processor[@type='Sitecore.Buckets.Pipelines.UI.Search.ResolveDocumentMapperFactoryRule.ResolveSitecoreUIRules, Sitecore.Buckets']"
                    type="MyAssembly.MyNamespace.CleanupMultiRootLocation, MyAssembly"/>
            </buckets.resolveUIDocumentMapperFactoryRules>
            <contentSearch.getGlobalSearchFilters>
                <processor type="MyAssembly.MyNamespace.AddMultiRootExpressionToQuery, MyAssembly"/>
            </contentSearch.getGlobalSearchFilters>
        </pipelines>
    </sitecore>
</configuration>
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Sitecore.Buckets.Pipelines;
using Sitecore.Buckets.Pipelines.UI.Search;
using Sitecore.ContentSearch.Utilities;
using Sitecore.Diagnostics;

namespace MyAssembly.MyNamespace
{
    /// <summary>
    /// Performs cleanup of values "location" elements in UISearchArgs stringModel field.
    /// Removes all the pipe separated values
    /// </summary>
    public class CleanupMultiRootLocation : BucketsPipelineProcessor<UISearchArgs>
    {
        public override void Process(UISearchArgs args)
        {
            try
            {
                if (args.GetType().GetField("stringModel", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(args) is List<SearchStringModel> stringModel)
                {
                    var searchStringModels = stringModel.Where(m => m.Type == "location" && m.Value.Contains('|')).ToList();
                    foreach (var model in searchStringModels)
                    {
                        stringModel.Remove(model);
                    }
                }
            }
            catch (Exception exc)
            {
                Log.Warn("Exception in CleanupMultiRootLocation", exc, this);
            }
        }
    }
}
using System;
using System.Linq;
using System.Web;
using Sitecore.ContentSearch.Linq.Utilities;
using Sitecore.ContentSearch.Pipelines.GetGlobalFilters;
using Sitecore.ContentSearch.SearchTypes;
using Sitecore.Diagnostics;

namespace MyAssembly.MyNamespace
{
    /// <summary>
    /// Adds AND-OR expression with all the Multilist with Search start search locations
    /// </summary>
    public class AddMultiRootExpressionToQuery : GetGlobalFiltersProcessor
    {
        public override void Process(GetGlobalFiltersArgs args)
        {
            try
            {
                var location = HttpContext.Current?.Request.Form["StartSearchLocation"] ?? HttpContext.Current?.Request["StartSearchLocation"];

                if (location != null && location.Contains('|') && args.Query is IQueryable<UISearchResult> queryable)
                {
                    var expression = PredicateBuilder.False<UISearchResult>();

                    foreach (var root in location.Split(new[] { "|" }, StringSplitOptions.RemoveEmptyEntries))
                    {
                        expression = expression.Or(i => i["_path"].Equals(root));
                    }

                    args.Query = queryable.Where(expression);
                }
            }
            catch (Exception exc)
            {
                Log.Warn("Exception in AddMultiRootExpressionToQuery", exc, this);
            }
        }
    }
}

The first processor removes pipe-separated locations from the stringModel to prevent errors when calling the search engine and to ensure the query does not expect an incorrect path. The second processor adds an expression to the query in the format AND (_path:(root1) OR _path:(root2) OR ...). This code ensures that all other parts of the field source work as expected, allowing you to include out-of-the-box template filters, relative paths, or any other filters, and they will still function as usual.

With these adjustments, you can seamlessly enable your content authors to utilize multiple roots for finding items in the Sitecore Multilist with Search field type.

Link to my GitHub repository: Sitecore Multi-Root Search in 'Multilist with Search' Field.

Comments? Find me on or Sitecore Chat