You are here

filter_harmonizer.module in Views Filter Harmonizer 1.0.x

Same filename and directory in other branches
  1. 8 filter_harmonizer.module
  2. 7 filter_harmonizer.module

filter_harmonizer.module

Harmonizes contextual with regular filter when both exist on the same View.

File

filter_harmonizer.module
View source
<?php

use Drupal\Core\Routing\RouteMatch;
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\argument\ArgumentPluginBase;
use Drupal\views\Plugin\views\filter\FilterPluginBase;
use Drupal\Core\Render\Element\Form;

/**
 * @file
 * filter_harmonizer.module
 *
 * Harmonizes contextual with regular filter when both exist on the same View.
 */

// Set by filter_harmonizer_views_pre_build() implementation and read in
// filter_harmonizer_page_attachments()
$filter_harmonizer_filter_pairs = [];

/**
 * Implements hook_views_pre_build().
 */
function filter_harmonizer_views_pre_build(ViewExecutable $view) {
  global $filter_harmonizer_filter_pairs;
  $filter_harmonizer_filter_pairs = [];

  // If this view has both a regular filter and a contextual filter argument,
  // and is requested to be harmonized on the settings page, then harmonize it
  // and return filter pair data for later use during this this HTTP request.
  if (!($config = Drupal::config('filter_harmonizer.settings'))) {
    return;
  }
  $harmonized_view_ids = $config
    ->get('filter_harmonizer_harmonized_view_ids') ?? [];
  if (!in_array($view
    ->id(), $harmonized_view_ids)) {
    return [];
  }
  $filter_harmonizer_filter_pairs = filter_harmonizer_harmonize_and_record_filter_pairs($view);
}

/**
 * When a View on a page is first loaded and if a context filter is present,
 * we obey any associated /arg1/arg2/.. on the browser URL/address bar.
 * Then, if regular exposed filters are present too for fields by the same
 * names, and their form values are submitted, we obey those submitted values,
 * discarding the contextual filter values used on initial page load.
 *
 * For clarity and visual feedback to the user, we support the option to
 * reflect, on the regular exposed filter form, the equivalent of the contextual
 * argument value as appearing in the URL.
 * Conversely, after exposed regular form values were selected and submitted,
 * there's the option to reflect this in the browser address bar URL, as
 * contextual arguments. Using contextual URL arguments is more concise then
 * the verbose URL query string format, i.e. "?...".
 *
 * The page with its filter settings can thus be concisely shared by copying the
 * browser address bar and sending it in emails and on social media.
 *
 * @param ViewExecutable $view
 *   The View. Its contextual or regular filters may be set in this call.
 * @return array
 *   An array with info about regular+contextual filter pairs on the View.
 */
