January 10, 2018

Making Episerver Categories More...Useful.

Episerver's categories work fine for what they do, but they're crammed into the legacy Admin area and, compared to the rest of the platform, feel a bit dated to me.

Coming from the Ektron world, the comparative Taxonomy functionality was very front-and-center. While Ektron's eventually came to be over-used (and abused) in any number of implementations, categorization of content using pre-defined lists and structures is still important to many organizations.

If you need the hierarchy, then Episerver's Category property works well for that. But sometimes I don't really need hierarchy so much as I need to manage somewhat flat lists of categories - not all of which are really applicable to all content.

In addition, I may sometimes want to allow multiple selections from that list while at other times it needs to be an exclusive selection. Enter ISelectionFactory.

What follows is a bit of a thought experiment that shows how to use an external data source to feed a selection factory while experimenting with using Episerver Categories as the source.

It's easy to find an Episerver SelectionFactory for Enum values. While that serves my purpose above to a degree, it's not always the ideal solution for one reason: things change.

We can't really say for sure today that our desired options in the list are never going to change. Sometimes we can be reasonably certain and then it becomes acceptable to use an Enum to control selection options. I prefer things to be more dynamic and, more importantly, manageable by the platform administrators, i.e., non-developers.

Categories provides the perfect solution to this. The interface may not be beautiful enough for all content managers, but for administering multiple lists of options it works great. So all we really need to do is de-clutter the experience for the authors for cases where we don't need:
  1. All options available all the time
  2. Hierarchical relationships between selections
If you don't need those, then a SelectionFactory really is perfect. With it, we can set up standard content properties that provide checkbox or drop-down selection options populated by category nodes.

To manage this solution, we really need two things:
  1. An attribute that will allow us to specify which category is feeding our options.
  2. A SelectionFactory that will read that attribute, load up child nodes, and return them as SelectItem options.
The attribute is rather simple and can accept two options to query the Categories for child nodes:
  • Category ID
  • Category Name
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class BaseCategoryAttribute : Attribute
{
    public int CategoryId { get; set; } = -1;
    public string CategoryName { get; set; }

    public BaseCategoryAttribute(int categoryId)
    {
        CategoryId = categoryId;
    }

    public BaseCategoryAttribute(string categoryName)
    {
        CategoryName = categoryName;
    }

    public BaseCategoryAttribute() { }
}

Attribute data is passed to the SelectionFactory metadata parameter an so we can poll it for the values we need. Just make sure that you have good exits if the attribute isn't there or the values aren't set.

public class CategorySelectionFactory : ISelectionFactory
{
    private CategoryRepository categoryRepository;

    public CategorySelectionFactory(CategoryRepository repository = null)
    {
        categoryRepository = repository ?? ServiceLocator.Current.GetInstance<CategoryRepository>();
    }

    public CategorySelectionFactory()
    {
        categoryRepository = ServiceLocator.Current.GetInstance<CategoryRepository>();
    }

    public IEnumerable<ISelectItem> GetSelections(ExtendedMetadata metadata)
    {
        var categoryAttribute = metadata.Attributes.FirstOrDefault(m => m is BaseCategoryAttribute) as BaseCategoryAttribute;

        if(categoryAttribute == null
            || (categoryAttribute.CategoryId < 0 && string.IsNullOrEmpty(categoryAttribute.CategoryName)))
        {
            return null;
        }

        Category category;
        if(categoryAttribute.CategoryId >= 0)
        {
            category = categoryRepository.Get(categoryAttribute.CategoryId);
        }
        else
        {
            category = categoryRepository.Get(categoryAttribute.CategoryName);
        }

        return category.Categories.Select(c => new SelectItem() { Text = c.Name, Value = c.ID });
    }
}

And, because I always want to know the correct namespaces for the code I find in other blogs, here are the ones I'm using in the above class:

using EPiServer.DataAbstraction;
using EPiServer.ServiceLocation;
using EPiServer.Shell.ObjectEditing;
using EpiserverPoc.Business.Attributes;
using System.Collections.Generic;
using System.Linq;

Then apply both the BaseCategory attribute and the SelectionFactory to your content properties:

[Display(Name = "Category 1 Label", Order = 1)]
[BaseCategory(CategoryName = "Category 1 Name")]
[SelectMany(SelectionFactoryType = typeof(CategorySelectionFactory))]
public virtual string Category1Selections { get; set; }

[Display(Order = 2, Name = "Category 2 Label")]
[BaseCategory(CategoryName = "Category 2 Name")]
[SelectOne(SelectionFactoryType = typeof(CategorySelectionFactory))]
public virtual string Category2Selections { get; set; }

(Note the SelectOne and SelectMany options above.)

What we have now are far more Episerver-ish (attractive) selection options for our content properties. Because we've moved the options out of the Category selection UI and into properties where it's easier to exercise control, you also could apply your own edit hints, styles, and interaction to these fields.



And, finally, because we're using Categories to manage the list, admins can feel free to add options to the list.

Potential Issues:
  • Selections are stored as a property of the content and removing a node from the Categories will not "de-select" that option for the content. If you're storing the Category ID and using it in your code later, then you need to account for the possibility that the node may no longer exist.
  • The parent Category ID (or Name) is hard-coded into the content mode, which I don't like. It introduces risk that the parent node might be deleted (or renamed) causing all manner of issues.
What other risks do you see? How might you do this differently? How else would you extend it? Feel free to use the comments below!

Happy coding.

No comments: