loading...

Modern Rails flash messages (part 1): ViewComponent, Stimulus & Tailwind CSS

citronak profile image Petr Hlavicka Updated on ・12 min read

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):

Final flash messages with variants

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

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 in the comments. Thanks!

Posted on by:

citronak profile

Petr Hlavicka

@citronak

I am a freelance web developer since 2007. I’ve started as HTML & CSS coder (in time of IE 6). Then as PHP (and WordPress) developer. Since 2015, I am working with Ruby on Rails.

Discussion

markdown guide