function filter_harmonizer_harmonize_and_record_filter_pairs(ViewExecutable $view) {
  $view_id = $view
    ->id();
  $display = $view
    ->getDisplay();

  // e.g. 'block' or 'page' object
  $regular_filter_inputs = $display
    ->getPlugin('exposed_form') ? $view
    ->getExposedInput() : [];
  $display_id = $display->display['id'];

  // e.g. 'block_5' or 'page_1'
  $request = Drupal::request();
  $filter_pairs = [];

  //$pager = $display->getPlugin('pager');

  //$is_paged = !($pager instanceof Drupal\views\Plugin\views\pager\None);

  // Examine the current display being executed on the View and see if it has
  // both a non-empty contextual and a non-empty (exposed) regular filter,
  // operating on the same field. If it does "harmonize".
  // Also return essential filter data for later use eg. in
  // hook_page_attachments(), which is required to add JavaScript to the page,
  // while not having direct access to the View filters on the page.
  $regular_filters = $display
    ->getHandlers('filter');
  $contextual_filters = $display
    ->getHandlers('argument');
  $active_regular_filter_count = 0;
  $i = -1;
  foreach ($contextual_filters as $field_name => $contextual_filter) {

    // Temporary store filter values for easy access during this request.
    $filter_pair =& $filter_pairs[$view_id][$display_id][$field_name];

    // Record $contextual_filter->argument (from URL) as we need it now.
    // Normally this is done later in ViewExecutable::build($display_id).
    // We need to record all contextual filters, even those that do not have
    // a companion regular filter, in order to correctly apply URL argument
    // substitution, using '/all' for skipped filters.
    $i++;
    if ($is_contextual_filter_present = isset($view->args[$i])) {
      $contextual_filter->position = $i;
      $contextual_filter
        ->setArgument($view->args[$i]);
      $filter_pair['contextual']['argument'] = $contextual_filter->argument;
    }
    $filter_pair['contextual']['exception'] = $contextual_filter->options['exception']['value'];

    // eg. 'all';
    // Collect the companion regular filter value by the same name, if present.
    if (isset($regular_filters[$field_name])) {
      $regular_filter = $regular_filters[$field_name];
      $filter_pair['field_label'] = $regular_filter->definition['title'];
      $field_alias = $regular_filter->options['exposed'] ? $regular_filter->options['expose']['identifier'] : $field_name;
      $filter_pair['regular']['alias'] = $field_alias;
      $is_regular_filter_active = isset($regular_filter_inputs[$field_alias]);
      if ($is_regular_filter_active) {
        $active_regular_filter_count++;
        $filter_pair['regular']['value'] = $regular_filter->value = $regular_filter_inputs[$field_alias];
        $filter_pair['regular']['stringified'] = filter_harmonizer_stringify_regular_filter($regular_filter, $contextual_filter);
      }
      if ($is_contextual_filter_present) {
        if ($is_regular_filter_active) {

          // User has submitted regular filter values. So we have two competing
          // filters. Let's harmonize! Deactivate contextual now, before the
          // WHERE clause is generated that stuffs things up.
          unset($view->args[$i]);

          // Title now no longer reflects contextual filter value.
          // Better to have a blank title than a wrong title...
          $contextual_filter->options['title'] = '';
        }
        elseif (Drupal::config('filter_harmonizer.settings')
          ->get('filter_harmonizer_contextual_args_in_exposed_form')) {

          // No regular filter value set, get it from contextual arg.
          $regular_filter_inputs[$field_alias] = filter_harmonizer_formalize_contextual_arg($regular_filter, $contextual_filter);
        }
      }
    }
  }
  if (!$active_regular_filter_count && ($regular_filter_inputs = array_filter($regular_filter_inputs))) {
    $view
      ->setExposedInput($regular_filter_inputs);
  }
  return $filter_pairs;
}

/**
 * Returns TRUE when the supplied regular filter info is empty or incomplete.
 *
 * By default filter $value is considered empty when it:
 * -- equals NULL or the empty string '' or an empty array, or
 * -- when all of its array elements
 *    - equal NULL or '' or an empty array, or
 *    - are arrays with elements that equal NULL or '' or an empty array
 *
 * @param FilterPluginBase $filter
 *   Contains the (limit) value and operator.
 */
function filter_harmonizer_is_empty(FilterPluginBase $filter) {
  if (!$filter || !isset($filter->value)) {
    return TRUE;
  }

  // This covers most simple regular filters...
  $is_empty = $filter->value == '' || $filter->value === [] || empty($filter->no_operator) && empty($filter->operator);

  // And here's for the more complex ones, e.g. multi-valued ones.
  if (!$is_empty && is_array($filter->value)) {
    foreach ($filter->value as $key => $val) {
      if ($is_empty = !isset($val) || $val === [] || $val == '') {
        break;
      }
    }
  }

  // Let other modules modify the meaning of $is_empty before we return it.
  // Example: Geofield Proximity filter.
  Drupal::moduleHandler()
    ->invokeAll('filter_harmonizer_is_empty', [
    &$is_empty,
    $filter,
  ]);
  return $is_empty;
}

