Adding information from a product to the index for faceting purposes is easy to achieve if the information is already part of the product (sellableitem). In this example we add new fields regarding the related product variants directly to the product and index them using the standard Sitecore methods.

The main advantage of this approach is that you will not need to modify the standard crawlers or create calculated index fields. All the work is handled by the commerce engine making the index processing faster.

Below is a video describing the example and what the final solution looks like. It also shows how to generate the Sitecore templates and add a new facet. The code for the example is also below.

There a couple things to note…

What this example does :-

  1. Adds the pipelines and other scaffolding to enable the storage of the variant search information.
  2. Example code on how to populate the values that will be searched on. For example, Size, Color Style, Price range, percentage off etc.
  3. Shows how to add the new field to the search config on the Sitecore side.
  4. Shows how to add a new facet to the products in Sitecore so the standard SXA components can show the facets filter on the list page.
  5. Show how to index price information for a specific catalog. As selling prices can be different per catalog only the price for a particular catalog are indexed.

What this example does not do :-

  1. Index the variants themselves
  2. Show exactly where you would populate the search fields. This will depend on your individual business case i.e. use a minion or part of your catalog integration.
  3. Index prices for multiple catalogs for the same product. You will need a custom crawler for this.

Watch this before looking at the code…

Step 1 – Create new plugin

  • In the Commerce Engine SDK create a new plugin and reference it from the engine
  • Add the following code to create the pipelines

In folder “Pipelines” create…

namespace Sitecore.Services.Examples.Indexing
{
using Microsoft.Extensions.Logging;
using Sitecore.Commerce.Core;
using Sitecore.Framework.Pipelines;
public class GenerateFacetsPipeline : CommercePipeline<GenerateFacetsArgument, bool>, IGenerateFacetsPipeline
{
public GenerateFacetsPipeline(IPipelineConfiguration<IGenerateFacetsPipeline> configuration, ILoggerFactory loggerFactory)
: base(configuration, loggerFactory)
{
}
}
}

namespace Sitecore.Services.Examples.Indexing
{
using Sitecore.Commerce.Core;
using Sitecore.Framework.Pipelines;
[PipelineDisplayName("GenerateFacetsPipeline")]
public interface IGenerateFacetsPipeline : IPipeline<GenerateFacetsArgument, bool, CommercePipelineExecutionContext>
{
}
}

In folder “Pipelines\Arguments” create…

namespace Sitecore.Services.Examples.Indexing
{
using Sitecore.Commerce.Core;
using Sitecore.Framework.Conditions;
public class GenerateFacetsArgument : PipelineArgument
{
public GenerateFacetsArgument(string catalogId)
{
Condition.Requires(catalogId).IsNotNull("The parameter can not be null");
this.CatalogId = catalogId;
}
public string CatalogId { get; set; }
}
}

In folder named “Pipelines\Blocks” create…

