Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

June 20, 2024

Last updated June 25, 2024

Hotwire Voting and Flash Messages with Ruby on Rails

In this tutorial, we will create a simple voting system for books using Ruby on Rails and Hotwire. Hotwire's unique features will help show more dynamic flash messages when you cast your vote.

Setting Up The Models

First, we must create Book, Vote, and User models. We generate these efficiently using Rails' scaffold and model generators. The user model comes in stock with Rails UI, which I would like to leverage to speed up Rails app development, giving you more project control and productivity.

It’s also my side project, and I’d love for you to check out if you need solid UI for your next Rails app!

Kick off the app

rails new hotwire_flash_messages

Add Rails UI to the Gemfile

# Gemfile 

gem "railsui", github: "getrailsui/railsui", branch: "main"

Bundle it!

bundle install

Run the Rails UI installer.

rails railsui:install

Boot your server and select a Rails UI template to base the project on. I chose Hound. Save your changes and reboot your server.

Generate resources

rails g scaffold Book title description:text user:references
rail g model Vote user:references book:references

The Book model belongs to a User and has many Votes. Each Vote is unique to a User for a particular Book.

# app/models/book.rb

class Book < ApplicationRecord
  belongs_to :user
  has_many :votes, dependent: :destroy
  has_many :voters, through: :votes, source: :user
end

Update the Vote model.

# app/models/vote.rb

class Vote < ApplicationRecord
  belongs_to :user
  belongs_to :book
  validates :user_id, uniqueness: { scope: :book_id }
end

The User model is generated with the Devise gem when we run the Rails UI installer. Each User can have many Books and Votes. Also included is the name_of_person gem and an :avatar active storage attachment.

class User < ApplicationRecord
  has_person_name
  has_one_attached :avatar

  has_many :books
  has_many :votes

  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
end

Adding Routes

In the config/routes.rb we create a nested route for Votes under Books.

resources :books do
  resources :votes, only: [:create]
end

root "books#index"

Seeding Data

To seed data, we first add the Faker gem:

bundle add faker --group=development

Then we populate the db/seeds.rb file with some dummy data for Users and Books.

# db/seeds.rb

User.destroy_all
Book.destroy_all

user_1 = User.create(name: "Andy Leverenz", email: "[email protected]", password: "password", password_confirmation: "password")
user_2 = User.create(name: "Jane Blah", email: "[email protected]", password: "password", password_confirmation: "password")
user_3 = User.create(name: "Travis Smith", email: "[email protected]", password: "password", password_confirmation: "password")

20.times do
  Book.create!(title: Faker::Book.title, description: Faker::Lorem.paragraph(sentence_count: 3), user: [user_1, user_2, user_3].sample)
end

Creating the Votes Controller

Generate the Votes controller with a create action.

rails g controller votes create

The create action creates a new Vote for the current User and the selected Book.

class VotesController < ApplicationController
  before_action :authenticate_user!
  before_action :set_book

  def create
    @vote = @book.votes.new(user: current_user)

    respond_to do |format|
      if @vote.save
        flash.now[:notice] = "Vote casted successfully"
        format.html { redirect_to @book }
        format.turbo_stream
      else
        flash.now[:alert] = "You have already voted for this book"
        format.html { redirect_to @book }
        format.turbo_stream
      end
    end
  end

  private

  def set_book
    @book = Book.find(params[:book_id])
  end
end

Reponse types include html and turbo_stream. Our focus will be turbo_stream.

Updating the Views

We update the views for Books and Votes. The Books index view lists all the books, and the book partial view displays a book's details and the voting button.

The voting button checks whether the user is signed in and has already voted for the book. The button links to the sign-in page if the user is not signed in.

<!-- app/views/books/_book.html.erb -->

<article id="<%= dom_id book %>" class="prose dark:prose-invert p-6 rounded-lg border flex items-start gap-6 bg-white shadow-sm">
  <%= render "votes/vote_button", book: book %>
  <div class="flex-1">
    <p class="my-0">
      <%= link_to book.title, book %>
    </p>
    <p class="my-0">
      <%= book.description %>
    </p>
  </div>
