Bootstrap Modals Using Hotwire In Rails
Tags: rails, bootstrap, hotwire, ruby
Published: 2022-02-16 16:39:01

I recently implemented [Bootstrap modals](https://getbootstrap.com/docs/5.1/components/modal/) on [RailsGems.com](https://railsgems.com) using [Hotwire](https://hotwired.dev) for showing [Devise](https://github.com/heartcombo/devise) forms. However I ran into a few hurdles so wanted to share the lessons learned and the final implementation. :sunglasses:

One of my requirements was to show the modal by default, however when you open the link in a new tab it should still render the regular form inline. I really liked this fallback functionality, so let's see how I got it implemented.

---

However before diving in, I'd recommend reading [How to build modals with Hotwire (Turbo Frames + StimulusJS)](https://www.bearer.com/blog/how-to-build-modals-with-hotwire-turbo-frames-stimulusjs) and [Turbo Frames on Rails](https://www.colby.so/posts/turbo-frames-on-rails#conditional-template-rendering-using-variants), both are excellent articles on using [Hotwire](https://hotwired.dev). :heart_eyes:

---

#### app/views/layouts/application.html.erb
You'll need to add an empty `turbo_frame_tag` with id `modal` to your `application.html.erb`. This is what we'll use to drop in the modal partial later.
```erb
<%= turbo_frame_tag "modal" %>
```

--- 

#### app/views/partials/_modal.html.erb
This partial is the Bootstrap modal, which takes `title`, `body` and `footer` locals, it also attached `modal` Stimulus controller.
```erb
<% 
  title ||= "Modal"
  body ||= ""
  footer ||= ""
%>
<div class="modal fade" tabindex="-1" data-controller="modal">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <%= tag.h5 title, class: "modal-title" %>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <%= tag.div class: "modal-body" do %>
        <%= render body %>
      <% end if body.present? %>
      <%= tag.div class: "modal-footer" do %>
        <%= render footer %>
      <% end if footer.present? %>
    </div>
  </div>
</div>
```
_Note_, I wanted to pass multiple blocks to the partial, however couldn't find an elegant way to achieve this, so opted to simply pass the path to the partials.

--- 

#### app/helpers/application_helper.rb
Added a helper so it's convenient to render the partial in our views.
```ruby
def render_modal(title: "", body: "", footer: "")
  render(partial: '/partials/modal', locals: { title: title, body: body, footer: footer })
end
```

---

#### app/javascript/controllers/modal_controller.js
This [Stimulus](https://stimulus.hotwired.dev) controller handles auto showing the modal, removing the `.modal-backdrop` to avoid layering backdrops if we show another modal, plus removes the contents of the `turbo_frame_tag` after the modal has been hidden. 
```javascript
import {
  Modal
} from "bootstrap"
import {
  Controller
} from "@hotwired/stimulus"
export default class extends Controller {
  connect() {
    let backdrop = document.querySelector(".modal-backdrop");
    if (backdrop) {
      backdrop.remove();
    }
    this.modal = new Modal(this.element);
    this.modal.show();
    this.element.addEventListener('hidden.bs.modal', (event) => {
      this.element.remove();
    })
  }
}
```

---

#### app/controllers/application_controller.rb
Big thanks to [David Colby](https://twitter.com/davidcolbyatx) for suggesting setting the [variant for Turbo Frame requests](https://www.colby.so/posts/turbo-frames-on-rails#conditional-template-rendering-using-variants), _it works like magic!_ :raised_hands:
```ruby
before_action :turbo_frame_request_variant

def turbo_frame_request_variant
  request.variant = :turbo_frame if turbo_frame_request?
end
```

---

#### app/views/devise/sessions/new.html.erb
Nothing fancy here, just plain Bootstrap flavored card showing the sessions partial.
```erb
<div class="row">
  <div class="col-sm-10 col-md-8 col-lg-6 col-xl-4 offset-sm-1 offset-md-2 offset-lg-3 offset-xl-4">
    <div class="card mb-4">
      <div class="card-header">
        <h2 class="card-title">Sign in</h2>
      </div>
      <div class="card-body">
        <%= render "devise/sessions/new" %>
      </div>
      <div class="card-footer text-muted">
        <%= render "devise/shared/links" %>
      </div>
    </div>
  </div>  
</div>
```

---

#### app/views/devise/sessions/new.html+turbo_frame.erb
Because we set `request.variant = :turbo_frame` in the `application_controller`, Rails will automatically render this view if it's a Turbo Frame request, _amazing!_ :blush:
```erb
<%= turbo_frame_tag "modal" do %>
  <%= render_modal title: t('.sign_in'), body: "/devise/sessions/new", footer: "/devise/shared/links" %>
<% end %>
```

---

#### app/views/devise/sessions/_new.html.erb
The sessions partial which renders the login form, note I set `data: { turbo: false }` so it does a full post back when submitting the login form. 
```erb
<%= form_for(resource, as: resource_name, url: session_path(resource_name), data: { turbo: false }) do |f| %>
  <div class="mb-3">
    <%= f.label :email, class: "form-label" %>
    <%= f.email_field :email, autofocus: true, autocomplete: "email", class: 'form-control' %>
  </div>
  <div class="mb-3">
    <%= f.label :password, class: "form-label" %>
    <%= f.password_field :password, autocomplete: "current-password", class: 'form-control' %>
  </div>
  <% if devise_mapping.rememberable? %>
    <div class="mb-3 form-check">
      <%= f.check_box :remember_me, class: 'form-check-input' %>
      <%= f.label :remember_me, class: 'form-check-label' %>
    </div>
  <% end %>
  <div class="mb-4 mt-4 text-end">
    <%= f.submit t('.sign_in'), class: "btn btn-primary" %>
  </div>
<% end %>
```

---

#### app/views/partials/_navbar.html.erb
When linking to `new_user_session_path` to login, it's important to set `data: { turbo: true, turbo_frame: 'modal' }`, this will set Hotwire know it's a Turbo Frame request.
```erb
<%= link_to "Sign In", new_user_session_path, data: { turbo: true, turbo_frame: 'modal' } %>
```

---

#### app/views/devise/shared/_links.html.erb
In your shared links for Devise, be sure to also set `data: { turbo: true, turbo_frame: 'modal' }`.
```erb
<%= link_to "Sign up", new_registration_path(resource_name), data: { turbo: true, turbo_frame: 'modal' } %>
```

---

_And that's it!_ The login form should now be shown as a modal, however when you open the link in a new tab, it renders the regular inline form instead, which is nice fallback logic.

![Hotwire Modal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/enakcaq8byqe5citw6b4.png)

Also clicking the footer links for signup or forgot password, will also show those as modals, and thanks to our Stimulus controller it avoids multiple backdrops getting layer on top each other. :+1:

---

You can checkout the full source code of [RailsGems.com](https://railsgems.com) at [github.com/dalezak/rails-gems](https://github.com/dalezak/rails-gems). _Enjoy!_