Multi-node tree picker values can be a bit of a beast to break apart. I got through this back in 2020 with Paul Seal's awesome article on the topic, but a lot has changed from v8 to now! My article updates his to show you how you can use categories conveniently with Examine.
Back in 2020, Paul Seal wrote an amazing blog post on how to search by picked multi node tree picker values in Umbraco 8. I found this post incredibly useful when I needed to update my Examine indexes to do something like this recently, but I also discovered that there are some different steps that we need to implement in newer versions of Umbraco.
Like Paul, I wanted to implement searching on a category picked in a multi-node tree picker for content in my client's Umbraco instance, so I quickly discovered his blog post and found that it was an incredible basis to get me going. The examples that I am providing were written for version 13, but should work all the way back to version nine. They are updated examples from Paul's blog post, so definitely go give his a read for his initial impressions.
The Underlying Problem
The data stored in Umbraco is exactly the same as Paul's example - a comma separated list of UDIs. This is how media is stored as well, but for our example, we will be using content. As Paul posted, you should see something like this:
And, just like in Umbraco 8, these aren't easily searchable - we need to find a better way to connect our data than this list.
The v9+ Solution
Step 1: Add a New Field to ExternalIndexerConfigureOptions
Paul started in the same place - we need to add the field definition of our categories to our project, but instead of starting in the Component we now add it to our ExternalIndexerConfigureOptions:
using Examine;
using Examine.Lucene;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
namespace ProWorks.Core.Search
{
public class ExternalIndexerConfigureOptions : IConfigureNamedOptions<LuceneDirectoryIndexOptions>
{
private readonly IOptions<IndexCreatorSettings> _settings;
public ExternalIndexerConfigureOptions(IOptions<IndexCreatorSettings> settings)
{
_settings = settings;
}
public void Configure(string? name, LuceneDirectoryIndexOptions options)
{
if (name?.Equals("ExternalIndex") is false)
{
return;
}
options.FieldDefinitions.AddOrUpdate(new FieldDefinition("searchableCategories", FieldDefinitionTypes.FullText));
}
public void Configure(LuceneDirectoryIndexOptions options) => throw new NotImplementedException();
}
}
The entire set of configuration options, which we will break down
The important part is this snippet of text where we add a new field definition to our index that allows us to populate our search categories:
The AddOrUpdate function that is the important piece
Step 2: Transforming the value in our ExamineComponent
In Umbraco 9+, we still use a component and composer to update our Examine indexes, but the naming of the functions have changed - there is no longer an event for IndexerComponent_TransformingIndexValues. Instead, we need to use IndexProviderTransformingIndexValues.
My updated code in Umbraco 13 looks like this:
using Examine;
using Microsoft.Extensions.Logging;
using System.Text.RegularExpressions;
using System.Text;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
using Umbraco.Cms.Infrastructure.Examine;
namespace ProWorks.Core.Search
{
public class ExamineComposer : ComponentComposer<ExamineComponents>, IComposer
{
}
public class ExamineComponents : IComponent
{
private readonly IExamineManager _examineManager;
private readonly ILogger<ExamineComponents> _logger;
public ExamineComponents(IExamineManager examineManager, ILogger<ExamineComponents> logger)
{
_examineManager = examineManager;
_logger = logger;
}
public void Initialize()
{
if (!_examineManager.TryGetIndex("ExternalIndex", out var externalIndex))
throw new InvalidOperationException($"No index found by name ExternalIndex");
if (!(externalIndex is BaseIndexProvider indexProvider))
throw new InvalidOperationException("Could not cast");
indexProvider.TransformingIndexValues += IndexProviderTransformingIndexValues;
}
public void Terminate()
{
}
private void IndexProviderTransformingIndexValues(object? sender, IndexingItemEventArgs e)
{
ConvertUdiPickerToGuidStringList(e, "searchableCategories")
}
private void ConvertUdiPickerToGuidStringList(IndexingItemEventArgs e, string fieldName)
{
try
{
var updatedValues = e.ValueSet.Values.ToDictionary(x => x.Key, x => x.Value.ToList());
updatedValues.TryGetValue(fieldName, out var pickedValues);
if (pickedValues?.FirstOrDefault() != null)
{
var value = pickedValues?.FirstOrDefault() as string;
if (value != null)
{
var keys = value.Split(',');
var keyValues = new List<string>();
foreach (var key in keys)
{
var match = Regex.Match(key, @"(?<=umb:\/\/document\/)[a-f0-9]+");
if (match.Success)
{
keyValues.Add(match.Value);
}
}
if (keyValues.Any())
{
value = string.Join(' ', keyValues);
}
updatedValues.Remove(fieldName);
updatedValues.Add(fieldName, new List<object> { value });
}
}
e.SetValues(updatedValues.ToDictionary(x => x.Key, x => (IEnumerable<object>)x.Value));
}
catch (Exception ex)
{
_logger.LogError(ex, $"Could not update {fieldName}");
}
}
}
}
The full ExamineComponents file, including the Composer
Like in Paul's v8 example, we initialize our component, but unlike Paul's example, we do the adding of the field definition in Step 1, above. Then we get our index from the provider and plug in our method to transform the values:
if (!_examineManager.TryGetIndex("ExternalIndex", out var externalIndex))
throw new InvalidOperationException($"No index found by name ExternalIndex");
if (!(externalIndex is BaseIndexProvider indexProvider))
throw new InvalidOperationException("Could not cast");
indexProvider.TransformingIndexValues += IndexProviderTransformingIndexValues;
How we call the indexes and then the event for transforming the index values
Now we check for our transforming values - and I did something different here! I added a generic help function that I could use in case I need to convert a UDI picker for different properties.
This shows that we call my custom ConvertUdiPickerToGuidStringList method
In Paul's example, we could get the keys from the cache in v8 but we don't have access to that in the same way in more modern versions. Instead, we get the raw data from the fields in the index - the comma separate string list of UDIs - and we have to convert that into our searchable format - a space separated list of keys (without hyphens!).
I use Regex to see if there is a match for the key, pull it out, and merge it back into a single string separated by a space instead, as you can see here:
private void ConvertUdiPickerToGuidStringList(IndexingItemEventArgs e, string fieldName)
{
try
{
var updatedValues = e.ValueSet.Values.ToDictionary(x => x.Key, x => x.Value.ToList());
updatedValues.TryGetValue(fieldName, out var pickedValues);
if (pickedValues?.FirstOrDefault() != null)
{
var value = pickedValues?.FirstOrDefault() as string;
if (value != null)
{
var keys = value.Split(',');
var keyValues = new List<string>();
foreach (var key in keys)
{
var match = Regex.Match(key, @"(?<=umb:\/\/document\/)[a-f0-9]+");
if (match.Success)
{
keyValues.Add(match.Value);
}
}
if (keyValues.Any())
{
value = string.Join(' ', keyValues);
}
updatedValues.Remove(fieldName);
updatedValues.Add(fieldName, new List<object> { value });
}
}
e.SetValues(updatedValues.ToDictionary(x => x.Key, x => (IEnumerable<object>)x.Value));
}
catch (Exception ex)
{
_logger.LogError(ex, $"Could not update {fieldName}");
}
}
This is just our reusable ConvertUdiPickerToGuidStringList function - you can see the Regex.Match calls where we sort out our values from Examine.
Step 3: Search for the property in the ArticleService
I also used an article service to search my index by my categories property, starting with an interface to match Umbraco's dependency injection:
using Umbraco.Cms.Core.Models.PublishedContent;
namespace ProWorks.Core.Services
{
public interface IArticleService
{
IEnumerable<IPublishedContent>? GetByCategory(string categoryIdentifier, int pageSize = 10, int page = 1);
}
}
The interface for our ArticleService
Then I inherit from the interface and search by the query in the service itself, with pagination implemented. From this point out, you'll see I'm referencing using pagination that I pass down. If you want more information on that, you can read my Skrift article on implementing reusable pagination.
using Examine;
using Umbraco.Cms.Core.Models.PublishedContent;
using Serilog;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;
namespace ProWorks.Core.Services
{
public class ArticleService : IArticleService
{
private ILogger _logger;
private IExamineManager _examineManager;
private IUmbracoContextAccessor _contextAccessor;
public ArticleService(ILogger logger, IExamineManager examineManager, IUmbracoContextAccessor contextAccessor)
{
_logger = logger;
_examineManager = examineManager;
_contextAccessor = contextAccessor;
}
public IEnumerable<IPublishedContent>? GetByCategory(string categoryIdentifier, int pageSize = 10, int page = 1)
{
if (String.IsNullOrEmpty(categoryIdentifier))
{
_logger.LogWarning($"{nameof(categoryIdentifier)} was not provided", this);
return null;
}
List<IPublishedContent>? resultNodes = null;
try
{
var index = _examineManager.GetIndex("ExternalIndex");
var searchCriteria = index.Searcher.CreateQuery();
var query = searchCriteria.Field("searchableCategories", categoryIdentifier);
ISearchResults? pageOfResults = query.Execute(new QueryOptions((page * pageSize), pageSize));
if (_contextAccessor.TryGetUmbracoContext(out var umbracoContext))
{
resultNodes = pageOfResults.Select(result => int.Parse(result.Id))?.Select(id => umbracoContext.Content?.GetById(id)).WhereNotNull().ToList();
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"There was a problem retrieving the articles by category identifier: {categoryIdentifier}", this);
}
return resultNodes;
}
}
}
How we search Examine to get results in our ArticleService
Step 4: Register the service
The component that I linked in Step 2 already has a composer at the top of the file that registers it. In Umbraco 9+ we need to add our IArticleService to the UmbracoBuilder extensions.
I personally keep a separate UmbracoBuilderExtensions file for this purpose to keep my Program.cs file clean and readable. In my Program.cs I have something like this:
using ProWorks.Core.UmbracoBuilder;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
var umbracoBuilder = builder.CreateUmbracoBuilder()
.AddBackOffice()
.AddWebsite()
.AddDeliveryApi()
.AddComposers()
.AddProWorksServices();
What you need to do to register custom UmbracoBuilderExtensions in your Program file
Then in my UmbracoBuilderExtensions I create that AddProWorksServices() method and register my ArticleService:
using ProWorks.Core.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
namespace ProWorks.Core.UmbracoBuilder
{
public static class UmbracoBuilderExtensions
{
/// <summary>
/// Initializes all of the ProWorks customizations for the UmbracoBuilder
/// </summary>
/// <param name="builder"></param>
/// <returns></returns>
public static IUmbracoBuilder AddProWorksServices(this IUmbracoBuilder builder)
{
builder.Services.AddSingleton<IArticleService, ArticleService>();
return builder;
}
}
}
Registering the ArticleService with it's interface in the UmbracoBuilderExtensions
Step 5: Adding Your Content to the View
There are multiple ways that you can render your articles by category in your front-end View:
If you're using a headless version, you would create an API call, reference your service, and call it that way.
You may use custom view models and want to map your articles to your view model by calling the service in a custom controller.
You may wish to render this with a ViewComponent (my personally preferred method) to display in the front in a custom partial view.
Or you might want to do the "quick and dirty" approach and call the service right in your view.
I'm going to show you how to do the last 2.
Rendering with a ViewComponent
First we need to create a new ViewComponent where we can pass in a category and get the articles attached to it. I'm going to call it ArticlesByCategoryViewComponent:
using ProWorks.Core.Models.Generated;
using ProWorks.Core.Services;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Extensions;
namespace ProWorks.Core.ViewComponents
{
public class ArticlesByCategoryViewComponent : ViewComponent
{
private readonly IArticleService _articleService;
public ArticlesByCategoryViewComponent(IArticleService articleService)
{
_articleService = articleService;
}
public IViewComponentResult Invoke(IPublishedContent articleCategory, int pageSize = 10, int page = 1)
{
var articles = new List<IPublishedContent>();
if(articleCategory != null)
{
var searchedArticles = _articleService.GetByCategory(articleCategory.Key.ToString().Replace("-", string.Empty), pageSize, page);
if(searchedArticles != null && searchedArticles.Any())
{
foreach(var searchedArticle in searchedArticles.OrderByDescending(a => a.UpdateDate))
{
if(searchedArticle is Article)
{
articles.Add(searchedArticle);
}
}
}
}
return View("~/Views/Partials/ViewComponents/ArticlesByCategory.cshtml", articles);
}
}
}
The full file for getting the articles by category, which includes calling the service and passing in the category variable
You'll note that I'm not particularly fond of the default ViewComponent return, so I always put mine in custom folders with custom names per my own organizational preferences.
My custom return for how I personally organize my ViewComponent views
You can add whatever content you want to the display in this view. I have mine mapped directly to an Umbraco model in my ViewComponent and can retrieve any properties needed off it.
To call it in any view where you want to pass in the category, you can use something like the following, where the you're calling articles in the same category from an existing article page:
@using ProWorks.Core.ViewComponents
@{
var pageSize = 10;
var page = 1;
}
@(await Component.InvokeAsync<ArticlesByCategoryViewComponent>(new { content.Category, pageSize, page }))
How to call the ViewComponent from inside your Razor file
Calling the Service Directly from your View
If you want to get the service directly in your View and then loop through all the results, you can inject the service and retrieve the articles like so:
@using ProWorks.Core.Services
@inject IArticleService _articleService;
@{
var pageSize = 10;
var page = 1;
var searchedArticles = _articleService.GetByCategory(articleCategory.Key.ToString().Replace("-", string.Empty), pageSize, page);
}
How to inject the ArticleService directly into the Razor file and call the search function
Then you can call your searchedArticles and loop through them like you would with any rendered content!
Conclusion
I hope this helps you sort searching by picked content in your project. Examine is very fast and is nicer to use than looping through the cache of the entire site to find all articles in this category.
In my particular use case, my client has a category on every piece of their content that can be used to search across the site, not just their blog posts, so this was a convenient way for them to display their related content wherever they want.