One of my favorite features in Sitecore Experience Accelerator (SXA) is query tokens. Query tokens make your templates and renderings multi-site friendly. With SXA, almost all field types support query tokens, but the Multilist with Search field doesn't. In this post I'll show you how to fix that.

Extend SourceFilterBuilderFactory

The Multilist with Search field uses a SourceFilterBuilder class to parse its field source. Sitecore uses SourceFilterBuilderFactory to create the SourceFilterBuilder, and it's a nice place to parse query tokens into something the SourceFilterBuilder can work with:

using System;
using System.Linq;
using Sitecore;
using Sitecore.Buckets.FieldTypes;
using Sitecore.Data.Items;
using Sitecore.XA.Foundation.SitecoreExtensions.Services;

namespace CoreySmith.Foundation.SitecoreExtensions
{
  public class QueryTokenSourceFilterBuilderFactory : SourceFilterBuilderFactory
  {
    private readonly IQueryService _queryService;

    public QueryTokenSourceFilterBuilderFactory(IQueryService queryService)
    {
      _queryService = queryService ?? throw new ArgumentNullException(nameof(queryService));
    }

    public override SourceFilterBuilder CreateSourceFilterBuilder(Item targetItem, string fieldId, string fieldSource)
    {
      if (targetItem == null) throw new ArgumentNullException(nameof(targetItem));
      if (fieldId == null) throw new ArgumentNullException(nameof(fieldId));
      if (fieldSource == null) throw new ArgumentNullException(nameof(fieldSource));

      var startSearchLocation = StringUtil.ExtractParameter("StartSearchLocation", fieldSource);
      if (!StartSearchLocationContainsQueryToken(startSearchLocation))
      {
        return base.CreateSourceFilterBuilder(targetItem, fieldId, fieldSource);
      }

      var resolvedStartSearchLocation = ResolveStartSearchLocation(startSearchLocation, targetItem);
      var resolvedFieldSource = fieldSource.Replace(startSearchLocation, resolvedStartSearchLocation);
      return base.CreateSourceFilterBuilder(targetItem, fieldId, resolvedFieldSource);
    }

    private static bool StartSearchLocationContainsQueryToken(string startSearchLocation)
    {
      return !string.IsNullOrEmpty(startSearchLocation)
           && startSearchLocation.StartsWith("query:")
           && startSearchLocation.Contains("$");
    }

    private string ResolveStartSearchLocation(string startSearchLocation, Item targetItem)
    {
      var query = ParseStartSearchLocationIntoQuery(startSearchLocation);
      var resolvedStartSearchLocation = _queryService.Resolve(query, targetItem.ID.ToString());

      // Multilist with Search fields only support one StartSearchLocation. If a pipe-delimited list
      // is set on the field source, no results will be returned; instead, return the first result.
      var firstStartSearchLocation = resolvedStartSearchLocation.Split('|').FirstOrDefault();
      return $"query:{firstStartSearchLocation}";
    }

    /// <summary>
    /// The StartSearchLocation parameter requires '->' in place of '=' within Sitecore queries.
    /// For example: StartSearchLocation=query:/sitecore/content//*[@@template->'SomeTemplate']
    /// The SXA query token resolver doesn't like '->', so replace with '=' for the Sitecore
    /// query engine.
    /// </summary>
    private static string ParseStartSearchLocationIntoQuery(string startSearchLocation)
    {
      return startSearchLocation.Replace("->", "=");
    }
  }
}

The QueryTokenSourceFilterBuilderFactory extracts the StartSearchLocation parameter out of the field source and checks if the StartSearchLocation contains a query token (i.e., the string starts with query: and contains $ somewhere). If not, it does nothing special.

If the field source does appear to have a query token, it uses SXA's IQueryTokenService to parse the StartSearchLocation into a traditional query. The QueryTokenSourceFilterBuilderFactory then creates the SourceFilterBuilder with the parsed field source.

Register the QueryTokenSourceFilterBuilderFactory with Sitecore's DI container. This will replace the SourceFilterBuilderFactory implementation that Sitecore uses out of the box.

using Microsoft.Extensions.DependencyInjection;
using Sitecore.Buckets.FieldTypes;
using Sitecore.DependencyInjection;

namespace CoreySmith.Foundation.SitecoreExtensions
{
  public class SitecoreExtensionsConfigurator : IServicesConfigurator
  {
    public void Configure(IServiceCollection serviceCollection)
    {
      serviceCollection.AddSingleton<SourceFilterBuilderFactory, QueryTokenSourceFilterBuilderFactory>();
    }
  }
}

Patch the configurator in through config:

<configuration>
  <sitecore>
    <services>
      <configurator type="CoreySmith.Foundation.SitecoreExtensions.SitecoreExtensionsConfigurator, CoreySmith.Foundation.SitecoreExtensions" />
    </services>
  </sitecore>
</configuration>

Deploy this code and config to your Sitecore instance and voila! Enjoy query tokens in your Multilist with Search fields.

Use It

Multilist with Search fields use content search for the "search" part, so they work a bit differently than other fields like Multilist or Treelist and have their own field source syntax. Scott "The Code Attic" Gillis did a fantastic write up on Multilist with Search field syntax and all its options. My friend Alex Shyba also did a great in-depth article on the Multilist with Search field when it was first released. If you're not familiar with the syntax, give both of these posts a read.

The QueryTokenSourceFilterBuilderFactory adds support for query tokens to the StartSearchLocation parameter. For example, if you want to limit selection to Video items under a site's Data\Videos folder, you can use this field source:

StartSearchLocation=query:$site/*[@@name='Data']/*[@@templatename='Video Folder']&TemplateFilter={ADE9EFF4-DA78-4E26-9248-B01BD93EAE95}

The ID in the TemplateFilter parameter is the ID of the SXA Video template.

Note that for the StartSearchLocation, your query should resolve to one item and | is not supported. If you use |, only the first (left-most) part of the query will be used.

Let me know what you think in the comments.