Facets, filters, and attributes

📘

This is an article for advanced users of Frontastic.

As a Developer within Frontastic, you'll come across the terms facet, filter, and attribute quite frequently and they can often be used as synonyms. However, they're also used with multiple meanings. We'll try to clear up the confusion here but we can't promise anything...

In our stack, the meanings for each are:

  • Attribute – A property of a data entity that might be displayed on lists, detail pages, and so on. For example, size can be an attribute of a product variant and the product detail page shows the size variant as the text XL.
  • Facet – A search for data entities might return statistics about attribute values among the search result. These statistics are called a facet. For example, a category has 12 product variants with the color red and 8 product variants with the color blue.
  • Filter – A filter is used to restrict the results of a search for data entities, typically using one of its attributes. For example, a customer could filter all the products that have a size equal to XL.

Usually, the filter possibilities displayed are facets and the user can filter by selecting a facet value which is then applied as a filter to the previously shown result. In the below example, the facets are the count of results per attribute value:

948

Within the Facets area of the Frontastic studio, a user can select which attributes should be available to users for facets and filters. They can also change the order that they appear to users. All attributes marked as searchable in the API can be used but they must be activated in the Facet area in the Frontastic studio. However, both of these can be overridden if it's written into your code, make sure you talk to your team to make sure there's no overlap. See the using facets in the Frontastic studio article for more information on how to use Facets in the Frontastic studio.

Whenever someone opens the Facets area in the Frontastic studio, it asks the API hub for all searchable attributes with the ProjectAPI and syncs these to the studio, these are then stored in the Frontastic studio database of the project as potential facets in the Facet area and are automatically updated each time it's opened. Any new facets will be added as deactivated by default.

The API hub hosts the implementation of the ProjectAPI and it has a database of all enabled facets from the Facet area in the Frontastic studio so it can communicate with your API providers. This means that your API hub also hosts a database of your facets.

Data sources can be parametrized on top of what is defined in the Frontastic studio, for example, with paging (offset/limit) and filtering (facets). This allows end-users to interact with data sources on a page by filtering its results or scrolling to later results.

The data fetching from the backend through streams depends on the URL requested by the end-user through the browser (which identifies a page folder or dynamic page). The Frontastic frontend uses the same URL presented in the browser to fetch the data. Therefore, in order to parametrize a stream, the URL needs to be manipulated.

1169

The above image shows this flow:

  • The URL of a page folder is requested from the browser
  • The URL change in the frontend app results in a request for data to the API hub backend
  • A data source filter of items is returned together with metadata such as total, offset, limit, and the Facets statistics
  • The facets from the data source filter can be used to render drill-down functionality for the end-user
  • If the user selects a filter from such a drill-down selection, the URL changes which results in a data request to update the information state of the data source filter
  • Paging works exactly the same, based on the other data source metadata (see the Pagination and sorting with commerce backends article for more info on pagination)

Reading and displaying facets

A product-list data source doesn't only contain items but also facets. This property is filled by the commerce backend with statistics about the products in the data source. The facets array contains an object for each attribute that has facet calculation enabled (through the Facets area). In the below code, there is the facet for color extracted from this array as example. In a real-world application, you'll probably iterate all facets to render all possible filters.

import React, { Component, Fragment } from 'react'
import { connect } from 'react-redux'
import { compose } from 'redux'
import PropTypes from 'prop-types'
import _ from 'lodash'
import Entity from '../../app/entity'
import UrlHandler from '../../app/urlHandler'

import app from '../../app/app'
import facetConnector from '../../app/connector/facet'
import categoryConnector from '../../app/connector/category'
import urlHandlerConnector from '../../app/connector/urlHandler'

class ExampleProductListFilterTastic extends Component {

    render () {
        const productList = this.props.data.stream

        // ... sanity checks ...

        const colorFacet = _.find(productList.facets, { handle: 'variants.attributes.color' })

        const streamId = this.props.tastic.configuration.stream

        return (<Fragment>
            <div style={{ width: '50%', 'float': 'left' }}>
            <h1>Color Filter:</h1>
            <ul>
                {colorFacet.terms.map((term) => {
                    return this.renderTerm(term, (event) => {
                        event.preventDefault()
                        this.toggleFacetTerm(streamId, colorFacet, term)
                    })
                })}
            </ul>
            </div>
        </Fragment>)
    }

    renderTerm = (term, toggleCallback) => {
        return (<li>
            <a href='#' onClick={toggleCallback}>{term.value}</a>
            ({term.count}) {term.selected ? '+' : ''}
        </li>)
    }

    // ... handlers, see below ...
}

ExampleProductListFilterTastic.propTypes = {
    data: PropTypes.object.isRequired,
    tastic: PropTypes.object.isRequired,

    // ... more props needed for handling, see below ...

    // From facetConnector
    facets: PropTypes.instanceOf(Entity).isRequired,
}

export default compose(
    connect(facetConnector),
    // ... more connectors, see below ...
)(ExampleProductListFilterTastic)

