Nested forms with polymorphic association in Active Admin/Formtastic

I spent nearly the whole day making this work.

Given models:
Invoice, has_many :items
Item belongs_to :itemizable, polymorphic: true
Domain & Service has_many :items, as: :itemizable

The problem was multiple things:

  1. The automagic of Formtastic can’t detect the collection if it’s a polymorphic association
  2. Formtastic doesn’t really play well with non-existent attributes

Initially, I’ve thought of just doing:

ActiveAdmin.register Invoice do
  form do |f|    
    # ...
    f.has_many :items do |item|
      item.input :itemizable, collection: (Domain.all + Service.all)
      item.input :quantity
      item.input :price_per_piece
    end

    f.actions
  end
end

But this fails because 1) domains and service can share the same id and 2) I have no way to tell what the item was.
A few hours in and I was going nowhere. It’s surprisingly hard to look for anything related to polymorphic associations on Formtastic. This post gave me an idea however.

So, I’ve thought, why not just hold the id temporarily on an accessor attribute and just do the assignment from a callback before validation kicks in based on which attribute it went into? Raise an error if both were filled up.

It worked! I can now save new polymorphic records. (look at Item#assign_itemizable)

There’s a small problem however. The form to edit an existing record doesn’t pre-populate the corresponding select dropdowns. The solutions was rather simple, override the reader method to return the id of the itemizable if the itemizable is a member of the class.

Maintenance-wise, everything here would add overhead for every new itemizable model I would associate to item, but overall, I think it was a pretty elegant hack. *pats self at back*

Here’s the complete code:

# app/models/invoice.rb
class Invoice < ActiveRecord::Base 
  has_many :items

  accepts_nested_attributes_for :items
end

# app/models/item.rb
class Item < ActiveRecord::Base
  before_validation :assign_itemizable

  belongs_to :invoice
  belongs_to :itemizable, polymorphic: true
  
  validates :itemizable, presence: true

  attr_accessor :itemizable_domain, :itemizable_service

  def itemizable_domain
    self.itemizable.id if self.itemizable.is_a? Domain
  end

  def itemizable_service
    self.itemizable.id if self.itemizable.is_a? Service
  end

  protected
  def assign_itemizable
    if [email protected]_domain.blank? && [email protected]_service.blank?
      errors.add(:itemizable, "can't have both a domain and a service") 
    end

    unless @itemizable_domain.blank?
      self.itemizable = Domain.find(@itemizable_domain)
    end

    unless @itemizable_service.blank?
      self.itemizable = Service.find(@itemizable_service)
    end
  end
end

# app/admin/invoice.rb
ActiveAdmin.register Invoice do
  form do |f|
    f.inputs "Invoice" do 
      f.input :customer
      f.input :invoice_number
      f.input :issuing_person
      f.input :issued_on
      f.input :remarks
    end
    
    f.has_many :items do |item|
      item.input :itemizable_domain, collection: Domain.all
      item.input :itemizable_service, collection: Service.all
      item.input :quantity
      item.input :price_per_piece
    end

    f.actions
  end
end

Edit: Fixed a bug in Item because I overwrote the accessors, which were being called in the validation. This caused a sort of chicken-egg situation, and ultimately causes the validation to fail all the time. Changed .empty? to .blank? so it doesn't barf out on nils.

  • RobWu
    • Rystraum

      Hi Rob! This is the case where we might be sacrificing readability for cleverness and you’re tying the logic into presence of a particular character (in this case “-“) which may become tricky to debug if ever you might forget it. Your solution works though and there will be cases where it’s going to be appropriate (more than 3 things that will be itemizable) and might pose a smaller overhead in the long-term. :)

    • gregg

      Not sure who added the additional approaches but have a question for whoever has. Like the approach as only want one drop down on my active admin form for adding items. However when i update my model (rails 4.x) with the code that is supplied i run into a problem whereby when invoke the create am getting an error “undefined method ‘id’ for nil:NilClass with a pointer to the “{itemizable.class)-#{templatedef.id}” in my items model. Somewhat new to ruby and rails but believe that when we are creating a new instance and this method fires it does not yet have the corresponding class and id of the model that the polymorphic relationship is allowing us to point to. Everything works perfectly for models already created and if i comment out the above referenced lines during the creation of a new model instance all works fine. I have played around with trying to make the above line only fire with a callback reference to a NEW and/or CREATE but have not been succesful. If anyone could provide some assistance here it would be much appreciated.

      Thanks, Gregg

      • Rystraum

        Hi Gregg,

        I didn’t add the additional approaches, but I’ll help you regardless.
        Just to confirm that you’re calling #{itemizable.class.to_s}-#{itemizable.id} and not #{itemizable.class}-#{templatedef.id} as per your comment. Because if templatedef wasn’t defined, then understandably, it would raise a undefined method on nil.

        – Rystraum

        • greggv

          sorry abut that…….i simply cut and pasted from my instance…….you are correct, it should be itemizable in both casues…..#{itemizable.class.to_s}-#{itemizable.id}

          • Rystraum

            Are you getting this error on the collection line? Or on the Item#itemizable_identifier method?

          • GreggV

            The error that i am getting via active admin when i attempt to create a new instance is NoMethodError for new ……UNDEFINED METHOD ‘id’ for nil:NilClass

          • Rystraum

            Hi Gregg,

            You can add a check if itemizable is nil at the top of the itemizable_identifier method.
            Something like the following:

            def itemizable_identifier
            return if itemizable.nil?
            “#{itemizable.class}-#{itemizable.id}”

            end

          • greggv

            Thanks kindly for not only the assistance but for responding so promptly. I had struggled with attempting something similar over the weekend, but was not successful. The code you provided led me down the correct path and resolved my problem. Thanks kindly once again.

            Gregg

          • Rystraum

            No problem. I’m glad to help. :)

          • greggv

            Additional Information

            appears to be pointing to the method and not the collection if i am understanding correctly……

            it is pointing to this line in the error getting back from active admin when attempt to add a new instance

            def itemizable_identifier

            # “{itemizer.class.to_s}-#{itemizer.id}”
            end

            and not at lines of the block starting with the following
            def itemizer_identifier=(itemizer_data)

        • GreggV

          sorry about that….i cut and pasted from my code but my comment should be refrencing itemizable in both cases……so it should read #{itemizable.class.to_s}-#{itemizable.id}

  • juncaleo

    I’ve tried the first example but I get the following error : “uninitialized constant Domain”
    In my proyect I’m trying to use polymorphic asociations between study_group and students.

    Another question: Is “itemizable” related in any way with the model “item” in the example? or is another model?

    -Thanks