jQuery Star Rating with ASP.NET MVC

There are a number of jQuery star rating plugins to choose from. All have their pros and cons. For this site, I decided to implement the one from FyneWorks because it allows for split stars. Here's how I did it.

The star rating system allows people to easily provide feedback on a blog item if they don't feel like submitting a comment. The rating plugin that I chose can be configured to allow any score to be applied, although I opted to allow people the provide a score from 1 - 5. I also wanted to give accurate information on the current average rating for each item, which meant that I need to show stars partially completed when an item's average rate is not a whole number. I also needed to prevent people rating an item more than once.

To begin with, I downloaded the plugin files, which consists of a number of .js files including jquery.MetaData.js, jquery.rating.pack.js and jquery.rating.js. There is also some documentation in the form of an html file, plus a style sheet for the rater and an image file for the stars. First thing I did was check the documentation for details on database integration, and was met with a message that this does not exist. The whole deal is completely up to me. Great. Then again, I suppose that there are so many options for connecting to databases - php to MySQL, ASP.NET to SQL Server via Linq To SQL, ADO.NET, Entity Framework, nHibernate, ColdFusion to whatever - that it would be a bit much to expect the authors to cover all angles. My method will be connecting to SQL Server via the Entity Framework, in keeping with previous articles on developing this site.

The rater will appear on the articles page. At the moment, there is nothing in the database to cope with rating articles, so I add two columns to the Articles table - Rating (int) and TotalRaters (int). As each rating comes in, I will increment the Rating value for the article by the score given by the rater. I will also add 1 to the TotalRaters for that article. I can calculate the average rating per article by dividing the Rating by the TotalRaters. I set the default value for both columns at 0. Having done that, I refresh my Model. The Article class is enhanced with the two extra properties:

I will be using a PartialView for the rater, so I create a class for the strongly typed ViewModel that it will use. The class will only contain 4 properties:


namespace MikesDotnetting.Models
{
  public class ArticleRating
  {
    public int ArticleID { get; set; }
    public int Rating { get; set;}
    public int TotalRaters { get; set; }
    public double AverageRating { get; set;}
  }
}

You may notice that this class is a subset of the full Article class properties (apart from the AverageRating property). You might be wondering why I am not using the full Article class. The reason is that I only need the data in this new class for the PartialView, and don't want to encumber it with more than necessary. Not only that, but exposing just these properties makes the PartialView more reusable.

Speaking of the PartialView, here it is:


<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<ArticleRating>" %>
<script type="text/javascript">
  $(function() {
    $('.star').rating('readOnly', true);
    $('#rater').hide();
    $('#rated').mouseover(function() {
      $('#rated').hide();
      $('#rater').show();
    });

    $('.auto-submit-star').rating({
      callback: function(value, link) {
        $.ajax({
          type: "POST",
          url: "/Article/Rate",
          data: $("#rate").serialize(),
          dataType: "text/plain",
          success: function(response) {
            if (response != 'false') {
              var data = eval('(' + response + ')');
              alert('Your rating has been recorded');
              $('#currentlyrated').html('Currently rated ' + data.AverageRating.toFixed(2) +
                ' by ' + data.TotalRaters + ' people');
            } else {
              alert('You have already rated this article');
            }
            $('#rater').hide();
            $('#rated').show();
          },
          error: function(response) {
            alert('There was an error.');
          }
        });
      }
    });
  });
