Filter content by year with Views on Drupal 8

A calendar on smartphone

It is not uncommon to propose to filter contents according to dates, and in particular depending on the year. How to filter content from a view based on years from a date field? We have an immediate solution using the Search API module coupled with Facets. This last module allows us very easily to add a facet, to a view, based on a date field of our content type, and to choose the granularity (year, month, day) that we wish to expose to the visitors. But if you do not have these two modules for other reasons, it may be a shame to install them just for that. We can get to our ends pretty quickly with a native Views option, the contextual filter. Let's discover in a few images how to get there.

Creating the content view to be filtered by year

Let's say we have a content type named bulletin, which has a Date field, and we want to filter by year. To do this, we designed a view that lists all bulletin contents. Here is the general, very classic, configuration of the view.

View bulletin general configuration

We distinguish in the view's settings a section Contextual filters. These contextual filters can be added and configured to a view in order to be able to filter its results from these filters. These filters can be configured to be provided from the view URL, or from a specific context, from a particular field of a current content (if we display the view beside a content), from a parameter in the request, etc. In fact the possibilities are endless and we can also provide our own logic to contextual filters very easily by implementing a Views Plugin type @ViewsArgumentDefault. Finally, these contextual filters are created in relation to a particular field of the contents that you visualize with Views.

Adding and configuring the contextual argument

We will add a contextual filter to our view, using somewhat specific field types that allow us to get aggregate values ​​of Date fields. We click on the button to add a contextual argument.

Add contextual filter

 

And we will look for our Date field (field_date) of our bulletin content type in an aggregated form per year (Date in the form YYYY).

After validating our choice, we get the control panel of our contextual filter.

settings contextual filter

Since we want to filter our content from a query parameter, we will configure the panel When the filter value is not in the URL. Note that if we had wanted to filter these contents from the URL directly (for example with a URL type /bulletins/2017) the configuration is almost nonexistent.

We will choose the Provide default value option, and select a query parameter as the default value type. We give a name to our parameter (year), supply it a fallback value if the query parameter is not present (all). And we set an exception value (all), which, if it is received by our query parameter, will let us ignore our argument, and therefore show all results.

All we have to do is save and test our view.

view filtered by year

And we simply add to the URL the parameter ?year=2017 to filter the contents of the year 2017.

Adding a Custom Exposed Filter

All we have to do now is add a select list in the exposed filters of the view to offer the visitor an interface to select the desired year.

You noted in the general view configuration that the Tags field was added as a filter criteria and was also exposed. This is not innocent, because it allows us to get the exposed filter form already operational, form in which we will only have to add our custom option for the years.

To do this, we create a small module, which we call my_module, and we will alter this form.

use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;

/**
 * Implements hook_form_FORM_ID_alter().
 */
function my_module_form_views_exposed_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  if (isset($form['#id']) && $form['#id'] == 'views-exposed-form-bulletins-page') {
    $options = [
      'all' => t('- All -'),
      '2015' => '2015',
      '2016' => '2016',
      '2017' => '2017',
    ];

    $form['year'] = [
      '#title' => new TranslatableMarkup('By year'),
      '#type' => 'select',
      '#options' => $options,
      '#size' => NULL,
      '#default_value' => 'all',
    ];
  }
}

 

We add to the exposed form corresponding to the view whose identifier is bulletins, and to the display whose identifier is page, a simple select element whose year name corresponds to the query parameter set in the contextual filter of the view. And we provide him with some options, hardcoded here, over available years and of course with our exception value all, to be able to return all the results without any filters.

 

Filter year added

There you go. We have a simple view, with a minimum of code, which allows us to filter content by date according to an annual granularity, granularity that we can modify at will by modifying the contextual filter of the view if needed.

Make filter options dynamic

The options we have provided are unlikely to be sustainable over time. What is being done in 2018? Are we changing the code? Let's improve a bit our view to make the years available in the select list dynamic.

We will simply, since the alteration of the exposed form, make a query on all contents bulletin to retrieve all the dates and propose the different available years. But because this type of query can be costly, for a simple select list field, we will use the Drupal Cache API to cache these results and not have to recalculate them each time the page loads.

Let's improve the snippet seen above.

/**
 * Implements hook_form_FORM_ID_alter().
 */
