Saturday, 30 December 2017

Creating a dynamic, multi-source, search enabled TreeList for Sitecore

I spent a partially frustrating but ultimately reasonably successful day recently looking to extend a Sitecore editorial control to add some functionality that would be of benefit to our editors.

Our starting point was the built in TreeList control that we were using to allow editors to select images for a carousel. With this we can set a source to limit the selections to an appropriate location in the media library tree. That did the job for the initial release of the feature but we then had to handle some additional requirements, as follows.

  1. We need to allow editors to select from two locations, not just one. Specifically they were selecting images for display on a page on a house-builder's website, with the page showing a house that's of a particular type and found on a particular site. So images for the site and the house type were appropriate, and these were stored in different folders.
  2. We needed to dynamically set the source of these locations, so they were picking from the appropriate site and house type folders for the page the image carousel component was hosted on.
  3. Although we wanted to make these folders the default, we also wanted to allow editors to search and use tagged images across the media library.

We then needed to consider extending the existing TreeList field type, or look at another option that might provide the other features we need, such as MultiList with Search. We decided on the former approach given the TreeList was giving us some of the functionality we needed and it looked feasible to extend to add the additional features. In contrast it looked tricky to adapt the MultiList with Search to our requirements of making it easy for editors to select from specific folder locations, where we'd expect most of the images they wanted to use to be found.

Multiple root nodes

The first issue was relatively easy to resolve, given Kam Figy had posted a means of doing this on his blog a few years ago. Using that technique we were able to set a source with multiple paths - e.g. /sitecore/media library/folder a|/sitecore/media library/folder b. When the editing control is rendered within the Sitecore Content Editor, the default TreeList is replaced by a MultiRootTreeList used by internal Sitecore controls, and configured with the provided roots.

Dynamic sources

We had part of the solution for this already which we could adapt for use in this context. Although Sitecore complains that the entry is invalid, it doesn't stop you entering a source for a field using the custom TreeList containing tokens of the form: /sitecore/media library/$sitePath|/sitecore/media library/$houseTypePath. When the control is rendered we'd want to replace $sitePath with the appropriate folder for the specific site's image and do similar for the $houseTypePath token.

In the CreateDataContext method from the code linked above, we get a string representing one of our sources, e.g. /sitecore/media library/$sitePath. We then need to replace $sitePath with the actual path to the folder containing images for the site that correspond to the current page where the image carousel component is located. In our case a field from one of the ancestors of the page provides this information, but in the general case, the only issue really is determining the current page item that's being edited. Once you can do that, you can access it's fields or ancestors, and carry out whatever logic is necessary to determine the appropriate media library path.

Fortunately this is quite straightforward. The base TreeList provides an ItemID property, so we can retrieve the page item as follows:

    var database = Sitecore.Data.Database.GetDatabase("master");
    return database.GetItem(ItemID);

Providing search functionality

Image template with tags

In our solution we've created a custom template to use for images, that inherits from the base image field as described here. With that in place we can create other templates containing fields for tagging and add that as a base template too.

So any images uploaded to the media library will now be of our custom template and the editor will be able to assign tags to them. However, we can't currently make use of that when selecting images for our carousels, as we only have a field that supports selection of images from given folders.

Adding the search interface

Having worked with the ASP.Net MVC framework for several years, it's been a while for me since the time of working with ASP.Net user controls, but that's what we need to work with here. Although we don't have access to the design surface (in the .ascx file) we can still add controls programatically to the interface.

Initially I started doing this with ASP.Net controls such as TextBox and Button, but then lost a few hours trying to debug issues around post-backs. When the button was clicked, an exception of "Failed to load ViewState" would be thrown. Usually this occurs due to issues with the ASP.Net page lifecycle - creating controls in the wrong page event handler or not recreating them on post-backs. Unfortunately any combination I could try led to the same issue, so this doesn't seem to be the way to go when looking to create or modify a user control intended for use within the Sitecore CMS itself.

Instead I ended up simply applying the necessary mark-up using a Literal control, added to the user control in an overridden OnLoad page event:

    const string SearchFieldId = "MultiSourceTreeListWithSearchSearchQuery";
    const string PerformSearchMessageName = "multisourcetreelistwithsearch:search";
    var markup = $@"
        
"; Controls.AddAt(0, new Literal { Text = markup });

