Colorfield logo

Drupalicious

Published on

React and Drupal 8 with JSON API 3/3

Authors
Drupal 8 and React logos

The first post was about setting up the Drupal and React environment, the second explained the concepts of component, route, fetch data from JSON API and localization.

This one focuses on translation issues and various pitfalls that you might encounter while building with React and Drupal. Knowing this, prefer the demos and documentation that have been build around Contenta and Drupal JSON API if you are looking for a more general introduction.

Following the second post example, here is the content model for an audioguide used by a film museum:

  • Itinerary (Drupal vocabulary) and Stop (Drupal content type):
    an itinerary has several "stops", that are obviously places to stop and listen to a track of an audioguide. We define a term reference to the itinerary vocabulary on the stop content type.
    Example: "The Maltese Falcon" and "You Only Live Once" stops have a reference to the "Adult" itinerary.
  • Each Drupal entities (nodes and terms) are fully translatable and exposed with JSON API.

We will discuss the following points:

  • Filter stops by itinerary (e.g. adult, child, ...), so we can demonstrate taxonomy term filtering.
  • Custom sort, using the Weight module.
  • Full multilingual support: localization and internationalization with and without node language fallback.
  • Get images, with image styles.
  • Fetch data on the route or the component.
  • How to deploy in production.

Documentation and code example

An update of the demo repositories (React and Drupal ones) is on its way.
It will contain a readme with a summary of the steps for getting started on a local dev environment.
The React repo will also describe the components used in the application and provide various examples, that are a bit long to describe in a single post, like: searching, fetching and displaying nested data structure like Paragraphs, ...

Boilerplate update

The code from the previous article will also be updated to the latest release of the React Starter Kit boilerplate that now includes React 16.2.0.

Languages

Localization: translate the UI with React Intl

Language setup

Here is an update of the second part of the tutorial for the React 16.2.0 React Intl branch of the boilerplate.

Edit the src/config.js file

module.exports = {
  // default locale is the first one
  locales: [
    /* @intl-code-template '${lang}-${COUNTRY}', */
    'en-US',
    'fr-BE',
    /* @intl-code-template-end */
  ],
}

Edit the src/components/LanguageSwitcher/LanguageSwitcher.js

const localeDict = {
  /* @intl-code-template '${lang}-${COUNTRY}': '${Name}', */
  'en-US': 'English',
  'fr-BE': 'Français',
  /* @intl-code-template-end */
}

Add your locale in src/client.js

import en from 'react-intl/locale-data/en'
import fr from 'react-intl/locale-data/fr'
// (...)
;[en, fr].forEach(addLocaleData)

If you want to change the default language, edit also the src/actions/intl.js

defaultLocale: 'fr-BE',

Run yarn build after having made your changes to languages, so the messages defined in your components will be extracted in one file per language in /src/messages.

Messages

You can define translatable messages in your components.
The defaultMessage and (optionaly) the description will be the ones that will be translatable after the extraction.

// Above the class definition.
const messages = defineMessages({
  search_description: {
    id: 'search.description',
    defaultMessage: 'Search using stop id or title.',
    description: 'Description for the search input',
  },
  search_placeholder: {
    id: 'search.placeholder',
    defaultMessage: 'Search...',
    description: 'Search input placeholder',
  },
})
// (...)
// In the render() method.
;<FormattedMessage {...messages.search_label} />

The tricky part for placeholders

But if you need to define a placeholder (e.g. for the search input field) the <FormattedMessage /> does not fit.
You will have to use injectIntl.

import { defineMessages, injectIntl, intlShape } from 'react-intl';
// (...)
static propTypes = {
  intl: intlShape.isRequired,
}
// (...)
export default injectIntl(withStyles(s)(SearchBar));

Then you can use React Intl for plain strings like html attributes.

<input
  placeholder={formatMessage(messages.search_placeholder)}
  value={this.props.filterText}
  onChange={this.handleFilterTextChange}
/>

Here is an issue that tells a bit more about that.

Internationalization: get translated content with JSON API from Drupal

Filtering content by language can be done with the Drupal language id on the path. It is still a workaround that is explained by @e0ipso in this video about content translation from the JSON API Drupal YouTube channel.

// JSON API request for translated stop nodes.
const languageId = 'fr'
const nodes = `${JSON_API_URL}/${languageId}/jsonapi/node/stop`
But wait... I do not want language fallback

The request above will produce language fallback, so if your Drupal node is not translated, the source language will be provided by default.
If your requirements does not comply with this behaviour, add a filter with the language id: ?filter\[langcode\]\[value\]=${languageId};_

// JSON API request for translated stop nodes, with no language fallback.
const languageId = 'fr'
const nodes = `${JSON_API_URL}/${languageId}/jsonapi/node/stop?filter[langcode][value]=${languageId}`

JSON API

To test JSON API requests, you can use your IDE, Chrome extensions (Postman, ...), or just use Firefox Developer Edition, it is fast and formats JSON + prints headers out of the box.

