Formulários complexos no rails com Atributos Aninhados
Rails provides a powerful mechanism for easily creating rich forms
called “nested attributes.” It allows you to combine more than one model
in your forms while keeping the same basic code pattern that you use
with simple single model forms.
In this article I’ll be showing a number of different ways to use this technique. I’ll assume that you’re familiar with basic Rails forms, of the kind that are generated by the scaffolding commands. We’ll be building up a complex form step by step that allows a user to edit their preferences. Our domain is a not-for-profit management system, where volunteers (users) have areas of expertise and tasks that have been assigned to them.
First up is a simple user model with just one attribute:
We will be using the same controller for the entire of this article.
This is the beauty of nested attributes—we don’t have to change our
controller code!
Our base form is exactly what is generated by the Rails scaffolding:
With that out of the way, let’s dive in!
Note the addition of
You can see how we won’t have to modify our controller code if we set
up our form correctly, since to edit the address attributes you use the
same
To create the form, we will use the
First of all you can pass
Please note the changed variable name for the
Now if the user doesn’t have an address we create a new unsaved one
that will be persisted when the form is submitted. Of course, if they do
have address no action is needed (
Try this out and you’ll see that rails even correctly accumulates and displays errors for the child object. It is pretty neat.
There are two new options here: allow_destroy and reject_if. I’ll explain them a bit later on when they become relevant.
As with the address, we want tasks to be assigned on the same form as editing the user. We have just set up
When
In addition, for each task that is persisted in the database, a check box is created that maps to the
Note that the id of any persisted records is automatically added in a hidden field by
The form we have created will allow us to edit and delete existing tasks for the user, but there is currently no way to add new tasks since for a new user with no tasks,
There are a number of different UI behaviours you could apply, such as using javascript to dynamically add new records as they are needed. For this example though we are going to choose a simple behaviour of adding three blank records at the end of the list that can optionally be filled in.
The only extra concept added here is the
As before, after adding
Once again, when
We know that the allow_destroy option above allows us to send a special
While we are in that area, we also need to override the default logic that decides whether the check box is checked initially. This is what
I have been talking about checking whether the current record is persisted or not. When you load a user out of the database, of course all the interest records will be persisted. The problem is only those interests already selected will be shown and checked, whereas we actually need to show all interests whether or not they have been selected in the past. This is where we use our
First this code creates a new join record for all interests that the user does not currently have selected (
You can download the complete code for this article over on github to have a play around with it. Let us know how you go in the comments.
In this article I’ll be showing a number of different ways to use this technique. I’ll assume that you’re familiar with basic Rails forms, of the kind that are generated by the scaffolding commands. We’ll be building up a complex form step by step that allows a user to edit their preferences. Our domain is a not-for-profit management system, where volunteers (users) have areas of expertise and tasks that have been assigned to them.
The Base Form
Let’s start with a basic form that can edit a user. I assume you are familiar with this pattern, so I won’t explain it. I only present it here because the rest of the article will be building on top of this code.First up is a simple user model with just one attribute:
1
2
3
4
| # app/models/user.rb class User < ActiveRecord::Base validates_presence_of :email end |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| # app/controllers/users_controller.rb class UsersController def new @user = User. new end def edit @user = User.find(params[ :id ]) end def create @user = User. new (params[ :user ]) if @user .save redirect_to @user else render :action => 'new' end end def update @user = User.find(params[ :id ]) if @user .update(params[ :user ]) redirect_to @user else render :action => 'edit' end end end |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| # app/views/users/_form.html.erb <%= form_for( @user ) do |f| %> <% if @user .errors.any? %> <div id= "error_explanation" > <h2> <%= pluralize( @user .errors.count, "error" ) %> prohibited this user from being saved: </h2> <ul> <% @user .errors.full_messages. each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %> <div> <%= f.label :email %> <%= f.text_field :email %> </div> <div class = "actions" > <%= f.submit %> </div> <% end %> |
Adding an Address
We are storing a user’s address record in a separate model, but we want to be able to edit the address on the same form as other attributes of the user.
1
2
3
4
5
6
7
8
9
10
11
12
| # app/models/user.rb class User < ActiveRecord::Base # ... code from above omitted has_one :address accepts_nested_attributes_for :address end # app/models/address.rb class Address < ActiveRecord::Base belongs_to :user validates_presence_of :city end |
accepts_nested_attributes_for
to the User
model. This method allows you to modify instances of Address
using the same mass-assignment facility on User that makes simple forms so trivial. accepts_nested_attributes_for
adds a writer method _attributes
to the model that allows you to write code like this:
1
2
3
4
5
6
7
| user = User.find( 1 ) # Normal mass-assignment user.update( :email => 'new@example.com' ) # Creates or edits the address user.update( :address_attributes => { :city => 'Hobart' }) |
#update
method as you do to edit the user’s email.To create the form, we will use the
fields_for
builder
method. This is a complicated method that can do many things. Rather
than explain it all upfront, I will introduce some of its many
behaviours through the upcoming examples.First of all you can pass
fields_for
a symbol of a
relationship name and it will intuit from that relationship how it
should render the fields. I know that sounds complicated, but the
following code snippet should make it clearer:
1
2
3
4
5
6
7
8
| # app/views/users/_form.html.erb # ... Form code from above omitted <%= f.fields_for :address do |ff| %> <div> <%= ff.label :city %> <%= ff.text_field :city %> </div> <% end %> |
fields_for
block—ff
rather than f
. In this case for a has_one
relationship, the logic is “if an address exists, show a field to edit
the city attribute. Otherwise if there is no address, don’t show any
fields.” Here we hit our first stumbling block: if the fields are hidden
when there is no address, how do we create an address record in the
first place? Since this is a view problem (do we display fields or
not?), we want to solve this problem in the view layer. We do this by
setting up default values for the form object in a helper:
1
2
3
4
5
6
7
8
9
10
11
| # app/helpers/form_helper.rb module FormHelper def setup_user(user) user.address ||= Address. new user end end # app/views/users/_form.html.erb <%= form_for(setup_user(user)) do |f| %> ... |
||=
means “assign this value unless it already has a value”).Try this out and you’ll see that rails even correctly accumulates and displays errors for the child object. It is pretty neat.
Adding Tasks
A user can have many tasks assigned to them. For this example, a task simply has a name.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # app/models/task.rb class Task < ActiveRecord::Base belongs_to :user validates_presence_of :name end # app/models/user.rb class User < ActiveRecord::Base # ... code from above omitted has_many :tasks accepts_nested_attributes_for :tasks , :allow_destroy => true , :reject_if => :all_blank end |
As with the address, we want tasks to be assigned on the same form as editing the user. We have just set up
accepts_nested_attributes_for
, and there are two steps remaining: adding correct fields_for
inputs, and setting up default values.
1
2
3
4
5
6
7
8
9
10
11
12
13
| # app/views/users/_form.html.erb <h2>Tasks</h2> <%= f.fields_for :tasks do |ff| %> <div> <%= ff.label :name %> <%= ff.text_field :name %> <% if ff.object.persisted? %> <%= ff.check_box :_destroy %> <%= ff.label :_destroy, "Destroy" %> <% end %> </div> <% end %> |
fields_for
is given the name of a has many
relationship, it iterates over every object in that collection and
outputs the given fields once for each record. So for a user that has
two tasks, the above code will create two text fields, one for each
task.In addition, for each task that is persisted in the database, a check box is created that maps to the
_destroy
attribute. This is a special attribute that is added by the allow_destroy
option. When it is set to true, the record will be deleted rather than
edited. This behaviour is disabled by default, so remember to explicitly
enable it if you need it.Note that the id of any persisted records is automatically added in a hidden field by
fields_for
, you don’t have to do this yourself (though if you do have to for whatever reason, fields_for
is smart enough to not add it again.) View the source on the generated HTML to see for yourself.The form we have created will allow us to edit and delete existing tasks for the user, but there is currently no way to add new tasks since for a new user with no tasks,
fields_for
will see an empty relationship and render no fields. As above, we fix this by adding new default tasks to the user in the view.There are a number of different UI behaviours you could apply, such as using javascript to dynamically add new records as they are needed. For this example though we are going to choose a simple behaviour of adding three blank records at the end of the list that can optionally be filled in.
1
2
3
4
5
6
7
8
9
| # app/helpers/form_helper.rb module FormHelper def setup_user(user) # ... code from above omitted 3 .times { user.tasks.build } user end end |
fields_for
will iterate over these three records and
create inputs for them. Now no matter how few or many tasks a user has,
there will always be three blank text fields for new tasks to be added.
There is a problem here though—if a blank task is submitted, is it a new
record that is invalid (blank name) and should cause the save to fail,
or was it never filled in? By default Rails assumes the former, but that
is often not what is desired. This behaviour can be customized by
specifying the reject_if option to accepts_nested_attributes_for
. You can pass a lambda that is evaluated for each attributes hash, returning true if it should be rejected, or you can use the :all_blank
shortcut like we have above, which is equivalent to:
1
2
3
4
| accepts_nested_attributes_for :tasks , :reject_if => proc {|attributes| attributes.all? {|k,v| v.blank?} } |
More complicated relationships
For this application, we want users to specify which areas in our not-for-profit they are interested in helping out with, such as admin or door knocking. This is modelled with a many-to-many relationship between users and interests.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| # app/models/interest.rb class Interest < ActiveRecord::Base has_many :interest_users validates_presence_of :name end # app/models/interest_user.rb class InterestUser < ActiveRecord::Base belongs_to :user belongs_to :interest end # app/models/user.rb class User < ActiveRecord::Base # ... code from above omitted has_many :interest_users has_many :interests , :through => :interest_users accepts_nested_attributes_for :interest_users , :allow_destroy => true end |
allow_destroy
option, which we used in the previous example. As the name implies, this
allows us to destroy child records in addition to creating and editing
them. Recall that by default, this behaviour is disabled, so we need to
explicitly enable it.As before, after adding
accepts_nested_attributes_for
there are two more steps to adding interest check boxes to our form: setting up appropriate default values, and using fields_for
to create the necessary form fields. Let’s start with the first one:
1
2
3
4
5
6
7
8
9
10
11
| # app/views/users/_form.html.erb <%= f.fields_for :interest_users do |ff| %> <div> <%= ff.check_box :_destroy, { :checked => ff.object.persisted?}, '0' , '1' %> <%= ff.label :_destroy, ff.object.interest.name %> <%= ff.hidden_field :interest_id %> </div> <% end %> |
fields_for
is given the name of a has
many relationship, it iterates over every object in that collection and
outputs the given fields once for each record. So for a user that has
two interests, the above code will create two check boxes, one for each
interest.We know that the allow_destroy option above allows us to send a special
_destroy
attribute that if true will flag the object to be deleted. The problem
is that this is the inverse of the default check box behaviour: when the
check box is unchecked we want _destroy
to be true, and when it is checked we want to keep the record around. That is what the last two parameters to check_box
do (‘0’ and ‘1’): set the checked and unchecked values respectively, flipping them from their defaults.While we are in that area, we also need to override the default logic that decides whether the check box is checked initially. This is what
:checked => ff.object.persisted?
does—if the record exists in the database, then the user has indicated
they are interested in that area, so the box should be checked. Note
here the use of ff.object
to access the current record in the loop. You can use this method inside any form_for
or fields_for
to get the current object.I have been talking about checking whether the current record is persisted or not. When you load a user out of the database, of course all the interest records will be persisted. The problem is only those interests already selected will be shown and checked, whereas we actually need to show all interests whether or not they have been selected in the past. This is where we use our
setup_user
method from earlier to provide “default” new records for interests that are not persisted.
1
2
3
4
5
6
7
8
9
10
11
| # app/helpers/form_helper module FormHelper def setup_user(user) user.address ||= Address. new (Interest.all - user.interests). each do |interest| user.interest_users.build( :interest => interest) end user.interest_users.sort_by! {|x| x.interest.name } user/tmp/clean-controllers.md.html end end |
Interest.all - user.interests
), and then uses an in-place sort (sort_by!
)
to ensure that the check boxes are always shown in a consistent order.
Without this, all the new unchecked records would be grouped at the
bottom of the list.Parting Words
Nested attributes is a powerful technique to quickly develop complex forms while keeping your code nice and neat.fields_for
gives you a lot of flexibility and options for conforming to the nested attributes pattern—see the documentation—and you should always try to structure your forms to take advantage of the behaviour that accepts_nested_attributes_for
gives you. Going beyond this article, just a touch of javascript magic
supporting dynamic creating of new nested records can make your forms
really stand out.You can download the complete code for this article over on github to have a play around with it. Let us know how you go in the comments.
Comentários
Postar um comentário