Sitecore Experience Commerce: Resolving Site-Catalog Association Lock

Reading Time: 2 minutes

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.

Sitecore: Using a Dedicated Custom Include Folder for Actual Custom Configuration Files

Reading Time: 2 minutes

In this article, we will look at creating our own custom include folder in Sitecore to avoid the ugliness of the folder/file structure that comes with a Sitecore installation and all of its modules.

Include folder contents from default installation of Sitecore with Sitecore XC 9.3

Previously, I covered Managing Commerce Configuration to Align with Helix Principles, however this approach was overly complex and not really developer friendly. Since then I have concluded that the simpler approach would be leave the “custom” folder for the Sitecore integrations and create a dedicated custom folder outside of the “custom” for solution specific custom configurations.

Added benefits to this approach include:

  • When performing an upgrade, a new or updated configuration will not override your custom configurations based on load order.
  • You won’t need to revisit the Layers.config file as potentially required with my previously documented approach.
  • For local development, you can comfortablely delete the dedicated custom folder and redeploy, avoiding any stale custom configurations from your current build.

Configuring Layers.config

As the order of the layer nodes in the Layers.confg defines the order in which they load at runtime, all that is required for this approach is to add the new custom folder to Layers.config after the “Custom” layer and ensure your custom solution configuration files are placed in the App_Config/Custom_Include folder within the projects.

...
<layer name="Custom" includeFolder="/App_Config/Include/">
  <loadOrder>
   <add path="Foundation" type="Folder" />
   <add path="Feature" type="Folder" />
   <add path="Project" type="Folder" />
  </loadOrder>
</layer>
<layer name="Custom_Include" includeFolder="/App_Config/Custom_Include/">
  <loadOrder>
    <add path="Foundation" type="Folder" />
    <add path="Feature" type="Folder" />
    <add path="Project" type="Folder" />
  </loadOrder>
</layer>
...

Add the custom folder after the “Custom” layer entry and include the “Foundation”, “Feature”, and “Project” folders to manage configuration files using Helix principles.

Custom Include folder contents segregated from the Include folder of the default installation of Sitecore with Sitecore XC 9.3

Summary

No longer will you need to fight for configuration priority over the platform’s standard configuration files or soil your solution with ‘zzz’ prefixed patch files.

Configuring and Customising SEO Friendly URLs in Sitecore Commerce SXA Storefront

Reading Time: 4 minutes

In this article, we will look at the configuration and customisation options available for manipulating URLs in the Sitecore Commerce Storefront.

The goal will be to determine how to manipulate the URLs so that they are more SEO-friendly as represented by the following URL structures.

  • https://{domain}/category/{1st level category name}
  • https://{domain}/category/{1st level category name}/{2nd level category name}
  • https://{domain}/category/{1st level category name}/{2nd level category name}/{3rd level category name}
  • https://{domain}/product/{product id}

This would translate to the following examples

  • https://sxa.storefront.com/category/computers-and-tablets
  • https://sxa.storefront.com/category/computers-and-tablets/kids-tablets
  • https://sxa.storefront.com/category/computers-and-tablets/kids-tablets/boys-tablets
  • https://sxa.storefront.com/product/6042221

Throughout this article we will utilise the category Computers and Tablets > Kid’s Tablets and product Minnow Kid’s Tablet—7”, 8GB to review our progress. These examples also contain some non-alphanumeric characters to ensure we take these special characters into consideration.

How Storefront URLs are Generated

Storefront URLs are constructed using the configuration of the site’ s linkManager. The configuration is located at sitecore/linkManager/providers/add[@name=’commerce‘].

Configuration Properties

