Site search on Core dna is an Elasticsearch index that the platform fills and queries, and a page you build. What reaches the index, search views, the search page, product facets, filtered listings that need no index, and when to go beyond the built-in index.
L2
Unit 03 · Faceted navigation for product finders
Facets and the mapping template
Faceted navigation for product findersPage 10 of 20Updated
Which kind of filter belongs where, how a facet is defined under Search › Facets, the mapping template that decides what each product puts into the facets index, price ranges as bands, and a Faceted Search view that returns the facets with their counts.
Who this is for. The developer building a product finder, and the content modeller deciding which product data becomes a filter. Afterwards you can define facets, write the mapping template in your site's template engine, and return the facets through a view.
Three kinds of filter, and where each belongs
A facet is a filter that also says how many results each of its options would leave: Brand: Acme (12), Northwind (4). The platform builds facets only for products, in a dedicated facets index. Other content is filtered in other ways:
Covers
Count per option
Use it for
Facets index
Product and variant descriptions
Yes
A product finder: brand, size, colour, price band, features
Filters on site search
Pages, posts, FAQ questions and the other site search types
No
Narrowing a content search by type, category, tag, language or a search field
Which values exist to filter on is a modelling decision, made when the product types are designed: categories against tags, custom fields against layout areas (Taxonomy, URLs and SEO, Custom fields). Each kind becomes a facet in its own way:
The primary category is in every product's document already, as categoryPath: the category and its parents, root first, each with categoryId, name and fullPath. Other categories a product belongs to are not.
Tags come from the description's getTags(), through the mapping template.
A layout area of the description, such as brand or colour, through the template with getAreaText() or getAreaValues().
A product custom field, such as material, through the template with the product's getCustomFieldText().
A price range, computed by the template as a band, because filters only match exact values (below).
Defining a facet
Facets are defined under Search › Facets, once for the whole account. Each has:
Setting
What it does
Identifier
The name the mapping template and the URL use, the URL as fields.<identifier>. Letters, digits, hyphens and underscores are accepted, spaces are not; use lower case and hyphens, because an underscore is read back as a dot in a URL parameter and the filter is then ignored
Name
The label shown to visitors
Type
Keyword, the usual one, for values filtered as they are; Integer, Double, Date or Bool; or Text, for words only. A Text facet cannot be filtered on or counted, and is not offered as a filter field on a view
Case Sensitive
On by default: "XL" and "xl" are separate values, and ?fields.size=xl does not find "XL". Turn it off for values typed by hand; values then come back in lower case without accents. Keyword facets only. Changing it needs a Rebuild
Fulltext
Also makes a keyword value findable from the search box, not only filterable. No effect on a Text facet. Changing it needs a Rebuild
Hidden
Filter only: the facet works in the URL but is never returned for display
Limit
How many options are returned, 10 by default
Order and Output Order
Highest count first, Lowest count first or alphabetical either way. Order decides which options Elasticsearch picks within the limit; Output Order sorts them for display
Unit of Measure
Shown next to the values, such as "mm"
Predefined Values
One per line, in display order, each optionally with a label after ::, as next-day::Next day. Predefined values are always returned, with a count of 0 when nothing matches, within the facet's limit
Properties
A JSON object, available to templates through $facet->getProperties() (facet.properties in Twig), for display hints such as a swatch colour or the partial to use; a missing key reads as null
The mapping template
What each product puts into the facets index is decided by one template, entered under Search › Indices, on the Faceted Search row's Configure screen. Next to it, Indexing Scope decides which descriptions are indexed: Products only, Variants only, or Products and variants, the default. The template runs once for each product or variant description when that description is indexed, in the site's template engine, with two variables:
productDescription: the description, as a front-end template sees it, with its layout areas, getTags() and getProduct(), the product or the variant, with its pricing.
index: the collector. set('<identifier>', value) adds a value, or a list of values, to a facet; skip('reason') leaves the description out of the index.
Every document carries its core fields without the template: id, productId, variantId (empty for a product), name, language, fullPath, categoryPath and indexed. The template adds the facets:
Template codeSearch › Indices › Faceted Search › Configure
{% set product = productDescription.getProduct() %}
{% set parent = product.isVariant() ? product.getProduct() : product %}
{% set price = product.hasDefaultPricing() ? product.getDefaultPricing().getBasePrice() : null %}
{% if price is null or price == '' %}
{% do index.skip('No price, so not sold online') %}
{% else %}
{% if price < 50 %}{% do index.set('price-band', 'under-50') %}
{% elseif price < 100 %}{% do index.set('price-band', '50-to-100') %}
{% else %}{% do index.set('price-band', '100-plus') %}{% endif %}
{% do index.set('price', price) %}
{% set brand = productDescription.getAreaText('brand') %}
{% if brand %}{% do index.set('brand', brand) %}{% endif %}
{% set colours = productDescription.getAreaValues('colours') %}
{% if colours %}{% do index.set('colour', colours) %}{% endif %}
{% set material = parent.getCustomFieldText('material') %}
{% if material %}{% do index.set('material', material) %}{% endif %}
{% set features = [] %}
{% for tag in productDescription.getTags() %}{% set features = features|merge([tag.getName()]) %}{% endfor %}
{% if features %}{% do index.set('features', features) %}{% endif %}
{% endif %}
How it behaves:
Only published, indexable descriptions reach it. Related records a signed-out visitor could not see, such as an unpublished category, come back empty, so they cannot leak into facets.
Test for a pricing before reading it.getDefaultPricing() on a product with no pricing creates one in memory rather than returning nothing; hasDefaultPricing() asks without that side effect.
Custom fields belong to the product. A variant description's getProduct() is the variant, which does not offer the custom field methods, so the example reads them from the parent.
Read layout areas with getAreaText() and getAreaValues(), not the shorthand: on a product whose layout lacks the area they return nothing, where the shorthand fails.
A template error keeps the previous values. A syntax error, or a method called on nothing, leaves the product indexed with the facet values it had before and records the error so you can fix the template; the product finder keeps working while you do.
Values are cleaned. Tags are stripped; a keyword value is cut at 190 characters and a text value at 2,048; an empty value is dropped; a value for an identifier that does not exist is dropped with the message Facet does not exist.
Only index->set() and index->skip() change the facets index. Anything else the template prints appears in the preview only.
The Configure screen previews the template against any product: whether it will be indexed, the values extracted for each facet with any messages, and whatever the template printed. Preview a few products with different layouts, and a variant, before saving. Saving the template, or changing the indexing scope, reindexes nothing: run Reindex on the Faceted Search index when you are ready (Reindex or Rebuild).
Price ranges become bands
A facet filter matches exact values and there is no range filter, so Under $50 cannot be asked of a price stored as a number. Compute the band when indexing, as the template does, and give the bands labels and a fixed order with predefined values on the price-band facet:
HTMLPredefined Values of the price-band facet
under-50::Under $50
50-to-100::$50 to $100
100-plus::$100 and over
The template also keeps the price itself, in a Double facet named price and marked Hidden, so that a view can sort by it with orderBy=fields.price:asc. Two cautions. A band is computed from the price at indexing time, and a price change belongs to the product, not to its description, so the band stays as it was until the description is saved again or the facets index is reindexed: schedule a reindex after a price import. And choose the bands from the catalogue's real price distribution, so that each holds a useful share of the products.
Showing the facets
Create a search view on the Faceted Search index. Under its filter fields, switch on each facet, fields.brand, fields.colour and so on, with Renderable to return it with the results and Changeable so that visitors can filter on it. A facet that is renderable but not changeable comes back marked as disabled. The facets then reach the template as $response->getFacets(), each with its name, unit, filter parameter and options, and each option with its value, label, count and selected state. A filter interface that keeps its state in the URL renders them.