Topic: Variable Number of Models in One Form (without AJAX)

This tutorial is based off of Ryan's great tutorial by the same name.

The difference with this one is that we will leave all the index generating magic to the client. If your gonna do it via RJS, the browser already needs JS anyways, so we might as well leverage that to it's full potential. Save your server just that little bit.

In any case, let's get started. I'm going to be using the same project has_many tasks model as Ryan did.

I've created a small Prototype class to help us out here, making it as little work as possible. For those of you who are scared of javascript, like I am, it's not so bad really. Though I wont vouch for my ability... some parts of this class was ripped from Blinksale and took on a few adjustments, hope they dont mind. Not that any of it is really complicated or magic.

[code Javascript]# in public/javascripts/application.js

var RowAdder = Class.create();

RowAdder.prototype = {
 
  lineIndex: -1,

  initialize: function(objectID, lineHTML, replacements) {
    this.lineHTML = lineHTML;
    this.objectID = objectID;
    this.replacements = replacements;
  },

  indexedHTML: function() {
    var t = new Template(this.lineHTML);
    return t.evaluate( $H({id: this.lineIndex--}).merge(this.replacements) );
  },

  addLine: function() {
    new Insertion.Bottom($(this.objectID), this.indexedHTML());
  },

  deleteLine: function(id) {
    Element.remove(id);
  }
};[/code]

Let's just start with the controller.

[code Ruby]# in app/controllers/projects_controller.rb
def new
  @project = Project.new
end

def edit
  @project = Project.find(params[:id])
end[/code]
Now let's setup some partials. We will need two, because I haven't yet figured out a way to use one. Be very careful when looking at these partials - the RowAdder class will be using #{var} syntax for replacements as well, so watch out what is double quoted and what is single quoted. I should probably just change the syntax used for the js template, but that's for another day.

# in app/views/projects/_task_fields.rhtml - the non-js version
<div id="task_<%= index %>">
<% fields_for "tasks[]", task do |f| %>
  <p>
    <%= f.text_field :name %>
    # Remove link will go here
  </p>
<% end %>
</div>

# in app/views/projects/_task_fields_js.rhtml - the js version
# I dont like putting the Task.new here sad
<div id="task_#{id}">
<% fields_for 'tasks[#{id}]', Task.new do |f| %>
  <p>
    <%= f.text_field :name %>
    # 'Remove' link will go here
  </p>
<% end %>
</div>


Now I guess we should use these partials in our views.

# in app/views/projects/new.rhtml
<% form_for :project, :url => { :action => 'create' } do |f| %>
  <p>Name: <%= f.text_field :name %></p>

  <h2>Tasks</h2>

  <div id="tasks">
  </div>
 
  # Our 'Add Another Task' link will go here

  <p><%= submit_tag 'Create Project' %></p>
<% end %>


# in app/views/projects/edit.rhtml
<% form_for :project, :url => { :action => 'update' } do |f| %>
  <p>Name: <%= f.text_field :name %></p>

  <h2>Tasks</h2>

  <div id="tasks">
  <% @project.tasks.each do |task| %>
    <%= render :partial => 'task_fields', :locals => { :task => task } %>
  <% end %>
  </div>
 
  # Our 'Add Another Task' link will go here

  <p><%= submit_tag 'Update Project' %></p>
<% end %>


Now we want to setup our RowAdder object. If the project has no tasks, we will add one row automagically on page load. The RowAdder object accepts 3 parameters. The first one is the id of the element we want to add rows to, in this case, "tasks". The second parameter is the Template, which we will render from our task_fields_js partial. The third is any extra replacements which you want to do on the template, as a Hash. We won't be adding any extra replacements, so we will just pass an empty hash as the last argument.

# in app/views/projects/new.rhtml
<script language="javascript">
  Event.observe(window, 'load', function() {
    task_adder = new RowAdder('tasks', '<%= escape_javascript(render :partial => 'task_fields_js') %>', '$H({})');
    // Automatically add a line because this a new project.
    task_adder.addLine();
  });
</script>


# in app/views/projects/edit.rhtml
<script language="javascript">
  Event.observe(window, 'load', function() {
    task_adder = new RowAdder('tasks', '<%= escape_javascript(render :partial => 'task_fields_js') %>', '$H({})');
    // Add a line only if the project has no tasks
    <% if @project.tasks.empty? %>
    task_adder.addLine();
    <% end %>
  });
</script>


Our javascript takes care of incrementing (well, decrementing) the index for us. New records get a negative index, existing records keep their positive one. You can use this in the controller.

Now all we need to do is add some 'Add New Task' and 'Remove' links and were good to go.

# in app/views/projects/new.rhtml and app/views/project/edit.rhtml
<%= link_to_function 'Add Another Task', 'task_adder.addLine()' %>

# in app/views/projects/_task_fields.rhtml
<%= link_to_function 'Remove', "task_adder.deleteLine('task_#{task.id}')" %>

# in app/views/projects/_task_fields_js.rhtml
# again, beware the quotes, and the quoted out quotes!
<%= link_to_function 'Remove', 'task_adder.deleteLine(\'task_#{id}\')' %>


And that's it for the views, all we need to do is our create and update actions and were finished!

# in app/controllers/projects_controller.rb
def create
  @project = Project.new(params[:project])
  # It's safe to assume that they are all new tasks, this is a new project
  params[:tasks].each_value { |task| @project.tasks.build(task) }
  if @project.save
    redirect_to :action => 'index'
  else
    render :action => 'new'
  end
end

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

  # First, delete records which were 'Removed'
  @project.tasks.reject { |task| params[:tasks].include?(task.id.to_s) }.each { |task| task.destroy } if params[:tasks]
 
  if params[:tasks]
    params[:tasks].each do |id, task|
      # Update existing records first
      if id.to_i > 0
        @project.tasks.find(id.to_i).attributes = task
      else
        # New record
        @project.tasks.build(task)
      end
    end
  end

  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


There you have it. Here's the final code.

# in public/javascripts/application.js
var RowAdder = Class.create();

RowAdder.prototype = {
 
  lineIndex: -1,

  initialize: function(objectID, lineHTML, replacements) {
    this.lineHTML = lineHTML;
    this.objectID = objectID;
    this.replacements = replacements;
  },

  indexedHTML: function() {
    var t = new Template(this.lineHTML);
    return t.evaluate( $H({id: this.lineIndex--}).merge(this.replacements) );
  },

  addLine: function() {
    new Insertion.Bottom($(this.objectID), this.indexedHTML());
  },

  deleteLine: function(id) {
    Element.remove(id);
  }
};


# in app/controllers/projects_controller.rb
def new
  @project = Project.new
end

def create
  @project = Project.new(params[:project])
  # It's safe to assume that they are all new tasks, this is a new project
  params[:tasks].each_value { |task| @project.tasks.build(task) }
  if @project.save
    redirect_to :action => 'index'
  else
    render :action => 'new'
  end
end

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

# Bit fat - someone else can refactor smile
def update
  @project = Project.find(params[:id])
  @project.attributes = params[:project]

  # First, delete records which were 'Removed'
  @project.tasks.reject { |task| params[:tasks].include?(task.id.to_s) }.each { |task| task.destroy } if params[:tasks]
   
  if params[:tasks]
    params[:tasks].each do |id, task|
      # Update existing records first
      if id.to_i > 0
        @project.tasks.find(id.to_i).attributes = task
      else
        # New record
        @project.tasks.build(task)
      end
    end
  end

  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


# in app/views/projects/new.rhtml
<script language="javascript">
  Event.observe(window, 'load', function() {
    task_adder = new RowAdder('tasks', '<%= escape_javascript(render :partial => 'task_fields_js') %>', '$H({})');
    task_adder.addLine();
  });
</script>

<h1>New project</h1>

<%= error_messages_for :project %>

<% form_for :project, :url => { :action => 'create' } do |f| %>
  <p>Name: <%= f.text_field :name %></p>

  <h2>Tasks</h2>

  <div id="tasks">
  </div>
 
  <%= link_to_function 'Add Another Task', 'task_adder.addLine()' %>

  <p><%= submit_tag 'Create Project' %></p>
<% end %>


