14 January 2022

Adding a Star Rating with Turbo

ruby rails hotwire

This post walks through the process of adding a rating system to a Turbo powered single page application. The application in question was built up in a previous post. If you want to follow along with the post, you can grab the repo at the starting point or just read the pull request.

What We’re Building

In this walkthrough, we’re going to be adding a basic star bar rating system to our movies single page application. You might be surprised to see that we can get a basic working version of this with no additional javascript whatsoever!

Demo of the Star Bar Rating

Add Ratings

The first step is to add ratings to our movies. Run the following in your terminal to create a migration file that will add a new column to our movies table.

bin/rails generate migration add_rating_to_movies rating:integer

Open the file and edit it to add a not-null constraint and a default value of 0.

class AddRatingToMovies < ActiveRecord::Migration[7.0]
  def change
    add_column :movies, :rating, :integer, null: false, default: 0
  end
end

We can also add a validation to our Movie model to ensure the rating stays within a sane bound that we can display on our star bar.

class Movie < ApplicationRecord
  validates :rating, numericality: { in: 0..5 }
end

Now we can run db:migrate to add the column. If you open up a console with bin/rails console now you’ll be able to update the rating on a movie e.g. Movie.first.update(rating: 5). Our validation will prevent you from setting the rating to something we can’t display like -1 or 6.

Now we can update the view for our movies to display the rating.

Text Rating

It’s pretty basic though, we can improve on that.

Add the Star Bar

The next step in creating our star bar is the stars! We’re going to use icons from the wonderful heroicons which is a suite of beautiful SVG icons from the makers of TailwindCSS. There’s an icon called star that we’ll use, copy the SVG contents of the icon and paste it into a file called app/assets/images/star.svg.

The best way to style an SVG icon (which is one of the big advantages of the format, in my opinion) is to embed the SVG in the HTML document (render it inline with the rest of the HTML) and from there apply CSS styles as you might expect. There’s a gem called inline_svg that we’ll make use of to make that easier.

Add to your Gemfile:

gem "inline_svg"

Run in your terminal:

bundle install

Now we can use the inline_svg_tag in our views.

To render our star bar we need to swap out the text rating we just added and replace it with this.

<div class="flex gap-x-3 mb-4">
  <% Movie::MAX_RATING.times do |n| %>
    <%= inline_svg_tag("star.svg",
      class: "w-8 h-8 #{star_rating_class(@movie, n)}") %>
  <% end %>
</div>

We going to render stars using the inline_svg_tag a total of Movie::MAX_RATING number of times. This is a constant we’ve introduced for the maximum rating i.e. the maximum number of stars. To avoid duplication, we should reuse it in the validation.

class Movie < ApplicationRecord
  MAX_RATING = 5

  validates :rating, numericality: { in: 0..MAX_RATING }
end

We’ve also extracted a new helper method star_rating_class so we can keep the template readable by extracting the logic for which CSS classes the star should have, based on what rating the movie has. If the movie has a rating of 4 then stars with index 0 through 3 should be solid yellow and the last star, with index 4, should be a grey outline.

# app/helpers/rating_helper.rb

module RatingHelper
  def star_rating_class(movie, index)
    if index < movie.rating
      "fill-yellow-400 stroke-yellow-400"
    else
      "fill-transparent stroke-gray-400"
    end
  end
end

A Static Star Bar

Our star bar looks a lot nicer, but it’s still not really doing anything. Let’s change that next.

Submit a Rating

We want users to be able to click a star and apply that as a rating. We will need to add the update action to the routes for movies and implement that action in the MoviesController.

# config/routes.rb

Rails.application.routes.draw do
  resources :movies, only: %i[index show update]
end
# app/contollers/movies_controller.rb

def update
  @movie = Movie.find(params[:id])

  @movie.update(rating: params[:movie][:rating])

  render :show
end

The update action is fairly simple CRUD, but we only support updating the rating so there’s no need for mass assignment. We’re also rendering the show template at the end where we would normally redirect back to the show action. Redirecting would work here, but we’re going to make use of not redirecting later when we add feedback.

In the template, we just need to turn the stars into buttons that will submit a form to the update endpoint PATCH movies/:id. We’ll use the button_to template helper to do this. We’ll set the method to :patch, the default is :post. We also need to add the index of the star (plus 1 to account for the 0-based index) as a parameter in the form. In the HTML, this will be a hidden field with the name movie[rating].

