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.
It’s HTML Over The Wire at its best.