Learning Ruby

Reference, Mnemonic & Ramblings

Enhancements to the PostIt App

The final lesson (Lesson 4) of Course 2 involved adding some more functionality to the PostIt app (the Reddit clone).

  1. 'AJAX'ifying voting
  2. 'Slug'ifying routes
  3. Simple Role Implementation
  4. Time-zone Setting

1. 'AJAX'ifying the voting

In Rails, this is slightly different from the way this is implemented in a non-Rails based web application. Called Server-generated JavaScript Response (SJR), Rails can dynamically create a JavaScript that implements an AJAX action.

1
2
3
link_to vote_post_path(post_object, vote: value), method: 'post', remote: true do
  icon.html_safe
end

The remote: true switch dictates AJAX implementation of that particular link.

Another thing to note is that the method: 'post' switch dynamically (using a JavaScript) injects a form with 'POST' submission to the same link as indicated by the vote_post_path(post_object) call.

The action that handles the new request needs to be modified to deal with a JavaScript response now:

1
2
3
4
5
6
7
8
9
10
11
12
  respond_to do |format|
    format.html do
      if @vote.valid?
        flash[:notice] = 'Your vote was cast.'
      else
        flash[:error]  = 'You can vote only once on this post.'
      end
      redirect_to :back
    end

    format.js # by default renders the [action_name].js.erb template in the views/[controller_name] folder
  end

The code in the [action_name].js.erb could be something as simple as: $('#post_<%= @post.id %>_votebox').html("<%= @post.total_votes %>"); or a bit more complicated as in this file

Refer to these commits to the PostIt app for details of code changes:

Back

2. 'Slug'ifying the routes

There is a security concern with URLs that look like 'ppj-postit.heroku.com/users/3' with the '3' representing the ID of the User object. Same is the case with Posts and Categories. A more appropriate URL for the above would be 'ppj-postit.heroku.com/users/testman' where 'testman' is automatically generated from the username attribute of a user. This is what is called a 'slug' in web development jargon. To achieve this, the to_param() method of the model object has to be over-ridden, because this is the method used by the named-route methods like user_path, et al.

First a 'slug' column needs to be added to the models (tables) to store the respective slugs. The models also need to have a way to automatically generate and save the slug. The former can be done by simple column creating migration. The generation of the slug can also be done in the same migration (for existing records). The slug-generator will take the appropriate attribute of the model, the :username attribute in case of the Users table.

Writing a generate_slug method for the model will be useful for auto-generation of slugs. This method can then be called to create a slug every time a new User object is created using the before_save ActiveRecord callback.

These modifications to the User model show a simple implementation of the above technique:
./app/models/user.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class User < ActiveRecord::Base
  before_save :generate_slug
  .
  # rest of the code omitted for brevity
  .
  def generate_slug
    self.slug = self.username
  end

  def to_param
    self.slug # overrides the default self.id
  end

end
The URL will now have the value of the `:username` attribute and so the view templates now need to search the User object by slug instead of ID:
1
2
3
4
   def set_user
-    @user = User.find(params[:id])
+    @user = User.find_by(slug: params[:id])
   end

Note: The implementation of generate_slug shown above is very simplistic. A robust implementation should ensure that the slugs are never repeated for the same model. This is especially applicable for the models where slugs will be generated from a non-unique attribute (e.g. Posts.title). The code below shows exactly that:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
  def generate_slug!
    the_slug = to_slug(self.title)

    count = 1
    record = Post.find_by slug: the_slug
    while record and record != self
      the_slug = make_unique(the_slug, count)
      record = Post.find_by slug: the_slug
      count += 1
    end

    self.slug = the_slug
  end

  def to_slug(str)                # str=" @#$@ My First @#2@%#@ Post!!  "
    str = str.strip               #  -->"@#$@ My First @#2@%#@ Post!!"
    str.gsub!(/[^A-Za-z0-9]/,'-') #  -->"-----My-First---2-----Post--"
    str.gsub!(/-+/,'-')           #  -->"-My-First-2-Post-"
    str.gsub!(/^-+/,'')           #  -->"My-First-2-Post-"
    str.gsub!(/-+$/,'')           #  -->"My-First-2-Post"
    str.downcase                  #  -->"my-first-2-post"
  end

  def make_unique(the_slug, count)
    arr = the_slug.split('-')
    if arr.last.to_i == 0
      the_slug = the_slug + '-' + count.to_s
    else
      the_slug = arr[0...-1].join('-') + '-' + count.to_s
    end
    the_slug
  end
Refer to these commits to the PostIt app for details of code changes:

Back

3. Simple Role Implementation

A very common requirement for an app that user-authentication is a need to differentiate between users on the basis of permissions they have. A simple way to enable roles for users:
  1. add a 'role' columns to the Users table using a simple migration
  2. add methods to the model to check whether a particular user has a specific role (indicated by a string in the role column)
    1
    2
    3
    
    def admin?
      self.role == 'admin'
    end
    
  3. enable/disable features in the controllers…
    application_controller.rb
    1
    2
    3
    
    def require_admin
      access_denied "You need admin access to do that!" unless logged_in? and current_user.admin?
    end
    
    or in the view templates…
    1
    2
    3
    
      <% if logged_in? and current_user.admin? %>
        <li><%= link_to "Create New Category", new_category_path %></li>
      <% end %>
    
    based on the role checks

A more serious implementation would call for defining a Role model and a Permission model. A has-many through associations will define Users can have many Roles through: :permissions. The role checks can now be done through this association. Chris Lee, the instructor of the 2nd course suggests that this should be avoided unless absolutely necessary, because this rolls into a massive net of checks and nested models. The simple implementation shown above suffices our need.

Refer to these commits to the PostIt app for details of code changes:

Back

4. Time-zone Setting

The app has a default time-zone (UTC) which can be overridden by specifying a different one in the './config/application.rb' file.
Adding config.time_zone = 'Melbourne' to this file will set the default time-zone of the application to Melbourne time overriding the default (UTC)

Note: All available time-zones can be listed by running the rake task rake time:zones:all.

Now that the application has a default time-zone, each user should be able to set his/her own. That would need a dedicated column to save that user's 'timezone' in. This can be added by a simple migration:

  1. rails generate migration add_timezone_to_users
  2. Add time-zone column to the User table:
    1
    2
    3
    4
    5
    
    class AddTimezoneToUsers < ActiveRecord::Migration
      def change
        add_column :users, :timezone, :string
      end
    end
    
  3. rake db:migrate

Time to modify the view template to allow setting of the time-zone: the common form partial used for User registration (User#new) and profile-update (User#edit). The following addition to the existing form partial will address this need:

1
2
3
4
  <div class='control-group'>
    <%= f.label :timezone %>
    <%= f.time_zone_select :timezone, nil, default: Time.zone.name %>
  </div>

The strong parameters need to include the new attribute :timezone for the above to work through the web interface.
params.require(:user).permit(:username, :password, :timezone)

For time to be displayed in the logged in user's time-zone, the time-display helper now needs to be modified:

1
2
3
4
5
6
7
   def fix_time(time)
-    time.localtime.strftime("(%d-%b-%Y %I:%M%p %Z)")
+    if logged_in?
+      time = time.in_time_zone(current_user.timezone)
+    end
+    time.strftime("(%d-%b-%Y %I:%M%p %Z)")
   end

Refer to this commit to the PostIt app for details of code changes.

Back

comments powered by Disqus