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.
Happy coding!
Categories
Collection
Part of the Hotwire and Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.