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

Sitecore Experience Commerce: Working with Environment Variables

In this article, we will review how environment variables are utilised in the Commerce Engine solution. These are findings that provide some more context on top of Commerce Developer Reference: Configuring the Commerce Engine using environment variables, which will allow us to work with them more effectively.

Introduction

Environment variables are primarily utilised in 3 areas of the Commerce Engine and each area has nuances that we need to be aware of. The first area is in the Commerce Engine configuration file, config.json. The second is the global environment configuration, global.json. The third are is made up of the role environment and policy set configurations.

Environment variables will only be consumed into the Commerce Engine's configuration builder if they are prefixed with "COMMERCEENGINE_".

Environment Variable Usage in Configuration Files

The Commerce Engine Configuration File (config.json)

Using the environment variable configuration provider, the config.json is updated without the need for placeholders or data type specification.

Updating a property value simply requires adding an environment variable that represents the path of the property to be overridden.

For example, adding the environment variable, COMMERCEENGINE_Serilog__MinimumLevel__Default: Information, will override the corresponding configuration property by resolving it to the structure of the configuration.

{
  "AppSettings": { ... },
  "Serilog": {
    ...
    "MinimumLevel": {
      "Default": "Warning",
      ...
    }
  },
  ...
}

While the placeholder values aren't relevant to the overrides themselves, the Commerce Engine is distributed with 'PlaceholderFor<property>' values in config.json. Consider these flags to ensure that environment variables are not omitted. When replacing other properties in the Commerce Engine configuration, placeholders are not required.

The Global Environment Configuration File (global.json)

Similar to the config.json, the global environment configuration file, global.json, is loaded during the start up process of the Commerce Engine. However, while the Commerce Engine leverages the environment variables for this configuration, the replacement strategy differs from the environment variable configuration provider in that instead of resolving variables to the structure of the configuration file it instead resolves via placedholder matches.

For example, the environment variable COMMERCEENGINE_GlobalDatabaseUserName: sa will override the corresponding configuration property by resolving it to the placeholder.

{
  ...
  "Name": "GlobalEnvironment",
  "Policies: {
    "$type": "System.Collections.ObjectModel.ReadOnlyCollection`1[[Sitecore.Commerce.Core.Policy, Sitecore.Commerce.Core]], mscorlib",
    "$values": [
      ...
      {
        "$type": "Sitecore.Commerce.Plugin.SQL.EntityStoreSqlPolicy, Sitecore.Commerce.Plugin.SQL",
        ...
        "UserName": "PlaceholderForGlobalDatabaseUserName",
        ...
      },
      ...
    ]
  }
}

Role Environment and Policy Set Configuration Files

For environment variables used in role environment and policy set configurations, the Commerce Engine also uses the same strategy as per the global.json (placeholder matching over structure resolution), however the replacement is performed at the time of bootstrapping the Commerce Engine, not during the start up process of the Commerce Engine.

This means that while the role environment and policy set configurations will retain the 'PlaceholderFor...' values on disk, the engine startup will still load configurations from the commerce global database. Therefore, the Commerce Engine role will not resolve changes during the start up process with updated variables (e.g. running docker-compose up for containers).

Changing environment variables used in role environment and policy set configuration files, should be thought the same as making changes to the configuration files themselves, where bootstrapping the Commerce Engine is required to consume the changes.

Supported Data Types in Placeholders

In Commerce Developer Reference: Configuring the Commerce Engine using environment variables, it 3 mentions supported data types for placeholders, where string is the implicit (default) type, and boolean and integer values can be represented by appending '|bool' and '|int' to placeholder names respectively.

The reality is that under the hood all that is happening is identifying whether the value should be wrapped with quotation marks or not. This means that we can actually support other data types, such as decimals by appending either '|bool' or '|int' to the placeholder.

Data types apply to placeholders in the global environment, role environment and policy set configuration files. They do not apply to placeholders in the Commerce Engine configuration file (config.json).

PlaceholderValuesResults
"PlaceholderForMyVariable"dev.sitecore.com
true
"dev.sitecore.com"
"true"
"PlaceholderForMyVariable|bool" or
"PlaceholderForMyVariable|int"
dev.sitecore.com
true
3
1.2 (decimal)
dev.sitecore.com
true
3
1.2

Summary

The following table summarises how environment variables are utilised in the various configuration files of the Commerce Engine.

Configuration File(s)Property ResolverAllows Fallback / Default Value*AppliesApplied ByRequires Bootstrap
config.jsonStructureYesDuring start upEnvironment variable configuration provider (.Net Core)No
global.jsonPlaceholderNoDuring start upCommerce EngineNo
Environment/Policy Set ConfigurationsPlaceholderNoDuring BootstrapCommerce EngineYes
*Where environment variable not provided.

References

Sitecore Experience Commerce: Customising Commerce Content in the Sitecore Indexes for Storefront Search Results

In this article, we will look at how to control the storefront search results by customising the commerce content that is indexed in the Sitecore master and web indexes.

If you are looking for how to add new fields for faceting and other purposes, see Adding a property field to an index.

How Storefront Search Works

The SXA Search component creates a solr query based on a number of criteria, however focusing on how the search term is involved in the query, we see that the sxacontent field is our culprit for this customisation. This can be seen highlighted below in our storefront and in our search log.

