Custom views filter plugin in Drupal 8 (Bounding box + Geofield)

As you may notice, views architecture has a lot of changes since the Drupal 7. Views handlers became plugins, “theme information” link is no longer available and config now stored in standardized .yml files instead of “Views export”.

Recently, we needed a “Bounding box” filter for our www.boardr.co project. Back in Drupal 7 times, Geofield module was shipped with proximity views filter plugin, which at least partially fitted our needs. Unfortunately, Drupal 8 version of Geofield lacks this functionality.

Note: Geofield module contains GeofieldProximity.php file, but it does not work. Probably, it was created when Drupal 8 views module was under active development and API was not frozen.

First of all, I think it’s worth clarifying what does the “bounding box filter” term mean. To make it short, the bounding box is a way to get geographical data (latitude/longitude points) which fit some rectangular area. A rectangular area defined as two sets of coordinates, lat/long of the top left corner and lat/long of the bottom right corner.

In our case, we need this to make it possible to filter outdoor advertising units by a region or by current map view. I bet similar queries are being used in Foursquare and Airbnb by-map-area searches.

Bounding Box filter AirBnb

To start with Views Filter plugin you need to create a plugin file following the proper file system structure. File should be placed in your_module_directory/src/Plugin/views/YourPlugin.php
Depending on your need you can extend one of the existing filter plugins or use FilterPluginBase as a parent. In our case, we used FilterPluginBase as a starting point.

Below is a code of our filter. It’s far from perfect, but it’s good enough to serve as an example.

<?php

/**
 * @file
 * Definition of Drupal\geofield\Plugin\views\filter\GeofieldProximity.
 */

namespace Drupal\rbrd\Plugin\views\filter;

use Drupal\Core\Form\FormStateInterface;
use Drupal\views\Plugin\views\filter\FilterPluginBase;
use Drupal\Core\Language\LanguageInterface;

/**
 * Field handler to filter Geofields by proximity.
 *
 * @ingroup views_field_handlers
 *
 * @ViewsFilter("geofield_bounding_box")
 */
class GeofieldBoundingBox extends FilterPluginBase {

  protected function defaultItems() {
    return ['top', 'right', 'bottom', 'left'];
  }

  /**
   * Provide a simple textfield for equality
   */
  protected function valueForm(&$form, FormStateInterface $form_state) {
    $sides = $this->defaultItems();

    $value = $this->getDecodedValues();

    $form['value'] = [];

    // We use additional selector on exposed forms.
    if ($this->options['exposed']) {
      $langcode = \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId();

      // Terms of City vocabulary.
      $query = \Drupal::entityQuery('taxonomy_term');
      $query->condition('vid', "city");
      $tids_city = $query->execute();
      $terms_city = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->loadMultiple($tids_city);

      $options_city = [];
      $cities_geo_data = [];
      foreach ($terms_city as $tid => $term) {
        $languages = array_keys($term->getTranslationLanguages());
        if (in_array($langcode, $languages)) {
          $term = $term->getTranslation($langcode);
        }

        $term_id = $term->id();
        $term_name = $term->name->value;
        $options_city[$term_id] = $term_name;
        $cities_geo_data[$term_id] = [
          'left' => $term->field_geofield->left,
          'top' => $term->field_geofield->top,
          'right' => $term->field_geofield->right,
          'bottom' => $term->field_geofield->bottom,
        ];
      }

      $form['city'] = [
        //@todo process the default value.
        '#empty_option' => t('Select a city'),
        '#type' => 'select',
        '#title' => t('City'),
        '#options' => $options_city,
        '#attributes' => [
          'class' => ['rbrd-city-switcher', 'rbrd-select2'],
        ],
        '#attached' => [
          'library' => ['rbrd/cityswitcher'],
          'drupalSettings' => [
            'unitGeofieldCities' => $cities_geo_data,
          ],
        ],
      ];
    }

    foreach ($sides as $side) {
      $form['value'][$side] = [
        '#attributes' => [
          'class' => ['rbrd-city-coords', "rbrd-city-coords-{$side}"],
        ],
        '#default_value' => isset($value[$side]) ? $value[$side] : NULL,
        '#type' => 'hidden',
        '#title' => $side,
      ];
    }
  }

