blog
Creating a Multi-Root Treelist Field in Sitecore
A Custom Treelist Field to Support Multiple Data Sources

Sitecore's Treelist field is an excellent way to select items that live under a common root node, but what if we want to select items from multiple root nodes? Moreover, what if we want to be able to use datasource parameters with our Sitecore queries? This post will provide you will a custom field type that you can use to do just that.

Considerations

Before you start developing, there are a few things that you need to consider:

  1. If you're displaying multiple roots then you need to customize the view so that the content author can see the paths to the elements. Since roots may have the same name, the name of the root may not be meaningful enough.
  2. The default Treelist shows the root /Sitecore node if the root was not found. However, since we're going to be showing multiple roots, it's likely better to instead hide the node if the root was not found.
  3. If you're planning to use this on a solution with content search and you want to index this field type, you will need to add it to your index configuration.

Implementation

With the above considerations in mind, the following two classes and config settings are needed to make this new field type. Once you have added them to your solution, you will need to add a new item for the field type under /sitecore/system/Field types/List Types in the core database.

MultiRootTreelist.cs

/// <summary>
/// This field type is like a tree list, but you can specify more than one root item to select from
/// </summary>
/// <remarks>
/// Credit to Kam Figy for providing the foundation for this solution: http://kamsar.net/index.php/2015/05/A-Multiple-Root-Treelist-Field/
/// 
/// The data source roots are specified using pipe delimiting just like regular Sitecore Query language. This is great when a field needs to allow, for example,
/// the selection of both videos and photos. 
/// 
/// Note that this solution also requires a config setting to be added for the field type. For example, the following would be added to the the 
/// sitecore/fieldTypes path: 
///
/// <fieldType name="Multi-Root Treelist" type="Sitecore.Data.Fields.MultilistField,Sitecore.Kernel" /> 
///
/// Additionally, the field type should be added to the ContentSearch configuration, if using ContentSearch
/// </remarks>
public class MultiRootTreeList : TreeList
{
    /// <summary>
    /// Prefix that all Sitecore queries should start with
    /// </summary>
    private const string QueryPrefix = "query:";
    /// <summary>
    /// Parameter key for the DataSource parameter of the Source field
    /// </summary>
    private const string DataSourceParameterKey = "datasource";

    private string[] _sources;
    /// <summary>
    /// The split sources within the <seealso cref="TreeList.Source"/> property
    /// </summary>
    public string[] Sources => _sources ?? (_sources = GetSources());

    private string[] _dataSources;
    /// <summary>
    /// The datasources retrieved from the <seealso cref="Sources"/>
    /// </summary>
    public string[] DataSources
    {
        get
        {
            // if we already found the sources, don't do all this cumbersome logic again
            if (_dataSources != null)
            {
                return _dataSources;
            }

            if (string.IsNullOrEmpty(Source) || global::Sitecore.Context.ContentDatabase == null || ItemID == null)
            {
                return (_dataSources = new string[] { });
            }

            var contextDb = global::Sitecore.Context.ContentDatabase;

            // the current item, from which relatative queries are executed
            var currentItem = contextDb.GetItem(ItemID);

            return (_dataSources = Sources
                .Select(source => GetDatasource(source, currentItem))
                // decided not to fall back here; if the datasource wasn't found then remove it, rather than show a default node
                .Where(datasource => datasource != null)
                .ToArray());
        }
    }

    protected override void OnLoad(EventArgs args)
    {
        Assert.ArgumentNotNull(args, "args");

        // if this is an event, just run the base logic and return
        if (global::Sitecore.Context.ClientPage.IsEvent)
        {
            return;
        }

        base.OnLoad(args);

        // find the existing TreeviewEx that the base OnLoad added, get a ref to its parent, and remove it from controls
        var existingTreeView = (TreeviewEx)WebUtil.FindControlOfType(this, typeof(TreeviewEx));
        var treeviewParent = existingTreeView.Parent;

        existingTreeView.Parent.Controls.Clear(); // remove stock treeviewex, we replace with multiroot

        // find the existing DataContext that the base OnLoad added, get a ref to its parent, and remove it from controls
        var dataContext = (DataContext)WebUtil.FindControlOfType(this, typeof(DataContext));
        var dataContextParent = dataContext.Parent;

        dataContextParent.Controls.Remove(dataContext); // remove stock datacontext, we parse our own

        // create our MultiRootTreeview to replace the TreeviewEx
        var impostor = new EnhancedMultiRootTreeview
        {
            ID = existingTreeView.ID,
            DblClick = existingTreeView.DblClick,
            Enabled = existingTreeView.Enabled,
            DisplayFieldName = existingTreeView.DisplayFieldName
        };

        // parse the data source and create appropriate data contexts out of it
        var dataContexts = ParseDataContexts(dataContext);

        // join the IDs of all of the DataContext objects together, to be added as controls
        impostor.DataContext = 
            string.Join(
                "|", 
                dataContexts
                    .Select(dc => dc.ID));

        // add the new DataContext controls into the selection pane
        foreach (var context in dataContexts)
        {
            dataContextParent.Controls.Add(context);
        }

        // inject our replaced control where the TreeviewEx originally was
        treeviewParent.Controls.Add(impostor);
    }

