-
-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Nested forms with polymorphic association in Active Admin Formtastic
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:
- The automagic of Formtastic can’t detect the collection if it’s a polymorphic association
- 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
- domains and service can share the same id and
- 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
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
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
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