</script>
  <div id="rated">
  <div style="float:left">
    <form id="Form1" method="post" action="">
      <input class="star {split:4}" type="radio" value="1" name="rating" <%= Utils.Check(0,0.25,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="2" name="rating" <%= Utils.Check(0.25,0.5,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="3" name="rating" <%= Utils.Check(0.5,0.75,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="4" name="rating" <%= Utils.Check(0.75,1,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="5" name="rating" <%= Utils.Check(1,1.25,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="6" name="rating" <%= Utils.Check(1.25,1.5,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="7" name="rating" <%= Utils.Check(1.5,1.75,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="8" name="rating" <%= Utils.Check(1.75,2,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="9" name="rating" <%= Utils.Check(2,2.25,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="10" name="rating" <%= Utils.Check(2.25,2.5,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="11" name="rating" <%= Utils.Check(2.5,2.75,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="12" name="rating" <%= Utils.Check(2.75,3,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="13" name="rating" <%= Utils.Check(3,3.25,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="14" name="rating" <%= Utils.Check(3.25,3.5,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="15" name="rating" <%= Utils.Check(3.5,3.75,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="16" name="rating" <%= Utils.Check(3.75,4,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="17" name="rating" <%= Utils.Check(4,4.25,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="18" name="rating" <%= Utils.Check(4.25,4.5,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="19" name="rating" <%= Utils.Check(4.5,4.75,Model.AverageRating) %>/>
      <input class="star {split:4}" type="radio" value="20" name="rating" <%= Utils.Check(4.75,5,Model.AverageRating) %>/>
      </form>
    </div>
    <p id="currentlyrated" style="float:left;padding-left:20px;">
    <%= Model.AverageRating > 0 ? "Currently rated " + Model.AverageRating.ToString("f") + " by " + Model.TotalRaters + " people" 
       : "<span style=\"color:red\">Not yet rated.  Be the first to rate this article!</span>"%>
    </p>
  </div>
  <div style="clear:both"></div>
  
  <div id="rater">
    <div style="float:left;">
      <form id="rate" method="post" action="">
      <input class="auto-submit-star" type="radio" name="score" value="1"/>
      <input class="auto-submit-star" type="radio" name="score" value="2"/>
      <input class="auto-submit-star" type="radio" name="score" value="3"/>
      <input class="auto-submit-star" type="radio" name="score" value="4"/>
      <input class="auto-submit-star" type="radio" name="score" value="5"/>
      <input type="hidden" name="ArticleID" value="<%=Model.ArticleID %>" />
      </form>
    </div>
    <p style="float:left;padding-left:20px;">
      Rate Now!
    </p>
  </div>
  <div style="clear:both"></div>

Right - before you simply copy and paste this and hope it will run, there is a fair amount to explain. I'll start with the second of the two divs. This is the 5 star rater which users will ise to score articles. I've used the option with a class of auto-submit-star, which according to the documentation is the one that does not need a submit button. It does need a bit of AJAX, which I will get to shortly. As well as the 5 stars for rating, the radio buttons are housed in a form which also contains a hidden field representing the ArticleID of the current article.

The previous div is the one that contains the split stars and will be shown to users when they first load an article. It will feature the current rating for that item. There are 20 of these, which when split into 4 represents 5 whole stars. Each star is capable of displaying ¼, ½, ¾ or a full star. That's as accurate as I need for display purposes. You will notice the <%= Utils.Check(0,0.25,Model.AverageRating) %> which appears in each input tag. This refers to a utility method that returns a string: checked="checked" if the average rating falls between two values. Here is the code for the method. It's dead simple:


public static string Check(double lower, double upper, double toCheck)
{
  return toCheck > lower && toCheck <= upper ? " checked=\"checked\"" : null;
}

I tried a number of ways to set the input as checked from client script, but in the end gave up. My lack of javascript knowledge eventually defeated me. I need to spend a lot more time examining the jquery.rater.js file, along with the MetaData.js file, I suspect.

Both the jquery.rater.pack.js file and the jquery.MetaData.js file are linked to in the main View, which is why they are not referenced in the Partial. The first segment of jQuery code sets the current rater (the first div) to disabled so that the user cannot use it to score with. It also hides the "live" rater, but adds a mouseover event to the disabled one which reveals the live rater, while hiding the disabled one. The message alongside the rater will change from a summary of the current score to one that invites users to Rate Now!

The second part of the jQuery code manages the AJAX form submission. Both the values from the form (the rate and the ArticleID) are serialized and posted to the Rate action on the Article Controller:


[AcceptVerbs("post")]
public ActionResult Rate(FormCollection form)
{
  var rate = Convert.ToInt32(form["Score"]);
  var id = Convert.ToInt32(form["ArticleID"]);
  if (Request.Cookies["rating" + id] != null)
      return Content("false");
  Response.Cookies["rating" + id].Value = DateTime.Now.ToString();
  Response.Cookies["rating" + id].Expires = DateTime.Now.AddYears(1);
  ArticleRating ar = repository.IncrementArticleRating(rate, id);
  return Json(ar);
}

Having taken the posted form values, the Action checks to see if there is a cookie called rating with the id of the article as part of its name, which would indicate that the user has rated this article previously. If there is, it sends back "false", but does not record the submitted score. If not, it creates a cookie showing that this article has been rated, and sets the expiry date of the cookie for one year's time. It then calls a method in the repository:


public ArticleRating IncrementArticleRating(int rate, int id)
{
  var article = de.ArticleSet
            .Where(a => a.ArticleID == id)
            .First();
  article.Rating += rate;
  article.TotalRaters += 1;
  de.SaveChanges();
  var ar = new ArticleRating()
             {
               ArticleID = article.ArticleID,
               Rating = article.Rating,
               TotalRaters = article.TotalRaters,
               AverageRating = Convert.ToDouble(article.Rating)/Convert.ToDouble(article.TotalRaters)
             };
  return ar;
}

which increments the Rating value by the score submitted, and the TotalRaters by 1. The updated values are then sent back to the Rate action to be serialized as JSON before being returned to the jQuery's success callback (repeated here to save you scrolling back up the page):


success: function(response) {
  if (response != 'false') {
    var data = eval('(' + response + ')');
    alert('Your rating has been recorded');
    $('#currentlyrated').html('Currently rated ' + data.AverageRating.toFixed(2) + 
      ' by ' + data.TotalRaters + ' people');
  } else {
    alert('You have already rated this article');
  }
  $('#rater').hide();
  $('#rated').show();
},
error: function(response) {
  alert('There was an error.');
}
          

If the response is not "false", it will be JSON. This is parsed using eval(), and while an alert is shown to the user confirming successful logging of their score, the paragraph containing the average rate is updated, and the rater is shown again.

Improvements

There are some things that I would like to have included. The first is the ability to set the initial state of the split star rater via javascript so that I don't need the Check() helper method. That would also apply when an article has just been rated so that I can update the stars as well as the paragraph containing the revised average rating. However, I tried a number of ways to achieve this without success. Simply using jQuery to set the attribute on the relevant radio didn't work. It just stopped the starts being shown. Using FireFox to view the generated DOM didn't help much either. I really need to know more javascript than I do.

The other thing that I would like to have done is to apply a mouseout to the second div - the one that allows people to rate. Again, just applying a mouseout to the div with the id of rater had the desired effect, until the mouse moved from one star to the next. It seemed that instead of being applied to the div itself, the mouseout had been applied to each individual star (or generated div containing a star). The plugin code actually generates some DOM which consists of a span containing a series of divs and <a> elements which house the star images. I could not understand why the mouseout did not apply to the containing elements, and could not find a solution.