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

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

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.
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
Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)