Sharing controllers and views with polymorphic resources

Posted by Andreas on Friday, May 16, 2008 at 08:26 (CEST)

A while ago I faced a problem with controllers for nested resources I wanted to reuse in different contexts (polymorphic resources). For example, I had a simple Todo model that is used to store tasks that have to be done. Having multiple users which are organized in teams, the goal was to have user-specific (personal) todos and team-specific todos. Obviously, both kind of todos could be handled exactly the same way (same controller, same views) – except that the context differs.

So here’s a way to use a single controller for a polymorphic resource.

Model

The model setup is quite straightforward. Users belong to a team and teams have multiple users. Todos can be referenced by both using a polymorphic association:

 1 class User < ActiveRecord::Base
 2   belongs_to :team
 3   has_many :todos, :as => :context
 4 end
 5 class Team < ActiveRecord::Base
 6   has_many :users
 7   has_many :todos, :as => :context
 8 end
 9 class Todo < ActiveRecord::Base
10   belongs_to :context, :polymorphic => true
11 end

Routing

Using nested resources in routes.rb, todos can be used in different contexts:

1 map.resource :personal do |personal|
2   personal.resources :todos   # /personal/todos/:id => TodosController
3 end
4 map.resources :teams do |teams|
5   teams.resources :todos      # /teams/:team_id/todos/:id => TodosController
6 end

Controller

Both cases (personal todos and team todos) are now routed to the same TodosController. To distinguish between different contexts, the controller-method context returns the current context, either the user for personal todos or the team for team todos:

1 helper_method :context
2 def context
3   if params[:team_id]
4     Team.find(params[:team_id])
5   else
6     current_user
7   end
8 end

This method can be either put to TodosController or to ApplicationController, which makes it available globally, so that you can use it with other nested resources (like photos or documents additionally to todos). I also made the context method available as a helper in views, in case a view needs to find out about the current context.

The TodosController can now use the context method everywhere to access the todos, e.g. the index method lists all todos:

1 def index
2   @todos = context.todos.find(:all)
3 end

Views

Using url helpers in views is a bit tricky. Rails provides url helpers for every resource. Helpers for nested resources get a name prefix of the parent resource:

1 personal_todo_url(1)   # => .../personal/todos/1
2 team_todo_url(5, 1)    # => .../teams/5/todos/1

So you need to use different url helpers with different parameters depending on the current context. If the current context is a team, team_todo_url needs to be called using the team id and todo id. If the current context is the user, personal_todo_url needs to be called and only a todo id must be provided (since personal is a singleton resource, it doesn’t need an id).

If you’d take care of the above in every view, it’d probably result in a ton of awful looking conditions – so better define a helper that automatically chooses the right url helper based on the current context. Since this helper is able to return a url for any todo object, let’s call it todo_url (just like the helper that rails would provide if the todo resource wouldn’t be nested):

1 def todo_url (*args)
2   if Team === current_context
3     team_todo_url(current_context.id, *args)
4   else
5     personal_todo_url(*args)
6   end
7 end

Naming this helper todo_url has another advantage: if Rails is asked to generate a url based on a model object, it uses modelname_url to build the url. Without todo_url, calls like link_to('a todo', @todo) or form_for(todo)@ won’t work anymore. Defining todo_url let these methods work again magically. You can create views like with a non-nested resource and it just works because the above helper method automatically chooses the right url based on the context.

Of course, todo_url isn’t the only url helper that’s related to todos. Rails by default provides a bunch of url helpers per resource that you may or may not use (todos_url, todo_path, new_todo_url, formatted_todo_url, and so on). If you only use few of them, you might be fine to manually define them like above. If you need many different url helpers or you’re a perfectionist and want to have all of them, here’s a slightly ugly piece of metaprogramming that I added to my url_helper.rb and that defines all of these helpers:

 1 ['todos'].each do |resource|
 2   [['', resource.pluralize], ['', resource.singularize], ['new_', resource.singularize], ['edit_', resource.singularize]].each do |prefix, helper|
 3     ['', 'formatted_'].each do |formatted|
 4       ['_url', '_path'].each do |suffix|
 5         define_method "#{formatted}#{prefix}#{helper}#{suffix}" do |*args|
 6           if Team === context
 7             send("#{formatted}#{prefix}team_#{helper}#{suffix}", context.id, *args)
 8           elsif User === context
 9             send("#{formatted}#{prefix}personal_#{helper}#{suffix}", *args)
10           else
11             raise NameError, "Unknown context"
12           end
13         end
14       end
15     end
16   end
17 end
• Tags: , , , ,
CommentsPermalink
blog comments powered by Disqus