namespace Sitecore.Services.Examples.Indexing
{
using System.Threading.Tasks;
using Sitecore.Commerce.Core;
using Sitecore.Framework.Pipelines;
using Sitecore.Commerce.Plugin.Catalog;
using System.Collections.Generic;
using System;
[PipelineDisplayName("FacetsBlock")]
public class FacetsBlock : PipelineBlock<GenerateFacetsArgument, GenerateFacetsArgument, CommercePipelineExecutionContext>
{
CommerceCommander _commander;
public FacetsBlock(CommerceCommander commander)
{
_commander = commander;
}
public override async Task<GenerateFacetsArgument> Run(GenerateFacetsArgument arg, CommercePipelineExecutionContext context)
{
var getItemsCommand = _commander.Command<GetCatalogItemsCommand>();
//Here we just loop through all the products and set the values, price are retrieved for a specific catalog
//Ideally we should be doing this in a more effiecient way e.g. using the paging functionality etc.
//You may want to do this in a minion or part of your catalog import
//You should also look at spliting up the variant property fields from pricing as pricing changes more often.
var items = await getItemsCommand.Process(context.CommerceContext,context.CommerceContext.Environment.Name, 0, int.MaxValue);
foreach (var catalogItem in items.Items)
{
SellableItem item;
if (catalogItem is SellableItem == false)
{
continue;
}
else
{
item = catalogItem as SellableItem;
}
var variationComponent = item.GetComponent<ItemVariationsComponent>();
var facetsComponent = item.GetComponent<FacetsComponent>();
facetsComponent.Colors = string.Empty;
facetsComponent.Sizes = string.Empty;
facetsComponent.Styles = string.Empty;
facetsComponent.SellPrices = string.Empty;
facetsComponent.ListPrices = string.Empty;
facetsComponent.PercentagesOff = string.Empty;
facetsComponent.PriceRange = string.Empty;
List<string> ids = new List<string>();
foreach (var variant in variationComponent.Variations)
{
var displayProperties = variant.GetComponent<DisplayPropertiesComponent>();
facetsComponent.Colors += " " + displayProperties.Color;
facetsComponent.Sizes += " " + displayProperties.Size;
facetsComponent.Styles += " " + displayProperties.Style;
}
//We only get prices for the specific catalog
ids.Add(arg.CatalogId + "|" + item.ProductId + "|");
var getBulkPricesCommand = _commander.Command<GetBulkPricesCommand>();
var priceResults = getBulkPricesCommand.Process(context.CommerceContext, ids).Result;
decimal highestPrice = 0.0M;
decimal lowestPrice = 0.0M;
foreach (var price in priceResults)
{
foreach (var variations in price.Variations)
{
if (Convert.ToDecimal(variations?.SellPrice?.Amount) > highestPrice)
{
highestPrice = Convert.ToDecimal(variations?.SellPrice?.Amount);
}
if (Convert.ToDecimal(variations?.SellPrice?.Amount) < lowestPrice || lowestPrice == 0)
{
lowestPrice = Convert.ToDecimal(variations?.SellPrice?.Amount);
}
facetsComponent.SellPrices += " " + variations?.SellPrice?.Amount;
facetsComponent.ListPrices += " " + variations?.ListPrice?.Amount;
//Percentages off
var difference = variations?.SellPrice?.Amount variations?.ListPrice?.Amount;
var percentage = Decimal.Round(Convert.ToDecimal((difference / variations?.SellPrice?.Amount) * 100), 0);
facetsComponent.PercentagesOff += " " + (percentage >= 0 ? string.Empty : (percentage * 1).ToString() + "%");
}
}
//Price range
if ((highestPrice >= 0 && highestPrice <= 1000) && (lowestPrice >= 0 && lowestPrice <= 1000))
{
facetsComponent.PriceRange = "0 to 1000";
}
else if ((highestPrice >= 1001 && highestPrice <= 2000) && (lowestPrice >= 1001 && lowestPrice <= 2000))
{
facetsComponent.PriceRange = "1001 to 2000";
}
else if ((highestPrice >= 2001 && highestPrice <= 3000) && (lowestPrice >= 2001 && lowestPrice <= 3000))
{
facetsComponent.PriceRange = "2001 to 3000";
}
else if (highestPrice >= 3001 && lowestPrice >= 3001)
{
facetsComponent.PriceRange = "more than 3000";
}
facetsComponent.Colors = facetsComponent.Colors.TrimStart();
facetsComponent.Sizes = facetsComponent.Sizes.TrimStart();
facetsComponent.Styles = facetsComponent.Styles.TrimStart();
facetsComponent.SellPrices = facetsComponent.SellPrices.TrimStart();
facetsComponent.ListPrices = facetsComponent.ListPrices.TrimStart();
facetsComponent.PercentagesOff = facetsComponent.PercentagesOff.TrimStart();
await _commander.Pipeline<PersistEntityPipeline>().Run(new PersistEntityArgument(item), context);
}
return await Task.FromResult(arg);
}
}
}

view raw
FacetsBlock.cs
hosted with ❤ by GitHub