The basic data structure of a facet looks like this:

{
    "type": "range",
    "handle": "variants.price",
    "key":"variants.price",
    // ... more type specific properties
}

When querying the ProductAPI the API hub returns facets that can be grouped into 2 counting facets. Term counting is used when you have a finite number of values that it can count the number of times that term comes into play, for example, color or brand, which is represented as an object:

{
    "terms": [ 
	      {
	          "handle": "Damen",
            "name": "Damen",
            "value": "Damen",
            "count": 903,
            "selected": false
	      }
    ]
}

Where handle is the identifier by which the server recognizes the term and value is the (localized) value to be displayed to the user. count is the number of items in the stream that satisfy a filter for this term and selected indicates if the term is applied as filter right now.

Range counting is when there's an infinite number of values that can be displayed so you can group them together, for example, price:

{
     "min":3125,
     "max":86125,
     "step":null,
     "value": {
         "min":0,
         "max":0
     },
     "selected": false
}

Where the min and max values on root level determine what values are possible in general for that facet (depending on current filters) and the value property contains the currently selected range values.

These should then be applied to your attribute types. For example, a boolean attribute type will most likely be a term facet and a number attribute type will most likely be a range facet. If you don't specify this, it will become a term facet by default.

The example shows how to simply display a list of all facet terms including their count and if the term is currently selected for filtering. Displaying a range would work accordingly, mostly with advanced slider elements or input fields.

Filtering by facet

The above example used a function toggleFacetTerm to select/deselect a facet term for filtering. This function and the required boilerplate code are shown below:

🚧

To determine if a filter is applied or not, we don't use the server result but rather the URL parameters. The reason is to avoid race conditions when a user clicks faster than the server handles the requests. It also gives faster feedback after the user has clicked.

import React, { Component, Fragment } from 'react'
import { connect } from 'react-redux'
import { compose } from 'redux'
import PropTypes from 'prop-types'
import _ from 'lodash'
import Entity from '../../app/entity'
import UrlHandler from '../../app/urlHandler'

import app from '../../app/app'
import facetConnector from '../../app/connector/facet'
import categoryConnector from '../../app/connector/category'
import urlHandlerConnector from '../../app/connector/urlHandler'

class ExampleProductListFilterTastic extends Component {
    // ... render method ...

    toggleFacetTerm = (streamId, facet, term) => {
        const parameters = this.props.urlHandler.deriveParameters((urlState) => {
            const streamState = urlState.getStream(streamId)
            let filterTerms = this.getSelectedTerms(streamState, facet)

            if (filterTerms.includes(term.handle)) {
                filterTerms = filterTerms.splice(filterTerms.indexOf(term.handle), 1)
            } else {
                filterTerms.push(term.handle)
            }

            streamState.setFilter(
                facet.handle,
                _.isEmpty(filterTerms) ? null : { terms: filterTerms })
        })

        app.getRouter().push(this.props.route.route, parameters)
    }

    getSelectedTerms = (streamState, facet) => {
        return ((streamState.getParameters().facets || {})[facet.handle] || {}).terms || []
    }
}

ExampleProductListFilterTastic.propTypes = {
    data: PropTypes.object.isRequired,
    tastic: PropTypes.object.isRequired,

    // From connector
    route: PropTypes.object.isRequired,

    // From urlHandlerConnector
    urlHandler: PropTypes.instanceOf(UrlHandler),

    // ... more props ...
}

export default compose(
    // ... more connectors ...
    connect(urlHandlerConnector),
    connect((globalState) => {
        let streamParameters = globalState.app.route.parameters.s || {}

        return {
            route: globalState.app.route,
            streamParameters: streamParameters,
        }
    }),
)(ExampleProductListFilterTastic)

Working with stream URL parameters is done through an object called urlHandler. This encapsulates the parameters from the URL for all data sources in a page folder. In order to change data source parameters, you call it method deriveParameters() giving it a callback that will actually do the mutation on a clone of the current parameter urlState. The reason for this is to not change the parameters directly but to receive a new set of (clean) parameters for either direct use (router push) or link creation.

The urlState has methods to access the parameters of a specific data source (there can be an arbitrary amount of data sources in a page folder). You need to get hold of the ID of your data source in question in order to access its parameters: this is possible through the Frontastic component configuration (see the previous example).

You'll still need to extract the currently selected terms of a facet filter from the parameters yourself. After updating the selection you can call methods on the streamState to manipulate the parameters.

For working with offset and limit there are corresponding methods available on the streamState.

🚧

The urlHandler only takes care of the parameters of data sources! If you have custom parameters in the URL (such as q for search), you need to merge them to the derived parameters before updating the route, otherwise, they'll be lost!

Be sure to use something to keep the deriveParameters(), for example:

const parameters = this.props.urlHandler.deriveParameters((urlState) => {
    // ...
})

// Conserve another parameter:
parameters.phrase = this.props.route.parameters.phrase

app.getRouter().push(this.props.route.route, parameters)