Now that Hotwire and Rails 7 are an inseparable couple, some things have changed. Yes, this helps us do some stuff much more easily than when we had to rely on JavaScript code. Still, it can be especially tricky and hard to understand at once for those who aren’t familiar with Hotwire yet. In this article, we’re going to solve one of the most common problems: the issue of showing the sign-in errors to the user when we use a login form inside a modal.
Is it good news that Rails 7 comes with Hotwire by default? The technology stack has now the power to transform Rails into a faster, more reactive, and responsive application. Thanks to a modern approach that consists mainly in sending HTML instead of JSON over the wire, freeing us from the JavaScript coding, everything ranging from first-load pages and template rendering to the development experience is easier and allows us to speed up the whole process.
Let’s have a look at our step-by-step guide to make the sign-in errors visible for our users with Devise.
First of all, we must create a new Rails project with Bootstrap—this is going to help us smooth the process of creating a modal and a navbar:
rails new devise_with_hotwire --css=bootstrap
To add the welcome controller and the index view with a navbar we must follow four steps.
First, we should add this line into config/routes.rb
root "welcome#index"
Now it’s time to create the controller app/controllers/welcome_controller.rb
. This will help us:
class WelcomeController < ApplicationController
def index; end
end
We are almost finished. Let’s create and get simple content in our file for the welcome index view—app/views/welcome/index.html.rb
. We’re going to use this:
<div class="container">
<h3>Devise with Turbo and modals</h3>
</div>
Last but not least, we have to use this HTML to add a simple navbar to app/views/layouts/application.html.rb
:
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">Hotwire</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li>
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Sign In</a>
</li>
</ul>
</div>
</div>
</nav>
We need now to install our Devise gem to handle the user authentication. Therefore, we must add the Devise gem to our Gemfile and then run bundle install
. Once we are done with that, it’s time to run the Devise generator and follow each instruction that appears on the console:
$ rails generate devise:install
After that, Devise will help us create a user model by using:
rails generate devise User
rails db:migrate
Now it’s time for us to change the sign in link this way:
<% if user_signed_in? %>
<%= link_to "Sign out", destroy_user_session_path, data: { turbo_method: :delete }, class: 'nav-link' %>
<% else %>
<%= link_to "Sign in", new_user_session_path, class: 'nav-link' %>
<% end %>
Devise helper method user_signed_in
will support us in showing a sign out link once the user is authenticated. It’s key that we make sure that we have flash messages in app/views/layouts/application.html.erb
. For instance:
<p class="notice"><%= notice %></p>
<p class="alert"><%= alert %></p>
Now, let’s test the implementation! We should create a user in the console, so, we have to open the Rails console with Rails C and run this line:
User.create(email: 'user@example.com', password: ‘example’)
When we run the server—bin/dev
—and put invalid credentials, we’ll notice that the flash messages don’t appear. It’s a common issue, you can read more about it here. In this guide, we'll disable Hotwire in the session new form by adding this to the form tag:
data: { turbo: "false" }
To make it happen, we’ll follow some of the recommendations that Chris Oliver gives in this GitHub issue.
First things first: let’s add a custom failure class to help us handle Devise error responses at the top of config/initializers/devise.rb
:
class TurboFailureApp < Devise::FailureApp
def respond
if request_format == :turbo_stream
redirect
else
super
end
end
def skip_format?
%w(html turbo_stream */*).include? request_format.to_s
end
end
Then, we must build app/controller/devise_turbo_controller.rb
:
class DeviseTurboController < ApplicationController
class Responder < ActionController::Responder
def to_turbo_stream
controller.render(options.merge(formats: :html))
rescue ActionView::MissingTemplate => error
if get?
raise error
elsif has_errors? && default_action
render rendering_options.merge(formats: :html, status: :unprocessable_entity)
else
redirect_to navigation_location
end
end
end
self.responder = Responder
respond_to :html, :turbo_stream
end
Once we’re finished, we have to contemplate our new DeviseTurboController and TurboFailureApp by modifying config/initializers/devise.rb
:
# ==> Controller configuration
# Configure the parent class to the devise controllers.
config.parent_controller = 'DeviseTurboController'
config.navigational_formats = [:html, :turbo_stream]
config.warden do |manager|
manager.failure_app = TurboFailureApp
end
Everything seems perfect until we try to use a Modal for the sign in form. However, don’t worry. We’re going to add a Modal first. We must create a partial with the sign in form, so we have to build app/views/devise/sessions/_form.html.erb
and add this following HTML code:
<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<div class="field">
<%= f.label :password %><br />
<%= f.password_field :password, autocomplete: "current-password" %>
</div>
<div class="actions mt-3">
<%= f.submit "Log in", class: "btn btn-primary" %>
</div>
<% end %>
<%= link_to "Sign up", new_registration_path(resource_name) %>
You can reuse this form in sessions/new.html.erb view too.
Now it’s time to add in our application.html.erb a Bootstrap modal with the sign-in form.
<div class="modal fade" id="sign-in-modal" tabindex="-1" aria-labelledby="sign-in-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="sign-in-modal-label">Sign In</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<%= render "devise/sessions/form", resource: User.new, resource_name: :user %>
</div>
</div>
</div>
</div>
After that, we should change the sign-in link one more time for a button that opens the sign in modal:
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#sign-in-modal">
Sign In
</button>
And here comes the trouble. It seems to be working smoothly until we input invalid credentials and the modal is closed, making it mandatory to be opened again. This is not right, but stay tuned to the next step, we’ll solve it.
We're going to manage the sign-in error message turbulence inside the modal, so first, we must wrap up the new session form in a turbo frame and display an alert message inside it. Before we move forward, here’s a friendly reminder: Devise uses flash[:alert]
to show sign-in error messages.
Our devise/sessions/_form.html.erb should look like this:
<%= turbo_frame_tag 'sign-in-form', target: '_top' do %>
<p class="alert"><%= alert %></p>
<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
…
<% end %>
<% end %>
<%= link_to "Sign up", new_registration_path(resource_name) %></br>
<%= link_to "Forgot your password?", new_password_path(resource_name) %>
As we want to replace the navbar and show a flash message in case of success, we must use target: ‘_top. Without it, the change will be limited to the turbo frame.
Now we should override the Devise session controller to respond with turbo_stream.replace when rendering sessions#new after a failed login. So we have to build app/controllers/sessions_controller.rb
with this logic:
class SessionsController < Devise::SessionsController
def new
respond_to do |format|
format.html { super }
format.turbo_stream do
# When sign in fails devise redirects to new path using turbo_stream format
# To update the sign in form with errors we use turbo_stream.replace
locals = { resource: User.new, resource_name: :user }
render turbo_stream: turbo_stream.replace('sign-in-form', partial: 'devise/sessions/form', locals: locals)
end
end
end
end
If the sign-in fails, Devise will redirect us to a new session path by default using turbo_stream format. Remember we were using target: ‘_top’
in the turbo frame? This will make the entire page change, and we only want to show the errors on the sign-in form. Therefore, to update the sign-in form with errors, we should use turbo_stream.replace to directly focus on the turbo frame that we want to change.
After that, we have to go to our routes.rb and make our Devise declaration look like this:
devise_for :users, controllers: { sessions: "sessions" }
Our last step is removing TurboFailureApp
class from initializers/devise.rb
and also delete:
config.warden do |manager|
manager.failure_app = TurboFailureApp
end
With this approach we don’t need a custom FailureApp
to handle sign in error because we are going to take advantage of the redirect to the new path that devise does by default.
We’re all set now. Maybe it looks a little tricky, but it’s our recommended way to adapt Devise to use Turbo with modals.
Did this guide help you? Let us know and dive into more of our step-by-step tutorials in our blog.