406: Public Activity
(view original Railscast)
It’s a common request to have a user activity feed on a website, similar to the one that Github has. This is great for social network-style applications so that we can see what other users have been up to.
We have a cookbook application where users can create, edit and share recipes. Users can add comments to recipes and mark other users as friends. We’ll add an activity page to this application so that we can see all our friends’ activity and know what they’ve been up to, be it posting new recipes or adding comments.
We’ll accomplish this with a gem called Public Activity. To use it in our application we’ll add it to the gemfile then run bundle
to install it.
/Gemfile
gem 'public_activity'
To set up the database tables that the gem needs we need to run a generator called publish_activity:migration
then migrate the database.
$ rails g public_activity:migration $ rake db:migrate
This creates an activities
table to go with the ActiveRecord model for managing activities. Public Activity is also compatible with Mongoid and these steps aren’t necessary if you’re using that. See the documentation if you’re using this setup. The next step is include a PublicActivity::Model
module in any model that we want to track the activity of and to call a method called tracked
. We’ll do this in our Recipe
model.
/app/models/recipe.rb
class Recipe < ActiveRecord::Base include PublicActivity::Model tracked attr_accessible :description, :image_url, :name belongs_to :user has_many :comments, dependent: :destroy end
The tracked
method sets up some callbacks to automatically create activity records after a model is created, updated or destroyed. We’ll do this in the Comment
model as well as we also want to track those. If we add a comment to a recipe now it will be automatically tracked by Public Activity.
The Activities Page
Next we need to create a page that displays the activities. We’ll generate a new controller with an index
action for this.
$ rails g controller activities index
Next we’ll modify the routes file and replace the generated route with an activities resource.
/config/routes.rb
resources :activities
In the this controller’s index action we want to want to list all the activities. Calling PublicActivity::Activity
will return the ActiveRecord models so we can query the database like we would normally. We’ll order the results by the time they were created at so that the most recent activities are displayed at the top.
/app/config/activities_controller.rb
class ActivitiesController < ApplicationController def index @activities = PublicActivity::Activity.order("created_at desc") end end
In the view we can loop through this data and display it. For now we’ll just inspect each activity to see what it contains.
/app/views/activities/index.html.erb
Friends’ Activities
<% @activities.each do |activity| %> <%= activity.inspect %> <% end %>
When we reload the page now we’ll see the one activity that we’ve already added.
We can see the different attributes that this activity has including trackable_id
and trackable_type
columns. This is a polymorphic association and we know that this activity is associated with a Comment
model. There are a couple of other polymorphic associations here, too: recipient
and owner
. The owner is the user who performed the activity and we’ll want to set this so that we can display the user’s name next to their activity. We’ll do this next.
We mentioned earlier that this gem uses callbacks to record activity. This presents a problem, however, as the model layer doesn’t have access to the current user so we can’t set the owner when we record the activity? Public Activity has a workaround for this; to use it we need to include a module in the ApplicationController
.
/app/controllers/application_controller.rb
class ApplicationController < ActionController::Base include PublicActivity::StoreController # Rest of class omitted end
This records the controller on each request allowing us to access it from the models. We can do this in the Comment
model by adding an owner
option to tracked
.
/app/models/comment.rb
tracked owner: ->(controller, model) { controller.current_user }
We set this option to a lambda and this is evaluated each time it tracks an activity. The controller and model are passed in to this and we can use the controller to set the owner to the current user. This presents a small problem, however, as current_user
is a private method in our ApplicationController
. We’ll make it public and use hide_action
to stop it from being considered an action.
/app/controllers/application_controller.rb
class ApplicationController < ActionController::Base include PublicActivity::StoreController protect_from_forgery def current_user @current_user ||= User.find(session[:user_id]) if session[:user_id] end helper_method :current_user hide_action :current_user private def require_login redirect_to login_url, alert: "You must first log in or sign up." if current_user.nil? end end
Another potential issue is that in Comment
calling controller.current_user
raises an exception if we try to create a record outside the current request as there won’t be a controller. We’ll check to see if there is a controller here before trying to fetch the current user from it.
/app/models/comment.rb
tracked owner: ->(controller, model) { controller && controller.current_user }
This workaround isn’t ideal and it feels a little ugly to have to access the controller in the model list but it does work. We’ll copy this into the Recipe
model so that we can fetch the user here, too. We’ll then try it out by adding another comment then visiting the activities page again.
Now we have two activities and the first one, which is the most-recently added, has its owner_id
set. We’ll fix up this view now so that it displays the activities instead.
/app/views/activities/index.html.erb
Friends’ Activities
<% @activities.each do |activity| %><%= link_to activity.owner.name, activity.owner if activity.owner %> added comment to <%= link_to activity.trackable.recipe.name, activity.trackable.recipe %><% end %>
We have a div
with a class of activity
for each activity and inside it we display the activity’s owner’s name, but only if the activity has an owner. Next we describe the activity and this can be a little difficult as each one is unique so for now we’ve hardcoded this as all our activities are comments. We then display the recipe’s name in a link to the recipe. To get the recipe we use activity.trackable
which is the polymorphic association which references the model that the activity is for, in this case Comment
. We’ll also want some styling for our list of activities so we’ll add that now.
/app/assets/activities.css.scss
.activity { border-bottom: solid 1px #CCC; padding: 16px 0; em { color: #777; font-size: 12px; padding-left: 5px; } }
When we reload the page now we should see our list of activities.
This looks quite good but we’re hard-coding the description. How can we change it depending on the type of activity? Public Activity provides a helper method called render_activity
that we can use; all we need to do is pass it the activity.
/app/views/activities/index.html.erb
Friends’ Activities
<% @activities.each do |activity| %><%= link_to activity.owner.name, activity.owner if activity.owner %> <%= render_activity activity %><% end %>
This will render a partial for the activity’s action. It will look for these in a public_activity
directory and in there we’ll need a directory for each different type of model that we track. We want a partial that will be displayed when a comment is created so we’ll call it _create.html.erb
. In it we can describe the activity like we did before.
/app/views/public_activity/comment/_create.html.erb
added comment to <%= link_to activity.trackable.recipe.name, activity.trackable.recipe %>
This looks like it did before but we can now define partials for the different types of activities including _destroy
and _update
. Once we’ve done this and added or updated some comments we can see how these appear on the activities page.
This all looks good but if we remove a record, say a recipe, then visit the activities page we’ll get an error message.
This is because we’re calling activity.trackable.recipe
for a recipe that no longer exists. It’s important in each activity partial to take into consideration the fact that the object may no longer exist so we’ll modify each one like this.
/app/views/public_activity/comment/_create.html.erb
added comment to <% if activity.trackable %> <%= link_to activity.trackable.recipe.name, activity.trackable.recipe %> <% else %> which has since been removed <% end %>
When we reload the activities page now it works again.
Excluding Actions
Next we’ll show you how to exclude certain actions. For example updated comments aren’t that interesting so we might not want to show them in the activities list. We can do this by passing an option to the call to tracked
in the Comment
model: either only
to specify which activities should be tracked or except
to specify the ones to exclude.
/app/models/comment.rb
tracked except: :update, owner: ->(controller, model) { controller && controller.current_user }
The tracked
method is starting to get a little complex with all these options and it’s not always the best approach to perform activity tracking through callbacks on the model so instead we’ll handle it through the controllers. To do this we include the PublicActivity::Common
in the model instead of PublicActivity::Model
. We can also removed the call to tracked
here, too.
/app/models/comment.rb
class Comment < ActiveRecord::Base include PublicActivity::Common attr_accessible :content belongs_to :user belongs_to :recipe end
Now in the CommentsController
we can record the activity whenever we save, update or destroy a comment.
/app/controllers/comments_controller.rb
if @comment.save @comment.create_activity :create, owner: current_user redirect_to @recipe, notice: "Comment was created." else render :new end
This gives us more control over when and how activities are created and it means that we can avoid the workaround for setting the current user. This approach also means that we can create custom activities very easily.
To finish this episode off we’ll change the list of activities so that is only shows our fiends’ activities instead of everyone’s. We can do this by adding a scope to our ActivitiesController
.
/app/controllers/activities_controller.rb
class ActivitiesController < ApplicationController def index @activities = PublicActivity::Activity.order("created_at desc").where(owner_id: current_user.friend_ids, owner_type: "User") end end
Now only the activities that belong to the current user’s friends will be shown. As owner can be a polymorphic association it’s a good idea to ensure that the owner_type
is User
. When we reload the page now we don’t see any activities as we don’t have any other users marked as friends, but if we mark another user who has made comments as a friend then try again we’ll see their activities listed.