Nested Attributes is a feature that allows you to save attributes of a record through its associated parent. In this example we’ll consider the following scenario:
We’re making an online store with lots of products. Each Product
can have zero or more Variants. Variants are exactly what they sound like; they represent a variation of the same product, but in different color, for example. Both have a name and price.
Each product will also be associated with one Image record, containing a url, alt and a caption.
Later in the tutorial we’ll improve these models:
1class Product < ActiveRecord::Base
2 has_many :variants
3 has_one :image
4 # Attributes: name:string, price:float
5end
1class Variant < ActiveRecord::Base
2 belongs_to :product
3 # Attributes: name:string, price:float
4end
1class Image < ActiveRecord::Base
2 belongs_to :product
3 # Attributes: url:string, alt:string caption:string
4end
The simplest example of Nested Attributes is with a one-to-one association. To add Nested Attributes support to the product model all you need to do is add the following line:
1class Product < ActiveRecord::Base
2 has_many :variants
3 accepts_nested_attributes_for :image
4end
What does this do, exactly? It would proxy the saved attributes from the Product
model to the Image model. In the Product form you’ll need to add the
additional fields for the image association. You can do it by using the
fields_for
helper.
1= form_for @product do |f|
2
3 // Product attributes
4 .form-group
5 = f.label :name
6 = f.text_field :name
7 .form-group
8 = f.label :price
9 = f.text_field :price
10
11 // Image attributes
12 = f.fields_for :image do |f|
13 = f.label :url
14 = f.text_field :url
15
16 = f.label :alt
17 = f.text_field :alt
18
19 = f.label :caption
20 = f.text_field :caption
21
22 = f.submit
Now, the only remaining part is to modify the controller to accept those new attributes.
The entire idea behind Nested Attributes is that you won't have to add
additional code in the controller to handle this input and the association, but
you do need to allow those attributes to reach the model, which is what
Strong Parameters would prevent by default. So, you’ll need to
add the following to the product_params
method in the ProductsController
.
1def product_params
2 params.require(:product).permit(
3 :name, :price,
4 image_attributes: [ :id, :url, :alt, :caption ]
5 )
6end
And voila! Now you can edit the Image association of the Product model inline from the same form. Now let's look at how we will build the same behavior with a many-to-many relationship.
Product variants are quite simple (just two fields), so there’s really no point in creating a separate page for editing them. Instead, we would like to edit them inline from the same product form, along with the product attributes. And since each product can have many variants that means that we’ll have to handle more than one item. We’ll also need to add new variants and delete old ones. Let's address the problems one by one.
The fields_for
method yields a block for each associated record, so we
don't need to change anything--but because we will need to reuse this form (for
the purpose of automatically adding new fields through JavaScript) we’ll need
to move it into a separate file. We’re going to create a new partial, called
_variant_fields.slim
containing just the variant fields, like this:
1= f.label :name
2= f.text_field :name
3
4= f.label :price
5= f.text_field :price
And back in the product form, to render the fields, we’ll just take advantage of
the fact that fields_for
yields a block for each association and we’ll pass the form
helper object to the partial.
1= f.fields_for :variants do |f|
2 = render 'variant_fields', f: f
In order to add new associations we’ll need to create some JavaScript that adds new fields. What I like to do is have a link that, when clicked, will add a new tuple of fields. Something like this:
1= link_to_add_fields 'Add Product Variant', f, :variants
This is a useful helper method I wrote that will create a link with the
data-form-prepend
attribute containing the entire contents of the
_variant_fields.slim
partial. The idea here is that when you click on it you’ll
use some simple reusable JavaScript to append those fields to the end of the
form.
The actual helper looks quite complex and messy but bear with me--I promise that it’s just as simple as most of the code. It just handles the arguments and the key
logic rests in the last seven lines. You can place this code in your
application_helper.rb
.
1def link_to_add_fields(name = nil, f = nil, association = nil, options = nil, html_options = nil, &block)
2 # If a block is provided there is no name attribute and the arguments are
3 # shifted with one position to the left. This re-assigns those values.
4 f, association, options, html_options = name, f, association, options if block_given?
5
6 options = {} if options.nil?
7 html_options = {} if html_options.nil?
8
9 if options.include? :locals
10 locals = options[:locals]
11 else
12 locals = { }
13 end
14
15 if options.include? :partial
16 partial = options[:partial]
17 else
18 partial = association.to_s.singularize + '_fields'
19 end
20
21 # Render the form fields from a file with the association name provided
22 new_object = f.object.class.reflect_on_association(association).klass.new
23 fields = f.fields_for(association, new_object, child_index: 'new_record') do |builder|
24 render(partial, locals.merge!( f: builder))
25 end
26
27 # The rendered fields are sent with the link within the data-form-prepend attr
28 html_options['data-form-prepend'] = raw CGI::escapeHTML( fields )
29 html_options['href'] = '#'
30
31 content_tag(:a, name, html_options, &block)
32end
On the JavaScript side I use a jQuery to find every element with the name
attribute set to new_record
and replace it with a timestamp. This solves a
problem when adding more than one new record; both will have the same
id (new_record
).
1$("[data-form-prepend]").click(function(e) {
2 var obj = $($(this).attr("data-form-prepend"));
3 obj.find("input, select, textarea").each(function() {
4 $(this).attr("name", function() {
5 return $(this)
6 .attr("name")
7 .replace("new_record", new Date().getTime());
8 });
9 });
10 obj.insertBefore(this);
11 return false;
12});
Fortunately the accepts_nested_attributes_for
has some neat features for
deleting associations. If we pass the allow_destroy: true
argument to
accepts_nested_attributes_for
, it will destroy any members from the attributes
which contain a _destroy
key.
1accepts_nested_attributes_for :variants, allow_destroy: true
In the view this could be implemented with a simple checkbox. So I added one to
my _variant_fields.slim
:
1= f.check_box :_destroy
2= f.label :delete
Again, as before the additions to the ProductsController
are just in the
product_params
method, which now should also include the variants_attributes
.
1def product_params
2 params.require(:product).permit(
3 :name, :price,
4 image_attributes: [ :id, :url, :alt, :caption ],
5 variants_attributes: [ :id, :name, :price, :_destroy ]
6 )
7end
The Product model just has the following addition to enable Nested Attributes
for the variants
association:
1accepts_nested_attributes_for :variants, reject_if: :all_blank, allow_destroy: true
Notice the reject_if :all_blank
option. It means that any record
In which attributes are all blank (excluding the value of _destroy
) will be
rejected. reject_if
also supports passing it a Proc, which can be used for some
additional validation, and it also checks whether to reject/include the association.
There are two other useful options. The first is the limit
option
which specifies the maximum number of records that will be processed. The second option is update_only
which applies to one-to-one associations and
has a rather interesting behavior. If it’s set to true, it will only update the
attributes of the associated record. If set to false upon change, it won't touch
the old record but will create a new one with the new attributes. By default it
is false and will create a new record unless the record includes an id
attribute, which is exactly the reason why we included the id
attribute in the
product_params
for the one-to-one image association. An alternative solution
would have been to define the nested attributes like this:
1accepts_nested_attributes_for :image, update_only: true
You can read more about Nested Attributes in the Ruby on Rails documentation where each of the configuration options is demonstrated and well documented.