<div class="flex gap-x-3 mb-4">
  <% Movie::MAX_RATING.times do |n| %>
    <%= button_to movie_path(@movie),
      method: :patch,
      params: { movie: { rating: n + 1 } } do %>
      <%= inline_svg_tag("star.svg",
        class: "
          w-8 h-8
          hover:fill-yellow-500 hover:stroke-yellow-500
          #{star_rating_class(@movie, n)}
        ") %>
    <% end %>
  <% end %>
</div>

If you try clicking stars now, you’ll notice that it works perfectly, without any page refreshes. How? Turbo is taking over the form submission so that it is asynchronous, and when the response comes back and includes the :details turbo-frame, it swaps out the contents for the contents of the frame in the response.

In previous versions of Turbo, you could not return HTML from a form submission unless the status code was an error, successful submissions had to redirect. Fortunately for us, this is no longer a restriction.

User Feedback

The star bar is now working as intended, but it happens so seamlessly, it’s not obvious that the user’s rating has been persisted. We can improve this with a little user feedback. We can add a small ‘toast’ next to the star bar after the user rates a movie.

Let’s start by adding and styling our toast. It’s just some plain HTML next to the star bar, coupled with a CSS animation to make it disappear after a short time. The icons used are also from heroicons. I’ve used the check-circle and exclamation icons.

.toast {
  animation: toast 3s linear 1 both;
}


@keyframes toast {
  0% {
    opacity: 0.0;
    visibility: hidden;
  }


  3% {
    opacity: 1.0;
    visibility: visible;
  }


  80% {
    opacity: 1.0;
    visibility: visible;
  }


  100% {
    opacity: 0.0;
    visibility: hidden;
  }
}

By setting visibility: hidden we remove the toast from the accessibility tree and focus events, though it still affects layout. We use this over display: none since it can be used in an animation, we would need to use javascript to apply a change to the display attribute and it doesn’t matter to us here that it still affects layout - you might argue that it’s better since nothing will move when the toast completes.

<!-- app/views/movies/show.html.erb -->

<!-- Within the same flex group as the stars
     but after all the stars have been rendered -->

<% if @toast %>
  <div class="flex items-center gap-x-1 px-4 py-2
    bg-black/75 rounded text-white text-sm
    relative top-[-2px]
    toast">
    <% if @toast == :success %>
      <%= inline_svg_tag("check.svg",
        class: "h-4 w-4 text-green-500 relative top-px") %>
      <span>Rating Added!</span>
    <% else %>
      <%= inline_svg_tag("warning.svg",
        class: "h-4 w-4 text-red-500 relative top-px") %>
      <span>Rating could not be added</span>
    <% end %>
  </div>
<% end %>

The @toast variable comes from the update action. This is why we chose to render the :show template, rather than redirecting. Though we could achieve the same effect with a redirect by making use of the flash object.

def update
  @movie = Movie.find(params[:id])

  if @movie.update(rating: params[:movie][:rating])
    @toast = :success
  else
    @toast = :warning
  end

  render :show
end

We simply set the @toast variable to whether or not the update happened successfully. Since the show action does not define the @toast variable, the message only shows in response to a call to the update action, i.e. clicking on a star.

A Demo of the Toast

Filling the Star Bar

If you’ve ever used a star bar in the past (I’m guessing you have) you’ll have noticed that they tend to fill up from 0 to the point you hover over. This is an effect we can reproduce with a small amount of stimulus.

To start with, create a new stimulus controller by running this in your terminal. This will make sure your stimulus manifest is up to date (not applicable if you’re using webpacker), as well as add the boilerplate for the controller and create a correctly named file.

bin/rails generate stimulus starBar

The basic expectation of the star bar is that when you hover over a star, that star and all the stars within an index lower than that star will show their hover state. When not hovering over any star, the star bar should simply display the rating.

The StarBarController looks like this.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["star"];
  static classes = ["hover"];

  enter(event) {
    this._fillToStar(event.params.starIndex);
  }

  leave() {
    this._fillToStar(-1);
  }

  _fillToStar(star) {
     this.starTargets.forEach((target, index) => {
      if (index <= star) {
        target.classList.add(this.hoverClass);
      } else {
        target.classList.remove(this.hoverClass);
      }
    });
  }
}

The two actions enter and leave simply toggle the hover class on the appropriate stars, depending on which star the user is hovering over or none if the user is not hovering over any stars.

The controller has one target called star. Since we expect a list of these (the 5 star icons) we’re using this.starTargets in the plural to access them. The controller also has a class called hover - we use this so we can define the hover class for the stars in the markup rather than hardcode them in the controller.

Now we just need to add to markup to connect our star bar to the controller. Our star bar now looks like this.

<div class="flex gap-x-3 mb-4"
  data-controller="star-bar"
  data-star-bar-hover-class="fill-yellow-500 stroke-yellow-500">
  <% Movie::MAX_RATING.times do |n| %>
    <%= button_to movie_path(@movie),
      method: :patch,
      params: { movie: { rating: n + 1 } } do %>
      <%= inline_svg_tag("star.svg",
        class: "w-8 h-8 #{star_rating_class(@movie, n)}",
        data: {
          star_bar_target: "star",
          star_bar_star_index_param: n,
          action: "pointerenter->star-bar#enter
            pointerleave->star-bar#leave"
        }) %>
    <% end %>
  <% end %>
  <!-- the toast is omitted, but would be here -->
</div>

The definition for the hover classes, data-star-bar-hover-class has to be on the same HTML element that attaches the controller. We also mark each of the stars as part of the star target with data-star-bar-target="star", we don’t have to treat it differently just because it is going to be used as an array within the controller.

When the user hovers over a star we need to toggle the hover class on for each of the stars up to the star the user hovered over. We can pass parameters to our stimulus controller actions. In our case, we need to pass the index of the star the user hovered over. This is what the data-star-bar-star-index-param="n" is doing. We can then access the parameter in the action with event.params.starIndex. The generic pattern for this is data-controller-name-param-name="value", the parameter can then be accessed from the action with event.params.paramName - the parameters name is converted to snakeCase, just like the controller names are.

Demo of the Star Bar Rating

This star bar is very limited, I only really did it for some fun. For a more complete implementation of a star rating bar, check out a component library like Shoelace.

Conclusion

We’ve managed to create a fully-featured, interactive star rating bar and most of the changes were in our HTML. We didn’t have to write any javascript if we didn’t want to.