From XC 9.3 onwards, the commerce portion of the Sitecore indexes were migrated over to the Commerce Engine for indexing performance improvements. There are a number of handlers utilised for populating the indexes with catalog data, however for managing the content related to the storefront search component we will look at the SxaContentFieldHandler.

See Catalog Search Field Handlers for a full list of handlers provided with XC, which are utilised when registering index fields in Plugin.Search.Solr.PolicySet-1.0.0.json.

The SxaContentFieldHandler Class

By default, the sellable item properties that are added to the sxacontent field consist of the following entries:

  • Tags
  • ProductId
  • Name
  • DisplayName
  • ItemDefinition
  • Brand

As we can see from the sitecore_web_index in Solr, a query for a specific 'Spectra' product, productid_t:6042260, shows that these properties are indexed against the sellable item.

{
  "sxacontent_txm":["39inch|4k|uhd|television|spectra",
    "6042260",
    "Habitat Spectra 39” 4K LED Ultra HD Television",
    "Habitat Spectra 39” 4K LED Ultra HD Television",
    "Product",
    "Spectra Televisions"],
  "productid_t":"6042260",
  ...
}

A common property that I have seen often requested to be included for searching is the sellable item's description, so we will use this property as well as a generic property from a custom component for demonstration purposes.

In the sample code below, we see that we inherit from the default SxaContentFieldHandler and execute the base ComposeValue method, which will populate the default fields listed above. We then simply add the properties to the sxaContent reponse object, which in this case is a list of strings.

You may have noticed that we are only adding the properties to sxacontent if a value is present. This is to minimise the bloat of empty entries and may lead to performance improvement however insignificant it may be. You may prefer to leave empty entries in the index to simplfy troubleshooting in mapping each entry back to its source property, so choose an approach that best suits you.

public class MySxaContentFieldHandler : Sitecore.Commerce.Plugin.Catalog.SxaContentFieldHandler
{
    public override object ComposeValue(object source, ConcurrentDictionary<string, object> context)
    {
        var sxaContent= base.ComposeValue(source, context) as List<string>;
        if (!(source is SellableItem) || sxaContent == null || sxaContent.Count() == 0)
        {
            return sxaContent;
        }

        var sellableItem = source as SellableItem;

        if (!string.IsNullOrWhiteSpace(sellableItem.Description))
        {
            sxaContent.Add(sellableItem.Description);
        }

        var myProperty = sellableItem.GetComponent<MyCustomComponent>().MyCustomProperty;
        if (!string.IsNullOrWhiteSpace(myProperty))
        {
            sxaContent.Add(myProperty);
        }

        return sxaContent;
    }
}

Now that we have created our modified SxaContentFieldHandler class, we register it in Plugin.Search.Solr.PolicySet-1.0.0.json for both the master and web indexes.

{
  "$type": "Sitecore.Commerce.Plugin.Search.ItemIndexablePolicy, Sitecore.Commerce.Plugin.Search",
  "IndexName": "sitecore_web_index",
  "FieldTypeMappers": [ ... ],
  "Fields": [
    {
      "$type": "Sitecore.Commerce.Plugin.Search.Solr.SolrIndexFieldConfiguration, Sitecore.Commerce.Plugin.Search.Solr",
      "Name": "sxacontent",
      "Type": "System.Collections.Generic.List`1[System.String]",
      "TypeHint": "textCollection",
      "Handler": {
        "$type": "Sitecore.Commerce.Engine.Search.MySxaContentFieldHandler, Sitecore.Commerce.Engine"
      }
    },
    ...
  ]
}

After deploying our customisation, boostrapping, and reindexing, we can see our custom entries in the sxacontent field.

{
  "sxacontent_txm":["39inch|4k|uhd|television|spectra",
    "6042260",
    "Habitat Spectra 39” 4K LED Ultra HD Television",
    "Habitat Spectra 39” 4K LED Ultra HD Television",
    "Product",
    "Spectra Televisions",
    "Enjoy incredible picture and dramatic detail with 4X the resolution of full HD. Enjoy incredible hues of color with technology that blends digital color for near-perfect representation. Experience Ultra HD picture quality with enhanced detail in every way.",
    "mycustomproperty"],
  "productid_t":"6042260",
  ...
}

And by performing a search in the storefront our search results now return our product from our custom search entries.

Development Considerations

Field Handlers are Synchronous

At this time, field handlers have been implemented synchronously and expect the information you require to be available on the provided source entity, e.g. sellable item in this case, therefore if you are looking at sourcing data externally it may be worth further consideration.

Following Helix Priniciples

Whether upgrading from a pre XC 9.3 solution or introducing existing component properties into the search index, by implementing the custom SxaContentFieldHandler you may be faced with having to break helix principles, i.e. housing your custom SxaContentFieldHandler in a feature project while referencing another feature project to retrieve the desired properties/content. As feature projects should not reference each other, I reluctantly chose to add MySxaContentFieldHandler to the Commerce Engine project in this instance.

References

Sitecore Experience Commerce: Pipeline Block Types

In this article, we will look at the foundational pipeline block types that are used to construct pipelines within the Commerce Engine plugins and custom solutions.

Asynchronous vs Synchronous Pipeline Blocks

