Views: 3965
Number of votes: 4
Average rating:

Configurable Episerver Find Facets

The proposed solution aims to offer an easy way for content editors to dynamically change the Find facet filters and proposes full flexibly on the filters used to narrow down the search results and guide the user experience in finding the right products.

Introduction

Searching and finding the product you want to buy is a key feature for an E-commerce solution. Having the flexibility to narrow down search results based on different criteria improves the user experience and facilitates the finding of the product that the customer is looking for.

Episerver Find facets allow us to group search results based on specific terms, price ranges and other criteria that we can establish based on the project requirements.

During the design phase of the project we establish the product attributes and the facets we want to filter the search results with the client. We end-up with a list of facets displayed as filter criteria on the search results page or on the category landing page.

We often hard-code the facets configuration (what facets, the display order, the display type, the numeric ranges, etc.) based on the initial design we agreed with the client.

What if, after we go live, the client wants to add more facets, change the facets order, add new numeric ranges or even remove some existing facets? We will need to change the code, test and redeploy the hotfix into Production.

Would it be nice to allow CMS editors or administrators to configure the Find facets dynamically from Episerver CMS Start page?

Solution

Implement a solution that allows dynamic configuration of Find facets by CMS content editors.

This blog post will describe in detail the solution to dynamically configure the Find facets.

The code is part of Episerver Foundation and can be found here: https://github.com/episerver/Foundation

How it works?

Episerver Foundation defines by default in code the following facets: Price, Brand, Size and Color and registers them as facets to be displayed on the search results page and be used by the Find engine:

In Episerver CMS > Home page, under Search settings tab, we’ve added a new field called “Search Filters Configuration” that allows content editors to dynamically define and configure the facets displayed on the search results page:

Content editors can add, remove and move up/down the filters to reorder them, change the numeric ranges, display mode and direction, exclude specific values or display only specific values.

As a result, after we save the search filters configuration and publish the Home page, the new facets will be displayed on the search results page:

Implementation

Let’s go into the detail and explain the solution from a code implementation perspective.

The solution consists of 3 main steps:

  1. Define the facet configuration model and include it in the Home page as a new property list.
  2. Implement the facet configuration factory that will transform the configuration model into a facet definition and provide the list of facet filters.
  3. Configure the facet configuration initialization and registration of all the facet definitions.

1. Facet Configuration Model

We first need to define an interface IFacetConfiguration.cs that will contain the definition of the new search configuration field:

public interface IFacetConfiguration
{
    IList<FacetFilterConfigurationItem> SearchFiltersConfiguration { get; set; }
}

We implement this interface in the DemoHomePage.cs page and define the new field as follows:

public class DemoHomePage : CommerceHomePage, IFacetConfiguration
{
  …

  #region Search Settings
  …
  [Display(
          Name = "Search Filters Configuration",
          Description = "Manage filters to be displayed on Search",
          GroupName = CommerceTabNames.SearchSettings,
          Order = 300)]
        [EditorDescriptor(EditorDescriptorType = typeof(IgnoreCollectionEditorDescriptor<FacetFilterConfigurationItem>))]
        public virtual IList<FacetFilterConfigurationItem> SearchFiltersConfiguration { get; set; }
  #endregion
}

Since we use a list of facet configuration items, we have implemented a custom PropertyListBase.cs and defined our own configuration property:

[PropertyDefinitionTypePlugIn]
public class FacetFilterConfigurationProperty : PropertyListBase<FacetFilterConfigurationItem>
{
}

