Rijk van Zanten
Directus
Published in
8 min readJul 5, 2017

--

This article was written for a legacy version of Directus. Only reference this information if you are using Directus 6 and can not upgrade to version 7.

How To Setup A Custom Extension

Directus allows you to extend the admin app as well as the API with any custom view or endpoint you might possibly need. In this article, we will take a deep dive into creating such a custom extension by creating an Activity Graph page.

Custom extensions are made out of three main parts: a front-end template file (HTML), the front-end logic (JS), and a back-end extension (PHP) which allows you to retrieve and modify Directus’ data directly. If we take a look at the example extension we will see this structure reflected in the files available:

/customs/extensions/_example
├── api.php
├── main.js
├── templates
│ ├── container.handlebars
│ ├── example.handlebars
│ ├── page1.handlebars
│ └── page2.handlebars
└── views
├── example.js
├── main.js
├── pageone.js
└── pagetwo.js

api.php

The api file allows us to define new custom endpoints that we can use to query data from within Directus or other sources. This endpoint is automatically created based on the name of your custom extension’s folder. For example: the code below will automagically create the endpoint /api/extensions/activity-graph/stats.

<!-- /customs/extensions/activity-graph/api.php --><?php$app = \Directus\Application\Application::getInstance();$app->get('/stats', function () use ($app) {
return $app->response([]);
});

When someone gets this route, we will respond using the $app->response() method, which allows us to send a PHP-array that will be converted to JSON automatically.

template.handlebars

The template file is actually the most straightforward. It can be any HTML you see fit. It gets rendered with Handlebars, so you can also use any of their built in helpers.

<style>
.graph {
max-width: 400px;
}
</style>
<div class="graph">{{amount}}</div>

main.js

This is where most of the magic happens. The main.js allows you to get data from the previously made endpoint and implement other custom logic for the view.

The module needs to return an object with the following structure:

{
id: 'activity-graph', // used internally
title: 'Activity Graph', // name in left sidebar
Router: Extension.Router // more on this below
}

The id and title properties are pretty straightforward, the Router however, is a bit more complicated.

A custom page consists out of three main parts. The Router, a BasePageView and one or more Views.

The Router maps a route to a BasePageView. BasePageViews are like different pages, and each can contain multiple Views (like components on the page). Each of these Views has a template file.

All this looks a little something like this in code:

var View = Extension.View.extend({
template: 'activity-graph/template'
});
var PageView = Extension.BasePageView.extend({
headerOptions: {
route: {
title: 'Activity Graph'
}
},
initialize: function () {
this.setView('#page-content', new View());
}
});
var Router = Extension.Router.extend({
routes: {
'(/)': function () {
app.router.v.main.setView('#content', new PageView());
app.router.v.main.render();
}
}
}});

Note: headerOptions.route.title is required in PageView.
Note 2: Don’t forget the parentheses
() around the route definition in the Router. Those make sure the home route works with and without the /

Getting Started

The goal for the Activity Graph page is to display a barchart of authors on the X-axis and the amount of items they’ve created in the main table on the Y-axis.

Lets get started on our Activity Graph view by creating the necessary files.

We start off with a simple hello world template.handlebars file to use later on.

<!-- template.handlebars --><h1>Hello World!</h1>

Next up, let's setup a basic main.js file using the boilerplate mentioned above.

// main.jsdefine(['app', 'core/extensions'], function (app, Extension) {

var View = Extension.View.extend({
template: 'activity-graph/template'
});
var PageView = Extension.BasePageView.extend({
headerOptions: {
route: {
title: 'Activity Graph'
}
},
initialize: function () {
this.setView('#page-content', new View());
}
});
var Router = Extension.Router.extend({
routes: {
'(/)': function () {
app.router.v.main.setView('#content', new PageView());
app.router.v.main.render();
}
}
});
return {
id: 'activity-graph',
title: 'Activity Graph',
Router: Router
};
});

It still doesn’t do an awful lot, but we have a working custom page!

Fetching Data

Having a friendly Hello World! message is nice of course, but our custom page won’t be very useful without real data. To fetch this data, we need to setup a custom endpoint on the API to be able to interface with the database on the server-side.

<!-- api.php --><?php$app = \Directus\Application\Application::getInstance();$app->get('/stats', function () use ($app) {
return $app->response([
'message' => 'Hello from the Server!'
]);
});

This route (/api/extensions/activity-graph/stats) now returns JSON data we can use in the front-end.

We can fetch this endpoint in a number of ways from the front-end, however we recommend using a Backbone model. When implementing this fetch for data The Directus Way™ the model will auto update on result and re-render the Handlebars view as needed.

To implement this connection, we first need to require Backbone and create a new model:

// main.jsdefine(
['app', 'core/extensions', 'backbone'],
function (app, Extension, Backbone) {
var Model = Backbone.Model.extend({
url: '/api/extensions/activity-graph/stats'
});
...});

Next up, we need to connect this model to the view that actually needs to update according to the data:

// main.js...  var View = Extension.View.extend({
template: 'activity-graph/template',
initialize: function () {
this.listenTo(this.model, 'sync', this.render);
this.model.fetch();
}

});
var PageView = Extension.BasePageView.extend({
headerOptions: {
route: {
title: 'Activity Graph'
}
},
initialize: function () {
this.setView('#page-content', new View({model: new Model()}));
}
});
...