namespace Sitecore.Services.Examples.Indexing
{
using System;
using System.Threading.Tasks;
using Sitecore.Commerce.Core;
using Sitecore.Commerce.EntityViews;
using Sitecore.Commerce.Plugin.Catalog;
using Sitecore.Framework.Conditions;
using Sitecore.Framework.Pipelines;
[PipelineDisplayName("GetFacetsViewBlock")]
public class GetFacetsViewBlock : PipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
{
private readonly ViewCommander _viewCommander;
public GetFacetsViewBlock(ViewCommander viewCommander)
{
this._viewCommander = viewCommander;
}
public override Task<EntityView> Run(EntityView arg, CommercePipelineExecutionContext context)
{
Condition.Requires(arg).IsNotNull($"{Name}: The argument cannot be null.");
var request = this._viewCommander.CurrentEntityViewArgument(context.CommerceContext);
var catalogViewsPolicy = context.GetPolicy<KnownCatalogViewsPolicy>();
var isConnectView = arg.Name.Equals(catalogViewsPolicy.ConnectSellableItem, StringComparison.OrdinalIgnoreCase);
// Make sure that we target the correct views
if (string.IsNullOrEmpty(request.ViewName) ||
!request.ViewName.Equals(catalogViewsPolicy.Master, StringComparison.OrdinalIgnoreCase) &&
!request.ViewName.Equals(catalogViewsPolicy.Details, StringComparison.OrdinalIgnoreCase) &&
!request.ViewName.Equals("Facets", StringComparison.OrdinalIgnoreCase) &&
!isConnectView)
{
return Task.FromResult(arg);
}
// Only proceed if the current entity is a sellable item
if (!(request.Entity is SellableItem))
{
return Task.FromResult(arg);
}
var sellableItem = (SellableItem)request.Entity;
var targetView = arg;
// Check if the edit action was requested
var isEditView = !string.IsNullOrEmpty(arg.Action) && arg.Action.Equals("EditFacets", StringComparison.OrdinalIgnoreCase);
if (!isEditView)
{
// Create a new view and add it to the current entity view.
var view = new EntityView
{
Name = "Facets",
DisplayName = "Facets",
EntityId = arg.EntityId,
ItemId = arg.ItemId
};
arg.ChildViews.Add(view);
targetView = view;
}
if (sellableItem != null && (sellableItem.HasComponent<FacetsComponent>() || isConnectView || isEditView))
{
FacetsComponent component = sellableItem.GetComponent<FacetsComponent>();
AddPropertiesToView(targetView, component, !isEditView);
}
return Task.FromResult(arg);
}
private void AddPropertiesToView(EntityView entityView, FacetsComponent component, bool isReadOnly)
{
entityView.Properties.Add(
new ViewProperty
{
Name = nameof(FacetsComponent.Colors),
RawValue = component.Colors,
IsReadOnly = isReadOnly,
IsRequired = false
});
entityView.Properties.Add(
new ViewProperty
{
Name = nameof(FacetsComponent.Styles),
RawValue = component.Styles,
IsReadOnly = isReadOnly,
IsRequired = false
});
entityView.Properties.Add(
new ViewProperty
{
Name = nameof(FacetsComponent.Sizes),
RawValue = component.Sizes,
IsReadOnly = isReadOnly,
IsRequired = false
});
entityView.Properties.Add(
new ViewProperty
{
Name = nameof(FacetsComponent.SellPrices),
RawValue = component.SellPrices,
IsReadOnly = isReadOnly,
IsRequired = false
});
entityView.Properties.Add(
new ViewProperty
{
Name = nameof(FacetsComponent.ListPrices),
RawValue = component.ListPrices,
IsReadOnly = isReadOnly,
IsRequired = false
});
entityView.Properties.Add(
new ViewProperty
{
Name = nameof(FacetsComponent.PercentagesOff),
RawValue = component.PercentagesOff,
IsReadOnly = isReadOnly,
IsRequired = false
});
entityView.Properties.Add(
new ViewProperty
{
Name = nameof(FacetsComponent.PriceRange),
RawValue = component.PriceRange,
IsReadOnly = isReadOnly,
IsRequired = false
});
}
}
}

  • Add the following code to be able to execute the code externally via the API

In folder “Commands”…

namespace Sitecore.Services.Examples.Indexing
{
using System;
using System.Threading.Tasks;
using Sitecore.Commerce.Core;
using Sitecore.Commerce.Core.Commands;
using Sitecore.Commerce.Plugin.Sample;
public class GenerateFacetsCommand : CommerceCommand
{
private readonly IGenerateFacetsPipeline _pipeline;
public GenerateFacetsCommand(IGenerateFacetsPipeline pipeline, IServiceProvider serviceProvider) : base(serviceProvider)
{
this._pipeline = pipeline;
}
public async Task<bool> Process(CommerceContext commerceContext, string catalogId)
{
using (var activity = CommandActivity.Start(commerceContext, this))
{
var arg = new GenerateFacetsArgument(catalogId);
var result = await this._pipeline.Run(arg, new CommercePipelineExecutionContextOptions(commerceContext));
return result;
}
}
}
}

