Welcome to the second installment of our three-part series on writing Views query plugins. In part one, we talked about the kind of thought and design work that must take place before coding begins. In part two, we’ll start coding our plugin and end up with a basic functioning example.
We’ve talked explicitly about needing to build a Views query plugin to accomplish our goal of having a customized Fitbit leaderboard, but we’ll also need field plugins to expose that data to Views, filter plugins to limit results sets, and, potentially, relationship plugins to span multiple API endpoints. There’s a lot to do, so let's dive in.
Getting started
In Drupal 8, plugins are the standard replacement for info hooks. If you haven’t yet had cause to learn about the plugin system in Drupal 8, I suggest the Drupalize.Me Drupal 8 Module Development Guide, which includes an excellent primer on Drupal 8 plugins.
Step 1: Create a views.inc file
Although most Views hooks required for Views plugins have gone the way of the dodo, there is still one that survives in Drupal 8: hook_views_data
. The Views module looks for that hook in a file named [module].views.inc,
which lives in your module's root directory. hook_views_data
and hook_views_data_alter
are the main things you’ll find here, but since Views is loading this file automatically for you, take advantage and put any Views-related procedural code you may need in this file.
Step 2: Implement hook_views_data()
Usually hook_views_data
is used to describe the SQL tables that a module is making available to Views. However, in the case of a query plugin it is used to describe the data provided by the external service.
/**
* Implements hook_views_data().
*/
function fitbit_views_example_views_data() {
$data = [];
// Base data.
$data['fitbit_profile']['table']['group'] = t('Fitbit profile');
$data['fitbit_profile']['table']['base'] = [
'title' => t('Fitbit profile'),
'help' => t('Fitbit profile data provided by the Fitbit API\'s User Profile endpoint.'),
'query_id' => 'fitbit',
];
return $data;
}
The format of the array is usually $data[table_name]['table']
, but since there is no table I’ve used a short name for the Fitbit API endpoint, prefixed by the module name instead. So far, I’ve found that exposing each remote endpoint as a Views “table”—one-to-one—works well. It may be different for your implementation. This array needs to declare two keys—‘group’ and ‘base.' When Views UI refers to your data, it uses the ‘group’ value as a prefix. Whereas, the ‘base’ key alerts Views that this table is a base table—a core piece of data available to construct Views from (just like nodes, users and the like). The value of the ‘base’ key is an associative array with a few required keys. The ‘title’ and ‘help’ keys are self-explanatory and are also used in the Views UI. When you create a new view, ‘title’ is what shows up in the “Show” drop-down under “View Settings”:
The ‘query_id’ key is the most important. The value is the name of our query plugin. More on that later.
Step 3: Expose fields
The data you get out of a remote API isn’t going to be much use to people unless they have fields they can display. These fields are also exposed by hook_views_data
.
// Fields.
$data['fitbit_profile']['display_name'] = [
'title' => t('Display name'),
'help' => t('Fitbit users\' display name.'),
'field' => [
'id' => 'standard',
],
];
$data['fitbit_profile']['average_daily_steps'] = [
'title' => t('Average daily steps'),
'help' => t('The average daily steps over all the users logged Fitbit data.'),
'field' => [
'id' => 'numeric',
],
];
$data['fitbit_profile']['avatar'] = [
'title' => t('Avatar'),
'help' => t('Fitbit users\' account picture.'),
'field' => [
'id' => 'fitbit_avatar',
],
];
$data['fitbit_profile']['height'] = [
'title' => t('Height'),
'help' => t('Fibit users\'s height.'),
'field' => [
'id' => 'numeric',
'float' => TRUE,
],
];
The keys that make up a single field definition include ‘title’ and ‘help’— again self-explanatory—used in the Views UI. The ‘field’ key is used to tell Views how to handle this field. There is only one required sub-key, ‘id,' and it’s the name of a Views field plugin.
The Views module includes a handful of field plugins, and if your data fits one of them, you can use it without implementing your own. Here we use standard
, which works for any plain text data, and numeric
, which works for, well, numeric data. There are a handful of others. Take a look inside /core/modules/views/src/Plugin/views/field
to see all of the field plugins Views provides out-of-the-box. Find the value for ‘id’ in each field plugin's annotation. As an aside, Views eats its own dog food and implements a lot of its core functionality as Views plugins, providing examples for when you're implementing your Views plugins. A word of caution, many core Views plugins assume they are operating with an SQL-based query back-end. As such you’ll want to be careful mixing core Views plugins in with your custom query plugin implementation. We’ll mitigate some of this when we implement our query plugin shortly.
Step 4: Field plugins
Sometimes data from your external resource doesn’t line up with a field plugin that ships with Views core. In these cases, you need to implement a field plugin. For our use case, avatar
is such a field. The API returns a URI for the avatar image. We’ll want Views to render that as an <img>
tag, but Views core doesn’t offer a field plugin like that. You may have noticed that we set a field ‘id’ of ‘fitbit_avatar’ in hook_views_data
above. That’s the name of our custom Views field plugin, which looks like this:
<?php
namespace Drupal\fitbit_views_example\Plugin\views\field;
use Drupal\views\Plugin\views\field\FieldPluginBase;
use Drupal\views\ResultRow;
/**
* Class Avatar
*
* @ViewsField("fitbit_avatar")
*/
class Avatar extends FieldPluginBase {
/**
* {@inheritdoc}
*/
public function render(ResultRow $values) {
$avatar = $this->getValue($values);
if ($avatar) {
return [
'#theme' => 'image',
'#uri' => $avatar,
'#alt' => $this->t('Avatar'),
];
}
}
}
Naming and file placement is important, as with any Drupal 8 plugin. Save the file at: fitbit_views_example/src/Plugin/views/field/Avatar.php
. Notice the namespace
follows the file path, and also notice the annotation: @ViewsField("fitbit_avatar")
. The annotation declares this class as a Views field plugin with the ‘id’ ‘fitbit_avatar,' hence the use of that name back in our hook_views_data
function. Also important, we're extending FieldPluginBase
, which gives us a lot of base functionality for free. Yay OOO! As you can see, the render
method gets the value of the field from the row and returns a render array so that it appears as an <img>
tag.
Step 5: Create a class that extends QueryPluginBase
After all that setup, we’re almost ready to interact with a remote API. We have one more task: to create the class for our query plugin. Again, we’re creating a Drupal 8 plugin, and naming is important so the system knows that our plugin exists. We’ll create a file named:
fitbit_views_example/src/Plugin/views/query/Fitbit.php
...that looks like this:
<?php
namespace Drupal\fitbit_views_example\Plugin\views\query;
use Drupal\views\Plugin\views\query\QueryPluginBase;
/**
* Fitbit views query plugin which wraps calls to the Fitbit API in order to
* expose the results to views.
*
* @ViewsQuery(
* id = "fitbit",
* title = @Translation("Fitbit"),
* help = @Translation("Query against the Fitbit API.")
* )
*/
class Fitbit extends QueryPluginBase {
}
Here we use the @ViewsQuery
annotation to identify our class as a Views query plugin, declaring our ‘id’ and providing some helpful meta information. We extend QueryPluginBase
to inherit a lot of free functionality. Inheritance is a recurring theme with Views plugins. I’ve yet to come across a Views plugin type that doesn’t ship with a base class to extend. At this point, we’ve got enough code implemented to see some results in the UI. We can create a new view of type Fitbit profile and add the fields we’ve defined and we’ll get this:
Not terribly exciting, we still haven’t queried the remote API, so it doesn’t actually do anything, but it’s good to stop here to make sure we haven’t made any syntax errors and that Drupal can find and use the plugins we’ve defined.
As I mentioned, parts of Views core assume an SQL-query backend. To mitigate that, we need to implement two methods which will, in a sense, ignore core Views as a way to work around this limitation. Let’s get those out of the way:
public function ensureTable($table, $relationship = NULL) {
return '';
}
public function addField($table, $field, $alias = '', $params = array()) {
return $field;
}
ensureTable
is used by Views core to make sure that the generated SQL query contains the appropriate JOINs to ensure that a given table is included in the results. In our case, we don’t have any concept of table joins, so we return an empty string, which satisfies plugins that may call this method. addField
is used by Views core to limit the fields that are part of the result set. In our case, the Fitbit API has no way to limit the fields that come back in an API response, so we don’t need this. We’ll always provide values from the result set, which we defined in hook_views_data
. Views takes care to only show the fields that are selected in the Views UI. To keep Views happy, we return $field
, which is simply the name of the field.
Before we come to the heart of our plugin query, the execute
method, we’re going to need a couple of remote services to make this work. The base Fitbit module handles authenticating users, storing their access tokens, and providing a client to query the API. In order to work our magic then, we’ll need the fitbit.client
and fitbit.access_token_manager
services provided by the base module. To get them, follow a familiar Drupal 8 pattern:
/**
* Fitbit constructor.
*
* @param array $configuration
* @param string $plugin_id
* @param mixed $plugin_definition
* @param FitbitClient $fitbit_client
* @param FitbitAccessTokenManager $fitbit_access_token_manager
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, FitbitClient $fitbit_client, FitbitAccessTokenManager $fitbit_access_token_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->fitbitClient = $fitbit_client;
$this->fitbitAccessTokenManager = $fitbit_access_token_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('fitbit.client'),
$container->get('fitbit.access_token_manager')
);
}
This is a common way of doing dependency injection in Drupal 8. We’re grabbing the services we need from the service container in the create
method, and storing them on our query plugin instance in the constructor.
Now we’re finally ready for the heart of it, the execute
method:
/**
* {@inheritdoc}
*/
public function execute(ViewExecutable $view) {
if ($access_tokens = $this->fitbitAccessTokenManager->loadMultipleAccessToken()) {
$index = 0;
foreach ($access_tokens as $uid => $access_token) {
if ($data = $this->fitbitClient->getResourceOwner($access_token)) {
$data = $data->toArray();
$row['display_name'] = $data['displayName'];
$row['average_daily_steps'] = $data['averageDailySteps'];
$row['avatar'] = $data['avatar'];
$row['height'] = $data['height'];
// 'index' key is required.
$row['index'] = $index++;
$view->result[] = new ResultRow($row);
}
}
}
}
The execute
method is open ended. At a minimum, you’ll want to assign ResultRow
objects to the $view->result[]
member variable. As was mentioned in the first part of the series, the Fitbit API is atypical because we’re hitting the API once per row. For each successful request we build up an associative array, $row
, where the keys are the field names we defined in hook_views_data
and the values are made up of data from the API response. Here we are using the Fitbit client provided by the Fitbit base module to make a request to the User profile endpoint. This endpoint contains the data we want for a first iteration of our leaderboard, namely: display name, avatar, and average daily steps. Note that it’s important to track an index
for each row. Views requires it, and without it, you’ll be scratching your head as to why Views isn’t showing your data. Finally, we create a new ResultRow
object with the $row
variable we built up and add it to $view->result
. There are other things that are important to do in execute like paging, filtering and sorting. For now, this is enough to get us off the ground.
That’s it! We should now have a simple but functioning query plugin that can interact with the Fitbit API. After following the installation instructions for the Fitbit base module, connecting one or more Fitbit accounts and enabling the fitbit_views_example sub-module, you should be able to create a new View of type Fitbit profile, add Display name, Avatar, and Average Daily Steps fields and get a rudimentary leaderboard:
Debugging problems
If the message ‘broken or missing handler’ appears when attempting to add a field or other type of handler, it usually points to a class naming problem somewhere. Go through your keys and class definitions and make sure that you’ve got everything spelled correctly. Another common issue is Drupal throwing errors because it can’t find your plugins. As with any plugin in Drupal 8, make sure your files are named correctly, put in the right folder, with the right namespace, and with the correct annotation.
Summary
Most of the work here has nothing to do with interacting with remote services at all—it is all about declaring where your data lives and what its called. Once we get past the numerous steps that are necessary for defining any Views plugins, the meat of creating a new query plugin is pretty simple.
- Create a class that extends QueryPluginBase
- Implement some empty methods to mitigate assumptions about a SQL query backend
- Inject any needed services
- Override the
execute
method to retrieve your data into aResultRow
object with properties named for your fields, and store that object on the results array of the Views object.
In reality, most of your work will be spent investigating the API you are interacting with and figuring out how to model the data to fit into the array of fields that Views expects.
Next steps
In the third part of this article, we’ll look at the following topics:
- Exposing configuration options for your query object
- Adding options to field plugins
- Creating filter plugins
Until next time!