function my_module_form_views_exposed_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  if (isset($form['#id']) && $form['#id'] == 'views-exposed-form-bulletins-page') {

    $options = &drupal_static(__FUNCTION__);
    if (is_null($options)) {
      $cid = 'my_module:bulletin:year';
      $data = \Drupal::cache()->get($cid);
      if (!$data) {
        $options = [];
        $options['all'] = new TranslatableMarkup('- All -');
        $query = \Drupal::entityQuery('node');
        $query->condition('type', 'bulletin')
          ->condition('status', 1)
          ->sort('field_date', 'ASC');
        $result = $query->execute();
        if ($result) {
          $nodes = Node::loadMultiple($result);
          foreach ($nodes as $node) {
            $date = $node->field_date->value;
            if ($date) {
              $date = new DrupalDateTime($date, new DateTimeZone('UTC'));
              $year = $date->format('Y');
              if (!isset($options[$year])) {
                $options[$year] = $year;
              }
            }
          }
        }

        $cache_tags = ['node:bulletin:year'];
        \Drupal::cache()->set($cid, $options, CacheBackendInterface::CACHE_PERMANENT, $cache_tags);
      }
      else {
        $options = $data->data;
      }

    }
    
    $form['year'] = [
      '#title' => new TranslatableMarkup('By year'),
      '#type' => 'select',
      '#options' => $options,
      '#size' => NULL,
      '#default_value' => 'All',
    ];
    
  }
}

Thus the options of the available years will be computed a first time, then recovered directly from the cache of Drupal. We have taken care to add a custom cache tag node:bulletin:year specific to the data stored in cache so as to be able to invalidate them just when it will be necessary. Indeed, we permanently cache the result of our options, and we just have to invalidate these data if, and only if, a new bulletin is created or modified, and if it contains a date whose year is not present in the cached options.

Cache Invalidation

With the magic cache tags, nothing is simpler for a Drupal 8 developer. Let's look at the snippet that will take care of invalidating the cached options.

use \Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Cache\Cache;

/**
 * Implements hook_ENTITY_TYPE_presave().
 */
function my_module_node_presave(EntityInterface $entity) {
  $bundle = $entity->bundle();
  if ($bundle == 'bulletin') {
     // Check if a bulletin updated has a new year, and invalidate the
    // options cached used in the custom views filter for filtering by year.
    $cid = 'my_module:bulletin:year';
    $data = \Drupal::cache()->get($cid);
    if ($data) {
      $options = $data->data;
      $date = $entity->field_date->value;
      if ($date) {
        $date = new DrupalDateTime($date, new DateTimeZone('UTC'));
        $year = $date->format('Y');
        if (!isset($options[$year])) {
          Cache::invalidateTags(['node:bulletin:year']);
        }
      }
    }
  }
}

For each update of a bulletin content, we retrieve the cached options ($data), and then compare the year of the date of the content being saved with the cached values. In case of absence of the year, thanks to the magic cache tags, then we simply need to invalidate the custom cache tag (node:bulletin:year) that we have associated with the cached options. And the next time you load the bulletins view, the options will be recalculated, updated with the new year, and re-cached for an indefinite period, and no doubt annually.

As a conclusion

The contextual filters of the Views module, available with Drupal 8 Core, offer a considerable range of possibilities to cover a wide range of needs. And when these are not enough, a simple implementation of a Plugin, in a few lines, allows us to insert our own business logic into the Views mechanism in a robust and maintainable way, thus conciliating the robustness of Views and the specificities of a project. And finally, for an apparently simple need (filter by year), but far from being as obvious (working with dates is always a source of surprise), these allow to treat the subject in a simple way, without resorting to heavier means. And, often, simples solutions are the more effective solutions. Why make it complicated when it can be simple ?

 

Commentaires

Soumis par Jesse (non vérifié) le 20/10/2017 à 14:11 - Permalien

Hi,

I have following code, could you tell me why it's not working?
I want to filter the view based on the node created time.

/**
* Implements hook_form_FORM_ID_alter().
*/
function custom_views_filter_form_views_exposed_form_alter(&$form, FormStateInterface $form_state, $form_id)
{
if (isset($form['#id']) && $form['#id'] == 'views-exposed-form-news-block-1') {
$options = &drupal_static(__FUNCTION__);
if (is_null($options)) {
$cid = 'custom_views_filter:article:created';
$data = \Drupal::cache()->get($cid);
if (!$data) {
$options = [];
$options['all'] = new TranslatableMarkup('- All -');
$query = \Drupal::entityQuery('node');
$query->condition('type', 'article')
->condition('status', 1)
->sort('created', 'ASC');
$result = $query->execute();
if ($result) {
$nodes = \Drupal\node\Entity\Node::loadMultiple($result);
foreach ($nodes as $node) {
$date = $node->getCreatedTime();
if ($date) {
$year = \Drupal::service('date.formatter')->format($date, 'html_year');
if (!isset($options[$year])) {
$options[$year] = $year;
}
}
}
}

$cache_tags = ['node:article:created'];
\Drupal::cache()->set($cid, $options, CacheBackendInterface::CACHE_PERMANENT, $cache_tags);
}
else {
$options = $data->data;
}

}
// kint($options);

$form['year'] = [
'#type' => 'select',
'#options' => $options,
'#size' => NULL,
'#default_value' => '2017',
];
}
}

Ajouter un commentaire