Extend Sitecore Experience Commerce SellableItem Schema (Product_Variant) in 9 Update 2 (9.0.2)

In this post we will look at how to extend a SellableItem’s Schema, in this case a Variant in Sitecore Experience Commerce 9.0.2. There are many ways to extend a SellableItem but this one concentrates on how to do it via code. With Sitecore Commerce, moving data between instances is challenging and to avoid this, a code based solution makes a lot of sense.

Before we begin, we need to look into the following pipelines as we will need to tap into them for our customizations:

IGetEntityViewPipeline – Creates the Entity View needed to display and Edit your content
IPopulateEntityViewActionsPipeline – Add an action to the Entity View like Edit
IDoActionPipeline – Save the data from the Entity View

1. Create a new plugin project and add it to your engine solution.

Your project structure should look similar to the following:

2. Add the NuGet reference for Sitecore.Commerce.Plugin.Catalog.
3. Add your project reference to the Sitecore.Commerce.Engine project.
4. Clean out all the sample classes created by the new project plugin.

5. Lets add a component “VariantSpecifications”.

    1. namespace Plugin.Konabos.ExtendSchema.Components
    2. {
    3. using Sitecore.Commerce.Core;
    4. using System.ComponentModel.DataAnnotations;
    5.  
    6. public class VariantSpecifications : Component
    7. {
    8. [Display(Name = "Number of Friends")]
    9. public int NoOfFriends { get; set; } = 0;
    10.  
    11. [Display(Name = "Application Approved")]
    12. public bool ApplicationApproved { get; set; } = false;
    13.  
    14. public string SomeText { get; set; } = string.Empty;
    15.  
    16. public bool SomeBool { get; set; } = false;
    17. }
    18. }

6. Next lets add a constants class to store Pipeline Display Names.

    1. namespace Plugin.Konabos.ExtendSchema
    2. {
    3. public static class ExtendSchemaConstants
    4. {
    5. public static class Pipelines
    6. {
    7. public static class Blocks
    8. {
    9. public const string DoActionEditVariantSpecificationBlock = "Konabos.catalog.block.doactioneditvariantspecificationblock";
    10. public const string GetVariantSpecificationViewBlock = "Konabos.catalog.block.getvariantspecificationviewblock";
    11. public const string PopulateVariantSpecificationActionsBlock = "Konabos.catalog.block.populatevariantspecificationactionsblock";
    12. }
    13. }
    14. }
    15. }

7. Lets add policies to define the strings we need for the view and the actions.

    1. namespace Plugin.Konabos.ExtendSchema.Policies
    2. {
    3. using Sitecore.Commerce.Core;
    4. public class KnownVariantSpecificationsViewsPolicy : Policy
    5. {
    6. public string Settings { get; set; } = "VariantSpecifications";
    7. public string DisplayName { get; set; } = "Variant Specifications";
    8. }
    9. }
    10.  
    11.  
    12. namespace Plugin.Konabos.ExtendSchema.Policies
    13. {
    14. using Sitecore.Commerce.Core;
    15. public class KnownVariantSpecificationsActionsPolicy:Policy
    16. {
    17. public string EditSettings { get; set; } = nameof(EditSettings);
    18. public string DisplayName { get; set; } = "Edit Variant Specifications";
    19. public string Description { get; set; } = "Edits the Variant Specification";
    20. public string Icon { get; set; } = "edit";
    21. }
    22. }

8. Add an extensions class to grab the display attributes for properties.

    1. namespace Plugin.Konabos.ExtendSchema.Components
    2. {
    3. using System.ComponentModel.DataAnnotations;
    4. using System.Linq;
    5.  
    6. public static class AnnotationExtensionMethods
    7. {
    8. public static string GetDisplayName(this object instance, string propertyName)
    9. {
    10. var attrType = typeof(DisplayAttribute);
    11. var property = instance.GetType().GetProperty(propertyName);
    12. var displayAttributes = (DisplayAttribute)property.GetCustomAttributes(attrType, false).First();
    13.  
    14. if (displayAttributes == null)
    15. return propertyName;
    16. else
    17. return displayAttributes.Name;
    18. }
    19. }
    20. }

