This post is now somewhat outdated. If you’re interested in Angular/Rails/Grunt/JavaScript/HTML, read on. If you’d prefer Angular/Rails/Gulp/CoffeeScript/Jade, check out this newer post.
Why this tutorial exists
I wrote this tutorial because I had a pretty tough time getting Rails and Angular to talk to each other as an SPA. The best resource I could find out there was Ari Lerner’s Riding Rails with AngularJS. I did find that book very helpful and I thought it was really well-done, but it seems to be a little bit out-of-date by now and I couldn’t just plug in its code and have everything work. I had to do a lot of extra Googling and head-scratching to get all the way there. This tutorial is meant to be a supplement to Ari’s book, not a replacement for it. I definitely recommend buying the book because it really is very helpful.
The sample app
There’s a certain sample app I plan to use throughout AngularOnRails.com called Lunch Hub. The idea with Lunch Hub is that office workers can announce in the AM where they’d like to go for lunch rather than deciding as they gather around the door and waste half their lunch break. Since Lunch Hub is a real project with its own actual production code, I use a different project here called “Fake Lunch Hub.” You can see the Fake Lunch Hub repo here. I also created a branch specifically to match up with this tutorial here.
Setting up our Rails project
Instead of regular Rails we’re going to use Rails::API. I’ve tried to do Angular projects with full-blown Rails, but I end up with a bunch of unused views, which feels weird. First, if you haven’t already, install Rails::API.
$ gem install rails-api
Creating a new Rails::API project works the same as creating a regular Rails project.
$ rails-api new fake_lunch_hub -T -d postgresql
Get into our project directory.
$ cd fake_lunch_hub
Create our PostgreSQL user.
$ createuser -P -s -e fake_lunch_hub
Create the database.
$ rake db:create
Now we’ll create a resource so we have something to look at through our AngularJS app. (This might be a good time to commit this project to version control.)
Creating our first resource
Add gem 'rspec-rails'
to your Gemfile
(in the test
group) and run:
$ bundle install
$ rails g rspec:install
When you generate scaffolds from now on, RSpec will want to create all kinds of spec files for you automatically, including some kinds of specs (like view specs) that in my opinion are kind of nutty and really shouldn’t be there. We can tell RSpec not to create these spec files:
# config/application.rb
require File.expand_path('../boot', __FILE__)
# Pick the frameworks you want:
require "active_model/railtie"
require "active_record/railtie"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_view/railtie"
require "sprockets/railtie"
# require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module FakeLunchHub
class Application < Rails::Application
# Settings in config/environments/* take precedence over those specified here.
# Application configuration should go into files in config/initializers
# -- all .rb files in that directory are automatically loaded.
# Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
# Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
# config.time_zone = 'Central Time (US & Canada)'
# The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
# config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
# config.i18n.default_locale = :de
config.generators do |g|
g.test_framework :rspec,
fixtures: false,
view_specs: false,
helper_specs: false,
routing_specs: false,
request_specs: false,
controller_specs: true
end
end
end
(Now might be another good time to make a commit.)
In Lunch Hub, I want everybody’s lunch announcements to be visible only to other people in the office where they work, not the whole world. And there’s actually a good chance a person might want to belong not only to a group tied to his or her current workplace, but perhaps a former workplace or totally arbitrary group of friends. So I decided to create the concept of a Group in Lunch Hub. Let’s create a Group
resource that, for simplicity, has only one attribute: name
.
$ rails g scaffold group name:string
Since groups have to have names, let’s set null: false
in the migration. We’ll also include a uniqueness index.
# db/migrate/<timestamp>_create_groups.rb
class CreateGroups < ActiveRecord::Migration
def change
create_table :groups do |t|
t.string :name, null: false
t.timestamps
end
add_index :groups, :name, unique: true
end
end
$ rake db:migrate
Now, if you run rails server
and go to http://localhost:3000/groups
, you should see empty brackets ([]
). We actually want to be able to do http://localhost:3000/api/groups
instead.
# config/routes.rb
Rails.application.routes.draw do
scope '/api' do
resources :groups, except: [:new, :edit]
end
end
At the risk of being annoying, I wanted to include a realistic level of testing in the tutorial, at least on the server side. (I don’t have the client-side testing part 100% figured out yet.)
# spec/models/group_spec.rb
require 'rails_helper'
RSpec.describe Group, :type => :model do
before do
@group = Group.new(name: "Ben Franklin Labs")
end
subject { @group }
describe "when name is not present" do
before { @group.name = " " }
it { should_not be_valid }
end
end
To make this spec pass you’ll of course need to add a validation:
# app/models/group.rb
class Group < ActiveRecord::Base
validates :name, presence: true
end
We also have to adjust the controller spec RSpec spit out for us because RSpec’s generators are evidently not yet fully compatible with Rails::API. I suggest you just copy my file and replace yours wholesale.
# spec/controllers/groups_controller_spec.rb
require 'rails_helper'
# This spec was generated by rspec-rails when you ran the scaffold generator.
# It demonstrates how one might use RSpec to specify the controller code that
# was generated by Rails when you ran the scaffold generator.
#
# It assumes that the implementation code is generated by the rails scaffold
# generator. If you are using any extension libraries to generate different
# controller code, this generated spec may or may not pass.
#
# It only uses APIs available in rails and/or rspec-rails. There are a number
# of tools you can use to make these specs even more expressive, but we're
# sticking to rails and rspec-rails APIs to keep things simple and stable.
#
# Compared to earlier versions of this generator, there is very limited use of
# stubs and message expectations in this spec. Stubs are only used when there
# is no simpler way to get a handle on the object needed for the example.
# Message expectations are only used when there is no simpler way to specify
# that an instance is receiving a specific message.
RSpec.describe GroupsController, :type => :controller do
# This should return the minimal set of attributes required to create a valid
# Group. As you add validations to Group, be sure to
# adjust the attributes here as well.
let(:valid_attributes) {
skip("Add a hash of attributes valid for your model")
}
let(:invalid_attributes) {
skip("Add a hash of attributes invalid for your model")
}
# This should return the minimal set of values that should be in the session
# in order to pass any filters (e.g. authentication) defined in
# GroupsController. Be sure to keep this updated too.
let(:valid_session) { {} }
describe "GET index" do
it "assigns all groups as @groups" do
group = Group.create! valid_attributes
get :index, {}, valid_session
expect(assigns(:groups)).to eq([group])
end
end
describe "GET show" do
it "assigns the requested group as @group" do
group = Group.create! valid_attributes
get :show, {:id => group.to_param}, valid_session
expect(assigns(:group)).to eq(group)
end
end
describe "POST create" do
describe "with valid params" do
it "creates a new Group" do
expect {
post :create, {:group => valid_attributes}, valid_session
}.to change(Group, :count).by(1)
end
it "assigns a newly created group as @group" do
post :create, {:group => valid_attributes}, valid_session
expect(assigns(:group)).to be_a(Group)
expect(assigns(:group)).to be_persisted
end
it "redirects to the created group" do
post :create, {:group => valid_attributes}, valid_session
expect(response).to redirect_to(Group.last)
end
end
describe "with invalid params" do
it "assigns a newly created but unsaved group as @group" do
post :create, {:group => invalid_attributes}, valid_session
expect(assigns(:group)).to be_a_new(Group)
end
it "re-renders the 'new' template" do
post :create, {:group => invalid_attributes}, valid_session
expect(response).to render_template("new")
end
end
end
describe "PUT update" do
describe "with valid params" do
let(:new_attributes) {
skip("Add a hash of attributes valid for your model")
}
it "updates the requested group" do
group = Group.create! valid_attributes
put :update, {:id => group.to_param, :group => new_attributes}, valid_session
group.reload
skip("Add assertions for updated state")
end
it "assigns the requested group as @group" do
group = Group.create! valid_attributes
put :update, {:id => group.to_param, :group => valid_attributes}, valid_session
expect(assigns(:group)).to eq(group)
end
it "redirects to the group" do
group = Group.create! valid_attributes
put :update, {:id => group.to_param, :group => valid_attributes}, valid_session
expect(response).to redirect_to(group)
end
end
describe "with invalid params" do
it "assigns the group as @group" do
group = Group.create! valid_attributes
put :update, {:id => group.to_param, :group => invalid_attributes}, valid_session
expect(assigns(:group)).to eq(group)
end
it "re-renders the 'edit' template" do
group = Group.create! valid_attributes
put :update, {:id => group.to_param, :group => invalid_attributes}, valid_session
expect(response).to render_template("edit")
end
end
end
describe "DELETE destroy" do
it "destroys the requested group" do
group = Group.create! valid_attributes
expect {
delete :destroy, {:id => group.to_param}, valid_session
}.to change(Group, :count).by(-1)
end
it "redirects to the groups list" do
group = Group.create! valid_attributes
delete :destroy, {:id => group.to_param}, valid_session
expect(response).to redirect_to(groups_url)
end
end
end
Now if you run all specs on the command line ($ rspec
), they should all pass. We don’t have anything interesting to look at yet but our Rails API is now good to go.
Adding the client side
On the client side we’ll be using Yeoman, a front-end scaffolding tool. First, install Yeoman itself as well as Yeoman’s Angular generator. (If you don’t already have npm installed, you’ll need to do that. If you’re using Mac OS with Homebrew, run brew install npm
.)
$ npm install -g yo
$ npm install -g generator-angular
We’ll keep our client-side code in a directory called client
. (This is an arbitrary naming choice and you could call it anything.)
$ mkdir client
$ cd client
Now we’ll generate the Angular app itself. Just accept all the defaults.
$ yo angular fake_lunch_hub
Start Grunt:
$ grunt serve
It’s likely that you’ll the error “invalid option: –fonts-dir”. The solution (or at least a solution) to this problem is to remove the following line from your Gruntfile.js
(line 186 for me):
fontsDir: '<%= yeoman.app %>/styles/fonts',
When it spins up free of errors or warnings, Grunt should open a new browser tab for you at http://localhost:9000/#/
where you see the “‘Allo, ‘Allo” thing. Our Angular app is now in place. It still doesn’t know how to talk to Rails, so we still have to make that part work.
Setting up a proxy
We’ll use something called grunt-connect-proxy to forward certain requests from our Grunt server running on port 9000 to our Rails server running on port 3000.
$ npm install --save-dev grunt-connect-proxy
Change your Gruntfile to match this:
// Gruntfile.js // Generated on 2014-07-18 using generator-angular 0.9.5 'use strict'; // # Globbing // for performance reasons we're only matching one level down: // 'test/spec/{,*/}*.js' // use this if you want to recursively match all subfolders: // 'test/spec/**/*.js' module.exports = function (grunt) { // Load grunt tasks automatically require('load-grunt-tasks')(grunt); // Time how long tasks take. Can help when optimizing build times require('time-grunt')(grunt); // Configurable paths for the application var appConfig = { app: require('./bower.json').appPath || 'app', dist: 'dist' }; // Define the configuration for all the tasks grunt.initConfig({ // Project settings yeoman: appConfig, // Watches files for changes and runs tasks based on the changed files watch: { bower: { files: ['bower.json'], tasks: ['wiredep'] }, js: { files: ['<%= yeoman.app %>/scripts/{,*/}*.js'], tasks: ['newer:jshint:all'], options: { livereload: '<%= connect.options.livereload %>' } }, jsTest: { files: ['test/spec/{,*/}*.js'], tasks: ['newer:jshint:test', 'karma'] }, compass: { files: ['<%= yeoman.app %>/styles/{,*/}*.{scss,sass}'], tasks: ['compass:server', 'autoprefixer'] }, gruntfile: { files: ['Gruntfile.js'] }, livereload: { options: { livereload: '<%= connect.options.livereload %>' }, files: [ '<%= yeoman.app %>/{,*/}*.html', '.tmp/styles/{,*/}*.css', '<%= yeoman.app %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}' ] } }, // The actual grunt server settings connect: { options: { port: 9000, // Change this to '0.0.0.0' to access the server from outside. hostname: 'localhost', livereload: 35729 }, proxies: [ { context: '/api', host: 'localhost', port: 3000 } ], livereload: { options: { open: true, middleware: function (connect, options) { if (!Array.isArray(options.base)) { options.base = [options.base]; } // Setup the proxy var middlewares = [ require('grunt-connect-proxy/lib/utils').proxyRequest, connect.static('.tmp'), connect().use( '/bower_components', connect.static('./bower_components') ), connect.static(appConfig.app) ]; // Make directory browse-able. var directory = options.directory || options.base[options.base.length - 1]; middlewares.push(connect.directory(directory)); return middlewares; } } }, test: { options: { port: 9001, middleware: function (connect) { return [ connect.static('.tmp'), connect.static('test'), connect().use( '/bower_components', connect.static('./bower_components') ), connect.static(appConfig.app) ]; } } }, dist: { options: { open: true, base: '<%= yeoman.dist %>' } } }, // Make sure code styles are up to par and there are no obvious mistakes jshint: { options: { jshintrc: '.jshintrc', reporter: require('jshint-stylish') }, all: { src: [ 'Gruntfile.js', '<%= yeoman.app %>/scripts/{,*/}*.js' ] }, test: { options: { jshintrc: 'test/.jshintrc' }, src: ['test/spec/{,*/}*.js'] } }, // Empties folders to start fresh clean: { dist: { files: [{ dot: true, src: [ '.tmp', '<%= yeoman.dist %>/{,*/}*', '!<%= yeoman.dist %>/.git*' ] }] }, server: '.tmp' }, // Add vendor prefixed styles autoprefixer: { options: { browsers: ['last 1 version'] }, dist: { files: [{ expand: true, cwd: '.tmp/styles/', src: '{,*/}*.css', dest: '.tmp/styles/' }] } }, // Automatically inject Bower components into the app wiredep: { options: { cwd: '<%= yeoman.app %>' }, app: { src: ['<%= yeoman.app %>/index.html'], ignorePath: /\.\.\// }, sass: { src: ['<%= yeoman.app %>/styles/{,*/}*.{scss,sass}'], ignorePath: /(\.\.\/){1,2}bower_components\// } }, // Compiles Sass to CSS and generates necessary files if requested compass: { options: { sassDir: '<%= yeoman.app %>/styles', cssDir: '.tmp/styles', generatedImagesDir: '.tmp/images/generated', imagesDir: '<%= yeoman.app %>/images', javascriptsDir: '<%= yeoman.app %>/scripts', importPath: './bower_components', httpImagesPath: '/images', httpGeneratedImagesPath: '/images/generated', httpFontsPath: '/styles/fonts', relativeAssets: false, assetCacheBuster: false, raw: 'Sass::Script::Number.precision = 10\n' }, dist: { options: { generatedImagesDir: '<%= yeoman.dist %>/images/generated' } }, server: { options: { debugInfo: true } } }, // Renames files for browser caching purposes filerev: { dist: { src: [ '<%= yeoman.dist %>/scripts/{,*/}*.js', '<%= yeoman.dist %>/styles/{,*/}*.css', '<%= yeoman.dist %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}', '<%= yeoman.dist %>/styles/fonts/*' ] } }, // Reads HTML for usemin blocks to enable smart builds that automatically // concat, minify and revision files. Creates configurations in memory so // additional tasks can operate on them useminPrepare: { html: '<%= yeoman.app %>/index.html', options: { dest: '<%= yeoman.dist %>', flow: { html: { steps: { js: ['concat', 'uglifyjs'], css: ['cssmin'] }, post: {} } } } }, // Performs rewrites based on filerev and the useminPrepare configuration usemin: { html: ['<%= yeoman.dist %>/{,*/}*.html'], css: ['<%= yeoman.dist %>/styles/{,*/}*.css'], options: { assetsDirs: ['<%= yeoman.dist %>','<%= yeoman.dist %>/images'] } }, // The following *-min tasks will produce minified files in the dist folder // By default, your `index.html`'s <!-- Usemin block --> will take care of // minification. These next options are pre-configured if you do not wish // to use the Usemin blocks. // cssmin: { // dist: { // files: { // '<%= yeoman.dist %>/styles/main.css': [ // '.tmp/styles/{,*/}*.css' // ] // } // } // }, // uglify: { // dist: { // files: { // '<%= yeoman.dist %>/scripts/scripts.js': [ // '<%= yeoman.dist %>/scripts/scripts.js' // ] // } // } // }, // concat: { // dist: {} // }, imagemin: { dist: { files: [{ expand: true, cwd: '<%= yeoman.app %>/images', src: '{,*/}*.{png,jpg,jpeg,gif}', dest: '<%= yeoman.dist %>/images' }] } }, svgmin: { dist: { files: [{ expand: true, cwd: '<%= yeoman.app %>/images', src: '{,*/}*.svg', dest: '<%= yeoman.dist %>/images' }] } }, htmlmin: { dist: { options: { collapseWhitespace: true, conservativeCollapse: true, collapseBooleanAttributes: true, removeCommentsFromCDATA: true, removeOptionalTags: true }, files: [{ expand: true, cwd: '<%= yeoman.dist %>', src: ['*.html', 'views/{,*/}*.html'], dest: '<%= yeoman.dist %>' }] } }, // ngmin tries to make the code safe for minification automatically by // using the Angular long form for dependency injection. It doesn't work on // things like resolve or inject so those have to be done manually. ngmin: { dist: { files: [{ expand: true, cwd: '.tmp/concat/scripts', src: '*.js', dest: '.tmp/concat/scripts' }] } }, // Replace Google CDN references cdnify: { dist: { html: ['<%= yeoman.dist %>/*.html'] } }, // Copies remaining files to places other tasks can use copy: { dist: { files: [{ expand: true, dot: true, cwd: '<%= yeoman.app %>', dest: '<%= yeoman.dist %>', src: [ '*.{ico,png,txt}', '.htaccess', '*.html', 'views/{,*/}*.html', 'images/{,*/}*.{webp}', 'fonts/*' ] }, { expand: true, cwd: '.tmp/images', dest: '<%= yeoman.dist %>/images', src: ['generated/*'] }, { expand: true, cwd: '.', src: 'bower_components/bootstrap-sass-official/assets/fonts/bootstrap/*', dest: '<%= yeoman.dist %>' }] }, styles: { expand: true, cwd: '<%= yeoman.app %>/styles', dest: '.tmp/styles/', src: '{,*/}*.css' } }, // Run some tasks in parallel to speed up the build process concurrent: { server: [ 'compass:server' ], test: [ 'compass' ], dist: [ 'compass:dist', 'imagemin', 'svgmin' ] }, // Test settings karma: { unit: { configFile: 'test/karma.conf.js', singleRun: true } } }); grunt.registerTask('serve', 'Compile then start a connect web server', function (target) { if (target === 'dist') { return grunt.task.run(['build', 'connect:dist:keepalive']); } grunt.task.run([ 'clean:server', 'wiredep', 'concurrent:server', 'autoprefixer', 'configureProxies', 'connect:livereload', 'watch' ]); }); grunt.registerTask('server', 'DEPRECATED TASK. Use the "serve" task instead', function (target) { grunt.log.warn('The `server` task has been deprecated. Use `grunt serve` to start a server.'); grunt.task.run(['serve:' + target]); }); grunt.registerTask('test', [ 'clean:server', 'concurrent:test', 'autoprefixer', 'configureProxies', 'connect:test', 'karma' ]); grunt.registerTask('build', [ 'clean:dist', 'wiredep', 'useminPrepare', 'concurrent:dist', 'autoprefixer', 'concat', 'ngmin', 'copy:dist', 'cdnify', 'cssmin', 'uglify', 'filerev', 'usemin', 'htmlmin' ]); grunt.registerTask('default', [ 'newer:jshint', 'test', 'build' ]); grunt.loadNpmTasks('grunt-connect-proxy'); };
Now kill Grunt and again run:
grunt serve
You should now be able to go to http://localhost:9000/api/groups
and get empty brackets. (Make sure your Rails server is running.) Our Angular app is now talking to Rails.
Getting Rails data to show up in our client app
Now we’ll want to get some actual data to show up in the actual HTML of our Angular app. This is a pretty easy step now that we have the plumbing taken care of. First, create some seed data:
# db/seeds.rb
# This file should contain all the record creation needed to seed the database with its default values.
# The data can then be loaded with the rake db:seed (or created alongside the db with db:setup).
#
# Examples:
#
# cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }])
# Mayor.create(name: 'Emanuel', city: cities.first)
Group.create([
{ name: 'Ben Franklin Labs' },
{ name: 'Snip Salon Software' },
{ name: 'GloboChem' },
{ name: 'TechCorp' },
])
Get the data into the database:
$ rake db:seed
Now we’ll add an AngularJS resource that will allow us to conveniently perform CRUD operations on Group
. AngularJS resources match pretty nicely with Rails resources, and I’ve found that my code can be a lot more DRY using Angular resources than jQuery AJAX. We won’t get into the details here of the Angular/Rails resource interaction, though. All we’ll use is the query()
method, which matches up to a Rails resource’s index
action. Add the Group
resource to app/scripts/app.js
. I changed more than just a few lines in this file, so you might want to just copy and paste the whole thing.
// app/scripts/app.js 'use strict'; /** * @ngdoc overview * @name fakeLunchHubApp * @description * # fakeLunchHubApp * * Main module of the application. */ var app = angular.module('fakeLunchHubApp', [ 'ngAnimate', 'ngCookies', 'ngResource', 'ngRoute', 'ngSanitize', 'ngTouch' ]); app.config(function ($routeProvider) { $routeProvider .when('/', { templateUrl: 'views/main.html', controller: 'MainCtrl' }) .when('/about', { templateUrl: 'views/about.html', controller: 'AboutCtrl' }) .when('/groups', { templateUrl: 'views/groups.html', controller: 'GroupsCtrl' }) .otherwise({ redirectTo: '/' }); }); app.factory('Group', ['$resource', function($resource) { return $resource('/api/groups/:id.json', null, { 'update': { method:'PUT' } }); }]);
Now add a controller for the Group resource:
// app/scripts/controllers/groups.js 'use strict'; /** * @ngdoc function * @name fakeLunchHubApp.controller:GroupsCtrl * @description * # GroupsCtrl * Controller of the fakeLunchHubApp */ angular.module('fakeLunchHubApp') .controller('GroupsCtrl', ['$scope', 'Group', function ($scope, Group) { $scope.groups = Group.query(); }]);
And add a view.
<!-- app/views/groups.html --> <h1>Groups</h1> <ul ng-repeat="group in groups"> <li>{{group.name}}</li> </ul>
Lastly, add the following line to app/index.html, near the bottom:
<!-- app/index.html --> <script src="scripts/controllers/groups.js"></script>
If you now go to http://localhost:9000/#/groups
, you should see our list of groups.
Deployment
I’ve written a separate article about deployment called How to Deploy an Angular/Rails Single-Page Application to Heroku.
This is a work in progress
This tutorial is a work in progress, and I intend to update it as a) the technology moves and b) I learn more. I’m aware that there are certain things missing, such as testing on the client side and end-to-end tests. But right now I just want to get this tutorial out into the world because I haven’t seen anything yet that spells out the Rails/Angular combo with this level of hand-holding. I hope this has been helpful. Please leave me any questions or comments you might have. Thanks.