The new model FacetFilterConfigurationItem.cs contains all the properties needed to allow the facet definition and the customization of the search filters.

  • Attribute as Filter (FieldName)
    • The name of the field indexed by Find; Attribute to be used as a filter
  • Display Name (DisplayName)
    • Display name for filter group
  • Filter Type (FieldType)
    • Data type of the attribute
  • Display Mode (DisplayMode)
    • How the values of the filter are displayed
  • Display Direction (DisplayDirection)
    • The direction of the facet option values: vertical or horizontal
  • Numeric Ranges (From-To) (NumericRanges)
    • Set of ranges based on field type in format: from-to, from- and -to
  • Exclude Flag Attributes or Specific Values (ExcludeFlagFields)
    • Exclude specific attributes from Flags or specific values of an attribute
  • Display Specific Values (DisplaySpecificValues)
    • Display only specific values of the facet

    public class FacetFilterConfigurationItem
    {
        public FacetFilterConfigurationItem()
        {
            FieldType = FacetFieldType.String.ToString();
            DisplayMode = FacetDisplayMode.Checkbox.ToString();
            DisplayDirection = FacetDisplayDirection.Vertical.ToString();
        } 

        [Display(
           Name = "Attribute as Filter (required)",
           Description = "Attribute to be used as a filter",
           Order = 1)]
        [Required]
        public virtual string FieldName { get; set; }
 
        [Display(
           Name = "Display Name",
           Description = "Display name for filter in English")]
        public virtual string DisplayNameEN { get; set; } 

        [Display(
          Name = "Display Name (FR)",
          Description = "Display name for filter in French")]
        [Ignore]
        [ScaffoldColumn(false)]
        public virtual string DisplayNameFR { get; set; }
 
        [Display(
            Name = "Filter Type (required)",
            Description = "Data type of attribute")]
        [SelectOneEnum(typeof(FacetFieldType))]
        [DefaultValue(FacetFieldType.String)]
        [Required]
        public virtual string FieldType { get; set; }
  
        [Display(
            Name = "Display Mode (required)",
            Description = "How the values of the filter are displayed")]
        [SelectOneEnum(typeof(FacetDisplayMode))]
        [DefaultValue(FacetDisplayMode.Button)]
        [Required]
        public virtual string DisplayMode { get; set; }
        
        [Display(
              Name = "Display direction (optional)",
              Description = "Only applies to color swatch and size swatch.")]
        [SelectOneEnum(typeof(FacetDisplayDirection))]
        [DefaultValue(FacetDisplayDirection.Vertical)]
        public virtual string DisplayDirection { get; set; }

        [Display(
            Name = "Numeric Ranges (From-To)",
            Description = "Set ranges based on field type in format: from-to, from- and -to.")]
        [ItemRegularExpression("[0-9]*\\.?[0-9]*-[0-9]*\\.?[0-9]*")]
        public virtual IList<string> NumericRanges { get; set; }

        [Display(
            Name = "Exclude Flag Attributes or Specific Values",
            Description = "Used to exclude specific attributes from Flags or specific values of an attribute")]
        public virtual IList<string> ExcludeFlagFields { get; set; }
 
        [Display(
            Name = "Display Specific Values",
            Description = "Used to display specific values of an Attribute as Filter: e.g. Brand. Must be exact match to value of attribute.")]
        public virtual IList<string> DisplaySpecificValues { get; set; }
    }

To ease the definition of the facet configuration item, we’ve create the following enumerations within FacetEnums.cs:

  • FacetFieldType
  • FacetDisplayMode
  • FacetDisplayDirection
    public enum FacetFieldType
    {
        [EnumSelectionDescription(Text = "String", Value = "String")]
        String = 1,
        [EnumSelectionDescription(Text = "List of String", Value = "ListOfString")]
        ListOfString,
        [EnumSelectionDescription(Text = "Integer", Value = "Integer")]
        Integer,
        [EnumSelectionDescription(Text = "2 Decimal Places", Value = "Double")]
        Double,
        [EnumSelectionDescription(Text = "Boolean", Value = "Boolean")]
        Boolean,
        [EnumSelectionDescription(Text = "Enhanced Boolean", Value = "NullableBoolean")]
        NullableBoolean
    } 

    public enum FacetDisplayMode
    {
        [EnumSelectionDescription(Text = "Checkbox", Value = "Checkbox")]
        Checkbox = 1,
        [EnumSelectionDescription(Text = "Button", Value = "Button")]
        Button,
        [EnumSelectionDescription(Text = "Color Swatch", Value = "ColorSwatch")]
        ColorSwatch,
        [EnumSelectionDescription(Text = "Size Swatch", Value = "SizeSwatch")]
        SizeSwatch,
        [EnumSelectionDescription(Text = "Numeric Range", Value = "Range")]
        Range,
        [EnumSelectionDescription(Text = "Rating", Value = "Rating")]
        Rating,
        [EnumSelectionDescription(Text = "Slider", Value = "Slider")]
        Slider,
        [EnumSelectionDescription(Text = "Price Range", Value = "PriceRange")]
        PriceRange,
    } 

    public enum FacetDisplayDirection
    {
        [EnumSelectionDescription(Text = "Vertical", Value = "Vertical")]
        Vertical = 1,
        [EnumSelectionDescription(Text = "Horizontal", Value = "Horizontal")]
        Horizontal
    }

