Nested forms with polymorphic association in Active Admin Formtastic - activeadmin/activeadmin GitHub Wiki

From my blog post.

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 !@itemizable_domain.blank? && !@itemizable_service.blank?
        errors.add(:itemizable, "can't have both a domain and a service") 
      else
        unless @itemizable_domain.blank?
          self.itemizable = Domain.find(@itemizable_domain)
        end

        unless @itemizable_service.blank?
          self.itemizable = Service.find(@itemizable_service)
        end
      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

Other possibility would be

  ActiveAdmin.register Invoice do
    form do |f|
      # ...
      f.has_many :items do |item|
        item.input :itemizable_identifier, collection: (Domain.all + Service.all).map { |i| [ i.name, "#{i.class.to_s}-#{i.id}"] }
        item.input :quantity
        item.input :price_per_piece
      end

      f.actions
    end
  end
  # app/models/item.rb
  class Item < ActiveRecord::Base

    ...

    belongs_to :itemizable, polymorphic: true

    validates :itemizable, presence: true

    ...

    def itemizable_identifier
      "#{itemizable_type}-#{itemizable_id}" if itemizable_type.present? && itemizable_id.present?
    end

    def itemizable_identifier=(itemizable_data)
      if itemizable_data.present?
        itemizable_data = itemizable_data.split('-')
        self.itemizable_type = itemizable_data[0]
        self.itemizable_id = itemizable_data[1]
      end
    end

Other possibility with a twist and support for UUID

  ActiveAdmin.register Invoice do
    form do |f|
      # ...
      f.has_many :items do |item|
        item.input :itemizable_identifier, collection: (Domain.all + Service.all).map { |i| [ i.name, "#{i.class}-#{i.id}"] }
        item.input :quantity
        item.input :price_per_piece
      end

      f.actions
    end
  end
  # app/models/item.rb
  class Item < ActiveRecord::Base

    ...

    belongs_to :itemizable, polymorphic: true

    validates :itemizable, presence: true

    ...

    attr_accessible :itemizable_identifier

    def itemizable_identifier
      "#{itemizable.class}-#{itemizable.id}"
    end

    def itemizable_identifier=(itemizable_data)
      return unless itemizable_data.present?
      match = itemizable_data.match(/^(?<itemizable_type>Domain|Service)-(?<itemizable_id>.*)$/)
      return unless match
      self.itemizable_id = match[:itemizable_id]
      self.itemizable_type = match[:itemizable_type]
    end

Create polymorphic child with custom form

I needed to create any posible polymorphic one-to-one child, each with it's different sub-form, here's what i came with.

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

    ...

    belongs_to :itemizable, polymorphic: true

    accepts_nested_attributes_for :itemizable

    ITEMIZABLE_TYPES = %w(Domain Service)

    def build_itemizable(params)
      raise "Unknown itemizable_type: #{itemizable_type}" unless ITEMIZABLE_TYPES.include?(itemizable_type)
      self.itemizable = itemizable_type.constantize.new(params)
    end
   # app/assets/stylesheets/active_admin.css.scss
   .polyform {display: none}
   # app/assets/javascripts/active_admin.js.coffee
    ready = ->
      $(".polyselect").on "change", ->
        $('.polyform').hide()
        $('#' + $(@).val() + '_poly').show()
    
      $('.polyform').first().parent('form').on "submit", (e) ->
        $('.polyform').each (index, element) =>
          $e = $(element)
          if $e.css('display') != 'block'
            $e.remove()
    
    $(document).ready(ready)
    $(document).on('page:load', ready)
    # app/admin/item.rb

   permit_params :itemizable_type, :itemizable_id, itemizable_attributes: [:all, :posible, :permitted, :fields]

   form do |f|

   ...

     f.inputs 'Type' do
       f.input :itemizable_type, input_html: {class: 'polyselect'},
         collection: Item::ITEMIZABLE_TYPES
     end
 
     f.inputs 'Domain', for: [:itemizable, f.object.itemizable || Domain.new],
       id: 'Domain_poly', class: 'inputs polyform' do |fc|
       fc.input :this
       fc.input :posible
       fc.input :child
       fc.input :fields
    end

    ...
    end

   controller do
     def create
       @tramo = Tramo.new permitted_params[:tramo]
       if @tramo.save
         redirect_to collection_path
       end
     end
   end