Sitecore Experience Commerce: Extending CatalogItemBase Entities with Components

In this article, we will review how to extend the schema of catalog, category, and sellable item (including its variants), entities by programatically creating and assigning components to them, render the components to their respective entity page in the BizFx, and allow the CommerceConnect dataprovider to consume the component data when syncing the catalog items to Sitecore.

The usage of the term 'catalog items' throughout this article is a collective term referring to catalog, category and sellable item commerce entities as well as variants, a.k.a. item variation components.

Implementation Details

Components can be used as a programmatic alternative to composer templates to extend catalog items. Components can be more powerful than composer templates as developers have more control over the implementation of custom components, although the trade off requires additional development effort. Some additional highlights of differences between composer templates and components are listed below.

  • Composer templates cannot be utilised to extend variants, only the sellable items themselves, therefore components are required to extend variants.
  • Components do not require developers to create a migration plan to move composer templates between deployment environments.
  • Composer templates are restricted to a specific set of data types for custom properties, while components can utilise a larger set of data types and UI Types for improved business user experience.
  • Components require get view blocks, populate view action blocks, and do action blocks to allow business users to view and edit component properties within the Commerce Tools, whereas the composer templates requires configuration, which can be completed controlled by a business user.

Creating New Components

We first create our components that will extend our catalog items and add our custom properties.

For extending our catalog items, only samples for sellable items and variants will be provided, however to extend categories and catalogs, this would be achieved by following the sellable item samples and substituting sellable item references with that of category or catalog references.

public class SellableItemExtensionComponent : Component
{
    public string CountryOfOrigin { get; set; } = "Australia";
    public int EnergyRating { get; set; } = 10;
}
public class VariationExtensionComponent : Component
{
    public string Material { get; set; } = "Steel";
    public bool IsClearance{ get; set; } = true;
}

Default values for properties are usually omitted in implementations, however values have been assigned here to demonstrate reading these values for the get view blocks in the next step.

Rendering Components in BizFx Using Get View Blocks

To render the properties in the Commerce Tools, we add entity views to the appropriate Merchandising pages using get view blocks and registering them to IGetEntityViewPipeline.

Important: Ensure component property names match view property names.
When translating components to entity views, it is not only good practice to assign the name of the view property with the corresponding name of component property, but it is crucial for Commerce Connect data provider to populate the values of the Sitecore catalog items. The data provider effectively maps properties by matching names if the Sitecore catalog item field names to the commerce entity properties from its representation within a JToken.

public class GetSellableItemExtensionViewBlock : SyncPipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
{
    public override EntityView Run(EntityView entityView, CommercePipelineExecutionContext context)
    {
        // Ensure parameters are provided
        Condition.Requires(entityView, nameof(entityView)).IsNotNull();
        Condition.Requires(context, nameof(context)).IsNotNull();

        var viewsPolicy = context.GetPolicy<KnownCatalogViewsPolicy>();
        var request = context.CommerceContext.GetObject<EntityViewArgument>();

        // Validate that the page entity is a sellable item
        // Validate the view name matches the requested page
        if (!(request?.Entity is SellableItem) ||
            !request.ViewName.EqualsOrdinalIgnoreCase(viewsPolicy.Master))
        {
            return entityView;
        }

        var sellableItem = request.Entity as SellableItem;
        var extensionComponent = sellableItem.GetComponent<SellableItemExtensionComponent>();

        // Create an entity view to hold the component properties
        var propertiesEntityView = new EntityView
        {
            DisplayName = "Extended Information",
            Name = "Extended Information",
            EntityId = entityView.EntityId,
            EntityVersion = entityView.EntityVersion,
            ItemId = entityView.ItemId
        };

        // Add the entity view as a child of the page entity view
        entityView.ChildViews.Add(propertiesEntityView);

        // Add the component properties to the entity view
        propertiesEntityView.Properties = new List<ViewProperty>
        {
            CreateViewProperty(nameof(extensionComponent.CountryOfOrigin), extensionComponent.CountryOfOrigin),
            CreateViewProperty(nameof(extensionComponent.EnergyRating), extensionComponent.EnergyRating)
        };

        return entityView;
    }

