Adding a Star Rating with Hotwire
Tags: ruby, rails, hotwire
Published: 2022-07-14 08:46:21

> Cover photo by <a href="https://unsplash.com/es/@grandbeau?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">GRANDBEAU</a> on <a href="https://unsplash.com/s/photos/hot-wire?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>

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

## 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][image-star-bar-demo]

## 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.

```bash
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`.

```ruby
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.

```ruby
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.

![Demo of the text rating](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ahxdprovizptfomapvi4.png)

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][heroicons] which is a suite of beautiful SVG icons from the makers of [TailwindCSS][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][svg-styling] 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`][inline-svg] that we'll make use of to make that easier.

Add to your `Gemfile`:

```ruby
gem "inline_svg"
```

Run in your terminal:

```bash
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.

```erb
<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.

```ruby
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.

```ruby
# 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
```

![Demo of static star bar](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ai0r56o8q1d7ksnv51o4.png)

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`.

```ruby
# 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`][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]`.

```erb
<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.

```css
.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.

```erb
<!-- 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.

```ruby
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][image-toast-demo]

## 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.

```bash
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.

```js
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.

```erb
<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][image-star-bar-demo]

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][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.

[turbo]: https://turbo.hotwire.dev
[single-page-search]: https://tonyrowan.tech/2021/12/04/making-a-single-page-search-with-turbo
[starting-point]: https://github.com/tony-rowan/turbo-movies/tree/codealong--star-bar--start
[pr]: https://github.com/tony-rowan/turbo-movies/pull/1
[heroicons]: https://heroicons.com
[tailwindcss]: https://tailwindcss.com
[svg-styling]: http://css-tricks.com/using-svg
[inline-svg]: https://github.com/jamesmartin/inline_svg
[button-to]: https://api.rubyonrails.org/classes/ActionView/Helpers/UrlHelper.html#method-i-button_to
[shoelace]: https://shoelace.style/components/rating

[image-star-bar-demo]: https://user-images.githubusercontent.com/2379659/148576104-cdef7b24-750f-4b81-b8db-200effcbae4b.gif
[image-text-rating]: https://user-images.githubusercontent.com/2379659/148576307-477a31e0-cadd-44ff-9977-a435d8b723f3.png
[image-static-stars]: https://user-images.githubusercontent.com/2379659/148576321-b4712d43-8536-44fc-a811-df301c91bb17.png
[image-toast-demo]: https://user-images.githubusercontent.com/2379659/148576117-2ffafcce-3e74-47d7-af57-f1232b172747.gif