AngularJS and Rails Tutorial: part 5 New and Delete, initial $resource error handling

Part 5 of a tutorial that connects AngularJS to a Rails backend. This post focuses on updating our modal form to support the “new” function, adding a delete button to our ngGrid, and adding error handling in case our rails application rejects our updates.  The previous post was Using ngGrid and building an edit popup, the next post is creating teams, and a relationship between team and club.  You can also find the index of the posts in the tutorial, or hit the tutorials menu at the top and select the Rails 3 tutorial.

There is a newer, rails 4 and newer angularJS, version of this tutorial.  It is also more complete and has a nicer UI that doesn’t use modal windows, which is probably a better choice for anyone starting fresh today.  The first page in that tutorial is here, and the index here. If you haven’t completed the earlier tutorials, you can get the code at github: tutorial_4.5. At the end of this post we’ll have extra buttons on our list page for new and delete, and we’ll have (ugly) error handling: Tutorial_5 edit error handling

We want to reuse our modal dialog and use the associated controller to provide the new function.  This means adding some if statements into the controller so that it knows whether we’re in new or edit mode, and creating a button on the bottom of the list to access the new function.  We also need to call the save method on the resource rather than update.

Start with the changes to the controller, let’s add a new function:

  $scope.newClub = function() {
    $scope.myDialog = $dialog.dialog({dialogFade: false, resolve: {club: function(){return new ClubRes(); }, isNew: function() {return true;}}});
    $scope.myDialog.open('club/club_edit.tpl.html', 'ClubEditCtrl').then(function(result){
      if (result === 'cancel'){}
      else {
        $scope.clubs = ClubRes.query();
      }
    });  
  };

This is very similar to our existing editClub function, the changes are that we are now passing in two parameters to the dialog – an entity (we’re creating a new blank club by calling new ClubRes()), and a flag that tells us whether we’re creating a new one or editing an existing one. Because of the way the resource and the “resolve” function works to pass data in, we have to pass this in as a function. So we pass in a function that returns true, instead of just saying “isNew: true”. Ugly, but that’s Javascript and angular for you.

We then edit the edit controller to accept the isNew flag (don’t miss the new isNew parameter off to the right of the controller definition):

/**
 * We define a controller for the edit action
 */
.controller('ClubEditCtrl', function ClubEditController($scope, ClubRes, dialog, club, isNew) {
  $scope.club = club;
  $scope.submit = function() {
    if (isNew) {
      $scope.club.$save(function(data) {
                              dialog.close($scope.club);      
                            });
    }
    else {
      $scope.club.$update(function(data) {
                              dialog.close($scope.club);      
                            });      
    }

  };

  $scope.cancel = function() {
    dialog.close('cancel');
  };
})