To allow the selection of one option from these listings we created SelectOneEnumAttribute.cs and apply it to the property as follows:

       [Display(
            Name = "Filter Type (required)",
            Description = "Data type of attribute")]
        [SelectOneEnum(typeof(FacetFieldType))]
        [DefaultValue(FacetFieldType.String)]
        [Required]
        public virtual string FieldType { get; set; }

The SelectOneEnumAttribute.cs attribute will allow editors to select a value of the enumeration:

    public class SelectOneEnumAttribute : SelectOneAttribute, IMetadataAware
    {
        public SelectOneEnumAttribute(Type enumType)
        {
            EnumType = enumType;
        }
 
        public Type EnumType { get; set; }

        public new void OnMetadataCreated(ModelMetadata metadata)
        {
            var enumType = metadata.ModelType; 

            SelectionFactoryType = typeof(EnumSelectionFactory<>).MakeGenericType(EnumType); 

            base.OnMetadataCreated(metadata);
        }
    }

Until now we defined the new search configuration property under the home page and added all the necessary code to allow content editors to define and configure the facets:

The next steps will be to define the facet configuration factory.

2. Facet Configuration Factory

The facet configuration factory will transform the configuration model into a facet definition and provide the list of facet configuration items.

The interface IFacetConfigFactory.cs defines 3 methods:

  • GetDefaultFacetDefinitions() – returns the default facet definitions: Price, Brand, Size and Color.
  • GetFacetFilterConfigurationItems() – returns the list of facet configuration models that are configured on the start page.
  • GetFacetDefinition() – converts a facet configuration model into a facet definition that is used by the Find search service implementation.
    public interface IFacetConfigFactory
    {
        List<FacetDefinition> GetDefaultFacetDefinitions();

        List<FacetFilterConfigurationItem> GetFacetFilterConfigurationItems();

        FacetDefinition GetFacetDefinition(FacetFilterConfigurationItem facetConfiguration);
    }

The FacetConfigFactory.cs is default implementation of this interface and implements the methods as follow:

    public class FacetConfigFactory : IFacetConfigFactory
    {
        private readonly IContentLoader _contentLoader;

        public FacetConfigFactory(IContentLoader contentLoader)
        {
            _contentLoader = contentLoader;
        }

        public virtual List<FacetDefinition> GetDefaultFacetDefinitions()
        {
            return new List<FacetDefinition>();
        }

        public virtual FacetDefinition GetFacetDefinition(FacetFilterConfigurationItem facetConfiguration)
        {
            switch (Enum.Parse(typeof(FacetFieldType), facetConfiguration.FieldType))
            {
                case FacetFieldType.String:
                    return new FacetStringDefinition
                    {
                        FieldName = facetConfiguration.FieldName,
                        DisplayName = facetConfiguration.GetDisplayName()
                    };

                case FacetFieldType.ListOfString:
                    return new FacetStringListDefinition
                    {
                        FieldName = facetConfiguration.FieldName,
                        DisplayName = facetConfiguration.GetDisplayName()
                    };

                case FacetFieldType.Boolean:
                case FacetFieldType.NullableBoolean:
                    return new FacetStringListDefinition
                    {
                        FieldName = facetConfiguration.FieldName,
                        DisplayName = facetConfiguration.GetDisplayName(),
                    };
            }

            return new FacetStringDefinition
            {
                FieldName = facetConfiguration.FieldName,
                DisplayName = facetConfiguration.GetDisplayName(),
            };
        }

        public List<FacetFilterConfigurationItem> GetFacetFilterConfigurationItems()
        {
            if (ContentReference.IsNullOrEmpty(ContentReference.StartPage))
            {
                return new List<FacetFilterConfigurationItem>();
            }

            var startPage = _contentLoader.Get<IContent>(ContentReference.StartPage);

            var facetsConfiguration = startPage as IFacetConfiguration;
            if (facetsConfiguration?.SearchFiltersConfiguration != null)
            {
                return facetsConfiguration
                    .SearchFiltersConfiguration
                    .ToList();
            }

            return new List<FacetFilterConfigurationItem>();
        }
    }