From XC 10.0, the PipelineBlock class has been split into AsyncPipelineBlock and SyncPipelineBlock classes for improved performance and stability, with the primary implementation difference being whether to call the pipeline block asynchronously or not during pipeline execution.

A notable change to the AsyncPipelineBlock is that it uses the RunAsync method instead of the Run method to enforce that the return type is a Task<TOutput> and as an added reminder that the pipeline block is in fact asynchronously executed.

How Async and Sync Pipeline Blocks Work in the Same Pipeline

The initial questions I had when seeing the pipeline split were "how does this affect registering pipeline blocks to pipelines?" and "how does this affect the way in which pipelines are executed?". The good news here is that is doesn't affect either from an implementation standpoint.

From the registration perspective, both async and sync pipelines are registered as per XC 9.X registration mechanisms.

.AddPipeline<IMyPipeline, MyPipeline>(pipeline => pipeline
    .Add<MySyncPipelineBlock>()
    .Add<MyAsyncPipelineBlock>()
    .Add<MyAsyncConditionalPipelineBlock>()
    .Add<MyAsyncPolicyTriggerConditionalPipelineBlock>()
    .Add<MyConditionalPipelineBlock>()
    .Add<MyAsyncConditionalPipelineBlock>()
)

From the async/sync execution perspective, async pipeline blocks are awaited before continuing on to the following pipeline block, which ensures the output of a pipeline block is the input of the following pipeline block.

Specialised Pipeline Blocks

You may have seen a few types of enhanced pipeline blocks in XC 9.X+, being ConditionalPipelineBlock and PolicyTriggerConditionalPipelineBlock, which may have been better suited to your custom implementations over adding conditional statements to the begining of your Run/RunAsync method.

Conditional Pipeline Blocks

As indicated by their names, the ConditionalPipelineBlock and AsyncConditionalPipelineBlock classes will be conditionally executed. This is achieved by defining the BlockCondition predicate to be evaluated to determine if the Run/RunAsync method should be called.

Where the evaluated BlockCondition returns false, an alternate method called ContinueTask is executed instead of Run/RunAsync and is intended to simply return the arg or null.

Returning arg from ContinueTask considers the pipeline block as optional whereas returning null may consider the pipeline block as mandatory depending on how this output is handled in subsequent pipeline blocks or the pipeline itself.

Warning: Using the ContinueTask method to run alternate code logic may be a sign of a bad code smell, such as breaking the separation of concerns rule. Having 2 separate pipeline blocks with inverted predicates is the recommended approach.

public class MyConditionalPipelineBlock : ConditionalPipelineBlock<MyArgument, MyArgument, CommercePipelineExecutionContext>
{
    public MyConditionalPipelineBlock()
    {
        BlockCondition = obj => ((CommercePipelineExecutionContext)obj).CommerceContext.HasPolicy<MyCustomPolicy>();
    }

    public override MyArgument Run(MyArgument arg, CommercePipelineExecutionContext context)
    {
        Condition.Requires(arg, nameof(arg)).IsNotNull();

        // My code logic

        return arg;
    }

    public override MyArgument ContinueTask(MyArgument arg, CommercePipelineExecutionContext context)
    {
        return arg;
    }
}

Policy Trigger Conditional Pipeline Blocks

Another more specialised form of conditional pipeline blocks are the PolicyTriggerConditionalPipelineBlock and AsyncPolicyTriggerConditionalPipelineBlock. These blocks actually have somewhat inverted logic to their name as you will need to specify the name of a policy in the ShouldNotRunPolicyTrigger property which will ensure the pipeline is not executed.

When the policy name is present in the request header's PolicyKeys value the pipeline block will execute the ContinueTask method over the Run/RunAsync method (See Conditional Pipeline Blocks for more details).

A full set of existing policy keys and their usages can be found in Commerce Developer Reference: Policy keys. It is recommended that the Commerce Engine policy keys be avoided when creating custom policy trigger conditional pipeline blocks.

The BlockCondition predicate is already implemented to manage the conditional logic as mentioned above and the ContinueTask method will return the arg as policy trigger conditional pipeline blocks should be considered optional and not mandatory.

public class MyPolicyTriggerConditionalPipelineBlock : PolicyTriggerConditionalPipelineBlock<MyArgument, MyArgument, CommercePipelineExecutionContext>
{
    public override string ShouldNotRunPolicyTrigger => "IgnoreMyPipelineBlock";

    public override MyArgument Run(MyArgument arg, CommercePipelineExecutionContext context)
    {
        Condition.Requires(arg, nameof(arg)).IsNotNull();

        // My code logic

        return arg;
    }
}

References

Sitecore Experience Commerce: Debugging the Commerce Engine from Visual Studio in XC 10

In this article, we will review how to attach a running Commerce Engine instance to the Visual Studio Debugger for local debugging.

Introduction

In Sitecore Experience Commerce 9.X.Y, the Commerce Engine was built on .NET Core 2.2 or lower, which either only supported the out-of-process hosting or is the default configuration. This meant that IIS was essentially being used as a proxy to forward requests to the Commerce Engine application, which is why you are probably familiar with attaching the debugger to a process named Sitecore.Commerce.Engine.exe. By hovering over the process, the source Commerce Engine could easily be identified when attaching the process to Visual Studio.