This calls save if we’re new, and update if not new. Finally, to keep the edit method working, it needs to pass in the isNew parameter. Change it as follows:

  $scope.editClub = function(club) {
    $scope.myDialog = $dialog.dialog({dialogFade: false, resolve: {club: function(){return angular.copy(club);}, isNew: function() {return false;}}});

Run grunt build, and try the new function, and verify the edit function still works. If you did the unit tests in tutorial 4.5, then you’ll also need to fix those, they’re now broken.  The calling syntax for the edit controller has changed, so update the test appropriately, so edit club_spec.js:

  describe( 'Edit controller', function() {
    //mock the controller
    beforeEach(angular.mock.inject(function($controller){
      // setup a mock for the isNew flag
      scope.isNew = false;

      //declare the controller and inject our parameters
      $controller('ClubEditCtrl', {$scope: scope, dialog: scope.fakeDialog, club: scope.fakeClub, isNew: scope.isNew});
    }));

We’ll also add a test to the base controller for the new method:

      it('Calls new, result cancel', function() {
        scope.fakeDialog.response = 'cancel';

        // we expect the fakeDialog dialog and open methods to be called, so we spy on them to get the parameters
        spyOn(scope.fakeDialog, "dialog").andCallThrough();
        spyOn(scope.fakeDialog, "open").andCallThrough();

        // call new
        scope.newClub();

        // check parameters passed in
        expect(scope.fakeDialog.dialog).toHaveBeenCalledWith({dialogFade: false, resolve: {club: jasmine.any(Function), isNew: jasmine.any(Function)}});
        expect(scope.fakeDialog.isNew).toEqual(true);
        expect(scope.fakeDialog.open).toHaveBeenCalledWith('club/club_edit.tpl.html', 'ClubEditCtrl');
      });

      it('Calls new, result OK', function() {
        scope.fakeDialog.response = 'not-cancel';

        // we expect the fakeDialog dialog and open methods to be called, so we spy on them to get the parameters
        spyOn(scope.fakeDialog, "dialog").andCallThrough();
        spyOn(scope.fakeDialog, "open").andCallThrough();

        // call new
        scope.newClub();

        // check parameters passed in
        expect(scope.fakeDialog.dialog).toHaveBeenCalledWith({dialogFade: false, resolve: {club: jasmine.any(Function), isNew: jasmine.any(Function)}});
        expect(scope.fakeDialog.isNew).toEqual(true);
        expect(scope.fakeDialog.open).toHaveBeenCalledWith('club/club_edit.tpl.html', 'ClubEditCtrl');

        // expect a query refresh
        scope.httpBackend.expect('GET', '../clubs.json').respond([]);
        scope.$digest();
        scope.httpBackend.flush();        
      });

Finally, we need to put a new button on the screen. We put this below the grid on the club.tpl.html:

<div class="body">
  <div class="gridStyle" ng-grid="gridOptions"></div>
  <button ng-click="newClub()" class="btn btn-primary" >New Club</button>
</div>

This adds a button in, and calls the newClub method when clicked. Verify that this works. Next, we’ll add a delete button. The button itself is easy – change the grid configuration in the controller to put in a delete button:

    columnDefs: [
      {field: 'id', displayName: 'Id'},
      {field: 'name', displayName: 'Club Name'},
      {field: 'contact_officer', displayName: 'Contact Officer'},
      {displayName: 'Edit', cellTemplate: '<button id="editBtn" type="button" class="btn btn-primary" ng-click="editClub(row.entity)" >Edit</button> '}
      {displayName: 'Delete', cellTemplate: '<button id="deleteBtn" type="button" class="btn btn-primary" ng-click="deleteClub(row.entity)" >Delete</button> '}
    ],

And put a delete function into the controller:

  $scope.deleteClub = function(club) {
    club.$remove (function() {
                      $scope.clubs = ClubRes.query();
                    });
  };

Angular provides both a delete and a remove method. Apparently the delete method can give problems in some browsers, as delete can be a reserved word. We’re using the remove method to be safe. Finally, the delete/remove has some trickiness on the resource. Rails seems to get confused when we call the delete without doing anything to the resource, and the solution I’ve found so far is to set the headers to tell rails that we’re giving it JSON. So update the resource to look like:

/**
 * And a resource to allow us to get at the server
 */
.factory( 'ClubRes', function ( $resource )  {
  return $resource("../clubs/:id.json", {id:'@id'}, {'update': {method:'PUT'}, 'remove': {method: 'DELETE', headers: {'Content-Type': 'application/json'}}});
})

Save that and grunt build – you should now have a delete. Add the unit test for the delete method:

      it('Calls delete, and requeries', function() {
        // we expect the remove method to be called on fakeClub, so we spy on fakeClub
        spyOn(scope.fakeClub, "$remove").andCallThrough();

        // call the delete
        scope.deleteClub(scope.fakeClub);

        // expect stuff to have happened
        expect(scope.fakeClub.$remove).toHaveBeenCalled();

        // expect a refresh on the query
        scope.httpBackend.expect('GET', '../clubs.json').respond([]);
        scope.$digest();
        scope.httpBackend.flush();        
      });

Finally for this post, we’re going to put some error handling in. Plausibly some of our calls to the rails backend might not work, and instead of just staying on the modal window with no indication of the problem, we’re going to have some error handling. Angular provides error handling as part of the server calls on the resource:

      $scope.club.$save(function(data) {
                              dialog.close($scope.club);      
                            }, 
                          function(error) {
                              // don't close dialog, display an error
                              $scope.error = error;                              
                            });

This is tied into the asynchronous processing (promises). Basically this says “call the save method, if it’s successful call the first function, if it’s not successful call the second function”. In our first function we close the dialog, in our second function we set the error variable. We do the same thing again with the update function:

      $scope.club.$update(function(data) {
                              dialog.close($scope.club);      
                            }, 
                            function(error) {
                              // don't close dialog, display an error
                              $scope.error = error;  
                            });

And we set a block in club_edit.tpl.html that only displays if the “$scope.error” variable has data in it.

<div class="modal-body">
  <div ng-show="error" class="error">
    <p>Error: {{error.data}}</p>
  </div>
  <p>Name: <input ng-model="club.name" /></p>
  <p>Contact Officer: <input ng-model="club.contact_officer" /></p>
</div>

This says “if the error variable has a value, then show the below div”. The div itself just shows the data from the error variable that came back from rails. We want an error that we can use to see this, so let’s modify app/models/club.rb in our rails app to make the name field mandatory:

class Club < ActiveRecord::Base
  attr_accessible :contact_officer, :date_created, :name
  validates :name, presence: true
end

Try creating a new club, or editing a club, and leaving the name blank. You should see an error (albeit a rather ugly one. In tutorial 7 we pretty it up). We need to include the error handling in the unit tests.  What we need is to test updates and saves with error, and without error.  Amend src/app/club/club_spec.js as follows. Firstly, change our fakeClub to deal with success and error callbacks:

    scope.fakeClub = {
      precannedResponse: 'success',
      $save: function(callback_success, callback_fail) {
        if (this.precannedResponse == 'success') {
          callback_success(null);
        } else {
          callback_fail(null);
        }        
      },
      $update: function(callback_success, callback_fail) {
        if (this.precannedResponse == 'success') {
          callback_success(null);
        } else {
          callback_fail(null);
        }
      },
      $remove: function(callback_success, callback_fail) {
        if (this.precannedResponse == 'success') {
          callback_success(null);
        } else {
          callback_fail(null);
        }
      }
    };

Then, add in error and success callbacks to the test for the edit controller:

  describe( 'Edit controller:', function() {
    //mock the controller
    beforeEach(angular.mock.inject(function($controller){
      // setup a mock for the isNew flag
      scope.isNew = false;

      //declare the controller and inject our parameters
      $controller('ClubEditCtrl', {$scope: scope, dialog: scope.fakeDialog, club: scope.fakeClub, isNew: scope.isNew});
    }));

    // tests start here
    it('Submit calls put on server, put succeeds', function(){
      // we expect $update to be called on fakeClub, and close to be called on fakeDialog
      spyOn(scope.fakeClub, "$update").andCallThrough();
      spyOn(scope.fakeDialog, "close").andCallThrough();

      scope.submit();

      expect(scope.fakeClub.$update).toHaveBeenCalled();
      expect(scope.fakeDialog.close).toHaveBeenCalled();
    }); 

    it('Submit calls put on server, put fails', function(){
      scope.fakeClub.precannedResponse = 'fail';

      // we expect $update to be called on fakeClub, and close not to be called on fakeDialog
      spyOn(scope.fakeClub, "$update").andCallThrough();
      spyOn(scope.fakeDialog, "close").andCallThrough();

      scope.submit();

      expect(scope.fakeClub.$update).toHaveBeenCalled();
      expect(scope.fakeDialog.close).not.toHaveBeenCalled();
    }); 

    it('Cancel does not call put on server', function(){
      // we expect $update not to be called on fakeClub, and close to be called on fakeDialog
      spyOn(scope.fakeClub, "$update").andCallThrough();
      spyOn(scope.fakeDialog, "close").andCallThrough();

      scope.cancel();

      expect(scope.fakeClub.$update).not.toHaveBeenCalled();
      expect(scope.fakeDialog.close).toHaveBeenCalledWith('cancel');
    }); 
  });

Finally, add error handling to the delete method as well. We’re using another dialog function, messagebox, to pop up a warning that an error occurred. This goes in src/app/club/club.js:

  $scope.deleteClub = function(club) {
    club.$remove (function() {
                      $scope.clubs = ClubRes.query();
                    }, 
                  function(error) {
                    $scope.msgbox = $dialog.messageBox('Error', error, [{label: 'OK'}]);
                    $scope.msgbox.open();
                  });
  };

You can trigger an error to see the behaviour by opening the club page, then killing your rails server. When you click delete you’ll get an error because your server isn’t there. Again, the layout of the error message isn’t so pretty , so this could do with tidyup over time. And add the delete unit test. First extend the mock for dialog to include a messageBox method:

   // setup a mock for the dialog - when called it returns the value that was input when it was instantiated
    scope.fakeDialog = {
      response: null,
      dialog: function(parameters) {
        return this;
      },
      open: function(template, controller) {
        return this;
      },
      close: function(parameters) {
        return this;
      },
      messageBox: function(title, message, buttons) {
        return this;
      },
      then: function(callBack){
        callBack(this.response);
      }
    };

Then, modify the delete unit tests to verify that when an error is returned a message box is displayed:

      it('Calls delete, no error, and requeries', function() {
        // we expect the remove method to be called on fakeClub, so we spy on fakeClub
        spyOn(scope.fakeClub, "$remove").andCallThrough();

        // we expect a messagebox not to be displayed, so we spy on fakeDialog
        spyOn(scope.fakeDialog, "messageBox").andCallThrough();

        // call the delete
        scope.deleteClub(scope.fakeClub);

        // expect stuff to have happened
        expect(scope.fakeClub.$remove).toHaveBeenCalled();
        expect(scope.fakeDialog.messageBox).not.toHaveBeenCalled();

        // expect a refresh on the query
        scope.httpBackend.expect('GET', '../clubs.json').respond([]);
        scope.$digest();
        scope.httpBackend.flush();        
      });

      it('Calls delete, gets error, and shows error box', function() {
        // we expect the remove method to be called on fakeClub, so we spy on the fakeClub methods
        spyOn(scope.fakeClub, "$remove").andCallThrough();

        // we expect a messagebox not to be displayed, so we spy on fakeDialog
        spyOn(scope.fakeDialog, "messageBox").andCallThrough();

        // set the mock to return an error
        scope.fakeClub.precannedResponse = 'not-success';

        // call the delete
        scope.deleteClub(scope.fakeClub);

        // expect an error mesageBox to have been shown
        expect(scope.fakeClub.$remove).toHaveBeenCalled();
        expect(scope.fakeDialog.messageBox).toHaveBeenCalledWith('Error', null, [{label: 'OK'}]);
      });
    });

You should have error handling that looks like this:

Tutorial_5 edit error handling

The final code for tutorial 5 can be found at github: tutorial_5. OK, that should be enough for today. Next post: do the same thing all over again with the team entity, and building a link between clubs and teams.

Advertisements

13 thoughts on “AngularJS and Rails Tutorial: part 5 New and Delete, initial $resource error handling

  1. unreal paulL, this is the first post ever I read where I did not understand one single sentence and few enough words.
    At first I thought you were building a factory with angular Js and boilers.

  2. @paul scott: clearly that means I’m achieving something!! Seriously, I’ve been watching hits and views since I started the blog 6 months ago. Politics posts get a reasonably small volume of hits for 1 or maybe 2 days after posted. Technology posts drive a continuous stream of traffic – people are still visiting posts I did 4 months ago, and the volume of traffic to them is actually growing over time as people link to them. In short, apparently the more valuable contribution I make to the internet (as measured by people’s behaviour) is to blog on technology!!

  3. Pingback: AngularJS and Rails: Tutorial Index | technpol

  4. Pingback: CRUD application with AngularJS, Rails, twitter-bootstrap and ng-boilerplate: part 4 grid and CRUD | technpol

  5. Pingback: CRUD application with AngularJS, Rails, twitter-bootstrap and ng-boilerplate: part 6 another entity | technpol

  6. Pingback: AngularJS and Rails Tutorial: part 4.5 unit testing bootstrap modals with Karma | technpol

  7. Yes, thank you for making this tutorial!
    Everything is working for me so far 🙂 I’d say this is one of the few rails / angular that are so in depth so that I can really understand how things work.

    Not trying to nitpick, but maybe this could be some advice: I initially passed it up because — even though this is silly — I assumed it was poorly done because of the blog styling. (old wordpress theme, small letters, too many distracting links / nav bars on side). Other angular + rails tutorail blogs are minimal and have that web 2.0 look with bigger words. Maybe it’s because I assumed a rails + angular pro wouldn’t be using wordpress? Not sure. But “don’t judge a book by it’s cover!” they say. Super helpful. I’m recommending this tutorial to my dev friends.

    I’m not sure if you’ve answered this, but what’s the difference with that Yeoman set up that I keep hearing about?

  8. Yeah, I’m not really a UI design guy, I used WordPress because it was free. And I just picked a theme that looked OK. Probably not too hard to pick a different one though, so that’s good advice.

    But I should be clear that I’m a long way from being a Rails or Angular pro – I’ve coded Rails on and off for 3-4 years, but only as a hobby, and Angular I’ve been doing for about 3 months. So in a sense this tutorial is just reflecting my experiences – things go up on the tutorial about 2 days after I do the web research and build them for the first time myself.

    That might be why it’s working well for some people – because I’ve only done much of this for the first time myself quite recently, the scars from obscure syntax, and the “what on earth does that mean” moments aren’t too far away from my memory – so probably I relate better to a beginner!!

    On the Yeoman setup, I haven’t really tried it. I reviewed a few boilerplates/templates, and I quite liked ng-boilerplate. From my reviewing of the Yeoman template it was putting all the controllers in one directory, all the tests in another directory, all the directives in another directory etc. Much more the way rails organises code. I really didn’t like that structure – I like the way ng-boilerplate does it much better. Yeoman provides a generator, similar to the rails generator, I’ve chosen to integrate my Angular generation with the rails generator, so the ability to generate code using Yeoman isn’t a big deal for me.

  9. Yea, that was quick, lol. The theme looks good!
    Have you pushed to Heroku yet? For production? Do we not need to use the $inject because of the minimizing done by rails?

  10. Yeah, the nice thing about WordPress is it’s easy to maintain. I’m still not thrilled with that theme either (and need to find my own photo to put on the top), but turns out my old theme was so old WordPress had deprecated it. So your comment was timely. 🙂

    Productionising I’m a bit worried about still. I’m targeting self-managed AWS servers for the application we’re working on, rather than Heroku, although Heroku is still on my list of things to look at more closely. My application is likely to be quite security and up-time sensitive, and I haven’t done enough research on Heroku to know that it’s a good idea for me. But certainly there are some risks around minification that I haven’t addressed yet (I haven’t even run grunt compile on the code to see what happens). Worst case I have to change the style to use the [] everywhere, though I thought I’d read somewhere that wasn’t necessary anymore for some reason that I can’t recall.

    I’m starting to think about doing a complete reset on the tutorial with Rails 4, and potentially bootstrap 3 once it lands. I’d also like the updated ng-boilerplate with end-to-end testing. As part of that I’d include CanCan as well, and a couple of other extensions. That probably won’t be till Christmas though, since my day job is going to consume a lot of time between now and then.

  11. Pingback: AngularJS and Rails CRUD application using ng-boilerplate and twitter bootstrap | Gatelockservice

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s