Developing a custom facet type

November 11, 2022

The section provides details on the technical implementation of a field facet, delivered with the search-provider-elasticsearch-facet module. The field facet shows a reference implementation and acts as a guideline on how to implement a custom facet type.

Understanding the processing flow

The following flowchart provides an overview of the search request processing flow and shows the main steps in the search. The flowchart also shows the Java interfaces and JSP taglib functions that apply to steps.

01-es-field-facet-flow.png

The following flowchart shows how search results and facet data display to users.

02-es-field-facet-flow-display.png

The next sections provide more details on the component and services implementation.

Component definition

The component for the field facet (jnt:esFieldFacet) is defined as follows:

[jnt:esFieldFacet] > jnt:esFacet, jmix:esLabeledFacet, jmix:queryContent
 - j:field (string, choicelist[facetFields]) mandatory indexed=no

The component extends the base jnt:esFacet component, which provides the Maximum number of result groups field and the jmix:esLabeledFacet. The jmix:esLabeledFacet provides two fields for label rendering behavior: Label renderer and Sort by label (alphabetically).
The component also provides a new field j:field that you use to choose which field the faceting applies to. For a better user experience, the dedicated choice list initializer (choicelist[facetFields]) provides a list of possible values for fields in the Create and Edit content engines in the user interface.
Your custom facet component can include additional fields, which could be needed for the component and its implementation.

Services implementation

For the implementation of the facet, there are three service interfaces that are essential for the behavior:

  • org.jahia.modules.elasticsearch.search.facets.FacetDefinitionBuilder
  • org.jahia.modules.elasticsearch.search.facets.FacetHandler
  • org.jahia.modules.elasticsearch.search.facets.ActiveFacetHandler

The implementations of those three interfaces are exposed as OSGi services, which the facet code looks up to process facets of a specific kind.

In the search-provider-elasticsearch-facet module, a single class implements all three interfaces, but is not a requirement. In your implementation, you can have three distinct classes that implement those interfaces.
The service implementation class (FieldFacetHandler) is defined in the Spring bean definition file of the module and is exposed as an OSGi service with above mentioned interfaces as follows:

    <bean id="org.jahia.modules.elasticsearch.search.facet.FieldFacetHandler" class="org.jahia.modules.elasticsearch.search.facet.FieldFacetHandler"/>

    <osgi:service ref="org.jahia.modules.elasticsearch.search.facet.FieldFacetHandler">
        <osgi:interfaces>
            <value>org.jahia.modules.elasticsearch.search.facets.FacetHandler</value>
            <value>org.jahia.modules.elasticsearch.search.facets.FacetDefinitionBuilder</value>
            <value>org.jahia.modules.elasticsearch.search.facets.ActiveFacetHandler</value>
        </osgi:interfaces>    
        <osgi:service-properties>
            <entry key="service.pid" value="org.jahia.services.search.provider.elasticsearch.FieldFacetHandler"/>
            <entry key="service.description" value="Field search facet implementation"/>
            <entry key="service.vendor" value="Jahia Solutions Group SA"/>
        </osgi:service-properties>
    </osgi:service>

 The next sections provide more details on the three interfaces.

FacetDefinitionBuilder

The FacetDefinitionBuilder reads data from the facet component node and converts it into the BaseFacetDefinition facet definition object, which is used throughout the implementation code and contains the “knowledge” about your facet.
Note that your FacetDefinitionBuilder implementation should only handle facets of your type. If the facet type is not handled by it, it should just ignore it and return null, so that framework can continue looking for other builders to process the facet component node.

For the field facet, the implementation looks as follows:


    @Override
    public BaseFacetDefinition buildFacetDefinition(JCRNodeWrapper facetDefinitionNode) {
        try {
            if (!facetDefinitionNode.isNodeType("jnt:esFieldFacet")) {
                // I can only handle field facet definitions
                return null;
            }
            FieldFacetDefinition facetDefinition = new FieldFacetDefinition(facetDefinitionNode.getIdentifier(),
                    facetDefinitionNode.getPropertyAsString("j:field"), getMaxGroups(facetDefinitionNode));
            facetDefinition.setField(DEF_FIELD_VALUE_RENDERER,
                    facetDefinitionNode.getPropertyAsString("j:valueRenderer"));
            facetDefinition.setField(DEF_FIELD_SORT_BY_LABEL, facetDefinitionNode.hasProperty("j:sortByLabel")
                    && facetDefinitionNode.getProperty("j:sortByLabel").getBoolean());
            return facetDefinition;
        } catch (RepositoryException e) {
            throw new JahiaRuntimeException(e);
        }
    }

