Range slider for the facets exposed filters

Created on 30 January 2025, 3 months ago

Problem/Motivation

Range slider doesn't work when using facets exposed filters.
There is error when enabling Range slider processor
https://www.drupal.org/project/facets/issues/3487002#comment-15867386 πŸ› Facets Exposed Filters - DateItemProcessor not working Active

Steps to reproduce

Add facets exposed filter to a view. Try to enable Range slider processor for it.

Proposed resolution

I've debugged code and it produces the error because there is no actually widget in the facet. It's just string ''. So we need solution for such cases. And as we don't have facet widget for the facets exposed filters and it can be a problem to get the exposed filters settings of the facet inside processor, we need some new solution for the facets exposed filters.

So I decided to create custom solution in custom module (at this moment while it's not fully ready) to use some functionality from the range slider in the facets_range_widget module and by using custom widget which extends the better_exposed_filters Sliders widget.

So my current suggested code is:

Custom query type plugin:

<?php

namespace Drupal\custom_facets\Plugin\facets\query_type;

use Drupal\facets\Plugin\facets\query_type\SearchApiRange;
use Drupal\search_api\Query\QueryInterface;

/**
 * Provides support for range facets within the Search API scope.
 *
 * This is the default implementation that works with all backends.
 *
 * @FacetsQueryType(
 *   id = "custom_search_api_range",
 *   label = @Translation("Custom Range"),
 * )
 */
class CustomSearchApiRange extends SearchApiRange {

  /**
   * {@inheritdoc}
   */
  public function execute() {
    $query = $this->query;

    $operator = $this->facet->getQueryOperator();
    $field_identifier = $this->facet->getFieldIdentifier();
    $exclude = $this->facet->getExclude();

    if ($query->getProcessingLevel() === QueryInterface::PROCESSING_FULL) {
      // Set the options for the actual query.
      $options = &$query->getOptions();
      $options['search_api_facets'][$field_identifier] = $this->getFacetOptions();
    }

    // Add the filter to the query if there are active values.
    $active_items = $this->facet->getActiveItems();

    if (count($active_items) && isset($active_items['min']) && isset($active_items['max'])) {
      $filter = $query->createConditionGroup($operator, ['facet:' . $field_identifier]);
      $filter->addCondition($field_identifier, [
        $active_items['min'],
        $active_items['max'],
      ], $exclude ? 'NOT BETWEEN' : 'BETWEEN');
      $query->addConditionGroup($filter);
    }
  }
}

Code which enables custom query type:

/**
 * Implements hook_facets_search_api_query_type_mapping_alter().
 */
function custom_facets_facets_search_api_query_type_mapping_alter($backend_plugin_id, array &$query_types) {
  if (isset($query_types['range'])) {
    // Add our range query type.
    $query_types['custom_range'] = 'custom_search_api_range';
  }
}

Custom processor:

<?php

namespace Drupal\custom_facets\Plugin\facets\processor;

use Drupal\Core\Form\FormStateInterface;
use Drupal\facets\FacetInterface;
use Drupal\facets\Processor\PostQueryProcessorInterface;
use Drupal\facets\Processor\ProcessorPluginBase;
use Drupal\facets\Result\Result;
use Drupal\facets\Result\ResultInterface;

/**
 * Provides a processor that adds all range values between an min and max range.
 *
 * @FacetsProcessor(
 *   id = "custom_facets_exposed_range_slider",
 *   label = @Translation("Custom Facets Exposed Range Slider"),
 *   description = @Translation("Add results for all the steps between min and max range."),
 *   stages = {
 *     "post_query" = 60,
 *   }
 * )
 */
class CustomFacetsExposedRangeSliderProcessor extends ProcessorPluginBase implements PostQueryProcessorInterface {

  /**
   * {@inheritdoc}
   */
  public function postQuery(FacetInterface $facet): void {
    $results = $facet->getResults();

    if (count($results) === 0) {
      return;
    }

    // phpcs:ignore
    uasort($results, function (ResultInterface $a, ResultInterface $b) {
      if ($a->getRawValue() === $b->getRawValue()) {
        return 0;
      }
      return $a->getRawValue() < $b->getRawValue() ? -1 : 1;
    });

    $step = $this->configuration['step'];

    // Round displayed values to step's precision.
    $precision = strlen(substr(strrchr($step, "."), 1));

    $minResult = reset($results);
    $minValue = number_format($this->floorPlus((float) $minResult->getRawValue(), $precision), $precision, '.', '');

    $maxResult = end($results);
    $maxValue = number_format($this->ceilPlus((float) $maxResult->getRawValue(), $precision), $precision, '.', '');

    // Overwrite the current facet values with the generated results.
    $facet->setResults([
      new Result($facet, $minValue, $minValue, $minResult->getCount()),
      new Result($facet, $maxValue, $maxValue, $maxResult->getCount()),
    ]);
  }

  /**
   * Custom ceil function with support for precision rounding.
   *
   * @param float $value
   *   The number to be rounded down.
   * @param int|null $precision
   *   The optional precision for the rounding.
   *
   * @return float
   *   The resulted rounded number.
   */
  private function ceilPlus(float $value, ?int $precision = NULL): float {
    if (NULL === $precision) {
      return (float) ceil($value);
    }
    if ($precision < 0) {
      throw new \RuntimeException('Invalid precision');
    }
    $reg = $value + 0.5 / (10 ** $precision);
    return round($reg, $precision, $reg > 0 ? PHP_ROUND_HALF_DOWN : PHP_ROUND_HALF_UP);
  }

  /**
   * Custom floor function with support for precision rounding.
   *
   * @param float $value
   *   The number to be rounded up.
   * @param int|null $precision
   *   The optional precision for the rounding.
   *
   * @return float
   *   The resulted rounded number.
   */
  private function floorPlus(float $value, ?int $precision = NULL): float {
    if (NULL === $precision) {
      return (float) floor($value);
    }
    if ($precision < 0) {
      throw new \RuntimeException('Invalid precision');
    }
    $reg = $value - 0.5 / (10 ** $precision);
    return round($reg, $precision, $reg > 0 ? PHP_ROUND_HALF_UP : PHP_ROUND_HALF_DOWN);
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(array $form, FormStateInterface $form_state, FacetInterface $facet) {
    $config = $this->getConfiguration();

    $build = [];
    // We are using config form in the processor as we can't get settings from
    // the exposed form widget from a facet (without widget) there.
    $build['step'] = [
      '#type' => 'number',
      '#step' => 0.001,
      '#title' => $this->t('slider step'),
      '#default_value' => $config['step'],
      '#size' => 2,
    ];

    return $build;
  }

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration() {
    return [
      'step' => 1,
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getQueryType() {
    return 'custom_range';
  }

}

And custom widget based on the Sliders widget from the bef 7.x module:
<?php

namespace Drupal\custom_facets\Plugin\better_exposed_filters\filter;

use Drupal\better_exposed_filters\Plugin\better_exposed_filters\filter\Sliders;
use Drupal\Core\Form\FormStateInterface;
use Drupal\facets_exposed_filters\Plugin\views\filter\FacetsFilter;

/**
* Custom Facets Exposed Sliders widget implementation.
*
* @BetterExposedFiltersFilterWidget(
* id = "custom_facets_exposed_sliders",
* label = @Translation("Custom Facets Exposed Sliders"),
* )
*/
class CustomFacetsExposedSliders extends Sliders {

/**
* {@inheritdoc}
*/
public static function isApplicable(mixed $handler = NULL, array $options = []): bool {
return ($handler instanceof FacetsFilter) && !empty($handler->options['facet']['processor_configs']['custom_facets_exposed_range_slider']['settings']);
}

/**
* {@inheritdoc}
*/
public function exposedFormAlter(array &$form, FormStateInterface $form_state): void {
parent::exposedFormAlter($form, $form_state);

/** @var \Drupal\facets_exposed_filters\Plugin\views\filter\FacetsFilter $filter */
$filter = $this->handler;
$filter_id = $filter->options['expose']['identifier'];
if (empty($form[$filter_id]['#type'])) {
return;
}

$processor_settings = $filter->options['facet']['processor_configs']['custom_facets_exposed_range_slider']['settings'];

/** @var array $exposed_input */
$exposed_input = $this->view->getExposedInput()[$filter_id] ?? [];
$exposed_input_min = $exposed_input['min'] ?? '';
$exposed_input_max = $exposed_input['max'] ?? '';

$facet_results = $filter->facet_results;

/** @var \Drupal\facets\Result\Result|null $min_result */
$min_result = $facet_results[0] ?? NULL;
/** @var \Drupal\facets\Result\Result|null $max_result */
$max_result = $facet_results[1] ?? NULL;

$min_result_value = $min_result?->getRawValue() ?? 0;
$max_result_value = $max_result?->getRawValue() ?? 0;

// Set the slider's settings.
$form[$filter_id]['#attached']['drupalSettings']['better_exposed_filters']['slider_options'][$filter_id]['min'] = $min_result_value;
$form[$filter_id]['#attached']['drupalSettings']['better_exposed_filters']['slider_options'][$filter_id]['max'] = $max_result_value;
$form[$filter_id]['#attached']['drupalSettings']['better_exposed_filters']['slider_options'][$filter_id]['step'] = $processor_settings['step'] ?? 1;

// Enable tooltips.
$form[$filter_id]['#attached']['drupalSettings']['better_exposed_filters']['slider_options'][$filter_id]['tooltips'] = $this->configuration['enable_tooltips'];
$form[$filter_id]['#attached']['drupalSettings']['better_exposed_filters']['slider_options'][$filter_id]['tooltips_value_prefix'] = $this->configuration['tooltips_value_prefix'];
$form[$filter_id]['#attached']['drupalSettings']['better_exposed_filters']['slider_options'][$filter_id]['tooltips_value_suffix'] = $this->configuration['tooltips_value_suffix'];

$min_default_value = '';
if ($min_result && $min_result_value != $exposed_input_min) {
$min_default_value = $exposed_input_min;
}
$max_default_value = '';
if ($max_result && $max_result_value != $exposed_input_max) {
$max_default_value = $exposed_input_max;
}

$new_element = [
'#type' => 'fieldset',
'#attributes' => ['class' => 'custom-facets-exposed-slider'],
'#title' => $form[$filter_id]['#title'],
$filter_id => [
'#tree' => TRUE,
'min' => [
'#type' => 'textfield',
'#title' => $this->t('Min'),
'#size' => 30,
'#default_value' => $min_default_value,
'#title_display' => 'invisible',
'#attributes' => [
'class' => [
'visually-hidden',
],
],
],
'max' => [
'#type' => 'textfield',
'#title' => $this->t('Max'),
'#size' => 30,
'#default_value' => $max_default_value,
'#title_display' => 'invisible',
'#attributes' => [
'class' => [
'visually-hidden',
],
],
],
'min_max_labels' => [
'#type' => 'container',
'#attributes' => [
'class' => ['slider-min-max-labels'],
],
'min_value' => [
'#type' => 'html_tag',
'#tag' => 'span',
'#attributes' => [
'class' => ['min-label'],
],
'#value' => $min_result_value,
],
'max_value' => [
'#type' => 'html_tag',
'#tag' => 'span',
'#attributes' => [
'class' => ['max-label'],
],
'#value' => $max_result_value,
],
],
],
'#context' => $form[$filter_id]['#context'],
];

$form[$filter_id] = [
$filter_id . '_wrapper' => $new_element,
'#attached' => $form[$filter_id]['#attached'],
];
}

/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
$form = parent::buildConfigurationForm($form, $form_state);

// Hide parent's widget fields as we can't get the exposed form's
// configs in the facet processors for the 'step'
// (see code of the custom_facets_exposed_range_slider processor) and
// support of other configs has not been implemented in the custom widget.
$field_ids = [
'min',
'max',
'step',
'animate',
'animate_ms',
'orientation',
];
foreach ($field_ids as $field_id) {
$form[$field_id]['#access'] = FALSE;
}

return $form;
}

}

Remaining tasks

It works when using just this filter, but when I try to apply another facets exposed filters, the custom slider filter gets min and max values from the results and it breaks functionality when we reset active filter - the slider remains on previous min and max values from the previous results.

So I'm trying to find solution how to get min and max values regardless of the selected other filters.
Is it possible with the facets exposed filters?
I tried to change query in custom query type plugin to unset all conditions but it breaks other facets - they just don't filter.
Any ideas how it can be fixed?

User interface changes

API changes

Data model changes

πŸ› Bug report
Status

Active

Version

3.0

Component

Code

Created by

πŸ‡ΊπŸ‡¦Ukraine khiminrm

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

  • Issue created by @khiminrm
  • πŸ‡ΊπŸ‡¦Ukraine khiminrm

    Looks like I've found solution how to get min and max values regardless active other filter.
    By making additional query without other active facets in custom query type processor's build() method:

      /**
       * {@inheritdoc}
       */
      public function build() {
        $field_identifier = $this->facet->getFieldIdentifier();
        $execute_additional_query = FALSE;
        // Check if there are any other active facet filters,
        // if yes - we need to unset them and execute additional query to get min and max.
        $new_query = clone $this->query->getOriginalQuery();
        $condition_groups = &$new_query->getConditionGroup()->getConditions();
        foreach ($condition_groups as $group_key => $condition_group) {
          if (!($condition_group instanceof ConditionGroupInterface)) {
            continue;
          }
          $tags = $condition_group->getTags();
          if (empty($tags) || $condition_group->hasTag('facet:' . $field_identifier)) {
            continue;
          }
          $has_facet_tags = FALSE;
          foreach ($tags as $tag) {
            if (str_starts_with($tag, 'facet:')) {
              $has_facet_tags = TRUE;
              break;
            }
          }
          if (!$has_facet_tags) {
            continue;
          }
          $conditions = &$condition_group->getConditions();
          foreach ($conditions as $key => $condition) {
            if ($condition instanceof ConditionInterface &&
              $condition->getField() !== $field_identifier) {
              unset($conditions[$key]);
              $execute_additional_query = TRUE;
              if (!count($conditions)) {
                unset($condition_groups[$group_key]);
              }
            }
          }
        }
    
        if ($execute_additional_query) {
          $facets = $this->query->getOption('search_api_facets');
          $new_query->setOption('search_api_facets', [$field_identifier => $facets[$field_identifier]]);
          $new_query->setProcessingLevel(QueryInterface::PROCESSING_NONE);
          $new_query->getOption('search_api_view', NULL);
          $this->results = $new_query->execute()->getExtraData('search_api_facets')[$field_identifier] ?? [];
        }
    
        parent::build();
      }
Production build 0.71.5 2024