  public function query() {
    $this->ensureMyTable();

    $value = $this->getDecodedValues();

    $lat_field = "{$this->tableAlias}.{$this->realField}__lat";
    $lon_field = "{$this->tableAlias}.{$this->realField}__lon";

    // Add top left coordinates.
    if (isset($value['bottom']) && !empty($value['bottom'])) {
      $this->query->addWhere($this->options['group'], $lat_field, $value['bottom'], '>=');
    }
    if (isset($value['top']) && !empty($value['top'])) {
      $this->query->addWhere($this->options['group'], $lat_field, $value['top'], '<=');
    }

    // Add bottom right coordinates.
    if (isset($value['right']) && !empty($value['right'])) {
      $this->query->addWhere($this->options['group'], $lon_field, $value['right'], '<=');
    }
    if (isset($value['left']) && !empty($value['left'])) {
      $this->query->addWhere($this->options['group'], $lon_field, $value['left'], '>=');
    }
  }

  /**
   * Do some minor translation of the exposed input
   */
  public function acceptExposedInput($input) {
    if (empty($this->options['exposed'])) {
      return TRUE;
    }

    // rewrite the input value so that it's in the correct format so that
    // the parent gets the right data.
    if (!empty($this->options['expose']['identifier'])) {
      $input[$this->options['expose']['identifier']] = [];
      foreach ($this->defaultItems() as $side) {
        $input[$this->options['expose']['identifier']][$side] = $input[$side];
      }
    }

    $rc = parent::acceptExposedInput($input);

    return $rc;
  }

  /**
   * {{ @inheritdoc }}
   */
  protected function valueSubmit($form, FormStateInterface $form_state) {
    parent::valueSubmit($form, $form_state);
    $key = ['options', 'value'];
    $real_value = $form_state->getValue($key);
    $form_state->setValue($key, $this->encodeValues($real_value));
  }

  /**
   * Display the filter on the administrative summary
   */
  public function adminSummary() {
    return $this->operator;
  }

  /**
   * Decodes a filter value from string to the properly structured array.
   *
   * @return array
   *   Assoc. array of coordinate filter values.
   */
  protected function getDecodedValues() {
    $id = $this->options['expose']['identifier'];
    $input = $this->view->getExposedInput();
    $value = isset($input[$id]) ? $input[$id] : $this->value;

    if (!is_array($value)) {
      // Prepare exposed value.
      $value = str_replace(' ', '+', $value);
      // We assume that data is valid. Obviously, it's better to have a validation here.
      $sub_values = explode('+', $value);
      $value = [];
      foreach ($this->defaultItems() as $key => $str_key) {
        // Direct input has a higher weight than get param.
        if (isset($input[$str_key])) {
          $value[$str_key] = $input[$str_key];
          continue;
        }
        $value[$str_key] = isset($sub_values[$key]) ? $sub_values[$key] : 0;
      }
    }

    return $value;
  }

  /**
   * Casts a filter value to string.
   *
   * @param array $real_value
   *   Assoc array containing top/bottom/right/left values.
   *
   * @return null|string
   *   Stringified filter value.
   */
  protected function encodeValues($real_value) {
    $value = '';

    if (!empty($real_value) && is_array($real_value)) {
      $value = implode('+', $real_value);
    }

    return $value;
  }
}

The most important methods to extend are:

  • valueForm() - which allows to manipulate the filter form.
  • query() - allows to hook-into the query and add all conditions you need. Be careful with it and follow the best practices. Do not add any sortings/groupings/joins in the filter plugin. It can mislead your teammates.

In our case, we also implemented/override few more methods.
acceptExposedInput() - Determines if the input from a filter should change the generated query. (copy/pasted the comment from the FilterPluginBase.php, I think it’s self-descriptive)
valueSubmit() - the logic is a bit “dirty”, but we needed it to make it work with 4 real inputs for bounding box coordinates and one pseudo-input to prefill the coordinates. Normally, you won’t need to override this thing. Though, if you started overriding it, remember: you should follow the structure of the parent form. You need to declare your form elements inside of a $form[‘value’], otherwise, you may get a bunch of notices from the parent plugin class.

And the last, but not the least. You need to allow using the newly created filter by appropriate fields. You may use hook_views_data(), hook_views_data_alter(), or describe everything in EntityViewsData file of your entity. We used the last option. Below is the snippet, where you can see the ‘filter’ key and just a few items, which are self-descriptive, I suppose.

$data['unit_field_data']['unit_geofield'] = [
  'title' => $this->t('Unit Geofield square'),
  'help' => $this->t('Unit Geofield square.'),
  'argument' => [
    'id' => 'rbrd_geofield_argument',
  ],
  'filter' => [
    'title' => $this->t('Unit Bounding Box'),
    'id' => 'geofield_bounding_box'
  ],
];

Conclusion

In my opinion, Bounding Box filter is not the best example of the filter plugin for views. Though it serves two purposes. 
It does the map filtering on the boardr.co.
And it’s a small enough snippet of code to describe it in a blog.
I hope these development notes will be useful for somebody and will help to accomplish development needs.

Some handy notes: