How to Use Hotwire in Rails 7 to Build a Real Time Notification System

Written by matiascarpintini | Published 2022/04/28
Tech Story Tags: ruby-on-rails | notifications | ruby | ruby-on-rails-development | guide | beginners-guide | tips | ruby-tutorial | web-monetization

TLDRThis article is a quick update on how to build a notifications system with Rails and Redis from scratch. Using Hotwire, 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. It 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.via the TL;DR App

DISCLAIMER

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.

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 to 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 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 😉

How to create 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 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.

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

  2. prepend/replace/remove: simple JS actions

  3. to: the target (AKA stream). In this case, I'm establishing a 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

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.

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

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


Written by matiascarpintini | Doer
Published by HackerNoon on 2022/04/28