Modern Rails flash messages (part 1): ViewComponent, Stimulus & Tailwind CSS
rails, ruby, viewcomponent, stimulus
1 Sep 2020 ・ Updated on 20 Sep 2020 ・ Featured at Ruby Weekly #520
I always thought that flash messages in Rails could be better. Don’t get me wrong, I really like how they work and how easy is to use them.
As a side project, I started to build a simple application for tabletop RPG players and found out, that I really need to have actions in them. Like typical “Undo” action when you delete something and skip the repetitive “Are you sure?” annoying question.
I confirmed my needs to myself when I saw, how Tailwind UI has some very nice notifications prepared.
I wanted them in my app!
TL;DR: scroll down for the complete code ;-) and here is a preview of the final version with different options (click here if you see only white image below):
Update after the second part: You can find running demo based on this series of articles on modern-rails-flash-messages.herokuapp.com with source code on github.com/CiTroNaK/modern-rails-flash-messages.
Prerequisites
- Ruby on Rails
- ViewComponent
- Stimulus
- Tailwind CSS for front-end part (is optional, because you can use your own CSS/HTML)
- FontAwesome for icons (optional)
Creating a component
Please, follow the installation section from ViewComponent Docs (if you already don’t have it).
Luckily we are not limited by passing only the string to the flash
object. We will use the possibility to pass the Hash
(you can also pass Array
, see docs).
As we will use key
and value
from flash
object, I will add it as arguments for a new view component.
bin/rails generate component Notification type data
Which will output something like:
create app/components/notification_component.rb
invoke erb
create app/components/notification_component.html.erb
One file for logic and second for HTML output. Let’s start with the logic for the component.
Ruby part
After generation, it will look like this:
# app/components/notification_component.rb
class NotificationComponent < ViewComponent::Base
def initialize(type:, data:)
@type = type
@data = data
end
end
We will pass the Hash
as our data, but for backward compatibility, we need to be sure, it will works for places, that aren’t under our control. There will use a String
, instead of Hash
.
We can easily ensure, that we will work with Hash
every time:
private
def prepare_data(data)
case data
when Hash
data
else
{ title: data }
end
end
and the corresponding change in the initialize
method
@data = prepare_data(data)
HTML part
I’ve used prepared notification from Tailwind UI, but you can use whatever you want. This notification will work with Tailwind CSS only, so you don’t need to have Tailwind UI (but if you find it useful, you should).
<!-- app/components/notification_component.html.erb -->
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto">
<div class="rounded-lg shadow-xs overflow-hidden">
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="h-6 w-6 text-gray-400">
<i class="far fa-info-square"></i>
</div>
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm leading-5 font-medium text-gray-900">
Discussion moved
</p>
<p class="mt-1 text-sm leading-5 text-gray-500">
Lorem ipsum dolor sit amet consectetur adipisicing elit oluptatum tenetur.
</p>
<div class="mt-2">
<button class="text-sm leading-5 font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Undo
</button>
<button class="ml-6 text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Dismiss
</button>
</div>
</div>
<div class="ml-4 flex-shrink-0 flex">
<button class="inline-flex text-gray-400 focus:outline-none focus:text-gray-500 transition ease-in-out duration-150">
<i class="h-5 w-5 far fa-times"></i>
</button>
</div>
</div>
</div>
</div>
</div>
As we can see, we have these parts: title
(“Discussion moved”), body
(“Lorem ipsum dolor sit amet consectetur adipisicing elit oluptatum tenetur.”) and one action
(“Undo”).
Let’s add them to the HTML (if you have a different, just place instance variables to the correct places in your HTML):
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto">
<div class="rounded-lg shadow-xs overflow-hidden">
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="h-6 w-6 text-gray-400">
<i class="far fa-info-square"></i>
</div>
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm leading-5 font-medium text-gray-900">
<%= @data[:title] %>
</p>
<% if @data[:body].present? %>
<p class="mt-1 text-sm leading-5 text-gray-500">
<%= @data[:body] %>
</p>
<% end %>
<% if @data[:action].present? %>
<div class="mt-2">
<button class="text-sm leading-5 font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:underline transition ease-in-out duration-150">
<%= @data.dig(:action, :name) %>
</button>
<button class="ml-6 text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:underline transition ease-in-out duration-150">
<%= t('.dismiss') %>
</button>
</div>
<% end %>
</div>
<div class="ml-4 flex-shrink-0 flex">
<button class="inline-flex text-gray-400 focus:outline-none focus:text-gray-500 transition ease-in-out duration-150">
<i class="h-5 w-5 far fa-times"></i>
</button>
</div>
</div>
</div>
</div>
</div>
We are already in the state when we can display them to see, how are we doing.
In app/views/layouts/application.html.erb
or in partial, you can display them using:
<div class="fixed inset-0 px-4 py-6 pointer-events-none sm:p-6 sm:items-start sm:justify-end">
<div class="flex flex-col items-end justify-center">
<% flash.each do |type, data| %>
<%= render NotificationComponent.new(type: type, data: data) %>
<% end %>
</div>
</div>
Then, add some flash message in the controller like this:
flash[:notice] = {
title: 'Discussion moved',
body: 'Lorem ipsum dolor sit amet consectetur adipisicing elit oluptatum tenetur.'
}
or as before:
flash[:notice] = 'Discussion moved'
Now, we should be able to see flash messages, but all with the same icon (we will fix it in a minute) and without the ability to close (or auto-hide) it and without some nice effects (we will do it with Stimulus).
Changing the icons (and their color for nicer UI), we need to update the notification_component.rb
and add two new methods below private
part. You may notice, that I have one more flash type added.
def icon_class
case @type
when 'success'
'fa-check-square'
when 'error'
'fa-exclamation-square'
when 'alert'
'fa-exclamation-square'
else
'fa-info-square'
end
end
def icon_color_class
case @type
when 'success'
'text-green-400'
when 'error'
'text-red-800'
when 'alert'
'text-red-400'
else
'text-gray-400'
end
end
and add new instance variables to initializer
@icon_class = icon_class
@icon_color_class = icon_color_class
which we will use in HTML
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto">
<div class="rounded-lg shadow-xs overflow-hidden">
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="h-6 w-6 <%= @icon_color_class %>">
<i class="far <%= @icon_class %>"></i>
</div>
</div>
...
</div>
</div>
</div>
</div>
This will change the color and icon per flash type.
Adding functionality and effects using Stimulus
Please, follow the installation section from Stimulus Docs (if you already don’t have it).
Let’s create our notification controller.
// app/javascript/controllers/notification_controller.js
import {Controller} from "stimulus"
export default class extends Controller {
}
And connect it to the HTML using data-controller
attribute on our root div
.
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto" data-controller="notification">
...
</div>
Closing and auto-hiding
For using transitions, we will need to hide the notification first and then trigger the transition. For that, I will add hidden
class to our root div
.
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto hidden" data-controller="notification">
...
</div>
And in a connect
method in our Stimulus controller, I will remove it and add classes for nice transition. The connect
method will be triggered anytime when the controller is connected to the DOM (see Lifecycle Callbacks). I will also use a neat trick for Turbolinks to prevent rendering it when you going back.
connect() {
if (!this.isPreview) {
// Display with transition
setTimeout(() => {
this.element.classList.remove('hidden');
this.element.classList.add('transform', 'ease-out', 'duration-300', 'transition', 'translate-y-2', 'opacity-0', 'sm:translate-y-0', 'sm:translate-x-2');
// Trigger transition
setTimeout(() => {
this.element.classList.add('translate-y-0', 'opacity-100', 'sm:translate-x-0');
}, 100);
}, 500);
// Auto-hide
setTimeout(() => {
this.close();
}, 5500);
}
}
close() {
// Remove with transition
this.element.classList.remove('transform', 'ease-out', 'duration-300', 'translate-y-2', 'opacity-0', 'sm:translate-y-0', 'sm:translate-x-2', 'translate-y-0', 'sm:translate-x-0');
this.element.classList.add('ease-in', 'duration-100')
// Trigger transition
setTimeout(() => {
this.element.classList.add('opacity-0');
}, 100);
// Remove element after transition
setTimeout(() => {
this.element.remove();
}, 300);
}
get isPreview() {
return document.documentElement.hasAttribute('data-turbolinks-preview')
}
Now, we need to connect the close
method to our HTML, we can do it easily with data-action="notification#close"
attribute on button
elements.
This is all for nice entering and (auto) leaving with a functional close button.
Countdown
For countdown, we will add an option to our notification to control the timeout. We also need to count on backward compatibility with adding default value.
# app/components/notification_component.rb
def initialize(type:, data:)
@type = type
@data = prepare_data(data)
@icon_class = icon_class
@icon_color_class = icon_color_class
@data[:timeout] ||= 3
end
And add into the HTML as data-notification-timeout="<%= @data[:timeout] %>"
to our root div
, so we will be able to use it in the Stimulus controller.
We also add the HTML for the countdown line at the bottom of the HTML with special data-target
attribute, that we use in the Stimulus controller. We will show it only when we will need it. There could be actions, where the countdown could be intrusive. Eg. “Open”, “View”, etc.
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto hidden" data-controller="notification" data-notification-timeout="<%= @data[:timeout] %>">
<div class="rounded-lg shadow-xs overflow-hidden">
...
<% if @data[:countdown] %>
<div class="bg-indigo-600 rounded-lg h-1 w-0" data-target="notification.countdown"></div>
<% end %>
</div>
</div>
Now, we need to update the controller.
import {Controller} from "stimulus"
export default class extends Controller {
static targets = ["countdown"]
connect() {
const timeoutSeconds = parseInt(this.data.get("timeout"));
if (!this.isPreview) {
setTimeout(() => {
this.element.classList.remove('hidden');
this.element.classList.add('transform', 'ease-out', 'duration-300', 'transition', 'translate-y-2', 'opacity-0', 'sm:translate-y-0', 'sm:translate-x-2');
// Trigger transition
setTimeout(() => {
this.element.classList.add('translate-y-0', 'opacity-100', 'sm:translate-x-0');
}, 100);
// Trigger countdown
if (this.hasCountdownTarget) {
this.countdownTarget.style.animation = 'notification-countdown linear ' + timeoutSeconds + 's';
}
}, 500);
setTimeout(() => {
this.close();
}, timeoutSeconds * 1000 + 500);
}
}
...
}
The notification-countdown
animation is simple:
@keyframes notification-countdown {
from {
width: 100%;
}
to {
width: 0;
}
}
Now, the last part…
The action button
For making a request from Javascript, we need to know only two things: url
and method
. When the method of the action will be GET
, we should not make the request and let the user open the page. For example, when he will create a new article, we can show Open
action with a link to the page with the article.
The final HTML:
<!-- app/components/notification_component.html.erb -->
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto mt-4 hidden" data-notification-action-url="<%= @data.dig(:action, :url) %>" data-notification-action-method="<%= @data.dig(:action, :method) %>" data-notification-timeout="<%= @data[:timeout] %>" data-controller="notification">
<div class="rounded-lg shadow-xs overflow-hidden">
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="h-6 w-6 <%= @icon_color_class %>">
<i class="far <%= @icon_class %>"></i>
</div>
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm leading-5 font-medium text-gray-900">
<%= @data[:title] %>
</p>
<% if @data[:body].present? %>
<p class="mt-1 text-sm leading-5 text-gray-500">
<%= @data[:body] %>
</p>
<% end %>
<% if @data[:action].present? %>
<div class="mt-2" data-target="notification.buttons">
<a <% if @data.dig(:action, :method) == 'get' %> href="<%= @data.dig(:action, :url) %>" <% else %> href="#" data-action="notification#run" <% end %> class="text-sm leading-5 font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:underline transition ease-in-out duration-150">
<%= @data.dig(:action, :name) %>
</a>
<button data-action="notification#close" class="ml-6 text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:underline transition ease-in-out duration-150">
<%= t('.dismiss') %>
</button>
</div>
<% end %>
</div>
<div class="ml-4 flex-shrink-0 flex">
<button class="inline-flex text-gray-400 focus:outline-none focus:text-gray-500 transition ease-in-out duration-150" data-action="notification#close">
<i class="h-5 w-5 far fa-times"></i>
</button>
</div>
</div>
</div>
<% if @data[:countdown] %>
<div class="bg-indigo-600 rounded-lg h-1 w-0" data-target="notification.countdown"></div>
<% end %>
</div>
</div>
You can see a new data-target="notification.buttons"
and new data-notification
attributes.
Final NotificationComponent
:
# app/components/notification_component.rb
# frozen_string_literal: true
# @param type [String] Classic notification type `error`, `alert` and `info` + custom `success`
# @param data [String, Hash] `String` for backward compatibility,
# `Hash` for the new functionality `{title: '', body: '', timeout: 5, countdown: false, action: { url: '', method: '', name: ''}}`.
# The `title` attribute for `Hash` is mandatory.
class NotificationComponent < ViewComponent::Base
def initialize(type:, data:)
@type = type
@data = prepare_data(data)
@icon_class = icon_class
@icon_color_class = icon_color_class
@data[:timeout] ||= 3
end
private
def icon_class
case @type
when 'success'
'fa-check-square'
when 'error'
'fa-exclamation-square'
when 'alert'
'fa-exclamation-square'
else
'fa-info-square'
end
end
def icon_color_class
case @type
when 'success'
'text-green-400'
when 'error'
'text-red-800'
when 'alert'
'text-red-400'
else
'text-gray-400'
end
end
def prepare_data(data)
case data
when Hash
data
else
{ title: data }
end
end
end
The main part of calling the action is in the run
method. I’ve also saved the timeout that closes the notification, so I will be able to stop it and make sure, that it will display returning content (look for this.timeoutId
and stop
method).
For making a valid request, we need to have CSRF token from the HTML header. For that, you can see csrfToken
method below.
The final Stimulus controller:
// app/javascript/controllers/notification_controller.js
import {Controller} from "stimulus"
export default class extends Controller {
static targets = ["buttons", "countdown"]
connect() {
const timeoutSeconds = parseInt(this.data.get("timeout"));
if (!this.isPreview) {
setTimeout(() => {
this.element.classList.remove('hidden');
this.element.classList.add('transform', 'ease-out', 'duration-300', 'transition', 'translate-y-2', 'opacity-0', 'sm:translate-y-0', 'sm:translate-x-2');
// Trigger transition
setTimeout(() => {
this.element.classList.add('translate-y-0', 'opacity-100', 'sm:translate-x-0');
}, 100);
// Trigger countdown
if (this.hasCountdownTarget) {
this.countdownTarget.style.animation = 'notification-countdown linear ' + timeoutSeconds + 's';
}
}, 500);
this.timeoutId = setTimeout(() => {
this.close();
}, timeoutSeconds * 1000 + 500);
}
}
run(e) {
e.preventDefault();
this.stop();
let _this = this;
this.buttonsTarget.innerHTML = '<span class="text-sm leading-5 font-medium text-grey-700">Processing...</span>';
// Call the action
fetch(this.data.get("action-url"), {
method: this.data.get("action-method").toUpperCase(),
dataType: 'script',
credentials: "include",
headers: {
"X-CSRF-Token": this.csrfToken
},
})
.then(function (response) {
let content;
// Example of the response, content should be provided from the controller
if (response.status === 200) {
content = '<span class="text-sm leading-5 font-medium text-green-700">Done!</span>'
} else {
content = '<span class="text-sm leading-5 font-medium text-red-700">Error!</span>'
}
// Set new content
_this.buttonsTarget.innerHTML = content;
// Close
setTimeout(() => {
_this.close();
}, 1000);
});
}
stop() {
clearTimeout(this.timeoutId)
this.timeoutId = null
}
close() {
// Remove with transition
this.element.classList.remove('transform', 'ease-out', 'duration-300', 'translate-y-2', 'opacity-0', 'sm:translate-y-0', 'sm:translate-x-2', 'translate-y-0', 'sm:translate-x-0');
this.element.classList.add('ease-in', 'duration-100')
// Trigger transition
setTimeout(() => {
this.element.classList.add('opacity-0');
}, 100);
// Remove element after transition
setTimeout(() => {
this.element.remove();
}, 300);
}
get isPreview() {
return document.documentElement.hasAttribute('data-turbolinks-preview')
}
get csrfToken() {
const element = document.head.querySelector('meta[name="csrf-token"]')
return element.getAttribute("content")
}
}
Example usage
The demo at the beginning was created by these examples:
NotificationComponent.new(type: 'notice', data: { timeout: 8, title: 'Entry was deleted', body: 'You can still recover the deleted item using Undo below.', countdown: true, action: { url: 'http://localhost:3000/undo', method: 'patch', name: 'Undo' } })
NotificationComponent.new(type: 'error', data: { timeout: 8, title: 'Access denied', body: "You don't have sufficient rights to the action." })
NotificationComponent.new(type: 'success', data: 'Successfully logged in')
NotificationComponent.new(type: 'alert', data: 'You need to log in to access the page')
The data
key is basically the content, you will pass to flash
object.
One last thing: how to trigger them from a js response from a controller?
Easily. Add the ID, like #notifications
, here:
<div class="fixed inset-0 px-4 py-6 pointer-events-none sm:p-6 sm:items-start sm:justify-end">
<div id="notifications" class="flex flex-col items-end justify-center">
<% flash.each do |type, data| %>
<%= render NotificationComponent.new(type: type, data: data) %>
<% end %>
</div>
</div>
And in the controller, prepare your notification, eg:
# controller
@notification = NotificationComponent.new(type: 'success', data: { title: t('.success.title'), content: t('.success.content') })
respond_to do |format|
format.js
end
And in the corresponding view, just render it:
// view, eg. create.js.erb
document.getElementById('notifications').insertAdjacentHTML("afterBegin", "<%=j render @notification %>");
If you need to render a classic flash
object, change it to this:
# controller
flash.now[:success] = { title: t('.success.title'), content: t('.success.content') }
// view, eg. create.js.erb
<% flash.each do |type, data| %>
document.getElementById('notifications').insertAdjacentHTML("afterBegin", "<%=j render(NotificationComponent.new(type: type, data: data)) %>");
<% end %>
Next
In the next article, I will show the backend part for the Undo function.
BTW
If you like the Tailwind CSS, you should definitely look at Tailwind UI. They have a lot of cool stuff there.
If you find an error or a better way of doing something, please let me know. Thanks!
Do you like it? You can subscribe to RSS (you know how), or follow me on Mastodon.