Nicer date ranges in Drupal – part 2

This is the second part of a series on improving the way date ranges are presented in Drupal, by creating a field formatter that can omit the day, month or year where appropriate, displaying the date ranges in a nicer, more compact form, e.g.:

The first post dealt with porting some existing code from Drupal 7 to Drupal 8, adding an automated test along the way.

In this post, we’ll do some of the work to make the the format customisable:

This is a long post, and we’ll cover a lot of ground. You may want to make a cup of tea before we start.


Configurable formats

Drupal’s core date formats are stored as configuration entities. Each one consists of a single pattern made up of PHP’s date & time formatting symbols. We’ll create another type of configuration entity for date range formats. Each format will need several different patterns:

We can also support times in a similar fashion:

All of these 8 patterns can be stored within a single configuration entity, alongside custom separator text.

Defining a configuration entity

To create the configuration entity, we need some custom code. We can use Drupal console to generate a certain amount of boilerplate code as a starting point. Be aware that Drupal console will produce a lot of code all in one go, including the admin interface which we’ll look at in part 3.

I find it helpful to generate this boilerplate into a temporary location, then copy the files over one at a time, editing them as I go. It slows things down in a good way, and forces me to understand the code I’m going to be responsible for maintaining.

Entity class and definition

Let’s start with the entity class, which lives in src/Entity/DateRangeFormat.php:

<?php
namespace Drupal\daterange_compact\Entity;

class DateRangeFormat extends ConfigEntityBase implements DateRangeFormatInterface {
  /* implementation */
}

In order to tell Drupal that this is a configuration entity, we need to add an annotation to the class. Similar to the annotation on the field formatter in part 1, we’re telling Drupal about the existence of a new date range format configuration entity, plus some information about it.

/**
 * @ConfigEntityType(
 *   id = "date_range_format",
 *   label = @Translation("Date range format"),
 *   config_prefix = "date_range_format",
 *   entity_keys = {
 *     "id" = "id",
 *     "label" = "label",
 *     "uuid" = "uuid"
 *   }
 * )
 */
class DateRangeFormat...

The class adheres to a corresponding DateRangeFormatInterface. We’ll refer to the interface, rather than the entity class directly, elsewhere in the code.

The complete implementation is here.

Schema

End users will create instances of the configuration entity—one per format. These can be represented as a single YAML file, and imported and exported as such. The schema describes the structure of these YAML files, dictating the type of data we’re storing inside the entity.

The schema definition is itself a YAML file, and lives in config/schema/daterange_compact.schema.yml:

daterange_compact.date_range_format.*:
  type: config_entity
  label: 'Date range format config'
  mapping:
    # properties of the config entity

The complete schema implementation is here.

Updating entity definitions

If we look at the status report of our site, we’ll see that there is an error: Mismatched entity and/or field definitions. Each time we add or change code that defines entities, we need to update Drupal’s internal copy of the definitions. We can do this with the drush entup command, and we should get the following output:

The following updates are pending:
date_range_format entity type :
  The Date range format entity type needs to be installed.

Do you wish to run all pending updates? (y/n): y
 [success] Cache rebuild complete.
 [success] Finished performing updates.

That’s the bare minimum for defining a config entity. Right now the only way to manage them is by editing YAML files by hand, but that’s enough to start working on the improved functionality for now. In the next post we’ll look at an administrative interface for editing these formats.

Providing a default entity

Any Drupal module can provide configuration entities as part of their install process. The contact module is a good example—enabling the module will create the feedback and personal contact forms, each configuration entities. You can then change those forms, remove them or add new ones.

Let’s make our module provide a date range format called Medium, following the same naming convention as Drupal’s standard date formats. We do that by providing a file called {$modulename}.{$config_entity_type}.{$machine_name}.yml in the config/install directory.

So the module will contain a config/install/daterange_compact.date_range_format.medium.yml file that looks like the following. You’ll see the properties follow the schema defined earlier. The patterns are made up of PHP date & time formatting symbols.

langcode: en
status: true
dependencies: {  }
id: medium
label: Medium
date_settings:
  default_pattern: 'j F Y'
  separator: ' - '
  same_month_start_pattern: j
  same_month_end_pattern: 'j F Y'
  same_year_start_pattern: 'j F'
  same_year_end_pattern: 'j F Y'
datetime_settings:
  default_pattern: 'j F Y H:i'
  separator: ' - '
  same_day_start_pattern: 'j F Y H:i'
  same_day_end_pattern: 'H:i'

We’ll need to re-install the module for this to take effect, but if we do, and then export the site configuration, we should get a copy of this YAML file along with the rest of the configuration.

A note on cacheability

Normally, whenever we produce some sort of output in Drupal 8, we need to provide its cacheability metdata, which describes how it may be cached. Whenever something changes, the render cache can be examined and anything that depended on it can be cleared.

Certain items, like date formats, are so widely used they a treated differently. From drupal.org:

The DateFormat config entity type entity type affects rendered content all over the place: it’s used pretty much everywhere. It seems appropriate in this case to not set cache tags that bubble up, but to just clear the entire render cache. Especially because it hardly ever changes: it’s a set-and-forget thing.

Invalidating the rendered cache tag should be done sparingly. It’s a very expensive thing to do, clearing the entire render cache. But it’s appropriate here to follow what the core date format entity does.

We need to add this to the @ConfigEntity annotation:

list_cache_tags = { "rendered" }

and this to the DateRangeFormat class:

public function getCacheTagsToInvalidate() {
  return ['rendered'];
}

Refactoring

Until now, the code for rendering date ranges has been part of the field formatter. As it’s getting more complicated, it makes sense to move it out of there into it’s own, distinct location. That means we’ll be able to use it outside of the context of a field.