An important note about the provider configuration properties is that only 3 properties actually affect the generated URLs – includeFriendlyName, useShopLinks, and encodeNames.

  • includeFriendlyName: Includes the DisplayName of the category or product in the URL segment. i.e. {category DisplayName}={category FriendlyId} and {product DisplayName}={ProductId/FriendlyId}.
  • useShopLinks: Constructs URL with shop/{category}/{product} if enabled, otherwise as category/{category} and product/{product} for category and product URLs respectively.
  • includeCatalog: Not currently supported
  • addAspxExtension: N/A
  • alwaysIncludeServerUrl: N/A
  • encodeNames: Encodes the DisplayName portion of the category and product segments. Only supported when useShopLinks is true.
  • languageEmbedding: N/A
  • languageLocation: N/A
  • lowercaseUrls: Not currently supported
  • shortenUrls: Not currently supported
  • useDisplayName: Not currently supported

URLs Generated from Various Configurations

The following decision table shows the available configurations

 Rules
Conditions12345
useShopLinksYYYNN
includeFriendlyNameYYNYN
encodeNamesYN Y 
Actions12345
ShopXXX  
Product/Category   XX
Display Name prefixXX X 
Display Name encodingX  X 

The following table shows the URLs generated from the rules in the above table.

#PageURL
1Categoryhttps://sxa.storefront.com/shop/Kid%E2%80%99sTablets%3dhabitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/shop/Kid%E2%80%99sTablets%3dhabitat_master-kid%20s%20tablets/MinnowKid%E2%80%99sTablet%E2%80%947%E2%80%9D%2C8GB%3d6042221
2Categoryhttps://sxa.storefront.com/shop/Kid’sTablets%3dhabitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/shop/Kid’sTablets%3dhabitat_master-kid%20s%20tablets/MinnowKid’sTablet—7”%2C8GB%3d6042221
3Categoryhttps://sxa.storefront.com/shop/habitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/shop/habitat_master-kid%20s%20tablets/6042221
4Categoryhttps://sxa.storefront.com/category/Kid’sTablets%3dhabitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/product/MinnowKid’sTablet—7”%2C8GB%3d6042221
5Categoryhttps://sxa.storefront.com/category/habitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/product/6042221

In reviewing the results of the configurations above, rule set 5 creates the desired product URL structure we set out to accomplish.

Now we will focus on customising the website solution to generate our catalog URL structure.

Customising the Solution to Complete the SEO-Friendly URLs

Updating the CatalogUrlManager

We will need to override the BuildCategoryLink method of the CatalogUrlManager class to apply the following customisations:-

  • Generate the category hierarchy (or breadcrumb)
  • Remove the catalog name from the category’s Friendly Id.
public override string BuildCategoryLink(Item item, bool includeCatalog, bool includeFriendlyName)
{
    return BuildBreadcrumbCategoryUrl(item, includeCatalog, includeFriendlyName, CatalogFoundationConstants.Routes.CategoryUrlRoute);
}

protected virtual string BuildBreadcrumbCategoryUrl(Item item, bool includeCatalog, bool includeFriendlyName, string root)
{
    Assert.ArgumentNotNull(item, nameof(item));

    string catalogName = ExtractCatalogName(item, includeCatalog);
    var categoryBreadcrumbList = GetCategoryBreadcrumbList(item);

    return BuildBreadcrumbCategoryUrl(categoryBreadcrumbList, includeFriendlyName, catalogName, root);
}

protected virtual string BuildBreadcrumbCategoryUrl(List<Item> categories, bool includeFriendlyName, string catalogName, string root)
{
    Assert.ArgumentNotNull(categories, nameof(categories));

    var stringBuilder = new StringBuilder("/");
    if (IncludeLanguage)
    {
        stringBuilder.Append(Context.Language.Name);
        stringBuilder.Append("/");
    }

    if (!string.IsNullOrEmpty(catalogName))
    {
        stringBuilder.Append(EncodeUrlToken(catalogName, true));
        stringBuilder.Append("/");
    }
    stringBuilder.Append(root);

    var itemName = string.Empty;
    var itemFriendlyName = string.Empty;
    foreach (var category in categories)
    {
        stringBuilder.Append("/");
        ExtractCatalogItemInfo(category, includeFriendlyName, out itemName, out itemFriendlyName);
        if (!string.IsNullOrEmpty(itemFriendlyName))
        {
            stringBuilder.Append(EncodeUrlToken(itemFriendlyName, true));
            stringBuilder.Append(UrlTokenDelimiterEncoded);
        }

        itemName = RemoveCatalogFromItemName(root, itemName);
        stringBuilder.Append(EncodeUrlToken(itemName, false));
    }

    return StorefrontContext.StorefrontUri(stringBuilder.ToString()).Path;
}

