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:

image.png

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:

image.png

image.png

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.

npmjs.com/package/el-transition