AngularJS with Ruby on Rails

Running Through Remaining Features

In the last section, we worked “inside out”, starting with RecipeController and integrating everything at the end, driven by a browser-based test.

This time, let's work “outside in” by starting with a browser test that will mimic user behavior. We'll make a test that creates a recipe, edits it, and then deletes it, so we can test everything at once.

require 'spec_helper.rb'

feature "Creating, editing, and deleting a recipe", js: true do
  scenario "CRUD a recipe" do
    visit '/'
    click_on "New Recipe…"

    fill_in "name", with: "Baked Brussel Sprouts"
    fill_in "instructions", with: "Slather in oil, then bake for 20 minutes"

    click_on "Save"

    expect(page).to have_content("Baked Brussel Sprouts")
    expect(page).to have_content("Slather in oil")

    click_on "Edit"

    fill_in "name", with: "Roasted Brussel Sprouts"
    fill_in "instructions", with: "Slather in oil, then roast for 20 minutes"

    click_on "Save"

    expect(page).to have_content("Roasted Brussel Sprouts")
    expect(page).to have_content("then roast for 20 minutes")

    visit "/"
    fill_in "keywords", with: "Roasted"
    click_on "Search"

    click_on "Roasted Brussel Sprouts"

    click_on "Delete"

    expect(Recipe.find_by_name("Roasted Brussel Sprouts")).to be_nil

  end
end

spec/features/edit_spec.rb

The test, of course, fails:

> rspec spec/features/edit_spec.rb
F

Failures:

  1) Creating, editing, and deleting a recipe CRUD a recipe
     Failure/Error: click_on "New Recipe…"
     Capybara::ElementNotFound:
       Unable to find link or button "New Recipe…"
     # ./spec/features/edit_spec.rb:6:in `block (2 levels) in <top (required)>'

Finished in 5.37 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/features/edit_spec.rb:4 # Creating, editing, and deleting a recipe CRUD a recipe

Randomized with seed 2416

To make it work, we'll need to:

Rails controller

First, we'll add some tests for our controller to get started:

diff --git a/spec/controllers/recipes_controller_spec.rb b/spec/controllers/recipes_controller_spec.rb
index 20e3ad0..a3394b5 100644
--- a/spec/controllers/recipes_controller_spec.rb
+++ b/spec/controllers/recipes_controller_spec.rb
@@ -68,4 +68,41 @@ describe RecipesController do
       it { expect(response.status).to eq(404) }
     end
   end
+
+  describe "create" do
+    before do
+      xhr :post, :create, format: :json, recipe: { name: "Toast", 
+                                           instructions: "Add bread to toaster, push lever" }
+    end
+    it { expect(response.status).to eq(201) }
+    it { expect(Recipe.last.name).to eq("Toast") }
+    it { expect(Recipe.last.instructions).to eq("Add bread to toaster, push lever") }
+  end
+
+  describe "update" do
+    let(:recipe) { 
+      Recipe.create!(name: 'Baked Potato w/ Cheese', 
+                     instructions: "Nuke for 20 minutes; top with cheese") 
+    }
+    before do
+      xhr :put, :update, format: :json, id: recipe.id, recipe: { name: "Toast", 
+                                                 instructions: "Add bread to toaster, push lever" }
+      recipe.reload
+    end
+    it { expect(response.status).to eq(204) }
+    it { expect(recipe.name).to eq("Toast") }
+    it { expect(recipe.instructions).to eq("Add bread to toaster, push lever") }
+  end
+
+  describe "destroy" do
+    let(:recipe_id) { 
+      Recipe.create!(name: 'Baked Potato w/ Cheese', 
+                     instructions: "Nuke for 20 minutes; top with cheese").id
+    }
+    before do
+      xhr :delete, :destroy, format: :json, id: recipe_id
+    end
+    it { expect(response.status).to eq(204) }
+    it { expect(Recipe.find_by_id(recipe_id)).to be_nil }
+  end
 end

spec/controllers/recipes_controller_spec.rb

This is nothing but the happy path, mostly to speed us along here.

Now, let's make these tests pass:

diff --git a/app/controllers/recipes_controller.rb b/app/controllers/recipes_controller.rb
index 574f98d..a0bdda0 100644
--- a/app/controllers/recipes_controller.rb
+++ b/app/controllers/recipes_controller.rb
@@ -1,4 +1,6 @@
 class RecipesController < ApplicationController
+  skip_before_filter :verify_authenticity_token
+
   def index
     @recipes = if params[:keywords]
                  Recipe.where('name ilike ?',"%#{params[:keywords]}%")
@@ -10,4 +12,22 @@ class RecipesController < ApplicationController
   def show
     @recipe = Recipe.find(params[:id])
   end