FacetHandler

For search facets of a specific kind, the FacetHandler interface handles Elasticsearch requests and responses and adds and extracts parts required to implement the facet. Multiple facet handlers make a chain of responsibility inside the ESSearchProvider (this is main service implementation of the Elasticsearch provider in DX) to enable the ESSearchProvider to determine the kind of facets that facet handlers can deal with.
The interface has two methods, addToRequest() and extractFromResponse(), which are invoked before and after a search request is executed in Elasticsearch. This enables the facet handler to add its part of the query and extract results after the query is executed.


    /**
     * Alter the ES request to add parts necessary to implement the facet defined by the facet definition passed (if familiar with the facet definition).
     *
     * @param sourceBuilder ES request builder to alter
     * @param facetDefinition The facet definition
     * @param nestedNodesFilter A filter that operates on the nested nodes to make sure we only get facet results for nodes that match the filter
     * @return An info object containing data necessary to find appropriate results in the ES response later if familiar with the facet definition, null otherwise
     */
    Object addToRequest(SearchSourceBuilder sourceBuilder, SearchCriteria.BaseFacetDefinition facetDefinition, QueryBuilder nestedNodesFilter);

    /**
     * Extract result groups from the ES response.
     *
     * @param esResponse The ES response
     * @param facetInfo The facet info object retrieved from the addToRequest method previously
     * @return Result groups extracted
     */
    List<SearchResponse.Facet> extractFromResponse(org.elasticsearch.action.search.SearchResponse esResponse, Object facetInfo);

Elasticsearch uses Aggregations for faceting. In case of field facet, the Terms Aggregation API of Elasticsearch is used.

The addToRequest() method does the following:

  1. Checks if it can handle the passed facet (using passed facet definition object). If it cannot, it returns null at this step and exits.
  2. Uses the AggregationBuilders API to build required aggregations and modify the passed query and nested query objects.
  3. Returns an object (facet info), that will allow the facet handler to retrieve corresponding results after the query is executed.

After the Elasticsearch query is executed, the extractFromResponse() method is called, which retrieves results corresponding to the facet.

ActiveFacetHandler

The ActiveFacetHandler interface handles currently active facets and converts their values into appropriate search criteria properties. The active facets narrow down search results when one or more facet values are activated, for example, when an end user selects a facet in a website.
The interface defines one method:


    /**
     * Handles the supplied active facet and populates the search criteria object with corresponding data. If the active facet data is
     * processed successfully by this handler (and so the passed {@link SearchCriteria} object gets modified during the execution of this
     * method), the method returns <code>true</code> otherwise <code>false</code>.
     *
     * @param facetData the active facet data
     * @param facetDefinition the definition, which corresponds to the facet data
     * @param searchCriteria current search criteria object with active facets to be handled; if this handler successfully processes active
     *            facet data, this search criteria object gets modified during the execution of this method
     * @param mainFacetQuery Boolean Query that will be apply on the main document.
     * @return <code>true</code> if the handler was able to process facet data; <code>false</code> if the handler does not know how to
     *         handle facets of this type
     */
    boolean handleActiveFacet(BaseFacetDefinition facetDefinition, Map<String, FacetValue> facetData,
            SearchCriteria searchCriteria, BoolQueryBuilder mainFacetQuery);

Component view (JSP)

The JSP view for the facet component lists facet results data. The following code is the JSP code for the checkboxes view of a field facet component (esFieldFacet.checkboxes.jsp).