    /// Helper method to simplify ViewProperty creation
    private ViewProperty CreateViewProperty(string name, object value, string uiType = "")
    {
        return new ViewProperty
        {
            Name = name,
            RawValue = value,
            UiType = uiType
        };
    }
}
public class GetVariationExtensionViewBlock : SyncPipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
{
    public override EntityView Run(EntityView entityView, CommercePipelineExecutionContext context)
    {
        // Ensure parameters are provided
        Condition.Requires(entityView, nameof(entityView)).IsNotNull();
        Condition.Requires(context, nameof(context)).IsNotNull();

        var viewsPolicy = context.GetPolicy<KnownCatalogViewsPolicy>();
        var request = context.CommerceContext.GetObject<EntityViewArgument>();

        // Validate that the page entity is a sellable item
        // Validate the view name matches the requested page
        if (!(request?.Entity is SellableItem) ||
            !request.ViewName.EqualsOrdinalIgnoreCase(viewsPolicy.Variant))
        {
            return entityView;
        }

        var sellableItem = request.Entity as SellableItem;
        // Get the component from the sellable items variant (ItemId) and do not inherit from the sellable item if not present on the variant
        var extensionComponent = sellableItem.GetComponent<VariationExtensionComponent>(entityView.ItemId, false);

        // Create an entity view to hold the component properties
        var propertiesEntityView = new EntityView
        {
            DisplayName = "Extended Information",
            Name = "Extended Information",
            EntityId = entityView.EntityId,
            EntityVersion = entityView.EntityVersion,
            ItemId = entityView.ItemId
        };

        entityView.ChildViews.Add(propertiesEntityView);

        // Add the entity view as a child of the page entity view
        propertiesEntityView.Properties = new List<ViewProperty>
        {
            CreateViewProperty(nameof(extensionComponent.Material), extensionComponent.Material),
            CreateViewProperty(nameof(extensionComponent.IsClearance), extensionComponent.IsClearance)
        };

        return entityView;
    }

    /// Helper method to simplify ViewProperty creation
    private ViewProperty CreateViewProperty(string name, object value, string uiType = "")
    {
        return new ViewProperty
        {
            Name = name,
            RawValue = value,
            UiType = uiType
        };
    }
}

The get view blocks are registered to IGetEntityViewPipeline in the pipeline configuration.

public class ConfigureSitecore : IConfigureSitecore
{
    public void ConfigureServices(IServiceCollection services)
    {
        var assembly = Assembly.GetExecutingAssembly();

        services.RegisterAllPipelineBlocks(assembly);
        services.RegisterAllCommands(assembly);
        services.Sitecore().Rules(config => config.Registry(registry => registry.RegisterAssembly(assembly)));
        services.Sitecore().Pipelines(pipelines => pipelines

            .ConfigurePipeline<IGetEntityViewPipeline>(pipeline => pipeline
                .Add<Pipelines.Blocks.GetSellableItemExtensionViewBlock>().After<PopulateEntityVersionBlock>()
                .Add<Pipelines.Blocks.GetVariationExtensionViewBlock>().After<PopulateEntityVersionBlock>()
            )

        );
    }
}

Upon deployment, we can see the new entity views appear on the Sellable Item and Variant pages with our default values.

SellableItemExtensionComponent rendered as Extended Information on the Sellable Item Details Page.
VariationExtensionComponent rendered as Extended Information on the Variant Details Page.

View property display names can be managed via Commerce terms under the Commerce Control Panel in the Sitecore Content Editor, allowing language specific user-friendly names to be configured for the property names rendered in the entity views.

Registering Components for Sitecore Catalog Item Templates

With components translated to entity views and rendered on our Merchandising pages, we need to register our component properties so that the Commerce Connect data provider can updating the commerce data templates in Sitecore.

In the screenshot below, registered components can be seen under /sitecore/templates/Commerce/Catalog Generated/Components/ConnectSellableItem, which are registered by the data provider to the ConnectSellableItem template.

Both sellable items and variants share the same template. This means that properties registered to a sellable item will appear on the variant and vice versa.

