Rails generator to generate angular views

It would be great if the rails generator could generate the angular views for us, this post walks through tailoring the generators to give that outcome.  The format for the angular content I’m going to generate matches the setup in my various AngularJS and rails tutorials, and in particular the angular components created match those of the ng-boilerplate project.

We’ll assume that we want to create an angular view that offers a list and a modal form, with CRUD functionality and a unit test that hits the highlights.  We’re not going to try to update the app.js file or index.html, that’ll have to be done manually. We’re going to follow the edgeguide on generators for guidance.

We’ll start by changing the generator config to suppress the views (erb), the stylesheets, the assets and the javascripts.  Insert the following lines into config/application.rb, inside your existing config.generators block. If it’s not there, you can create it:

    config.generators do |g|
      # don't create views, assets or stylesheets 
      g.template_engine false
      g.assets false
      g.stylesheets false
    end

Next, we want to create a new “angular” generator, we want to be able to embed this into the scaffold generator (we want it in the rails namespace). We can create this using the generator generator:

  rails generate generator rails/angular

This will create a blank generator for us in lib/generators/angular/angular_generator.rb. Open that up, and insert some content:

class Rails::AngularGenerator < Rails::Generators::NamedBase
  source_root File.expand_path('../../../templates/rails/angular', __FILE__)

  def create_module_folder
    empty_directory "src/app/#{name}"
  end  

  def create_core_javascript
    template "module.js", "src/app/#{name}/#{name}.js"
  end

  def create_karma_test_spec
    template "module_spec.js", "src/app/#{name}/#{name}_spec.js"
  end

  def create_module_page_template
    template "module.tpl.html", "src/app/#{name}/#{name}.tpl.html"
  end

  def create_module_edit_template
    template "module_edit.tpl.html", "src/app/#{name}/#{name}_edit.tpl.html"
  end  
end

The methods in this class are run in sequential order, the first creates an empty directory for this module, the remainder create the 4 files that our angular module needs, using the templates we’re about to create.

Create a directory lib/templates/rails/angular/ into which we’ll put our templates. Create templates module.js, module_spec.js, module_tpl.html, module_edit.tpl.html. The content for these is at the bottom of this post (I’ll avoid cluttering here). I’ve created these by copying the associated files from the club object, most of the work was to global replace:

  Club -> 
  clubs -> 
  club ->

We also iterated over the field list to create the base grid configuration, and to create the edit page template. Realistically this will need editing anyway but it’s at least a starting point that saves a bunch of typing.

We now want to add this generator into the scaffold generator. Edit again our config/application.rb:

    config.generators do |g|
      # don't create views, assets or stylesheets 
      g.template_engine :angular
      g.assets false
      g.stylesheets false
    end

You should now be happily generating angular code for your ng-boilerplate app using the rails generator.

————————————————————–
module.js:

/**
 * <%= class_name %> module
 */
angular.module( 'bProject.<%= singular_table_name %>', [
  'ui.state',
  'ngResource',
  'ngGrid'
])

/**
 * Define the route that this module relates to, and the page template and controller that is tied to that route
 */
.config(function config( $stateProvider ) {
  $stateProvider.state( '<%= singular_table_name %>', {
    url: '/<%= singular_table_name %>',
    views: {
      "main": {
        controller: '<%= class_name %>Ctrl',
        templateUrl: '<%= singular_table_name %>/<%= singular_table_name %>.tpl.html'
      }
    },
    data:{ pageTitle: '<%= class_name %>' }
  });
})

/**
 * And of course we define a controller for our route.
 */
.controller( '<%= class_name %>Ctrl', function <%= class_name %>Controller( $scope, <%= class_name %>Res, $dialog, $location ) {
  $scope.<%= plural_table_name %> = <%= class_name %>Res.query();

  $scope.gridOptions = {
    data: '<%= plural_table_name %>',
    columnDefs: [
<% attributes.each do |attribute| -%>
<%   case attribute.name       when 'lock_version'      # do nothing - not displayed       else -%>
      {field: '<%= attribute.name %>', displayName: '<%= attribute.human_name %>'},
<%   end -%>
<% end -%>
      {displayName: 'Show Teams', cellTemplate: 'Show Teams '},
      {displayName: 'Edit', cellTemplate: '(row.entity)" >Edit '},
      {displayName: 'Delete', cellTemplate: '(row.entity)" >Delete '}
    ],
    multiSelect: false
  };

  $scope.edit<%= class_name %> = function(<%= singular_table_name %>) {
    $scope.myDialog = $dialog.dialog({dialogFade: false, resolve: {<%= singular_table_name %>: function(){return angular.copy(<%= singular_table_name %>);}, isNew: function() {return false;}}});
    $scope.myDialog.open('<%= singular_table_name %>/<%= singular_table_name %>_edit.tpl.html', '<%= class_name %>EditCtrl').then(function(result){
      if (result === 'cancel'){}
      else {
        $scope.<%= plural_table_name %> = <%= class_name %>Res.query();
      }
    });  
  };

  $scope.new<%= class_name %> = function() {
    $scope.myDialog = $dialog.dialog({dialogFade: false, resolve: {<%= singular_table_name %>: function(){return new <%= class_name %>Res(); }, isNew: function() {return true;}}});
    $scope.myDialog.open('<%= singular_table_name %>/<%= singular_table_name %>_edit.tpl.html', '<%= class_name %>EditCtrl').then(function(result){
      if (result === 'cancel'){}
      else {
        $scope.<%= plural_table_name %> = <%= class_name %>Res.query();
      }
    });  
  };

  $scope.delete<%= class_name %> = function(<%= singular_table_name %>) {
    <%= singular_table_name %>.$remove (function() {
                      $scope.<%= plural_table_name %> = <%= class_name %>Res.query();
                    }, 
                  function(error) {
                    $scope.msgbox = $dialog.messageBox('Error', error, [{label: 'OK'}]);
                    $scope.msgbox.open();
                  });
  };  

  $scope.showTeams = function(<%= singular_table_name %>) {
    $location.path("/team").search({<%= singular_table_name %>_id: <%= singular_table_name %>.id});
  };  
})

/**
 * We define a controller for the edit action
 */