# in app/views/projects/edit.rhtml
<script language="javascript">
  Event.observe(window, 'load', function() {
    task_adder = new RowAdder('tasks', '<%= escape_javascript(render :partial => 'task_fields_js') %>', '$H({})');
    <% if @project.tasks.empty? %>
    task_adder.addLine();
    <% end %>
  });
</script>

<h1>Editing project</h1>

<%= error_messages_for :project %>

<% form_for :project, :url => { :action => 'update', :id => @project } do |f| %>
  <p>Name: <%= f.text_field :name %></p>

  <h2>Tasks</h2>

  <div id="tasks">
  <% @project.tasks.each do |task| %>
  <%= render :partial => 'task_fields', :locals => { :task => task } %>
  <% end %>
  </div>
 
  <%= link_to_function 'Add Another Task', 'task_adder.addLine()' %>

  <p><%= submit_tag 'Update Project' %></p>
<% end %>


# in app/views/projects/_task_fields.rhtml
<div id="task_<%= task.id %>">
<% fields_for "tasks[]", task do |f| %>
  <p>
  <%= f.text_field :name %>
  <%= link_to_function 'Remove', "task_adder.deleteLine('task_#{task.id}')" %>
  </p>
<% end %>
</div>


# in app/views/projects/_task_fields_js.rhtml
<div id="task_#{id}">
<% fields_for 'tasks[#{id}]', Task.new do |f| %>
  <p>
  <%= f.text_field :name %>
  <%= link_to_function 'Remove', 'task_adder.deleteLine(\'task_#{id}\')' %>
  </p>
<% end %>
</div>


Hope this was useful for you guys.

Re: Variable Number of Models in One Form (without AJAX)

Thanks for posting this. It's great to see variations of my tutorial!

Railscasts - Free Ruby on Rails Screencasts

Re: Variable Number of Models in One Form (without AJAX)

Awesome tutorial nagash smile

vinnie - rails forum admin

Re: Variable Number of Models in One Form (without AJAX)

Thanks guys. The update action is actually broken in some cases, I'll fix it up when I have some free time.

Re: Variable Number of Models in One Form (without AJAX)

Nagash - any chance on an update for this? It's working flawlessly - except for the update. On updated it shows the proper amount of task fields, but they're empty.

Re: Variable Number of Models in One Form (without AJAX)

I made two modifications to get the update code working on my application.  Let me know if this works for anyone else.

In _task_fields.rhtml, change line 2 to:

<% fields_for "tasks[#{task.id}]", task do |f| %>

In projects_controller.rb, change line 33 to:

@project.tasks(:id => id)[0].attributes = task

Hope this helps!

Re: Variable Number of Models in One Form (without AJAX)

This works for me in IE6, but not in firefox.  In firefox everything appears to be working, but the new input fields that show up never get transfered to the params.

Is there something browser specific that is bad here?

Re: Variable Number of Models in One Form (without AJAX)

Just to give you an update.  The problem in firefox is with line 1626 in prototype.  Somehow the javascript in application.js doesn't set an element and so firefox returns

this.element has no properties

this is the line of prototype code:

    if (this.adjacency && this.element.insertAdjacentHTML) {

and the part of application.js that eventually calls that line:

  addLine: function() {
    new Insertion.Bottom($(this.objectID), this.indexedHTML());
  },

it seems to me that somehow we aren't setting the element with the application.js code.

I'll post a fix if I figure it out.

Re: Variable Number of Models in One Form (without AJAX)

Nagash,

I like your goal of reducing load on the server by moving logic to the client.

Did you consider simply creating pure JavaScript that is functionally equivalent to the two RJS files in Ryan's tutorials?  I haven't tried it, but it seems that if you just created two JavaScript methods (remove_task and add_task) that take the same parameters as Ryan's RJS files do, you would only have to change the link_to_remote calls to be equivalent link_to_function calls--all of the rest of Ryan's tutorials would "just work" without changes!

At least I think so--after all, the only thing the RJS files do is create JavaScript to be sent to the client to be executed.  So if all you do is cut out the middle man (the call to the server to return JavaScript) and call JavaScript methods that you've already downloaded, you're golden!

Right?

Ed