Alokai
ReferenceSAP Commerce CloudNormalizers

Facet normalizer

Concept of facets exists in both Unified Data Layer world and SAP. The normalizeFacet function maps SAP Facet into Unified SfFacet.

Parameters

NameTypeDefault valueDescription
contextNormalizerContextContext which contains currentQuery for searchProducts
facetFacetSAP Facet

Extending

The SfFacet is returned from SearchProducts Method. If the SfFacet structure doesn't contain the information you need for your Storefront, you can extend its logic using the addCustomFields API. The following example demonstrates how to extend SfFacet with a multiSelect field.

export const unifiedApiExtension = createUnifiedExtension({
  normalizers: {
    addCustomFields: [
      {
        normalizeFacet: (context, facet) => ({
          multiSelect: facet.multiSelect,
        }),
      },
    ],
  },
  config: {
    ...
  },
});

Source

In the Unified Data Layer, the SfFacet array is expected to return all of the available facets, including the current selected facets. In SAP the selected facets are present in the currentQuery object returned from the searchProducts API Client method. So to retrieve them, we have to extract them from the query value. An explanation of the expected behaviour can be found in the facet.feature file which contains test scenarios.

import { type SfFacetType, SfFacetTypes } from "@alokai/connect";
import type { Facet, FacetValue } from "@vsf-enterprise/sapcc-types";

import { maybe } from "@vsf-enterprise/unified-api-sapcc";

import { defineNormalizer } from "../defineNormalizer";

export const normalizeFacet = defineNormalizer.normalizeFacet((context, facet) => {
const { name: label, values } = facet;
const name = normalizeFacetName(facet);
const selectedValues = getSelectedValues(context.currentQuery?.query?.value ?? "", name);

const sfFacetItems =
  values?.map((term) => ({
    label: term.name as string,
    productCount: maybe(term.count),
    value: normalizeFacetItemValue(term, selectedValues),
  })) || [];

if (sfFacetItems.length === 0) {
  return null;
}
const { getFacetType = defaultGetFacetType } = context;

return {
  label: label as string,
  name: normalizeFacetName(facet),
  type: getFacetType(facet),
  values: sfFacetItems,
};
});

function normalizeFacetName(facet: Facet): string {
let name = facet.name as string;
const nonSelectedValue = facet.values?.find((value) => !value?.selected);
const exampleQueryUrl = nonSelectedValue?.query?.query?.value;

if (exampleQueryUrl) {
  name = exampleQueryUrl.split(":").at(-2) ?? name;
}

return name;
}

function normalizeFacetItemValue(facetValue: FacetValue, selectedValues: string[]): string {
let value = facetValue.name as string;
const queryUrl = facetValue?.query?.query?.value;

if (facetValue.selected) {
  value = guessSelectedFacetItemValue(value, selectedValues);
} else if (queryUrl) {
  value = queryUrl.split(":").at(-1) ?? value;
}

return value;
}

function getSelectedValues(queryValue: string, facetName: string): string[] {
const regex = new RegExp(`:${facetName}:([^:]+)`, "g");
const matches = queryValue.match(regex);
if (matches) {
  return matches.map((match) => match.split(":")?.[2]).filter(Boolean);
}
return [];
}

function guessSelectedFacetItemValue(facetItemName: string, selectedValues: string[]): string {
const facetNameAsUri = encodeURIComponent(facetItemName as string).replace(/%20/g, "+");
const nameLower = facetNameAsUri.toLowerCase();

const exactMatch = selectedValues.find((value) => value.toLowerCase() === nameLower);
if (exactMatch) return exactMatch;

const suffixMatches = selectedValues.filter((value) =>
  value.toLowerCase().endsWith(`-${nameLower}`),
);
if (suffixMatches.length > 0) {
  return suffixMatches.sort((a, b) => a.length - b.length)[0]!;
}

return facetNameAsUri.toUpperCase();
}

function defaultGetFacetType(facet: Facet): SfFacetType {
return facet.multiSelect === false ? SfFacetTypes.SINGLE_SELECT : SfFacetTypes.MULTI_SELECT;
}
Feature: Normalize SfFacet

Scenario: Normalizing a valid Facet object
  Given a valid Facet object with values
  When the normalize function is called
  Then the returned SfFacet object should have the same label and name as the Facet object
  And the SfFacet object should have an array of SfFacetItem objects that match the values in the FacetValue object

Scenario: Normalizing a Facet object without values
  Given a Facet object without values
  When the normalize function is called
  Then the returned null

Scenario: Normalizing a Facet object with undefined values
  Given a Facet object with undefined values
  When the normalize function is called
  Then the returned null

Scenario: Normalizing a Facet object with an empty string as the facet name
  Given a Facet object with an empty string as the facet name
  When the normalize function is called
  Then the returned SfFacet object should have an empty string as the label and name
  And the SfFacet object should have an array of SfFacetItem objects that match the terms in the FacetValue object

Scenario Outline: Facet has a value which has query url and is not selected
  Given a Facet object with a name <name> and one value
  And value has query value of <queryValue>
  When the normalize function is called
  Then the returned SfFacet object should have the name equal to <expectedName>
  And the facet item should have value equal to <expectedValue>

  Examples:

    | name   | queryValue                                              | expectedName | expectedValue |
    | Colour | :relevance:allCategories:collections:swatchColors:BLACK | swatchColors | BLACK         |
    | Size   | :relevance:allCategories:collections:size:4.0+US        | size         | 4.0+US        |
    | Brand  | :relevance:allCategories:collections:brand:Burton       | brand        | Burton        |

Scenario Outline: Facet has a value which has query url and is selected
  Given a Facet object with two values and one of the value is selected
  And current query value is <currentQueryValue>
  And selected value has query value of <selectedValueQuery>
  And selected value has name of <selectedValueName>
  And non selected value has query value of <nonselectedValueQuery>
  When the normalize function is called
  Then the selected facet item should have value equal to <expectedSelectedValue>

  Examples:

    | currentQueryValue                                | nonselectedValueQuery                            | selectedValueQuery                  | selectedValueName | expectedSelectedValue |
    | :swatchColors:RED                                 | :swatchColors:RED:swatchColors:GRAY               |                                     | Red               | RED                   |
    | :brand:Burton:swatchColors:RED:swatchColors:GREY  | :swatchColors:RED:swatchColors:GREY:brand:Volcom  | :swatchColors:RED:swatchColors:GREY | Burton            | Burton                |
    | :swatchColors:RED:size:8.0+US                     | :swatchColors:RED:size:8.0+US:size:7.0            | :swatchColors:RED                   | 8.0 US            | 8.0+US                |
    | :swatchColors:color-black                         | :swatchColors:color-black:swatchColors:color-blue |                                     | Black             | color-black           |
    | :swatchColors:color-black:size:size-10            | :swatchColors:color-black:size:size-10:size:size-8 | :swatchColors:color-black          | 10                | size-10               |

Scenario Outline: Normalizing a Facet should map default facet type
  Given a valid Facet object with multiselect set to <multiselect>
  When the normalize function is called
  Then the returned type equals <expectedType>

  Examples:

    | multiselect | expectedType  |
    | true        | MULTI_SELECT  |
    | false       | SINGLE_SELECT |
    | undefined   | MULTI_SELECT  |


Scenario: Using a custom getFacetType function
  Given a valid Facet object
  And a custom getFacetType function
  When the normalize function is called
  Then the returned type equals custom type

On this page