Decoupling the CMS with React and CouchDB

Juampy NR - @juampynr

Juampy NR - @juampynr

Senior Developer

about.me/juampynr

The goal

Give the design and front end teams freedom to craft the website without the constraints of a CMS

The project

  • Migrate existing data to a simpler Drupal installation.
  • Export content to CouchDB on every cron run.
  • Serve content from a Node.js + React application.

The back end

Exporting data via Queue API

              
$ drush queue-list
 Queue                                   Items  Class       
 file_entity_type_determine              0      SystemQueue 
 lullabot_edit_exporter_nodes            1      SystemQueue 
 lullabot_edit_exporter_views            0      SystemQueue 
 lullabot_edit_exporter_lanyrd           0      SystemQueue 
 lullabot_edit_exporter_latest_articles  0      SystemQueue 
 lullabot_edit_exporter_weather          0      SystemQueue 

$ drush queue-run lullabot_edit_exporter_nodes
              
            

Queue Processing

              
function _lullabot_edit_exporter_nodes_run($nid) {
  $sag = _lullabot_edit_exporter_get_couchdb_handler();
  // ...
  switch ($result['op']) {
    case 'update':
      $node = node_load($nid);
      if ($node) {
        $output = new stdClass();
        $output->_id = $node->uuid;

        // Allow other modules to determine the output class structure.
        module_invoke_all('lullabot_edit_exporter_node_' . $node->type, $node, $output);
        module_invoke_all('lullabot_edit_exporter_node', $node, $output);

        // Insert/Update the node into couchdb.
        try {
          if ($sag->put($output->_id, $output)->body->ok) {
            $success = TRUE;
          }
        }
        catch (Exception $e) {
          watchdog('lullabot_edit_exporter', $e->getMessage(), array(), WATCHDOG_WARNING);
        }
  // ...
}
              
            

Node formatting for CouchDB

              
function lullabot_edit_article_lullabot_edit_exporter_node_article($node, $output) {
  $node_wrapper = entity_metadata_wrapper('node', $node);
  $output->nid = $node->nid;
  $output->type = $node->type;
  $output->title = $node->title;
  $output->published = $node->created;
  $output->full_slug = _lullabot_edit_shared_fields_get_full_slug($node);
  $output->full_slug_flat = _lullabot_edit_shared_fields_get_flat_slug($output->full_slug);
  $output->legacy_nid = $node_wrapper->field_legacy_nid->value();
  $output->deck = _lullabot_edit_exporter_filter_xss($node_wrapper->field_deck->raw());
  $output->body = array(
    'value' => $node_wrapper->body->value() ? $node_wrapper->body->value->value() : NULL,
    'format' => $node_wrapper->body->value() ? $node_wrapper->body->format->value() : NULL,
  );

  $output->related_links = array();
  foreach ($node_wrapper->field_related_links->getIterator() as $delta => $item_wrapper) {
    $output->related_links[] = array(
      'url' => $item_wrapper->url->value(),
      'title' => $item_wrapper->title->value(),
    );
  }

  // ...
}
              
            

The front end's database

CouchDB's interface

CouchDB's API

The front end

The article.jsx component (simplified)

              
// First we have declarations and dependencies being loaded.
'use strict';
var React = require('react');
var Settings = require('../../../settings');
var Header = require('../component/header.jsx');

// Then the component definition.
var LayoutArticle = React.createClass({
  // The class may have custom methods:
  getBackground: function() {},
  getSeries: function() {},

  // React's component lifecycle offers a few events:
  getInitialState: function() {},
  componentDidMount: function() {},

  // The render() method returns a mix of HTML and XML that is then compiled.
  render: function() {
    var activeAuthors = [];
    this.props.doc.authors.map(function(author) {
      if (author.active) {
        activeAuthors.push(author);
      }
    });

    return (
      
); } }); // Finally, export the React component as a NodeJS module. module.exports = LayoutArticle;

Forms

The newsletter form

The newsletter.jsx component

              
'use strict';
var React = require('react');
var Icon = require('./../icon.jsx');

var PromoNewsletter = React.createClass({
  getInitialState: function() {
    return {
      sending: false,
      status: null,
      tryAgain: false
    };
  },
  tryAgain: function (event) {
    event.preventDefault();
    this.setState({sending: false, status: '', tryAgain: false});
  },
  handleSubmit: function (event) {
    event.preventDefault();

    this.email = React.findDOMNode(this.refs.email).value;
    if (this.email) {
      var url = '/mailchimp-signup';
      this.setState({sending: true, status: '', tryAgain: false});
      var xmlhttp = new XMLHttpRequest();
      var _this = this;
      // Handle network errors.
      xmlhttp.onerror = function() {
        _this.setState({sending: false, status: 'Sorry, there has been a network error', tryAgain: true});
      };
      xmlhttp.onreadystatechange = function() {
        if (xmlhttp.readyState === 4) {
          _this.setState({sending: false, status: '', tryAgain: false});
          var response = JSON.parse(xmlhttp.responseText);
          if (xmlhttp.status === 200) {
            // Evaluate response and update status.
            if (!response.error) {
              _this.setState({sending: false, status: 'Success! Check your email for confirmation', tryAgain: false});
            }
            else {
              switch (response.name) {
                // Server errors.
                case 'List_DoesNotExist':
                case 'Invalid_ApiKey':
                case 'User_InvalidRole':
                  _this.setState({sending: false, status: 'Oops, there is something wrong on our side. Please try again later', tryAgain: false});
                  break;
                // Client errors.
                case 'Email_NotExists':
                case 'List_AlreadySubscribed':
                case 'List_InvalidImport':
                default:
                  _this.setState({sending: false, status: response.error, tryAgain: true});
                  break;
              }
            }
          }
          else {
            // Handle server errors.
            _this.setState({sending: false, status: 'Oops, there is something wrong on our side. Please try again later', tryAgain: false});
          }
        }
      };
      xmlhttp.open('POST', url, true);
      xmlhttp.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
      xmlhttp.send('email=' + encodeURIComponent(this.email));
    }
  },
  render: function() {
    return (
      
Sign up for the Lullabot Newsletter and get our latest articles and more direct to your inbox
{this.state.status} Try again.
Loading...
); } }); module.exports = PromoNewsletter;

Processing a Newsletter subscription

              
'use strict';
var express = require('express');
var path = require('path');
var app = express();
var bodyParser = require('body-parser');

// ... Other routes go here ...

// Newsletter form handler.
app.use('/mailchimp-signup', function(req, res) {
  var ServerSettings = require('../server-settings.js');
  var Mcapi = require('mailchimp-api');
  var mc = new Mcapi.Mailchimp(ServerSettings.mailChimp.apiKey);
  mc.lists.subscribe({id: ServerSettings.mailChimp.listId, email: {email: req.body.email}}, function(data) {
    res.send(data);
  },
  function(error) {
    res.send(error);
  });
});
              
            

Deployments

Deploying releases

Back end deployment

              
echo "[TAG]: $TAG"

set -e
error() {
  echo "Error detected: $@" 1>&2
  exit 1
}

DRUSH=drush

# Set up the destination for the backup.
cd /var/www/edit.lullabot.com
OLD_TAG=`git describe --always --tag`
FILE=`date "+%Y-%m-%d-%H%M-$OLD_TAG.sql.gz"`
BACKUP_DIR="$HOME/drush-backups/pulls/edit.lullabot.com"
mkdir -p $BACKUP_DIR
BACKUP_FILE="$BACKUP_DIR/$FILE"
RESTORE_MSG="If problems were found, the database was saved to $BACKUP_FILE."

# Save a database backup.
cd docroot
eval "$DRUSH sql-dump | gzip > $BACKUP_FILE" &&

## Checkout the Git tag and update the database.
eval cd `eval $DRUSH dd` &&
eval git fetch origin &&
eval git checkout $TAG &&
eval $DRUSH -v updatepath ||
# If we end up here, there was a problem.
error "$RESTORE_MSG"
echo "$RESTORE_MSG"

echo "Cleaning up old dumps."
# Delete all but 4 most recent backups.
for x in `ls -tr $BACKUP_DIR | head -n -4`; do rm -v $BACKUP_DIR/$x; done

# Update the version number.
echo $TAG > version.txt
              
            

Front end deployment

First, install dependencies:

              
echo "[TAG]: $GIT_TAG"

# Checks out the repository to a numbered directory.
mv $WORKSPACE/release /var/www/www.lullabot.com-releases/$BUILD_NUMBER
cd /var/www/www.lullabot.com-releases/$BUILD_NUMBER

# Copy .env settings.
cp /var/www/www.lullabot.com/.env .

# Build project dependencies and assets.
npm run update
npm run build
              
            

Front end deployment (2)

Then, update the web root's symlink:

              
# Point webroot to the new release through a symlink.
cd /var/www/
ln -s $WORKSPACE/$BUILD_NUMBER www.lullabot.com_tmp
mv -Tf www.lullabot.com_tmp www.lullabot.com
echo $GIT_TAG > www.lullabot.com/version.txt

# Restart the Node.js application.
sudo service lullabot.com-www restart
              
            

Next steps

  • Upgrade the back end to Drupal 8.
  • No user session. What if we need it?
  • No site preview. How can we let editors preview an article?

Useful links

Acknowledgements

Jeff Eaton
Sally Young

Thanks! Questions?

@juampynr

about.me/juampynr

Lullabot is looking for projects!