We can use Hotwire -- specifically, Stimulus and Turbo -- to create some modals that present a nice, dynamic user experience. And they can do this while staying in a mutli-page app structure that Rails is so good at.
First off, we need to know that, with Turbo, you can load a page with a turbo-frame
element and Turbo will take the element from the newly loaded page and inject its contents into the current page. It works just like frame
s and iframe
s work, but... way better.
So, what we're talking about here is an index page and an edit page -- oh, and a small Stimulus controller to pop up the modal. The index page lists out your Resources and has an empty modal section. The edit page looks exactly like the index page, but has an edit form in the modal section.
For the purpose of this example, let's call the Resource a "Gadget", just so it doesn't have a name that's already a core Rails concept.
On the index page, you have your Gadgets, and you want to list your Gadgets in a cool table (for however cool a table can be, but I digress). You'll likely have something like this in your app/views/gadgets/index.html.erb
:
<table id="gadgets-table">
<% @gadgets.each do |gadget| %>
<tr>
<td><%= gadget.name %></td>
<td><%= link_to "Edit", edit_gadget_path(gadget), data: { "turbo-frame": "modal-content" } %></td>
</tr>
<% end %>
</table>
<div id="modal" data-controller="modal">
<turbo-frame id="modal-content"></turbo-frame>
</div>
Ok, there we go. Now we have an index that displays our Gadgets and a link that will load the edit page and replace the content of the modal via Turbo. Plus, also, a nice container for the modal itself. Gotta have that.
Similarly, we need the edit page. That will look very similar, but with an important difference.
<table id="gadgets-table">
<% @gadgets.each do |gadget| %>
<tr>
<td><%= gadget.name %></td>
<td><%= link_to "Edit", edit_gadget_path(gadget), data: { "turbo-frame": "modal-content" } %></td>
</tr>
<% end %>
</table>
<div id="modal" data-controller="modal">
<turbo-frame id="modal-content">
<%= form_with model: @gadget do |form| %>
<%= form.text_field :name %>
<%= form.submit "Save" %>
<% end %>
</turbo-frame>
</div>
The difference, as you probably noticed, is that the modal has a form in it, set to edit the specified Gadget
. Importantly, [the data-turbo-frame
attributes on the links let Turbo know to load the page and swap out the modal-content
frame targeting. So when the user clicks on the link in the table, the edit form's contents will just pop into that turbo-frame
. You can see this if you did it right now, because we don't have any CSS to make this look like a modal, or to hide the modal.
Oh, right! CSS! Ok, let's get on that. Put this inside app/assets/stylesheets/modal.css
(or wherever you're putting your CSS):
#modal {
background: rgba(#002045, 0.85)
display: none;
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
#modal.visible {
display: flex;
align-items: center;
justify-content: center;
}
#modal-content {
width: 50%;
background: #fff;
}
Now the modal will look and act like a modal. This is the last part I mentioned at the start: The small Stimulus controller that watches for changes. Here's where we add the ModalController
that the data-controller="modal"
attribute on the modal container alluded to. This will go in app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["open"]
openTargetConnected() {
this.open()
}
openTargetDisconnected() {
this.close()
}
open() {
this.element.classList.add("visible")
}
close() {
this.element.classList.remove("visible")
}
}
This works because Stimulus controllers have a number of lifecycle callbacks. In this case, we want the callbacks that activate when a target
is connected and disconnected (that is, added to or removed from the children of the controller element). A target
is marked with an attribute: data-<controller-name>-target="<target-name>"
. We have the ModalController
above, and it has an open
target, so the attribute would be data-modal-target="open"
. When a DOM node is either added that already has this attribute, or the attribute is added to an existing element, the openTargetConnected
method on the ModalController
is called.
This means that when Turbo loads the edit page and swaps in the modal, the ModalController
will see the open
target and give the whole thing the visible
class.
Cool, so now what happens when we submit? Well, turbo-frame
s are limited to one interaction per page load, basically. And since what we want to do is close the modal and update the contents of the index, we'll need something a little more flexible: Turbo Streams
Turbo Streams let you batch up multiple changes into one response. And, despite their name, you don't have to use them in a streaming context. You can return them from an action, same as anything else.
In our GadgetsController
's update
action, we need to save the Gadget
we just changed in the browser. Then, instead of rendering, we redirect to the index
. The index
's turbo-stream
response will do what we need it to: it will replace the modal with an empty modal, and it will replace the table with a table that contains the new Gadget
's information.
This change to the index
action is pretty small. It's using already-existing functionality:
respond_to do |format|
format.html
format.turbo_stream
end
If you are using the turbo-rails
gem, this format will be created for you. This, then, should be what app/views/gadgets/index.turbo_stream.erb
looks like (and you can, of course, extract whatever you want into partials):
<turbo-stream action="replace" target="modal-content">
<template>
<turbo-frame id="modal-content"></turbo-frame>
</template>
</turbo-stream>
<turbo-stream action="replace" target="gadgets-table">
<template>
<table id="gadgets-table">
<% @gadgets.each do |gadget| %>
<tr>
<td><%= gadget.name %></td>
<td><%= link_to "Edit", edit_gadget_path(gadget), data: { "turbo-frame": "modal-content" } %></td>
</tr>
<% end %>
</table>
</template>
</turbo-stream>
Now, when the user submits the form and it saves successfully, the update
action will redirect to index
and index
will render the above turbo-stream
. Turbo will replace the modal-content
which will make Stimulus close the modal, and it will update the table to reflect the new value of the Gadget
.
The best part of all of this is that this is just using existing web paradigms. And if the user (for whatever reason) doesn't have javascript enabled, this will Just Work in exactly the same manner (albeit a little slower) because of the progressive enhancement on top of the multi-request cycle. And with Javascript, it never reloads the page.
Learn more about how The Gnar builds Ruby on Rails applications.
Top comments (2)
Great post! If you gave each gadget an id using dom_id, could you modify the turbo stream to replace the change to the individual gadget, rather than replacing the collection?
Yup! Turbo stream is capable of replacing anything with an id. Replacing the collection here just made sense since it was using the
index
action.