Sitecore Experience Commerce 10 now leverages .Net Core 3.1, which uses in-process hosting, meaning the Commerce Engine instance run in the same process as its IIS worker process. Overall, the in-process hosting provides improved performanced as requests aren't being proxied over the loopback adapter.

Now that we have a better understanding of the switch from out-of-process to in-process hosting in XC 10, it should be apparent as to why we need to amend how we attach the Commerce Engine to Visual Studio's debugger.

Attaching the Commerce Engine instance to the Visual Studio Debugger

First thing we will need to do is to retrieve the process Id to verfiy that we are attaching the correct process in Visual Studio. To do this, open IIS and navigate to the Worker Processes view.

In the Worker Processes view, look for the application pool name containing the name of the application pool configured for the Commerce Engine instance and note the Process Id.

If the application pool does not show the Commerce Engine instance, it has likely not started, or shut down due to inactivity. Execute a request to the Commerce Engine instance and refresh the Worker Processes view in IIS.

Over in Visual Studio, open the Attach to Process dialog, select the Show processes from all users as you will likely be running the Commerce Engine under a dedicated user profile, and locate the ID that we identified from the Worker Proccesses view in IIS. The 'w3wp' can also be applied to the process filters to more easily locate the process.

References

Microsoft: Host ASP.Net Core on Windows with IIS > Hosting models

Sitecore Experience Commerce: Promotion Evaluation and Application Logic in XC 10

In this article, we will review the breaking changes to promotions that have been introduced in XC 10, considerations for upgrading from a XC 9.X solution, and the updated business logic that the Commerce Engine utilises to evaluate and apply promotions.

Note: References to date will indicate both date and time throughout this article.

Introduction

Previously in Sitecore Experience Commerce: Promotion Evaluation and Application Logic, we had identified that promotions calculations were broken down into cart line level and cart level promotions, of which cart line level promotions were calculated and applied prior to cart level promotions.

In Sitecore Commerce 10, there are 3 key changes that are important to understand, especially when upgrading solutions to XC 10, as the changes to rules for promotion application means that cart line and cart subtotal and total calculations may differ.

  1. Exclusive promotions are now exclusive across the cart line level and cart level promotions.
  2. Promotion Priority indexes have been introduced, allowing business users to specify a priority level that will take precedence in the initial ordering of promotions during evaluation. The lower the promotion priority value, the higher the promotion is evaluated (let it sink in for a minute).
    1. For exclusive promotion application, automatic promotions are addressed prior to coupon promotions, then Promotion Priority is the decider for determining the exclusive promotion within the group regardless of whether it's a cart line level or cart level promotion.
    2. For applying multiple non-exclusive promotions, cart line level promotions, still take precedence over cart level promotions, while the Promotion Priority is a secondary factor.
 
Sitecore.Commerce.Plugin.Carts
ICalculateCartPipeline (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
     ------------------------------------------------------------
     Plugin.Carts.ClearCartBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
     ------------------------------------------------------------
     Plugin.Carts.CalculateCartSubTotalsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
     ------------------------------------------------------------
     Plugin.Fulfillment.CalculateCartLinesFulfillmentBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
     ------------------------------------------------------------
     Plugin.Fulfillment.CalculateCartFulfillmentBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
     ------------------------------------------------------------
     Plugin.Promotions.CalculateCartPromotionsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
     ------------------------------------------------------------
     Plugin.Promotions.RemoveUnwatedFreeGiftsFromCartBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
     ------------------------------------------------------------
     Plugin.Tax.CalculateCartLinesTaxBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
     ------------------------------------------------------------
     Plugin.Tax.CalculateCartTaxBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
     ------------------------------------------------------------
     Plugin.Carts.CalculateCartTotalsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
     ------------------------------------------------------------
     Plugin.Payments.CalculateCartPaymentsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
     ------------------------------------------------------------
     Plugin.Carts.WriteCartTotalsToContextBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)

Promotion Evaluating Logic

The following diagram shows the pipelines and pipeline blocks that are called during the process of evaluating the applicable promotions and additional filtering for exclusive promotion evaluation.

Figure 1. Promotion qualification evaluation pipeline logic

The following steps make up the evaluation process:

  1. Pre-Evaluate Promotions: Removes any previous selected free gift cart lines from the cart and adds them as a unique object by type to the commerce context. This is too ensure that these cart lines are not utilised when evaluating applicable promotions.
  2. Search For Promotions: Performs a search on the promotions index and retrieves the promotion entities from the commerce database:
    • Index Search Filters:
      1. By Promotion Entity Type: Filters down to the promotion entity type, i.e. excludes promotion book indexes.
      2. By Valid Date: Filters out promotions that do not fall within the Valid From/To dates based on effective date.
      3. By Catalogs: Filters out promotions where the associated catalog does not match any of the catalogs that are associated to any of the cart line items.
    • Post-Search Filters on Entity List:
      1. Filter Not Approved Promotions: Removes promotions that are not approved and, if the promotion is disabled, where the effective date is prior to the updated date (the date the promotion was disabled). The latter rule is to allow the promotion to be active when reviewed the storefront at a previous point in time.
  3. Filter Promotions By Items: Removes promotions where the cart contains no sellable items marked as included in the ItemsCollection qualification or where the cart contains any of the sellable items marked as excluded in the ItemsCollection qualification.
  4. Filter Promotions By Coupon: Removes promotions that require a coupon that has not been applied to the cart.
  5. Evaluate Promotions: Filters out promotions where promotion qualification and benefit rules are not applicable to the current cart.
  6. Filter Promotions With Coupons By Exclusivity: If exclusive coupon promotions are present in the promotions list, the list will be filtered down to a single exclusive coupon promotion. The exclusive promotion will be determined with the lowest Promotion Priority value, followed by the Added date that their corresponding coupons were applied to the cart, in the event of a tie breaker.
  7. Filter Promotions By Exclusivity: If exclusive automatic promotions are present in the promotions list, the list will be filtered down to a single exclusive automatic promotion. The promotion will be determined by the lowest Promotion Priority value, followed by the earliest (oldest) Valid From date, and in the event of multiple promotions sharing the same lowest Promotion Priority value and earliest Valid From date the promotion that was created earliest will take win.
  8. Post-Evaluate Promotions: Adds the previously selected free gift cart lines back to the cart; retrieved from the commerce context.