9. Add a Pipeline Block to add a view action.

    1. namespace Plugin.Konabos.ExtendSchema.Pipelines.Blocks.EntityViews
    2. {
    3. using System;
    4. using System.Threading.Tasks;
    5. using Plugin.Konabos.ExtendSchema.Policies;
    6. using Sitecore.Commerce.Core;
    7. using Sitecore.Commerce.EntityViews;
    8. using Sitecore.Framework.Conditions;
    9. using Sitecore.Framework.Pipelines;
    10.  
    11. [PipelineDisplayName(ExtendSchemaConstants.Pipelines.Blocks.PopulateVariantSpecificationActionsBlock)]
    12. public class PopulateVariantSpecificationsActionsBlock : PipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
    13. {
    14. public override Task<EntityView> Run(EntityView arg, CommercePipelineExecutionContext context)
    15. {
    16. Condition.Requires(arg).IsNotNull($"{Name}: The argument cannot be null.");
    17.  
    18. var viewsPolicy = context.GetPolicy<KnownVariantSpecificationsViewsPolicy>();
    19.  
    20. if (string.IsNullOrEmpty(arg?.Name) ||
    21. !arg.Name.Equals(viewsPolicy.Settings, StringComparison.OrdinalIgnoreCase))
    22. {
    23. return Task.FromResult(arg);
    24. }
    25.  
    26. var actionPolicy = arg.GetPolicy<ActionsPolicy>();
    27.  
    28. actionPolicy.Actions.Add(
    29. new EntityActionView
    30. {
    31. Name = context.GetPolicy<KnownVariantSpecificationsActionsPolicy>().EditSettings,
    32. DisplayName = context.GetPolicy<KnownVariantSpecificationsActionsPolicy>().DisplayName,
    33. Description = context.GetPolicy<KnownVariantSpecificationsActionsPolicy>().Description,
    34. IsEnabled = true,
    35. EntityView = arg.Name,
    36. Icon = context.GetPolicy<KnownVariantSpecificationsActionsPolicy>().Icon
    37. });
    38.  
    39. return Task.FromResult(arg);
    40. }
    41. }
    42. }

