DEV Community

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

Posted on

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)