In folder “Controllers”…

namespace Sitecore.Services.Examples.Indexing
{
using System;
using System.Threading.Tasks;
using System.Web.Http.OData;
using Microsoft.AspNetCore.Mvc;
using Sitecore.Commerce.Core;
public class CommandsController : CommerceController
{
public CommandsController(IServiceProvider serviceProvider, CommerceEnvironment globalEnvironment)
: base(serviceProvider, globalEnvironment)
{
}
[HttpPut]
[Route("GenerateFacetsCommand()")]
public async Task<IActionResult> GenerateFacetsCommand([FromBody] ODataActionParameters value)
{
string catalogId = value["catalogid"].ToString();
var command = this.Command<GenerateFacetsCommand>();
var result = await command.Process(this.CurrentContext, catalogId);
return new ObjectResult(command);
}
}
}

In folder “Components”…

namespace Sitecore.Services.Examples.Indexing
{
using Sitecore.Commerce.Core;
public class FacetsComponent : Component
{
public FacetsComponent()
{
Colors = string.Empty;
Sizes = string.Empty;
Styles = string.Empty;
SellPrices = string.Empty;
ListPrices = string.Empty;
PercentagesOff = string.Empty;
PriceRange = string.Empty;
}
public string Colors { get; set; }
public string Sizes { get; set; }
public string Styles { get; set; }
public string SellPrices { get; set; }
public string ListPrices { get; set; }
public string PercentagesOff { get; set; }
public string PriceRange { get; set; }
}
}

view raw
FacetsComponent.cs
hosted with ❤ by GitHub

In the root of your project add…

namespace Sitecore.Services.Examples.Indexing
{
using System.Threading.Tasks;
using Microsoft.AspNetCore.OData.Builder;
using Sitecore.Commerce.Core;
using Sitecore.Commerce.Core.Commands;
using Sitecore.Framework.Conditions;
using Sitecore.Framework.Pipelines;
[PipelineDisplayName("ServiceExamplesConfigureServiceApiBlock")]
public class ConfigureServiceApiBlock : PipelineBlock<ODataConventionModelBuilder, ODataConventionModelBuilder, CommercePipelineExecutionContext>
{
public override Task<ODataConventionModelBuilder> Run(ODataConventionModelBuilder modelBuilder, CommercePipelineExecutionContext context)
{
Condition.Requires(modelBuilder).IsNotNull($"{this.Name}: The argument cannot be null.");
var configuration = modelBuilder.Action("GenerateFacetsCommand");
configuration.Parameter<string>("CatalogId");
configuration.ReturnsFromEntitySet<CommerceCommand>("Commands");
return Task.FromResult(modelBuilder);
}
}
}

namespace Sitecore.Commerce.Plugin.Sample
{
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Sitecore.Commerce.Core;
using Sitecore.Framework.Configuration;
using Sitecore.Framework.Pipelines.Definitions.Extensions;
using Sitecore.Services.Examples.Indexing;
using Sitecore.Commerce.EntityViews;
using Sitecore.Commerce.Plugin.Catalog;
public class ConfigureSitecore : IConfigureSitecore
{
public void ConfigureServices(IServiceCollection services)
{
var assembly = Assembly.GetExecutingAssembly();
services.RegisterAllPipelineBlocks(assembly);
services.Sitecore().Pipelines(config => config
.AddPipeline<IGenerateFacetsPipeline, GenerateFacetsPipeline>(
configure => {configure.Add<FacetsBlock>();})
.ConfigurePipeline<IConfigureServiceApiPipeline>(configure => configure.Add<Services.Examples.Indexing.ConfigureServiceApiBlock>()));
services.Sitecore().Pipelines(config => config
.ConfigurePipeline<IGetEntityViewPipeline>(c => c.Add<GetFacetsViewBlock>().After<GetSellableItemDetailsViewBlock>()));
services.RegisterAllCommands(assembly);
}
}
}

view raw
ConfigureSitecore.cs
hosted with ❤ by GitHub

Your solutions should look like this…

productvariantsindex

Step 2 – Run

    1. Go to Postman and run https://{{ServiceHost}}/{{ShopsApi}}/GenerateFacetsCommand
    2. Watch the video for further instructions