Topic: Editing Multiple Models in One Form

In the previous article I showed you how to create a project and a task in the same form. In this article I will show you how to edit a project and all of its tasks in one form.

Let's jump right in. The edit action in the controller is simple enough:

# in projects_controller.rb
def edit
  @project = Project.find(params[:id])
end

The real complexity is in the view. The form needs to loop through @project.tasks and display the fields for each. This will be a problem because each field must have a unique name. How do we accomplish this? We can just append the id of the task to the field name.

This brings us to another problem: appending the id of the model makes it difficult to work with form helper methods because they require an instance variable to exist with the same name as the field model. We don't want to create @task_1, @task_2, etc. instance variables, so that's not going to work for us.

Have you ever heard of the form_for method? It allows you to specify the model when you set up the form so you don't have to specify it for each helper method. The real reason we want to use this is it allows us to specify the name and the instance of the model so  we don't have to worry about making the names match.

This brings us to yet another problem, we can't use form_for because that creates a <form> element and we want this all to exist in one form. It just so happens that form_for has a cousin method: fields_for. This works just like form_for but doesn't generate form element. That's exactly what we need!

Phew, I'm done talking. Time to show some code:

# in projects/edit.rhtml
<h1>Edit Project</h1>

<%= error_messages_for :project %>

<%= start_form_tag :action => 'update', :id => params[:id] %>
<p>
  Project Name:
  <%= text_field :project, :name %>
</p>

<h2>Tasks</h2>
<% for @task in @project.tasks %>
  <%= error_messages_for :task %>
  <% fields_for "task[]" do |f| %>
    <p><%= f.text_field :name %></p>
  <% end %>
<% end %>

<p><%= submit_tag 'Update' %></p>
<%= end_form_tag %>


Rails is smart enough to automatically place the id inside the task[] hash for each field, so the resulting html output will look like this for the name field of one task:

<p><input id="task_1_name" name="task[1][name]" size="30" type="text" value="thing" /></p>

You probably notice I snuck the error_messages_for :task in that task loop. This is good enough for development, but is kind of ugly. Before moving onto production I recommend cleaning this up and manually looping through the error messages and displaying them however you like.

Now, on to the update action. Let's summarize what this action must accomplish: update the project and all task attributes, validate the project and all tasks, and save the project and all tasks if valid. Sounds like things can get pretty complex, so let's take it one step at a time.


1. Update the project attributes

This is easy enough, just grab the project and set the attributes. We don't want to use the update_attributes method here because we don't want to save the project (the tasks might be invalid).

@project = Project.find(params[:id])
@project.attributes = params[:project]

2. Update each task attributes

We need to loop through each task and update its attributes, just like we did for the project itself.

@project.tasks.each { |t| t.attributes = params[:task][t.id.to_s] }

3. Validate the project and all tasks

