PolyParent is a plugin designed to help DRY up your controllers and views for polymorphic objects. In other words you want both these URLs to map to the same controller action:
/customers/123/phone_numbers/new
/customers/123/locations/321/phone_numbers/new
You start out with a PhoneNumber model that you associate to both your Customer and Location models (through a polymorphic association) with Location nested below Customer like so:
map.resources :customers do |customers|
customers.resources :locations
end
To make both URLs map to the same controller action you modify the above to become:
map.resources :customers do |customers|
customers.resources :phone_numbers
customers.resources :locations do |locations|
locations.resources :phone_numbers
end
end
Now you have an issue though. When processing requests to PhoneNumbersController you have no way of knowing where to attach the new PhoneNumber. How can you avoid havoing two different controller actions doing exactly the same thing? Or even worse, having two different controllers, one dealing with PhoneNumbers associated to Customers and the other to Locations?
Let's look at how the 'normal' controller actions would look.
The following snippet works when the PhoneNumbersController was invoked from a Customer (POST to /customers/123/phone_numbers).
class PhoneNumbersController < ApplicationController
def new
@phone_number = PhoneNumber.new
end
def create
@customer = Customer.find(params[:id])
@customer.phone_numbers.build(params[:phone_number])
if @customer.save
flash[:info] = 'A crazy success! A new PhoneNumber is born!'
redirect_to customer_phone_numbers_path(@customer)
else
flash[:error] = @customer.errors.full_messages.to_sentence
render :action => :new
end
end
end
When accessed from a Location context the create action would look like this:
def create
@location = Location.find(params[:id])
@location.phone_numbers.build(params[:phone_number])
if @location.save
flash[:info] = 'A crazy success! A new PhoneNumber is born!'
redirect_to customer_location_phone_numbers_path(@location.customer, @location)
else
flash[:error] = @location.errors.full_messages.to_sentence
render :action => :new
end
end
The logic is exactly the same but the objects ivolved are different and the paths that need to be generated after the save need different parameters.
Ecce PolyParent.
By analyzing the request path PolyParent extracts the hierarchy of the nested routes and helps you build generic controller actions that work for all cases.
First of all we include the PolyParent module and name the models we wish allow as possible 'parents' of the requests. This allows us to call the controller through other URLs if we need to invoke the it without involving PolyParent.
We call the set_poly_parents in a before_filter that provides us with a @parents instance variable (this is not strictly necessary, but mighty convenient for views. See below):
class PhoneNumbersController < ApplicationController
include PolyParent
parent_resources :location, :customer
before_filter :set_poly_parents
end
Then we proceed by rewriting the controller actions not to make assumptions on the 'parent' resource we're attaching our new PhoneNumber.
def create
@parents.last.phone_numbers.build(params[:phone_number])
if @parents.last.save
flash[:info] = 'A crazy success! A new PhoneNumber is born!'
redirect_to polymorphic_path([@parents, :phone_numbers].flatten)
else
flash[:error] = @parents.last.errors.full_messages.to_sentence
render :action => :new
end
end
When the create action is invoked for a PhoneNumber associated with a Customer, @parents will be a one-element Array containing the Customer instance defined by the :customer_id key in the params Hash.
When accessed through a Location, the first element will be the parent Customer and the last element the parent Location instance to which we want to attach the PhoneNumber. The @parents Array is ordered the same way the routes are nested, thus containing the hierarchy information we need to generate the right paths.
Maybe the messiest part of building a reusable controller for our PhoneNumber is the views. The UI will contain the form of course but also a link back to a page listing all the phone numbers (the index view will need links to edit and new as well). All those paths need to be generated polymorphically so they can be used in both the Customer and Location contexts.
An example new.html.haml view:
%h2
New Phone Number:
- form_for @phone_number, :url => polymorphic_path([@parents, :phone_numbers].flatten) do |f|
%fieldset
%ul
%li.form-field
= f.label :number, "Phone Number"
= f.text_field :number
%li.form-field
= submit_tag "Create"
= link_to 'Cancel', polymorphic_path([@parents, :phone_numbers].flatten)
To generate a 'new' link, use:
link_to 'new Phone Number', polymorphic_path([:new, @parents, :phone_number].flatten)
To generate an 'edit' link to @phone_number, use:
link_to 'edit', polymorphic_path([:edit, @parents, @phone_number].flatten)
For some complicated views it is sometimes necessary to know the class of the object being edited/created (for instance for nested forms).
While possible to access the class through @parents.last.class.class_name, PolyParent provides a #klassy_name instance method that will return the downcased class name of the parent base class. The base class is the superclass of your model class that inherits from ActiveRecord::Base so it's the same as the class_name for normal models, while for STIed models it returns the base class.
If, for instance, the Customer model from the examples above inherits from a Person model, then klassy_name will return 'person' and the instance in the @parents Array will be a Person and not a Customer, thus allowing you to use the PhoneNumbersController for all subclasses of Person.