How to extend Catalog system entities schema in Sitecore Experience Commerce


Description

Sitecore Experience Commerce provides a default schema for Sellable Items (Products), Categories, and Catalogs. The following article describes how you extend the default schema programmatically.

A Business Tools UI mechanism for extending the schema is available starting from Sitecore Experience Commerce 9.0.2. More details can be found in the Adding Properties To Commerce Items and the Entity Composer articles.

Solution

This section describes how you extend the Sellable Item entities through components. You can extend the other catalog system entities in a similar way. For more details on the Commerce plugin, refer to the Creating your first plugin article. The code snippets referenced in this article are included in the Plugin.Sample.Notes.zip sample project. (An updated sample project compatible with Sitecore Experience Commerce 10.x is available as Plugin.Sample.Notes.XC10.x.zip. This new version also contains changes to better support localization UI and proper handling of entity version in the business tool).

To extend the Sellable Item entity, create a new component class with the properties that will be persisted as a part of the Sellable Item:

namespace Plugin.Sample.Notes
{
  using Sitecore.Commerce.Core;
 
  public class NotesComponents : Component
  {
    public string WarrantyInformation { get; set; } = string.Empty;
    public string InternalNotes { get; set; } = string.Empty;
  }
}

To allow users to provide content for these properties, the existing views for sellable items and their variants can be extended by creating a new entity view and registering it in the IGetEntityViewPipeline.

When creating entity views, you should keep in mind that all blocks in that pipeline execute when an entity view is requested, so it is your responsibility to make sure that your block only acts for specific view names, action names, or entity types.

The following code sample handles the Sellable Item views and adds the properties from the previously created component as view properties that are rendered in the business tools.

namespace Plugin.Sample.Notes
{
  using System;
  using System.Threading.Tasks;
  using Sitecore.Commerce.Core;
  using Sitecore.Commerce.EntityViews;
  using Sitecore.Commerce.Plugin.Catalog;
  using Sitecore.Framework.Conditions;
  using Sitecore.Framework.Pipelines;
 
  [PipelineDisplayName(NotesConstants.Pipelines.Blocks.GetNotesViewBlock)]
  public class GetNotesViewBlock : PipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
  {
    private readonly ViewCommander _viewCommander;
 
    public GetNotesViewBlock(ViewCommander viewCommander)
    {
      this._viewCommander = viewCommander;
    }
 
    public override Task<EntityView> Run(EntityView arg, CommercePipelineExecutionContext context)
    {
      Condition.Requires(arg).IsNotNull($"{Name}: The argument cannot be null.");
      var request = this._viewCommander.CurrentEntityViewArgument(context.CommerceContext);
      var catalogViewsPolicy = context.GetPolicy<KnownCatalogViewsPolicy>();
      var notesViewsPolicy = context.GetPolicy<KnownNotesViewsPolicy>();
      var notesActionsPolicy = context.GetPolicy<KnownNotesActionsPolicy>();
      var isVariationView = request.ViewName.Equals(catalogViewsPolicy.Variant, StringComparison.OrdinalIgnoreCase);
      var isConnectView = arg.Name.Equals(catalogViewsPolicy.ConnectSellableItem, StringComparison.OrdinalIgnoreCase);
 
      // Make sure that we target the correct views
      if (!isCatalogView && !isConnectView)
      {
        return Task.FromResult(arg);
      }
 
      // Only proceed if the current entity is a sellable item
      if (!(request.Entity is SellableItem))
      {
        return Task.FromResult(arg);
      }
 
      var sellableItem = (SellableItem)request.Entity;
 
      // See if we are dealing with the base sellable item or one of its variations.
      var variationId = string.Empty;
      if (isVariationView && !string.IsNullOrEmpty(arg.ItemId))
      {
        variationId = arg.ItemId;
      }
 
      var targetView = arg;
 
      // Check if the edit action was requested
      var isEditView =
      !string.IsNullOrEmpty(arg.Action) &&
      arg.Action.Equals(
        notesActionsPolicy.EditNotes,
        StringComparison.OrdinalIgnoreCase);
 
      if (!isEditView)
      {
        // Create a new view and add it to the current entity view.
        var view = new EntityView
        {
          Name = context.GetPolicy<KnownNotesViewsPolicy>().Notes,
          DisplayName = "Notes",
          EntityId = arg.EntityId,
          ItemId = variationId
        };
 
        arg.ChildViews.Add(view);
 
        targetView = view;
        }
 
      if (sellableItem != null && (sellableItem.HasComponent<NotesComponents>(variationId) || isConnectView || isEditView))
        {
          var component = sellableItem.GetComponent<NotesComponents>(variationId);
 
          targetView.Properties.Add(
          new ViewProperty
          {
            Name = nameof(NotesComponents.WarrantyInformation),
            RawValue = component.WarrantyInformation,
            IsReadOnly = !isEditView,
            IsRequired = false
          });
 
          // Add additional properties
      }
      return Task.FromResult(arg);
    }
  }
}


If you want your content to be accessible in your storefront, you must handle the ConnectSellableItem view as shown previously, because this allows the template generation to pick up new views.

You must then create an action that takes the user input and persists it in the component of the given Sellable Item:

namespace Plugin.Sample.Notes
{
  using System;
  using System.Linq;
  using System.Threading.Tasks;
  using Sitecore.Commerce.Core;
  using Sitecore.Commerce.EntityViews;
  using Sitecore.Commerce.Plugin.Catalog;
  using Sitecore.Framework.Conditions;
  using Sitecore.Framework.Pipelines;
 
