Find the keys to using Devise with Hotwire utilizing a modal

post cover picture

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.

 

Step zero: A little recap 


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. 

 

Step 1: Bootstrap gives us a hand


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

 

 

Step 2: Creating a warm welcome


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>

 

 

Step 3:  Time to install our Devise gem


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’)

 

Step 4: Where are the flash messages?


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" }

 

Step 5: Let’s make it work without disabling Turbo


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.

 

Step 6: Handling the sign-in error message turbulence


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.

Want to know more about us?

LEARN MORE
post creator picture
Santiago Bertinat
July 01, 2022

Would you like to stay updated?