We’ll use a Drupal service for this. A service is a separate class in which we can handle the business logic independently of field formatters. When Drupal manages a request, it will take care of creating an instance of the service class and making it available to other parts of the system.

Our service class it quite straightforward:

<?php
namespace Drupal\daterange_compact;

class DateRangeFormatter implements DateRangeFormatterInterface {

  function __construct(…) {
    /* implementation */
  }

  function formatDateRange($start_timestamp, $end_timestamp, $type = 'medium', …) {
    /* implementation */
  }

  function formatDateTimeRange($start_timestamp, $end_timestamp, $type = 'medium', …) {
  /* implementation */
  }

}

The complete implementation of DateRangeFormatter is here.

We then tell Drupal about the service by including it in the module’s daterange_compact.services.yml file. We can also specify dependencies on other services, and these will be passed to our object’s constructor.

services:
  daterange_compact.date_range.formatter:
    class: Drupal\daterange_compact\DateRangeFormatter
    arguments: ['@entity_type.manager', '@date.formatter']

Now our formatting functions are available to use within other parts of Drupal via the daterange_compact.date_range.formatter service. We’ll access it from the field formatter next.

You can find more documentation about Drupal 8 services on drupal.org.


Pulling it all together

We need to revisit the field formatter and make a couple of changes. First we remove the hardcoded formatting logic that was there previously, and instead delegate that work to the service. Second, we need a way to let the site builder choose a particular format.

Dependency injection

The field formatter needs to be able access to the new service. Drupal 8 makes use of dependency injection for this sort of thing—a way to access dependencies at runtime without being tied to any particular implementation.

There are a few steps involved in getting to use our service this way:

First, we want it for the lifetime of the field formatter, so it needs to be in the constructor. The constructor for FieldFormatterBase takes quite a lot of parameters. We need to accept them all as well, plus our formatter. Some of the other parameters are hidden with here for clarity.

function __construct(, DateRangeFormatterInterface $date_range_formatter) {
  parent::__construct();

  $this->dateRangeFormatter = $date_range_formatter;
}

Next, we need to state that this formatter makes use of dependency injection. We do that by making the class implement ContainerFactoryPluginInterface, which declares a create function that should be used to create instances. The create function is passed the container, an object from which we can get services by name. In the create function we get the service by name and pass it to the constructor:

static function create(ContainerInterface $container, ) {
  return new static(,
    $container->get('daterange_compact.date_range.formatter')
);

Now we will always have access to a formatter via the $this-dateRangeFormatter variable.

Field formatter settings

Whenever we use this formatter, we can choose a particular date range format. We store that choice in the field formatter settings, once for each time a field is displayed.

Adding field formatter settings is documented quite thoroughly on drupal.org, but it involves a YAML file describing the type of data we want to store, some default settings, a form and a summary.

The YAML file is named field.formatter.settings.{$formatter_name}:

field.formatter.settings.daterange_compact:
  type: mapping
  label: 'Date/time range compact display format settings'
  mapping:
    format_type:
      type: string
      label: 'Date/time range format'

The extra functions we need to implement in the DateRangeCompactFormatter class are as follows:

public static function defaultSettings() {
  /* an array of default values for the settings */
}

public function settingsForm(array $form, FormStateInterface $form_state) {
  /* form from which to choose from a list of formats */
}

public function settingsSummary() {
  /* text describing what format will be used */
}

Now when anyone opts to use the compact formatter to render a date range field, they will be prompted to choose a date range format.

Display

Finally, we have everything we need to render the date range using the chosen format. We can change the viewElements function, removing the hardcoded stuff we had before, and delegating to our date range formatter service:

$format = $settings['format_type'];
$formatter = $this->dateRangeFormatter;
$output = $formatter->formatDate($start_timestamp, $end_timestamp, $format, );

The complete implementation of the formatter is here


Test it!

We’ve added substantial functionality, so we need to make sure there have been no regressions on what we had before. We also want to test the new configurable formats.

The test should pass as before, with one small tweak. In the setUp function, we need to load the configuration for the daterange_compact module, so that the medium format is present.

We’ll also define a new usa format, for US-style month, day, year display. That is created in the setUp function:

protected function setUp() {
  parent::setUp();
  /* existing set up code */
  /* create a new date range format called "usa" */
}

We’ll add another test specifically for rendering the USA format:

function testUSAFormats() {
  $all_data = [
  ['start' => '2017-01-01', 'end' => '2017-01-01', 'expected' => 'Jan 1, 2017'],
  ['start' => '2017-01-02', 'end' => '2017-01-03', 'expected' => 'Jan 2–3, 2017'],
  ['start' => '2017-01-04', 'end' => '2017-02-05', 'expected' => 'Jan 4–Feb 5, 2017'],
  ['start' => '2017-01-06', 'end' => '2018-02-07', 'expected' => 'Jan 6, 2017–Feb 7, 2018'],
];

  foreach ($all_data as $data) {
    /* 1. programmatically create an entity and populate start/end dates */
    /* 2. programmatically render the entity */
    /* 3. assert that the output contains the expected text */
  }
}

Whilst the goal should be to have as much test coverage as possible, is isn’t feasible to cover every combination of dates, formats and settings. But we should try to test lots of variations of date and datetime ranges, edge cases, possible formats and results that would vary by timezone. And if something doesn’t behave as expected later, we can write a test to demonstrate it before changing code. Later we can verify any fixes via the new test, and make sure there are no other regressions too!

You can find the full test implementation here.


Phew, that was a lot!

In the final post, we’ll provide an admin interface for editing the date range formats and look at some implications of using this in a multilingual environment.