Rails Form With Multiple Attributes
While developing a recent Ruby on Rails project, I ran into a case where the client needed to create a product that had multiple attributes. In this case, a single product was available in multiple sizes, and I wanted a single form that would allow a user to create as many sizes of the product that they want. Luckily, Rails makes this easy and the real challenge was figuring out the JavaScript to make it all work correctly.
The Models
The first order of business is to take a look at the models that I will be needing. In this case, I am creating a product that has multiple sizes, so I will need a Product model and a size model:
class CreateProductsAndSizes < ActiveRecord::Migration
def self.up
create_table :products do |t|
t.string :name
t.text :description
t.timestamps
end
create_table :sizes do |t|
t.string :name
t.text :product_id
t.timestamps
end
add_index :sizes, :product_id
end
def self.down
drop_table :products
drop_table :sizes
end
end
Depending on how you want to set up your models, you might store the product price in the Product model, with an adjustment made by the size, or you might just store the price associated with each size, adding a price field to the Size model. Either way, that’s not terribly important to this tutorial. Let’s take a look at each of the model files:
app/models/product.rb
class Product < ActiveRecord::Base
has_many :sizes, :dependent => :destroy
end
app/models/product.rb
class Size < ActiveRecord::Base
belongs_to :product
end
Pretty simple there. Really all we are doing is saying that “each product has many sizes”, which is pretty basic Rails goodness.
The Controllers
Let’s move on to the controller code. In this case we want to create a new Product, so let’s take a look at the products_controller first:
app/controllers/products_controller.rb
class ProductsController < ApplicationController
def new
@product = Product.new
@product.sizes.build
end
def create
@product = Product.new(params[:product])
@sizes = params[:sizes]
@sizes.each do |k, v|
if v and v != ""
@product.sizes.build({:name => v[‘name’]})
end
end
if @product.save
flash[:notice] = “Product created successfully”
redirect_to product_path(@product)
else
render :action => :new
end
def edit
@product = Product.find(params[:id])
end
def update
@product = Product.find(params[:id])
@sizes = params[:sizes]
@sizes.each do |k, v|
if existing_size = @product.sizes.find_by_id(k)
existing_size.update_attributes({:name => v[‘name’]})
else
if v and v != ’
@product.sizes.build({:name => v[‘name’]})
end
end
end
end
Pretty basic stuff, except that we are also including a sizes array with our forms, which need to be processed. As you can see, in the create action the sizes are each evaluated to be sure that there is a value for them. Then each one is used to build an association with the product. When the product is saved, so are the sizes that are associated with it. Isn’t Rails great?
Of course, we will also need a controller to handle the logic associated with our sizes. However, this controller only needs to have one action in it, since deleting sizes is the only thing that we will need it for:
app/controllers/sizes_controller.rb
class SizesController < ApplicationController
def destroy
@size = Size.find(params[:id])
respond_to do |format|
if @size.product.sizes.size > 1
@size.destroy
format.html {
flash[:notice] = 'Size successfully deleted.'
redirect_to product_path(@size.product)
}
format.js { flash.now[:notice] = 'Size successfully deleted.' }
else
format.html {
flash[:error] = 'There has to be at least one size.'
redirect_to product_path(@size.product)
}
format.js { flash.now[:error] = 'There has to be at least one size.' }
end
end
end
end
Again, all we are doing here is deleting a size unless it is the last size for that product, since each product needs to have at least one size associated with it. You can change this behavior if you want. It probably belongs in the size model anyway. I also left in the respond_to blocks to illustrate how to respond to different requests. In this case, we will be using Ajax to trigger the deletion of sizes, but it should also work if an HTML request is used instead.
The Views
The view files for this are as expected, and typical of most Rails applications, what is going to make our trick work is that we can use JavaScript to add additional size fields to our form. By ensuring that all the sizes on the page are included in the sizes array that we will be submitting to the controller, we can easily create as many sizes as we want simply by adding more input elements to the form. Let’s start with the views:
app/views/products/new.html.erb
<% form_for(:product, @product, :url => products_path, :html => {:multipart => true}) do |p| -%>
<%= error_messages_for 'product' %>
<p>
<label for="product_name">Name:</label>
<%= p.text_field :name %>
</p>
<p>
<label for="product_description">Description:</label>
<%= p.text_area :description %>
</p>
<ul id="product_sizes">
<% @product.sizes.each_with_index do |size, index| -%>
<li id="sizes_<%= size.id || index %>">
<label for="sizes_<%= size.id || index %>_name" class="inline">Name:</label>
<input class="text" id="sizes_<%= size.id || index %>_name" name="sizes[<%= size.id || index %>][name]" size="30" type="text" value="<%= size.name %>" />
<% unless index == 0 -%>
<a href="#" title="Delete size" onclick="Product.removeSize('sizes_<%= size.id || index %>');"><img src="/images/delete.png" alt="Delete size" width="16" height="16" /></a>
<% end -%>
<% if (index + 1) == @product.sizes.size -%>
<a href="#" title="Add another size" onclick="Product.addSize(<%= size.id || index %>);return false;" id="add_size"><img src="/images/icons/add.png" alt="Add another size" width="16" height="16" /></a>
<% end -%>
</li>
<% end -%>
</ul>
<p>
<%= p.submit "Create Product" %>
</p>
<% end -%>
As you can see, the only thing that is new to this form is the section for the “available sizes”, which creates an unordered list of the sizes. Remember that in our new action we had Rails build an association, so we will start with one size field showing in our form. If you would like more to start out with, you can use something like:
3.times {@product.sizes.build}
Also, be sure to notice that we don’t display a delete button unless there is more than one size. This is because we want to be sure that each product has at least one size associated with it.
Let’s a take a quick look at the edit form, since it has some minor changes to handle already existing sizes for our product. Then we’ll move on to the JavaScript that makes it all work. So, our for for editing a product will look something like this:
app/views/products/edit.html.erb
<% form_for(:product, @product, :url => product_path(@product), :html => {:multipart => true, :method => :put}) do |p| -%>
<%= error_messages_for 'product' %>
<p>
<label for="product_name">Name:</label>
<%= p.text_field :name %>
</p>
<p>
<label for="product_description">Description:</label>
<%= p.text_area :description %>
</p>
<ul id="product_sizes">
<% @product.sizes.each_with_index do |size, index| -%>
<li id="sizes_<%= size.id || index %>">
<label for="sizes_<%= size.id || index %>_name" class="inline">Name:</label>
<input class="text" id="sizes_<%= size.id || index %>_name" name="sizes[<%= size.id || index %>][name]" size="30" type="text" value="<%= size.name %>" />
<% unless index == 0 -%>
<%= link_to_remote image_tag('/images/delete.png', :size => '16x16', :alt => "Delete this size"), :url => size_path(size), :confirm => "Are you sure you want to delete this size? This cannot be undone.", :method => :delete, :html => {:title => 'Delete this size', :id => "trash_#{size.id}"}, :loading => "$('loadicon_#{size.id}').show();$('trash_#{size.id}').hide();" %>
<img src="/images/loader.gif" alt="Loading" width="16" height="16" id="loadicon_<%= size.id %>" style="display:none;" />
<% end -%>
<% if (index + 1) == @product.sizes.size -%>
<a href="#" title="Add another size" onclick="Product.addSize(<%= size.id || index %>);return false;" id="add_size"><img src="/images/icons/add.png" alt="Add another size" width="16" height="16" /></a>
<% end -%>
</li>
<% end -%>
</ul>
<p>
<%= p.submit "Save Changes" %>
</p>
<% end -%>
As you might have noticed, the only section that has changed at all between to the two forms is the delete button. Since on the new form we were simply creating a new page element (or we will using JavaScript), we only need to remove that page element when a size is removed. On the edit form, the size is stored in our database, so instead we create a link_to_remote Ajax link to the destroy action in our sizes_controller. Pretty simple. However, it does lead to our last view file, which is the RJS template that will respond to deleting a size. That file simply removes the required elements from the edit form after deleting the size record from the database:
app/views/sizes/destroy.js.rjs
if flash[:notice]
page.hide "loadicon_#{@size.id}"
page.visual_effect :highlight, "sizes_#{@size.id}", :duration => 0.5
page.delay(0.3) do
page.visual_effect :fade, "sizes_#{@size.id}", :duration => 0.5
page.visual_effect :blindUp, "sizes_#{@size.id}", :duration => 0.5
end
page.delay(0.8) do
page << "Product.removeSize('sizes_#{@size.id}');"
end
elsif flash[:error]
page << "$('loadicon_#{@size.id}').hide();$('trash_#{@size.id}').show();alert('#{flash[:error]}');"
end
Let’s go through this template. First, it checks the flash response to determine if the deletion was successful. If it was, and flash[:notice] is set, then we proceed to hide the loading icon that we displayed and start the “highlight” effect on the list element. After a short delay to allow the effect to complete, we then fade and blind the element off the page, removing it completely from the DOM once that has completed. If the deletion did not take (such as if someone tried to delete the last size), then a JavaScript alert will notify the user of the error message. Again, pretty basic Rails stuff.
The JavaScript
Rails includes the Prototype and Scriptaculous JavaScript libraries, and we have been using the associated view helpers to generate Ajax requests and such. It just so happens that these libraries make it simple to manipulate the DOM of the page so that we can add input elements to the page for more sizes. There is probably a much more efficiently coded way of doing this, but I wanted to keep the code readable. If you notice in the views above, we are calling functions associated with the Product JavaScript object, such as Product.addSize and Product.removeSize. Here is the JavaScript file that makes our form work properly:
apps/public/javascripts/application.js
Product = {
addSize: function(current_size_index) {
current_size_index++;
$('add_size').remove();
new_size = '<li id="sizes_' + current_size_index + '">\n';
new_size += '<label for="sizes_'+ current_size_index + '_name" class="inline">Dimensions:</label>\n';
new_size += '<input class="text" id="sizes_' + current_size_index + '_name" name="sizes[' + current_size_index + '][name]" size="30" type="text" />\n';
new_size += '<label for="sizes_' + current_size_index + '_price" class="inline">Price:</label>\n';
new_size += '<input class="text" id="sizes_' + current_size_index + '_price" name="sizes[' + current_size_index + '][price]" size="30" type="text" />\n';
new_size += '<a href="#" title="Delete size" onclick="Product.removeSize(\'sizes_' + current_size_index + '\');"><img src="/images/delete.png" alt="Delete size" width="16" height="16" />\n';
new_size += '<a href="#" title="Add another size" onclick="Product.addSize(' + current_size_index + ');return false;" id="add_size"><img src="/images/add.png" alt="Add another size" width="16" height="16" /></a>\n';
new_size += '</li>\n';
$('product_sizes').insert(new_size);
},
removeSize: function(size) {
target_size = $(size);
target_index = parseInt(size.split('_')[1]);
current_sizes = target_size.parentNode.getElementsByTagName('li');
total_current_sizes = current_sizes.length;
if (total_current_sizes > 1) {
if (target_size.getAttribute('id') == current_sizes[(total_current_sizes - 1)].getAttribute('id')) {
current_sizes[(total_current_sizes - 2)].insert('<a href="#" title="Add another size" onclick="Product.addSize(' + target_index + ');return false;" id="add_size"><img src="/images/add.png" alt="Add another size" width="16" height="16" /></a>\n');
}
target_size.remove();
} else {
alert('There has to be at least one size for this product.');
}
}
}
Essentially, the JavaScript handles the job of adding and removing input elements for each size to the form. If a field is added with the JavaScript, it will add the values to the sizes array that is submitted with the form. In addition to the form, the script also adds a button to remove the new page element.
Hopefully this helps someone out there that has been looking to add multiple associations through a single form. There are a ton of applications for this, and it can be extended to include multiple product attributes, such as different sizes and colors for a single product.