Dynamically add nested forms to an ActiveModel::Model form
Last week we talked about how to create nested records using an ActiveModel::Model form. The problem with the end result of my example is that we could only add a single Contact record, whereas we need to be able to add arbitrarily many. What we need here is a link or a button that will add the fields for another Contact whenever clicked.
There is a great Railscast devoted to this, and it almost works for us except for one detail. Check out the first line of the link_to_add_fields
function:
def link_to_add_fields(name, f, association)
new_object = f.object.class.reflect_on_association(association).klass.new
fields = f.fields_for(association, new_object, :child_index => "new_#{association}") do |builder|
render(association.to_s.singularize + "_fields", :f => builder)
end
link_to_function(name, "add_fields(this, \"#{association}\", \"#{escape_javascript(fields)}\")")
end
ActiveModel::Model classes don't have associations and they don't have reflections. So once again, we'll have to add some stuff to our class to fake out the behavior. It looks like we just need it to return an object that has the klass
method defined where calling this method will return the class of the association.
require 'ostruct'
class ContactListForm
include ActiveModel::Model
attr_accessor :contacts
def self.association association, klass
@@attributes ||= {}
@@attributes[association] = klass
end
association :contacts, Contact
def self.reflect_on_association(association)
data = { klass: @@attributes[association] }
OpenStruct.new data
end
.....
end
What we did here is pretty simple. We define a class method self.association
so that we can register classnames for our associations into a class variable. Then we define self.reflect_on_associations
to create an OpenStruct
object whose klass
method simply looks in its @@attributes
hash for the passed in association name and returns the class that we registered.
With this change, we can use Ryan's technique verbatim to create this link on our form.
Thanks for reading, and hopefully this helps at least a few of you.
Written by Sam Slotsky
Related protips
12 Responses
Can I use ActiveRecord for the master entity and ActiveModel for the detail entity? I am trying to save the data for the detail into a text column in the master table itself.
@geordee I'm not sure what your use case is but this doesn't sound very clean. If you can make that input part of the form for the master entity that would make a whole lot more sense.
Having said that, in general you probably could use ActiveModel as a detail entity if there were a good use case for it. You'd probably have to define the detail entity as a virtual attribute on your ActiveRecord model and maybe add a before_save hook that validates your detail entity if it's present. But this doesn't sound all that clean either. Maybe understanding your use case would be helpful.
Say, I have an invoice model and multiple taxes attached to it. It may not be too many (unlike invoiceitems), so I was thinking of avoiding the invoicetaxes table and the join thereof. I was thinking of leveraging RyanB's nestedform for dynamically generating the tax lines. I am already using the gem for invoiceitems, so writing another set of helpers and JavaScripts isn't all that exciting.
So you have multiple taxes for an invoice, but you're not storing tax records in their own table. How do you store them on the invoice then? Do you have a field for each possible type of tax? A single field that stores a JSON string?
The answer for you might be to use an ActiveModel form object for your main object. When it's submitted, you can do whatever calculations you need to with the tax parameters and then create your Invoice.
I intend to store it as a JSON string. I started with ActiveModel form, but the Nested Form was giving errors. In your article association and reflectonassociation are on the ContactList model, right? If I use it that way, other things (invoice items) break.
I think I see what you mean. You're trying to dynamically add tax information using Ryan B's technique, but you don't have a tax class of any kind. In your case you wouldn't even use reflect_on_association
. You would have to do something else to generate the fields you need and add them to the form.
Right. But, since I use RyanB's gem already for invoice items, it would be almost a duplication of logic, and that's how I thought of a hack and came across this article. Then I was wondering, will I be able to convince Rails that there is a hasmany relation and acceptsnestedattributesfor so that nested_form will generate the lines for me.
I see. This sounds pretty difficult, but maybe define a Tax
class with ActiveModel::Model
setup and then your Invoice
model can override self.reflect_on_association
so that when :taxes
is passed in, it will return an object where the klass
method returns your Tax
class (your method will call super
if anything else is passed in). Then you should be able to get your fields in the form by defining attributes in your Tax
class.
How to handle submission would be a different story. You might have to start with an ActiveModel::Model
object that encapsulates the Invoice
and displays it in a f.fields_for :invoice do |invoice_fields|
, and then the taxes would show up as a nested form within invoice_fields
.
I had a feeling that I was looking for the impossible, or impractical one. ActiveRecord Store is another route I took. Only if the nested_form gem could cover some more scenarios.
Thanks for the and it was a great discussion.
Hello,
I didn't need to all this mambo jambo to work with nested_form.
I just submitted an object directly to use as a blueprint to the add link this way:
f.linktoadd 'Add new item', :items, model_object: Item.new
The Item class is a plain Ruby Struct class.
PS: How come your posts are not dated? It's useful, especially when things move so fast like in the Rails community.
Hi @geordee, I'm facing the same scenario which you explained. Did you find out any workaround for it?
thanks to this i could implement the solution. beware that the JavaScript of the screencast is old and faulty.
Also, linktofunction is not available in 4.2 anymore. You have to use link_to with onclick
function remove_fields(link) {
$(link).prev("input[type=hidden]").val("1");
$(link).closest(".fields").hide(); }
function addfields(link, association, content) {
var newid = new Date().getTime();
var regexp = new RegExp("new" + association, "g");
$(link).parent().before(content.replace(regexp, newid));
}