That still left the challenge of needing to find a way to "post-back" the search query entered in the text box so we can respond to it server side, by carrying out a search and then making the search results available to the user for selection.

I found the solution to that via looking at the little context menu items that can be added to controls and are found on many of the built-in ones. You do this by adding a folder named Menu under your custom field template, and then creating items of type Menu item below that.

Although I didn't want to use these in the final control - as a menu button on it's own was no use, we needed a text box too - by temporarily creating one of these with an appropriate message, I could see what JavaScript was generated, copy it and add it onto my own button:

    var clickHandler = $"javascript: return scForm.postEvent(this, event, '{PerformSearchMessageName}:' +  document.getElementById('{SearchFieldId}').value);";

In the above, when the button is clicked it'll pass a message of the form multisourcetreelistwithsearch:search:[search query] with the latter part read from the value entered into the text box.

Handling the search message

Server-side we can catch and respond to these messages like this:

    public override void HandleMessage(Message message)
    {
        if (message.Name.StartsWith(PerformSearchMessageName))
        {
            HandleSearchMessage(ExtractQueryFromMessage(message));
        }
    }

    private static string ExtractQueryFromMessage(Message message)
    {
        return message.Name.Split(':').Last();
    }

    private void HandleSearchMessage(string query)
    {
        HttpContext.Current.Items.Add("SearchQuery", query);

        var treeView = GetMultiRootTreeView();
        treeView.RefreshRoot();
    }
    
    private MultiRootTreeview GetMultiRootTreeView()
    {
        return (MultiRootTreeview)WebUtil.FindControlOfType(this, typeof(MultiRootTreeview));
    }

Messages are retrieved via the HandleMessage method where we check the value of the message to see if it's one we want to process. If it's the one passed from the search button we extract the search query from the end of the message and store it in the HttpContext.Current.Items dictionary so we can make use of to it later in the request cycle. How we do that is described shortly, but its triggered by the call to RefreshRoot of the MultiRootTreeview we have that displays the items we can select from when constructing our image carousel.

Rendering the search results

Before we can run the search and display the results, we need to prepare the MultiRootTreeview control to allow for display of the results such that they can then be selected by the editor for the image carousel. The aim is that it'll look and work like this:

By default the control will render the root folders we've configured and allow selection of any items found within those folders. We also create an empty root named "Search results" that will be populated by items that match the editor's search query.

We do this in code like this, calling the following method on the OnLoad page event handler of our derived control:

    protected override void OnLoad(EventArgs args)
    {
        ...

        var treeView = GetMultiRootTreeView();
        var dataContext = GetExistingDataContext();
        AddSearchResultsSourceToTreeView(dataContext, treeView);
    }
    
    private MultiRootTreeview GetMultiRootTreeView()
    {
        return (MultiRootTreeview)WebUtil.FindControlOfType(this, typeof(MultiRootTreeview));
    }
    
    private DataContext GetExistingDataContext()
    {
        return (DataContext)WebUtil.FindControlOfType(this, typeof(DataContext));
    }

    private void AddSearchResultsSourceToTreeView(DataContext dataContext, TreeviewEx treeView)
    {
        var searchResultsDataContext = CreateDataContext(
            dataContext,
            "/sitecore/templates/Foundation/Media/Search Results",
            "TreeListSearchResults");
        treeView.DataContext = $"{searchResultsDataContext.ID}|{treeView.DataContext}";
        dataContext.Parent.Controls.Add(searchResultsDataContext);
    }
    
    private DataContext CreateDataContext(DataContext baseDataContext, string dataSource, string dataViewName = "Master")
    {
        var dataContext = new DataContext
            {
                ID = GetUniqueID("D"),
                Filter = baseDataContext.Filter,
                DataViewName = dataViewName,
                Root = dataSource,
                Language = Language.Parse(ItemLanguage)
            };
        if (!string.IsNullOrEmpty(DatabaseName))
        {
            dataContext.Parameters = "databasename=" + DatabaseName;
        }

        return dataContext;
    }