<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
<%@ taglib prefix="jcr" uri="http://www.jahia.org/tags/jcr" %>
<%@ taglib prefix="s" uri="http://www.jahia.org/tags/search" %>
<%@ taglib prefix="esSearch" uri="http://www.jahia.org/tags/esSearch" %>
<%@ taglib prefix="functions" uri="http://www.jahia.org/tags/functions" %>
<%@ taglib prefix="template" uri="http://www.jahia.org/tags/templateLib" %>

<template:addResources type="css" resources="searchfacets.css"/>

<jcr:nodeProperty var="titleProperty" name="jcr:title" node="${currentNode}"/>
<c:if test="${renderContext.editMode}">
    <span data-src-role="search-field-facet"><fmt:message key="jnt_esFieldFacet"/>: <strong>${fn:escapeXml(not empty titleProperty ? titleProperty : currentNode.properties['j:field'].string)}</strong></span>
</c:if>
<c:if test="${!renderContext.editMode}">
    <c:set var="facetId" value="${currentNode.identifier}"/>
    <c:set var="facetData" value="${esSearch:getFacetData(facetId, renderContext)}"/>
    <c:if test="${not empty facetData}">
        <div class="searchFieldFacetContainer">
            <c:set var="valueParamName" value="${fn:escapeXml(facetData.valueParameterName)}"/>
            <c:set var="facetFormId" value="facet-form-${facetId}"/>
            <s:drillDownForm excludeParamsRegex="${facetData.parameterNameRegex}" id="${facetFormId}">
                <c:if test="${not empty titleProperty}">
                    <div class="searchFacetTitle">
                        <h4>${fn:escapeXml(titleProperty.string)}</h4>
                    </div>
                </c:if>
                <c:forEach var="facet" items="${facetData.facetValues}">
                    <c:set var="facetValue" value="${fn:escapeXml(facet.value)}"/>
                    <label class="searchFacetOption ${facet.active ? 'active' : 'inactive'}"
                        data-facets-value="${facetValue}">
                        <input type="checkbox" name="${valueParamName}"
                            value="${facetValue}" ${facet.active ? 'checked="checked"' : ''}
                            onchange="this.form.submit();"/>
                        <%-- In case facet label is a complex object, we do not use escapeXml for rendering, but rather raw value --%>
                        ${not empty facet.labelObject ? facet.label : fn:escapeXml(facet.label)} <b data-src-role="facet-count">${facet.count}</b>
                    </label>
                </c:forEach>
            </s:drillDownForm>
        </div>
    </c:if>
</c:if>

The view is rendered as:

03-es-field-facet-view-checkboxes.png

A more complex label render, like country + flag, displays as:

04-es-field-facet-view-checkboxes-complex-label.png

In Edit mode, the component presents information about the type of facet. In other modes, the component handles and presents facet results data (if any).

<c:if test="${renderContext.editMode}">
    Field facet: ${currentNode.properties['j:field'].string)}
</c:if>
<c:if test="${!renderContext.editMode}">
    Here we will render the facet results
</c:if>

In the rendering part (when you are not in Edit mode), first the facet result data is retrieved using the esSearch Tag Library, which is supplied in the search-provider-elasticsearch module. If the retrieved data is not empty, then the data is rendered. Otherwise, no rendering occurs. Empty data can occur in two cases:

  • No search request was submitted, meaning that the end user is on a search results page and has not submitted search criteria.
  • Search results do not contain data for the related facet.
<c:set var="facetId" value="${currentNode.identifier}"/>
<c:set var="facetData" value="${esSearch:getFacetData(facetId, renderContext)}"/>
<c:if test="${not empty facetData}">
    We do the rendering of facet results here...
</c:if>

When results are available for the relevant facet, rendering begins using the retrieved facet data (of type org.jahia.modules.elasticsearch.search.facets.FacetRenderingData):

<div class="searchFieldFacetContainer">
    <c:set var="valueParamName" value="${fn:escapeXml(facetData.valueParameterName)}"/>
    <c:set var="facetFormId" value="facet-form-${facetId}"/>
    <s:drillDownForm excludeParamsRegex="${facetData.parameterNameRegex}" id="${facetFormId}">
        <c:if test="${not empty titleProperty}">
            <div class="searchFacetTitle">
                <h4>${fn:escapeXml(titleProperty.string)}</h4>
            </div>
        </c:if>
        <c:forEach var="facet" items="${facetData.facetValues}">
            Here we render an entry for single facet value
        </c:forEach>
    </s:drillDownForm>
