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:
- 24–25 January 2017
- 29 January–3 February 2017
- 9:00am–4:30pm, 1 April 2017
(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:
- 1 January 2017 (start and end date are the same)
- 2–3 January 2017 (same month)
- 4 January–5 February 2017 (different months, same year)
- 6 January 2017–7 January 2018 (different years)
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.