protected virtual List<Item> GetCategoryBreadcrumbList(Item item)
{
    var categoryBreadcrumbList = new List<Item>();
    var startNavigationCategoryID = StorefrontContext.CurrentStorefront.GetStartNavigationCategory();

    while (item.ID != startNavigationCategoryID)
    {
        categoryBreadcrumbList.Add(item);
        item = item.Parent;
    }
    categoryBreadcrumbList.Reverse();

    return categoryBreadcrumbList;
}

protected virtual string RemoveCatalogFromItemName(string root, string itemName)
{
    if (root == CatalogFoundationConstants.Routes.CategoryUrlRoute)
    {
        var tokens = itemName.Split('-');
        if (tokens.Length > 1)
        {
            itemName = tokens[1];
        }
    }

    return itemName;
}

protected override void ExtractCatalogItemInfo(Item item, bool includeFriendlyName, out string itemName, out string itemFriendlyName)
{
    base.ExtractCatalogItemInfo(item, includeFriendlyName, out itemName, out itemFriendlyName);
    var parentItemName = item.Parent.Name.ToLowerInvariant();
    if (itemName.StartsWith(parentItemName))
    {
        itemName = itemName.Substring(parentItemName.Length);
    }
}

Category URL: https://sxa.storefront.com/category/computers%20and%20tablets/kid%20s%20tablets

The URLs generated are looking good. We do have an issue with the URLs still containing encoded spaces. To control character encoding there is a configuration in the content editor at /sitecore/Commerce/Commerce Control Panel/Storefront Settings/Global Configuration > URL Encoding > Catalog Item Encoding.

Unfortunately there is a quirk in which it doesn’t accept space entries, so we will override the EncodeUrlToken and DecodeUrlToken methods instead. As we aren’t allowed to have hyphens in the category names, we won’t have any character conflicts where encoding or decoding.

protected override string EncodeUrlToken(string urlToken, bool removeInvalidPathCharacters)
{
    if (!string.IsNullOrEmpty(urlToken))
    {
        if (removeInvalidPathCharacters)
        {
            foreach (string invalidPathCharacter in _invalidPathCharacters)
            {
                urlToken = urlToken.Replace(invalidPathCharacter, string.Empty);
            }
        }
        EncodingTokenList.ForEach(t => urlToken = urlToken.Replace(t.Delimiter, t.EncodedDelimiter));
        urlToken = urlToken.Replace(' ', '-');
        urlToken = Uri.EscapeDataString(urlToken).Replace(UrlTokenDelimiter, EncodedDelimiter);
    }

    return urlToken;
}

protected override string DecodeUrlToken(string urlToken)
{
    if (!string.IsNullOrEmpty(urlToken))
    {
        urlToken = Uri.UnescapeDataString(urlToken).Replace(EncodedDelimiter, UrlTokenDelimiter);
        urlToken = urlToken.Replace('-', ' ');
        EncodingTokenList.ForEach(t => urlToken = urlToken.Replace(t.EncodedDelimiter, t.Delimiter));
    }

    return urlToken;
}

Category URL: https://sxa.storefront.com/category/computers-and-tablets/kid-s-tablets

Now that we have our category URL structure that meets our requirements, our last step is to ensure the URLs are resolving back to their correct Sitecore items.

