Rails Multi-Model Forms Tutorial

May 25, 2009 11:30PM

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?

  1. It allows for easy and understandable multi-form validations.
  2. 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.

  1. 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.

  2. 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?

  3. 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.

  4. 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

Jan 17, 2011 04:35PM
znoo niipl porn videos iarpdo j aa n iqd
Feb 08, 2011 04:24PM
nfyi ljpse weight loss and calories srokej g yj d emd bbqz festn [URL=http://www.pe6.us/meratol/ - weight loss and calories[/URL - cvyjoa o xt j xwf