.controller('<%= class_name %>EditCtrl', function <%= class_name %>EditController($scope, <%= class_name %>Res, dialog, <%= singular_table_name %>, isNew) {
  $scope.<%= singular_table_name %> = <%= singular_table_name %>;
  $scope.submit = function() {
    if (isNew) {
      $scope.<%= singular_table_name %>.$save(function(data) {
                              dialog.close($scope.<%= singular_table_name %>);      
                            }, 
                          function(error) {
                              // don't close dialog, display an error
                              $scope.error = error;                              
                            });
    }
    else {
      $scope.<%= singular_table_name %>.$update(function(data) {
                              dialog.close($scope.<%= singular_table_name %>);      
                            }, 
                            function(error) {
                              // don't close dialog, display an error
                              $scope.error = error;  
                            });
    }
  };

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

/**
 * Add a resource to allow us to get at the server
 */
.factory( '<%= class_name %>Res', function ( $resource )  {
  return $resource("../<%= plural_table_name %>/:id.json", {id:'@id'}, {'update': {method:'PUT'}, 'remove': {method: 'DELETE', headers: {'Content-Type': 'application/json'}}});
})
;

module_spec.js

/**
 * Unit tests for the <%= singular_table_name %> functionality
 */
describe( '<%= class_name %> functionality.', function() {
  // mock Application to allow us to inject our own dependencies
  beforeEach(angular.mock.module('league'));

  // create the custom mocks on the root scope
  beforeEach(angular.mock.inject(function($rootScope, _$httpBackend_){
    //create an empty scope
    scope = $rootScope.$new();

    // we're just declaring the httpBackend here, we're not setting up expectations or when's - they change on each test
    scope.httpBackend = _$httpBackend_;

    // 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);
      }
    };

    // setup a mock for the <%= singular_table_name %> entity - it handles both $update and $remove methods, and calls the provided callback immediately (no promise needed)
    scope.fake<%= class_name %> = {
      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);
        }
      }
    };   
  }));

  afterEach(function() {
    scope.$digest();
    scope.httpBackend.verifyNoOutstandingExpectation();
    scope.httpBackend.verifyNoOutstandingRequest();
  });  

  describe( 'Base <%= singular_table_name %> controller.', function() {  

    beforeEach(angular.mock.inject(function($controller){
      //declare the controller and inject our scope
      $controller('<%= class_name %>Ctrl', {$scope: scope, $dialog: scope.fakeDialog});
    }));

    describe( 'Initial render:', function() {
      beforeEach(function() {
         // setup a mock for the resource - instead of calling the server always return a pre-canned response
        scope.httpBackend.expect('GET', '../<%= plural_table_name %>.json').respond([
          {"contact_officer":"Contact Officer 1","created_at":"2012-02-02T00:00:00Z","date_created":"2012-01-01T00:00:00Z","id":1,"name":"<%= class_name %> 1","updated_at":"2012-03-03T00:00:00Z"},
          {"contact_officer":"Contact Officer 2","created_at":"2012-02-02T00:00:00Z","date_created":"2012-01-01T00:00:00Z","id":2,"name":"<%= class_name %> 2","updated_at":"2012-03-03T00:00:00Z"}]);
        scope.$digest();
        scope.httpBackend.flush();
      });

      // tests start here
      it('Has two <%= plural_table_name %> defined', function(){
        expect(scope.<%= plural_table_name %>.length).toEqual(2);
      });

      it('First <%= singular_table_name %>\'s contact officer is as expected', function(){
        expect(scope.<%= plural_table_name %>[0].contact_officer).toEqual('Contact Officer 1');
      });
    });

    describe('Other controller methods:', function(){

      beforeEach(function() {
        // The initial render triggers a get before we get to the method that we're testing.
        // Drain that before we start the test proper
        scope.httpBackend.expectGET('../<%= plural_table_name %>.json').respond([]);
        scope.$digest();
        scope.httpBackend.flush();
      });

      it('Calls edit on first row, 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 edit
        scope.edit<%= class_name %>(scope.<%= plural_table_name %>[0]);

        // check parameters passed in
        // haven't worked out how to verify the parameters of dialog, as they're passed as a promise (i.e. as a function), and I can't resolve them'
        expect(scope.fakeDialog.dialog).toHaveBeenCalled();
        expect(scope.fakeDialog.open).toHaveBeenCalledWith('<%= singular_table_name %>/<%= singular_table_name %>_edit.tpl.html', '<%= class_name %>EditCtrl');
      });

      it('Calls edit on first row, 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 edit
        scope.edit<%= class_name %>(scope.<%= plural_table_name %>[0]);

        // check parameters passed in
        // haven't worked out how to verify the parameters of dialog, as they're passed as a promise (i.e. as a function), and I can't resolve them'
        expect(scope.fakeDialog.dialog).toHaveBeenCalled();
        expect(scope.fakeDialog.open).toHaveBeenCalledWith('<%= singular_table_name %>/<%= singular_table_name %>_edit.tpl.html', '<%= class_name %>EditCtrl');

        // expect a get after the successful save 
        scope.httpBackend.expect('GET', '../<%= plural_table_name %>.json').respond([]);
        scope.$digest();        
        scope.httpBackend.flush();
      });

      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.new<%= class_name %>();

        // check parameters passed in
        // haven't worked out how to verify the parameters of dialog, as they're passed as a promise (i.e. as a function), and I can't resolve them'
        expect(scope.fakeDialog.dialog).toHaveBeenCalled();
        expect(scope.fakeDialog.open).toHaveBeenCalledWith('<%= singular_table_name %>/<%= singular_table_name %>_edit.tpl.html', '<%= class_name %>EditCtrl');
      });

      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.new<%= class_name %>();

        // check parameters passed in
        // haven't worked out how to verify the parameters of dialog, as they're passed as a promise (i.e. as a function), and I can't resolve them'
        expect(scope.fakeDialog.dialog).toHaveBeenCalled();
        expect(scope.fakeDialog.open).toHaveBeenCalledWith('<%= singular_table_name %>/<%= singular_table_name %>_edit.tpl.html', '<%= class_name %>EditCtrl');

        // expect a query refresh
        scope.httpBackend.expect('GET', '../<%= plural_table_name %>.json').respond([]);
        scope.$digest();
        scope.httpBackend.flush();        
      });

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

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

        // call the delete
        scope.delete<%= class_name %>(scope.fake<%= class_name %>);

        // expect stuff to have happened
        expect(scope.fake<%= class_name %>.$remove).toHaveBeenCalled();
        expect(scope.fakeDialog.messageBox).not.toHaveBeenCalled();

        // expect a refresh on the query
        scope.httpBackend.expect('GET', '../<%= plural_table_name %>.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 fake<%= class_name %>, so we spy on the fake<%= class_name %> methods
        spyOn(scope.fake<%= class_name %>, "$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.fake<%= class_name %>.precannedResponse = 'not-success';

        // call the delete
        scope.delete<%= class_name %>(scope.fake<%= class_name %>);

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

  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('<%= class_name %>EditCtrl', {$scope: scope, dialog: scope.fakeDialog, <%= singular_table_name %>: scope.fake<%= class_name %>, isNew: scope.isNew});
    }));

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

      scope.submit();

      expect(scope.fake<%= class_name %>.$update).toHaveBeenCalled();
      expect(scope.fakeDialog.close).toHaveBeenCalled();
    }); 

    it('Submit calls put on server, put fails', function(){
      scope.fake<%= class_name %>.precannedResponse = 'fail';

      // we expect $update to be called on fake<%= class_name %>, and close not to be called on fakeDialog
      spyOn(scope.fake<%= class_name %>, "$update").andCallThrough();
      spyOn(scope.fakeDialog, "close").andCallThrough();

      scope.submit();

      expect(scope.fake<%= class_name %>.$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 fake<%= class_name %>, and close to be called on fakeDialog
      spyOn(scope.fake<%= class_name %>, "$update").andCallThrough();
      spyOn(scope.fakeDialog, "close").andCallThrough();

      scope.cancel();

      expect(scope.fake<%= class_name %>.$update).not.toHaveBeenCalled();
      expect(scope.fakeDialog.close).toHaveBeenCalledWith('cancel');
    }); 
  });

});

