How to search by picked multi node tree picker values in Umbraco v8

Posted written by Paul Seal on January 16, 2020 Umbraco

In Umbraco you can use a multinode tree picker for properties like categories and you might want to search by a particular category.

On a category page you might want to display all articles which have that category picked.

This post shows you how you can use examine to search for articles which have the category picked in the multinode tree picker.

Understanding the problem

First of all we need to understand how the categories property is stored.

To do this we can go into Settings > Examine Management > External Index and then search for our article and then look for the categories property in the list of fields.

The value will be something like this:

umb://document/29bea3758fbb4517a8f58a3d4c001091,umb://document/067113a0bf494e8da6cd28127aaa08c7

These are the UDIs of the categories that we have picked. We aren't able to search by these UDIs.

The solution

We need to make them searchable, so we need to create an new field called searchableCategories.

Add this file called IndexerComposer to the Composition folder

IndexerComposer.cs

using Examine;
using Examine.Providers;
using System;
using System.Collections.Generic;
using System.Linq;
using Umbraco.Core.Composing;
using Umbraco.Core.Models.PublishedContent;
using Umbraco.Web;

namespace CodeShare.Core.Composition
{
    [RuntimeLevel(MinLevel = RuntimeLevel.Run)]
    public class IndexerComposer : ComponentComposer<IndexerComponent>
    { }

    public class IndexerComponent : IComponent
    {
        private readonly IExamineManager _examineManager;
        private readonly IUmbracoContextFactory _umbracoContextFactory;

        public IndexerComponent(IExamineManager examineManager,
            IUmbracoContextFactory umbracoContextFactory)
        {
            _examineManager = examineManager ?? throw new ArgumentNullException(nameof(examineManager));
            _umbracoContextFactory = umbracoContextFactory ?? throw new ArgumentNullException(nameof(umbracoContextFactory));
        }

public void Initialize()
{
    if (_examineManager.TryGetIndex("ExternalIndex", out IIndex externalIndex))
    {
        externalIndex.FieldDefinitionCollection.AddOrUpdate(
            new FieldDefinition("searchableCategories", FieldDefinitionTypes.FullText));

        ((BaseIndexProvider)externalIndex).TransformingIndexValues +=
            IndexerComponent_TransformingIndexValues;
    }
}

        private void IndexerComponent_TransformingIndexValues(object sender, IndexingItemEventArgs e)
        {
            if (int.TryParse(e.ValueSet.Id, out var nodeId))
            {
                switch (e.ValueSet.ItemType)
                {
                    case "article":
                        using (var umbracoContext = _umbracoContextFactory.EnsureUmbracoContext())
                        {
                            var contentNode = umbracoContext.UmbracoContext.Content.GetById(nodeId);
                            if (contentNode != null)
                            {
                                var categories = contentNode.Value<IEnumerable<IPublishedContent>>("categories");
                                if (categories != null &amp;&amp; categories.Any())
                                {
e.ValueSet.Set("searchableCategories", string.Join(" ", categories.Select(x => x.Key.ToString("N"))));
                                }
                                else
                                {
                                    e.ValueSet.Set("searchableCategories", null);
                                }
                            }
                        }
                        break;
                }
            }
        }
        public void Terminate() { }
    }
}

The first part of this makes sure that we have a searchableCategories field in the index.

public void Initialize()
{
    if (_examineManager.TryGetIndex("ExternalIndex", out IIndex externalIndex))
    {
        externalIndex.FieldDefinitionCollection.AddOrUpdate(
            new FieldDefinition("searchableCategories", FieldDefinitionTypes.FullText));

        ((BaseIndexProvider)externalIndex).TransformingIndexValues +=
            IndexerComponent_TransformingIndexValues;
    }
}

The in the IndexerComponent_TransformingIndexValues method we get the original categories and store them as a space separated list of keys.

e.ValueSet.Set("searchableCategories", string.Join(" ", categories.Select(x => x.Key.ToString("N"))));

So the values will be stored in examine in a new field called searchableCategories like this:

29bea3758fbb4517a8f58a3d4c001091 067113a0bf494e8da6cd28127aaa08c7

This makes it a lot easier for us to be able to use examine to search for articles which have been tagged with a specific category now.