+
+  def create
+    @recipe = Recipe.new(params.require(:recipe).permit(:name,:instructions))
+    @recipe.save
+    render 'show', status: 201
+  end
+
+  def update
+    recipe = Recipe.find(params[:id])
+    recipe.update_attributes(params.require(:recipe).permit(:name,:instructions))
+    head :no_content
+  end
+
+  def destroy
+    recipe = Recipe.find(params[:id])
+    recipe.destroy
+    head :no_content
+  end
 end

app/controllers/recipes_controller.rb

You'll notice that we are skipping the authenticity token verification. While there is a way to insert the one that Rails generates into all HTTP requests, this is a one-time use token. If we make more than one HTTP POST to our app we'll need a new token each time. There currently isn't a good way to generate one, so we have to skip the check for this.

Don't forget to add the routes for the new actions:

diff --git a/config/routes.rb b/config/routes.rb
index 612e169..eeb8bd2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,5 +1,5 @@
 Receta::Application.routes.draw do
   root 'home#index'
 
-  resources :recipes, only: [:index, :show]
+  resources :recipes, only: [:index, :show, :create, :update, :destroy]
 end

config/routes.rb

Back to our test, everything seems to be working:

> rspec spec/controllers/recipes_controller_spec.rb
..................

Finished in 0.65982 seconds
18 examples, 0 failures

Randomized with seed 54619

With the back-end working, let's turn our attention to the front end, which will call this code.

Angular controllers

We'll have RecipeController handle these operations. It has the Recipe resource we created using Angular's $resource service, so it should be pretty straightforward to add new functions.

First, we'll create a test for creating, saving, and deleting. One wrinkle in our design is that for the case of creating a new recipe, there won't be an id in the URL, so we don't want to fetch anything from the backend. At this point, it just means we need to setup the controller so that there's no recipeId in the URL, and no expectation of doing a GET to the backend.

Once we've changed setupController() accordingly, all we need to do is setup expectations of the HTTP calls to our backend, and then call the functions on scope that we'll need.

diff --git a/spec/javascripts/controllers/RecipeController_spec.coffee b/spec/javascripts/controllers/RecipeController_spec.coffee
index 3455f0d..28e6707 100644
--- a/spec/javascripts/controllers/RecipeController_spec.coffee
+++ b/spec/javascripts/controllers/RecipeController_spec.coffee
@@ -4,6 +4,7 @@ describe "RecipeController", ->
   routeParams  = null
   httpBackend  = null
   flash        = null
+  location     = null
   recipeId     = 42
 
   fakeRecipe   =
@@ -11,22 +12,23 @@ describe "RecipeController", ->
     name: "Baked Potatoes"
     instructions: "Pierce potato with fork, nuke for 20 minutes"
 