Based on the facet configuration model field type we create instances of FacetStringDefinition or FacetStringListDefinition.

The commerce facet configuration factory CommerceFacetConfigFactory.cs inherits from the default implementation and override the GetFacetDefinition() and GetDefaultFacetDefinitions() methods.

    public class CommerceFacetConfigFactory : FacetConfigFactory
    {
        private readonly ICurrentMarket _currentMarket;

        public CommerceFacetConfigFactory(ICurrentMarket currentMarket,
            IContentLoader contentLoader) : base(contentLoader)
        {
            _currentMarket = currentMarket;
        }

        public override FacetDefinition GetFacetDefinition(FacetFilterConfigurationItem facetConfiguration)
        {
            switch (Enum.Parse(typeof(FacetFieldType), facetConfiguration.FieldType))
            {
                case FacetFieldType.Integer:
                    return new FacetNumericRangeDefinition(_currentMarket)
                    {
                        FieldName = facetConfiguration.FieldName,
                        DisplayName = facetConfiguration.GetDisplayName(),
                        BackingType = typeof(int)
                    };

                case FacetFieldType.Double:
                    if (facetConfiguration.DisplayMode == FacetDisplayMode.Range.ToString()
                        || facetConfiguration.DisplayMode == FacetDisplayMode.PriceRange.ToString())
                    {
                        var rangeDefinition = new FacetNumericRangeDefinition(_currentMarket)
                        {
                            FieldName = facetConfiguration.FieldName,
                            DisplayName = facetConfiguration.GetDisplayName(),
                            BackingType = typeof(double)
                        };

                        rangeDefinition.Range = facetConfiguration.GetSelectableNumericRanges();

                        return rangeDefinition;
                    }
                    else if (facetConfiguration.DisplayMode == FacetDisplayMode.Rating.ToString())
                    {
                        var rangeDefinition = new FacetAverageRatingDefinition(_currentMarket)
                        {
                            FieldName = facetConfiguration.FieldName,
                            DisplayName = facetConfiguration.GetDisplayName(),
                            BackingType = typeof(double)
                        };

                        rangeDefinition.Range = facetConfiguration.GetSelectableNumericRanges();

                        return rangeDefinition;
                    }
                    break;
            }

            return base.GetFacetDefinition(facetConfiguration);
        }

        public override List<FacetDefinition> GetDefaultFacetDefinitions()
        {
            var brand = new FacetStringDefinition
            {
                FieldName = "Brand",
                DisplayName = "Brand"
            };

            var color = new FacetStringListDefinition
            {
                DisplayName = "Color",
                FieldName = "AvailableColors"
            };

            var size = new FacetStringListDefinition
            {
                DisplayName = "Size",
                FieldName = "AvailableSizes"
            };

            var priceRanges = new FacetNumericRangeDefinition(_currentMarket)
            {
                DisplayName = "Price",
                FieldName = "DefaultPrice",
                BackingType = typeof(double)

            };
            priceRanges.Range.Add(new SelectableNumericRange() { To = 50 });
            priceRanges.Range.Add(new SelectableNumericRange() { From = 50, To = 100 });
            priceRanges.Range.Add(new SelectableNumericRange() { From = 100, To = 500 });
            priceRanges.Range.Add(new SelectableNumericRange() { From = 500, To = 1000 });
            priceRanges.Range.Add(new SelectableNumericRange() { From = 1000 });

            return new List<FacetDefinition>() { priceRanges, brand, size, color };
        }
    }

Based on the facet configuration model field type and display mode we create instances of FacetNumericRangeDefinition.cs or FacetAverageRatingDefinition.cs.

We also define the 4 default facet definitions: Price, Brand, Size and Color.

With the facet configuration factory and facet configuration model ready, the only step remaining is to initialize the facet configuration during the initialization module.

3. Facet Configuration Initialization

First, we need to register the 2 new facet configuration factories in the initialization module Initialize.cs of Find.Cms and Find.Commerce:

