DISCLAIMER
Time ago i wrote an article here, on how to build a notifications system with Rails and Redis, from scratch. This new one it's a quick update of that one.
If you don't know what i'm talking about, and are curious on how to accomplish same thing without Hotwire, here you go:
Real Time Notification System with Sidekiq, Redis and Devise in Rails 6
Matias Carpintini ใป Apr 25 '20
Rails 7 was a huuuge update. I'm not going to talk about that in this post but Hotwire. Hotwire became -officially- part of Rails 7. Now you can have real-time interactions, SPA looking Rails projects, without even write a single line of JS.
TL;TR
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 some button clicks :p
Hands on ๐
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 setup that 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 to what you're looking for ๐
How to notifications
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, read about that 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 noticed that unviewed_notifications_count doesn't exists 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 of the whole article (notification.rb
). Turbo actions. Let's take the first callback to explain what's going on.
- broadcast: now turbo handle the sttuff we built on the previous article. TD;TR: ApplicationCable to stablish 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 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 exists 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 have a simple callback that runs once we create a new resource (post in this case) object.
- If that resource model have the user_ids method, it will create the notifications for that users. ```ruby
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`
```ruby
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's already couple job offers for Rails devs, if you're looking for job, check this out.
Bye.
Top comments (8)
Very helpful article.
<% if user_signed_in? %>
<%= turbo_stream_from dom_id(current_user, :broadcast_to) %>
<% end %>
when I added in the layout, the application started through an error in the browser console
Any idea about it?
Seems that there's an asset that couldn't be loaded. Can you share me a screenshot of the full error?
Thanks for taking interest in helping, I have found the issue and solved it. The issue is not related actionable directly, I have two entry points webpack.config.js so build creating actioncable.js and .js.map files separately and this is causing problems in production.
in the development environment all working good, I am getting this error on heroku.
Very nice guide, thank you โ๏ธ
My pleasure! ๐