How to develop your own facet type

  Written by The Jahia Team
   Estimated reading time:

The section goes into more details w.r.t. to 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” for how a custom facet type can be implemented.

Understanding the processing flow

In order to give you an overview of the search request processing flow, here is a general flowchart diagram, show the main steps in the search:

01-es-field-facet-flow.png

In the above diagram the responsible “parts” (Java interfaces and JSP taglib functions) are marked on their corresponding steps in the flow.

The display of the search results and facet data can be represented by the following flow diagram:

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

Next sections go into details of 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

It extends the base jnt:esFacet component, which provides the field for “Maximum number of result groups” and also the jmix:esLabeledFacet that brings two fields for the label rendering behaviour (“Label renderer” and “Sort by label (alphabetically)”).
In addition, it brings a new field j:field that allows to choose which field, the faceting will be done on. For user convenience, we use the dedicated choice list initializer (choicelist[facetFields]), which gives the list of possible values for that field in the Create / Edit content engines in the UI.
Your custom facet component can include more 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 behaviour:

  • 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 have to be exposed as OSGi services, which our facet code is able to lookup to process facets of a specific kind.

In the search-provider-elasticsearch-facet module, there is a single class, implementing all three interfaces, but it is not a requirement. In your implementation, you could have three distinct classes, implementing those interfaces.
The service implementation class (FieldFacetHandler) is defined in Spring bean definition file of the module and also exposed as OSGi services 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>


Let us go through all three interfaces and their purpose in details in the next sections.

FacetDefinitionBuilder

This one is responsible for reading the data from the facet component node and converting it into corresponding facet definition object (BaseFacetDefinition), which is used throughout the whole implementation code and contains the “knowledge” about your facet.
Note, please, 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

The implementation of this interface is familiar with search facets of a specific kind and responsible for dealing with the Elasticsearch request / response to add / extract parts, necessary to implement this kind of facet. Multiple facet handlers make a chain of responsibility inside the ESSearchProvider (this is main service implementation of the Elasticsearch provider in DX) so that the ESSearchProvider can see, which facet handler is able to deal with which kind of facets.
The interface has two methods, which are invoked before and after a search request is executed in Elasticsearch, so the facet handler can add its “part” of the query and extract the 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 we use the Terms Aggregation API of Elasticsearch.

The addToRequest() method does the following:

  1. Checks if it can handle the passed facet (using passed facet definition object). If it cannot, it just returns null at this step and exits.
  2. Uses 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 is responsible of retrieving the results, corresponding to the facet.

ActiveFacetHandler

Implementors of this interface are handling the currently active facets, converting their values into appropriate search criteria properties. The active facets are used for narrowing down the search, when one or more facet values are getting “activated” (e.g. by an end user in the UI).
The interface defines one single 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)

This section shows the details of the JSP view for the facet component, which is responsible for listing the facet results data.
Here is the full listing of a JSP code for the “checkboxes” view of a field facet component (esFieldFacet.checkboxes.jsp), which we will analyse further part by part:


<%@ 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 resulting rendering of such a view is:

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

Or in case of complex label render, like country + flag, as:

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

In Edit mode the component is just presenting some information about the type of facet, whereas in other modes, the component is handling and presenting the 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 we are not in Edit mode), we first retrieve the facet result data using the esSearch Tag Library, which is supplied in the search-provider-elasticsearch module. If the retrieved data is not empty, we do the rendering of it. Otherwise we do not render anything. The empty data could be in two cases: there was no search request submitted (the end user is just on search results page, but has not yet submitted any search) or the search results do not contain data for the facet, we are interested in. 

<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>

In case we have results for the facet, we are interested it, we start rendering 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>

In the snippet above we render a title of the component and iterate over all facet values to render each of them.
The whole rendering is nested into <s:drillDownForm/> element, supplied by DX search library. This one renders the HTML form element with appropriate parameters to narrow down the search results (submit new search with additional facet value parameters) when particular facet value gets “activated” by the end user. The facetData.valueParameterName and facetData.parameterNameRegex are used to build an adjusted search request.

In the <c:forEach/> loop we iterate and render 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 if this value is currently “active” or not (facet.active boolean flag).
When the checkbox state changes (see the onchange attribute of input element), it triggers the submit of the surrounding search drill down form and the 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 we render it as:

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

Label renderer implementation

During the implementation there could be a need to implement custom facet value label renderer, in case the set of renderers, supplied with DX out of the box, is not sufficient.
In this section, we show an how it could be done using an example of a Content type label renderer, which is used by the field facet on “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 in our case 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;
    }
}

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