Photo by Prateek Katyal on Unsplash
Notifications and Animation with Hotwire
Part 19 of building a Rails 7 application
An important aspect of any application is letting the users know what is happening in the clearest possible way, and even more so when this application can now be broadcasting changes that other users have made.
Right now the default Rails notifications look a little bland:
The first thing I can do is improve the look of those by using a TailwindUI notification component.
As always a ViewComponent will be the starting point:
app/components/notification/component.rb
module Notification
class Component < ViewComponent::Base
include IconsHelper
attr_reader :name, :type, :message
def initialize(name:, type:, message:)
super
@name = name
@type = type
@message = message
end
def title
type.titleize
end
def icon_options
case type
when 'success'
{ name: :check_circle, colour: :emerald }
when 'warning'
{ name: :exclamation, colour: :amber }
when 'error'
{ name: :x_circle, colour: :rose }
else
{ name: :information_circle, colour: :sky }
end
end
end
end
Here's a couple examples of these in Storybook:
Behind the scenes these notifications are still using the Rails "flash" mechanism. I've expanded on the list of possible flash types though:
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
add_flash_types :success, :info, :error, :warning
end
In the controllers not much changes, instead of using the default notice
I'll use my new types depending on the scenario:
def destroy
@resource.destroy!
respond_to do |format|
format.html { redirect_to(collection_url, success: "#{resource_human_name} '#{@resource}' was successfully deleted.") }
end
end
def update
respond_to do |format|
if @resource.update(resource_params)
format.html do
redirect_to(resource_url(@resource, { format: :html }), success: "#{resource_human_name} '#{@resource}' was successfully updated.")
end
else
format.html do
flash.now[:error] = "#{resource_human_name} could not be updated."
render(:edit, status: :unprocessable_entity)
end
end
end
end
And then I'll start broadcasting those notifications to other users whenever updates or deletions occur. I already have the Broadcast
concern which is sending out turbo stream messages to update the pages, I can insert a new message in there that will build my Notification components.
app/models/concerns/broadcast.rb
after_update_commit lambda {
broadcast_append_to(
"notification_#{resource_name}_#{id}",
partial: 'notification',
locals: {
name: "notification_#{resource_name}_#{id}",
type: 'warning',
message: 'Another user has updated this record.'
},
target: 'notifications'
)
end
To facilitate this I've just added a div to the bottom of each page that can be the target:
<div id="notifications"></div>
The last piece is to start using the animations that Tailwind is recommending for these components. This will require the addition of some attributes to the HTML elements and a small amount of JavaScript to be added to the Stimulus controllers.
The Notification controller looks like this:
app/components/notification/component_controller.js
import { Controller } from "@hotwired/stimulus"
import { enter, leave } from "el-transition"
export default class extends Controller {
static targets = ["container", "notification"]
connect() {
this.containerTarget.classList.remove("hidden")
enter(this.notificationTarget)
}
close() {
Promise.all([
leave(this.notificationTarget)
]).then(() => {
this.containerTarget.classList.add("hidden")
})
}
}
The first thing to note is that I'm including a module called el-transition
. This will take care of applying the CSS transitions for me. It requires a set of "enter" and/or "leave" data attributes. Notice how the values marry up with what Tailwind specifies for the "Entering" and "Leaving" transition.
app/components/notification/component.html.erb
<!--
Notification panel, dynamically insert this into the live region when it needs to be displayed
Entering: "transform ease-out duration-300 transition"
From: "translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
To: "translate-y-0 opacity-100 sm:translate-x-0"
Leaving: "transition ease-in duration-100"
From: "opacity-100"
To: "opacity-0"
-->
<div
class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden"
data-notification--component-target="notification"
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"
>
The connect
method of the controller simply removes the hidden
class on the notification container then calls the enter
method to perform the transition. Likewise when closing the notification it will call the leave
method to perform that transition and then hide the container.
Now that the "flash" messages look nicer I'll tackle some other visual flares that will help when adding and deleting records from the index page.
Right now when the user adds a record turbo stream is inserting a row at the top of the index table, but it is happening immediately so it is not entirely clear what is going. I can add some animation here to make it more obvious what happened.
To do this I can take advantage of an event that Turbo emits, called before-stream-render
. This fires whenever a turbo stream fragment is about to be rendered to screen. I can apply animation to it prior to that happening. On the index page I'll add that as a Stimulus action:
<div
id="collection"
class=min-w-full"
data-controller="editor"
data-action="turbo:before-stream-render@document->editor#animate"
>
In the editor controller I'll create an animate
method:
animate(event) {
const action = event.target.attributes.action.value
const source = event.srcElement.attributes.target.value
if (action === 'prepend' && source === 'collection_rows') {
event.preventDefault()
event.target.performAction()
const target = cash("#collection_rows tr")[0]
enter(target)
}
}
And on the tr
tag I'll set the transition I want to have. This will make the background slowly go to the sky
colour and the row will appear to slide in to place by using a translate-y
<tr
class="collection-rows__row"
id="<%= dom_id(resource, "turbo_stream") %>"
data-controller="resource-form--component editor"
data-transition-enter="transition ease-in-out duration-500"
data-transition-enter-start="bg-transparent translate-y-6"
data-transition-enter-end="bg-sky-200 translate-y-0"
>
I can repeat this for a removal transition as well, or any other visual flare that should be applied when something changes. Here is what the row insertion looks like in practice:
This is much more obvious to the user that something has just happened. And because it is being broadcast, then all users currently looking at the screen will see that a new record has been added.