10. Add a Pipeline Block to Display the views for Edit and non-edit mode.

    1. namespace Plugin.Konabos.ExtendSchema.Pipelines.Blocks.EntityViews
    2. {
    3. using System;
    4. using System.Threading.Tasks;
    5. using Plugin.Konabos.ExtendSchema.Components;
    6. using Plugin.Konabos.ExtendSchema.Policies;
    7. using Sitecore.Commerce.Core;
    8. using Sitecore.Commerce.EntityViews;
    9. using Sitecore.Commerce.Plugin.Catalog;
    10. using Sitecore.Framework.Conditions;
    11. using Sitecore.Framework.Pipelines;
    12.  
    13. [PipelineDisplayName(ExtendSchemaConstants.Pipelines.Blocks.GetVariantSpecificationViewBlock)]
    14. public class GetVariantSpecificationsViewBlock : PipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
    15. {
    16. private readonly ViewCommander _viewCommander;
    17.  
    18. public GetVariantSpecificationsViewBlock(ViewCommander viewCommander)
    19. {
    20. this._viewCommander = viewCommander;
    21. }
    22.  
    23. public override Task<EntityView> Run(EntityView arg, CommercePipelineExecutionContext context)
    24. {
    25. Condition.Requires(arg).IsNotNull($"{Name}: The argument cannot be null.");
    26.  
    27. var request = this._viewCommander.CurrentEntityViewArgument(context.CommerceContext);
    28.  
    29. var catalogViewsPolicy = context.GetPolicy<KnownCatalogViewsPolicy>();
    30.  
    31. var settingsViewsPolicy = context.GetPolicy<KnownVariantSpecificationsViewsPolicy>();
    32. var settingsActionsPolicy = context.GetPolicy<KnownVariantSpecificationsActionsPolicy>();
    33.  
    34. var isVariationView = request.ViewName.Equals(catalogViewsPolicy.Variant, StringComparison.OrdinalIgnoreCase);
    35.  
    36. // Make sure that we target the correct views
    37. if (string.IsNullOrEmpty(request.ViewName) ||
    38. !request.ViewName.Equals(catalogViewsPolicy.Variant, StringComparison.OrdinalIgnoreCase) &&
    39. !request.ViewName.Equals(settingsViewsPolicy.Settings, StringComparison.OrdinalIgnoreCase))
    40. {
    41. return Task.FromResult(arg);
    42. }
    43.  
    44. // Only proceed if the current entity is a sellable item
    45. if (!(request.Entity is SellableItem))
    46. {
    47. return Task.FromResult(arg);
    48. }
    49.  
    50. var sellableItem = (SellableItem)request.Entity;
    51.  
    52. // See if we are dealing with the base sellable item or one of its variations.
    53. var variationId = string.Empty;
    54. if (isVariationView && !string.IsNullOrEmpty(arg.ItemId))
    55. {
    56. variationId = arg.ItemId;
    57. }
    58.  
    59. var targetView = arg;
    60.  
    61. // Check if the edit action was requested
    62. var isEditView = !string.IsNullOrEmpty(arg.Action) && arg.Action.Equals(settingsActionsPolicy.EditSettings, StringComparison.OrdinalIgnoreCase);
    63. if (!isEditView)
    64. {
    65. // Create a new view and add it to the current entity view.
    66. var view = new EntityView
    67. {
    68. Name = context.GetPolicy<KnownVariantSpecificationsViewsPolicy>().Settings,
    69. DisplayName = context.GetPolicy<KnownVariantSpecificationsViewsPolicy>().DisplayName,
    70. EntityId = arg.EntityId,
    71. ItemId = String.Empty
    72. };
    73.  
    74. arg.ChildViews.Add(view);
    75.  
    76. targetView = view;
    77. }
    78.  
    79. //in the edit more, prepare the view
    80. if (sellableItem != null && (sellableItem.HasComponent<VariantSpecifications>(variationId) || isEditView))
    81. {
    82. var component = sellableItem.GetComponent<VariantSpecifications>(variationId);
    83. AddPropertiesToView(targetView, component, !isEditView);
    84. }
    85.  
    86. return Task.FromResult(arg);
    87. }
    88.  
    89. private void AddPropertiesToView(EntityView entityView, VariantSpecifications component, bool isReadOnly)
    90. {
    91. entityView.Properties.Add(
    92. new ViewProperty
    93. {
    94. Name = nameof(VariantSpecifications.NoOfFriends),
    95. RawValue = component.NoOfFriends,
    96. IsReadOnly = isReadOnly,
    97. IsRequired = false,
    98. OriginalType = "int",
    99. DisplayName = component.GetDisplayName(nameof(VariantSpecifications.NoOfFriends))
    100. });
    101.  
    102. entityView.Properties.Add(
    103. new ViewProperty
    104. {
    105. Name = nameof(VariantSpecifications.ApplicationApproved),
    106. RawValue = component.ApplicationApproved,
    107. IsReadOnly = isReadOnly,
    108. IsRequired = false,
    109. DisplayName = component.GetDisplayName(nameof(VariantSpecifications.ApplicationApproved))
    110. });
    111.  
    112. entityView.Properties.Add(
    113. new ViewProperty
    114. {
    115. Name = nameof(VariantSpecifications.SomeText),
    116. RawValue = component.SomeText,
    117. IsReadOnly = isReadOnly,
    118. IsRequired = false,
    119. OriginalType = "string"
    120. });
    121.  
    122. entityView.Properties.Add(
    123. new ViewProperty
    124. {
    125. Name = nameof(VariantSpecifications.SomeBool),
    126. RawValue = component.SomeBool,
    127. IsReadOnly = isReadOnly,
    128. IsRequired = false,
    129. OriginalType = "bool"
    130. });
    131. }
    132. }
    133. }

