DEV Community

Matias Carpintini
Matias Carpintini

Posted on

Real Time Notification System with Hotwire, in Rails 7

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:


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

Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

And of course, specify the relationship on the User model with:



has_many :posts


Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Just add a default value for viewed field on the migration:



t.boolean :viewed, null: false, default: false


Enter fullscreen mode Exit fullscreen mode

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 %>


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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 %>


Enter fullscreen mode Exit fullscreen mode

Here's the thing of the whole article (notification.rb). Turbo actions. Let's take the first callback to explain what's going on.

  1. 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.
  2. prepend/replace/remove: simple JS actions
  3. to: the target (AKA stream). In this case, i'm establishing connection with the specific signed user.
  4. 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


Enter fullscreen mode Exit fullscreen mode

Yup, the latest scope didn't exists either, i like to define that on application_record.rb.



scope :latest, ->{ order("created_at DESC") }


Enter fullscreen mode Exit fullscreen mode

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 %>


Enter fullscreen mode Exit fullscreen mode

Now let's put notifications-related stuff on concerns/notificable.rb.

  1. 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.
  2. 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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

Don't forget to notify the controller about this new path on config/routes.rb



resources :notifications, only: [ :index ]


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
aventum_cormac_3c5c3a8b5d profile image
Aventum Cormac

Very helpful article.

Collapse
 
aventum_cormac_3c5c3a8b5d profile image
Aventum Cormac

<% 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

load script:41
GET /assets/actioncable.js net::ERR_ABORTED 404 (Not Found)

Any idea about it?

Collapse
 
matiascarpintini profile image
Matias Carpintini

Seems that there's an asset that couldn't be loaded. Can you share me a screenshot of the full error?

Collapse
 
aventum_cormac_3c5c3a8b5d profile image
Aventum Cormac

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.

Collapse
 
aventum_cormac_3c5c3a8b5d profile image
Aventum Cormac • Edited

in the development environment all working good, I am getting this error on heroku.

Collapse
 
aventum_cormac_3c5c3a8b5d profile image
Aventum Cormac

Image description

Collapse
 
zilton7 profile image
Zil Norvilis

Very nice guide, thank you ✌️

Collapse
 
matiascarpintini profile image
Matias Carpintini

My pleasure! 🙏