Updating the CatalogPageItemResolver

Now we have covered the URL generation implementation, we now need to resolve these URLs back to their correct Sitecore items.

public override void Process(PipelineArgs args)
{
	if (Context.Item == null || SiteContext.CurrentCatalogItem != null)
	{
		return;
	}

	var contextItemType = GetContextItemType();
	switch (contextItemType)
	{
		case ItemTypes.Category:
		case ItemTypes.Product:
			var isProduct = contextItemType == ItemTypes.Product;
			var catalogItemIdFromUrl = GetCatalogItemIdFromUrl(isProduct);
			if (string.IsNullOrEmpty(catalogItemIdFromUrl))
			{
				break;
			}
			var catalog = StorefrontContext.CurrentStorefront.Catalog;
			var catalogItem = ResolveCatalogItem(catalogItemIdFromUrl, catalog, isProduct);
			if (catalogItem == null && !isProduct)
			{
				catalogItemIdFromUrl = GetCatalogItemIdFromUrl(true);
				if (string.IsNullOrEmpty(catalogItemIdFromUrl))
				{
					break;
				}
				catalogItem = ResolveCatalogItem(catalogItemIdFromUrl, catalog, isProduct);
			}
			if (catalogItem == null)
			{
				WebUtil.Redirect("~/");
			}

			SiteContext.CurrentCatalogItem = catalogItem;
		break;
	}
}

private string GetCatalogItemIdFromUrl(bool isProduct)
{
    var catalogItemId = string.Empty;
    var rawUrl = HttpContext.Current.Request.RawUrl;
    var urlTokens = rawUrl.Split('/');
    if (urlTokens.Any())
    {
        var item = urlTokens.Last();
        var queryStringPosition = item.IndexOf("?", StringComparison.OrdinalIgnoreCase);
        if (queryStringPosition > 0)
        {
            item = item.Substring(0, queryStringPosition);
        }

        if (isProduct && urlTokens.Length >= 4)
        {
            var parentCategoryName = urlTokens[urlTokens.Length - 2];
            item = $"{parentCategoryName}{item}";
        }
        catalogItemId = CatalogUrlManager.ExtractItemId(item);
    }

    return catalogItemId;
}

Summary

We learnt that the construction of the URL can be managed via Sitecore Configuration, the Sitecore Content Editor and via code customisations, depending on the URL requirements.

Source Code: Ajsuth.Foundation.Catalog

Managing Commerce Configuration to Align with Helix Principles

Reading Time: 3 minutes

In this article, we will review a sample configuration of the layers.config file to better manage your solution’s configuration files when working with Sitecore Experience Commerce 9 (XC9).

Deprecated Article: See Sitecore: Using a Dedicated Custom Include Folder for Actual Custom Configuration Files for a more streamline approach to this problem.

If you take a look at an XC9 website’s /App_Config/Include folder, you would have noticed that there’s still some chaos happening with folder and file prefixes of ‘Y’ and ‘Z’ to assign priority order to the patch configuration files. On top of this, you may have even previous worked on projects where there have been crazy levels of prefixing ‘z’, ‘zz’, ‘zzzzz’, etc. to folders and files (I know I have seen some crazy levels of this), leading to developers pulling their hair out trying to locate the file’s origin. As chaotic as this is, we will look at how we can bring back order for our solution.

Configuring layers.config

Our intention is to ensure that our solution’s patch config files are always in a position to override the platform’s patch files, while adhering to the Helix layers principle.

With the following approach, the only additional consideration introduced is to review the platform’s custom config folders and files during any future upgrades to update the layers.config file to ensure the intended load order remains in tact.