We can use the all? enumerable method to help us out in this validation (along with the Symbol#to_proc hack to make it a little more concise).

if @project.valid? && @project.tasks.all?(&:valid?)

4. Save the project and all tasks

Just loop through the tasks and call save! on each one.

@project.save!
@project.tasks.each(&:save!)

The end result looks like this:

# in projects_controller.rb
def update
  @project = Project.find(params[:id])
  @project.attributes = params[:project]
  @project.tasks.each { |t| t.attributes = params[:task][t.id.to_s] }
  if @project.valid? && @project.tasks.all?(&:valid?)
    @project.save!
    @project.tasks.each(&:save!)
    redirect_to :action => 'show', :id => @project
  else
    render :action => 'edit'
  end
end

That's a large chunk of code to take in, but if you just look at each line individually, it's not too bad. If someone knows a better way to handle this, please reply to this thread as I would love to know.

That finishes this tutorial. Next time I will discuss creating multiple models in one form (more than two).

Last edited by ryanb (2006-11-08 12:36:45)

Railscasts - Free Ruby on Rails Screencasts

Re: Editing Multiple Models in One Form

Thanks again Ryan

Re: Editing Multiple Models in One Form

I used this method to successfully edit two models in my app--but how would one go about adding a third model?--AND, what if that third model was called with a collection_select method?


Here's a snippet from my _form.rhtml partial:

<% for @conversation in @memory.conversations %>
<p><label for="conversation_message">Message:</label><br />
    <%= collection_select('author', 'id', @authors, 'id', 'name') %>
    <% fields_for "conversation_#{@conversation.id}", @conversation do |f| %>
    <%= f.text_field :message %></p>
    <% end %>
<% end %>

As you can see, instead of projects, I have memories.  Instead of tasks, I have conversations.  Conversations belongs_to memory and belongs_to author (author has_many conversations, memory has_many conversations)


In my memories_controller, i've got this:

  def edit
    @memory = Memory.find(params[:id])
    @authors = Author.find_all
  end

Whatcha think? Thanks for putting up these articles--they are great middle ground for the huge amount of RoR Newbies like me.

Last edited by darrenemo (2006-11-06 05:04:57)

Re: Editing Multiple Models in One Form

Here you aren't actuallly editing the Author model. Instead, you are selecting which author goes with the conversation. In other words, you are setting the author_id attribute in the conversation model. You can do it like this:

<% for @conversation in @memory.conversations %>
<p><label for="conversation_message">Message:</label><br />
  <% fields_for "conversation_#{@conversation.id}", @conversation do |f| %>
    <%= f.collection_select(:author_id, @authors, :id, :name) %>
    <%= f.text_field :message %></p>
  <% end %>
<% end %>

Does that work for you?

Railscasts - Free Ruby on Rails Screencasts

Re: Editing Multiple Models in One Form

Thanks for the reply--ill try it out later

Re: Editing Multiple Models in One Form

Hi,

I've found a small issue with the solution below: if any task is not valid, the "all" method stops calling "valid?" for the next tasks.

Orginal:

if @project.valid? && @project.tasks.all?(&:valid?)

My suggestion to show errors of all tasks:
if @project.valid? && !project.tasks.any?{|h| !h.valid?}

This code can be improved, feel free to suggest.

Marcus Derencius
www.taoweb.com.br

Re: Editing Multiple Models in One Form

Good catch taoweb! However, won't your suggestion still have the same problem? I'm assuming any? will stop on the first true that is returned. I haven't tested it though.

To solve this I suppose it can be split into two lines:

@project.tasks.each(&:valid?) # run the validations
if @project.valid? && @project.tasks.all? { |t| t.errors.empty? }

I'm sure there's a better solution, but it's not coming to me at the moment.

Railscasts - Free Ruby on Rails Screencasts

Re: Editing Multiple Models in One Form

ryanb wrote:

Does that work for you?

Yes! Thanks dude

Re: Editing Multiple Models in One Form

Rails actually has this functionality built in already. Note the [] in the form_for call:

<%
conversation = Conversation.find(21) # could be assigned e.g. in a loop
form_for "conversation[]", conversation do |f| %>
<%= f.text_field :message %>
<% end %>

leads to

<input name="conversation[21][message]" ...>

This works the same with fields_for as well

Re: Editing Multiple Models in One Form

GarethAdams wrote:

Rails actually has this functionality built in already. Note the [] in the form_for call:

<%
conversation = Conversation.find(21) # could be assigned e.g. in a loop
form_for "conversation[]", conversation do |f| %>
<%= f.text_field :message %>
<% end %>

leads to

<input name="conversation[21][message]" ...>

This works the same with fields_for as well

Cool, I never knew this! I'll update the tutorial. Thanks.

Railscasts - Free Ruby on Rails Screencasts

Re: Editing Multiple Models in One Form

Ive started my project over and for the life of me, can't get the update method to work! I've looked over the code at least 20 times, comparing it with the tutorial--no dice. whats up?

#memories_controller.rb
def edit
    @memory = Memory.find(params[:id])
    @authors = Author.find(:all)
  end

   def update
     @memory = Memory.find(params[:id])
     @memory.attributes = params[:memory]
     @memory.conversations.each { |t| t.attributes = params[:conversation][t.id.to_s] }
     if @memory.valid? && @memory.conversations.all?(&:valid?)
       @memory.save!
       @memory.conversations.each(&:save!)
       redirect_to :action => 'show', :id => @memory
     else
       render :action => 'edit'
     end
   end


#edit.rhtml
<h1>Editing memory</h1>

<%= error_messages_for :memory %>

<%= start_form_tag :action => 'update', :id => params[:id] %>

<p>
    Memory Title:
    <%= text_field :memory, :name %>
</p>

<p>
    <%= date_select :memory, :memory_date %>
</p>

<% for @conversation in @memory.conversations %>
<p><label for="conversation_message">Message:</label><br />
   <% fields_for "conversation_#{@conversation.id}", @conversation do |f| %>
     <%= f.collection_select(:author_id, @authors, :id, :name) %>
     <%= f.text_field :message %></p>
   <% end %>
<% end %>



<p><%= submit_tag 'Update' %></p>
<%= end_form_tag %>


What gives?

Last edited by darrenemo (2006-11-12 00:19:21)

Re: Editing Multiple Models in One Form

nevermind, i found the update method from my previous app:

@memory = Memory.find(params[:id])
     @memory.attributes = params[:memory]
     @memory.conversations.each { |t| t.attributes = params["conversation_#{t.id}"] }
     if @memory.valid? && @memory.conversations.all?(&:valid?)
       @memory.save!
       @memory.conversations.each(&:save!)
       redirect_to :action => 'show', :id => @memory
     else
       render :action => 'edit'
     end
   end

Re: Editing Multiple Models in One Form

So everything is going good--i'm creating and editing memories, but my new.rhtml will also save conversations even if the message is blank.  (each conversation has an author (conversation.author_id and a conversation.message)

how can I edit the

#memories.controller.rb
@memory.conversations.build(conversation) unless conversation.values.all?(&:blank?)

to NOT save the conversation if the message is blank, even if an author is selected? (the authors are loaded from a select menu)

would it be something like:

@memory.conversations.build(conversation) unless @memory.conversations(params[:message]).blank?

I've tried that, its still not working.  Can anyone shed some light on what I need to put, how do I call the conversation.message variable each time its ran through the loop?

Heres the full method:

 def create
      @authors = Author.find(:all)
      @memory = Memory.new(params[:memory])
      params[:conversations].each_value do |conversation|
         @memory.conversations.build(conversation) unless conversation.values.all?(&:blank?)
      end
      if @memory.save
        redirect_to :action => 'index'
      else
        render :action => 'new'
      end
    end

Also, it seems as if my controller is completely ignoring the unless statement no matter what--it saves every conversation i throw at it!

Last edited by darrenemo (2006-11-13 17:37:03)

Re: Editing Multiple Models in One Form

Anyone got any ideas?

Re: Editing Multiple Models in One Form

Sorry I've been away. I'm still planning on writing another tutorial for creating and editing models in the same form, but I haven't gotten around to it yet. Hopefully I'll have something up in a couple days if things go well.

Railscasts - Free Ruby on Rails Screencasts

Re: Editing Multiple Models in One Form

Oh its all good Ryan--youve been such a great help so far though man.  But what i'm asking has to do with the article up there, more specifically, the 'unless...' snippet

I just want to know how I can extract a column from the second model (@project.task.name) to use for comparison instead of values.all--but in my case what i need is @memory.conversation.message.. would you know to get that value each time it loops?

   1.  def create
   2.       @authors = Author.find(:all)
   3.       @memory = Memory.new(params[:memory])
   4.       params[:conversations].each_value do |conversation|
   5.          @memory.conversations.build(conversation) unless conversation.values.all?(&:blank?)
   6.       end
   7.       if @memory.save
   8.         redirect_to :action => 'index'
   9.       else
  10.         render :action => 'new'
  11.       end
  12.     end

ive tried unless @memory.conversations(params[:message]).blank?

but that didn't work.



Anyways if you get a chance id appreciate the tip--this is very basic..just need to know how to get the variable.

Thanks for all your hard work ryan!

Re: Editing Multiple Models in One Form

Try unless conversation[:message].blank?

Re: Editing Multiple Models in One Form

This is cool, but how would you write a test for a controller action that updates multiple models? In other words, in an integration test, what would go in this spot:

post_via_redirect 'memory/update', { ? form elements for multiple models ?}

Re: Editing Multiple Models in One Form

Take a look at the development log and see the parameters that are being passed to the action, this is what you should supply for your test. Something like this:

post 'update', :project => { :name => 'foo' }, :task => { '1' => { :name => 'bar' }, '2' => { :name => 'foobar' } }

Railscasts - Free Ruby on Rails Screencasts

Re: Editing Multiple Models in One Form

Great Tutorial!

I've seen examples where the index of existing elements use the id of that element, and the index of new elements are negative. I'm trying to adapt your writeup to utilize this schema, as it would solve some update issues.

-George

Last edited by harking (2007-01-29 13:23:12)