Getting started with AngularJS isn't hard. The documentation is some of the best out there and it's tutorials are simple enough.
But things get tricky when you start combining technologies.
If you're using CoffeeScript instead of straight JavaScript, you know have preprocessing concerns to take into account - as well as the obvious syntax difference. These are minor issues by themselves, but what if you throw Ruby on Rails, Jasmine and Karma into the mix? It gets surprisingly trickier.
This is exactly the stack we're going to use in this tutorial. Not because we're gluttons for punishment, but because this is the kind of setup you'll see in the real world.
This tutorial assumes that you're comfortable with Rails, but not necessarily AngularJS.
Creating a base Rails app
Since there are so many technology layers involved, I'm going to build a simple application that barely does anything. We'll be setting up CRUD functionality for restaurants - actually, just the CR part. The -UD is left as an exercise for the reader. ;-)
We'll call the application Restauranteur.
I'm using PostgreSQL and RSpec here, but the DBMS and server-side testing framework are not significant. You can use whatever you want.
Initial setup
First create the project:
$ rails new restauranteur --database=postgresql --skip-test-unit
If you're using Pow, add your project to Pow:
$ ln -s /Users/jasonswett/projects/restauranteur ~/.pow/restauranteur
Create the PostgreSQL database user:
$ createuser -P -s -e restauranteur
Add RSpec to your Gemfile:
# Gemfile
gem "rspec-rails", "~> 2.14.0"
Install RSpec:
$ bundle install
$ rails g rspec:install
Create the database:
$ rake db:create
Creating the Restaurant model
Now that we have our project and database created, let's create our first resource. The Restaurant resource will have only one attribute: name, which is a string.
$ rails generate scaffold restaurant name:string
Now, just to be OCD about it, we'll make sure restaurant names are unique.
# db/migrate/[timestamp]_create_restaurants.rb
class CreateRestaurants < ActiveRecord::Migration
def change
create_table :restaurants do |t|
t.string :name
t.timestamps
end
# Add the following line
add_index :restaurants, :name, unique: true
end
end
Run the migration:
$ rake db:migrate
Let's add some specs to verify that we can't create invalid restaurants. Notice that unique failure gives raw error.
require 'spec_helper'
describe Restaurant do
before do
@restaurant = Restaurant.new(name: "Momofuku")
end
subject { @restaurant }
it { should respond_to(:name) }
it { should be_valid }
describe "when name is not present" do
before { @restaurant.name = " " }
it { should_not be_valid }
end
describe "when name is already taken" do
before do
restaurant_with_same_name = @restaurant.dup
restaurant_with_same_name.name = @restaurant.name.upcase
restaurant_with_same_name.save
end
it { should_not be_valid }
end
end
Adding these validators will make the specs pass:
class Restaurant < ActiveRecord::Base
validates :name, presence: true, uniqueness: { case_sensitive: false }
end
We're now good to move on.
Bringing AngularJS into the mix
Rather than dump everything on you at once, I'd first like to demonstrate the simplest "Hello, world" version of an AngularJS-Rails application and then build our restaurant CRUD functionality onto that.
There's no reason our "Hello, world" page must or should be tied to any particular Rails resource. For this reason, we'll create a StaticPagesController
to serve up our AngularJS home page.
Create the controller
$ rails generate controller static_pages index
Our root route right now is just the "Welcome to Rails" page. Let's set it to the index
action of our new StaticPagesController
:
# config/routes.rb
Restauranteur::Application.routes.draw do
# Add the following line
root 'static_pages#index'
end
Download Angular
- In order to get our tests to work properly later, we'll need a file called
angular-mocks.js
. I don't think there's any mention of this in the Angular docs anywhere, but it's necessary. - In the AngularJS tutorial, the docs list the latest bleeding-edge version, but if I recall correctly, I had problems with compatibility between
angular.js
andangular-mocks.js
for the latest version. I know that versions 1.1.5 worked together, so even though that's not the latest stable version, that's the version I'm listing here. Of course as time goes on the compatibility situation will probably improve.
Download angular.js
and angular-mocks.js
from code.angularjs.org and move the files into app/assets/javascripts
.
$ wget http://code.angularjs.org/1.1.5/angular.js \
http://code.angularjs.org/1.1.5/angular-mocks.js
$ mv angular* app/assets/javascripts
Add it to the asset pipeline
Now we want to tell our application to require the AngularJS file, and we want to make sure it gets loaded before other files that depend on it. (We could use something like RequireJS to manage these dependencies, and that's probably what I would do on a production product, but for the purposes of this tutorial I want to keep the technology stack as thin as possible.)
Note: Angular and Turbolinks can conflict with one another, so we disable them here
// app/assets/javascripts/application.js
// This is a manifest file that'll be compiled into application.js, which will include all the files
// listed below.
//
// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
// or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path.
//
// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
// compiled file.
//
// Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details
// about supported directives.
//
//= require jquery
//= require jquery_ujs
// Add the following two lines
//= require angular
//= require main
//= require_tree .
Set up the layout
We'll add ng-app and ng-view, which signal that we have an Angular app in our page. Also notice that mentions of Turbolinks have been removed.
<%= yield %>
Creating an Angular controller
First let's create a directory for our controllers. You can name it whatever you want.
$ mkdir -p app/assets/javascripts/angular/controllers
Now let's create the controller file itself. I'm calling this controller the "home controller," and the convention in Angular is to append your controller filenames withCtrl
. Thus our filename will beapp/assets/javascripts/angular/controllers/HomeCtrl.js.coffee
:
# app/assets/javascripts/angular/controllers/HomeCtrl.js.coffee
@restauranteur.controller 'HomeCtrl', ['$scope', ($scope) ->
# Notice how this controller body is empty
]
Add an Angular route
Now we'll add a routing directive in order to make our HomeCtrl
be our "default page." Here I'm defining my routing in app/assets/javascripts/main.js.coffee
, but again I don't think the filename matters.
# app/assets/javascripts/main.js.coffee
# This line is related to our Angular app, not to our
# HomeCtrl specifically. This is basically how we tell
# Angular about the existence of our application.
@restauranteur = angular.module('restauranteur', [])
# This routing directive tells Angular about the default
# route for our application. The term "otherwise" here
# might seem somewhat awkward, but it will make more
# sense as we add more routes to our application.
@restauranteur.config(['$routeProvider', ($routeProvider) ->
$routeProvider.
otherwise({
templateUrl: '../templates/home.html',
controller: 'HomeCtrl'
})
])
Add an Angular template
We'll also want a place to keep our Angular templates. I decided to put mine inpublic/templates
. Again, you can place them wherever you like.
mkdir public/templates
If we create a file public/templates/home.html
with some arbitrary content, we should be able to see it in the browser.
This is the home page.
Now, if you go to http://restauranteur.dev/
(or http://localhost:3000/
if you're not using Pow) and you should see the contents of home.html
.
An example of data binding
That's kind of interesting, but probably not very impressive. Let's actually send something across the wire. Edit yourapp/assets/angular/controllers/HomeCtrl.js.coffee
like this:
# app/assets/angular/controllers/HomeCtrl.js.coffee
@restauranteur.controller 'HomeCtrl', ['$scope', ($scope) ->
$scope.foo = 'bar'
]
This is kind of analagous to saying @foo = "bar"
in a Rails controller. We can plugfoo
into the template by using the double-brace syntax like this:
Value of "foo": {{foo}}
Doing it for real this time
We've already built a simple hello world app. Creating a full blown CRUD application isn't much harder.
Seed the database
Working with our restaurant CRUD will be a little more meaningful if we start with some restaurants in the database. Here's a seed file you can use.
# db/seeds.rb
Restaurant.create([
{ name: "The French Laundry" },
{ name: "Chez Panisse" },
{ name: "Bouchon" },
{ name: "Noma" },
{ name: "Taco Bell" },
])
rake db:seed
Creating a restaurant index page
First let's create a template folder for restaurants:
mkdir public/templates/restaurants
The first template we'll create is the index page:
[index](/#)
* {{ restaurant.name }}
I'll explain in a moment what these things mean. First let's create the controller:
# app/assets/javascripts/angular/controllers/RestaurantIndexCtrl.js.coffee
@restauranteur.controller 'RestaurantIndexCtrl', ['$scope', '$location', '$http', ($scope, $location, $http) ->
$scope.restaurants = []
$http.get('./restaurants.json').success((data) ->
$scope.restaurants = data
)
]
Lastly, we'll adjust our routing configuration:
# app/assets/javascripts/main.js.coffee
@restauranteur = angular.module('restauranteur', [])
@restauranteur.config(['$routeProvider', ($routeProvider) ->
$routeProvider.
when('/restaurants', {
templateUrl: '../templates/restaurants/index.html',
controller: 'RestaurantIndexCtrl'
}).
otherwise({
templateUrl: '../templates/home.html',
controller: 'HomeCtrl'
})
])
Now, finally, we can go to the URI /#/restaurants
and we should be able to see our list of restaurants. Before we move on let's add a test.
Adding our first test
Add JS test folder:
mkdir spec/javascripts
Write test:
# spec/javascripts/controllers_spec.js.coffee
describe "Restauranteur controllers", ->
beforeEach module("restauranteur")
describe "RestaurantIndexCtrl", ->
it "should set restaurants to an empty array", inject(($controller) ->
scope = {}
ctrl = $controller("RestaurantIndexCtrl",
$scope: scope
)
expect(scope.restaurants.length).toBe 0
)
Add config:
// spec/javascripts/restauranteur.conf.js
module.exports = function(config) {
config.set({
basePath: '../..',
frameworks: ['jasmine'],
autoWatch: true,
preprocessors: {
'**/*.coffee': 'coffee'
},
files: [
'app/assets/javascripts/angular.js',
'app/assets/javascripts/angular-mocks.js',
'app/assets/javascripts/main.js.coffee',
'app/assets/javascripts/angular/controllers/RestaurantIndexCtrl.js.coffee',
'app/assets/javascripts/angular/*',
'spec/javascripts/*_spec.js.coffee'
]
});
};
Install Karma and start the server:
sudo npm install -g karma
sudo npm install -g karma-ng-scenario
karma start spec/javascripts/restauranteur.conf.js
If you go to http://localhost:9876/
, our test will run and be successful. If you'd like to see the test fail, change expect(scope.restaurants.length).toBe 0
toexpect(scope.restaurants.length).toBe 1
and run the test again.
The meaningfulness of this test we just added is obviously questionable, but my intention here is to save you the work of figuring out how to get your Angular code into a test harness. There are certain things, like the CoffeeScript preprocessor andangular-mocks.js
inclusion that are totally not obvious and took me several hours of head-scratching to get right.
Building out the restaurants page
Let's now make a temporary adjustment to our restaurant index template:
* {{restaurant.name}} ({{restaurant.id}})
If you now revisit /#/restaurants
, you'll notice that none of the restaurants have their IDs. Why are they blank?
When you generate scaffolding in Rails 4, it gives you some .jbuilder
files:
$ ls -1 app/views/restaurants/*.jbuilder
app/views/restaurants/index.json.jbuilder
app/views/restaurants/show.json.jbuilder
If you open up app/views/restaurants/index.json.jbuilder
, you'll see this:
# app/views/restaurants/index.json.jbuilder
json.array!(@restaurants) do |restaurant|
json.extract! restaurant, :name
json.url restaurant_url(restaurant, format: :json)
end
As you can see, it's including :name
but not :id
. Let's add it:
# app/views/restaurants/index.json.jbuilder
json.array!(@restaurants) do |restaurant|
json.extract! restaurant, :id, :name
json.url restaurant_url(restaurant, format: :json)
end
If you save the file and refresh /#/restaurants
, you should see the IDs appear.
Now let's change the template back to the way it originally was:
[index](/#)
* {{ restaurant.name }}
You may have noticed at some point that we're pointing these things at something called viewRestaurant()
but we never actually defined anything calledviewRestaurant()
. Let's do that now:
# app/assets/javascripts/angular/controllers/RestaurantIndexCtrl.js.coffee
@restauranteur.controller 'RestaurantIndexCtrl', ['$scope', '$location', '$http', ($scope, $location, $http) ->
$scope.restaurants = []
$http.get('./restaurants.json').success((data) ->
$scope.restaurants = data
)
# Add the following lines
$scope.viewRestaurant = (id) ->
$location.url "/restaurants/#{id}"
]
The convention in Rails is that resource_name/:id
maps to a "show" page, and that's what we'll do here. Let's create a show template, route and controller.
# {{restaurant.name}}
# app/assets/javascripts/main.js.coffee
@restauranteur = angular.module('restauranteur', [])
@restauranteur.config(['$routeProvider', ($routeProvider) ->
$routeProvider.
when('/restaurants', {
templateUrl: '../templates/restaurants/index.html',
controller: 'RestaurantIndexCtrl'
}).
when('/restaurants/:id', {
templateUrl: '../templates/restaurants/show.html',
controller: 'RestaurantShowCtrl'
}).
otherwise({
templateUrl: '../templates/home.html',
controller: 'HomeCtrl'
})
])
# app/assets/javascripts/angular/controllers/RestaurantShowCtrl.js.coffee
@restauranteur.controller 'RestaurantShowCtrl', ['$scope', '$http', '$routeParams', ($scope, $http, $routeParams) ->
$http.get("./restaurants/#{$routeParams.id}.json").success((data) ->
$scope.restaurant = data
)
]
Now if you refresh /#/restaurants
and click on a restaurant, you should find yourself at that restaurant's show page. Yay!
That's all for now
We may not have seen particularly impressive results, but I hope I've saved you some time plugging AngularJS into Rails 4. Next I might recommend looking intongResource, which can help make CRUD functionality more DRY.
Interested in learning more?
Check out the great post by Adam Anderson, whose Bootstrapping an AngularJS app in Rails 4.0 series helped me get started with AngularJS and Rails. You might like to go through his tutorial as well, but this tutorial is different in the sense that I try to _really_spoon-feed you all the details, minimizing the chances you'll get stuck in the weeds.