Birds Migrating Over Lake

Port a Custom Module to Drupal 8 with D3.js Integration

For visualizing data, D3 rules. To show an example of how to integrate D3 with Drupal 8, let’s take a custom module created in Drupal 7 and refactor it for Drupal 8.

The original D7 module was an exercise in sorting a range of integers from user entered form inputs. To see it in action, install and enable the custom module in a vanilla Drupal 7 site. Here's the module in an animated gif taking the form inputs with D3 handling the graphing/visualization:

The challenge was to take the total number of integers (represented by the purple bars) and generate a random set of numbers ranging from the first delimiting integer to the second delimiting integer. The Step and Play buttons show the animation of sorting the randomly generated numbers in descending order.

In Drupal 7, the module file contains the typical form elements and menu callbacks using hook_menu(). The heavy lifting is done by the custom JavaScript file that leverages the D3 library to load the bar chart and to animate the sorting.

Converting Drupal 7 hook_menu() items

In Drupal 8, we have to convert hook_menu() to use the Drupal 8’s routing system by adding a MODULE.routing.yml file. To breakdown the pieces, let’s start with creating a route for the path/callback in a new file called bubblesort.routing.yml. In this case, there are two routes that need defining: the path to the form itself and a path to the JSON data generated from the form inputs needed by the custom JavaScript.

bubblesort.form:
 path: '/bubblesort'
 defaults:
   _title: 'Bubble Sort'
   _form: '\Drupal\bubblesort\Form\BubblesortForm'
 requirements:
   _permission: 'access content'
bubblesort.bubblesort_json:
 path: '/bubblesort/json/{data}'
 defaults:
   _title: 'Bubble JSON'
   _controller: '\Drupal\bubblesort\Controller\BubblesortController::bubblesort_json'
 requirements:
   _permission: 'access content'

Controllers

Next, let’s take a closer look at the controller classes. The form controller lives in the src/Form directory and is called BubblesortForm.php. This code handles how the form is built, validated, and submitted. In Drupal 7, this is the equivalent of a form function passed as a page argument to hook_menu().

<?php

namespace Drupal\bubblesort\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Builds the bubblesort form.
 */
class BubblesortForm extends FormBase {

  /**
   * [email protected]}
   */
  public function getFormId() {
    return 'bubblesortform';
  }

  /**
   * [email protected]}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {
    global $base_url;
    // Use hook_theme to generate form render array.
    $form['#theme'] = 'bubblesortform';
    $form['#attached']['library'][] = 'bubblesort/bubblesort-form';
    $form['#attached']['drupalSettings']['baseUrl'] = $base_url;

    $form['numbers_total'] = array(
      '#type' => 'number',
      '#title' => $this->t('Total number of bars:'),
      '#required' => true,
      '#min' => 1,
      '#max' => 35,
    );

    $form[integer_min] = array(
      '#type' => 'number',
      '#title' => $this->t('First number:'),
      '#required' => TRUE,
      '#min' => 1,
      '#max' => 99,
    );

    $form[integer_max] = array(
      '#type' => 'number',
      '#title' => $this->t('Second number:'),
      '#required' => TRUE,
      '#min' => 1,
      '#max' => 99,
    );

    $form['submit'] = array(
      '#type' => 'submit',
      '#value' => $this->t('Shuffle'),
    );

    $form['step_button'] = array(
      '#type' => 'button',
      '#value' => $this->t('Step'),
    );

    $form['play_button'] = array(
      '#type' => 'button',
      '#value' => $this->t('Play'),
    );

    return $form;
  }

  /**
   * [email protected]}
   */
  public function validateForm(array &$form, FormStateInterface $form_state) {
    $values = $form_state->getValues();
    if ($values[integer_max]  <= $values[integer_min]) {
      $form_state->setErrorByName(integer_max, t('Second number must be greater than the first number.'));
    }
  }

  /**
   * [email protected]}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    drupal_set_message($this->t([email protected] bars between @first and @second', array(
      [email protected]' => $form_state->getValue('numbers_total'),
      [email protected]' => $form_state->getValue(integer_min),
      [email protected]' => $form_state->getValue(integer_max),
      )));
    $form_state->setRebuild();
  }
}

Attaching Libraries

The following two lines from the file above attach the custom JavaScript and CSS to the form and pass the base URL using drupalSettings (formerly Drupal.settings in Drupal 7) :

$form['#attached']['library'][] = 'bubblesort/bubblesort-form';
$form['#attached']['drupalSettings']['baseUrl'] = $base_url;

The library is defined by the bubblesort.libraries.yml file in the root of the module folder. Multiple libraries can be defined as entries detailing dependencies and assets such as CSS and JavaScript files. Here is the bubblesort.libraries.yml:

bubblesort-form:
  version: 1.x
  css:
    theme:
      css/bubblesort.css: {}
  js:
    js/d3.min.js: {}
    js/bubblesort.js: {}
  dependencies:
    - core/drupal
    - core/jquery
    - core/drupalSettings

Notice that we have to include drupalSettings and jquery from core as dependencies. In Drupal 8, these dependencies aren’t included by default as they were in Drupal 7.

Passing JSON to D3

D3 enables the mapping of an arbitrary dataset to a Document Object Model (DOM), and then allows for manipulation of the document by data-driven changes. D3 can accept text-based data as a plain text file, CSV, or in JSON format. Drupal 8 provides RESTful web services and serialization out-of-the-box to create views that generate JSON data. In this particular use case however, we’re creating our own route and generating the data from form inputs in the controller.

In order for D3 to map all the randomly generated numbers in a bar chart, the data points are returned in a simple array of values as a JsonResponse object. To create a path that only displays JSON, a function in the controller takes the form parameters and returns the correct number of random integers delimited by minimum and maximum values in a JSON callback.

The controller class for generating the JSON needed by the custom JavaScript can be found in the src/Controller directory and is called BubblesortController.php:

<?php

namespace Drupal\bubblesort\Controller;

use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\JsonResponse;

/**
 * Controller for bubblesort routes.
 */
class BubblesortController extends ControllerBase {

