Rails and Angular without the SPA

A full on Angular single page application (SPA) is overkill 90% of the time. This article will illustrate a simple way to get the rich user experience from javascript while still relying on Rails server rendering for the bulk of the application.

Our approach advocates the following philosophy:

Javascript and angular functionality exists to enrich the user experience of existing server-side rendered pages.

This is based on the premise that only a handful of pages actually need javascript functionality. Most of your pages are happily being rendered server side, but there’s that one page that needs sophisticated user experience (UX). For example, you might have a master-detail page, and want AJAX requests to fill the detail without navigating to a new page.

Setting up Angular with Rails

I recommend using bower-rails to manage client side dependencies like Angular. You will need to install bower, please go to bower-rails for more details if unfamiliar.

Bowerfile

# A sample Bowerfile
# Check out https://github.com/42dev/bower-rails#ruby-dsl-configuration for more options

asset 'angular', '~> 1.4.8'

Pull down the client side dependencies

rake bower:install bower:clean

Then add .js files to application.js

//= require angular/angular

Setup is done.

If you’re using Turbolinks (you should), you’ll need to bootstrap the angular application on every Turbolinks page load.

I recommend creating an initializer.js file with the following:

// On turbolinks load:
$(document).on('ready page:load', function() {
  angular.bootstrap('body', ['ltApp'])
});

This callback will bootstrap angular on every turbolinks page navigation.

Angular Directives and Controllers

With all our setup done, we can now add Angular functionality to our pages.

There are two main ways to do this:

  1. Angular Controllers
    • Useful when adding page specific functionality
  2. Angular Directives
    • Useful when creating reusable widgets, such as a HTML 5 video player, that can be reused across pages.

Angular Controllers

Angular let’s you tag DOM elements with ng-controller to associate javascript functionality with a particular page. The key word below being UserCountsChartCtrl:

users/index.haml.html

.graphs{"ng-controller" => "UserCountsChartCtrl"}
  .hidden.graph-data{data: { 'graph-data' => @weekly_data.to_json} }
  .row
    .col-md-12
      %canvas.chart.chart-bar{data: "barGraph.data", labels: "barGraph.labels", series: "barGraph.series", options: "barGraph.options"}

javacsripts/ng/controllers/UserCountsChartCtrl.js

angular.module('ltApp').
  controller('UserCountsChartCtrl', ['$scope', '$element', function($scope, $element) {
    var labels = $element.find('.graph-data').data("graph-data").dates;
    var data = $element.find('.graph-data').data("graph-data").user_counts;

    // bar graph
    $scope.barGraph = {
      labels: labels,
      series: ["User Counts"],
      data: [data],
      options: { responsive: true, maintainAspectRatio: false }
    };
}]);

Here I am drawing bar charts on the index page using Charts.js with an Angular Controller.

Passing data from server side to client side

One often needs to get information to the javascript client. In a typical SPA, this is done with a subsequent AJAX call. Here we save that AJAX call and just render the information in data HTML attributes. Notice the lines below:

.graphs{"ng-controller" => "UserCountsChartCtrl"}
  .hidden.graph-data{data: { 'graph-data' => @weekly_data.to_json} }
  ....

javacsripts/ng/controllers/UserCountsChartCtrl.js

angular.module('ltApp').
  controller('UserCountsChartCtrl', ['$scope', '$element', function($scope, $element) {
    var labels = $element.find('.graph-data').data("graph-data").dates; // pull data
    var data = $element.find('.graph-data').data("graph-data").user_counts;
    ...

This allows you to leverage Rails (controllers, presenters, etc) to present the data needed by the javascript client side.

Angular Directives

Angular Directives allow you to create javascript functionality that is cross cutting. The example below is ensures that a form can only be submitted once, to stop those pesky double click submissions.

angular.module('ltApp').
    directive('ltSingleSubmit', function () {
      return function (scope, element, attrs) {
        element.bind("submit", function (event) {
          element.find("input[type='submit']").prop('disabled', true);
        });
      };
    });

This can now be easily used in any form:

  = simple_form_for @build, url: build_path(@build), html: { "lt-single-submit" => '' } do |f|
    = f.input :name
    = f.submit 'Save'

Relying on Server Side Rendering

When making hybrid applications like this, it’s easy to get server side and client side rendering jumbled together. I advocate always using server side rendering. Sure it’s less flashy, but it’s more productive and consolidates your validations into one place.

I’ve done this many ways. We’ll start with the simplest: Angular $http GETs.

AJAX with Angular $http

angular.module('ltApp').
  controller('MarketingShowCtrl', ['$scope', '$element', '$http', 'turbosafe', function($scope, $element, $http, turbosafe) {
    var path = $element.find(".cta").data("path"); // Get the path for the GET form the data attribute.

    // Angular Scope has method fetchCollection used by ng-click.
    $scope.fetchCollection = function(collectionName, direction) {
      // Start Turbolinks progress bar even though we're using Angular!
      turbosafe.start();

      $http.get(path, {params: {id: collectionName, direction: direction}}).
        then(function(response) {
          var newDom = $(response.data);
          // Replace DOM element with Server Side rendered HTML.
          $element.find("#top").replaceWith(newDom);
          turbosafe.stop();
        }, function(response) {
          console.warn("Unable to retrieve collection", response);
          turbosafe.stop();
      });
    };
= link_to "Next Collection", "#", "ng-click" => 'fetchCollection("$50", "Next")'

AJAX with Remote Forms

angular.module('ltApp').
  controller('AccessTokenCtrl', ['$scope', '$element', function($scope, $element) {
    // Handle remote forms success.
    $element.on("ajax:success", function(e, data, status) {
      $element.find("#access_token").html(data.html);
    });
}]);
.panel-body
  .row
    .col-md-12{ "ng-controller" => "AccessTokenCtrl"}
      %h3.col-header
      #access_token= @user.live_access_token
      - # Notice the remote: true to show Rails remote forms.
      = simple_form_for(@user, url: generate_access_token_account_path(mode: :live), remote: true) do |f|
        = f.button :button do
          %span.glyphicon.glyphicon-refresh

Wrap up

You’re right, I got carried away at the end. But now you have an exhaustive set of examples that show how a sprinkle of Angular can breathe rich interactivity into your Rails rendered pages.

Enjoy!

comments powered by Disqus