To register our components, we simply need to include an additional view name in the validation section of our get view blocks. This view name would be either ConnectCatalog, ConnectCategory, or ConnectSellableItem, depending of the entity context.

if (!(request?.Entity is SellableItem) ||
    (!request.ViewName.EqualsOrdinalIgnoreCase(viewsPolicy.Master) &&
    !request.ViewName.EqualsOrdinalIgnoreCase(viewsPolicy.ConnectSellableItem)))
{
    return entityView;
}
if (!(request?.Entity is SellableItem) ||
    (!request.ViewName.EqualsOrdinalIgnoreCase(viewsPolicy.Variant) &&
    !request.ViewName.EqualsOrdinalIgnoreCase(viewsPolicy.ConnectSellableItem)))
{
    return entityView;
}

We should now see our components registered as templates in Sitecore.

Once the changes have been deployed, if the changes are not reflected in Sitecore, run the Update Data Templates command from the Commerce ribbon menu.

As we have created the entity view with the name 'Extended Information' for both sellable items and variants, properties are merged into a single template.

Populating Component Property Values on Sitecore Items

Our templates have been generated in Sitecore, however if we look at a sellable item or variant Sitecore item, we will find that the values have not been populated. This is because the data provider uses different Commerce Engine APIs to generate the templates and populate the Sitecore catalog item property values.

In our get view block examples, GetComponent<SellableItemExtensionComponent>() is creating a new component, but is not persisted to the sellable item and its variants. The data provider populates Sitecore items from each instance of the commerce entity, rather than our entity view representation of the commerce entity, which is why no values are populated at this time.

Important: Ensure component properties are unique across the commerce entity.
The data provider essentially flattens the commerce entity when populating the Sitecore catalog items. This means that if a property with the same name exists multiple times across the commerce entity and its nested component properties, the data provider will use the first instance to populate the Sitecore catalog items.

Sitecore Content Editor: Extended Information values have not been populated

In order for us to populate the values, we need to take a step back to populate and persist property values to the catalog items in the Commerce Engine.

First, we will register populate view actions blocks, containing an edit action, to the Extended Information entity views in the Merchandising pages.

public class PopulateSellableItemExtensionViewActionsBlock : SyncPipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
{
    public override EntityView Run(EntityView entityView, CommercePipelineExecutionContext context)
    {
        // Ensure parameters are provided
        Condition.Requires(entityView, nameof(entityView)).IsNotNull();

        var viewsPolicy = context.GetPolicy<KnownCatalogViewsPolicy>();
        var request = context.CommerceContext.GetObject<EntityViewArgument>();

        // Validate the context of the request, i.e. entity, view name, and action name
        if (!(request?.Entity is SellableItem) ||
            !request.ViewName.EqualsOrdinalIgnoreCase(viewsPolicy.Master) ||
            !entityView.Name.EqualsOrdinalIgnoreCase("Extended Information") ||
            !string.IsNullOrEmpty(request.ForAction))
        {
            return entityView;
        }

        // Add action to entity view
        var actionPolicy = entityView.GetPolicy<ActionsPolicy>();
        actionPolicy.Actions.Add(
            new EntityActionView
            {
                Name = "EditExtensionProperties",
                DisplayName = "Edit Extension Properties",
                Description = "Edit Extension Properties",
                IsEnabled = true,
                EntityView = "ExtensionProperties",
                Icon = "edit"
            });
                

        return entityView;
    }
}
public class PopulateVariationExtensionViewActionsBlock : SyncPipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
{
    /// <summary>Executes the pipeline block's code logic.</summary>
    /// <param name="entityView">The entity view.</param>
    /// <param name="context">The context.</param>
    /// <returns>The <see cref="EntityView"/>.</returns>
    public override EntityView Run(EntityView entityView, CommercePipelineExecutionContext context)
    {
        // Ensure parameters are provided
        Condition.Requires(entityView, nameof(entityView)).IsNotNull();

        var viewsPolicy = context.GetPolicy<KnownCatalogViewsPolicy>();
        var request = context.CommerceContext.GetObject<EntityViewArgument>();

        // Validate the context of the request, i.e. entity, view name, and action name
        if (!(request?.Entity is SellableItem) ||
            !request.ViewName.EqualsOrdinalIgnoreCase(viewsPolicy.Variant) ||
            !entityView.Name.EqualsOrdinalIgnoreCase("Extended Information") ||
            !string.IsNullOrEmpty(request.ForAction))
        {
            return entityView;
        }

        // Add action to entity view
        var actionPolicy = entityView.GetPolicy<ActionsPolicy>();
        actionPolicy.Actions.Add(
            new EntityActionView
            {
                Name = "EditExtensionProperties",
                DisplayName = "Edit Extension Properties",
                Description = "Edit Extension Properties",
                IsEnabled = true,
                EntityView = "ExtensionProperties",
                Icon = "edit"
            });
                

        return entityView;
    }
}
services.Sitecore().Pipelines(pipelines => pipelines

    .ConfigurePipeline<IPopulateEntityViewActionsPipeline>(pipeline => pipeline
        .Add<Pipelines.Blocks.PopulateSellableItemExtensionViewActionsBlock>().After<InitializeEntityViewActionsBlock>()
        .Add<Pipelines.Blocks.PopulateVariationExtensionViewActionsBlock>().After<InitializeEntityViewActionsBlock>()
    )

);

