Nicer date ranges in Drupal – part 1

A while ago I wanted to present events with a date range on a Drupal 7 site. Drupal’s contributed date module provided a way to store the data, but the display wasn’t to my liking, always showing the complete start and end date.

Wouldn’t it be nice to show the dates in a more compact fashion, by not repeating the day, month or year where they aren’t necessary? Like this:

(and yes, those are en dashes!)

There didn’t seem to be a contributed module available, so I wrote some bespoke code for the project. It only supports one particular UK-specific date format, and there’s no support for different languages.

Over the last few days, I’ve spent some time porting it to Drupal 8 and improving it, suitable for release as a new contributed module. I thought I’d write about this process in the form of a tutorial over a few posts. I hope you’ll find it useful.

Let’s port this to Drupal 8

Firstly, as of Drupal 8.3, there’s an experimental core date range module, so we’re no longer reliant on another contributed module for data storage.

As mentioned, the original code doesn’t offer much in the way of customisation. Neither are there any guarantees about what will happens when different languages and timezones are introduced. While there are lots of things we can improve on later, for now let’s get what was there before working with Drupal 8.

This module consists of a field formatter. These are well documented on drupal.org, and make for quite a gentle introduction to Drupal 8 development.

It’s helpful to think of field formatters in two parts—a definition, describing what it is, and an implementation, concerned with how it works. This is quite a common pattern in Drupal. In D7, we’d see this pattern implemented as two hook functions; in D8, as a plugin and annotation.

D7 .module file → D8 plugin

In Drupal 7, lots of code lives in a single, monolithic .module file. Drupal 8 makes use of object oriented programming, so individual components such as field formatters are each defined in their own classes. A plugin is a class that implements particular functionality and is discoverable at runtime.

Our plugin is defined in src/Plugin/Field/FieldFormatter/DateRangeCompactFormatter.php and looks like this:

<?php
namespace Drupal\daterange_compact\Plugin\Field\FieldFormatter;

class DateRangeCompactFormatter extends FormatterBase {
  /* implementation */
}

D7 info hook → D8 annotation

Our Drupal 7 implementation has a hook function that specifies there is a formatter called daterange_compact (and labelled Compact), that is suitable for date/time fields:

/**
 * Implements hook_field_formatter_info().
 */
function daterange_compact_field_formatter_info() {
  $info['daterange_compact'] = array(
    'label' => t('Compact'),
    'field types' => array('datetime'),
    'settings' => array(),
  );
  return $info;
}

In Drupal 8, we supply the same information but using an annotation. Note the change of field type, Drupal 8.3 comes with a an experimental date range field type that’s separate from the singular date field.

/**
 * @FieldFormatter(
 *   id = "daterange_compact",
 *   label = @Translation("Compact"),
 *   field_types = {"daterange"}
 * )
 */
class DateRangeCompactFormatter...

D7 → D8 implementation

In Drupal 7, the formatting itself happens in another hook, that gets passed the field values (via the $items parameter) and returns the desired output. I’ve left out the actual implementation for brevity; complete versions are available here (D7) and here (D8).

/**
 * Implements hook_field_formatter_view().
 */
function daterange_compact_field_formatter_view($entity_type, $entity,
      $field, $instance, $langcode, $items, $display) {
  /* given the field values, return a render array */
}

This is really similar in Drupal 8, except that we define a function called viewElements in our class, and the field values are accessible through an object.

function viewElements(FieldItemListInterface $items, $langcode) {
  /* given the field values, return a render array */
}

Test it!

To see this in action, let’s set up a content type with a field of type date range. The field is date only—at the moment this formatter doesn’t support times (something we’ll change later). We’ll populate the field with four pairs of values, all of which should appear differently:

A quick check reveals the output is as expected, so we’ve successfully ported the formatter to Drupal 8!

Now is an ideal time to automate that check with a unit test. We’re going to be adding more functionality to this module, during which we may well inadvertently introduce regressions. The test will help flag those up.

Writing a test in D8

Testing field formatters is done with PHPUnit. The timestamp formatter does a similar thing to our formatter, so we can examine that to see how it works. There is a TimestampFormatterTest class that extends KernelTestBase, so let’s create a similar class in modules/daterange_compact/src/Tests/DateRangeCompactFormatterTest.php:

<?php
namespace Drupal\daterange_compact\Tests;

class DateRangeCompactFormatterTest extends KernelTestBase {
  /* implementation */
}

The setUp function will create an arbitrary entity type with fields. It makes use of the entity_test module to define that entity and bundle, to which we create an appropriate daterange field and define it’s default display settings.

protected function setUp() {
  parent::setUp();
  /* 1. install the entity_test schema */
  /* 2. programmatically create a field of type daterange */
  /* 3. programmatically create a field instance based on the above */
  /* 4. set the display settings to use our new formatter */
}

Each function whose name begins with test___ corresponds to a single test. Within each test we can iterate through a set of values, populating an entity, rendering it and comparing the expected output with the actual output.

function testCompactFormatter() {
  $all_data = [
    ['start' => '2017-01-01', 'end' => '2017-01-01', 'expected' => '1 January 2017'],
    ['start' => '2017-01-02', 'end' => '2017-01-03', 'expected' => '2–3 January 2017'],
    ['start' => '2017-01-04', 'end' => '2017-02-05', 'expected' => '4 January–5 February 2017'],
    ['start' => '2017-01-06', 'end' => '2018-02-07', 'expected' => '6 January 2017–7 February 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 */
  }

You can see the full implementation of the test class here.

This test won’t stop bugs, but it will mean that if the behaviour changes in such a way that the given dates start producing different output, we’ll have a way of knowing. At that point, either the code or the test might need some work.

Running the test

The run PHPUnit, we need to set it up by creating a phpunit.xml file in the core directory. This is documented on drupal.org, and there’s an example file provided.

We also need a database (different to the one used for the site itself).

To run all the tests in our module, run the following command from the core directory:

../vendor/bin/phpunit ../modules/custom/daterange_compact/

If everything worked, we should get a result like the following:

PHPUnit 4.8.27 by Sebastian Bergmann and contributors.

.

Time: 4.92 seconds, Memory: 6.75Mb

OK (1 test, 6 assertions)

The test passed! That’s a good first version of our updated contrib module.

In the next post we’ll look at some improvements, namely making the format configurable and adding support for times.