    /// <summary>
    /// Gets all of the datasources from the Source field
    /// </summary>
    protected virtual string[] GetSources()
    {
        if (string.IsNullOrEmpty(Source))
        {
            return new string[] {};
        }

        var datasource = global::Sitecore.StringUtil.ExtractParameter(DataSourceParameterKey, Source).Trim();
        datasource = string.IsNullOrEmpty(datasource)
            ? Source
            : datasource;

        return datasource.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
    }

    /// <summary>
    /// Gets the item path to the datasource item
    /// </summary>
    /// <param name="source">The source used to find the datasource item</param>
    /// <param name="currentItem">The current item</param>
    protected virtual string GetDatasource(string source, Item currentItem)
    {
        Item datasourceItem = null;

        // if the source is a query then run the query from the current item
        if (source.StartsWith(QueryPrefix))
        {
            try
            {
                var results = LookupSources.GetItems(currentItem, source);
                datasourceItem = results
                    .FirstOrDefault(item => item != null);
            }
            catch (Exception ex)
            {
                Log.Error($"Treelist field failed to execute query: '{source}'", ex, this);
            }
        }
        // otherwise just get the item specified
        else
        {
            datasourceItem = currentItem.Database.GetItem(source);
        }

        return datasourceItem?.Paths.FullPath;
    }

    /// <summary>
    /// Parses multiple source roots into discrete data context controls (e.g. 'dataSource=/sitecore/content|/sitecore/media library')
    /// </summary>
    /// <param name="originalDataContext">The original data context the base control generated. We reuse some of its property values.</param>
    protected virtual DataContext[] ParseDataContexts(DataContext originalDataContext)
    {
        // if there are multiple roots; otherwise, just use the original datasource (shouldn't happen, but just in case...)
        return (DataSources.Any() ? DataSources : new ListString(DataSource).AsEnumerable())
            .Select(datasource => CreateDataContext(originalDataContext, datasource))
            .ToArray();
    }

    /// <summary>
    /// Creates a DataContext control for a given Sitecore path data source
    /// </summary>
    protected virtual DataContext CreateDataContext(DataContext baseDataContext, string dataSource)
    {
        var dataContext = new DataContext
        {
            ID = GetUniqueID("D"),
            Filter = baseDataContext.Filter,
            DataViewName = "Master"
        };

        if (!string.IsNullOrEmpty(DatabaseName))
        {
            dataContext.Parameters = "databasename=" + DatabaseName;
        }

        dataContext.Root = dataSource;
        dataContext.Language = Language.Parse(ItemLanguage);

        return dataContext;
    }
}

EnhancedMultiRootTreeview.cs

public class EnhancedMultiRootTreeview : MultiRootTreeview
{
    protected override string GetHeaderValue(Item item)
    {
        Assert.ArgumentNotNull(item, "item");

        var nodeTitle = string.IsNullOrEmpty(DisplayFieldName) ? item.DisplayName : item[DisplayFieldName];
        nodeTitle = string.IsNullOrEmpty(nodeTitle) ? item.DisplayName : nodeTitle;
        // you can get fancy here and make the format of the nodeTitle manageable in config
        nodeTitle = $"{nodeTitle}  -  <span>({item.Paths.FullPath})</span>";

        return nodeTitle;
    }
}

Custom.FieldTypes.config

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <fieldTypes>
      <fieldType name="Multi-Root Treelist" type="Sitecore.Data.Fields.MultilistField,Sitecore.Kernel" patch:after="fieldType[@name='Treelist']" />
    </fieldTypes>
  </sitecore>
</configuration>

Custom.FieldTypes.Solr.config

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <contentSearch>
      <indexConfigurations>
        <defaultSolrIndexConfiguration type="Sitecore.ContentSearch.SolrProvider.SolrIndexConfiguration, Sitecore.ContentSearch.SolrProvider">
          <fieldMap type="Sitecore.ContentSearch.SolrProvider.SolrFieldMap, Sitecore.ContentSearch.SolrProvider">
            <fieldTypes hint="raw:AddFieldByFieldTypeName">
              <fieldType fieldTypeName="multi-root treelist"   returnType="stringCollection" />
            </fieldTypes>
          </fieldMap>
          <fieldReaders type="Sitecore.ContentSearch.FieldReaders.FieldReaderMap, Sitecore.ContentSearch">
            <mapFieldByTypeName hint="raw:AddFieldReaderByFieldTypeName">
              <fieldReader fieldTypeName="multi-root treelist"    fieldReaderType="Sitecore.ContentSearch.FieldReaders.MultiListFieldReader, Sitecore.ContentSearch" />
            </mapFieldByTypeName>
          </fieldReaders>
        </defaultSolrIndexConfiguration>
      </indexConfigurations>
    </contentSearch>
  </sitecore>
</configuration>
3 Comments
Post a Comment

 
  • Comment by Ruben Chan on Feb 17, 2017
    Thanks Zachary, this is exactly what I was looking for! Just a note that this code is splits datasources using a comma instead of a pipe that is mentioned in the comments.
  • Comment by Kasia Pietraszko on Oct 03, 2017
    That's just awesome. Very useful, thank you!
  • Comment by Daniel on Oct 06, 2017
    I would love to implement this, but I can't get through all the compiler warnings. Can you include entire files, with namespace and usings?