  [PipelineDisplayName(NotesConstants.Pipelines.Blocks.DoActionEditNotesBlock)]
  public class DoActionEditNotesBlock : PipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
  {
    private readonly CommerceCommander _commerceCommander;
 
    public DoActionEditNotesBlock(CommerceCommander commerceCommander)
    {
      this._commerceCommander = commerceCommander;
    }
 
    public override Task<EntityView> Run(EntityView arg, CommercePipelineExecutionContext context)
    {
      Condition.Requires(arg).IsNotNull($"{Name}: The argument cannot be null.");
      var notesActionsPolicy = context.GetPolicy<KnownNotesActionsPolicy>();

      // Only proceed if the right action was invoked
      if (string.IsNullOrEmpty(arg.Action) || !arg.Action.Equals(notesActionsPolicy.EditNotes, StringComparison.OrdinalIgnoreCase))
      {
        return Task.FromResult(arg);
      }
 
      // Get the sellable item from the context
      var entity = context.CommerceContext.GetObject<SellableItem>(x => x.Id.Equals(arg.EntityId));
      if (entity == null)
      {
        return Task.FromResult(arg);
      }
 
      // Get the notes component from the sellable item or its variation
      var component = entity.GetComponent<NotesComponents>(arg.ItemId);
 
      // Map entity view properties to component
      component.WarrantyInformation = arg.Properties.FirstOrDefault(x => x.Name.Equals(nameof(NotesComponents.WarrantyInformation), StringComparison.OrdinalIgnoreCase))?.Value;
      component.InternalNotes = arg.Properties.FirstOrDefault(x => x.Name.Equals(nameof(NotesComponents.InternalNotes), StringComparison.OrdinalIgnoreCase))?.Value;
 
      // Persist changes
      this._commerceCommander.Pipeline<IPersistEntityPipeline>().Run(new PersistEntityArgument(entity), context);
 
      return Task.FromResult(arg);
    }
  }
}

Depending on the data that you are handling here, it is highly recommended that user input is validated before persisting any changes.

You now have two blocks in place that can render content in the business tools and persist changes to the entities.

In order to allow users to edit the view content from within the business tool, you must populate the view actions:

namespace Plugin.Sample.Notes
{
  using System;
  using System.Threading.Tasks;
  using Sitecore.Commerce.Core;
  using Sitecore.Commerce.EntityViews;
  using Sitecore.Framework.Conditions;
  using Sitecore.Framework.Pipelines;
 
  [PipelineDisplayName(NotesConstants.Pipelines.Blocks.PopulateNotesActionsBlock)]
  public class PopulateNotesActionsBlock : PipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
  {
    public override Task<EntityView> Run(EntityView arg, CommercePipelineExecutionContext context)
    {
      Condition.Requires(arg).IsNotNull($"{Name}: The argument cannot be null.");
      var viewsPolicy = context.GetPolicy<KnownNotesViewsPolicy>();
 
      if (string.IsNullOrEmpty(arg?.Name) || !arg.Name.Equals(viewsPolicy.Notes, StringComparison.OrdinalIgnoreCase))
      {
        return Task.FromResult(arg);
      }
 
      var actionPolicy = arg.GetPolicy<ActionsPolicy>();
 
      actionPolicy.Actions.Add(
        new EntityActionView
        {
          Name = context.GetPolicy<KnownNotesActionsPolicy>().EditNotes,
          DisplayName = "Edit Sellable Item Notes",
          Description = "Edits the sellable item notes",
          IsEnabled = true,
          EntityView = arg.Name,
          Icon = "edit"
        });
 
      return Task.FromResult(arg);
    }
  }
}

In the last step, all the different blocks wire into their respective pipelines. The pipeline configuration takes place in the ConfigureSitecore class of your plugin:

namespace Plugin.Sample.Notes
{
  using System.Reflection;
  using Microsoft.Extensions.DependencyInjection;
  using Sitecore.Commerce.Core;
  using Sitecore.Commerce.EntityViews;
  using Sitecore.Commerce.Plugin.Catalog;
  using Sitecore.Framework.Configuration;
  using Sitecore.Framework.Pipelines.Definitions.Extensions;
 
  public class ConfigureSitecore : IConfigureSitecore
  {
    public void ConfigureServices(IServiceCollection services)
    {
      var assembly = Assembly.GetExecutingAssembly();
      services.RegisterAllPipelineBlocks(assembly);
 
        services.Sitecore().Pipelines(config => config
          .ConfigurePipeline<IGetEntityViewPipeline>(c =>
          {
            c.Add<GetNotesViewBlock>().After<GetSellableItemDetailsViewBlock>();
          })
          .ConfigurePipeline<IPopulateEntityViewActionsPipeline>(c =>
          {
            c.Add<PopulateNotesActionsBlock>().After<InitializeEntityViewActionsBlock>();
          })
          .ConfigurePipeline<IDoActionPipeline>(c =>
          {
            c.Add<DoActionEditNotesBlock>().After<ValidateEntityVersionBlock>();
          })
      );
    }
  }
}

If you want to make your new properties available in your Storefront and you handled the Connect-specific views as mentioned above, open the Content Editor of your Sitecore XP instance and perform the following steps:

  1. Refresh the Sitecore Experience Commerce cache:
    1. Click the "Commerce" tab.
    2. On the ribbon, click "Refresh Commerce Cache".
  2. Refresh data templates:
    1. On the "Commerce" tab, on the ribbon, click "Delete Data Templates".
    2. Then, click "Update Data Templates".
  3. Perform publishing after updating the data templates.