/**
 * Takes a regular filter and outputs its filter value(s) as a string for later
 * use as a contextual argument in the browser URL.
 * For example a GeofieldProximityFilter value may return "-37.8,144.9<=100km".
 *
 * @param FilterPluginBase $regular_filter.
 *   The regular filter handler with its (exposed) values and optional operator.
 * @param ArgumentPluginBase $contextual_filter.
 *   The contextual filter whose arg will be calculated from the regular value.
 *
 * @return string
 *   The contextual argument string for use on the browser address bar.
 */
function filter_harmonizer_stringify_regular_filter(FilterPluginBase $regular_filter, ArgumentPluginBase $contextual_filter) {
  if (filter_harmonizer_is_empty($regular_filter)) {
    return $contextual_filter->argument ?: $contextual_filter->options['exception']['value'];
  }
  $value = Drupal::moduleHandler()
    ->invokeAll('filter_harmonizer_stringify_regular_filter', [
    $regular_filter,
  ]);
  if (empty($value) && $value != 0) {
    $value = $regular_filter->value;
  }
  if (is_array($value)) {
    $isAnd = $regular_filter->operator == 'and';
    $delimiter = empty($isAnd) ? '+' : ',';
    $value = implode($delimiter, $value);
  }
  return $value;
}

/**
 * Takes a contextual filter arg and outputs it as form value(s) to later
 * populate the exposed form belonging to a companion regular filter.
 *
* @param FilterPluginBase $regular_filter
*   The regular filter handler whose (exposed) form will be populated from the
*   contextual filter.
* @param ArgumentPluginBase $contextual_filter
*   The contextual filter whose arg will be used to return regular filter form
*   values.
*
* @return string|array
*   A single number or string or array of values to populate the regular filter
*/
function filter_harmonizer_formalize_contextual_arg(FilterPluginBase $regular_filter, ArgumentPluginBase $contextual_filter) {
  $values = Drupal::moduleHandler()
    ->invokeAll('filter_harmonizer_formalize_contextual_arg', [
    $contextual_filter,
    $regular_filter,
  ]);
  if (empty($values)) {

    // If no special implementation has provided the contextual argument in
    // a format we can use on the regular filter form, then use
    // $contextual_filter_argument as is.
    if ($contextual_filter->argument !== $contextual_filter->options['exception']['value']) {
      $values = [];
      $and_ed = explode(',', $contextual_filter->argument);
      foreach ($and_ed as $value) {
        $or_ed = explode('+', $value);

        // If '+' doesn't work, try space instead. This is how '+' usually gets
        // converted into $contextual_filter->argument.
        if (count($or_ed) == 1) {
          $or_ed = explode(' ', $value);
        }
        $values = array_merge($values, $or_ed);
      }
    }
  }

  // If the contextual multiplicity is at odds with the regular filter, make
  // an attempt to sort out the mismatch.
  $is_multi_valued = !empty($regular_filter->options['expose']['multiple']);
  if ($is_multi_valued) {
    if (!is_array($values)) {

      // Convert the single value into an array (ie. a multi-value).
      $values = [
        $values,
      ];
    }
  }
  return $values;
}

/**
 * Implements hook_page_attachments().
 *
 * This is here to implement the feature that reflects the regular filter
 * choice on the browser address bar, using contextual filters, rather than
 * the "?FIELD_NAME1=VALUE1&FIELD_NAME2=VALUE2" format.
 */
