Developing a social networking site part 2 - rating stars
Posted by Jim Morris on Sat Jun 23 13:49:25 -0700 2007
In part 1 I outlined my project to implement snowdogsr.us a social networking site for snow dogs.
I am pleased to announce that version 1 of this site is up, however I had to make some trade-offs to get it up this far. I needed to prioritize my goals and just get the essentials implemented.
So the ability to create Posts, Events and Places, and add photos of your dogs was goal number 1. The linking to friends was postponed, although most of the logic was implemented, I couldn't figure out how to expose that in an intuitive UI.
The first thing I wanted was to be able to rate the various Posts and Places people entered, and how better to do that than to copy the Netflix and Google groups method of showing 5 stars and clicking on them to rate the item.
To that end some googling found an excellent CSS method to do this, all I had to do was integrate it into Rails. I had some extra business rules I needed to deal with
- Only people logged in can rate an article
- The creator of the article can not rate their own article
- If the rating is readonly then it still needs to be displayed as stars, just not clickable ones.
Out of the box the Stars were always clickable, so I needed a way to show readonly stars when the user was not allowed to rate the article, luckily the above referenced blog explained how to do that in the blogs comments.
I'm using the acts_as_rated plugin, which works fine so long as you don't try using the optimized method of creating a stats table, that method didn't seem to work, and for now I'll just let the SQL do the math.
As I have many models that acts_as_rated I needed to write a helper to display the stars, and handle the rating clicks, and the business logic.
The helpers look a little ugly as you need to figure out what the parent model is.
So here is the code that goes in application_helper.rb
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 33 34 35 36 37 38 39 40 41 42 | def star_rating(rating, obj_type, id, allow_rate= true ) per= rating > 0 ? (rating/ 5 . 0 )* 100 : 0 ; url_meth= "rate_#{obj_type}_path" .to_sym if allow_rate links= [ link_to( '1' , send(url_meth, { :id => id, :rate => 1 }), :method => :put , :class => "one-star" , :title => "1 star out of 5" ), link_to( '2' , send(url_meth, { :id => id, :rate => 2 }), :method => :put , :class => "two-stars" , :title => "2 stars out of 5" ), link_to( '3' , send(url_meth, { :id => id, :rate => 3 }), :method => :put , :class => "three-stars" , :title => "3 stars out of 5" ), link_to( '4' , send(url_meth, { :id => id, :rate => 4 }), :method => :put , :class => "four-stars" , :title => "4 stars out of 5" ), link_to( '5' , send(url_meth, { :id => id, :rate => 5 }), :method => :put , :class => "five-stars" , :title => "5 stars out of 5" )] end r= " " r += "<span class=\"inline-rating\"> <ul class=\"star-rating\"> <li class=\"current-rating\" style=\"width:#{per}%;\"></li>" # if we can't rate then just show stars if allow_rate ( 0 .. 4 ). each do |i| r += "<li>#{links[i]}</li>" end end r += "</ul></span>" r += '<span class="prompt">Click star to rate</span>' if allow_rate r end def render_rating(o, allow_rating= true ) can_rate= allow_rating && logged_in? && ! o.created_by?(current_user) o_type= o. class .to_s.downcase if o.rated? r= "Rating #{o.rating_average}/5 by #{pluralize(o.rated_count, 'person')}" r += star_rating(o.rating_average, o_type, o.id, can_rate) elsif can_rate r= "Not yet Rated" r += star_rating( 0 , o_type, o.id, can_rate) else r= "" end r end |
The render_rating method is what you call from your view, the star_rating method generates the HTML.
(I presume you included the star_rating.css which is available from the above mentioned blog, and the acts_as_rated plugin.)
The render_rating method takes the model object being rated, and an optional override to allow_rating or not (which determines if the stars are clickable or not).
I include some text saying what the rating is and how many people have rated the article, followed by the stars showing the rating.
I have all my ratings set to 1 to 5, YMMV.
In my view (HAML) code I have...
= render_rating(place)
Which will render the rating for the current place article.
the Place model has this code...
acts_as_rated(:rating_range => 1..5, :rater_class => 'Person')
because I have a Person class as the rater rather than the default User.
The rating percentage is calculated using..
per= rating > 0 ? (rating/5.0)*100 : 0;
Which is used by the CSS to highlight the stars correctly.
I use RESTful routing so the links are generated using named routes, however as the named route will be different depending on the Model object being rated I need to use this code...
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 | # added to routes.rb to provide a rate method map.resources :places , :member => { :rate => :put } # added to places_controller.rb to handle the new route def rate @place = Place.find(params[ :id ]) rating= params[ :rate ].to_i @place .rate(rating, current_user.person) respond_to do |format| format.html { redirect_to :back } format.xml { head :ok } end end # code snippets from the application helper above # get the named route to use in this case rate_place_path url_meth= "rate_#{obj_type}_path" .to_sym ... # generate the named route, equivalent to # rate_place_path(:id => id, :rate => 1) send(url_meth, { :id => id, :rate => 1 }) |
One last thing slightly on topic, I added the following to the acts_as_rated.rb file so I could get a list of the top rated articles, seemed like a logical thing that anyone would want to do ;)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # Find the top rated items, ordered by average rating def find_top_rated(limit= nil ) rating_class = acts_as_rated_options[ :rating_class ].constantize base_sql = <<- EOS select #{table_name}.*,COALESCE(average,0) AS rating_average from #{table_name} left outer join (select avg(rating) as average, rated_id from #{rating_class.table_name} where rated_type = '#{class_name}' group by rated_id) as rated on rated_id=id order by rating_average DESC EOS base_sql += " LIMIT #{limit}" if limit find_by_sql base_sql end |
I hope this helps others trying the same thing, as always comments and code improvements always welcome.
I have to take this opportunity to mention one of the worst implementations of star rating I've seen.
Someone felt that the basic 5 stars wasn't good enough and decided to make a spectrum that reflects the percentage of votes for each rating. The result is a very confusing graphic.
http://www.summize.com/about.html
In _Information Dashboard Design_, Stephen Few mentions the kinds of graphical differentiation our minds can understand at first glance. Comparing several widths spread horizontally isn't one of them.
Sometimes it's better to stick with a widely-used meme that people understand!
Thats interesting, the engineer in me likes the concept ;) it would be nice to combine the rating and the number of people who rated it in one graphic, but then I never was any good at UI design.
Maybe you could overlay a line graph on top of the stars.
Or, just display the two graphs side-by-side.
Dude, I Love You!!!
I've been slacking on adding the stars rating to my rails project, but you just gave me a butt kick on getting that rolling. Thanks!
Great work!
If the object hasn't been rated it can't have an average rating.
Changing` o.rating_average` to 0 in the below code saves me a sql call (I'm using the stats table)
r= "Not yet Rated"
r += `star_rating(o.rating_average, o_type, o.id, can_rate)`
Good catch I'll update the code above
Thanks
Hi there, I'm using this same code but I keep getting this error:
"the rater object must be the one used when defining acts_as_rated (or a descendent of it). other objects are not acceptable"
Did you encounter this error when you were writing this example?
no, but then the code is pretty old, and newer versions of rails and/or acts_as_rated may have changed something, and I haven't upgraded either as the current versions work well enough.
If you find the solution please post here.
thanks
OH HAI! Awesome little tutorial/plugin/I-don't-know here :) I solved abenamer@yahoo.com's problem. It originates in the controller in your rate definition, specifically:
<code>
@landlord.rate(rating, current_user.person)
</code>
As you said, you used person instead of user, but it might have been vague. Regardless, you need to use something like:
<code>
@landlord.rate(rating, current_user)
</code>
if you are using the standard user system.
That is all.
Has anyone tried to cache the ratings?
I have a product search screen, and recalculating the rates is expensive, so I was thinking either caching the rates in the database, or fragment cache.
Any cool ideas?
I think the `act_as_rated` plugin has several schemes for the ratings, and one is to keep them in a table rather than calculate them each time, It should be easy to use that option instead of the calculate every time I use here.