module.tpl.html

<div>
  <h1><%= class_name %> functions</h1>
  <p>Soon this will show a list of all the <%= plural_table_name %>, based on information from the server</p>
</div>

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

module_edit.tpl.html

<div class="modal-header">
  <h3>Edit <%= class_name %></h3>
</div>

<div class="modal-body">
  <div ng-show="error" class="error">
    <p>Error: {{error.data}}</p>
  </div>
<% attributes.each do |attribute| -%>
<% case attribute.name
   when 'lock_version'
   # do nothing - not displayed
   else -%>

  <div ng-class="{error: login_error.errors.<%= attribute.name %>}"><%= attribute.human_name %>: <input ng-model="login_user.<%= attribute.name %>" />
    <div ng-show="login_error.errors.<%= attribute.name %>">
      <div ng-repeat="field_error in error.errors.<%= attribute.name %>">{{field_error}}</div>
    </div>
  </div>
<% end -%>
<% end -%>
</div>

<div class="modal-footer">
  <button ng-click="submit()" class="btn btn-primary" >Save</button>
  <button ng-click="cancel()" class="btn btn-primary" >Cancel</button>
</div>
Advertisements

One thought on “Rails generator to generate angular views

  1. Pingback: AngularJS and Rails CRUD application using ng-boilerplate and twitter bootstrap: Tutorial Index | technpol

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