DEV Community

Cover image for Ruby on Rails Flash notifications with Hotwire and ViewComponents
Georgy Yuriev
Georgy Yuriev

Posted on

2

Ruby on Rails Flash notifications with Hotwire and ViewComponents

DEMO REPO

Every time we start out new Rails app, we need beautify default flash notifications and make it more comfortable to use. Let's incapsulate logic with ViewComponents and spice it up with dry-rb approach.

In the end of this article we will extend standard Rails approach of calling flash notifications by a couple of methods (with possibility of customization):

# add ability to show titles
flash[:alert] = 'notification text', 'WITH TITLE' 

# render within turbo_stream answers:
show_flash('Lorem ipsum')

# from anywhere:
Flash::Broadcast.call(user, 'Personal notification')

Enter fullscreen mode Exit fullscreen mode

Gems

gem 'view_component'
gem 'view_component-contrib' # common patterns and practices
gem 'dry-initializer' # beautify the way of parameters handling
gem 'dry-types' # specify params types
gem 'inline_svg' # use helpers to render svg files
Enter fullscreen mode Exit fullscreen mode

I like the way Vladimir Dementyev cook his view components, so we'll be using view_component-contrib meta-repository to get advantages of sidecar file structure and dry-way of defining parameters.

I handle my SVGs with inline_svg gem, which allows me to keep svg files in "app/assets/images/icons" folder and render it using simple helper.

You can skip those gems and use vanilla view components.

Also in this article I will use Tailwind for styling. Let's take Flowbite notification design.

JS libs

For animations and ability to close and remove from DOM our notifications we will use Stimulus Notification library.

Add dependencies

bin/importmap pin @stimulus-components/notification
Enter fullscreen mode Exit fullscreen mode

Register stimulus controller as described in documentation

// app/javascript/controllers/application.js
import { Application } from '@hotwired/stimulus'
import Notification from '@stimulus-components/notification' // ADD THIS 

const application = Application.start()
application.register('notification', Notification) // ADD THIS 

// ... the rest of the file
Enter fullscreen mode Exit fullscreen mode

Create container

Add this wrapper to the bottom of <body>.

<%= tag.div id: :flash_container, class: 'z-50 fixed top-4 inset-x-4 sm:left-auto sm:w-full sm:max-w-sm md:right-8 md:top-8' do %>
  <% flash.each do |type, (message, title)| %>
    <%= render Flash::Component.new(message, type:, title:) %>
  <% end %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Build component

Prepare generator and file structure for components (see "installation" in view_component-contrib documentation)

$ rails app:template LOCATION="https://railsbytes.com/script/zJosO5"
Enter fullscreen mode Exit fullscreen mode

Interactive shell log:

Where do you want to store your view components? (default: app/frontend/components) ↵
Would you like to use dry-initializer in your component classes? (y/n) y
Do you use Stimulus? (y/n) y
Do you use TailwindCSS? (y/n) y
Would you like to create a custom generator for your setup? (y/n) y
Which template processor do you use? (1) ERB, (2) Haml, (3) Slim, (0) Other 1
Enter fullscreen mode Exit fullscreen mode

Add this line to tell tailwind to watch our components directory:

// config/tailwind.config.js
module.exports = {
  content: [
    './app/frontend/components/**/*.{erb,html,rb,js}'
  ]
}
Enter fullscreen mode Exit fullscreen mode

Generate our component

$ rails generate view_component Flash
Enter fullscreen mode Exit fullscreen mode
# app/frontend/components/flash/component.rb
class Flash::Component < ApplicationViewComponent
  param :message, type: Types::Coercible::Array.of(Types::Coercible::String)
  option :type, type: Types::FlashType, default: -> { 'info' }
  option :title, type: Types::Coercible::String, optional: true

  COLORS = {
    info: 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200',
    success: 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200',
    warning: 'text-amber-500 bg-amber-100 dark:bg-amber-800 dark:text-amber-200',
    error: 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200'
  }.freeze

  ICONS = {
    info: :information_circle,
    success: :check_circle,
    warning: :exclamation_circle,
    error: :exclamation_circle
  }.freeze
end
Enter fullscreen mode Exit fullscreen mode

Types of types ☉ ‿ ⚆

Notice the defenitions of types. Since Rails has only two types of flash (notice and alert) lets add some own types and cast the values to accepted ones.

# app/controllers/application_controller.rb
add_flash_types :error, :success
Enter fullscreen mode Exit fullscreen mode
# lib/types.rb
module Types
  include Dry.Types()

  FLASH_TYPE_MAP = {
    info: :info,
    notice: :info,
    success: :success,
    alert: :warning,
    warning: :warning,
    fail: :error,
    error: :error
  }.freeze

  FlashType = Types::Coercible::Symbol
              .enum(*FLASH_TYPE_MAP.keys)
              .constructor { FLASH_TYPE_MAP[_1.to_sym] }
end
Enter fullscreen mode Exit fullscreen mode

Also message is wrapped in array to be able to render e.g. @record.errors.full_messages as a several <p> tags inside notification.

Template