namespace Foundation.Find.Cms
{
    [ModuleDependency(typeof(Foundation.Cms.Initialize))]
    public class Initialize : IConfigurableModule
    {
        void IConfigurableModule.ConfigureContainer(ServiceConfigurationContext context)
        {
            …
            services.AddSingleton<IFacetConfigFactory, FacetConfigFactory>();
namespace Foundation.Find.Commerce
{
    [ModuleDependency(typeof(Cms.Initialize), typeof(FindCommerceInitializationModule))]
    public class Initialize : IConfigurableModule
    {
        void IConfigurableModule.ConfigureContainer(ServiceConfigurationContext context)
        {
           …
           services.AddSingleton<IFacetConfigFactory, CommerceFacetConfigFactory>();

We have the code in place to dynamically configure the Find facets. We need now to write the code to glue everything together during the initialize module and every time the start page is published.

Episerver Foundation uses the IFacetRegistry.cs interface to register the Find facets that will be used by the Find service and that will be displayed on the search results page.

To facilitate the integration into the initialization engine we implemented an InitializationEngine extension as follows:

    public static class InitializationEngineExtensions
    {
        private static Lazy<IContentEvents> _contentEvents = new Lazy<IContentEvents>(() => ServiceLocator.Current.GetInstance<IContentEvents>());
        private static Lazy<IFacetRegistry> _facetRegistry = new Lazy<IFacetRegistry>(() => ServiceLocator.Current.GetInstance<IFacetRegistry>());
        private static Lazy<IFacetConfigFactory> _facetConfigFactory = new Lazy<IFacetConfigFactory>(() => ServiceLocator.Current.GetInstance<IFacetConfigFactory>());

        public static void InitializeFoundationFindCms(this InitializationEngine context)
        {
            InitializeFacets(_facetConfigFactory.Value.GetFacetFilterConfigurationItems());

            _contentEvents.Value.PublishedContent += OnPublishedContent;
        }

        static void OnPublishedContent(object sender, ContentEventArgs contentEventArgs)
        {
            if (contentEventArgs.Content is IFacetConfiguration facetConfiguration)
            {
                InitializeFacets(facetConfiguration.SearchFiltersConfiguration);
            }
        }

        private static void InitializeFacets(IList<FacetFilterConfigurationItem> configItems)
        {
            _facetRegistry.Value.Clear();

            if (configItems != null && configItems.Any())
            {
                configItems
                    .ToList()
                    .ForEach(x => _facetRegistry.Value.AddFacetDefinitions(_facetConfigFactory.Value.GetFacetDefinition(x)));
            }
            else
            {
                _facetConfigFactory.Value.GetDefaultFacetDefinitions()
                    .ForEach(x => _facetRegistry.Value.AddFacetDefinitions(x));
            }
        }
    }

Using the facet configuration factory, we register the facet definitions configured by the content editors in start page or we fallback to the default one.

When the start page is published we listen to the published content even and reset the facet registration with the new facet definitions and configurations.

The last thing to do is to call the initialization engine extension we’ve created inside the initialize method of the InitializeSite.cs

namespace Foundation.Infrastructure
{
    public class InitializeSite : IConfigurableModule
    {
        …

        public void Initialize(InitializationEngine context)
        {
         …
            context.InitializeFoundationCms();
            context.InitializeFoundationCommerce();
            context.InitializeFoundationFindCms();
            context.InitializeFoundationDemo();

Conclusion

Enabling the configuration of the search facets and allowing live changes of what the end-user will see and filter on, brings an added value to any e-commerce solution and allows enrichment of the search engine.

The enrichments and next steps of the solution include:

  • Display modes: color swatches, checkboxes, sliders, etc. Use the facet display mode in the view to call a partial view that displays the facet options in different ways;
  • Display direction: vertically or horizontally. For some facets we might want to display the facet options horizontally, one beside the other, like for Size;
  • Exclude flags: for Boolean facets we might want to group all of them under one facet group, like Status and have facet options like New, On Sale, Featured, Available on Store, etc.
  • Exclude values: Exclude specific values that we do not want to be displayed as facet options; and
  • Display specific values: Only display specific values in case we have too many facet options we might want to display on the top brands.

The facet configuration model properties can be transferred to the facet definition FacetDefinition.cs and implement the additional logic inside PopulateFacet() method.

The facet configuration can be then transferred to the FacetGroupOption.cs and used in the view to customize the display of a facet by rendering a partial view for each display mode.

I hope you enjoyed the reading and the proposed solution. Looking forward to your feedback! Thank you!

The full source code is available on Episerver Foundation GitHub repository: https://github.com/episerver/Foundation

Dec 15, 2019

Please login to comment.