function filter_harmonizer_page_attachments(array &$attachments) {
  global $filter_harmonizer_filter_pairs;

  // This is only set if there is a View active on the current page.
  if (empty($filter_harmonizer_filter_pairs)) {
    return;
  }
  if (!Drupal::config('filter_harmonizer.settings')
    ->get('filter_harmonizer_regular_filter_values_in_address_bar')) {
    return;
  }

  // If an (exposed) regular filter was submitted, visually reflect that in the
  // browser address bar.
  $request = Drupal::request();
  $route_path = RouteMatch::createFromRequest($request)
    ->getRouteObject()
    ->getPath();
  $qs = urldecode($request
    ->getQueryString());
  foreach ($filter_harmonizer_filter_pairs as $displays) {
    foreach ($displays as $filters) {
      $arg_values = [];
      $regular_filter_count = 0;
      foreach ($filters as $field_name => $filter_pair) {
        if (isset($filter_pair['regular'])) {
          $regular_filter_count++;
        }

        // If regular filter is empty, take the contextual value (or 'all').
        $arg_values[$field_name] = isset($filter_pair['regular']['value']) ? isset($filter_pair['regular']['stringified']) ? $filter_pair['regular']['stringified'] : $filter_pair['contextual']['exception'] : (isset($filter_pair['contextual']['argument']) ? $filter_pair['contextual']['argument'] : $filter_pair['contextual']['exception']);
      }
      if (!$regular_filter_count) {

        // If we have no active regular filters on this display, then we
        // don't have to update the URL path.
        continue;
      }

      // If the number of /{arg} occurrences in $route_path is not equal to the
      // number of $arg_values we can move to the next display.
      if ($match_count = preg_match_all('/\\/{[^\\/.]+}/', $route_path, $matches)) {
        $matches = reset($matches);

        // used below
      }
      if ($match_count != count($arg_values)) {
        continue;
      }
      $path = $route_path;

      // Now substitute URL, but don't add superfluous trailing "/all"'s.
      // It is assumed that contextual arguments appear in order on the URL.
      // Start at the rear.
      $i = count($arg_values) - 1;
      foreach (array_reverse($arg_values) as $field_name => $value) {
        if (empty($done_exc) && $value == $filters[$field_name]['contextual']['exception']) {

          // Avoid trailing "/all". Wipe the arg.
          $path = str_replace($matches[$i], '', $path);
        }
        else {

          // A non-trailing /all or "normal" value: replace it. Keep the '/'.
          $done_exc = TRUE;
          $path = str_replace($matches[$i], "/{$value}", $path);
        }

        // The Views module appends the regular filter values as rather ugly
        // query-parameters. Example:  ?field_size_value[]=XL
        // These query parameters are no longer required because we've just
        // put the equivalent values in the URL as contextual arguments.
        if (isset($filters[$field_name]['regular'])) {
          $field_alias = $filters[$field_name]['regular']['alias'];
          $pattern = '/' . $field_alias . '\\[.*\\]=.+[&]?/';

          // Remove the query parameter(s) from the query string.
          $qs = preg_replace($pattern, '', $qs);
        }
        $i--;
      }
    }
  }
  if (isset($path) && $path !== $route_path) {
    $path_plus_qs = empty($qs) ? $path : $path . '?' . $qs;
    $attachments['#attached']['drupalSettings']['browser_url'] = $request
      ->getBasePath() . $path_plus_qs;

    // Let's not forget to include the few lines of JS that make this happen!
    $attachments['#attached']['library'][] = 'filter_harmonizer/filter_harmonizer';
  }
}
if (Drupal::moduleHandler()
  ->moduleExists('geofield')) {
  include_once 'includes/filter_harmonizer_for_geofield.inc';
}

Functions

Namesort descending Description
filter_harmonizer_formalize_contextual_arg Takes a contextual filter arg and outputs it as form value(s) to later populate the exposed form belonging to a companion regular filter.
filter_harmonizer_harmonize_and_record_filter_pairs When a View on a page is first loaded and if a context filter is present, we obey any associated /arg1/arg2/.. on the browser URL/address bar. Then, if regular exposed filters are present too for fields by the same names, and their form values are…
filter_harmonizer_is_empty Returns TRUE when the supplied regular filter info is empty or incomplete.
filter_harmonizer_page_attachments Implements hook_page_attachments().
filter_harmonizer_stringify_regular_filter Takes a regular filter and outputs its filter value(s) as a string for later use as a contextual argument in the browser URL. For example a GeofieldProximityFilter value may return "-37.8,144.9<=100km".
filter_harmonizer_views_pre_build Implements hook_views_pre_build().