Promotion Priorisation Rules

While the previous section covered how promotions are evaluated, and also provided some insight into promotion priorisation, we will now cover the prioritisation rules.

The following diagram shows the logic used to determine which promotion(s) to apply to the cart.

Figure 2. Flow chart of promotion application logic

The blue boxes represent logic for maintaining free gift selections and don't impact the application of the promotions, however this shows where this logic is handled.

There are essentially 3 steps that make up the promotion application process:

  1. Apply a single exclusive automatic promotion.
    • The promotion will be determined by the lowest Promotion Priority value, which will fallback to the earliest (oldest) Valid From date, and then the earliest Created date the promotion that was created, in the result of tied values.
    • If a promotion is applied here, no further promotions are applied.
  2. Apply a single exclusive coupon promotion.
    • The promotion will be determined by the lowest Promotion Priority value, with the Added date that their corresponding coupons were applied to the cart as a fallback to manage tied values.
    • If a promotion is applied here, no further promotions are applied.
  3. Apply all non-exclusive promotions.
    • The promotion order will be determined by cart line level promotions being applied first and cart level promotions applied second, both ordered by ascending Promotion Priority value. Where Promotion Priority values are equal, promotions are furthered ordered as follows:
      1. Automatic promotions, ordered by earliest (oldest) Valid From date, and in the event of multiple promotions sharing the same earliest Valid From date the promotion that was created earliest will win.
      2. Coupon promotions, ordered by earliest Added date that their corresponding coupons were applied to the cart.

Can Promotions Be Configured to XC 9.X Logic?

If Promotion Priorities are not utilised, the promotion logic remains largely the same as cart line level promotions are still applied before cart level promotions. The key difference is the promotion logic now ensures a sole exclusive promotion, which cannot be reverted by configuration and will require customisation.

References

Sitecore Experience Commerce: Creating a Commerce Engine Plugin in XC 10

In this article, we will look at the steps in creating a custom plugin project for the Commerce Engine solution using the current developer experience in XC 10.

Introduction

With XC 10, creating a Sitecore Commerce plugin project is currently not available as a Visual Studio extension project template and has been migrated to the .Net Core CLI as a static project template.

As per previous versions of the the Sitecore Commerce plugin project, this is intended as a reference project representing a loose end-to-end implementation with sample classes. It is also commonly utilised as the starting template for your project, where you will remove the sample classes and utilise the csproj file and the project's folder structure as the skeleton for your plugins.

Installing the Sitecore.Commerce.Plugin.Template

The plugin project template is no longer distributed via the Commerce Engine SDK and is now available via the Sitecore Commerce Official NuGet Feed. To download the project template, search for Sitecore.Commerce.Plugin.Template and navigate to the package details page.

On the package details page, scroll down and download the package from right panel under the Info section.

The plugin project template can now be installed via the .Net Core CLI. Open PowerShell and run the following command, dotnet new -i <full path to Sitecore.Commerce.Plugin.Template.*.*.*.nupkg>.

dotnet new -i C:\Projects\Sitecore.Commerce.Plugin.Template.6.0.4.nupkg

Now if we run the dotnet new command in PowerShell, we can see the installed plugin project template.

Creating a new Sitecore Commerce Plugin Project

Now with the plugin project tempate installed, we can create a new project using the template.

Using you Commerce Engine solution's root directory, the following command will create the plugin project in the desired directory, dotnet new pluginsample -o <relative path to project directory>/<project name>.

dotnet new pluginsample -o src/Foundation/MyPlugin/Engine/Foundation.MyPlugin.Engine

Note: The project name has not been configured at this time, so we will perform some post project creation steps to resolve this.

After creating the project, navigate to the folder where the project was created and rename Sitecore.Commerce.Plugin.Sample.csproj to your project name, e.g. Foundation.MyPlugin.Engine.csproj

To resolve namespaces within the project, you can use a tool, such as Notepad++, to replace all references of 'Sitecore.Commerce.Plugin.Sample' to <your project name>, e.g. Foundation.MyPlugin.Engine.

Adding the Plugin to the Commerce Engine Solution

With our new project created, we can add it the Commerce Engine solution by right-clicking a solution folder, selecting the Add Existing Project command, and adding the project from its location.

Finally add the project to the Commerce Engine by adding the project reference to the Sitecore.Commerce.Engine project.

Sitecore Experience Commerce: Implementing Multi-Step Actions in the Business Tools