The get view blocks created earlier are then amended and refactored to include support for the create/edit action, which will render the entity view in the modal.

public override EntityView Run(EntityView entityView, CommercePipelineExecutionContext context)
{
    // Ensure parameters are provided
    Condition.Requires(entityView, nameof(entityView)).IsNotNull();
    Condition.Requires(context, nameof(context)).IsNotNull();

    var viewsPolicy = context.GetPolicy<KnownCatalogViewsPolicy>();
    var request = context.CommerceContext.GetObject<EntityViewArgument>();

    // Determine the context of rendering the entity view, e.g. create/edit or view (page or via commerce context)
    var isViewAction = (request.ViewName.EqualsOrdinalIgnoreCase(viewsPolicy.Master) ||
        request.ViewName.EqualsOrdinalIgnoreCase(viewsPolicy.ConnectSellableItem)) &&
        string.IsNullOrWhiteSpace(request.ForAction);
    var isEditAction = request.ViewName.EqualsOrdinalIgnoreCase("ExtensionProperties") && request.ForAction.EqualsOrdinalIgnoreCase("EditExtensionProperties");

    // Validate the context of the request, i.e. entity, view name, and action name
    if (!(request?.Entity is SellableItem) ||
        (!isViewAction &&
        !isEditAction))
    {
        return entityView;
    }

    var sellableItem = request.Entity as SellableItem;
    var extensionComponent = sellableItem.GetComponent<SellableItemExtensionComponent>();

    var propertiesEntityView = entityView;
    if (isViewAction)
    {
        // Create an entity view to host the component properties
        propertiesEntityView = new EntityView
        {
            DisplayName = "Extended Information",
            Name = "Extended Information",
            EntityId = entityView.EntityId,
            EntityVersion = entityView.EntityVersion,
            ItemId = entityView.ItemId
        };

        // Add the entity view as a child of the current entity view
        entityView.ChildViews.Add(propertiesEntityView);
    }
            
    // Add the component properties to the entity view
    propertiesEntityView.Properties = new List<ViewProperty>
    {
        CreateViewProperty(nameof(extensionComponent.CountryOfOrigin), extensionComponent.CountryOfOrigin),
        CreateViewProperty(nameof(extensionComponent.EnergyRating), extensionComponent.EnergyRating)
    };

    return entityView;
}
public override EntityView Run(EntityView entityView, CommercePipelineExecutionContext context)
{
    // Ensure parameters are provided
    Condition.Requires(entityView, nameof(entityView)).IsNotNull();
    Condition.Requires(context, nameof(context)).IsNotNull();

    var viewsPolicy = context.GetPolicy<KnownCatalogViewsPolicy>();
    var request = context.CommerceContext.GetObject<EntityViewArgument>();

    // Determine the context of rendering the entity view, e.g. create/edit or view (page or via commerce context)
    var isViewAction = (request.ViewName.EqualsOrdinalIgnoreCase(viewsPolicy.Variant) ||
        request.ViewName.EqualsOrdinalIgnoreCase(viewsPolicy.ConnectSellableItem)) &&
        string.IsNullOrWhiteSpace(request.ForAction);
    var isEditAction = request.ViewName.EqualsOrdinalIgnoreCase("ExtensionProperties") && request.ForAction.EqualsOrdinalIgnoreCase("EditExtensionProperties");

    // Validate the context of the request, i.e. entity, view name, and action name
    if (!(request?.Entity is SellableItem) ||
        (!isViewAction &&
        !isEditAction))
    {
        return entityView;
    }

    var sellableItem = request.Entity as SellableItem;
    // Get the component from the sellable items variant (ItemId) and do not inherit from the sellable item if not present on the variant
    var extensionComponent = sellableItem.GetComponent<VariationExtensionComponent>(entityView.ItemId, false);

    var propertiesEntityView = entityView;
    if (isViewAction)
    {
        // Create an entity view to hold the component properties
        propertiesEntityView = new EntityView
        {
            DisplayName = "Extended Information",
            Name = "Extended Information",
            EntityId = entityView.EntityId,
            EntityVersion = entityView.EntityVersion,
            ItemId = entityView.ItemId
        };

        // Add the entity view as a child of the current entity view
        entityView.ChildViews.Add(propertiesEntityView);
    }

    // Add the component properties to the entity view
    propertiesEntityView.Properties = new List<ViewProperty>
    {
        CreateViewProperty(nameof(extensionComponent.Material), extensionComponent.Material),
        CreateViewProperty(nameof(extensionComponent.ReleaseDate), extensionComponent.ReleaseDate, "FullDateTime")
    };

    return entityView;
}