Now we can write a service to Get all articles which have the category picked in the categories property.

First create an interface called IArticleService

IArticleService.cs

using Examine;
using System.Collections.Generic;
using Umbraco.Core.Models.PublishedContent;

namespace CodeShare.Core.Services
{
    public interface IArticleService
    {
        IEnumerable<ISearchResult> GetArticlesByCategory(IPublishedContent category);
    }
}

Then create a class called ArticleService which inherits from IArticleService

ArticleService.cs

using Examine;
using System.Collections.Generic;
using System.Linq;
using Umbraco.Core.Models.PublishedContent;
using Umbraco.Web;

namespace CodeShare.Core.Services
{
    public class ArticleService : IArticleService
    {
        private readonly IUmbracoContextAccessor _umbracoContextAccessor;

        public ArticleService(IUmbracoContextAccessor umbracoContextAccessor)
        {
            _umbracoContextAccessor = umbracoContextAccessor;
        }

        /// <summary>
        /// Given a category this will return the matching articles as ISearchResults
        /// </summary>
        /// <param name="category">The category to search for articles by</param>
        /// <returns>The search results</returns>
        public IEnumerable<ISearchResult> GetArticlesByCategory(IPublishedContent category)
        {

            if (ExamineManager.Instance.TryGetIndex("ExternalIndex", out var index))
            {
                var searcher = index.GetSearcher();
                var query = searcher.CreateQuery().GroupedOr(new[] { "__NodeTypeAlias" }, new[] { "article" });

                query = query.And().Field("searchableCategories", category.Key.ToString("N"));

                var allResults = query.Execute();

                return allResults;
            }

            return Enumerable.Empty<ISearchResult>();
        }
    }
}

Now when we use this service we will be able to get our articles by Category.

If you want to use the service you first need to register it like this

RegisterServicesComposer.cs

using CodeShare.Core.Services;
using Umbraco.Core;
using Umbraco.Core.Composing;

namespace CodeShare.Core.Composition
{
    /// <summary>
    /// In this class we are registering our custom services to the umbraco composition
    /// </summary>
    [RuntimeLevel(MinLevel = RuntimeLevel.Run)]
    public class RegisterServicesComposer : IUserComposer
    {
        public void Compose(Umbraco.Core.Composing.Composition composition)
        {
            //Lifetime is set to .Request here because we are using the Umbraco Context Accessor in the service
            composition.Register<IArticleService, ArticleService>(Lifetime.Request);
        }
    }
}

You can then use it in your controllers or you could add the service as a property on your views by creating a custom view page like this:

CodeShareViewPage.cs

using CodeShare.Core.Services;
using Umbraco.Core;
using Umbraco.Core.Cache;
using Umbraco.Core.Services;
using Umbraco.Web.Mvc;
using Current = Umbraco.Web.Composing.Current;

namespace CodeShare.Core.ViewPages
{
    public abstract class CodeShareViewPage<T> : UmbracoViewPage<T>
    {
        public readonly IArticleService ArticleService;

        public CodeShareViewPage() : this(
                Current.Factory.GetInstance<IArticleService>(),
                Current.Factory.GetInstance<ServiceContext>(),
                Current.Factory.GetInstance<AppCaches>()
                )
        { }

        public CodeShareViewPage(
            IArticleService articleService, ServiceContext services, AppCaches appCaches)
        {
            ArticleService = articleService;
            Services = services;
            AppCaches = appCaches;
        }
    }

    public abstract class CodeShareViewPage : UmbracoViewPage
    {
        public readonly IArticleService ArticleService;

        public CodeShareViewPage() : this(
                Current.Factory.GetInstance<IArticleService>(),
                Current.Factory.GetInstance<ServiceContext>(),
                Current.Factory.GetInstance<AppCaches>()
                )
        { }

        public CodeShareViewPage(IArticleService articleService, ServiceContext services, AppCaches appCaches)
        {
            ArticleService = articleService;
            Services = services;
            AppCaches = appCaches;
        }
    }
}

And then you can have a partial view called articles which will be used on a category page so the Model is the IPublishedContent item which represents the category.

articles.cshtml

@inherits CodeShare.Core.ViewPages.CodeShareViewPage

@{
    var articles = ArticleService.GetArticlesByCategory(Model);
}