Firefox developer edition JSON

Before we start, here is a discussion about using fetch.

Fetch

Fetch returns a Promise, here are some ways to use it.

// Chaining with then
const data = await fetch(endpoint).then((response) => response.json())

// Declare method explicitely and using await, in two steps.
const response = await fetch(endpoint, { method: 'GET' })
const data = await response.json()

We have basically two options to fetch the data from JSON API: on the router action or on the component.

1. Fetch on the router

A First naive approach, described on the second part of this serie, was to fetch on the router and pass data to the component.

I was basically happy with that, before reading about this performance issue:

(...) since the routes are universal, it will first run on the server and then run again on the client.
This is basically one of the major point of using isomorphic architecture: to prefetch the data on the server.

See this issue comment on React Starter Kit

So, it should lead to use Redux by default. You have to define your actions and reducers to couple the stored data to React components.
There are loads of good tutorials for that (and here is one of them), but I didn’t wanted the overhead of adding Redux now.

It also appears that if you want basic behaviours like error handling for your data, while using the route, you also need Redux or have to work with throw on the router / try - catch on the component.

Another point was the propTypes definition for each response.

So I reconsidered fetching on the component itself.

2. Fetch on the component

Fetching on the component resulted as a simplification, apart while changing a language.
The JSON API request was called automatically on language change while getting the data from the route. Now that we are using componentDidMount to fetch, the request is not executed again while using the language switcher.

componentDidMount() {
  const endpoint = ItineraryListPage.getItinerariesEndpoint(
    this.props.languageId,
  );
  this.fetchItineraries(endpoint);
}

So we have to add a componentWillReceiveProps definition.

componentWillReceiveProps(nextProps) {
  if(nextPropos.languageId !== this.props.languageId) {
    const endpoint = ItineraryListPage.getItinerariesEndpoint(nextProps.languageId);
    this.fetchItineraries(endpoint);
  }
}

On the code below, we have simplified the route and added fetch loading state and error handlers. Also, note the usage of propTypes and states that now appears to be much more readable.

Then, it will still be possible to add progressive enhancement with Redux later on, which looks like a better development approach.

Sorting

The default sort will not be what you expect in most situations.
In some cases, custom sort should be provided instead of alphabetical.
This can be easily achieved with the Drupal Weight module and an extra parameter passed to JSON API: ?sort=field_weight.

// Fetch sorted node stops for this itinerary.
const itineraryStopNodesEndpoint = `${JSON_API_URL}/${languageId}/jsonapi/node/stop?sort=field_weight&filter[field_itinerary.uuid][value]=${this.props.itineraryId}`

Filtering examples

Filtering by term can be easily done with filter[field_itinerary.uuid][value]=this.props.itineraryId

Here is how to filter nodes with a term: get the stops for an itinerary.

// Fetch the translated node stops for this itinerary.
const itineraryStopNodesEndpoint = `${JSON_API_URL}/${languageId}/jsonapi/node/stop?sort=field_weight&filter[field_itinerary.uuid][value]=${this.props.itineraryId}`

And here is the reverse one: stops that are not part of this itinerary.

// Fetch all the available translated node stops that are not part of the
// current itinerary.
const stopNodesEndpoint = `${JSON_API_URL}/${languageId}/jsonapi/node/stop?sort=field_weight&filter[not-current-itinerary][condition][path]=field_itinerary.uuid&filter[not-current-itinerary][condition][operator]=NOT%20IN&filter[not-current-itinerary][condition][value][]=${this.props.itineraryId}`

You can continue reading about filtering, sorting and paginating. For more advanced filtering, have a look at JSON API Fancy Filters.

Include images

Don't do this

async getImageUrl() {
    const imageUUID = this.props.itinerary.relationships.field_image.data.id;
    const fileEndpoint = `${JSON_API_URL}/jsonapi/file/file/${imageUUID}`;
    const imageResponse = await fetch(fileEndpoint).then(response =>
      response.json(),
    );
    if (imageResponse) {
      const url = `${JSON_API_URL}/${imageResponse.data.attributes.url}`;
      this.setState({ imageUrl: url });
    }
  }

But prefer includes

const itineraryStopNodesWithImages = `${JSON_API_URL}/${languageId}/jsonapi/node/stop?filter[field_itinerary.uuid][value]=${this.props.itineraryId}&include=field_image`

Image styles

JSON API comes with zero configuration. It means that when you include images, the original image will be provided. You known this 5MB thing that could live on your server.

This is an exception in the zero configuration rule and it totally makes sense.

Download and enable the Consumer Image Styles contrib module on your Drupal site then go to Configuration > Consumers and add a Consumer.

Drupal add Consumer for the React app

Then you can call your image style with your consumer id.

const itineraryStopNodesWithImageStyles = `${JSON_API_URL}/${languageId}/jsonapi/node/stop?_consumer_id=${CONSUMER_ID}&sort=field_weight&filter[field_itinerary.uuid][value]=${this.props.itineraryId}&include=field_image`

