1. Creating an Ajaxified Star Rating System in Rails 3

    On the app I’m currently working on I needed to create a star rating system. I searched around for different solutions, but everything I found either didn’t support Rails 3 or had very obtrusive javascript or both.

    I was able to figure this out on my own, however, and I thought it would be pretty useful to share this with anyone else who found themselves in a similar situation. I used HAML instead of ERB, which is absolutely a wonderful way to write view code, but you should be able to follow the presentation logic even if HAML is unfamiliar to you.

    Here’s the gist of what we’re going to look at:

    1. Start by creating a Rating Model
    2. Associate that model with the users (who give ratings) and whatever model objects they’re going to rate.
    3. Create the necessary forms.
    4. Setup your controllers to handle the forms.
    5. Refine the model to aggregate data.
    6. Ajaxify your form.
    7. Use CSS to make your form look right.
    8. Setup a jQuery function to handle interaction with your new form.

    It’s a little complicated, to be honest, but it’s not as bad as you might think. I’ll cover each step in detail and show you my code along the way.

    Create the Model

    The first thing we need to do is create the model. This is pretty straightforward. We’re going to use a has_many :through relationship, so you just need to know the id of the two models you’ll be linking (users and rated objects). In my case I was letting users rate the quality of different photographs, so in my examples I’ll have a relationship between the User Model and the Photo model. You also have one piece of data for each rating, the actual rating value. Therefore, your generator should look like this:

    rails generate model Rating user_id:string photo_id:string value:integer
    

    You probably don’t need to customize this at all, so go ahead and migrate the database.

    rake db:migrate
    

    Associate your models

    Now we need to declare the relationships between the various models. First of all, Ratings belong to both Users and Photos (or whatever you’re rating). It belongs to photos, and it also belongs to users. For good measure, go ahead and protect all the attributes except the value (using attr_accesible).

    rating.rb
    class Rating < ActiveRecord::Base
        attr_accessible :value
    
        belongs_to :photo
        belongs_to :user
    
    end
    

    Now, open up your User model. Your user has many ratings, and also has many photos that have been rated. It should look something like this:

    user.rb
    class User < ActiveRecord::Base
        has_many :ratings
        has_many :rated_photos, :through => :ratings, :source => :photos
    end
    

    Finally, let’s edit the Photo model (or whatever you’re rating). This will be basically the same as your user model.

    photo.rb
    class Photo < ActiveRecord::Base
        has_many :ratings
        has_many :raters, :through => :ratings, :source => :users
    end
    

    There you go! Your relationships are now all declared.

    Create a form

    You could do the next two steps in either order, but I think it’s easier to visualize what you’re controller is supposed to be doing after you’ve gotten your form working.

    The goal in creating the form is simple: you need to create a consistent markup pattern where you have labels attached to radio buttons. Why? Well, it turns out it’s pretty hard to change the visual style of radio buttons (or check boxes etc.), but you can basically do whatever you want with labels. This works out fine because as long as your label has a “for” element that matches the ID of one of your radio buttons, then clicking the label will result in selecting that radio button.

    What we’re going to do is actually hide the “standard” form elements and use the labels to generate a custom form. We’ll keep that in mind as we go, but we have to get the forms working first.

    The first decision you have to make is where you’re going to put your star rating view. In my case I put it in the “photos#show” view. You may want to stick it somewhere else. Regardless of where you put it these instructions should still apply, but you may have to make some minor adjustments.

    The first problem you’ll run into is right at the beginning of your code. You need to put in a form_for — but depending on whether or not your user has already rated this photo you need to either have a form for a new rating, or a form to update an existing rating. The best way to solve this dilemma is with a helper method.

    Helper Method No. 1: Rating Ballot

    To simplify the rating form I created a helper method called rating_ballot. This helper method returns the object the form is modifying: either a new rating (if the current user has never rated this object before) or the user’s existing rating for this object.

    I’ll assume that you’re using some kind of authentication system, and that you have a current_user method that returns the current logged in user. If not, you’ll need to either create that helper method or adapt these instructions to suit your application.

    The rating_ballot helper method is only used in Photo views, so I put it in the photos_helper.rb file. Here’s what it looks like:

    photos_helper.rb
    ...
    def rating_ballot
        if @rating = current_user.ratings.find_by_photo_id(params[:id])
            @rating
        else
            current_user.ratings.new
        end
    end
    ...
    

    The method first checks to see if there is an existing rating from the current user on the photo that is being shown. If there is it returns that rating, if not it creates a new rating for the current user and returns that new rating.

    Note: This is not using the “create” method – therefore a rating is not created in the database when a user just views a photo, they have to actually commit a rating first.

    Back to the Form

    Using this method we can build our form. It should look basically like this:

    = form_for(rating_ballot, :html => { :class => 'rating_ballot' }) do |f|
        = f.label("value_1", content_tag(:span, '1'), {:class=>"rating", :id=>"1"})
        = radio_button_tag("rating[value]", 1, current_user_rating == 1, :class => 'rating_button')
        = f.label("value_2", content_tag(:span, '2'), {:class=>"rating", :id=>"2"})
        = radio_button_tag("rating[value]", 2, current_user_rating == 2, :class => 'rating_button')
        = f.label("value_3", content_tag(:span, '3'), {:class=>"rating", :id=>"3"})
        = radio_button_tag("rating[value]", 3, current_user_rating == 3, :class => 'rating_button')
        = f.label("value_4", content_tag(:span, '4'), {:class=>"rating", :id=>"4"})
        = radio_button_tag("rating[value]", 4, current_user_rating == 4, :class => 'rating_button')
        = f.label("value_5", content_tag(:span, '5'), {:class=>"rating", :id=>"5"})
        = radio_button_tag("rating[value]", 5, current_user_rating == 5, :class => 'rating_button')
    
        = hidden_field_tag("photo_id", @photo.id)
        = f.submit :Submit
    

    This is a slightly ghetto way of building a form, the label and “radiobuttontag” values are very ‘manual’, but this is actually simpler than any other approach I tried. Basically what this is doing is pre-defining a set of acceptable parameters that the user can input, in this case a rating from 1 to 5. Feel free to refactor this code if you want.

    Note two things:

    1. There’s a hidden field that is saving the photo id as a parameter. This is important because the form is going to end up in the Ratings Controller, not the photo controller, and the Ratings Controller isn’t going to know what photo you came from, nor will it be able to get the data from the URL (the form will be posting to a url like: “root/ratings/” or “root/ratings/2”). Putting this hidden field in there will save you a lot of headaches in your controller later on.

    2. In each radio_button_tag there’s a second helper method: current_user_rating. This argument in the radio button helper needs to evaluate either true or false. If it’s true the radio button is checked, and of course only one radio button can be checked at a time. The next step of this tutorial will look at that helper method and see what it’s doing.

    Helper Method #2: Current User Rating

    So what’s the helper method doing? Quite simply we need a clean way to return the value of the current user’s rating for the photo that we’re currently viewing. The nicest way to do this is to make a new helper method that looks like this:

    photos_helper.rb
    ...
    def current_user_rating
        if @rating = current_user.ratings.find_by_photo_id(params[:id])
            @rating.value
        else
            "N/A"
        end
    end
    ...
    

    This is checking to see if the current user has already rated the photo that is being requested in the URL. If yes, then it returns the current user’s rating for that photo, if not then it returns the string “N/A”. The string will cause any test in the view to return false, and it also will render nicely if you want to put the numeric value of the current user’s rating somewhere on the page.

    With those helper methods in place our form render correctly. However, if you try it out in your view you’ll get an exception: we don’t have the controller set up to handle the form yet. Let’s set that up.

    Setting up the Controller

    The fastest way to do this is with the generator:

    rails generate controller Ratings
    

    Now let’s add some logic to the controller. We’ll need basically the same function for both the create and update methods: we need to prevent users from rating their own content (of course, you can omit that if you want people to vote on their own stuff), and we want to send users back to the view they came from when they submit a rating. Here’s what my controller looked like:

    ratings_controller.rb
    class RatingsController < ApplicationController
        before_filter :authenticate_user!
    
        def create
                @photo = Photo.find_by_id(params[:photo_id])
                if current_user.id == @photo.id
                    redirect_to photo_path(@photo), :alert => "You cannot rate for your own photo"
                else
                    @rating = Rating.new(params[:rating])
                    @rating.photo_id = @photo.id
                    @rating.user_id = current_user.id
                    if @rating.save
                        respond_to do |format|
                            format.html { redirect_to photo_path(@photo), :notice => "Your rating has been saved" }
                            format.js
                        end
                    end
                end
            end
    
            def update
                @photo = Photo.find_by_id(params[:photo_id])
                if current_user.id == @photo.id
                    redirect_to photo_path(@photo), :alert => "You cannot rate for your own photo"
                else
                    @rating = current_user.ratings.find_by_photo_id(@photo.id)
                    if @rating.update_attributes(params[:rating])
                        respond_to do |format|
                            format.html { redirect_to photo_path(@photo), :notice => "Your rating has been updated" }
                            format.js
                        end
                    end
                end
            end
    
        end
    

    This is pretty straightforward stuff, we’re getting the current photo from the parameters, we’re also getting the rating value that is passed through the parameters, and we’re either creating a new rating or updating an existing one with this information. You’ll notice the first declaration in this controller is before_filter :authenticate_user!. It’s a good idea to make sure that only signed-in users are rating things, so you should use whatever authentication solution your app has to accomplish this. I use the Devise plugin, but there are plenty of good options out there.

    You’ll notice that, after saving the new rating or updating the existing rating, I’ve got a respond block with options for both HTML and JS requests. This is because after we are sure the forms are working we’re going to make them ajax-powered. When we do we’ll need the format.js option in the code, so we might as well put it in there now.

    That ought to be enough to get a plain vanilla test of your rating system working. Try it out!

    Refine the Model

    Now that you’re getting a rating for each logged in user, you might want to also display the average rating that something has accumulated from many different users. The best way to do this is to create a new method in the model that you’re rating — so, Photos in my case. I called my method average_rating, and it looks like this:

    photo.rb
    def average_rating
        @value = 0
        self.ratings.each do |rating|
            @value = @value + rating.value
        end
        @total = self.ratings.size
        @value.to_f / @total.to_f
    end
    

    Now in your views you can show the average rating for your model with this simple method:

    show.html.haml (or show.hrml.erb)
    @photo.average_rating
    

    You could also show the numeric value of the user’s current rating with current_user_rating. Testing the next few steps is a bit easier if these values are showing, so stick them both in your view somewhere close to your form. I put them in my view with a little partial that looks like this:

    %table#rating
        %thead
            %tr
                %th{ :colspan => 2 }
                    Photo Ratings
        %tr
            %td Average Rating
            %td= @photo.average_rating
        %tr
            %td Your Rating
            %td= current_user_rating
    

    Ajax for your forms

    Ok, let’s make this a little spiffier. How about being able to update the rating without reloading the page? Well, this is actually pretty simple! You already informed the controller to respond to javascript, now just tell your form to use Ajax.

    = form_for(rating_ballot, :remote => true, :html => { :class => 'rating_ballot' }) do |f|
    

    If you have an element in your view that you want updated you should also add a “create.js.erb” and “update.js.erb” file to your “views/ratings” folder. Here’s what they should look like:

    create.js.erb and update.js.erb
    $('table#rating').replaceWith("<%= escape_javascript(render :partial => 'photos/rating') %>");
    

    This jQuery command takes the existing table with an ID of “rating” and replaces it with the updated rating partial. The create and update files should be exactly the same.

    Now, this is pretty neat, but we could also make it even nicer for the end user if we could spare them the step of clicking on the “submit” button when they want to save their form. To do this we need to add a jQuery function to the page. I recommend not having this jQuery function on every page — only when it’s needed. So, if you don’t have something like this already, add a :yield to your header right after your main javascript_include_tag. It should look like this:

    application.erb.haml
    = javascript_include_tag 'jquery', 'jquery_ui', 'rails', 'application'
    = yield :scripts
    

    Then go back to your form and stick this right above it:

    - content_for(:scripts) do
        = javascript_include_tag 'rating_ballot'
    

    (again, please note that this is HAML, you have to adjust the markup for ERB if you prefer to use ERB)

    Now in your public/javascripts folder create a new file called rating_ballot.js. Inside it you should put something like this:

    rating_ballot.js
    $(document).ready(function() {
        // Submits the form (saves data) after user makes a change.
        $('form.rating_ballot').change(function() {
            $('form.rating_ballot').submit();
        });
    });
    

    What this does is wait until the page is loaded (document ready function) and then looks for a form with a class of “rating_ballot”, and after any change is made to that form it submits the form — just like if you had clicked the submit button.

    NOTE: Do not use jQuery’s .click() method for updating forms. Oddly enough, this actually submits the value of the form as it was right before you clicked on it… it’s weird. The .change() method waits until after the change in your form has registered before submitting it.

    So, try this out again and you should see that your ratings update automatically when you click on the radio buttons, no more need to push submit! That’s actually good news, because we’re about to kill the submit button!

    Use CSS to Make Your Form Look Right

    Ok, now you have a radio-button powered rating system. That’s great, but it doesn’t look or feel like a star rating, and that’s what we’re here for, isn’t it?

    To make it look like a star rating we’re going to start by hiding the things that we don’t need — specifically, the things that we can’t style on our own. In your style.css file you should set “display:none” on the radio buttons and on the submit button. Your CSS should look something like this:

    form.rating_ballot input#rating_submit { display: none; }
    form.rating_ballot input.rating_button { display: none; }
    

    Now if you reload the view you’ll just see the numbers 1,2,3,4 and 5, no more radio buttons and no more submit button. If you click on those numbers, however, you should still see the same interaction with the rating data as before. So now the trick is to make those numbers look like stars.

    First we need some stars to work with. I recommend some 20x20 transparent .png files, you can make them in just about any image editor, or probably find some on the web. You need images for stars that are “dim”, stars that are “bright” and stars that are “glowing”.

    Once you find or create some images put them in your public/images file. Then add the following lines to your css:

    form.rating_ballot label.rating { cursor: pointer; display: block; height: 20px; width: 20px; float: left; }
    form.rating_ballot label.rating span { display: none; }
    form.rating_ballot label.rating { background-image: url(../images/star-dim.png); }
    form.rating_ballot label.rating.bright { background-image: url(../images/star-bright.png); }
    form.rating_ballot label.rating.glow { background-image: url(../images/star-glow.png); }
    

    The first line sets up your labels to be 20x20 blocks in a line, and tells the browser to use the pointer cursor when you hover over them. The second line hides the text. The last three lines define a background image for your labels.

    Now if you reload the page you should see a bunch of dim stars. The interaction should still work, but you won’t yet get a visual cue — the stars are still dim no matter when you click on them. At this point we’re ready for the last layer of jQuery interactivity!

    Setup a jQuery function to handle interaction with your new form.

    Let’s revisit your rating_ballot.js file. We need to add a bunch of stuff to it. First, let me show you what it will look like when we’re done, then I’ll explain each block of code.

    rating_ballot.js
    // Sets up the stars to match the data when the page is loaded.
    $(function () {
        var checkedId = $('form.rating_ballot > input:checked').attr('id');
        $('form.rating_ballot > label[for=' + checkedId + ']').prevAll().andSelf().addClass('bright');
    });
    
    $(document).ready(function() {
        // Makes stars glow on hover.
        $('form.rating_ballot > label').hover(
            function() {    // mouseover
                $(this).prevAll().andSelf().addClass('glow');
            },function() {  // mouseout
                $(this).siblings().andSelf().removeClass('glow');
        });
    
        // Makes stars stay glowing after click.
        $('form.rating_ballot > label').click(function() {
            $(this).siblings().removeClass("bright");
            $(this).prevAll().andSelf().addClass("bright");
        });
    
        // Submits the form (saves data) after user makes a change.
        $('form.rating_ballot').change(function() {
            $('form.rating_ballot').submit();
        });
    });
    

    Ok! To start we put a new function that’s outside the document.ready event. This function runs first, immediately as the page loads, and it does two things. First, it finds the ID of the checked radio button inside your “rating_ballot” form and stores this as “checkedId”. Second, it finds the label for that radio button, and adds the class “bright” to that label and all the ones that come before it.

    Inside the document ready function we have two new things going on. First we handle the hover condition. The mouseover portion of the hover function takes the label you’re hover over and all the ones in front of it and adds the “glow” class to them. The mouseout function removes the “glow” class from all the labels.

    Secondly we handle the click event. After any label is clicked the “bright” class is wiped off of all the labels, then the label that was clicked and all the labels that come before it have the “bright” class added. This happens in an instant, so you just see it as the stars that are lit shifting to stop at whatever one you clicked.

    Save this, reload your page, and everything should work!

    Wrap Up!

    So that’s all there is to it! This was a pretty lengthy tutorial, but I hope it has shown you several helpful ideas. There’s a lot of cool stuff you can do with forms once you know how to link the labels to the form elements and use jQuery and CSS to interact with the labels.

    I’ll try to answer questions if you have some!

Notes

  1. railsforbeginner reblogged this from eighty-b
  2. jawadrashid reblogged this from eighty-b
  3. eighty-b posted this