In Part 2 of Angular with Rails series, we covered creating a Rails API with tests in RSpec and setting up an AngularJS app that reads from the API.
In Part 3 we will cover:
- Adding CSS
- Adding editing functionality
- Creating a custom filter
- Setting up JavaScript tests
Setup Bootstrap Sass
In order to support editing functionality we’ll add some CSS to the UI. Following the README at bootstrap-sass, we’ll add to our Gemfile:
gem 'bootstrap-sass', '~> 3.3.0'
Add to our app/assets/javascripts/application.js:
//= require bootstrap-sprockets
Rename app/assets/stylesheets/application.css to application.css.sass (if you prefer sass over scss) and add:
@import "bootstrap-sprockets" @import "bootstrap"
Then we’ll add some classes to our employee index in app/views/employees/index.html.erb:
<div ng-app='app.employeeApp' ng-controller='EmployeeListCtrl' class="col-xs-8"> <h1>Employees List</h1> <table ng-if="employees" class="table table-hover table-striped"> <thead> <th>Name</th> <th>Email</th> <th>SSN</th> <th>Salary</th> </thead> <tbody> <tr ng-repeat="employee in employees"> <td>{{employee.name}}</td> <td>{{employee.email}}</td> <td>{{employee.ssn}}</td> <td>{{employee.salary | currency}}</td> </tr> </tbody> </table> </div>
Add the Edit API
Next, we’ll update our Rails API to support editing of employee records. Let’s start by adding some tests for the update method in spec/controllers/api/employees_controller_spec.rb:
require 'spec_helper' describe Api::EmployeesController do before(:each) do @employee = create(:employee, name: 'Calhoun Tubbs', salary: 50000) end describe '#index' do it 'should return a json array of users' do get :index result = JSON.parse(response.body) expect(result[0]['name']).to eq('Calhoun Tubbs') end end describe "#update" do it 'should successfully respond to edits' do put :update, id: @employee.id, employee: { id: @employee.id, salary: 60000 } expect(response).to be_success end it "should change the employee's salary" do @employee.update_attribute(:salary, 50000) put :update, id: @employee.id, employee: { id: @employee.id, salary: 60000 } expect(@employee.reload.salary).to eq(60000) end end end
At this point, these tests will fail. We need to create an update method in our API controller, but first we must specify what params are allowed on update (Rails’ strong parameters) at app/controllers/api/employees_controller.rb:
class Api::EmployeesController < ApplicationController # section omitted private def employee_params attributes = [ :salary, :name, :email, :ssn ] params.require(:employee).permit(attributes) end end
Now, our update action is simple. The code will try to update the employee object and render the errors if there’s a problem.
class Api::EmployeesController < ApplicationController # section omitted def update employee = Employee.find(params[:id]) if employee.update(employee_params) render json: employee else render json: employee.errors.messages, status: :bad_request end end # section omitted end
Next run the tests to make sure they pass. For a more complex application there would be more tests, but in our example these will suffice.
Add the Edit Front End
For our employee edit functionality we’ll define a modal dialog containing a form and a submit button. The Angular-bootstrap package contains a modal dialog tool. We’ll add the angular-bootstrap package to our bower.json:
{ "lib": { "name": "bower-rails generated lib assets", "dependencies": { "angular": "v1.2.25", "restangular": "v1.4.0", "angular-bootstrap": "v0.11.0" } }, "vendor": { "name": "bower-rails generated vendor assets", "dependencies": { } } }
And bundle exec rake bower:install
Next, require angular-bootstrap in app/assets/javascripts/application.js:
//= require jquery //= require jquery_ujs //= require angular //= require angular-rails-templates //= require lodash //= require restangular //= require angular-bootstrap //= require bootstrap-sprockets //= require angular-app/app // rest of the file omitted
Finally, we’ll add it as a dependency to our angular app in app/assets/javascripts/angular-app/modules/employee.js.coffee.erb:
@employeeApp = angular .module('app.employeeApp', [ 'restangular', 'ui.bootstrap' 'templates' ]) .run(-> console.log 'employeeApp running' )
Now, we need to add a modal controller to our AngularJS app. This controller will handle popping up a modal editable form and submitting it to our Rails API. In app/assets/javascripts/angular-app/controllers/employee/EmployeeListCtrl.js.coffee:
angular.module('app.employeeApp').controller("EmployeeListCtrl", [ '$scope', 'EmployeeService', '$modal', ($scope, EmployeeService, $modal)-> $scope.editEmployee = (employee) -> modalInstance = $modal.open({ templateUrl: 'employee/edit.html', controller: 'EmployeeEditModalCtrl' size: 'lg' resolve: employee: -> employee }) modalInstance.result.then(-> console.log 'edit closed' ) # rest of file omitted
Next, we’ll create the modal controller at app/assets/javascripts/angular-app/controllers/employee/EmployeeEditModalCtrl.js.coffee:
angular.module('app.employeeApp').controller('EmployeeEditModalCtrl', [ '$scope', '$modalInstance', 'employee', ($scope, $modalInstance, employee)-> $scope.submitEmployee = -> console.log 'submit employee' $scope.cancel = -> $modalInstance.dismiss('cancel') ])
In the above code we’re defining a modal controller that receives the employee object we passed in the prior controller, along with function stubs for the save / cancel buttons in the dialog. Next, we need to define the edit form template in app/assets/javascripts/angular-app/templates/employee/edit.html.erb:
<div class="modal-header"> <h4 class="modal-title">Edit Employee</h4> </div> <div class="modal-body"> <form name="" ng-submit="submitEmployee(employee)"> <div class="form-group"> <div class="row"> <div class="col-xs-12"> <label>Name<span class="required"> required</span></label> <input class="form-control" ng-model="employee.name"> </div> </div> </div> <div class="form-group"> <div class="row"> <div class="col-xs-12"> <label>Email<span class="required"> required</span></label> <input class="form-control" ng-model="employee.email"> </div> </div> </div> <div class="form-group"> <div class="row"> <div class="col-xs-12"> <label>SSN<span class="required"> required</span></label> <input class="form-control" ng-model="employee.ssn"> </div> </div> </div> <div class="form-group"> <div class="row"> <div class="col-xs-12"> <label>Salary<span class="required"> required</span></label> <input class="form-control" ng-model="employee.salary"> </div> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-default" data-dismiss="modal" ng-click="cancel()">Cancel</button> <button type="submit" class="btn btn-primary">Save Changes</button> </div> </form> </div>
Finally, if you run the code now you’ll get an error loading the template. We need to tell our angular-rails-templates
gem what the base path for our templates is. This is necessary because we are using the rails asset pipeline to combine our javascript files. In config/application.rb:
require File.expand_path('../boot', __FILE__) require 'rails/all' Bundler.require(*Rails.groups) module AngularExample class Application < Rails::Application # the path relative to app/assets/javascripts config.angular_templates.ignore_prefix = 'angular-app/templates/' end end
Next, we can return to our employees index and provide an ng-click attribute to launch the edit form. We’ll add this link on the employee name cell in the table in app/views/employees/index.html.erb:
# rest of the file omitted <td><a ng-click="editEmployee(employee)">{{employee.name}}</a></td>
Now, clicking an employee’s name will launch a modal form with fields containing that employee’s data. However, we still need to hook up the Save Changes button to our Rails API. In app/assets/javascripts/angular-app/controllers/employee/EmployeeEditModalCtrl.js.coffee:
angular.module('app.employeeApp').controller('EmployeeEditModalCtrl', [ '$scope', '$modalInstance', 'employee', ($scope, $modalInstance, employee)-> $scope.employee = employee $scope.submitEmployee = (employee)-> employee.put().then( $modalInstance.close('saved') ) # section omitted ])
We can simply say put()
above because we are using Restangular to manage that object.
One problem with the above code is that while the server is applying validation to the object, the client side AngularJS portion is not. One downside of using client-side MVC is the fact that validation from the server must often be duplicated on the client. The subject of AngularJS form validation could be a blog post on its own. I’ll leave that as an exercise for the reader.
Secure the UI
Though the editing functionality now works, notice the page still shows each employee’s social security number. In order to mask the SSN and only reveal it in edit form, we can create an AngularJS filter. In app/assets/javascripts/angular-app/filters/employee/empMaskNumber.js.coffee
angular.module('app.employeeApp').filter('empMaskNumber', ()-> (value, nonMaskLength) -> if value? maskLength = value.length - nonMaskLength - 1 if maskLength > 0 v = value.split("") for i in [0..maskLength] by 1 if v[i] != '-' v[i] = '*' v = v.join('') else value else value )
It’s recommended that you name filters with an app prefix to avoid colliding with AngularJS’s built-in filters. Let’s edit our employee table to make use of the filter in app/views/employees/index.html.erb:
# section omitted <tr ng-repeat="employee in employees"> <td><a ng-click="editEmployee(employee)">{{employee.name}}</a></td> <td>{{employee.email}}</td> <td>{{employee.ssn | empMaskNumber: 4 }}</td> <td>{{employee.salary | currency}}</td> </tr> # section omitted
Viewing the employee table again you should see the SSN field being masked with asterisks except the last 4 digits. While this filter is simple and apparently works, when building more complex filters we will need javascript tests.
Setup JavaScript Tests
Next, we’ll setup jasmine-rails. Add jasmine-rails to the Gemfile:
group :test, :development do gem 'jasmine-rails' end
And add angular-mocks to the bower.json:
{ "lib": { "name": "bower-rails generated lib assets", "dependencies": { "angular": "v1.2.25", "restangular": "v1.4.0", "angular-bootstrap": "v0.11.0", "angular-mocks": "v1.3.2" } }, "vendor": { "name": "bower-rails generated vendor assets", "dependencies": { } } }
Then run:
bundle install rails generate jasmine_rails:install bundle exec rake bower:install
Next, we need to setup our spec_helper. In app/assets/javascripts/spec_helper.coffee:
#= require application #= require angular-mocks #= require sinon beforeEach(module('app', 'app.employeeApp')) beforeEach inject (_$httpBackend_, _$compile_, $rootScope, $controller, $location, $injector, $timeout, $filter) -> @scope = $rootScope.$new() @compile = _$compile_ @location = $location @controller = $controller @injector = $injector @http = @injector.get('$httpBackend') @timeout = $timeout @model = (name) => @injector.get(name) @filter = $filter @eventLoop = flush: => @scope.$digest() afterEach -> @http.resetExpectations() @http.verifyNoOutstandingExpectation()
Note the beforeEach line above. As your application grows and more angular apps are added, they must also be added here to be available for testing. The above spec helper also requires SinonJS. Download the file and place it at vendor/assets/javascripts/sinon.js .
Now you should be able to run an empty test suite:
RAILS_ENV=test bundle exec rake spec:javascript
Which should return 0 specs and 0 failures. We are now ready to write our test for our filter defined above. In spec/javascripts/filters/emp_mask_number_spec.js.coffee:
#= require spec_helper describe 'Filters', -> describe 'EmpMaskNumber', -> beforeEach -> @maskFilter = @filter('empMaskNumber') it 'should mask the value with asterisks', -> @value = "123" @nonMaskLength = 0 expect(@maskFilter(@value, @nonMaskLength)).toEqual "***"
In a real application expect to have more specs than just the above. At this point though, you have everything you need to continue building your application and expanding. You can find the code for this example on Github.
Need to catch up on the Angular with Rails series? Check these out:
- Angular with Rails Part I
- Angular with Rails, Part II
Resources: