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')
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
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
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
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 %>
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"
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
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}'
]
}
Generate our component
$ rails generate view_component Flash
# 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
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
# 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
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 %>
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
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
That's it!
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
Now you can use it in your controller
respond_to do |format|
format.turbo_stream do
show_flash('Hello World', type: :success)
end
end
Or even better take it out to your .turbo_stream.erb
template:
<%= show_flash('I need to know', title: 'So Tell me something') %>
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? %>
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
# 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
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)