-  setupController =(recipeExists=true)->
+  setupController =(recipeExists=true,recipeId=42)->
     inject(($location, $routeParams, $rootScope, $httpBackend, $controller, _flash_)->
       scope       = $rootScope.$new()
       location    = $location
       httpBackend = $httpBackend
       routeParams = $routeParams
-      routeParams.recipeId = recipeId
+      routeParams.recipeId = recipeId if recipeId
       flash = _flash_
 
-      request = new RegExp("\/recipes/#{recipeId}")
-      results = if recipeExists
-        [200,fakeRecipe]
-      else
-        [404]
+      if recipeId
+        request = new RegExp("\/recipes/#{recipeId}")
+        results = if recipeExists
+          [200,fakeRecipe]
+        else
+          [404]
 
-      httpBackend.expectGET(request).respond(results[0],results[1])
+        httpBackend.expectGET(request).respond(results[0],results[1])
 
       ctrl        = $controller('RecipeController',
                                 $scope: scope)
@@ -50,3 +52,51 @@ describe "RecipeController", ->
         httpBackend.flush()
         expect(scope.recipe).toBe(null)
         expect(flash.error).toBe("There is no recipe with ID #{recipeId}")
+
+  describe 'create', ->
+    newRecipe =
+      id: 42
+      name: 'Toast'
+      instructions: 'put in toaster, push lever, add butter'
+
+    beforeEach ->
+      setupController(false,false)
+      request = new RegExp("\/recipes")
+      httpBackend.expectPOST(request).respond(201,newRecipe)
+
+    it 'posts to the backend', ->
+      scope.recipe.name         = newRecipe.name
+      scope.recipe.instructions = newRecipe.instructions
+      scope.save()
+      httpBackend.flush()
+      expect(location.path()).toBe("/recipes/#{newRecipe.id}")
+
+  describe 'update', ->
+    updatedRecipe =
+      name: 'Toast'
+      instructions: 'put in toaster, push lever, add butter'
+
+    beforeEach ->
+      setupController()
+      httpBackend.flush()
+      request = new RegExp("\/recipes")
+      httpBackend.expectPUT(request).respond(204)
+
+    it 'posts to the backend', ->
+      scope.recipe.name         = updatedRecipe.name
+      scope.recipe.instructions = updatedRecipe.instructions
+      scope.save()
+      httpBackend.flush()
+      expect(location.path()).toBe("/recipes/#{scope.recipe.id}")
+
+  describe 'delete' ,->
+    beforeEach ->
+      setupController()
+      httpBackend.flush()
+      request = new RegExp("\/recipes/#{scope.recipe.id}")
+      httpBackend.expectDELETE(request).respond(204)
+
+    it 'posts to the backend', ->
+      scope.delete()
+      httpBackend.flush()
+      expect(location.path()).toBe("/")

spec/javascripts/controllers/RecipeController_spec.coffee

You'll notice that save() is handling both creating a new recipe and updating an existing one. This will make it easier to re-use our view for both purposes, since the “Save” button we'll create can just call save(), allowing the controller to work out just how to talk to the back-end.

To make this test pass, we'll need to:

Let's do it:

controllers = angular.module('controllers')
controllers.controller("RecipeController", [ '$scope', '$routeParams', '$resource', '$location', 'flash',
  ($scope,$routeParams,$resource,$location, flash)->
    Recipe = $resource('/recipes/:recipeId', { recipeId: "@id", format: 'json' },
      {
        'save':   {method:'PUT'},
        'create': {method:'POST'}
      }
    )

    if $routeParams.recipeId
      Recipe.get({recipeId: $routeParams.recipeId},
        ( (recipe)-> $scope.recipe = recipe ),
        ( (httpResponse)->
          $scope.recipe = null
          flash.error   = "There is no recipe with ID #{$routeParams.recipeId}"
        )
      )
    else
      $scope.recipe = {}

    $scope.back   = -> $location.path("/")
    $scope.edit   = -> $location.path("/recipes/#{$scope.recipe.id}/edit")
    $scope.cancel = ->
      if $scope.recipe.id
        $location.path("/recipes/#{$scope.recipe.id}")
      else
        $location.path("/")

    $scope.save = ->
      onError = (_httpResponse)-> flash.error = "Something went wrong"
      if $scope.recipe.id
        $scope.recipe.$save(
          ( ()-> $location.path("/recipes/#{$scope.recipe.id}") ),
          onError)
      else
        Recipe.create($scope.recipe,
          ( (newRecipe)-> $location.path("/recipes/#{newRecipe.id}") ),
          onError
        )

    $scope.delete = ->
      $scope.recipe.$delete()
      $scope.back()


])

app/assets/javascripts/controllers/RecipeController.coffee

You'll notice we made a create() method when setting up our Recipe resource. By default, Angular allows both recipe.$save() and Recipe.save(recipe), the latter being what we'd use to create a new recipe. Since Rails wants a POST for create and a PUT for update, we have to change the defaults.

You'll also notice that we added a few functions for navigation. edit() takes the user to the edit form, and cancel() takes the user back to wherever makes sense based on the current operation.

This implementation also shows a deviation from what we get with Rails. With Angular, there's no built-in way to route our code based on the which “CRUD” operation is being performed. We have to examine the inputs and figure it out for ourselves.

Now, let's see if our tests pass:

> rake teaspoon
Starting the Teaspoon server...
Teaspoon running default suite at http://127.0.0.1:58544/teaspoon/default
........

Finished in 0.03300 seconds
8 examples, 0 failures

So far, so good. The only thing left is to create and wire up the views.

Views

As you could see from our test, we're going to create two new routes: /recipes/new and /recipes/:recipeId/edit, both handled by RecipeController and a new view, form.html.

First, let's set up the routes in app.coffee:

diff --git a/app/assets/javascripts/app.coffee b/app/assets/javascripts/app.coffee
index 00b5df2..00eb6bb 100644
--- a/app/assets/javascripts/app.coffee
+++ b/app/assets/javascripts/app.coffee
@@ -19,9 +19,15 @@ receta.config([ '$routeProvider', 'flashProvider',
       .when('/',
         templateUrl: "index.html"
         controller: 'RecipesController'
+      ).when('/recipes/new',
+        templateUrl: "form.html"
+        controller: 'RecipeController'
       ).when('/recipes/:recipeId',
         templateUrl: "show.html"
         controller: 'RecipeController'
+      ).when('/recipes/:recipeId/edit',
+        templateUrl: "form.html"
+        controller: 'RecipeController'
       )
 ])
 

app/assets/javascripts/app.coffee

Next, we'll create form.html:

<aside class="flash row">
  <article flash-alert duration="0" active-class="alert" class="col-md-6 col-md-offset-3">
    {{flash.message}}
  </article>
</aside>
<form class="form-horizontal" role="form" class="edit-user">
  <div class="form-group" ng-class="{'has-warning has-feedback':errors.name}">
    <label 
      for="name" 
      class="col-md-2 control-label col-md-offset-2">
      Name
    </label>
    <div class="col-md-5">
      <input type="text" name="name" class="form-control" placeholder="e.g. Baked Alaska" ng-model="recipe.name">
    </div>
  </div>
  <div class="form-group" ng-class="{'has-warning has-feedback':errors.name}">
    <label 
      for="instructions" 
      class="col-md-2 control-label col-md-offset-2">
      Instructions
    </label>
    <div class="col-md-5">
      <textarea name="instructions" class="form-control" ng-model='recipe.instructions' placeholder="e.g. Flambé for 20 seconds">
      </textarea>
    </div>
  </div>
  <div class="form-group">
    <div class="col-md-offset-2 col-md-3">
      <button class="btn btn-default" ng-click="cancel()"> Cancel</button>
    </div>
    <div class="col-md-4 text-right">
      <button class="btn btn-primary" ng-click="save()"> Save </button>
    </div>
  </div>
</section>

app/assets/javascripts/templates/form.html

Now, we'll need to add a link to create a recipe as well as one to edit in index.html:

diff --git a/app/assets/javascripts/templates/index.html b/app/assets/javascripts/templates/index.html
index d830331..36ec3a8 100644
--- a/app/assets/javascripts/templates/index.html
+++ b/app/assets/javascripts/templates/index.html
@@ -9,6 +9,7 @@
     </div>
     <div class="form-group col-md-6 col-md-offset-3 text-center">
       <button ng-click="search(keywords)" class="btn btn-primary btn-lg">Search</button>
+      <button ng-click="newRecipe()" class="btn btn-info btn-lg">New Recipe…</button>
     </div>
   </form>
 </section>
@@ -20,8 +21,7 @@
       <section class="well col-md-6 col-md-offset-3">
         <h1 class="h3 col-md-6 text-right" style="margin-top: 0"><a href ng-click="view(recipe.id)" >{{recipe.name}}</a></h1>
         <div class="col-md-6">
-          <button class="btn btn-info">Edit</button>
-          <button class="btn btn-danger">Delete</button>
+          <button ng-click="edit(recipe.id)" class="btn btn-info">Edit</button>
         </div>
       </section>
     </li>

app/assets/javascripts/templates/index.html

These links require new functions called newRecipe() and edit(), which we'll add to RecipesController.coffee:

diff --git a/app/assets/javascripts/controllers/RecipesController.coffee b/app/assets/javascripts/controllers/RecipesController.coffee
index bfff8fd..6ab7a33 100644
--- a/app/assets/javascripts/controllers/RecipesController.coffee
+++ b/app/assets/javascripts/controllers/RecipesController.coffee
@@ -10,4 +10,7 @@ controllers.controller("RecipesController", [ '$scope', '$routeParams', '$locati
       $scope.recipes = []
 
     $scope.view = (recipeId)-> $location.path("/recipes/#{recipeId}")
+
+    $scope.newRecipe = -> $location.path("/recipes/new")
+    $scope.edit      = (recipeId)-> $location.path("/recipes/#{recipeId}/edit")
 ])

app/assets/javascripts/controllers/RecipesController.coffee

And lastly, we'll need links to edit and delete a recipe in show.html:

diff --git a/app/assets/javascripts/templates/show.html b/app/assets/javascripts/templates/show.html
index df041fb..9522b52 100644
--- a/app/assets/javascripts/templates/show.html
+++ b/app/assets/javascripts/templates/show.html
@@ -10,7 +10,15 @@
       {{recipe.instructions}}
     </p>
   </article>
-  <button ng-click="back()" class="btn btn-default">
-    &larr; Back
-  </button>
+  <section>
+    <div class="col-md-3">
+      <button ng-click="back()" class="btn btn-default">
+        &larr; Back
+      </button>
+    </div>
+    <div class="col-md-9 text-right">
+      <button ng-click="edit()" class="btn btn-info"> Edit </button>
+      <button ng-click="delete()" class="btn btn-danger"> Delete </button>
+    </div>
+  </section>
 </section>

app/assets/javascripts/templates/show.html

Whew! Let's see if it all works by re-running our browser-based test:

> rspec spec/features/edit_spec.rb
.

Finished in 6.62 seconds
1 example, 0 failures

Randomized with seed 37644

Voila! It works!

Wrapping Up

We went quite quickly through this part of the app, mostly to just see what an entire “CRUD” app would look like as well as some differences between what Rails gives us and what Angular doesn't.

It may seem like we've written a lot of extra code and tests to do something that would be far simpler in Rails. In a sense, this is true, but what Angular lacks in creating CRUD applications, it more than makes up for when creating a richer user experience.

The last section is going to be some reflection on what we've just learned and where we could go from here.