  /**
   * Returns JSON data of form inputs.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   A JSON response containing the sorted numbers.
   */
  public function bubblesort_json($data) {
    if (empty($data)) {
      $numbers = numbers_generate();
    }
    else {
      $data_parts = explode('&', $data);
      foreach($data_parts as $part) {
        $fields[] = explode('=', $part);
      }
      // Loop through each field and grab values.
      foreach($fields as $field) {
        if (!empty($field[1])) {
          switch ($field[0]) {
            case 'numbers_total':
              $total = $field[1];;
              break;
            case integer_min:
              $range1 = $field[1];
              break;
            case integer_max:
              $range2 = $field[1];
              break;
          }
        }
      }
      // Sort the numbers.
      $numbers = $this->numbers_generate($total, $range1, $range2, FALSE);
    }
    // Return a response as JSON.
    return new JsonResponse($numbers);
  }

  /**
   * Generates random numbers between delimiters.
   *
   * @param int $total
   *   The total number of bars.
   * @param int $range1
   *   The starting number.
   * @param int $range2
   *   The ending number.
   * @return array
   */
  private function numbers_generate($total = 10, $range1 = 1, $range2 = 100, $sort = FALSE) {
    $numbers = range($range1, $range2);
    shuffle($numbers);
    $numbers = array_slice($numbers, 0, $total);
    if ($sort) {
      rsort($numbers);
    }
    return $numbers;
  }
}

The functions declared in the controller above are the same callbacks that are used in hook_menu() of the Drupal 7 version of the module.

Using hook_theme to create a render array

The bar chart for stepping through or playing the sorting animation of the randomly generated numbers needs a place to be appended in the form template. We use hook_theme() in the module file to generate a render array for the form:

<?php

/**
 * @file
 * Contains bubblesort.module.
 */

/**
 * Implements hook_theme().
 */
function bubblesort_theme($existing, $type, $theme, $path) {
  return array(
    'bubblesortform' => array(
      'template' => 'bubblesort',
      'render element' => 'form',
    ),
  );
}

In the Twig template, there is a <div> element with class "chart" that will get populated with the sorting bars through jquery selectors:

<form{{ attributes }}>
    {{ form.form_build_id }}
    {{ form.form_token }}
    {{ form.form_id }}
    <div>
        {{ form.numbers_total }}
    </div>
    <div>
        {{ form.integer_min }}
    </div>
    <div>
        {{ form.integer_max }}
    </div>
    <div>
        {{ form.submit }} {{ form.step_button }} {{ form.play_button }}
    </div>
</form>
<div class="chart"></div>

Alternatively, you could create a markup form element and pass the necessary markup into the form.

drupalSettings

The juice of the sorting animation lives in the custom JavaScript file called bubblesort.js. The functionality is almost identical to the JavaScript file in the Drupal 7 module, save for two instances. First is the syntax for passing a variable via drupalSettings.

Compare the following line in Drupal 7:

base_path = Drupal.settings.basePath;

In Drupal 8, it changes to:

base_path = drupalSettings.baseUrl;

HTML5 elements

The other minor difference lies in the selector for grabbing all the form inputs. In the Drupal 7 version, the selector looks like:

values = (!empty) ? $('form input:text').serialize() : '';

While in Drupal 8, the selector is:

values = (!empty) ? $('input[type="number"]').serialize() : '';

The reason for this is that in Drupal 8, we’re using a new HTML5 element '#type' => 'number' for the form elements, while in Drupal 7, the input fields were of type text.

Our Port to Drupal 8 is Complete!

The meat of the sorting functionality resides in a function called bubblesort_display(data) in the JavaScript file. This is where the D3 library is used to draw and sort the bar chart based on the data passed into it from the JSON callback. Parsing the JavaScript is beyond the scope of this article, but there are tons of examples and tutorials for how to implement the wild variety of D3 charts and graphs into an application (see Additional Resources below).

So this wraps up the conversion of a Drupal 7 custom module integrated with D3 to Drupal 8. The most challenging parts of such a migration center around the routing mechanism and extending the controller classes to provide the functionality that was defined in a procedural approach using hook_menu(). The method for passing data to a custom JavaScript file using the D3 library is the same for Drupal 8 as it is for Drupal 7 - using a JSON callback to return the data that can be consumed by D3 methods.

The Drupal 8 version of the bubblesort custom module is also available for download and can be readily installed in a vanilla Drupal 8 site by just enabling it. Here’s a gif of bubblesort in action in Drupal 8:

Additional Resources

Drupal 8
Clare Ming Headshot

From writing custom modules to wrangling frameworks and APIs, Clare has been developing web applications since the early aughts. Her latent fixations involve the conjuring of riotous color, narrative grit, and harmonic minors.

Other recent articles by this author: