Benito Serna Tips and tools for Ruby on Rails developers

Remote modals with rails, hotwire and bootstrap

January 4, 2022

Here I want to show you how you can build “remote modals” (modals where the content is requested to the server), submit forms embedded in them and handle errors, with rails, hotwire and bootstrap.

Example app

I will show you how to build is something like this…

Where you can…

Like a standard rails CRUD, but using modals to show the form.

Play with the code

The code of the app is on github.com/bhserna/remote modals hotwire bootstrap, you can clone it and play with it.

Versions

For the dependencies that I will talk about here, I am using this versions:

Show a form in modal

In the example app you can see this action twice:

  1. On the “New product” link
  2. And on the “Edit” link

Let’s see how the “New product” link works…

If you go to views/products/index.html.haml, you will see that the link has a data-turbo-frame="remote_modal" attribute.

%div= link_to "New product", new_product_path, class: "btn btn-primary",
  data: { turbo_frame: "remote_modal" }

This will tell turbo, to look for an already defined turbo_frame_tag with the id remote_modal.

This tag is defined on views/layouts/application.html.erb

<%= turbo_frame_tag "remote_modal", target: "_top" %>

With that tag defined, turbo will expect a server response with a matching turbo_frame_tag.

That means that a <turbo-frame id="remote_modal"> element should be somewhere in the server response.

This is implemented like this…

If you see the products controller you will see that it just sets the @product instance variable and renders the default template new.html.

def new
  @product = Product.new 
end

On view/products/new.html.haml you will see that it renders the "remote_modal" partial.

= render "remote_modal", title: "New product" do
  = render "form", product: @product

This partial is on views/application/_remote_modal.html.haml

= turbo_frame_tag "remote_modal" do
  .modal.fade(tabindex="-1" data-controller="remote-modal" data-action="turbo:before-render@document->remote-modal#hideBeforeRender")
    .modal-dialog
      .modal-content
        .modal-header
          %h5.modal-title= title
          %button(type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close")
        .modal-body#remote_modal_body
          = yield

As you can see it wraps the modal html within the expected <turbo-frame id="remote_modal"> element with the code…

= turbo_frame_tag "remote_modal" do

The html is almost the standard modal template from bootstrap. But with some modifications…

  1. It expects a title local variable.
  2. It yield on the .modal-body.
  3. It initializes a remote-modal stimulus controller.
  4. It defines an action on the stimulus controller that calls remote-modal#hideBeforeRender.
  5. It defines a #remote_modal_body html id.

At this point the things that are required are the first three…

  1. It expects a title local variable.
  2. It yield on the .modal-body.
  3. It initializes a remote-modal stimulus controller.

Here I am passing the title and putting the yield on the .modal-body just because I think that in this example app it makes scense, but maybe on your app it will be better to just yield on .modal-content and define the .modal-header and .modal-body on the passed block.

The attribute data-controller="remote-modal" initializes the remote modal stimulus controller that on connect will initialize a bootstrap.Modal object for the element and show it.

import { Controller } from "@hotwired/stimulus"
import { Modal } from "bootstrap"

export default class extends Controller {
  connect() {
    this.modal = new Modal(this.element)
    this.modal.show()
  }

  //...
}

And… that’s what you need to show a remote modal…

Handing the form redirect and close the modal

When the form is processed with success we normally want to redirect to a new page. On the example app the redirection is to the products_path, the index page.

Be default a turbo_frame would expect a matching turbo_frame in the response, to update the content of the turbo_frame, but if we want to redirect and close the modal, we need to target the whole page by default with a target="_top".

Like in the template views/layouts/application.html.erb

<%= turbo_frame_tag "remote_modal", target: "_top" %>

That, combined with the redirect on the create action, will change the content of the whole page and close the modal instantly (without the fade effect).

def create
  @product = Product.new(product_params)

  if @product.save
    redirect_to products_path
  else
    render :form_update, status: :unprocessable_entity
  end
end

Close the modal with the fade effect

If we want to keep the modal’s fade effect that bootstrap provides, we need somehow tell turbo to pause before it renders the page, to let bootstrap hide the modal, and then continue with the render after the modal it is already hidden.

So, you can listen to the turbo:before-render event to then…

In the example app the listener to the turbo:before-render is on the data-action defined on the remote_modal partial…

= turbo_frame_tag "remote_modal" do
  .modal.fade(tabindex="-1" data-controller="remote-modal" data-action="turbo:before-render@document->remote-modal#hideBeforeRender")
    = #...

Here the code is telling stimulus, to listen for the turbo:before-render event on document and then call the remote-modal#hideBeforeRender method.

That method is defined like this…

export default class extends Controller {
  //...

  hideBeforeRender(event) {
    event.preventDefault()
    this.element.addEventListener('hidden.bs.modal', event.detail.resume)
    this.modal.hide()
  }
}

Here the code is…

Rendering form errors

To render the errors what you need to do is to respond with a turbo stream that will replace the html of the form.

As you can see on the create action, when @product.save is not successfull, the app will render the form_update template and return the :unprocessable_entity status.

def create
  @product = Product.new(product_params)

  if @product.save
    redirect_to products_path
  else
    render :form_update, status: :unprocessable_entity
  end
end

The form_update template looks like this…

<%= turbo_stream.update "remote_modal_body" do %>
  <%= render "form", product: @product %>
<% end %>

This will respond with a turbo-stream that will look something like this…

<turbo-stream action="update" target="remote_modal_body">
  <template>
    <form action="...">
    </form>
  </template>
</turbo-stream>

This will tell turbo to look for an html id remote_modal_body (defined on the remote_modal partial) and update its content with whatever is inside the template tag. In this case the form with the rendered errors.

Here we don’t actually need to respond with :unprocessable_entity for the errors by rendered. But I think that in this case it is a better response status than 200.

Conclusion

If we try to compact the whole post in some steps…

To show a remote modal you will need…

To handle redirects and close the modal you will need to…

To close the modal with the fade effect…

And to render the errors…

Related articles

Weekly tips and tools for Ruby on Rails developers

I send an email each week, trying to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or All post by topic.