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 with a text query
Faceted navigation for product findersPage 12 of 20Updated
What a text query matches on a product finder and what it does to the order of results, how facet counts follow a selection and how to get true counts within a facet, and what to show when a selection excludes every result.
Who this is for. The theme developer combining a search box with facets on a product finder. Afterwards you can predict what a query finds among products, explain every count on the page, and give the visitor a way back from a selection that leaves nothing.
What a query matches among products
On a Faceted Search view, the words in q are matched against the facets index's text fields: the description's name and fullPath, the primary category's categoryPath.name and categoryPath.fullPath, every facet of type Text, and every keyword facet whose Fulltext switch is on. A keyword facet without it is for filtering only: a visitor who types "acme" finds Acme products only if their names say so, or if the brand facet has Fulltext on. Turning Fulltext on needs a Rebuild (Defining a facet).
Facets narrow, the query ranks
Selecting a facet value removes results without changing the order of the rest: relevance comes from the text query alone, and facets act as filters. With facets and no query, every result scores the same, so a browse page without a query needs a sort, such as orderBy=fields.price:asc or name.keyword:asc, or its order is arbitrary.
How counts follow a selection
Every count is computed over the results as they stand: after the query and after every selected facet value, the facet's own selection included. Tick "Acme" in Brand, and Brand's other options either drop to 0, if they are predefined values, or vanish from the list, because no Acme product is also a Northwind product. That 0 does not mean that ticking Northwind would leave nothing: within a facet, values mean any of them, so it would add Northwind's products. The platform does not compute that number.
There are two honest ways to show a facet that has a selection:
Keep its options enabled and hide their counts, showing a count only on the selected options, as the filter form does. Give the facet predefined values so that its options stay on the page at all. This costs nothing.
Compute true counts with a second search for each facet that has a selection: the same parameters without that facet's, from which only that facet's counts are read. Each such facet costs one more request to Elasticsearch (Facet counts at scale).
The second search, for the brand facet, reuses the request's own parameters. They arrive with PHP's underscores in place of dots, as fields_brand, which the view reads back as fields.brand:
Template codesearch/search.html
{$params = $_request->getParameters()}
{$brandCounts = []}
{if isset($params['fields_brand'])}
{$others = $_search->search($view->getSlug(), array_diff_key($params, ['fields_brand' => true]))}
{foreach $others->getFacets() as $facet}
{if $facet->getIdentifier() == 'brand'}
{foreach $facet->getValues() as $option}{$brandCounts[$option->getValue()] = $option->getCount()}{/foreach}
{/if}
{/foreach}
{/if}
{* in the Brand fieldset, print $brandCounts[$option->getValue()] when it is set, else $option->getCount() *}
{% set params = _request.getParameters() %}
{% set brandCounts = {} %}
{% if params.fields_brand is defined %}
{% set others = _search.search(view.getSlug(), params|filter((v, k) => k != 'fields_brand')) %}
{% for facet in others.getFacets() %}
{% if facet.getIdentifier() == 'brand' %}
{% for option in facet.getValues() %}{% set brandCounts = brandCounts|merge({(option.getValue()): option.getCount()}) %}{% endfor %}
{% endif %}
{% endfor %}
{% endif %}
{# in the Brand fieldset, print brandCounts[option.getValue()] when it is defined, else option.getCount() #}
The second search goes through the view like the first, so the same Changeable switches apply, and its brand facet lists the options the other selections allow, each with the number of results it would add. Generalise it with a loop over the facets that have a selection, and stop at the two or three that matter: each is a full search.
When a selection excludes every result
A combination of values that no product has empties the result list. The facets that remain are only those with predefined values, every option at 0; a facet without predefined values disappears entirely, taking its ticked boxes with it, so the visitor can no longer untick what caused the problem. Show the active filters separately, each as a link that removes it, built from the request rather than from the facets, and keep the "Clear all filters" link:
{% set params = _request.getParameters() %}
<ul class="active-filters">
{% for name, values in params %}
{% if name starts with 'fields_' %}
{% set rest = params|filter((v, k) => k != name) %}
{% set label = values is iterable ? values|join(', ') : values %}
<li><a href="?{{ rest|url_encode|e }}">{{ label|e }} <span aria-hidden="true">×</span><span class="visually-hidden">remove this filter</span></a></li>
{% endif %}
{% endfor %}
</ul>
Each link keeps the query and every other filter and drops one facet's parameter. The links use the parameters as PHP received them, with underscores, which the view reads correctly; getParameters() does not declare them to the page cache, which is one more reason the search page prevents caching. Then apply the zero-result state as on any search page: say what was searched for, and offer the same query without the filters.