Note: The following way is just one of many that can be implemented to achieve the same outcome. There is no one right way, so if you manage your solution’s configuration files differently there is no need to align to this.

  1. Backup the layers.config. Always important when you plan on modifying any files that are not part of your project solution (as infrequent as this should be).
  2. Copy the layers.config file out of the App_Config folder into a core project in the same location. This is because the layers.config file itself cannot be patched and we would want this file version controlled so that the file can be deployed to all environments, and modified (if required) by our colleagues.
  3. The layers.config file is then updated to specify the load order for all current folders. While this step is a bit of a pain, as any omissions will mean that any folder/files unspecified will be applied after the loadOrder , it allows us to ensure that all of our solution configuration files will be applied last, having the highest level of priority.
    <layer name="Custom" includeFolder="/App_Config/Include/">
      <loadOrder>
        <add path="Cognifide.PowerShell.config" type="File" />
        <add path="Sitecore.Commerce.Carts.config" type="File" />
        <add path="Sitecore.Commerce.Catalogs.config" type="File" />
        <add path="Sitecore.Commerce.config" type="File" />
        <add path="Sitecore.Commerce.Customers.config" type="File" />
        <add path="Sitecore.Commerce.GiftCards.config" type="File" />
        <add path="Sitecore.Commerce.Globalization.config" type="File" />
        <add path="Sitecore.Commerce.Inventory.config" type="File" />
        <add path="Sitecore.Commerce.LoyaltyPrograms.config" type="File" />
        <add path="Sitecore.Commerce.Orders.config" type="File" />
        <add path="Sitecore.Commerce.Payments.config" type="File" />
        <add path="Sitecore.Commerce.Prices.config" type="File" />
        <add path="Sitecore.Commerce.Shipping.config" type="File" />
        <add path="Sitecore.Commerce.WishLists.config" type="File" />
        <add path="z.Cognifide.PowerShell.config" type="File" />
        <add path="ContentTesting" type="Folder" />
        <add path="Examples" type="Folder" />
        <add path="Feature" type="Folder" />
        <add path="Foundation" type="Folder" />
        <add path="Foundation.Overrides" type="Folder" />
        <add path="Project" type="Folder" />
        <add path="Y.Commerce.Engine" type="Folder" />
        <add path="Z.Commerce.Engine" type="Folder" />
        <add path="z.Feature.Overrides" type="Folder" />
        <add path="Z.Foundation" type="Folder" />
        <add path="Z.Foundation.Overrides" type="Folder" />
        <add path="Z.LayoutService" type="Folder" />
      </loadOrder>
    </layer>
  4. We then create three folders, following the Helix principles for our project solution. I just chose to use ‘Helix’ as a prefix to drill the point in.
    1. Helix.Feature
    2. Helix.Foundation
    3. Helix.Project
  5. Add the new folders to the <loadOrder>.
    <layer name="Custom" includeFolder="/App_Config/Include/">
      <loadOrder>
        ...
        <add path="Z.LayoutService" type="Folder" />
        <add path="Helix.Foundation" type="Folder" />
        <add path="Helix.Feature" type="Folder" />
        <add path="Helix.Project" type="Folder" />
      </loadOrder>
    </layer>
  6. As you are developing your solution you may find that you need to set the load order for the individual configuration files within a certain layer. In this case, you can list out the file order required. In the sample below, we have Feature A, Feature B, and Feature C. Feature A has a configuration conflict with Feature B and needs higher priority. Feature C has no conflicts. We only need to specify the config for Feature A after the folder to ensure the correct patch order.
  7. <layer name="Custom" includeFolder="/App_Config/Include/">
      <loadOrder>
        ...
        <add path="Helix.Feature" type="Folder" />
        <add path="Helix.Feature/Feature.A.config" type="File" />
        <add path="Helix.Project" type="Folder" />
      </loadOrder>
    </layer>
  8. Deploy your solution.

Summary

While you could continue down the path of having project with ‘z’, ‘zz’, ‘zzzzz’, etc. prefixes, by taking the initial steps to isolate and manage your solution’s patch files under managed folder and file load order, we bring the chaos back to order with meaningfully named patch files that can be found exactly where you expect them to be located.