#407 Activity Feed from Scratch pro
- Download:
- source codeProject Files in Zip (93.7 KB)
- mp4Full Size H.264 Video (29.3 MB)
- m4vSmaller H.264 Video (15.5 MB)
- webmFull Size VP8 Video (20 MB)
- ogvFull Size Theora Video (34.3 MB)
In the previous episode we created a user activity feed like the one found on Github, using the Public Activity gem. Like any gem this one is great if it fits our needs exactly, but if not we can always consider creating the functionality from scratch. In this episode we’ll build an activity feed this way. This isn’t too difficult, although it does present some interesting problems. We’ll work with the same cookbook application that we used last time. With this application users can create and update recipes and also comment on them. We want to track this activity and display it on a separate page.
We want to store the activities in the database so we’ll start by generating an activity
resource. With it we’ll track the user who generated the activity, the action that’s performed (e.g. create, update or destroy) and the object that the action is performed on, which we’ll call trackable
. This is a polymorphic association so that it can belong to either a Comment
or a Recipe
. In Rails 4 we can use belongs_to{polymorphic}
to do this, but for our Rails 3.2 application we’ll add a trackable_type
column instead. We’ll also migrate the database to add the new table.
$ rails g resource activity user:belongs_to action trackable:belongs_to trackable_type $ rake db:migrate
Next we’ll make some changes to the generated Activity model. We need to make the trackable association polymorphic and change the attr_accessible
list so that we can set the trackable
object when we create an activity.
class Activity < ActiveRecord::Base belongs_to :user belongs_to :trackable, polymorphic: true attr_accessible :action, :trackable end
We also need to set up the other side of the association with User
.
has_many :activities
Recording Activities
This is all fairly standard so far. Next we need to decide how we’ll create an activity record whenever a user performs an action on our site. If, for example, someone creates a recipe we want to record this. One option is to use callbacks: we could add an after_create
callback that creates the activity, giving it an action of “create” and passing in the recipe as the trackable object.
class Recipe < ActiveRecord::Base attr_accessible :description, :image_url, :name belongs_to :user has_many :comments, dependent: :destroy after_create { |recipe| Activity.create! action: "create", trackable: recipe } end
ActiveRecord callbacks have their uses but it’s important to be aware of the potential issues. One disadvantage is that they make it less obvious as to what is happening in an action. In this case our RecipesController
’s create
action triggers this callback but we can’t tell that by looking at its code as it only looks like it saves the new recipe. The controller actions should be more obvious as this makes it easier to read and debug the code.
Another issue with callbacks is that they often have unexpected side effects. There are times where we might create a record outside of a user request, such as in a Rake task, in the console, while seeding the database or while running tests. Do we want this callback to be triggered every time one of these events takes place? It’s also worth asking when deciding to use a callback or not is what happens when a callback is triggered twice in a single request. This might have some unwanted effects and in this case it would create two Activity
records for a single activity. This question is more relevant to an update
callback but it’s a good question to ask for any type.
Another sign that this code doesn’t belong in a callback is that we can’t easily get the current user and this is something we want to track for each activity. It seems that we have enough reasons to avoid using callbacks so we’ll take a different approach and track the activity in the controller. We can use the current user’s activities
association to do this.
def create @recipe = current_user.recipes.build(params[:recipe]) if @recipe.save current_user.activities.create! action: "create", trackable: recipe redirect_to @recipe, notice: "Recipe was created." else render :new end end
We’ll be doing this a lot throughout our application so we’ll move this code into a method that we’ll call track_activity
.
if @recipe.save track_activity @recipe redirect_to @recipe, notice: "Recipe was created." else render :new end
We’ll write this new method in the ApplicationController
. If we run into any cases where we don’t want this behaviour we can move the action into a second optional argument.
def track_activity(trackable, action = params[:action]) current_user.activities.create! action: action, trackable: trackable end
This works because Ruby evaluates argument defaults in the context of the method. If you find this approach unclear you can replace params[:action]
with nil
and set the default within the method, but either way works. With this method in place we can easily add activity tracking to any controller action and it isn’t much work to add it to a controller’s create
, update
and destroy
actions or wherever else we might want it and we can do the same thing in our CommentsController
. If we find that adding this makes our actions too complex we can consider refactoring this into a service object, like we did in episode 398.
The Activities Page
Whenever a user performs an action now, such as adding a comment, that activity will be recorded. Next we’ll create a page to display these activities. We already have an ActivitiesController
from our generated resource so we’ll add an index
action to it. In it we’ll fetch all the activities in the reverse order that they were created.
class ActivitiesController < ApplicationController def index @activities = Activity.order("created_at desc") end end
We’ll need a view template to go with this action to render each activity.
<h1>Activities</h1> <% @activities.each do |activity| %> <%= div_for activity do %> <%= link_to activity.user.name, activity.user %> <%= render "activities/#{activity.trackable_type.underscore}/#{activity.action}", activity: activity %> <% end %> <% end %>
Here we loop through each activity, wrapping each one in a div
so that we can add some styling later. We then display a link to the user who created it followed by a description of the activity. This description needs to be different for each activity so we’ve taken a similar approach to how the Public Activity gem works and rendered a partial for each different type of activity. Each partial needs to be in a directory based on it’s trackable type and action, e.g. the partial for creating a comment will be at activities/comments/_create.html.erb
.
commented on <%= link_to activity.trackable.recipe.name, activity.trackable.recipe %>
Here we describe the activity and link to the recipe that the comment was added to. As the activity was recorded for a comment we fetch its recipe by calling activity.trackable.recipe
. Finally we’ll add some styling to our list.
.activity { border-bottom: solid 1px #CCC; padding: 16px 0; }
If we add a comment to a recipe now then visit the activities page we should see the activity listed.
It would be better if we didn’t have to go through activity.trackable
in our partials but could instead use the comment
object directly, like this:
commented on <%= link_to comment.recipe.name, comment.recipe %>
To do this we’ll need to make the way we render the partials in the index
template more complicated but this template is already quite complex. When a template gets like this it’s generally a sign that we should refactor some of its code out into a presenter. (If you’re unfamiliar with presenters they were covered in episode 287.) We’ll create a new presenters
directory under app
and put our new presenter in there. First we’ll modify the view to call the presenter, passing in the activity and self
to pass in the view. We could just instantiate this object and erb will render its string representation by calling to_s
on it. We could then override to_s
in the presenter and put the code that generates the output into it. This doesn’t really make explicit how our presenter generates its output and so we’ll define a render_activity
method and call that in the view.
<% @activities.each do |activity| %> <%= ActivityPresenter.new(activity, self).render_activity %> <% end %>
This render_activity
method should do that same thing that we did in the view, rendering a div
that contains the name of the user who performed that activity, its description and a link to the recipe that it relates to.
class ActivityPresenter < SimpleDelegator attr_reader :activity def initialize(activity, view) super(view) @activity = activity end def render_activity div_for activity do link_to(activity.user.name, activity.user) + " " + render_partial end end def render_partial locals = {activity: activity, presenter: self} locals[activity.trackable_type.underscore.to_sym] = activity.trackable render partial_path, locals end def partial_path "activities/#{activity.trackable_type.underscore}/#{activity.action}" end end
In order to keep the render_activity
method simple we’ve moved the code that renders the partial off into a render_partial
method. In it we create a hash of the things we want to pass to the partial, including the activity, the presenter and the trackable object. The way we add the trackable object to the hash means that we can refer to it by its name in the partial, e.g. by calling comment.recipe
to get the comment’s recipe instead of having to go through activity.trackable
. Finally we have a partial_path
method to create the path to the correct partial.
Fallback Partials
We can now create the extra partials for the various other activities but what if we find that there’s a lot of duplication between them? One way to reduce this is to create a fallback partial so that instead of having a separate partial for each action for a recipe we have a one that can handle all the activities.
<%= activity.action.sub(/e?$/, "ed") %> recipe <%= link_to recipe.name, recipe %>
Here we convert the action into the past tense by taking any “e” at the end off and adding “ed” then add the word “recipe” and a link to the recipe. This won’t work yet because we’re hard-coding the partial path to include the activity’s action in the partial_path
method in our presenter. We need to supply a second partial path that it can fall back to that doesn’t include the action portion. We’ll do this by creating another method in the presenter called partial_paths
that returns an array of the partial paths that should be searched.
def partial_path partial_paths.detect do |path| lookup_context.template_exists? path, nil, true end || raise("No partial found for activity in #{partial_paths}") end def partial_paths [ "activities/#{activity.trackable_type.underscore}/#{activity.action}", "activities/#{activity.trackable_type.underscore}", "activities/activity" ] end
We have the same partial path that we had before in the first position in the array, followed by two fallback paths, including a global one. In partial_path
we can loop through this array and detect the first one where the path has a template that exists. To do this we can go through the lookup_context
on the view and call template_exists?
. This takes three arguments, a path, an array of prefixes (we just pass in nil
), and whether the template is a partial. If none of the paths find a match we raise an exception so that it’s easier to debug these cases.
If we add some more activities now then visit the activities page we’ll see them all listed and rendered by the same partial.
We’re almost done now but we’ll make a couple of small final changes. We need to handle the case where the trackable object might not exist so we’ll make sure that it does before linking to it.
<%= activity.action.sub(/e?$/, "ed") %> recipe <%= link_to recipe.name, recipe if recipe %>
We’ll do something similar for the comments.
<% if comment %> commented on <%= link_to comment.recipe.name, comment.recipe %> <% else %> added a comment which has since been removed <% end %>
Our page can now display a whole range of activities.