Some time ago, I wrote an article here on how to build a notifications system with Rails and Redis from scratch. This new one is a quick update on that one.
If you don't know what I'm talking about and are curious about how to accomplish the same thing without Hotwire, here you go:
👉 Real-Time Notification System with Sidekiq, Redis and Devise in Rails 6
Rails 7 was a huuuge update, but I'm not going to talk about that in this post. Hotwire -officially- became a part of Rails 7. Now, you can have real-time interactions and SPA looking-Rails projects, without even writing a single line of JS.
Hotwire can be understood as an umbrella/approach. Inside this thing, you will find 2 little boys, Turbo, that replaces TurboLinks, and Stimulus, which is, in their own words: A modest JavaScript framework for the HTML you already have, or the thing that we're going to use when we need to listen to some button clicks :p
I'm going to use a clean project, you can skip this if you want. Therefore:
$ rails new notifications_with_hotwire -d=postgresql -T
???
-d=postgresql
specify the DB that we're going to use. By default, Rails uses sqlite3.
-T
skips test files.
To make it more real, let's include Devise and Tailwind with their icons library, Heroicons:
$ bundle add devise tailwindcss-rails heroicon
Now we need to set up those gems...
For tailwind and heroicons, just run $ rails tailwindcss:install && rails g heroicon:install
For Devise, run $ rails g devise:install && rails g devise User
and follow their notes.
I'm also going to create a resource that will dispatch notifications later on: $ rails g scaffold Post title body:rich_text user:references
. Don't forget to assign user on create,
before_action :authenticate_user!, except: [ :index, :show ]
def create
@post = current_user.posts.new(post_params)
....
And of course, specify the relationship on the User model with:
has_many :posts
Finally, let's move on to what you're looking for 😉
I want to create notifications for different resources, such as posts, comments, or likes. For this, I'm using a polymorphic reference. If you're not familiar with it, read about it here.
$ rails g model Notification item:references{polymorphic} user:references viewed:boolean
Just add a default value for viewed field on the migration:
t.boolean :viewed, null: false, default: false
Build a basic nav and place the notifications count badge on it with something like:
<%= link_to notifications_path, class: "bg-gray-800 p-1 rounded-full text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white" do %>
<span class="sr-only">View notifications</span>
<%= heroicon "bell", variant: :outline, options: { class: "h-6 w-6" } %>
<%= tag.div id: :notifications_count do %>
<%= render "notifications/count", count: current_user.unviewed_notifications_count %>
<% end %>
<% end %>
You may notice that unviewed_notifications_count doesn't exist on our User model, so let's define that as a class method on user.rb
:
has_many :notifications
def unviewed_notifications_count
self.notifications.unviewed.count
end
And the app/views/notifications/_count
partial is needed for Turbo. Going to explain this below.
<%= tag.div id: :notifications_count do %>
<% if count > 0 %>
<span class="h-6 w-6 flex items-center justify-center bg-red-500 rounded-md text-sm">
<%= count > 9 ? "+9" : count %>
</span>
<% end %>
<% end %>
Here's the thing about the whole article (notification.rb
). Turbo actions. Let's take the first callback to explain what's going on.
broadcast: now turbo handles the stuff we built on the previous article. TD;TR: ApplicationCable to establish the connection between the user and the server, create and send jobs to the background to be performed async with Redis.
prepend/replace/remove: simple JS actions
to: the target (AKA stream). In this case, I'm establishing a connection with the specific signed user.
target: simple HTML (or turbo_frame) element used to perform the JS action.
scope :unviewed, ->{ where(viewed: false) }
default_scope { latest }
after_create_commit do
broadcast_prepend_to "broadcast_to_user_#{self.user_id}",
target: :notifications
end
after_update_commit do
broadcast_replace_to "broadcast_to_user_#{self.user_id}",
target: self
end
after_destroy_commit do
broadcast_remove_to "broadcast_to_user_#{self.user_id}",
target: :notifications
end
after_commit do
broadcast_replace_to "broadcast_to_user_#{self.user_id}",
target: "notifications_count",
partial: "notifications/count",
locals: { count: self.user.unviewed_notifications_count }
end
end
Yup, the latest scope didn't exist either, I like to define that on application_record.rb
.
scope :latest, ->{ order("created_at DESC") }
In application.html.erb
, create the stream that allows the previous snippet "talk" with the signed user (without this, everyone's getting everyone's notifications 😛)
<% if user_signed_in? %>
<%= turbo_stream_from dom_id(current_user, :broadcast_to) %>
<% end %>
Now let's put notifications-related stuff on concerns/notificable.rb
.
included will append where we import this concern. It sets a relationship and has a simple callback that runs once we create a new resource (post in this case) object.
If that resource model has the user_ids method, it will create the notifications for that user.
module Notificable
extend ActiveSupport::Concern
included do
has_many :notifications, as: :item, dependent: :destroy
after_create_commit :send_notifications_to_users
end
def send_notifications_to_users
if self.respond_to? :user_ids
self.user_ids&.each do |user_id|
Notification.create user_id: user_id, item: self
end
end
end
end
So then, when we want to create notifications for a new resource, we just need to include that concern on our resource model, in this case post.rb
include Notificable
def user_ids
User.where.not(id: self.user_id).ids
end
Finally, to show the notifications on our application UI, let's respond to /notifications path on notifications_controller.rb
class NotificationsController < ApplicationController
before_action :authenticate_user!
def index
@notifications = current_user.notifications
@notifications.update(viewed: true)
end
end
Don't forget to notify the controller about this new path on config/routes.rb
resources :notifications, only: [ :index ]
And there you go. The notifications index :)
views/notifications/index.html.erb
<div class="space-y-4">
<p class="text-lg leading-6 font-medium text-gray-900 flex justify-between">
Notifications
</p>
<ul class="border-t border-b border-gray-200 divide-y divide-gray-200" id="notifications">
<%= render @notifications %>
</ul>
</div>
The previous render @notifications
will look for the views/notifications/_notification.html.erb
partial.
And I like to render a new partial here since we have a polymorphic relation.
<li class="py-4" id="<%= dom_id(notification) %>">
<%= render "notifications/#{notification.item_type.downcase}", notification: notification %>
</li>
That previous render
will look for views/notifications/_post.html.erb
(in this case). If you create notifications for, let's say, likes, then you need to create a new partial, called _like
. And customize the notification for that new resource.
<p class="text-gray-900">
<%= notification.item.user.email %> just posted:
<%= link_to notification.item.title, notification.item, class: "underline font-medium" %>
</p>
Here's the repo with everything :)
Oh, and I'm building a job board for devs that want to work remotely. There are already a couple of job offers for Rails devs, if you're looking for a job, check this out.
Bye.
Also published here
Lead Photo by Johannes Plenio on Unsplash