</div>

The snippet above is responsible for rendering a title of the component and iterating over all facet values to render each value.
The entire rendering is nested into the <s:drillDownForm/> element, supplied by the DX search library. This library renders the HTML form element with appropriate parameters to narrow down the search results (submit new search with additional facet value parameters) when a particular facet value is selected by a user. The facetData.valueParameterName and facetData.parameterNameRegex are used to build an adjusted search request.

The <c:forEach/> loop is responsible for iterating and rendering each facet value as follows (the loop variable facet here is of type org.jahia.modules.elasticsearch.search.facets.FacetRenderingData.FacetValueRenderingData):

<c:set var="facetValue" value="${fn:escapeXml(facet.value)}"/>
<label class="searchFacetOption ${facet.active ? 'active' : 'inactive'}"
    data-facets-value="${facetValue}">
    <input type="checkbox" name="${valueParamName}"
        value="${facetValue}" ${facet.active ? 'checked="checked"' : ''}
        onchange="this.form.submit();"/>
    <%-- In case facet label is a complex object, we do not use escapeXml for rendering, but rather raw value --%>
    ${not empty facet.labelObject ? facet.label : fn:escapeXml(facet.label)} <b data-src-role="facet-count">${facet.count}</b>
</label>

This renders an HTML input element of type checkbox with a corresponding label.
The facet object contains data of a facet value, count, label and whether this value is currently active or not (facet.active boolean flag).
A checkbox state change (see the onchange attribute of input element) triggers the submit of the surrounding search drill down form and search results are narrowed down considering the facet value.
The label could have a complex HTML rendering (like country with a flag as an image), so the above JSP is rendered as:

${not empty facet.labelObject ? facet.label : fn:escapeXml(facet.label)}

Label renderer implementation

During the implementation, you may require a custom facet value label renderer, if the out-of-the-box renderers are not sufficient. This section provides an example to show how to create a custom renderer. The example shows the  Content type label renderer, which is used by the field facet on the Content type field.
The label renderer is an implementation of the org.jahia.services.content.nodetypes.renderer.ModuleChoiceListRenderer interface that allows it to be automatically registered in DX when such class is defined in a module’s Spring bean definition file:

<bean id="contentTypeChoiceListRenderer" class="org.jahia.modules.elasticsearch.search.facet.ContentTypeChoiceListRenderer">
    <property name="key" value="contentType" />
</bean>

The ContentTypeChoiceListRenderer class extends from the abstract implementation (AbstractChoiceListRenderer) and returns as a label for the content type (like “jnt:page”) a user friendly display name (“Page”).
The implementation reads as follows:


public class ContentTypeChoiceListRenderer extends AbstractChoiceListRenderer implements ModuleChoiceListRenderer {

    private static final Logger logger = LoggerFactory.getLogger(ContentTypeChoiceListRenderer.class);

    private String key;

    @Override
    public String getKey() {
        return key;
    }

    @Override
    public String getStringRendering(Locale locale, ExtendedPropertyDefinition propDef, Object propertyValue) {
        String label = null;
        if (propertyValue != null) {
            String ntName = propertyValue.toString();
            try {
                label = NodeTypeRegistry.getInstance().getNodeType(ntName).getLabel(locale);
            } catch (NoSuchNodeTypeException e) {
                // we prefer to log a warning here and return the node type name instead of breaking the rendering
                logger.warn(e.getMessage(), e);
                label = ntName;
            }

        }
        return label;
    }

    @Override
    public String getStringRendering(RenderContext context, JCRPropertyWrapper propertyWrapper)
            throws RepositoryException {
        return getStringRendering(context, null, propertyWrapper.getValue().getString());
    }

    @Override
    public void setKey(String key) {
        this.key = key;
    }
}

The renderer transforms a property value with the JCR node type (propertyValue) into a user-friendly, locale-specific label.