Rails form enhancement with AngularJS -- Part Two
In the previous post we looked at using AngularJS to progressively enhance a basic Rails form.
In this article we’ll expand on that to build a more complex example with dynamic fields that map one to many models, that you can add or remove inline with Angular, then we will look at handling form state for the non js ( Plain Old Post ) form’s add/remove in Rails, to give a percieved ‘inline’ effect similar to the Angular functionality.
And in the next article ( Part 3 ), we’ll look at progressively enhancing the form with inline validations in AngularJS that fall back to Rails standard validations when JS is unavailable.
If you want to see a working example of this tutorial, just download version 0.0.2 of the app ( assuming you have the capability to run Ruby on Rails on your local machine ) from the releases section of the Github repo.
We will need to add/remove fields on the client ( Browser & Angular ) and persist the form data on the server ( Rails ). So first, we will implement an active model based solution for the Rails backend.
A nested model
First, lets create our nested model for the one to many example:
rails g model article title:string description:string author:references
And add the has many reference and mass assignment to the author model in app/models/author.rb
:
class Author < ActiveRecord::Base
has_many :articles # add this line
accepts_nested_attributes_for :articles # and this line
end
Now run the migrations to update our schema.
rake db:migrate
The next thing to do is set up the controller and form for our new model. Add a partial at app/views/authors/form/_articles.html.haml
with the fields:
#articles{'ng-controller' => 'ArticlesController'}
= f.fields_for :articles do |a|
.div{'ng-repeat' => 'article in articles'}
.field
= a.label :title
= a.text_field :title, 'ng-model' => 'title'
.field
= a.label :description
= a.text_area :description, 'ng-model' => 'description'
And then include this partial in the form view.
= render :partial => 'authors/form/articles', locals:{f:f}
_Now set up the respective controllers … _
In app/controllers/authors_controller.rb
build an instance of article in the new action:
def new
@author = Author.new
@author.articles.build # add this line
end
And modify the angular code at app/assets/javascripts/authors.js.coffee
to do the same.
...
class ArticlesController
constructor: (@$scope)->
$scope.articles = [{}]
$scope.addArticle = ->
$scope.articles.push( {} )
$scope.removeArticle = (index)->
$scope.articles.splice(index, 1)
...
.controller( 'ArticlesController', ['$scope', ArticlesController] )
A model form service
Now that we have added a new set of nested fields with a nested key author[article]
, the Angular code needs to be modified to handle the attribute keys in the same way that Rails would do. Otherwise we will end up with complex code or multiple end points doing virtually the same thing. To do this we wrap the form data model in a seperate service.
The service looks like this:
class FormData
constructor: (data)->
formData = data or {articles:[{}]}
return formData
angular.module('authorsApp.services', [])
.factory('FormData', [FormData])
But for the sake of simplicity look at the diff to get a better idea, and follow on from there.
Adding and removing articles.
Now that we have a form service handling our form submission, we can start to think about adding and removing authors and how to implement these in JS and POP ( Plain Old Posts ).
We’re going to start with the progressively enhanced version and the fallback to POP with the help of some directives. Directives are a feature of Angular that enable you to do stuff.
We open up our authors partial at app/views/authors/form/_articles.html.haml
. And add the buttons that we are going to use, so that the template reads like this:
#articles{'ng-controller' => 'ArticlesController'}
= f.fields_for :articles do |a|
.div{'ng-repeat' => 'article in articles'}
.field
= a.label :title
= a.text_field :title, 'ng-model' => 'article.title'
.field
= a.label :description
= a.text_area :description, 'ng-model' => 'article.description'
.actions
= f.submit 'Remove Article', 'pe-remove-article' => true, 'ng-click' => 'removeArticle($index)'
.actions
= f.submit 'Add Article', 'pe-add-article' => true
Now we add the angular directive code to enable us to swap out the old submits that we added with the nice angular links.
.directive('peAddArticle', (@$compile)->
return {
link: (scope, element, attrs)->
@html = '<a ng-click="addArticle()">Add Article</a>'
@e = $compile(@html)(scope)
element.replaceWith(@e)
}
).directive('peRemoveArticle', (@$compile)->
return {
scope: {
eventHandler: '&ngClick'
},
link: (scope, element, attrs)->
@html = '<a ng-click="eventHandler()">Remove Article</a>'
@e = $compile(@html)(scope)
element.replaceWith(@e)
}
)
Handling the form with Plain Old Posts.
This part pays homage to the concepts outlined in Hexagonal Rails.
In order to add and remove articles without the use of angular or ajax, we will need to use some plain old posting with form state handling added in to give us an almost up to par user experience.
The plot thickens …
HTML forms only allow us to submit to one url ( without the use of javascript to modify the submission of course! ).
To notify the server that we are adding or removing articles we need to send back a parameter with the submission so that the server app can render the correct response. First, lets modify our enhanced submit buttons by adding an index and some name attributes that will get sent back to the server when the button is clicked.
#articles{'ng-controller' => 'ArticlesController'}
- @index = 0
= f.fields_for :articles do |a|
.div{'ng-repeat' => 'article in articles'}
.field
= a.label :title
= a.text_field :title, 'ng-model' => 'article.title'
.field
= a.label :description
= a.text_area :description, 'ng-model' => 'article.description'
.actions
= f.submit 'Remove Article', 'pe-remove-article' => true, 'ng-click' => 'removeArticle($index)', name:"remove_article[#{@index}]"
- @index += 1
.actions
= f.submit 'Add Article', 'pe-add-article' => true, name:'add_article'
The new view after we have made some more modifications.
Form handler
Now that we are telling the server what we pressed when we submit the form, we can write a form handler to process the submission accordingly. Lets add some hexagonal magic to facilitate this.
First, create the form handler in app/handlers/authors_handler.rb
, something like this should suffice for now:
class AuthorsHandler < Struct.new(:listener)
attr_accessor :state_change, :params, :author
def perform params, author
@author = author
@params = params
set_request_type
if state_change?
manage_articles
listener.recycle_form
else
if author.save
listener.author_create_succeeded
else
listener.author_create_failed
end
end
end
def set_request_type
@state_change = false
@state_change = true if params['add_article'] || params['remove_article']
end
def state_change?
state_change == true
end
def manage_articles
if params['add_article']
author.articles.build
end
if params['remove_article']
articles = author.articles.to_a
articles.delete_at(remove_article_id)
author.articles = articles
end
end
def remove_article_id
params['remove_article'].keys.first.to_i
end
end
Will will use this handler in the create action of our authors controller, which is the action that our form posts back to every time we press a button in the form. Based on the parameters that are sent back to the server, we can deduce whether or not the request is a form “state_change” or an actual attempt to save the data. First we refactor the create action so that it reads as follows:
# POST /authors
# POST /authors.json
def create
@author = Author.new(author_params)
handler = AuthorsHandler.new(self) # pass the controller (self) in as the subscriber/listener.
handler.perform(params, @author)
end
And now we add some callback methods to our controller subscriber clas, these will perform the correct response actions based on the outcome from the form handler.
def recycle_form
render :new
end
def author_create_succeeded
respond_to do |format|
format.html { redirect_to @author, notice: 'Author was successfully created.' }
format.json { render action: 'show', status: :created, location: @author }
end
end
def author_create_failed
respond_to do |format|
format.html { render action: 'new' }
format.json { render json: @author.errors, status: :unprocessable_entity }
end
end
Also, don’t forget to add the articles attributes to the controller’s permit
attributes, you’ll need to change the author_params method like so:
def author_params
params.require(:author).permit(:name, :email)
params.require(:author).permit(:name, :email, articles_attributes:[:title, :description])
end
Now we have a working create form that will submit new data to the server both asyncronously ( the progressive bit ) and traditionally the Plain Old Post bit.
see the full source code changes here » If I have left anything out in this post, please leave a comment and I will update it accordingly.
In the next article, we will look at progressively adding validations.