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.
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.
The code of the app is on github.com/bhserna/remote modals hotwire bootstrap, you can clone it and play with it.
For the dependencies that I will talk about here, I am using this versions:
In the example app you can see this action twice:
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…
title
local variable.yield
on the .modal-body
.remote-modal
stimulus controller.remote-modal#hideBeforeRender
.#remote_modal_body
html id.At this point the things that are required are the first three…
title
local variable.yield
on the .modal-body
.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…
data-turbo-frame="remote-modal"
(or whatever id you want).<turbo-frame id="remote-modal">
.<turbo-frame id="remote-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
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…
event.preventDefault()
.hidden.bs.modal
event, that will be triggered
by bootstrap after the modal has been closed, and then call
event.detail.resume
that is provided by turbo.this.modal.hide()
that will tell bootstrap to actually hide the modal.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
.
If we try to compact the whole post in some steps…
data-turbo-frame="remote-modal"
(or whatever id you want).<turbo-frame id="remote-modal">
.<turbo-frame id="remote-modal">
.target="_top"
to your already defined <turbo-frame id="remote-modal">
.turbo:before-render
event and then…Here I try 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 posts.