Interactive HOTWire Notifications with Turbo Streams

Rails’ use of flash messages is a great way to provide context for customer actions. If they delete an object, flashing a notice that the delete action succeeded, or perhaps failed, gives them more context to make the next decision. With HOTWire’s asynchronous nature, you don’t get those notifications in the same way, especially if you’re just replacing a part of a page.

Refactoring Notifications

Let’s refactor the flash messages into the upper-left corner of the app, where they’ll float. Using a TailwindUI design, the key component is something with an id where Turbo can append the notification. I’m using a div with the id="notifications". I refactored the HTML for an alert or a notice into a partial, which I put in the app/views/layouts folder as _alert.html.erb and _notice.html.erb.

Displaying Alerts from TurboStreams

In any *.turbo_stream.erb view, add the following to check if there is an alert or notification:

<% if notice %>
  <%= turbo_stream.append 'notifications' do %>
    <%= render 'layouts/notice' %>
  <% end %>
<% end %>

<% if alert %>
  <%= turbo_stream.append 'notifications' do %>
    <%= render 'layouts/alert' %>
  <% end %>
<% end %>

This will append the notification to the notifications’ element, and display the notification.

Building the notifications

You can start with a new project, and scaffold a Post model:

$ rails new notification_test -c tailwind
$ rails g scaffold Post title:string body:text
$ rails db:migrate

We’re going to add the HTML code to display our notifications. In app/views/layout/application.html.erb, add the notifications “home”, which won’t show anything, but Turbo will append notification onto it.

<body>
  <main class="container mx-auto mt-28 px-5 flex">
    <div aria-live="assertive" class="fixed inset-0 flex items-end px-4 py-6 pointer-events-none sm:p-6 sm:items-start z-50">
      <div id="notifications" class="w-full flex flex-col items-center space-y-4 sm:items-end">
        <% if notice %>
          <%= render 'layouts/notice' %>
        <% end %>
        <% if alert %>
          <%= render 'layouts/alert' %>
        <% end %>
      </div>
    </div>
    <%= yield %>
  </main>
</body>

In the app/views/layouts folder, add two almost identical partials that will display the alert or notice. Here is app/views/layout/_alert.html.erb:

<div data-controller="alert"
  data-transition-enter="transform ease-out duration-300 transition"
  data-transition-enter-start="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
  data-transition-enter-end="translate-y-0 opacity-100 sm:translate-x-0"
  data-transition-leave="transition ease-in duration-100"
  data-transition-leave-start="opacity-100"
  data-transition-leave-end="opacity-0"
  class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden hidden">
  <div class="p-4">
    <div class="flex items-start">
      <div class="flex-shrink-0">
        <svg class="h-6 w-6 text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
        </svg>
      </div>
      <div class="ml-3 w-0 flex-1 pt-0.5">
        <p class="text-sm font-medium text-gray-900">Alert!</p>
        <p class="mt-1 text-sm text-gray-500"><%= alert %></p>
		<%# Need to clear flash[:alert] once displayed %>
        <% flash[:alert] = nil %>
      </div>
      <div class="ml-4 flex-shrink-0 flex">
        <button type="button" data-action="alert#close"  class="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
          <span class="sr-only">Close</span>
          <!-- Heroicon name: solid/x -->
          <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
            <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
          </svg>
        </button>
      </div>
    </div>
  </div>
</div>

Here is the notice in app/views/layout/_notice.html.erb:

