Rails 3.2: A Nested-Form Demo, Part 1: All Wings Report In!
Background
I’ve been working on a Rails 3.2 project for the last several months. While it’s been a blast to learn a new technology stack (I’ve spent most of my career working with .NET), there have definitely been some bumps in the road. In particular, I’ve spent the last couple weeks working on persisting a model that has a parent-child relationship. The bulk of that time was spent poking around the inter-webs, looking for tips and tricks to accomplish my goal. During my inter-web search, I couldn’t find a single post (or series of posts) that had a complete example or tutorial on how to persist a model that has a parent/child relationship. My Google-fu might not be as good as some other developers out there, but I searched pretty hard. What I found were bits and pieces scattered about (primarily on my savior, Stackoverflow). After a LOT of trial and error (mostly the latter), I finally have it working. So I thought I’d collect my notes as a series of posts. FYI – this started as one post, but got WAY too long. Even for me.
Objective/Goals
Reading is real neat, and I do a whole lot of it. I tend to learn by doing, so reading only gets me halfway there. To that end, we’ll build a demo application that ultimately addresses the following:
- Allows a user to save a model that has a parent/child relationship.
- The operation will allow us to save the parent object as well as insert/update/delete new child objects that are associated to the parent.
Index
Shown below are links to the other articles in this series:
- Rails 3.2: A Nested-Form Demo, Part 2: Accelerate to Attack Speed!
- Rails 3.2: A Nested-Form Demo, Part 3: We’re Starting Our Attack Run!
- Rails 3.2: A Nested-Form Demo, Part 4: Switch to Targeting Computer!
The Application
Most demo apps I see deal with users, customers, security groups and the like. That’s all well and good, but I thought it’d be fun to work on a demo app about something I like – Star Wars! My favorite aspect of Star Wars is the ships, particularly the fighters. So, let’s build a simple database app that will allow us to maintain data about the fighters in the Star Wars universe. The idea behind the demo application is fairly simple: it’s a Star Wars Starfighter Recognition Guide (albeit a VERY simplified one).
Initially, the concept for our initial application is very simple:
- There are Ships.
- Ships have zero to many Pilots (i.e. characters/personalities who fly the type of ship being viewed).
For example: Garven Dreis, Wedge Antilles, and Biggs Darklighter all fly the T-65 X-Wing.
This application will allow a user to create/edit/delete a Ship
. While creating a Ship
, a user has the option of creating records for the Pilots
that fly the Ship
being created. When editing an existing Ship
, a user has the ability to:
- Create a new
Pilot
. - Edit an existing
Pilot
. - Remove a
Pilot
from the list.
A user can conduct any number of the changes cited above prior to saving. When the form is submitted, we expect Rails/ActiveRecord will do the right thing. It will:
- Destroy the
Pilots
that were removed. - Add the
Pilots
that were created. - Update the
Pilots
that were changed. - Update the
Ship
attributes that were changed.
NOTE: This series of posts assumes some basic knowledge of Ruby and Rails. I highly recommend going through the Rails Tutorial – it’s a great introduction to the technology.
Righty-o – let’s get started.
The Model
For starters, let’s take a look at the domain model we’ll be working with for this demonstration. We’ll keep things simple by having a straightforward parent/child relationship:
Ship
: This will be our “parent” object – it represents some basic information about a starfighter in the Star Wars universe.Pilot
: This will be our “child” object – it represents a person who is rated to fly the starfighter.
Here’s a picture of what our domain model we’ll be working with (thanks, RubyMine!):
Moving on, let’s take a look at the code for each object in our domain model:
app/models/ship.rb:
class Ship < ActiveRecord::Base attr_accessible :armament, :crew, :has_astromech, :name, :speed attr_accessible :pilots_attributes has_many :pilots accepts_nested_attributes_for :pilots, :reject_if => lambda { |attrs| attrs.all? { |key, value| value.blank? } }, :allow_destroy => true #I find your lack of validation disturbing... end
I’d like to call your attention to the attr_accessible :pilots_attributes
– that’ll be important later.
app/models/pilot.rb:
class Pilot < ActiveRecord::Base belongs_to :ship attr_accessible :call_sign, :first_name, :last_name, :ship_id #I find your lack of validation disturbing... end
The accepts_nested_attributes_for
Method
According to the documentation, the accepts_nested_attributes_for
method “Defines an attributes writer for the specified association(s).” What does that mean, exactly? That means that our Ship
model can take in attributes for any of its associated Pilots
and update them. To use accepts_nested_attributes_for
, we point it at one of our associations. Right now, we only have one association – has_many :pilots
– so the choice is pretty easy. Armed with the accepts_nested_attributes_for :pilots
, we can:
- add new
Pilots
to ourShip
. - update our
Ship's
existingPilots.
- there is a special attribute (
_destroy
) that will allow us to mark certainPilots
to be deleted from ourShip
(more on this in a bit).
How accepts_nested_attributes_for
Works
What’s going on behind the scenes (as I understand it, anyway) is this: When you add an accepts_nested_attributes_for
method to your model, a writer is also added to your model. That writer will be named as follows [the name of your association]_attributes
. In our case, since we have a accepts_nested_attributes_for :pilots
method, the writer will be named pilots_attributes
(note the pluralization of Pilot
).
accepts_nested_attributes_for
Options
Notice that our Ship
model also has an attr_accessible :pilots_attributes
accessor set up. This allows us to easily pass our Pilot
data to the pilots_attributes
writer that was created as a result of the accepts_nested_attributes_for
implementation. With the pilots_attributes
in our attr_accessible
list, we won’t get the dreaded “Can’t mass-assign” error.
NOTE: having your accepts_nested_attributes_for
writer available for mass-assignment might not be a good idea for your real-life application. Take careful consideration when adding your model’s attributes to the attr_accessible
list.
Next, we have the :reject_if
option. This option allows us to specify a method (using a symbol or an anonymous method) to determine if a Pilot
record should be built. Whatever code is executed by the :reject_if
option should return true
or false
. In our case, we don’t want to build a Pilot
record if all the attributes are empty. This will prevent ActiveRecord from saving blank/empty rows in our Pilots
table.
Finally, we have the :allow_destroy
option. This option can be set to true
or false
, and it does pretty much what it says. If we have :allow_destroy => true
(as we do in our case), ActiveRecord can delete the Pilots
that have their :_destroy
attribute set to true
.
You can check out the documentation and the available options for the accepts_nested_attributes_for
method here.
What a POST Looks Like From the UI
I realize I’m skipping ahead a bit, ’cause we haven’t even talked about the UI. However, I thought it would be beneficial to see how the data for nested objects (i.e. our Pilots
) is sent across the wire. After watching a couple POSTs, it’s really easy to see what’s going on with all the setup we did on our models above.
Scenario 1 – A User Creates a New Ship With No Pilots
If a user only creates a Ship
and no Pilots
(i.e. the ships#create
method is called on the ships_controller
), the data contained in the POST will look like this (taken and “prettified” a bit from Firebug):
&ship[name]=TIE+Fighter
&ship[crew]=1
&ship[has_astromech]=0
&ship[speed]=100
&ship[armament]=2+laser+cannons
As you can see above, we’re only passing attributes for our Ship
object. The controller will take these parameters and build a new Ship
object. Here’s the SQL that was executed when the controller saved our new Ship
to the database:
INSERT INTO "ships" ("armament", "created_at", "crew", "has_astromech", "name", "speed", "updated_at") VALUES (?, ?, ?, ?, ?, ?, ?) [["armament", "2 laser cannons"], ["created_at", Tue, 15 Jan 2013 19:04:13 UTC +00:00], ["crew", 1], ["has_astromech", false], ["name", "TIE Fighter"], ["speed", 100], ["updated_at", Tue, 15 Jan 2013 19:04:13 UTC +00:00]]
Scenario 2 – A User Creates a New Ship With a Couple Pilots
Now our user is getting a little ambitious, but thanks to the setup we did on our model we’re ready. In this case, a user has created a new Ship
with a couple Pilots
associated. Shown below is a POST that creates a new Ship
with two Pilots
:
&ship[name]=TIE+Fighter
&ship[crew]=1
&ship[has_astromech]=0
&ship[speed]=100
&ship[armament]=2+laser+cannons
&ship[pilots_attributes][1358277455305][first_name]=Cive
&ship[pilots_attributes][1358277455305][last_name]=Rashon
&ship[pilots_attributes][1358277455305][call_sign]=Howlrunner
&ship[pilots_attributes][1358277455305][_destroy]=false
&ship[pilots_attributes][1358277658761][first_name]=Dodson
&ship[pilots_attributes][1358277658761][last_name]=Makraven
&ship[pilots_attributes][1358277658761][call_sign]=Night+Beast
&ship[pilots_attributes][1358277658761][_destroy]=false
Let’s take a look at the pilots_attributes
parameters – they correspond to our :pilots_attributes
accessor. Each set of pilots_attributes
has four attributes grouped by a “unique id” of sorts (we’ll see how the unique id is generated in a future article). In our controller, when we call Ship.new(params[:ship])
, ActiveRecord can build the Pilots
that correspond to the Ship
in the ships#create
method. That’s because we have declared attr_accessible :pilots_attributes
in our Ship
model. We can also see the special _destroy
attribute, which is set to false
in both cases. That means we want to add these Pilots
to our database.
For the sake of completeness, here’s the create
method in the ships_controller
:
app/controllers/ships_controller.rb:
def create @ship = Ship.new(params[:ship]) if @ship.save redirect_to(ships_path, :notice => "The #{ @ship.name } ship has been saved successfully.") else render(:new, :error => @ship.errors) end end
Again, here’s the SQL that was generated by ActiveRecord to save our Ship
and its associated Pilots
:
(0.1ms) begin transaction SQL (0.4ms) INSERT INTO "ships" ("armament", "created_at", "crew", "has_astromech", "name", "speed", "updated_at") VALUES (?, ?, ?, ?, ?, ?, ?) [ ["armament", "2 laser cannons"], ["created_at", Tue, 15 Jan 2013 19:21:33 UTC +00:00], ["crew", 1], ["has_astromech", false], ["name", "TIE Fighter"], ["speed", 100], ["updated_at", Tue, 15 Jan 2013 19:21:33 UTC +00:00] ] SQL (0.7ms) INSERT INTO "pilots" ("call_sign", "created_at", "first_name", "last_name", "ship_id", "updated_at") VALUES (?, ?, ?, ?, ?, ?) [ ["call_sign", "Howlrunner"], ["created_at", Tue, 15 Jan 2013 19:21:33 UTC +00:00], ["first_name", "Cive"], ["last_name", "Rashon"], ["ship_id", 8], ["updated_at", Tue, 15 Jan 2013 19:21:33 UTC +00:00] ] SQL (0.3ms) INSERT INTO "pilots" ("call_sign", "created_at", "first_name", "last_name", "ship_id", "updated_at") VALUES (?, ?, ?, ?, ?, ?) [ ["call_sign", "Night Beast"], ["created_at", Tue, 15 Jan 2013 19:21:33 UTC +00:00], ["first_name", "Dodson"], ["last_name", "Makraven"], ["ship_id", 8], ["updated_at", Tue, 15 Jan 2013 19:21:33 UTC +00:00] ] (2.2ms) commit transaction
Summary: Look at the Size of That Thing!
We’ve barely impacted on the surface of our Starfighter Recognition Guide, but we’ve managed to take a fairly detailed look at how to:
- Establish a parent/child relationship in our model.
- Set up our “parent” model to accept data for any “child” objects, allowing us to save the “parent” and all its “children” at once.
- We’ve also used the Force to look ahead at our UI and the data it will send to our controller when the user submits the form. This peek into the future has exposed some of the internal plumbing that goes on when Rails builds/saves a model with a parent/child relationship.
In our next installment, we’ll take a good long look at the controller and the UI helpers for our Starfighter Recognition Guide. We’ll add some screens that will allow our user to perform CRUD operations on our Ships
and Pilots
.
Stay tuned…
Thanks for your excellent series of articles, Jeff. It was an outstanding explanation of your approach and a comprehensive discussion of your solution! I’m trying to adapt the approach – adding a new nested-attrib record via a modal. I appreciate if you are not in a position to support it, but I wanted to see if you might speculate? I’m getting a “Uncaught TypeError: Cannot read property ‘ownerDocument’ of undefined” in the pilot-adding js code. Since I don’t know exactly how your Javascript works, I’m wondering if this could be:
a) I’m not showing all the fields in the record (I apply defaults to some fields)
b) I am trying to use a select field along with two inputs (is it the same class selector?)
c) I am displaying several ‘pilot tables’ in the view (imagine it’s squadrons nesting ships nesting pilots).
I think the issue occurs when trying to create the new row – any thoughts?
Thanks!
Hi Kevin. First off: thank you so much for the kind words, I’m glad you enjoyed this series! Without seeing the code, it’s a bit difficult to assess exactly what’s happening. However, I have a few thoughts:
It sounds like some elements are trying to be added to a “parent” element that doesn’t exist. This could be during the population of your
select
field. The same thing could be happening you’re storing the default values inhidden
fields and adding them to aform
(or something). Assuming you’re using jQuery, I’d check all yourappend
andappendTo
calls.Is there a lot of AJAX going on? If you’re building several DOM elements via AJAX results (like during the
ajax:success
event), that might be causing your issue. For example, the javascript is trying to append elements to another element that doesn’t exist yet. I tend to screw this up a lot.The debugging tools in the browser (FireBug for Firefox or Chrome’s debugging tools) might be able to provide some insight to where the javascript is throwing the
ownerDocument
exception. Hopefully, the debugger will point to the missing elementAlso, I ran into this article, which might be what you’re running into.
Finally, if you can set up a jsfiddle that re-produces your issue, I can try to take a look and see if I can help identify what’s going on.
I appreciate the reply, Jeff, especially as I didn’t give you much to go on! I’m way new to jQuery and JS using DOM generally, but your response gave me some ideas on how to track down what’s amiss. It’s definitely creating the new nested-attrib, so I am looking into where it’s losing the ‘owner’. I backed out some of my partials to see if it’s the same issue they talk about on Jeuweb, but I’m thinking it’s core to adding the table row.
I’ll continue running that down for now. Thanks again!
Sure thing, Kevin! I’m sorry I couldn’t be more helpful. Issues like these can be pretty tricky to track down. Here are another couple things that might help:
1. Check all your
ids
andclass
es on your jQuery selectors. Maybe you’re trying to add aTR
element to aTABLE
that is misnamed in theaddRow
method somewhere? If I had a nickel for every time I left off a#
or.
on one of my jQuery selectors, well… let’s just say I could retire early to a wee island in the sun. This is unlikely to be your issue, but it’s something that’s tripped me up about a bajillion times.2. You might try changing your
append
and/orappendTo
calls tohtml
. Theoretically, this will show you what’s being rendered to the page. Maybe that’ll shed some light on what’s going on.3. The browser’s debugging tools can be a bit daunting at first, but they really are a godsend. I found this slide presentation to be incredibly helpful (and more than a little entertaining!).
If you can post the code that’s causing you grief somewhere (and I totally understand if you can’t), maybe I can take a look and lend a hand. Best of luck, and once again I’m sorry I couldn’t be more helpful.
So I should have checked this earlier, but I’m using a later version of jquery-rails (3.0.2), which installs jquery 1.10.x. Your app uses 2.1.4, which uses jquery 1.8.3. According to the jquery site, sizzle has been totally re-written, on top of whatever other differences there are between the ie-compat(1.x) and non-ie-compat(2.x-trending) versions of jquery. I’m going to play around with their migration plug-in, replacing the jquery-rails version in gemfile, etc.
Hey there, Kevin – I think I have this sorted out. I grabbed the “nested-form” source from GitHub and upgraded the gems etc. to all the latest and greatest using
bundle update
. After scratching my head for a bit, here’s what I found:The issue is in the
rowBuilder.buildRow()
method (line 87 inships.js
). It looks like theownerDocument
exception is being thrown on line 93, when theTD
elements are being added to theTR
we’re trying to build. I made a couple minor changes to thebuildRow
method, which now looks like this:var rowBuilder = function() {
...
// A private method for building a w/the required data.
var buildRow = function(fields) {
var newRow = row.clone();
// Instead of returning an array of TD elements, we'll add the TD directly to the newRow
// object. This resolves an "ownerDocument undefined" exception thrown w/jQuery 1.10.1.
$(fields).map(function() {
$(this).removeAttr('class');
var td = $('<td/>').append($(this));
td.appendTo(newRow);
//return td;
});//.appendTo(newRow);
return newRow;
}
}();
I’ve also created a JS Fiddle where you can see a stripped-down version of the
buildRow
method. Just click theAdd a Row
button and watch the magic! Feel free to mess with it to your heart’s content.If you’re still having trouble, please don’t hesitate to leave a comment and I’ll do what I can to help out. Thanks for all your patience!
Seriously, I’m the one thanking YOU for your patience! I can’t believe you went to all that trouble – it definitely helps. What I found in going back to jquery 1.8.3 — and what I’m seeing now with your new rowBuilder — is that the script returns the new row without ownerDocument errors as you note. My issue is that, for some reason, it is only picking up the hidden “destroy” field, so it’s only adding one in the new row, and it appears empty because it has a hidden field. I’ve been stepping through the jQuery code, and it *seems* to be the result of not properly reading my modal. As it’s getting ready to collect all of the fields, the selector is set properly, but the context in your code is set to ‘body.modal-open’, while mine is just ‘body’. I’ve backed out everything complex and am just using fields. Will be re-reading the Bootstrap docs to see if there is something I’m doing elsewhere that might be confusing it.
Thanks again for your generous assistance to an RoR newbie!
Kevin
Howdy, Kevin! Sorry for the delayed response. I’m glad you were able to resolve the
ownerDocument
error; now it sounds like you’re tackling a number of other issues. Let’s see if I can help get you pointed in the right direction:…only picking up the hidden destroy field…
Hmm… that’s odd. I tested the code in my previous comment, and it seems to be working. You clearly have the
TABLE
‘s selector set properly, since you’re getting a new row with just a_destroy hidden
field. Here’s what I suggest:1. Make sure you’re passing what you’re expecting to the second parameter of the
rowBuilder.addRow()
method.2. in the
buildRow
method, change this:$(fields).map(function() {
$(this).removeAttr('class');
var td = $('<td/>').append($(this));
td.appendTo(newRow);
});
to this:
$(fields).map(function() {
$(this).removeAttr('class');
var td = $('<td/>').append($(this));
console.log(td);
td.appendTo(newRow);
});
You should be able to see the
TD
elements created in themap
method in the browser’s console. From there, you should be able to drill down into theTD
elements to see what (if anything) is in there. If theTD
elements aren’t being created, I suspect thefields
argument is empty or isn’t an array.…the context in your code is set to ‘body.modal-open’, while mine is just ‘body’…
So… the modal isn’t popping up at all, right? This is probably a dumb question, but are all the bootstrap js files being included? Check to make sure that bootstrap-modal.js is being included.
Also, you might want to check the
app/assets/javascripts/application.js
file and take a look at theadd_fields
method. The last line (line 27 in mine) has the ID of the modalDIV
that contains the data entry fields. In the demo app, the ID is#new-pilot-fields
. When I changed this:$('#new-pilot-fields').modal('show');
to this:
$('#new-pilot-fields-foo').modal('show');
I saw the behavior I think you’re experiencing. That is, the
.modal-open
class was not added to thebody
element. If the ID looks right in theapplication.js add_fields
method, then it might be worthwhile to double-check the ID of your modalDIV
.In other news, I’ve updated part thee of this series with the solution to the
ownerDocument
error you struggled through. I’ve also checked the change into GitHub.I did check all of those. Since I am loading bootstrap through the bootstrap-rails gem, it automagically pulls in all the correct js files. I’m going back through my application layout to see if some things I’m doing there may be interfering with the ‘add pilot’ modal. I found that when I refer directly to the fields like this:
var inputFields = $(‘#new-battalion-fields :input’).not(‘:button’);
…the javascript detaches the fields from the modal and inserts them in the table as expected. I have to adapt this part for the fact that I have the equivalent of 1-5 “ships” per page, each with its own “pilots table”, so adding a pilot currently adds the fields to all of the “ships”. But I should be able to add a counter to the table names, and pass that into the javascript code.
Can’t tell you how cool the framework you created is, though. When I get a bit more working right, I’ll actually post it onto github if you want to take a look!
Kevin
Well, it certainly sounds to me like you’re on the right track! Adding a counter (or some other mechanism) to uniquely identify your tables should do the trick. I’d love to see what you’ve put together, so feel free to post it on GitHb.