The image styles will then be available in the meta.derivatives:

Consumer Image Style

For more information about image styles, read this article from Lullabot.

Getting parent terms only

Let's say that we have the following structure on our content model.

  • Itineraries can have or not child itineraries, we use the terms relation (hierarchy) for that.
    Example: The "Adult" itinerary has the "Film Noir" and "Zombie films" sub itineraries. The "Child" itinerary have no child itineraries.
  • A stop can belong to several itineraries, so the term reference has a Drupal multiplicity of 'unlimited'.
    Example: "The Maltese Falcon" belongs to the "Adult" and "Film Noir"

Based on that, we want to display the parent itinerary terms on a first view.

Depending on your Drupal version, the parents may be empty in you JSON API response, see Parent is always empty for taxonomy terms and Make $term->parent behave like any other entity reference field, to fix REST support and de-customize its Views integration

This is far from ideal (for redundancy), but as a temporary workaround, a is parent boolean can be added to the Itinerary vocabulary.
So the request for itineraries is now using filter[field_is_parent][value]=1 (note the 1 as true).

    ${JSON_API_URL}/${languageId}/jsonapi/taxonomy_term/itinerary?filter[field_is_parent][value]=1&sort=weight&include=field_image

Displaying optional child itineraries with propTypes and defaultProps

On a second view, e.g. while getting stops for an itinerary, we may want to group the stops by child itineraries.
In our content model, an itinerary can have a relation of 0..* for child itineraries. So we need tell React that they are optional and that JSON API will not always return values for the childItineraries prop.

defaultProps are there for that purpose.

  static propTypes = {
    childItineraries: PropTypes.shape({
      data: PropTypes.arrayOf(
        PropTypes.shape({
          id: PropTypes.string.isRequired,
        }).isRequired,
      ).isRequired,
      included: PropTypes.arrayOf(
        PropTypes.shape({
          id: PropTypes.string.isRequired,
        }).isRequired,
      ).isRequired,
    }),
  };

  static defaultProps = {
    childItineraries: null,
  };

Read more on Typechecking With PropTypes from the official React documentation.

Deploy in production

If you are using the same production server that hosts your Drupal site to serve your web app, you will have to run it on the same port (80 or 443) as Apache or Nginx.

We will use Apache here, as a reverse proxy.

1. Install and test Node.js

Install the NodeSource PPA

cd ~
curl -sL https://deb.nodesource.com/setup_6.x -o nodesource_setup.sh
sudo bash nodesource_setup.sh

Install node, npm and build-essential

sudo apt-get install nodejs
sudo apt-get install build-essential

Test node from the same server

nano hello.js

#!/usr/bin/env nodejs
var http = require('http');
http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Hello World\n');
}).listen(8080, 'localhost');
console.log('Server running at http://localhost:8080/');

chmod +x ./hello.js
./hello.js

curl http://localhost:8080

The result of the curl command should be the message from your console.log.

Test form another server

If you want to test your app from the outside, just remind to open your port (here 8080).

iptables -I INPUT 1 -i eth0 -p tcp --dport 8080 -j ACCEPT

Read more about Iptables.

Also, in this case, let your node server listen to 0.0.0.0 and not localhost, as described in this issue.

var http = require('http')
http
  .createServer(function (req, res) {
    res.writeHead(200, { 'Content-Type': 'text/plain' })
    res.end('Hello World\n')
  })
  .listen(8080, '0.0.0.0')
console.log('Server running at http://0.0.0.0:8080/')

2. Install Yarn

curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt-get update && sudo apt-get install yarn

3. Prepare your virtualhost

  • Clone your repository, to keep things organized I put it on the user directory that is dedicated to the virtualhost.
  • Cd into the cloned repo directory then install dependencies with yarn install
  • Production build with yarn run build --release

Another option is to use a deployment script, so we do not need yarn in production.

4. Manage your application with PM2

PM2 is a production process manager for Node.js. We will use it to run our React application.
Install it globally and make it available on startup.

sudo npm install -g pm2
pm2 startup systemd

Then start your application and check the status.

pm2 start build/server.js
pm2 status

If you need to restart it, after a rebuild.

pm2 restart build/server.js

5. Configure Apache

Modify your Apache vhost configuration as a reverse proxy to the port used by your application (here 3000).
Do the same for the port 443 (https).

<VirtualHost *:80>
ServerName mysite.org
ServerAlias www.mysite.org
DocumentRoot /home/mysite/docroot

<Proxy *>
  Order deny,allow
  Allow from all
</Proxy>

ProxyPreserveHost On
ProxyRequests off
ProxyPass / http://mysite.org:3000/
ProxyPassReverse / mysite.org:3000/
</VirtualHost>

Enable the Apache proxy modules and reload Apache.

sudo a2enmod proxy
sudo a2enmod proxy_http
service apache2 reload

Your app should now be available, yay!

Resources