Custom views filters with Search API and Simple Hierarchical Select
A recent project involved the use of the Simple Hierarchical Select module to input category data for a particular content type. Simple Hierarchical Select provides a clean way of browsing hierarchical vocabularies to easily add taxonomy terms to nodes.
An initially tricky user interface problem to utilise this module with Search API and Views exposed filters was solved using a couple of Drupal 8 plugins and a bit of smart thinking!
A recent project involved the use of the Simple Hierarchical Select module to input category data for a particular content type. Simple Hierarchical Select provides a clean way of browsing hierarchical vocabularies to easily add taxonomy terms to nodes.
The module works great and does exactly what it says on the tin, however it created something of a head-scratcher as the same project, which was using Apache Solr via Search API, required this data to be searchable by the end user. Creating a view of the indexed data and then exposing the various filters to be included in the search was the obvious answer and initially it was felt that it should be pretty straightforward.
For a number of the exposed filters, it was straightforward; although some required the use of hook_form_alter to make some alterations to the output of some exposed filters such as changing boolean filters from a True/False selection to Yes/No.
Unfortunately, the simple hierarchical select filter was not available as an exposed filter for Search API index views. A few approaches were made to try and resolve this including using hook_form_alter and trying to force the exposed filter to make use of simple hierarchical select. In the end, a solution was found that was actually more simple but perhaps not quite immediately obvious and required creating two plugins; a Search API processor plugin and a Views filter plugin.
Because the taxonomy that required to be searchable was hierarchical in nature, it is quite possible that the user making a search may want to do a search on higher level categories and return results that included sub-categories. For example, the top level categories could be Business and Community and below Business may be sub categories such as Manufacturing, Transport and Retail and then perhaps below Retail would be child categories such as Food, Clothing, Consumer Electronics. A user searching on Retail would expect results to be returned that have been categorised with Food, Clothing or Consumer Electronics rather than simply those categorised as Retail.
The Search API processor plugin is needed to add the parent terms of each taxonomy term to the index so that the views filter would be able to refer to those parent terms when the search query is generated.
It looks something like this:
namespace Drupal\search_api_demo\Plugin\search_api\processor; use Drupal\search_api\Processor\ProcessorPluginBase; use Drupal\taxonomy\TermStorageInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Adds an additional field containing the term parents. * * @SearchApiProcessor( * id = "term_parents", * label = @Translation("Term parents"), * description = @Translation("Adds all term parents for directory field."), * stages = { * "pre_index_save" = -10, * "preprocess_index" = -30 * } * ) */ class TermParents extends ProcessorPluginBase { /** * Term storage. * * @var \Drupal\taxonomy\TermStorageInterface */ protected $termStorage; public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { $plugin = parent::create($container, $configuration, $plugin_id, $plugin_definition); $plugin->setTermStorage($container->get('entity_type.manager')->getStorage('taxonomy_term')); return $plugin; } protected function setTermStorage(TermStorageInterface $storage) { $this->termStorage = $storage; } public function preprocessIndexItems(array &$items) { foreach ($items as $item) { foreach ($this->filterForPropertyPath($item->getFields(), 'field_category) as $field) { foreach ($field->getValues() as $tid) { foreach ($this->termStorage->loadAllParents($tid) as $term) { $field->addValue($term->id()); } } } } } public function calculateDependencies() { parent::calculateDependencies(); $this->addDependency('config', 'field.storage.node.field_category); $this->addDependency('config', 'taxonomy.vocabulary.category'); return $this->dependencies; } }
​The most interesting part of this class is the preprocessIndexItems method. When Search API indexes the data, the processor plugin iterates through each item to be indexed, filtering out fields other than field_category, which contains the term id stored by the particular node and the field we are interested in working with. It loads the parents of the term, by term id, and adds the id of each parent to the index using the addValue method. This method is basically storing each term id value in the item field, in this case field_category.
Note that the indexed item mentioned above isn’t an entity, it is referring to a Search API Item. This item could as easily be a user as a node, taxonomy term, file, comment or any other piece of data that Search API is able to index and it represents data that is being indexed or returned as a search result.
Incidentally, this functionality is already available in the Drupal 7 version of Search API and progress is being made on porting this to Drupal 8.
The second plugin is the Views filter itself. This is in two parts, the plugin itself and a hook that lets Views know about the filter. Firstly, the hook:
/** * Implements hook_views_data_alter(). */ function search_api_demo_views_data_alter(array &$data) { $data['search_api_index_default']['shs'] = [ 'title' => t('Category filters'), 'help' => t('SHS filter for category'), 'filter' => [ 'field' => 'field_category’, 'id' => search_api_demo_shs_taxonomy_index_tid_depth', ], ]; }
The array key search_api_index_default refers to the Default Search API index or if you were creating a custom filter for node data, you would want to use node_field_data to refer to the database table instead. The field key is the important item in this array and refers to the actual field, field_category, we want to be able to filter on. shs is a reference in Views to the field_category field and can be set to anything. Lastly, the id key is the plugin we want to use.
Alternatively if you simply wanted to create a filter that would provide a text filter for a particular database, or Search API, field, this id could be set to String which would use the core Views string filter plugin and you’d be finished now. As we have a specific requirement for our filter, we need to create a plugin, which has been given the id search_api_demo_shs_taxonomy_index_tid_depth.
The plugin code is simple and looks as follows:
namespace Drupal\search_api_demo\Plugin\views\filter; use Drupal\search_api\Plugin\views\filter\SearchApiFilterTrait; use Drupal\search_api\UncacheableDependencyTrait; use Drupal\shs\Plugin\views\filter\ShsTaxonomyIndexTidDepth; /** * Filter handler for taxonomy terms with depth. * * @ingroup views_filter_handlers * * @ViewsFilter("search_api_demo_shs_taxonomy_index_tid_depth") */ class SearchAPIDemoFilter extends ShsTaxonomyIndexTidDepth { use UncacheableDependencyTrait; use SearchApiFilterTrait; public function query() { if ($value = reset($this->value)) { $this->getQuery() ->addCondition('field_directory_type', $value); } } }
The magic happens in two places. Firstly the class extending Simple Hierarchical Select’s ShsTaxonomyIndexTidDepth views filter, which does all the hard graft of creating the hierarchical select widget, and secondly SearchApiFilterTrait, a trait which gives access to Search API filter methods and lets us add our filter as a condition to the search query, as can be see in the query() method in the code above.
All this results in turning the default select list that only works on the current term id...
...to something more flexible and easier to use, and that respects taxonomy hierarchy: