Customizing Apachesolr facets built by FacetAPI with the power of CTools

The Apachesolr module (7.x) allows us to build sites that use powerful search technology. Besides being blazingly fast, Apachesolr has another advantage: faceted search.

Faceted search allows our search results to be filtered by defined criteria like category, date, author, location, or anything else that can come out of a field. We call these criteria facets. With facets, you can narrow down your search more and more until you get to the desired results.

Sometimes, however, the standard functionality is not enough. You might want or need to customize the way that facets work. This is controlled by Facet API. Unfortunately there is not much documentation for Facet API, and the API can be difficult to understand.

This post will change that!

In this post, you will learn how to use FacetAPI to create powerful custom faceted searches in Drupal 7. I will take a look at the UI of the module as well as its internals, so that we can define our own widgets, filters, and dependencies using some cool ctools features.

Introduction

For an example of powerful facets, see: http://www.photoshare.org/images/search/grid/health

This is a site that we built recently. It uses facets from SearchAPI instead of Apachesolr, but the general principle is the same.

As we move forward, I plan to answer the following questions:

  • How can I set the sort order of facet links? How can I create my own sort order?
  • How can I show/hide some facets depending on custom conditions? For example, how can I show a facet only if there are more than 100 items in the search results
  • How can I exclude some of the links from the facets?
  • How can I change the facets from a list of links to form elements with autosubmit behavior?

I will start with showing the general settings that can be configured out of the box and then dive into the code to show how those settings can be extended.

Block settings options


Let's take a look at the contextual links of the facet block. We can see there are three special options:

  • Configure facet display
  • Configure facet dependencies
  • Configure facet filters

Let's take a closer look at each of these.

Facets UI

On the facet display configuration page we can choose the widget used to display the facet and its soft limit, the setting of the widget, which controls how many facets are displayed. We can also use different sort options to set the links to display in the order we prefer.

The next settings page is for Facet dependencies. Here we can choose different options that will make Drupal understand when to show or hide the facet block.

The third settings page is Facet filters. Here we can filter what facet items to show (or not show) in the block.

As you can see, there are plenty of options in the UI that we can use when we set up our facets. We can filter some options, change sort order, control visibility, and even change the facet widget to something completely different than a list of links.

Now let's take a look at the internals of Facet API and learn how we can define our own facet widgets, facet dependencies, and facet filters.

Facet API is built on the ctools plugins system, which is a very, very flexible instrument.

Build your own facet widget

Imagine we want to create a facet that will consist of a select list of options and a submit button. Better yet, let's hide the submit button and just autosubmit the form when we change the value by selecting an item from the list.

In order to define our own widget we need to implement the following hook:

<?php
/**
 * Implements hook_facetapi_widgets()
 */
function example_facetapi_widgets() {
  return array(
   
'example_select' => array(
     
'handler' => array(
       
'label' => t('Select List'),
       
'class' => 'ExampleFacetapiWidgetSelect',
       
'query types' => array('term', 'date'),
      ),
    ),
  );
}
?>

With this we define a new widget called "example_select" which is bound to a class called "ExampleFacetapiWidgetSelect".

All our logic will be in this ExampleFacetapiWidgetSelect plugin class:

<?php
class ExampleFacetapiWidgetSelect extends FacetapiWidget {
  
/**
   * Renders the form.
   */
 
public function execute() {
   
$elements = &$this->build[$this->facet['field alias']];

   
$elements = drupal_get_form('example_facetapi_select', $elements);
  }
}
?>

Instead of rendering the links directly, we will load a form to show our select element.

Now let's see how to build the form:

<?php
/**
 * Generate form for facet.
 */
function example_facetapi_select($form, &$form_state, $elements) {

 
// Build options from facet elements.
 
$options = array('' => t('- Select -'));
  foreach (
$elements as $element) {
    if (
$element['#active']) {
      continue;
    }
   
$options[serialize($element['#query'])] = $element['#markup'] . '(' . $element['#count'] . ')';
  }

 
$form['select'] = array(
   
'#type' => 'select',
   
'#options' => $options,
   
'#attributes' => array('class' => array('ctools-auto-submit')),
   
'default_value' => '',
  );
 
$form['submit'] = array(
   
'#type' => 'submit',
   
'#value' => t('Filter'),
   
'#attributes' => array('class' => array('ctools-use-ajax', 'ctools-auto-submit-click')),
  );

 
// Lets add autosubmit js functionality from ctools.
 
$form['#attached']['js'][] = drupal_get_path('module', 'ctools') . '/js/auto-submit.js';
 
// Add javascript that hides Filter button.
 
$form['#attached']['js'][] = drupal_get_path('module', 'example') . '/js/example-hide-submit.js';

 
$form['#attributes']['class'][] = 'example-select-facet';


  return
$form;
}

/**
 * Submit handler for facet form.
 */
function example_facetapi_select_submit($form, &$form_state) {
 
$form_state['redirect'] = array($_GET['q'], array('query' => unserialize($form_state['values']['select'])));
}
?>

In this form, the value of each select box option is the url where the page should be redirected. So, on the submit handler we simply redirect the user to the proper page. We add autosubmit functionality via ctools' auto-submit javascript. Also, we add our own javascript example-hide-submit to hide the Filter button. This enables our facet to work even if javascript is disabled. In such a case, the user will just need to manually submit the form.

Each element that we retrieved from FacetAPI has the following properties:

  • #active - Determines if this facet link is active
  • #query - The url to the page that represents the query when this facet is active
  • #markup - The text of the facet
  • #count - The number of search results that will be returned when this facet is active.

Please note how easy it is to add the autosubmit functionality via ctools. It is just a matter of setting up the right attributes class on the form elements, where one is used as sender (.ctools-autosubmit) and the other one is the receiver (.ctools-autosubmit-click). Then we just need to add the javascript in order to get the autosubmit functionality working.

Please use minimum beta8 version of the FacetAPI module.

Sorting order

All options in facets are sorted. On the settings page above, we saw different kinds of sorts. We can also define our own type of sorting if none of the options on the settings page meet our needs. As an example, I will show you how to implement a random sort order.

To reach this goal we need to implement hook_facetapi_sort_info():

<?php
/**
 * Implements hook_facetapi_sort_info().
 */
function example_facetapi_sort_info() {
 
$sorts = array();

 
$sorts['random'] = array(
   
'label' => t('Random'),
   
'callback' => 'example_facetapi_sort_random',
   
'description' => t('Random sorting.'),
   
'weight' => -50,
  );

  return
$sorts;
}

/**
 * Sort randomly.
 */
function example_facetapi_sort_random(array $a, array $b) {
  return
rand(-1, 1);
}
?>

The sort callback is passed to the uasort() function, so we need to return -1, 0, or 1. Note that you again get a facetapi element, which has the same properties as outlined above, so you could, for example, compare $a['#count'] and $b['#count'].

In order to see the result we just need to disable all the other sort order plugins and enable our own.

Filter

When facet items are generated they are passed on to filters. If we want to exclude some of the items, we should look at the filters settings. We can also, of course, implement our own filter.

As an example, we will create a filter where we can specify what items (by labels) we want to exclude on the settings page.

<?php
/**
 * Implements hook_facetapi_filters().
 */
function example_facetapi_filters() {
  return array(
   
'exclude_items' => array(
     
'handler' => array(
       
'label' => t('Exclude specified items'),
       
'class' => 'ExampleFacetapiFilterExcludeItems',
       
'query types' => array('term', 'date'),
      ),
    ),
  );
}
?>

Like with the widgets, we define a filter plugin and bind it to the ExampleFacetapiFilterExcludeItems class.

Now lets take a look at the ExampleFacetapiFilterExcludeItems class:

<?php

/**
 * Plugin that filters active items.
 */
class ExampleFacetapiFilterExcludeItems extends FacetapiFilter {

 
/**
   * Filters facet items.
   */
 
public function execute(array $build) {
   
$exclude_string = $this->settings->settings['exclude'];
   
$exclude_array = explode(',', $exclude_string);
   
// Exclude item if its markup is one of excluded items.
   
$filtered_build = array();
    foreach (
$build as $key => $item) {
      if (
in_array($item['#markup'], $exclude_array)) {
        continue;
      }
     
$filtered_build[$key] = $item;
    }

    return
$filtered_build;
  }

 
/**
   * Adds settings to the filter form.
   */
 
public function settingsForm(&$form, &$form_state) {
   
$form['exclude'] = array(
     
'#title' => t('Exclude items'),
     
'#type' => 'textfield',
     
'#description' => t('Comma separated list of titles that should be excluded'),
     
'#default_value' => $this->settings->settings['exclude'],
    );
  }

 
/**
   * Returns an array of default settings
   */
 
public function getDefaultSettings() {
    return array(
'exclude' => '');
  }
}
?>

In our example, we define settingsForm to hold information about what items we want to exclude. In the execute method we parse our settings value and remove the items we don't need.

Again please note how easy it is to enhance a plugin to expose settings in a form: All that is needed is to define the functions settingsForm and getDefaultSettings in the class.

Dependencies

In order to create our own dependencies we need to implement hook_facetapi_dependencies() and define our own class. This is very similar to the implementation of creating a custom filter, so I am not going to go into great detail here. The main idea of dependencies is that it allows you to show facet blocks based on a specific condition. The main difference between using dependencies and using context to control the visibility of these blocks is that facets whose dependencies are not matched are not even processed by FacetAPI.

For example, by default there is a Bundle dependency that will show a field facet only if we have selected the bundle that has the field attached. This is very handy, for example, in the situation when we have an electronics shop search. Facets related to monitor size should be shown only when the user is looking for monitors (when he has selected the bundle monitor in the bundle facet). We could create our own dependency to show some facet blocks only when a specific term of another facet is selected. There are many potential use cases here. For example you can think of a Facet that really is only interesting to users interested in content from a specific country. So you could process and show this facet only if this particular country is selected. A practical example would be showing the state field facet only when the country "United States" is selected as you know that for other countries filtering by the state field is not useful. Being able to tweak this yourself gives you endless possibilities!

Here is a shorted code excerpt from FacetAPI that can be used as a sample. It displays facet if the user has one of the selected roles. The main logic is in execute() method.

<?php
class FacetapiDependencyRole extends FacetapiDependency {

 
/**
   * Executes the dependency check.
   */
 
public function execute() {
    global
$user;
   
$roles = array_filter($this->settings['roles']);
    if (
$roles && !array_intersect_key($user->roles, $roles)) {
      return
FALSE;
    }
  }

 
/**
   * Adds dependency settings to the form.
   */
 
public function settingsForm(&$form, &$form_state) {
   
$form[$this->id]['roles'] = array(
     
'#type' => 'checkboxes',
     
'#title' => t('Show block for specific roles'),
     
'#default_value' => $this->settings['roles'],
     
'#options' => array_map('check_plain', user_roles()),
     
'#description' => t('Show this facet only for the selected role(s). If you select no roles, the facet will be visible to all users.'),
    );
  }

 
/**
   * Returns defaults for settings.
   */
 
public function getDefaultSettings() {
    return array(
     
'roles' => array(),
    );
  }
}
?>

Conclusion

As you can see, FacetAPI gives us the option to change anything we want about facets. We can change the display, alter the order, filter some facets out, and control facet blocks visibility and content based on dependencies we define.

I would like to thank the maintainers of this module, Chris Pliakas and Peter Wolanin, for their great work!

An example module is attached to this article. Thank you for reading, and if you have any further questions please let us know in the comments!

(Author: Yuriy Gerasimov, Co-Author: Fabian Franz, Editor: Stuart Broz)