<div data-controller="alert"
  data-transition-enter="transform ease-out duration-300 transition"
  data-transition-enter-start="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
  data-transition-enter-end="translate-y-0 opacity-100 sm:translate-x-0"
  data-transition-leave="transition ease-in duration-100"
  data-transition-leave-start="opacity-100"
  data-transition-leave-end="opacity-0"
  class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden hidden">
  <div class="p-4">
    <div class="flex items-start">
      <div class="flex-shrink-0">
        <!-- Heroicon name: outline/check-circle -->
        <svg class="h-6 w-6 text-green-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
          <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
        </svg>
      </div>
      <div class="ml-3 w-0 flex-1 pt-0.5">
        <p class="text-sm font-medium text-gray-900">Notice!</p>
        <p class="mt-1 text-sm text-gray-500"><%= notice %></p>
      	<%# Need to clear flash[:notice] once displayed %>
        <% flash[:notice] = nil %>
	  </div>
      <div class="ml-4 flex-shrink-0 flex">
        <button type="button" data-action="alert#close"  class="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
          <span class="sr-only">Close</span>
          <!-- Heroicon name: solid/x -->
          <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
            <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
          </svg>
        </button>
      </div>
    </div>
  </div>
</div>

Now, when you perform an action, like creating a Post, you’ll see the notification floating above.

Hopefully, you noticed the Stimulus alert controller that will make this floating notification disappear after appearing, or allow the user to click the X to remove the notification. This uses the el-transition library to manage the animations. Add it with your current JavaScript management tool, for example with importmap:

$ ./bin/importmap pin el-transition

or yarn:

$ yarn add el-transition

Add the Stimulus controller, alert_controller.js:

import { Controller } from "@hotwired/stimulus";
import { enter, leave } from "el-transition";

let TIMEOUT_MILLISECONDS = 2000;

export default class extends Controller {
  connect() {
    enter(this.element).then(() => {
      setTimeout(() => {
        this.close();
      }, TIMEOUT_MILLISECONDS);
    });
  }

  close() {
    leave(this.element).then(() => {
      this.element.remove();
    });
  }
}

Now we have a nice floating notification layout, with a Stimulus controller providing the entering and leaving animations, and a timeout that removes the notification after a few seconds. Let’s add the Turbo Stream magic.

TurboFrames and TurboStreams

First, on the posts/index.html.erb page, let’s wrap the “New Post” button in a turbo_frame_tag.

<%= turbo_frame_tag 'new_post' do %>
  <%= link_to 'New post', new_post_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
<% end %>

In the posts/new.html.erb, wrap the key part of the form in the same turbo frame. This will be styled to be a modal that appears in the middle of the page:

<%= turbo_frame_tag 'new_post' do %>
  <div class="fixed inset-0 z-10 overflow-y-auto">
    <div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
      <div class="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
        <h1 class="font-bold text-4xl">New post</h1>
        <%= render "form", post: @post %>
      </div>
    </div>
  </div>
<% end %>

Turbo is going to handle the replacement, so the page will load the new form in the middle of the page.

When the “Create Post” button is clicked, a request goes to Posts controller on the create action. Add a new format response to the create action, complete with the flash notices:

  # POST /posts or /posts.json
  def create
    @post = Post.new(post_params)

    respond_to do |format|
      if @post.save
        format.turbo_stream { flash[:notice] = 'Post was successfully created.' }
        format.json { render :show, status: :created, location: @post }
      else
        format.turbo_stream { flash[:alert] = 'Post was not created.' }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end

Let’s add the posts/create.turbo_stream.erb template that will append the new post to the page, replace the “New Post” button, and append our notice to the notifications’ element.

<%= turbo_stream.append 'posts' do %>
  <%= render @post %>
<% end %>

<%= turbo_stream.replace 'new_post' do %>
  <%= turbo_frame_tag 'new_post' do %>
    <%= link_to 'New post', new_post_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
  <% end %>
<% end %>

<% if notice %>
  <%= turbo_stream.append 'notifications' do %>
    <%= render 'layouts/notice' %>
  <% end %>
<% end %>

<% if alert %>
  <%= turbo_stream.append 'notifications' do %>
    <%= render 'layouts/alert' %>
  <% end %>
<% end %>

Now, we can create a post, and get the SPA look, without having to resort to rendering everything from JavaScript.

The whole notification look

It’s HTML Over The Wire at its best.

Leave a Reply