In this article, we will look at multi-step actions and how we can implement them for customising the Sitecore Commerce Business Tools.

What is a Multi-Step Action?

The multi-step action is the approach to building out entity view modals to act as a kind of wizard, where inputs from each step can affect the subsequent steps.

Adding a qualification/condition to a promotion - step 1.
Adding a qualification/condition to a promotion - step 2.

A Dive into the Multi-Step Action Implementation

We will take a dive into the pieces that make up the multi-step action implementation to see just how we should be building our custom multi-step modal, using the Add Qualification modal for promotions as our working example.

This section is focused on explaining what will be found in the current platform implementation, which can be useful if looking to extend the BizFx and Commerce Engine functionality, but can be skipped if you are only after Implementing Custom Multi-Step Actions as quick as possible.

MultiStepActionPolicy

The MultiStepActionPolicy houses a single property, FirstStep, which will contain the EntityActionView that will be populated in the modal entity view.

public MultiStepActionPolicy()
{
    this.FirstStep = new EntityActionView();
}

public EntityActionView FirstStep { get; set; }

In the following code snippet, we see that the MultiStepActionPolicy is added to the EntityActionView, in which the QualificationsDetails that is assigned to the EntityView will use to populate the first step of the add qualification modal.

Line 18 highlights the Name of the entity view action, which will be resolved to its localised value from the .../Commerce Terms/BusinessTools/ViewActionNames/AddQualification, and populates modal's title.

var actionPolicy = arg.GetPolicy<ActionsPolicy>();
actionPolicy.Actions.Add(
        new EntityActionView(new List<Policy>
        {
            new MultiStepActionPolicy
            {
                FirstStep = new EntityActionView
                {
                    Name = context.GetPolicy<KnownPromotionsActionsPolicy>().SelectQualification,
                    DisplayName = "Select Qualification",
                    Description = "Selects a Qualification",
                    IsEnabled = isEnabled,
                    EntityView = context.GetPolicy<KnownPromotionsViewsPolicy>().QualificationDetails
                }
            }
        })
        {
            Name = context.GetPolicy<KnownPromotionsActionsPolicy>().AddQualification,
            DisplayName = "Add Qualification",
            Description = "Adds a Qualification",
            IsEnabled = isEnabled,
            EntityView = string.Empty,
            Icon = "add"
        });
The entity view populated in the first step, of the Add Qualification modal, is resolved from the entity view action name of the EntityActionView, assigned to the MultiStepActionPolicy's FirstStep, which in this case is the SelectQualification action.

LocalizeEntityViewBlock

In the LocalizeEntityViewBlock, we find that the SelectQualification action name is being populated with the localised term, however as mentioned above, the modal utilises the AddQualification localised term instead, therefore we don't need to be concerned with this.

if (!action.HasPolicy<MultiStepActionPolicy>())
{
    continue;
}

var firstStepAction = action.GetPolicy<MultiStepActionPolicy>().FirstStep;
await SetActionLocalizedTerms(firstStepAction, context).ConfigureAwait(false);

MultiStepActionModel

This model is quite simple. The NextStep represents the name of the action to be executed when the modal form is submitted.

public MultiStepActionModel(string nextStep)
{
    this.NextStep = nextStep;
}

public string NextStep { get; set; }

In the following extract of DoActionSelectQualificationBlock, I have substituted out most of the code with comments, explaining the original code logic, to reduce the noise and keep focus on the multi-step implementation functionality.

In line 11, we see that the original condition property has been made readonly as we don't want the user to change their mind at this stage, however we don't want to hide the property as the user still needs to have context of what was previously selected.

Updating the readonly status of submitted view properties is considered a recommended practice.

Line 15 we have a comment that tells us that this is where the next step's view properties are added to the current entity view, building out the modal form.

This also means that the Do Action Blocks acts a pseudo Get Entity View Block as the initial modal's entity view is populated via the GetEntityView() API, utilising the IGetEntityViewPipeline under the hood, while subsequent updates in the multi-step implementation are triggered as the DoUxAction() API calls the IDoActionPipeline to process requests.

Finally, line 17 has the original entity view action name, 'AddQualification', added to the MultiStepActionModel as the NextStep and applied to the commerce context.

With the knowledge that adding the MultiStepActionModel to the context effectively creates the next step in the modal, there is no limit to how many steps we can add to a multi-step modal.

public override async Task<EntityView> Run(EntityView entityView, CommercePipelineExecutionContext context)
{
    /* validate action */

    /* validate promotion */

    var selectedCondition = entityView.Properties.FirstOrDefault(p => p.Name.Equals("Condition", StringComparison.OrdinalIgnoreCase));

    /* validate condition */

    selectedCondition.IsReadOnly = true;

    /* add and/or conditional operator. hide if no qualifications have been applied to the promotion so far */

    /* add new view properties for selected condition */

    context.CommerceContext.AddModel(new MultiStepActionModel(context.GetPolicy<KnownPromotionsActionsPolicy>().AddQualification));

    return entityView;
}
DoActionSelectQualificationBlock validates the Condition selection and updates the entity view to contain the remaining view properties required for configuring the 'Cart Has [count] Items?' qualification as the second step of this multi-step action modal.

CheckForMultiStepActionBlock

