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
Enter fullscreen mode Exit fullscreen mode

Which will output something like:

      create  app/components/notification_component.rb
      invoke  erb
      create    app/components/notification_component.html.erb
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and the corresponding change in the initialize method

@data = prepare_data(data)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.'
}
Enter fullscreen mode Exit fullscreen mode

or as before:

flash[:notice] = 'Discussion moved'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and add new instance variables to initializer

@icon_class = icon_class
@icon_color_class = icon_color_class
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 {
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

The notification-countdown animation is simple:

@keyframes notification-countdown {
  from {
    width: 100%;
  }
  to {
    width: 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
  }
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And in the corresponding view, just render it:

// view, eg. create.js.erb

document.getElementById('notifications').insertAdjacentHTML("afterBegin", "<%=j render @notification %>");
Enter fullscreen mode Exit fullscreen mode

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') }
Enter fullscreen mode Exit fullscreen mode
// view, eg. create.js.erb

<% flash.each do |type, data|  %>
  document.getElementById('notifications').insertAdjacentHTML("afterBegin", "<%=j render(NotificationComponent.new(type: type, data: data)) %>");
<% end %>
Enter fullscreen mode Exit fullscreen mode

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!

Discussion

pic
Editor guide
Collapse
skatkov profile image
Stanislav(Stas) Katkov

Loved the post, I was actually looking to improve flash messages for one of my own projects.

Added it to awesome-stimulus repo as well. github.com/skatkov/awesome-stimulusjs

Collapse
citronak profile image
Petr Hlavicka Author

Thanks 🙂

Collapse
pabloc profile image
PabloC

Great example on how to use viewcomponents. I was looking for exactly this. Thank you!

Collapse
hackvan profile image
Diego Camacho

Great explanation very useful, thanks a lot!