The default values from the components are also removed to provide a true representation of the catalog item property values that are persisted to the database.

public class SellableItemExtensionComponent : Component
{
    public string CountryOfOrigin { get; set; }
    public int EnergyRating { get; set; }
}
public class VariationExtensionComponent : Component
{
    public string Material { get; set; }
    public bool isClearance { get; set; }
}

To persist the changes made to the Edit Extension Properties entity view, do action blocks are responsibile for processing the entity view submission action, which in this case is translating the entity view properties back to the component on the commerce entity and then persisting it.

public class DoActionEditSellableItemExtensionViewBlock : AsyncPipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
{
    protected CommerceCommander Commander { get; set; }

    public DoActionEditSellableItemExtensionViewBlock(CommerceCommander commander)
    {
        this.Commander = commander;
    }

    public override async Task<EntityView> RunAsync(EntityView entityView, CommercePipelineExecutionContext context)
    {
        // Ensure parameters are provided
        Condition.Requires(entityView, nameof(entityView)).IsNotNull();

        var viewsPolicy = context.GetPolicy<KnownCatalogViewsPolicy>();

        // Validate the context of the request, i.e. entity, view name, and action name
        var entity = context.CommerceContext.GetObject<CommerceEntity>(p => p.Id.EqualsOrdinalIgnoreCase(entityView.EntityId));
        if (!(entity is SellableItem) ||
            !entityView.Name.EqualsOrdinalIgnoreCase("ExtensionProperties") ||
            !entityView.Action.EqualsOrdinalIgnoreCase("EditExtensionProperties") ||
            !string.IsNullOrWhiteSpace(entityView.ItemId))
        {
            return entityView;
        }

        var sellableItem = entity as SellableItem;
        var extensionComponent = sellableItem.GetComponent<SellableItemExtensionComponent>();

        // Assign component property values from the entity view property values
        extensionComponent.CountryOfOrigin = entityView.GetPropertyValueByName(nameof(extensionComponent.CountryOfOrigin));
        extensionComponent.EnergyRating = int.Parse(entityView.GetPropertyValueByName(nameof(extensionComponent.EnergyRating)));

        // Persist the changes to the sellable item
        await Commander.PersistEntity(context.CommerceContext, sellableItem).ConfigureAwait(false);

        return entityView;
    }
}
public class DoActionEditVariationExtensionViewBlock : AsyncPipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
{
    protected CommerceCommander Commander { get; set; }