<%# app/frontend/components/flash/component.html.erb %>
<%= tag.div class: 'flex items-start w-full p-4 mb-2 text-gray-500 bg-white rounded-lg shadow dark:bg-gray-700 transition transform duration-200 hidden',
            data: {
              controller: :notification,
              notification_delay_value: 4000,
              transition_enter_from: 'opacity-0 translate-x-6',
              transition_enter_to: 'opacity-100 translate-x-0',
              transition_leave_from: 'opacity-100 translate-x-0',
              transition_leave_to: 'opacity-0 translate-x-6'
            } do %>
  <%= tag.div icon(ICONS[type], size: 24, variant: :solid),
              class: "#{COLORS[type]} inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg" %>

  <div class="ml-3 w-0 flex-1 pt-0.5">
    <%= tag.p title, class: 'text-sm font-medium text-gray-900 mb-1 dark:text-gray-100' %>
    <% message.each do |msg| %>
      <%= tag.p msg, class: 'text-sm text-gray-900 dark:text-gray-300' %>
    <% end %>
  </div>

  <div class="ml-4 flex-shrink-0 flex h-8 w-8">
    <%= button_tag icon(:x_mark, classes: 'w-5 h-5 stroke-2'),
                   data: { action: 'notification#hide' },
                   class: 'ms-auto bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-500 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:focus:ring-gray-500 dark:text-gray-300 dark:hover:text-white dark:bg-gray-700 dark:hover:bg-gray-700' %>
  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

And also we used icon helper, here is the definition:

# app/helpers/application_helper.rb
def icon(name, options = {})
  options[:aria] = true
  options[:nocomment] = true
  options[:variant] ||= :outline
  options[:size] = options.fetch(:size, nil).to_s.presence
  options[:class] = options.fetch(:classes, nil)
  options[:style] << "stroke-width: #{options[:stroke]}" if options[:stroke].present?
  path = options.fetch(:path, "icons/#{options[:variant]}/#{name}.svg")
  icon = path
  inline_svg_tag(icon, options)
end
Enter fullscreen mode Exit fullscreen mode

I have two styles of icons: outline and solid. Create two corresponding directories for it:

  • app/assets/images/icons/solid/
  • app/assets/images/icons/outline/

and put there your svg icons (heroicons e.g.) named accordingly with invocation parameters (we already defined them in Flash::Component::ICONS.values).

Make icon helper accessible in components:

# app/frontend/components/application_view_component.rb
delegate :icon, to: :helpers
Enter fullscreen mode Exit fullscreen mode

That's it!

Demo

Advanced usage

Now we want to have the ability to render a flash as a part of TURBO_STREAM answers or even broadcast it from async jobs or service objects.

Let's write a couple of helpers for it.

Streaming from controllers

This is very simple, all you need is define handy helper:

# app/controllers/application_controller.rb
helper_method :show_flash

def show_flash(...)
  turbo_stream.append(:flash_container) do
    render Flash::Component.new(...)
  end
end
Enter fullscreen mode Exit fullscreen mode

Now you can use it in your controller

respond_to do |format|
  format.turbo_stream do
    show_flash('Hello World', type: :success)
  end
end
Enter fullscreen mode Exit fullscreen mode

Or even better take it out to your .turbo_stream.erb template:

<%= show_flash('I need to know', title: 'So Tell me something') %>
Enter fullscreen mode Exit fullscreen mode

Broadcasting from anywhere

Assume you have generating report feature which runs asynchronously, and you want to notify the user that the generation of this report is complete. So user can surf the application, and upon receiving a notification, go to the reports section to download the generated file.

We need to have the ability to broadcast flash notification from our async job. Let's build the service object which sends a notification to a specific user.

Assume we use devise for authentication. We need to subscribe user for personal notifications channel. Add this line to app/views/layouts/application/_flash_container.html.erb

<%= turbo_stream_from dom_id(current_user, :flash_container) if user_signed_in? %>
Enter fullscreen mode Exit fullscreen mode

Now create service object:

# app/services/application_service.rb
class ApplicationService
  extend Dry::Initializer

  class << self
    def call(...)
      new(...).call
    end
  end

  def call
    raise NotImplemetedError
  end
end
Enter fullscreen mode Exit fullscreen mode
# app/services/flash/broadcast.rb
module Flash
  class Broadcast < ApplicationService
    param :user
    param :message
    param :options, optional: true, default: -> { {} }

    delegate :broadcast_append_to, to: Turbo::StreamsChannel
    delegate :dom_id, to: ActionView::RecordIdentifier
    delegate :render, to: ApplicationController

    def call
      broadcast_append_to(
        dom_id(user, :flash_container),
        target: :flash_container,
        html: render(Flash::Component.new(message, **options), layout: false)
      )
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

We pass user here and forward other parameters as "options" hash. Params validation already implemented in flash component, so we do not need to repeat it here, just "user", "message", and "some other stuff".

Thats all. Now we can go to rails console and play around with our service object (Flash::Broadcast.call(User.take, 'Hello')). Notifications will be displayed in "live mode".

Conclusion

We left the standard flash API untouched, so any plain old flash notifications will work as usual. But now we can customize notifications (set new types and titles). We can show flashes in turbo_stream responses without page reload and even send notifications asynchronously. Also we implemented a stack structure for them, so we can spam it as much as we want.

Hope this cookbook give you some ideas for your project!

Top comments (0)