The data now gets fetched by the view, but it still isn’t getting rendered to the Handlebars template. Let's change that. We can send data to the template by returning it in the serialize method of the View:

// main.js...  var View = Extension.View.extend({
template: 'activity-graph/template',
initialize: function () {
this.listenTo(this.model, 'sync', this.render);
this.model.fetch();
},
serialize: function () {
return {
message: this.model.get('message')
};
}

});
...

And render it in the template like follows

<!-- template.handlebars --><h1>{{message}}</h1>
Data from the server-side, nice!

Now that we have the client-server connection setup successfully, it’s time to dive deeper into the database to find some nice stats to display!

Let's start by creating tableGateways for both the main table and the directus_users table and fetching the items stored:

<!-- api.php --><?php$app = \Directus\Application\Application::getInstance();$mainTable = \Directus\Database\TableGatewayFactory::create('main'); $userTable = \Directus\Database\TableGatewayFactory::create('directus_users');$app->get('/stats', function () use ($app, $mainTable, $userTable) {
// Get all users
$users = $userTable->getItems()['data'];
// Get all items
$items = $mainTable->getItems([
'status' => [1, 2]
])['data'];
...

Note: I’ve hardcoded this endpoint to use a table called main. Don’t forget to change the name of this table if you’re following along in your own instance.

We have to map the items to the actual user who created it, to be able to get the count per user.

<!-- api.php -->
...
// Get all items
$items = $mainTable->getItems([
'status' => [1, 2]
])['data'];
// Map array to be in format userID => count
$results = array_count_values(array_map(function ($item) {
return $item['user'];
}, $items));
// Replace the userID with the name
// of the user in the results array
foreach($results as $key => $value) {
$userName = getUserName($users, $key);
$results[$userName] = $value;
unset($results[$key]);
}
// Return the results to the client
return $app->response([
'message' => 'Hello from the Server!'
]);
});
function getUserName($users, $value) {
foreach($users as $key => $user) {
if ( $user['id'] === $value ) {
return $users[$key]['first_name'];
}
}
return false;
}

Note: I’ve hardcoded the endpoint to use column name user to retrieve the users data from the User Interface. Make your main table has a column user with the User Interface, or change this reference to contain the name of your User Interface column.

Last but not least, we need to send this data back to the client:

<!-- api.php -->
...
}
// Return the results to the client
return $app->response([
'results' => $results
]);
});
function getUserName($users, $value) {
...

If we go to our custom made endpoint /api/extensions/activity-graph/stats, we should get something like this returned:

{
"Rijk": 23,
"Ben": 57,
"Welling" 64
}

Note: This data is different for each instance, of course. Please make sure to include a user_created interface into your table. Otherwise, the item won’t contain the user key and this data can’t be retrieved.

Since we now have access to the stats we were actually looking for, it’s time for us to render this data into a graph.

We’ll be using ChartJS to actually display the stats. Let’s start of by including the library in a lib folder and require it from main.js:

// main.jsdefine(
['app', 'core/extensions', 'backbone', './lib/chart'],
function (app, Extension, Backbone, ChartJS) {
...});

Next, change the template to have an element to render the chart in:

<!-- template.handlebars --><style>
#graph {
width: 400px;
height: 400px;
}
</style>
<h2>Activity per user:</h2>
<div id="graph">
<canvas id="activity-chart"></canvas>
</div>

With the canvas in place, everything is ready to rock. Lets convert the canvas element to a ChartJS instance on render of the View. We don’t have to pass through any data to Handlebars anymore, so we can safely remove the serialize method.

// main.js...var View = Extension.View.extend({
template: 'activity-graph/template',
initialize: function () {
this.listenTo(this.model, 'sync', this.render);
this.model.fetch();
},
afterRender: function () {
var canvasElement = this.el.querySelector('canvas');
var data = this.model.get('results');
if (data) {
this.chart = new ChartJS(canvasElement, {
type: 'bar',
data: {
labels: Object.keys(data),
datasets: [{
label: 'Posts per user',
data: Object.keys(data).map(function (key) {
return data[key];
})
}]
}
});
};
}

});
...

Note: Make sure to check if data actually exists before rendering the chart. The first time the view renders, the request hasn’t been made so data will be undefined.

Awesome!!

Last but not least: let's add some styling to make our page feel a little more at home in the admin interface:

<!-- template.handlebars --><style>
#graph-page {
padding: 2em;
}
#graph {
margin-top: 2em;
max-width: 500px;
}

</style>
<div id="graph-page">
<h2>Activity per user:</h2>
<div id="graph">
<canvas id="activity-chart"></canvas>
</div>
</div>

Don’t forget to add in the ever-important blue Directus accent color as well 🙂

// main.js
...
datasets: [{
label: 'Posts per user',
data: Object.keys(data).map(function (key) {
return data[key];
}),
backgroundColor: '#3598dB'
}]
...
The finished custom extension

There you have it! A great looking yet simple graph displaying who has added the most items within a certain table. It looks like I need to step it up a notch!

I’ve published the source code of this example in this GitHub repo.

What great custom extensions are you going to build? Let us know in the comments below!

Happy coding!

Your friends at Directus

🐰

Update August 1, 2017:
* Fix missing arguments in
api.php closure
* Fix missing
) after argument list in main.js
* Add GitHub repo link

Update November 27, 2017:
* Updated the demo repo
* Updated .html to .handlebars for the template
* Fixed link to default Directus example

--

--