    public DoActionEditVariationExtensionViewBlock(CommerceCommander commander)
    {
        this.Commander = commander;
    }

    public override async Task<EntityView> RunAsync(EntityView entityView, CommercePipelineExecutionContext context)
    {
        // Ensure parameters are provided
        Condition.Requires(entityView, nameof(entityView)).IsNotNull();

        var viewsPolicy = context.GetPolicy<KnownCatalogViewsPolicy>();

        // Validate the context of the request, i.e. entity, view name, and action name
        var entity = context.CommerceContext.GetObject<CommerceEntity>(p => p.Id.EqualsOrdinalIgnoreCase(entityView.EntityId));
        if (!(entity is SellableItem) ||
            !entityView.Name.EqualsOrdinalIgnoreCase("ExtensionProperties") ||
            !entityView.Action.EqualsOrdinalIgnoreCase("EditExtensionProperties") ||
            string.IsNullOrWhiteSpace(entityView.ItemId))
        {
            return entityView;
        }

        var sellableItem = entity as SellableItem;
        var extensionComponent = sellableItem.GetComponent<VariationExtensionComponent>(entityView.ItemId, false);

        // Assign component property values from the entity view property values
        extensionComponent.Material = entityView.GetPropertyValueByName(nameof(extensionComponent.Material));
        extensionComponent.IsClearance = bool.Parse(entityView.GetPropertyValueByName(nameof(extensionComponent.IsClearance)));

        // Persist the changes to the sellable item
        await Commander.PersistEntity(context.CommerceContext, sellableItem).ConfigureAwait(false);

        return entityView;
    }
}
services.Sitecore().Pipelines(pipelines => pipelines

    .ConfigurePipeline<IDoActionPipeline>(pipeline => pipeline
        .Add<Pipelines.Blocks.DoActionEditSellableItemExtensionViewBlock>().After<ValidateEntityVersionBlock>()
        .Add<Pipelines.Blocks.DoActionEditVariationExtensionViewBlock>().After<ValidateEntityVersionBlock>()
    )

);

Now we should see our persisted values render in the Extended Infomation entity views of the Merchandising pages and against their respective catalog items in the Sitecore Content Editor.

If the property values are not reflected in Sitecore, run the Refresh Commerce Cache command from the Commerce ribbon menu.

Extended Information populated on sellable item Sitecore item.
Extended Information populated on variant Sitecore item.

Variants will have sellable item properties populated, while sellable items will not have variant properties populated as variants represent a specialised form of the sellable item, while the sellable item should not know the specifics of any one variant.

Summary

Implementation Highlights

The following bullet points can be utilised as a checklist to ensure that all bases have been covered :

  • Create components to house custom properties.
  • Create get view blocks to retrieve components properties for BizFx pages (view), BizFx modals (create and edit actions) and CommerceConnect (generate catalog item template).
  • Create populate view actions blocks to allow business user to trigger actions, e.g. create, edit, delete.
  • Create do action blocks to persist create, edit, delete actions to commerce entities.

Considerations for Extending Catalog Items

  • Logically group properties into multiple components over using a catch all component. This could mean grouping related properties, grouping only properties that will be mapped to the Sitecore catalog items, or grouping properties into a component that will be utilised to extend cart lines.
  • View property names should match their component property names in order for the values to be mapped from the commerce entity to the Sitecore catalog item properties.
  • Property names must be unique for the catalog item commerce entity, otherwise this can cause a conflict in mapping values; properties are resolved by mapping the first instance of a property with the given name on the commerce entity.
  • Sellable items and variants Sitecore items inherit the same catalog generated templates.
    • Sellable items will host the variant properties on their Sitecore items, however these values will not be populated.
    • Variants will host the sellable item properties on their Sitecore items and these properties will be populated with the sellable item property values.

References

Sitecore KB article: How to extend Catalog system entities schema in Sitecore Experience Commerce

Source Code

Sitecore Commerce Catalog Component Extension Sample project repository