TiC
Sat, Nov 20, 2010
Dynamic Nested Forms in Rails 3

I’ll preface this article with a note about Rails 3 and UJS (Unobtrusive JavaScript). I was skeptical at first, but I have to say I’m really impressed. It turns out that UJS is nothing new, (it’s simply the idea of registering event handlers programmatically, via window.onload and friends, rather than placing your event handlers directly into the HTML code), but Rails does some nice abstraction that lets you get right down to the important stuff.

The code structure is a bit of a change from Rails 2; suddenly the application.js file is way more important, so there’s some getting used-to ahead of you if you’re firmly planted in the Rails 2 way of doing things, but if you approach it with an open mind you’ll probably end up liking the Rails 3 style a lot better.

So, what’s up?

I’ve spent the last few days working on the next version of my WebGL space-based game. Things have gone well overall; Rails’ productivity lends itself well to mission editors and the like, especially when JavaScript and AJAX are heavily employed.

However, there was one issue that I’d been able to skate around fairly easily until tonight: the age-old question of how to handle nested forms dynamically – that is, adding and deleting nested objects without submitting the form in between.

Let me back up a bit and explain what some of my models look like. I’m trying to define the concept of Missions – that is, individual scenarios within the game that make up a single “step” in the storyline. To help drive the storyline, I’m using Messages. A single Mission can have any number of Messages. Finally, a Message is the polymorphic owner of any number of Conditions; once all Conditions are met, the Message is sent to the player. In code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# app/models/mission.rb
class Mission < ActiveRecord::Base
  has_many :messages, :dependent => :destroy
end
# app/models/message.rb
class Message < ActiveRecord::Base
  has_many :conditions, :dependent => :destroy, :as => :owner
  accepts_nested_attributes_for :conditions, :allow_destroy => true
  belongs_to :mission
end
# app/models/condition.rb
class Condition < ActiveRecord::Base
  belongs_to :message, :polymorphic => true
end

Of course, there’s a lot more to each of these models than I’ve shown above. I’ve stripped out the rest of the details because they don’t really relate to this article.

So, the issue is how to allow the user to create or edit a single Message, and while doing so, create or edit any arbitrary number of Conditions on the same page. My previous approach is not viable here because the Message might not exist when the Conditions are being edited; since the Message might not exist, we can’t go creating Conditions willy-nilly or we’ll end up with orphaned database records all over the place.

Let’s take this one step at a time. First, we want to allow the user to edit existing conditions for a Message. So here’s the view for doing that:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- app/views/messages/_form.html.erb -->
<%=form_for @message do |f|%>
  <div class="field">
    <%=f.label :text%>

    <%=f.text_field :text%>
  </div>
  <div id="message_conditions">
    <%f.fields_for :conditions do |nested|%>
      <%=render :partial => 'common/condition', :locals => { :f => nested }%>
    <%end%>
  </div>
  <%=link_to "Add condition", '#', :remote => true, :class => :add_message_condition%>
<%end%>

Take a close look at the form partial for Message, above. First it renders the nested conditions fields, and then there’s a link for adding new conditions. The link has the :remote option, which tells Rails that it’s going to be handled by JavaScript (usually involving AJAX – but as you’ll see, not in this case). There’s also a CSS class assigned to it: add_message_condition. That CSS class is our unobtrusive hook into the event that will be fired when the user clicks on the link.

As far as the form for each Condition goes, that’s delegated to another partial. Here’s the code for that:

1
2
3
4
5
6
7
8
9
10
<!-- app/views/common/_condition.html.erb -->
<%#
  f - form builder for a Condition
%>
<p>
  <%=f.collection_select :constraint_id, ConditionConstraint.all, :id,  :name%>
  

  <%=f.collection_select :requirement_id, ConditionRequirement.all, :id, :name%>
</p>

Note that, in that last partial, it has a couple of collection selects. These are basically unrelated to this article, and are only there to demonstrate that you should, indeed, be rendering a form for a Condition at this stage.

Now we need to handle the “new condition” case, when the user clicks the “Add a condition” link on the form. To accomplish this, we’re going to combine Ruby and JavaScript, so you need to put it in an appropriate place – a layout, or a partial, or some such. This is the meat of the code, and is responsible for creating the new Condition when the link is pressed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
function newCondition(owner_name) {
  var basic = "<%=escape_javascript(render :partial => "common/condition",
                                           :locals => {
                                             :f => ActionView::Helpers::FormBuilder.new(
                                               '__owner__[conditions_attributes][__index__]',
                                               Condition.new, self, {}, proc {})
                                           })%>";
  var index = new Date() * 1 + parseInt(Math.random()*100000);
  while (basic.indexOf("__owner__") != -1) basic = basic.replace("__owner__", owner_name);
  while (basic.indexOf("__index__") != -1) basic = basic.replace("__index__", index);
  return basic;
}
</script>

Whoo! Hope that wasn’t too complicated. If you read it carefully, you’ll see there really isn’t all that much magic going on:

  • First we render the same partial used to render the existing Conditions. Unfortunately, since we don’t have a Form Builder object, we have to create our own, and that is a contributing factor to the ugliness of this approach. We’re going to directly instantiate ActionView::Helpers::FormBuilder with a few special values:

    • __owner__ - this value is a placeholder for the object which holds the nested association. Remember, Message is polymorphic; that means, in my particular case, I had to handle the possibility that other “owners” (other than Message) might be trying to embed Conditions – for instance, mission objectives will have their own set of Conditions.

    • __index__ - this is an array index used to keep track of which condition is which. Rails numbers these sequentially, but we have no convenient way of doing this from JavaScript, so we use a random number combined with the current date in seconds to create unique keys.

  • The rendered partial is escaped using escape_javascript to make it easy to embed directly into our JavaScript code.

  • After the appropriate replacements are done, we return the HTML as a String.

The only thing left before testing this out is to hook up our event listener to intercept the mouse click. If you’re using jQuery, then there are a lot of examples on the Net about how to do this; I found it depressingly difficult, however, to find such an example using Prototype. Therefore, here’s how to do it using the latter:

1
2
3
4
document.on('ajax:before', 'a.add_message_condition', function(evt) {
  $("message_conditions").update($("message_conditions").innerHTML + newCondition("message"));
  evt.stop();
});

Add that to your application.js file, or to the same section of code that you placed the newCondition function into.

This event handler basically says that whenever the user clicks any link with the “add_message_condition” CSS class, before the AJAX call is dispatched, our callback will be fired. Since it’s fired before the AJAX call, we can call #stop on it to abort the AJAX call. Stopping the event is useful in this particular case because we don’t really need to perform the call. In effect, we’ve set up a glorified onclick handler.

The most important part of this callback is the second line: we’re going to update the “message_conditions” DIV with its current content, plus the result of our newCondition function, above. This will have the effect of appending the new condition to the end of the list of conditions.

At this point, we have a nested form that can use JavaScript to dynamically add new Conditions – and we never even had to touch our controller or models.

I hope this all made sense. Clearly, we have not yet reached the holy grail of dynamic nested forms. However, this certainly feels less “hackish” than it did in the past. The UJS approach is a new way of thinking compared to the previous versions of Rails, but as I begin to grasp the nuances of how Rails implements this, I am coming to believe it’s a noteworthy improvement. Naturally, I’ll post more information here as I figure it out myself.

0 comments
Please log in if you wish to leave a comment.