11. Add a Pipeline Block to save the information from the Edit view.

    1. namespace Plugin.Konabos.ExtendSchema.Pipelines.Blocks.DoActions
    2. {
    3. using System;
    4. using System.Linq;
    5. using System.Threading.Tasks;
    6. using Plugin.Konabos.ExtendSchema.Components;
    7. using Plugin.Konabos.ExtendSchema.Policies;
    8. using Sitecore.Commerce.Core;
    9. using Sitecore.Commerce.EntityViews;
    10. using Sitecore.Commerce.Plugin.Catalog;
    11. using Sitecore.Framework.Conditions;
    12. using Sitecore.Framework.Pipelines;
    13.  
    14. [PipelineDisplayName(ExtendSchemaConstants.Pipelines.Blocks.DoActionEditVariantSpecificationBlock)]
    15. public class DoActionEditVariantSpecificationsBlock : PipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
    16. {
    17. private readonly CommerceCommander _commerceCommander;
    18.  
    19. public DoActionEditVariantSpecificationsBlock(CommerceCommander commerceCommander)
    20. {
    21. this._commerceCommander = commerceCommander;
    22. }
    23.  
    24. public override Task<EntityView> Run(EntityView arg, CommercePipelineExecutionContext context)
    25. {
    26. Condition.Requires(arg).IsNotNull($"{Name}: The argument cannot be null.");
    27.  
    28. var specsActionsPolicy = context.GetPolicy<KnownVariantSpecificationsActionsPolicy>();
    29.  
    30. // Only proceed if the right action was invoked
    31. if (string.IsNullOrEmpty(arg.Action) || !arg.Action.Equals(specsActionsPolicy.EditSettings, StringComparison.OrdinalIgnoreCase))
    32. {
    33. return Task.FromResult(arg);
    34. }
    35.  
    36. // Get the sellable item from the context
    37. var entity = context.CommerceContext.GetObject<SellableItem>(x => x.Id.Equals(arg.EntityId));
    38. if (entity == null)
    39. {
    40. return Task.FromResult(arg);
    41. }
    42.  
    43. // Get the specs component from the sellable item or its variation
    44. var component = entity.GetComponent<VariantSpecifications>(arg.ItemId);
    45.  
    46. // Map entity view properties to component
    47. ViewProperty viewProperty = arg.Properties.FirstOrDefault(x => x.Name.Equals(nameof(VariantSpecifications.NoOfFriends), StringComparison.OrdinalIgnoreCase));
    48. int tempIntValue = 0;
    49. bool tempBoolValue = false;
    50. if (!string.IsNullOrEmpty(viewProperty?.Value) && int.TryParse(viewProperty.Value, out tempIntValue))
    51. component.NoOfFriends = tempIntValue;
    52.  
    53. viewProperty = arg.Properties.FirstOrDefault(x => x.Name.Equals(nameof(VariantSpecifications.ApplicationApproved), StringComparison.OrdinalIgnoreCase));
    54. if (!string.IsNullOrEmpty(viewProperty?.Value) && bool.TryParse(viewProperty.Value, out tempBoolValue))
    55. component.ApplicationApproved = tempBoolValue;
    56.  
    57. component.SomeText =
    58. arg.Properties.FirstOrDefault(x =>
    59. x.Name.Equals(nameof(VariantSpecifications.SomeText), StringComparison.OrdinalIgnoreCase))?.Value;
    60.  
    61. viewProperty = arg.Properties.FirstOrDefault(x => x.Name.Equals(nameof(VariantSpecifications.SomeBool), StringComparison.OrdinalIgnoreCase));
    62. if (!string.IsNullOrEmpty(viewProperty?.Value) && bool.TryParse(viewProperty.Value, out tempBoolValue))
    63. component.SomeBool = tempBoolValue;
    64.  
    65. // Persist changes
    66. this._commerceCommander.Pipeline<IPersistEntityPipeline>().Run(new PersistEntityArgument(entity), context);
    67.  
    68. return Task.FromResult(arg);
    69. }
    70. }
    71. }

12. Finally lets tie it all together and finish the configurations in the ConfigureSitecore.cs file.

    1. public class ConfigureSitecore : IConfigureSitecore
    2. {
    3. /// <summary>
    4. /// The configure services.
    5. /// </summary>
    6. /// <param name="services">
    7. /// The services.
    8. /// </param>
    9. public void ConfigureServices(IServiceCollection services)
    10. {
    11. var assembly = Assembly.GetExecutingAssembly();
    12. services.RegisterAllPipelineBlocks(assembly);
    13.  
    14. services.Sitecore().Pipelines(config =>
    15. config
    16. .ConfigurePipeline<IGetEntityViewPipeline>(c =>
    17. {
    18. c.Add<GetVariantSpecificationsViewBlock>().After<GetSellableItemDetailsViewBlock>();
    19. })
    20. .ConfigurePipeline<IPopulateEntityViewActionsPipeline>(c =>
    21. {
    22. c.Add<PopulateVariantSpecificationsActionsBlock>().After<InitializeEntityViewActionsBlock>();
    23. })
    24. .ConfigurePipeline<IDoActionPipeline>(c =>
    25. {
    26. c.Add<DoActionEditVariantSpecificationsBlock>().After<ValidateEntityVersionBlock>();
    27. })
    28. );
    29.  
    30. services.RegisterAllCommands(assembly);
    31. }
    32. }

Rebuild and deploy your commerce engine. Once that is done, Bootstrap and load up the Business tools.

Open a Product, add a new Entity Version, select the new Entity Version and click on the Variant.

Once the Variant opens, you can see the Variant Specifications section, click on the edit button.

As you can see one of our Boolean fields shows up as a checkbox and the other does not. This is because we specified OriginalType = “bool” on the SomeBool field. If we remove that, the Entity View figures out the field type and renders a checkbox.

Lets recompile and deploy code after removing OriginalType = “bool”. The following screenshot shows the difference.

And that is it. Remember we are doing this via code so when a new developer run this on his instance, the schema for the Variant is defined by the code and the EntityViews.

If you have any questions or concerns, please get in touch with me. (@akshaysura13 on twitter or on Slack).

Links:
Big shout out to Sergey Chmelev, he is a Commerce superstar!