</article>

For the voting button I’ll add the following code to app/views/votes/_vote_button.html.erb. You’ll see we first check if a user is signed in and redirect them to do so before casting a vote.

<% if user_signed_in? %>
  <%= turbo_frame_tag "#{dom_id(book)}_vote_button" do %>
    <% unless book.votes.exists?(user: current_user) %>
      <%= button_to book_votes_path(book), class: "btn btn-white !flex-col" do %>
        <%= icon "arrow-up", classes: "size-3" %>
        <span><%= book.votes.size %></span>
      <% end %>
    <% else %>
      <button class="btn btn-white !flex-col disabled:opacity-70 disabled:pointer-events-none disabled:bg-gray-100 disabled:shadow-none" disabled>
        <%= icon "arrow-up", classes: "size-3" %>
        <span><%= book.votes.size %></span>
      </button>
    <% end %>
  <% end %>
<% else %>
  <%= link_to new_user_session_path, class: "btn btn-white !flex-col" do %>
    <%= icon "arrow-up", classes: "size-3" %>
    <span><%= book.votes.size %></span>
  <% end %>
<% end %>

Handling Responses

We use Turbo Streams to update the flash messages in the response from the votes controller. Here, we can render updated flash messages and button styles for the voting UI.

<!-- app/views/votes/create.turbo_stream.erb-->

<%= turbo_stream.update "flash", partial: "shared/flash" %>

<%= turbo_stream.update "#{dom_id(@book)}_vote_button" do %>
  <%= render "votes/vote_button", book: @book %>
<% end %>

Adding a flash Stimulus controller

rails g stimulus flash

Insert dynamic JavaScript to create a Toast-like component.

// app/javascript/controllers/flash_controller.js

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="flash"
export default class extends Controller {
  static targets = ["toast"]

  connect() {
    requestAnimationFrame(() => {
      this.toastTarget.classList.remove("translate-x-full")
      this.toastTarget.classList.add("translate-x-0")
    })

    setTimeout(() => {
      this.dismiss()
    }, 3000)
  }

  dismiss() {
    this.toastTarget.classList.add(
      "opacity-0",
      "transition-opacity",
      "duration-300",
      "translate-x-full"
    )
    setTimeout(() => {
      this.toastTarget.remove()
    }, 300)
  }
}

Update the shared/flash partial that ships with Rails UI OR create a new flash partial.

<!--- app/views/shared/_flash.html.erb-->

 <%= turbo_frame_tag "flash" do %>
  <% flash.each do |type, message| %>
    <div data-controller="flash" data-flash-target="toast" class="fixed z-50 top-2 right-2 border rounded-md p-3 shadow-lg opacity-100 translate-x-full transition-transform ease-in-out duration-300 transform <%= type == :alert ? "bg-red-50 border-red-500/50 text-red-800" : "bg-white border-gray-300/90 text-gray-800" %>">
      <div class="flex items-center justify-between gap-4">
        <%= icon "check", classes: "size-4 stroke-primary-500" %>
        <span><%= message %></span>
        <button type="button" data-action="click->flash#dismiss" class="p-1 hover:bg-gray-100/70 flex items-center justify-center rounded-md">
          <%= icon "x-mark", class: "size-4 stroke-current text-gray-600 pointer-events-none" %>
        </button>
      </div>
    </div>
  <% end %>
<% end %>

Final Result

We have successfully created a simple book voting system with Rails and Hotwire. All flash messages utilize our new turbo_stream responses with Stimulus-enabled toast UI. Users can cast votes for books, and the vote count and button state update in real-time and know their votes have been cast.

If you want to see this in action, check out the final result video:

New course

Want to master Hotwire with Ruby on Rails? Check out my new free course, Hello Hotwire, which is coming soon.

Hello Hotwire Promo

Happy coding!

Link this article
Est. reading time: 6 minutes
Stats: 2,055 views

Categories

Collection

Part of the Hotwire and Rails collection

Products and courses