Rails Multi-Model Forms Tutorial
lets build some trains to go with our rails!
Today I've decided to take my best shot at explaining the Ruby on Rails multi-model form. This is an important skill for any Rails enthusiast to have a good grasp on, and is one that personally took me a while to get. The technique I'll be using is described very well in the Railscasts complex forms episode, and is another excellent resource for learning this technique.
Why is this an extremely important technique?
- It allows for easy and understandable multi-form validations.
- It gives us skinny controllers and fat models.
Project Setup
Define a Train model, which will be comprised of many Cars. Our join model will be TrainCar. To do this from the terminal, type:
>> ruby ./script/generate scaffold Train
>> ruby ./script/generate model Car
>> ruby ./script/generate model TrainCar
This example is good because any Train can be comprised of any number of cars at any given time. Also notice the `position` column on the train_cars table. This will let us order the cars in our train.
class Train < ActiveRecord::Base
has_many :train_cars
has_many :cars, :through => :train_cars, :order => "train_cars.`position` ASC"
end
class Car < ActiveRecord::Base
has_many :train_cars
has_many :trains, :through => :train_cars, :order => "train_cars.`position` ASC"
end
class TrainCar < ActiveRecord::Base
belongs_to :train
belongs_to :car
end
In the migrations:
create_table :trains do |t|
t.column :name, :string
t.timestamps
end
create_table :cars do |t|
t.column :name, :string
t.timestamps
end
create_table :train_cars do |t|
t.column :car_id, :integer
t.column :train_id, :integer
t.column :position, :integer
t.timestamps
end
Migrate your database:
>> rake db:migrate
Define Attribute Accessors
Your previous attempts at multi-model forms may have included attempting to build your multi-model associations from the passed hash within the controller. We're going to DRY up that method a significant amount by off loading all of the model association building to where they originally belonged: the model.
Open up your train model, and add the following code:
class Train < ActiveRecord::Base
has_many :train_cars
has_many :cars, :through => :train_cars, :order => "train_cars.`position` ASC"
after_update :save_train_cars
def new_train_car_attributes=(train_car_attributes)
train_car_attributes.each_pair do |object_id,attributes|
train_cars.build(attributes)
end
end
def existing_train_car_attributes=(train_car_attributes)
train_cars.reject(&:new_record?).each do |train_car|
attributes = train_car_attributes[train_car.id.to_s]
if attributes
train_car.attributes = attributes
else
train_cars.delete(train_car)
end
end
end
def save_train_cars
train_cars.each do |train_car|
train_car.save(false)
end
end
end
This does exactly what you might think. Now, your train model can handle the attributes passed from our controller hash params[:train][:new_train_car_attributes] and params[:train][:existing_train_car_attributes]. Additionally, after the update completes, those changes are saved to the database through the `save_train_cars` method.
Define our Helper Method
The helper method we're going to define here is going to be used to get data into those `new_train_car_attributes` and `existing_train_car_attributes` methods that we just defined in the Train model.
Open up app/helpers/trains_helper.rb
module TrainsHelper
def fields_for_train_car(train_car, &block)
if train_car.new_record?
train_car_id = ((train_car.object_id > 0)? train_car.object_id * -1 : train_car.object_id)
fields_for("train[new_train_car_attributes][#{train_car_id}]", train_car, &block)
else
fields_for("train[existing_train_car_attributes][]", train_car, &block)
end
end
end
If you pass the `fields_for_train_car` helper a TrainCar object, and it will determine whether it is a new or existing TrainCar (saved in the database or not), and put all of its attributes in the appropriate hash (`new_train_car_attributes` or `existing_train_car_attributes`) to be processed by the Train model.
The Train Controller
Open the controller that was automatically generated by the scaffold at app/controllers/trains_controller.rb.
We're going to leave this file virtually unchanged, accept for the addition of a single line to the `update` method.
def update
@train = Train.find(params[:id])
params[:train][:existing_train_car_attributes] ||= {}
respond_to do |format|
if @train.update_attributes(params[:train])
flash[:notice] = 'Train was successfully updated.'
format.html { redirect_to(@train) }
format.xml { head :ok }
else
format.html { render :action => "edit" }
format.xml { render :xml => @train.errors, :status => :unprocessable_entity }
end
end
end
Adding params[:train][:existing_train_car_attributes] ||= {} means that if there is no container `existing_train_car_attributes` in the `train` hash, create an empty one. This is needed to trigger the `existing_train_car_attributes` method within the Train model if all cars have been removed from the train. Since the hash contains no elements, it would otherwise not fire, and not remove all of the cars from the train as was intended.
The Edit View
The final piece of our puzzle will be the actual view that we'll be using to editing the multi-model form. We will be using an AJAX approach for adding and removing elements, so don't forget to include Prototype/Scriptaculous to your layout file.
- In edit.html.erb and new.html.erb add:
<% form_for(@train) do |f| %>
...
<%= render :partial => 'form', :locals => { :f => f, :train => @train} %>
...
<% end %>
This will take care of both the 'new' and 'edit' views with our _form.html.erb partial.
- Create the file app/views/trains/_form.html.erb, add:
<% content_tag("ul") do %> <%= content_tag("dl", f.label(:name) ) %> <%= content_tag("dd", f.text_field(:name) ) %> <% end %> <% content_tag("fieldset") do %> <% content_tag("legend") do %> Cars <%= select_tag("car_id", options_for_select( Car.find(:all).collect { |i| [i.name,i.id] } ), { :id => "car_id" } ) %> <%= link_to_function("add", remote_function( :url => { :controller => :trains, :action => :add_train_car, :train_id => train.id }, :with => "'car_id='+$(\"car_id\").options[ $(\"car_id\").options.selectedIndex ].value" ) ) %> <% end %> <% content_tag("div", { :id => "train_cars" }) do %> <% @train.train_cars.each { |train_car| %> <%= render :partial => 'train_car', :locals => { :train_car => train_car } %> <% } %> <% end %> <% end %>
This is the actual partial. All of our train cars will go into the DIV#train_cars container. The select_tag contains all possible trains for us to add. The add button summons our add_train_car.rjs file to plunk the trains down into our DIV#train_cars container. Comprende?
- Create the file app/views/trains/add_train_car.rjs, add:
train_car = TrainCar.new(:train_id => params[:train_id], :car_id => params[:car_id]) if train_car.valid? page.insert_html :bottom, :train_cars,:partial => 'train_car', :locals => {:train_car => train_car} end
The add_train_car RJS file will link up the train and the car, and place it at the bottom of DIV#train_cars. Please note that nothing has been saved to the database yet.
- Create the file app/views/trains/_train_car.html.erb, add:
<% content_tag("div", :id => "train_car_#{train_car.object_id}") do %> <% fields_for_train_car(train_car) do |f| %> <%= f.hidden_field(:car_id) %> <%= f.text_field(:position) %> <%= train_car.car.name %> <%= link_to_function("remove", "$('train_car_#{train_car.object_id}').remove()") %> <% end %> <% end %>
Once the `add` button has been pressed, this partial will be rendered into the DIV#train_cars container. Also note that the `remove` button will remove this train car, but changes will only be saved once the `update` button has been pressed.
That's a lot of code! Hopefully it all makes sense to you. It's all been tested to work in the Rails 2.3.2 environment with Ruby 1.9.1.
-Butch
Name:
Website: