Modern Rails flash messages (part 1): ViewComponent, Stimulus & Tailwind CSS
Tags: rails, ruby, viewcomponent, stimulus
Published: 2020-09-01 05:15:16

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](https://tailwindui.com/) has some very nice notifications [prepared](https://tailwindui.com/components/application-ui/overlays/notifications#component-755581b198213fbd8058c85f7f18cb39).

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](https://dev-to-uploads.s3.amazonaws.com/i/3p2sp78pute2vimhj5ui.gif) if you see only white image below):

![Final flash messages with variants](https://dev-to-uploads.s3.amazonaws.com/i/3p2sp78pute2vimhj5ui.gif)

*Update after the second part*: You can find running demo based on this series of articles on [modern-rails-flash-messages.herokuapp.com](https://modern-rails-flash-messages.herokuapp.com/) with source code on [github.com/CiTroNaK/modern-rails-flash-messages](https://github.com/CiTroNaK/modern-rails-flash-messages).

## Prerequisites

- Ruby on Rails
- [ViewComponent](https://github.com/github/view_component)
- [Stimulus](https://stimulusjs.org/)
- [Tailwind CSS](https://tailwindcss.com/) for front-end part (is optional, because you can use your own CSS/HTML) 
- [FontAwesome](https://fontawesome.com/) for icons (optional)

## Creating a component

Please, follow the installation section from [ViewComponent Docs](https://github.com/github/view_component#installation) (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](https://api.rubyonrails.org/classes/ActionDispatch/Flash.html)).

As we will use `key` and `value` from `flash` object, I will add it as arguments for a new view component.

```bash
bin/rails generate component Notification type data
```

Which will output something like:

```bash
      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:

```ruby
# 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:

```ruby
private 

def prepare_data(data)
  case data
  when Hash
    data
  else
    { title: data }
  end
end
```

and the corresponding change in the `initialize` method

```ruby
@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).

```html
<!-- 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):

```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">
            <%= @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:

```erb
<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:

```ruby
flash[:notice] = { 
  title: 'Discussion moved', 
  body: 'Lorem ipsum dolor sit amet consectetur adipisicing elit oluptatum tenetur.'
}
```

or as before:

```ruby
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.

```ruby
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

```ruby
@icon_class = icon_class
@icon_color_class = icon_color_class
```

which we will use in 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 <%= @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](https://stimulusjs.org/handbook/installing) (if you already don't have it).

Let's create our notification controller.

```javascript
// 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`.

```html
<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`.


```html
<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](https://stimulusjs.org/reference/lifecycle-callbacks)). I will also use a [neat trick](https://dev.to/adrienpoly/animations-with-turbolinks-and-stimulus-4862) for Turbolinks to prevent rendering it when you going back.

```javascript
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.

```ruby
# 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`](https://stimulusjs.org/reference/targets) 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.

```html
<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.

```javascript
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:

```css
@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:

```erb
<!-- 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`:

```ruby
# 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:

```javascript
// 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: 

```ruby
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:

```erb
<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:

```ruby
# 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:

```javascript
// 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:

```ruby
# controller

flash.now[:success] = { title: t('.success.title'), content: t('.success.content') }
```

```javascript
// 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](https://tailwindui.com/). 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!