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',
];
}
}

Soumis par Srividya (non vérifié) le 09/02/2018 à 05:22 - Permalien

This is a very good blog. Thank you for the same. Can you please help me how to modify the submit handler so that it can work for ajax submission also?
How should the arguments be set in the case of ajax?

thanks.

Soumis par G. (non vérifié) le 26/02/2018 à 16:45 - Permalien

Thanks for this great article!
I have a bug... I'm trying to do the same with "month" (I've already set the "Year" filter). I've an error when I set "all" to "fallback value". This is the error : "InvalidArgumentException: The timestamp must be numeric. in Drupal\Component\Datetime\DateTimePlus::createFromTimestamp() (line 198 of /web/core/lib/Drupal/Component/Datetime/DateTimePlus.php)."
Have you an idea? Thanks again!

Soumis par G. (non vérifié) le 26/02/2018 à 18:44 - Permalien

En réponse à par G. (non vérifié)

Ok, I've found a workaroud... I put "1" instead of "all" (in view fallback and exception), and into my my_module_form_views_exposed_form_alter function! $months_list[1] = "- Any -". I hope it helps someone with the same problem :)

Soumis par Anonyme (non vérifié) le 12/07/2018 à 16:55 - Permalien

Something must be missing in this article. When I follow the first steps (until right before “Adding a Custom Exposed Filter”) then there is no filter shown in the frontend at all. Not even an empty select box.

Where does the dropdown “Thème” come from in this example? How do I activate such a filter? A contextual filter created as described above does not provide any UI.

Soumis par Brian Lee (non vérifié) le 25/07/2018 à 22:21 - Permalien

I have a similar solution - instead of using contextual filters, I have an exposed date filter using BETWEEN. They are hidden using CSS, and are filled out "in the background" in the views_exposed_form submit. In that submit function, you can using $form_state->setValue('field_start_date_time_value', 'min', '2018-01-01') and $form_state->setValue('field_start_date_time_value', 'max', '2019-01-01') to get everything in 2018. Similar,y month would be 2018-01-01 to 2018-02-01. Obviously you can substitute any part of that date with variables from the custom filters you create as shown in the guide.

Soumis par Zhe (non vérifié) le 28/03/2019 à 15:35 - Permalien

Thank you sharing, I did this and used type 'checkboxes', everything works perfect. But when I tried to checked more than 2 option, it won`t return any result

Soumis par Zhe (non vérifié) le 28/03/2019 à 19:51 - Permalien

Thank you for sharing ! I follwoed this and it works perfectly. But whem I tried to select 2 or more option, it shows not result found. In drupal I have multiple values set to or.

Soumis par Steen (non vérifié) le 16/05/2019 à 12:15 - Permalien

Thanx - great example!

One thing that seems a bit buggy, as far as i can see, is that if you only need the year filter, you need to add an extra exposed filter option to create the exposed form that you subsequently alter.

You also might consider adding a hook_exposed_entity_delete - to do mostly the same as the hook_exposed_views_pre_build - to invalidate cache-tags when a node is deleted.

Soumis par jesse (non vérifié) le 18/10/2019 à 17:32 - Permalien

Nice post.
Would the year filter work if Ajax was enabled?

Soumis par Uriel Frazier (non vérifié) le 06/08/2021 à 22:55 - Permalien

En réponse à par jesse (non vérifié)

Instead of using the form alter hook to edit the contextual filter, you can delete/omit the contextual filter, add a regular exposed filter (in my case, it was based on the field "year") and used the form alter hook to manipulate that. Not sure why the original tutorial had us creating a contextual filter, because editing the already available field (date) filter worked just fine.

Here's a part of the code you would use as a replacement...

....
$options[''] = new TranslatableMarkup('- All -');
...

$default_year = $form_state->getUserInput()['field_year_value'];
$form['field_year_value'] = [
'#title' => new TranslatableMarkup('By year'),
'#type' => 'select',
'#options' => $options,
'#size' => NULL,
'#default_value' => '',
'#value' => $default_year,
];

Soumis par AlexMazaltov (non vérifié) le 23/07/2021 à 12:30 - Permalien

https://www.drupal.org/project/views_year_filter
You can alter form and set Exposed filter for year to be not text but select

//Get query parameter from POST vars:
//$field_date_value_ajax = \Drupal::request()->request->get('field_date_value'); // form param
//Incidentally, for GET vars, you would use:
$field_date_value_get = \Drupal::request()->query->get('field_date_value');

$form['field_date_value'] = [
'#type' => 'select',
'#options' => $options,
'#size' => NULL,
'#value' => $field_date_value_get ?: date('Y', time()),
];

Ajouter un commentaire