#165 Edit Multiple (revised)
- Download:
- source codeProject Files in Zip (130 KB)
- mp4Full Size H.264 Video (34.4 MB)
- m4vSmaller H.264 Video (18.1 MB)
- webmFull Size VP8 Video (23.3 MB)
- ogvFull Size Theora Video (42.5 MB)
Below is a screenshot that shows a list of products. We want to mark a number of these products as discontinued but to do this we currently have to modify each product separately by clicking the “edit” link next to each product, checking a checkbox on the edit form for that product and then submitting the form.
Doing this for each product that we want to discontinue can get a little tedious. It would be much better if there was a way to update multiple records in a single form. In this episode we’ll show you a few different ways to achieve this.
Using Checkboxes
One approach is to add a checkbox next to each product and a button at the bottom of the table which when clicked will mark all the selected products as discontinued. To do this we’ll first modify the template for the list of products.
<%= form_tag discontinue_products_path, method: :put do %> <table> <tr> <th> </th> <th>Name</th> <th>Category</th> <th>Discontinued</th> <th>Price</th> </tr> <% @products.each do |product| %> <tr> <td><%= check_box_tag "product_ids[]", product.id %></td> <td><%= product.name %></td> <td><%= product.category.name %></td> <td><%= product.discontinued ? "Yes" : "No" %></td> <td class="price"><%= number_to_currency product.price %></td> <td><%= link_to 'Edit', edit_product_path(product) %></td> <td><%= link_to 'Destroy', product, method: :delete %></td> </tr> <% end %> </table> <%= submit_tag "Discontinued Checked" %> <% end %>
We use the check_box_tag
helper method to render each checkbox and give it a name of product_ids[]
. The empty square brackets mean that checked values will be submitted as an array of values when the form is submitted. We’ve also added a submit button under the table and wrapped the whole thing in a form. The form will be submitted to the discontinue_products_path
path. This doesn’t exist yet, but we’ll write it shortly. As the form will be used to update models we’ve set the method to :put
. Before we write the action we’ll modify the routes file to handle this new route.
Store::Application.routes.draw do root to: 'products#index' resources :products do collection do put :discontinue end end end
This route will look for a discontinue
action in the ProductsController
. We’ll write this action next.
def discontinue Product.update_all({discontinued: true}, {id: params[:product_ids]}) redirect_to products_url end
Here we use update_all
to update the products that we’ve marked to be discontinued. This method needs to be passed two arguments: the first is a hash of the values we want to set while the second is like a WHERE clause and to which we pass the id
s of the products we want to update. We fetch the id
s by reading the product_ids
parameter, which returns them in an array. Note that update_all
doesn’t trigger any callbacks which isn’t a problem for us, but which might be a problem in other scenarios. This code assumes that the user can update any of the records; in a production application we’d need to add some authorization here.
When we reload the page now we’ll see checkboxes next to each item and if we check some of products and submit the form they’ll all be marked as discontinued.
Editing Multiple Fields Through a Form
If we just want to modify a single field for a number of records using checkboxes works well but there are a number of of other ways that we can achieve the same thing. We’ll show two of these, both of which involve using a separate form to edit the fields. Instead of just discontinuing products we might want to update the category for multiple products too. How can we do this?
For the next solution we’ll keep the checkboxes but have the button redirect to a new form that lists all the fields for the selected products so that we can pick and choose what we want to change. First we’ll modify the product list template, changing the button’s text and also the action that the form is submitted to. We’ll also change the method so that the form makes a GET request as we’re just going to display another page when the form is submitted. We could be submitting a lot of data through this form so it’s worth considering using POST instead, but we’ll stick with GET.
<%= form_tag edit_multiple_products_path, method: :get do %> <!-- table of products omitted --> <%= submit_tag "Edit Checked" %> <% end %>
As with our previous solution we’ll need to add this new route to the routes file. We’ll also add a route for a PUT request to handle when the form is submitted.
Store::Application.routes.draw do root to: 'products#index' resources :products do collection do get :edit_multiple put :update_multiple end end end
We can now add these actions in the controller. We’ll start with edit_multiple
.
def edit_multiple @products = Product.find(params[:product_ids]) end
We’ll need a template to go with this. In it we want to display a form and we’ll use form_tag
for this. This will submit to the update_products_path
and use the put
method. In this form we want to loop through all the products that were selected and display fields for each one. The tricky part is displaying fields for multiple records. Normally we’d do this through a form builder but we can’t do that when we’re using form_tag
. The trick here is to use fields_for
, which is usually something we call on a form builder object but which we can also call directly. This is great if we want to edit multiple models without having a parent model that we can use form_for
with. We can pass in a prefix as the first argument to this and using a name with empty square brackets will mean that these are filled with the id
of the product, if we pass the product in as a second argument. This gives us a form builder object that we can call fields on. We already have all the product fields in a partial so we can copy and paste them from there into here then make a few modifications so that it works here. This form includes error messages and we’ll cover validations shortly.
<h1>Edit Checked Products</h1> <%= form_tag update_multiple_products_path, method: :put do %> <% @products.each do |product| %> <h2><%= product.name %></h2> <%= fields_for "products[]", product do |f| %> <% if product.errors.any? %> <div id="error_explanation"> <h2><%= pluralize(product.errors.count, "error") %> prohibited this product from being saved:</h2> <ul> <% product.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %> <div class="field"> <%= f.label :name %><br /> <%= f.text_field :name %> </div> <div class="field"> <%= f.label :price %><br /> <%= f.text_field :price %> </div> <div class="field"> <%= f.label :category_id %><br /> <%= f.collection_select :category_id, Category.order("name"), :id, :name %> </div> <div class="field"> <%= f.check_box :discontinued %> <%= f.label :discontinued %> </div> <% end %> <% end %> <div class="actions"> <%= submit_tag "Update" %> </div> <% end %>
If we reload the list page now, select a couple of products then click the button we’ll be taken to our new form.
Note that all the fields for the selected products are filled in. When we submit this form we want the products to be updated with the changes we’ve made. If we look at the source for the page we can see how the form is submitted by looking at the field names. Each form field has a name based on the id
of the product and the attribute, for example products[1][name]
. This means that the products
parameter is submitted as a hash, with a key of the product’s id
and a value containing the attributes that are sent through the form. We’ll handle the submission in the ProductsController
. Instead of using update_all
we’ll use update
this time. This accepts two arguments, the first being an array of id
s and the second an array of attributes which map to these id
s. We can fetch both of these from the products
params.
def update_multiple Product.update(params[:products].keys, params[:products].values) redirect_to products_path end
If we make changes to some of the fields in the form now and submit it the products will be updated.
Our form works but we still have to get validations working. If we submit invalid data we want the form to be displayed again with error messages visible so that we can fix them. Our Product
model doesn’t currently have any validations so we’ll add one for the price
attribute to check that it’s a number.
class Product < ActiveRecord::Base belongs_to :category attr_accessible :name, :price, :discontinued, :category_id validates_numericality_of :price end
In the controller we need to change the way our update_multiple
action works so that it redisplays the form when there are validation errors. To do that we need to understand how Product.update
works. This method returns all the products whether they were updated or not. We’ll get these and use reject!
to filter the list so that we’re left with only those with errors. If this filtered list is empty we know that all the products are valid and we can redirect back to the list. Otherwise we’ll display the form again, but with only the invalid products showing.
def update_multiple @products = Product.update(params[:products].keys, params[:products].values) @products.reject! { |p| p.errors.empty? } if @products.empty? redirect_to products_path else render "edit_multiple" end end
If we select a couple of products now and modify them, but make the price blank for one of them when we submit the form we’re redirected back to it with just the invalid product’s fields showing and an error message is displayed.
This solution is pretty much complete. We can now edit multiple products in a single form and we have validations working, too.
Using a Single Set of Fields
Our third approach gives us the ability to edit multiple products using a single set of fields. We’ll start by modifying our edit_multiple
template and move our fields_for
call outside of the products loop. This means that we’ll need to remove any mention of the product in the code that we’ve moved so we’ll change the argument that we pass to fields_for
to :product
so that the fields are nested in a product hash. We’ll also remove the validations for now.
<h1>Edit Checked Products</h1> <%= form_tag update_multiple_products_path, method: :put do %> <ul> <% @products.each do |product| %> <li> <%= hidden_field_tag "product_ids[]", product.id %> <%= product.name %> </li> <% end %> </ul> <%= fields_for :product do |f| %> <div class="field"> <%= f.label :name %><br /> <%= f.text_field :name %> </div> <div class="field"> <%= f.label :price %><br /> <%= f.text_field :price %> </div> <div class="field"> <%= f.label :category_id %><br /> <%= f.collection_select :category_id, Category.order("name"), :id, :name %> </div> <div class="field"> <%= f.check_box :discontinued %> <%= f.label :discontinued %> </div> <% end %> <div class="actions"> <%= submit_tag "Update" %> </div> <% end %>
Note that we’re displaying a list of all the products that are being edited in an unordered list at the top of the page. The changes we’ve made mean that we’ll lose the id
s of the products that we’re updating so we’ve added them back in hidden fields using the same loop we use to show the names.
We can try this out now. If we select a couple of products from the list and click “Edit Checked” we’re now taken to a form that shows a single set of fields and a list of the products that we’re changing.
What we want to do next is have the ability to set any fields here and have all the relevant products update when we submit the form. We have a problem, though. Some fields will be changed even if we don’t want them to be, such as the category. We don’t have a way to select an option that will leave all the fields unchanged and there’s a similar problem with the “discontinued” checkbox. We’ll fix this by changing “discontinued” to a select menu and adding a blank option to it and to the category field.
<div class="field"> <%= f.label :category_id %><br /> <%= f.collection_select :category_id, Category.order("name"), :id, :name, include_blank: true %> </div> <div class="field"> <%= f.label :discontinued %> <%= f.select :discontinued, [["Yes", true], ["No", false]], include_blank: true %> </div>
We now have the ability to select a blank option for the “category” and “discontinued” fields that we can use when we don’t want to update those fields for the selected products. We’ll need to modify our update_multiple
action and Product.update
won’t do what we want to do now. Instead we’ll fetch the products that match the id
s and then in our reject
call we’ll update the non-blank attributes for each product. We do this by calling reject
on the form’s params
and rejecting the blank ones.
def update_multiple @products = Product.find(params[:product_ids]) @products.reject! do |product| product.update_attributes(params[:product].reject { |k,v| v.blank? }) end if @products.empty? redirect_to :products_url else render "edit_multiple" end end
If the validations fail for any of the products then update_attributes
will return false
which will cause the product not to be rejected from the @products
array. This means that the array will not be empty and the form will be redisplayed. We removed the error message code from the template earlier but we’ll replace it now with some code that will display the errors for the selected products where we list them out at the top of the page.
<ul> <% @products.each do |product| %> <li> <%= hidden_field_tag "product_ids[]", product.id %> <%= product.name %> <ul class="errors"> <% product.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </li> <% end %> </ul>
If we enter an invalid value in one of the form fields now then click “Update” we’ll be taken back to the form with an error message showing for each product.
This works, but we’ve lost the data that we entered in the form. It would be better if it persisted. We can fix this by setting an instance variable called @product
to a new Product
and passing it the parameters from the form.
if @products.empty? redirect_to :products_url else @product = Product.new(params[:product]) render "edit_multiple" end
The call to fields_for
in the template will pick up this variable and use it to populate the fields.
Relative Values
We’ll finish off by showing one more trick. If we’re editing multiple records through a single field we might want the ability to enter new relative values for the fields. For example we might want to put a number of products on sale and reduce their price by 20% by entering “-20%” in the price field. We can do this by using virtual attributes, which were covered back in episode 16. We’ll create a virtual attribute called price_modification
in the Product
model.
class Product < ActiveRecord::Base belongs_to :category attr_accessible :name, :price, :discontinued, :category_id, :price_modification validates_numericality_of :price attr_reader :price_modification def price_modification=(new_price) @price_modification = new_price if new_price.to_s.ends_with? "%" self.price += (price * (new_price.to_d/100)).round(2) else self.price = new_price end end end
When we set this field we’ll also set the price, depending on whether or not the entered value ends with a percent sign. If it does we’ll modify price based on the percentage that was entered and if not we’ll set the value that was entered. We’ll also need to adjust our template so that it uses our virtual attribute.
<div class="field"> <%= f.label :price_modification %><br /> <%= f.text_field :price_modification %> </div>
We can now enter absolute or relative prices to modify products’ prices.