From the following snippet from CheckForMultiStepActionBlock, we see that it updates the current entity views action, removes the MultiStepActionModel from the commerce context, and adds the entity view to the commerce context.

In short, this logic is more of a helper, and we could get the same result by omitting the registration of the MultiStepActionModel, updating the entity view action, and add the entity view directly in the Do Action Block instead.

var multiAction = context.CommerceContext.GetModels<MultiStepActionModel>().FirstOrDefault();
if (string.IsNullOrEmpty(multiAction?.NextStep))
{
    return Task.FromResult(arg);
}

entityView.Action = multiAction.NextStep;
context.CommerceContext.RemoveModel(multiAction);
context.CommerceContext.AddModel(entityView);

The DoUxAction API

The last piece of the puzzle comes with BizFx's handling of the response of the DoUxAction API when we submit the modal dialog.

If an entity view object is returned in the API response object, BizFx will render the modal with the updated view instead of closing the modal and refreshing the current page view.

If we were to change the modal's entity view name during any of the steps it will not be reflected in the modal's title as there is no handling for this implemented by default in BizFx.

Summary

Let's review what we have learnt about multi-step actions.

  1. Adding the MultiStepActionPolicy to an EntityActionView basically swaps out the intended action from being executed in the initial GetEntityView() request, however the modal's title will reflect the initial EntityActionView's localised name.
  2. The LocalizeEntityViewBlock application to the EntityActionView in the MultiStepActionPolicy's FirstStep is superfluous and does not impact the multi-step implementation.
  3. Do Action Blocks act as pseudo Get Entity View Block for updating the modal's entity view.
  4. When updating entity views with multi-step actions, previously input view properties should be set to readonly as a recommended practice.
  5. The usage of the MultiStepActionModel is more of a helper model in CheckForMultiStepActionBlock, rather than a dependent piece of the implementation.
  6. There is no limit to how many steps we can add to a multi-step action modal.
  7. The modal title is not updated with an updated entity view.
  8. When the Commerce Engine's DoUxAction() API returns an entity view in the response model, BizFx will render it in the current modal view.

Implementing Custom Multi-Step Actions

For implementing custom multi-step actions we will skip the details about how to build out the pre-requisites, being the initial Populate View Actions Block to create the entity view action that will trigger the modal, and the Get Entity View Block that will populate the initial entity view that is rendered in the modal, and instead focus on the Do Action Block that will allow us to add the additional steps to the modal.

The multi-step sample show the initial entity view that we will create the second step for.

Essentially, we can implement multi-step actions without using any of the MultiStep classes, simply by adding the new entity view to the commerce context during a Do Action Block, however providing a more complete sample, the following code logic would be applied to our custom Do Action Block.

  1. Validate action
  2. Validate entity (if applicable)
  3. Validate current view properties
  4. Set current view properties to read only
  5. Add new view properties to entity view for next step
  6. Update entity view action with action to perform on the next time the modal is submitted
  7. Add entity view to the commerce context
public override async Task<EntityView> Run(EntityView entityView, CommercePipelineExecutionContext context)
{
    Condition.Requires(entityView).IsNotNull($"{Name}: The argument cannot be null");

    /* 1. Validate action */
    if (string.IsNullOrEmpty(entityView?.Action)
        || !entityView.Action.Equals("FirstAction", StringComparison.OrdinalIgnoreCase))
    {
        return await Task.FromResult(entityView).ConfigureAwait(false);
    }

    /* 2. Validate entity (if applicable) */
    // Not applicable

    /* Validate current view properties */
    // Not critical for sample implementation

    /* Set current view properties to read only */
    foreach (var property in entityView.Properties)
    {
        property.IsReadOnly = true;
    }

    /* Add new view properties to entity view for next step */
    entityView.Properties.Add(new ViewProperty
    {
        Name = "Step 2 Field 1"
    });

    entityView.Properties.Add(new ViewProperty
    {
        Name = "Step 2 Field 2",
        IsRequired = false
    });

    /* Update entity view action with action to perform on the next time the modal is submitted */
    entityView.Action = "SecondAction";

    /* Add entity view to the commerce context */
    context.CommerceContext.AddModel(entityView);

    return await Task.FromResult(entityView).ConfigureAwait(false);
}
Our multi-step sample entity view after the modal has been submitted, creating the second step view for completing data input.

Sitecore Experience Commerce: Configuring and Customising the BizFx Solution with Locale Support

In this article, we will look at how the locale can be changed in BizFx to support a desired locale type and the steps required to add support for locales that haven't been configured in the BizFx solution.

Working in Australia, a common customisation required is to add the 'en-AU' locale, primarily to support date/time formatting in BizFx.

A sample date using the default 'en' locale configuration.
The same sample date using the 'en-AU' locale.

Adding Support for a Locale

To add support for a locale that has not been registered to BizFx, the process is quite simple.

If you are looking to configure BizFx with any of the following locales, then this step will not be required. Default Locales: 'en' ('en-US'), 'fr-FR', 'de-DE', and 'Ja-JP'.

In the BizFx solution, open src\app\app.module.ts , import the locale and advanced formatting options from the extra dataset and register the locale via the registerLocaleData function.

The locales are already provided with the BizFx SDK, under src\locales.