As well as the addition of this new "root" (that maps to a Sitecore item we've created that has no children), the key thing to note is that we're using a custom value for DataViewName, of TreeListSearchResults. This corresponds to a class we've created that derives from MasterDataView:

    public class TreeListSearchResultsDataView : MasterDataView
    {
        protected override void GetChildItems(ItemCollection items, Item item)
        {
            var query = RetrieveSearchQuery();
            var results = GetSearchResults(query);
            AddSearchResultsToView(items, results);
        }

        private static string RetrieveSearchQuery()
        {
            return HttpContext.Current.Items["SearchQuery"] as string;
        }

        private static IList<BaseSearchResultItem> GetSearchResults(string query)
        {
            if (string.IsNullOrWhiteSpace(query))
            {
                return new List<BaseSearchResultItem>();
            }

            var searchRepo = new SitecoreSearchRepository("master");
            return searchRepo.Search<BaseSearchResultItem>(
                    q => (q.TemplateId == Settings.TemplateIDs.UnversionedJpeg || q.TemplateId == Settings.TemplateIDs.VersionedJpeg) &&
                        (q.Name.Contains(query) || q.Content.Contains(query)),
                    o => o.Name)
                .ToList();
        }

        private static void AddSearchResultsToView(ItemCollection items, IList<BaseSearchResultItem> results)
        {
            items.Clear();
            if (!results.IsAndAny())
            {
                return;
            }

            foreach (var result in results)
            {
                items.Add(result.GetItem());
            }
        }
    }

Within this class we override the GetChildItems method and carry out our search - retrieving the value from the HttpContext.Current.Items dictionary where we stashed it earlier and carrying out a search. Code for SitecoreSearchRepository isn't shown, but it's just wrapping the standard Sitecore functionality used for retrieving values from the Lucene or Solr search index.

When items are found in the search results, they are added to the ItemCollection passed as a parameter to the GetChildItems method. This populates the child items that appear under "Search Results" in the left hand side of our TreeList, from where the editor can select them as normal.

Tag indexing

The only thing missing now is that the search currently only operates on the name of the item in the media library. It's searching the Content field too, but this doesn't contain any detail about the selected tags - we'll need to add them when the item is added to the search index.

To do this we can create a computed field as follows:

    public class MediaContent : IComputedIndexField
    {
        public string FieldName { get; set; }

        public string ReturnType { get; set; }

        public object ComputeFieldValue(IIndexable indexable)
        {
            Assert.ArgumentNotNull(indexable, nameof(indexable));

            var indexableItem = indexable as SitecoreIndexableItem;
            if (indexableItem == null)
            {
                return null;
            }

            var imageTemplateIds = new[] { Settings.TemplateIDs.UnversionedJpeg, Settings.TemplateIDs.VersionedJpeg };
            if (!imageTemplateIds.Contains(indexableItem.Item.TemplateID))
            {
                return null;
            }

            var fields = new List<string>
                {
                    "Tags1",
                    "Tags2"
                };

            return ConcatenateIndexableContent(indexableItem.Item, fields);
        }

        private static string ConcatenateIndexableContent(Item item, IEnumerable<string> fields)
        {
            var sb = new StringBuilder();
            foreach (var field in fields)
            {
                var value = item[field];
                if (string.IsNullOrEmpty(value))
                {
                    continue;
                }

                sb.Append(GetItemIdListValueNames(item.Database, value));
                sb.Append(" ");
            }

            RemoveTrailingSpace(sb);
            return sb.ToString();
        }

        private static string GetItemIdListValueNames(Database database, string fieldValue)
        {
            var values = new ListString(fieldValue);
            var sb = new StringBuilder();

            foreach (var value in values)
            {
                var item = database.GetItem(value);
                if (item == null)
                {
                    continue;
                }

                sb.Append(item.Name);
                sb.Append(" ");
            }

            RemoveTrailingSpace(sb);
            return sb.ToString();
        }

        private static void RemoveTrailingSpace(StringBuilder sb)
        {
            if (sb.Length > 0)
            {
                sb.Length = sb.Length - 1;
            }
        }
    }

With the code above we locate the field(s) containing our tags, convert the stored pipe (|) delimited list of item Ids into the tag names and return a space delimited list of these names as the field value. By configuring this to be added to the _content field in the search index via a patch file like the following (example for Solr), we ensure the Content field is populated and can be searched on by tag values.

  <sitecore>
    <contentSearch>
      <indexConfigurations>
        <defaultSolrIndexConfiguration>
          <fields hint="raw:AddComputedIndexField">
            <field fieldName="_content" type="MyApp.ComputedFields.MediaContent, MyApp" />
          </fields>
        </defaultSolrIndexConfiguration>
      </indexConfigurations>
    </contentSearch>
  </sitecore>

No comments:

Post a Comment