/* Locales */
import localeFr from '../locales/fr';
import localeFrExtra from '../locales/extra/fr';
import localeJa from '../locales/ja';
import localeJaExtra from '../locales/extra/ja';
import localeDe from '../locales/de';
import localeDeExtra from '../locales/extra/de';
import localeEnAu from '../locales/en-AU';
import localeEnAuExtra from '../locales/extra/en-AU';

registerLocaleData(localeFr, 'fr-FR', localeFrExtra);
registerLocaleData(localeDe, 'de-DE', localeDeExtra);
registerLocaleData(localeJa, 'Ja-JP', localeJaExtra);
registerLocaleData(localeEnAu, 'en-AU', localeEnAuExtra);

Once we have registered the locale, if we were to configure the locale at this point we would find that there is some messaging in that is not rendered quite right. This is because there are also a handful of text overrides that are stored in the internationalisation folder of BizFx ('src\assets\i18n\') that also need to be added to override the default messaging in BizFx.

The LanguageSelector display text has not been configured.

Copy an existing locale file fom the internationalisation folder and update the messaging accordingly.

{
    "ValidationErrors": {
        "IsRequired": "{{DisplayName}} is required.",
        "IncorrectDecimalValue": "{{DisplayName}} has an incorrect decimal value.",
        "IncorrectDateValue": "{{DisplayName}} has an incorrect date value."
    },
    "ActionExecuted": "{{DisplayName}} executed.",
    "Back": "Back",
    "Logout": "Log out",
    "LanguageSelector": "Language displayed",
    "NoSearchResults": "No results matching your search were found.",
    "Searching": "Searching..."
}
The LanguageSelector display text when configured for 'en-AU'.

Configuring the Locale for BizFx

Now that the locale has been registered to the BizFx solution, the next step is to update the BizFx configuration to use it.

Open src\assets\config.json and set the Language to the newly registered locale.

{
  "EnvironmentName": "HabitatAuthoring",
  "EngineUri": "https://commerceauthoring.XC92.local",
  "IdentityServerUri": "https://XP0.IdentityServer",
  "BizFxUri": "https://bizfx.XC92.local",
  "Language": "en-AU",
  "Currency": "USD",
  "ShopName": "CommerceEngineDefaultStorefront",
  "LanguageCookieName": "selectedLanguage",
  "EnvironmentCookieName": "selectedEnvironment",
  "AutoCompleteTimeout_ms": 300
}

Deploy the BizFx solution as per your preferred deployment method and the locale will now be utilised throughout the BizFx tooling.

Source Code

The source code for this example of adding the 'en-AU' locale and other BizFx customisations can be found at Ajsuth.BizFx.

Sitecore Experience Commerce: Resolving Site-Catalog Association Lock

In this article, we look at how to fix the "'<catalog>' is already associated to another site." error message that can occur when attempting to save the catalog association to a site, although no other sites have an association to this catalog at the time.

Note: This solution is only intended for local development and not recommended for production instances.

Background

Over in Sitecore Commerce, the category and sellable item entities contain a ParentCatalogList property that houses the Sitecore ids of the associated catalog entities. For catalog entities however, the ParentCatalogList is being repurposed for the site association Sitecore id and acts as a lock, preventing the catalog from being associated to other sites.

If you happen to have deleted a site that had a existing catalog association, the catalog commerce entity will not be updated to release the lock. Similar scenarios may also be seen if the commerce shared database is restored to an environment that has conflicting sitecore ids with existing sites, as often can be seen during development across teams that do not sync their Sitecore and Commerce data.

Solution

Removing the lock can be achieved by executing the following script against the commerce shared database.

Update the database name and catalog friendly id values, in the highlighted lines, as appropriate.

USE SitecoreCommerce9_SharedEnvironments
GO

DECLARE @catalogEntityId VARCHAR(MAX) = 'Habitat_Master';
DECLARE @uniqueId VARCHAR(MAX);
DECLARE @entity VARCHAR(MAX);
DECLARE @modifiedEntity VARCHAR(MAX);

SELECT @entity = Entity,
       @uniqueId = Entities.UniqueId
FROM   sitecore_commerce_storage.CatalogEntities AS Entities
       INNER JOIN sitecore_commerce_storage.CatalogEntity AS Entity
               ON Entities.UniqueId = Entity.UniqueId
WHERE  Id = 'Entity-Catalog-' + @catalogEntityId

UPDATE sitecore_commerce_storage.CatalogEntity
SET    Entity = JSON_MODIFY(@entity, '$.ParentCatalogList', '')
WHERE  UniqueId = @uniqueId

Now that we have released the lock, we can associate the catalog to our site, however we may still encounter issues navigating through the catalog structure in the content editor and attempting to publish the site may throw null reference exceptions.

Error message when trying to navigate to category under catalog in the Content Editor.

Site Publish Error Message
Job started: Publish to 'web'|#Exception: System.Reflection.TargetInvocationException: Exception has been thrown by the target of an invocation. ---> System.NullReferenceException: Object reference not set to an instance of an object.
at Sitecore.Commerce.Engine.Connect.DataProvider.ReadOnlyCatalogDataProvider.GetChildIDs(ItemDefinition parentItem, CallContext context)
...

These issues are caused by the catalog's Template Overrides, which are configured to templates that don't exist in this Sitecore instance.

Updating the Template Overrides to valid templates for the site will resolve these issues and you should be up and running again.