Sketching our Rails application without modals
In this article, we will learn how to create modals with Hotwire.
We will start by creating a standard CRUD application and then enhance it with our modals.
Let's imagine we want to build a to-do list application with Ruby on Rails and Hotwire. The Items#index
page will look like this:
We will also need a page to create new items, so we will need a Items#new
page that could look like this:
As our application is built with Turbo Rails, when clicking on the "New item" link, the new item form is extracted from the Items#new
page and inserted within the Turbo Frame with the corresponding id on the Items#index
page:
When we create a valid item, it is prepended to the list of items, and we remove the form from the Items#index
page:
Let's first build this application without any modals. We will learn how to add them later.
Coding our Rails application without modals
First, we need an Item
model that has a name
field that must be present:
# app/models/item.rb
class Item < ApplicationRecord
validates :name, presence: true
scope :ordered, -> { order(id: :desc) }
end
We can then create the corresponding Rails controller that only has the #index
, #new
, and #create
actions:
# app/controllers/items_controller.rb
class ItemsController < ApplicationController
def index
@items = Item.all.ordered
end
def new
@item = Item.new
end
def create
@item = Item.new(item_params)
if @item.save
respond_to do |format|
format.html { redirect_to items_path }
format.turbo_stream
end
else
render :new, status: :unprocessable_entity
end
end
private
def item_params
params.require(:item).permit(:name)
end
end
The Items#index
view lists the items. It also contains a link to the Items#new
page that drives the Turbo Frame with id "new_item"
:
<%# app/views/items/index.html.erb %>
<main class="container">
<div class="col-md-6 offset-md-3">
<div class="d-flex justify-content-between align-items-center my-4">
<h1 class="h3">
All items
</h1>
<%= link_to "New item",
new_item_path,
class: "btn btn-primary",
data: { turbo_frame: "new_item" } %>
</div>
<%= turbo_frame_tag "new_item" %>
<div id="items" class="d-flex flex-column gap-3">
<%= render @items %>
</div>
</div>
</main>
Each item looks like this:
<%# app/views/items/_item.html.erb %>
<div class="border rounded p-3">
<%= item.name %>
</div>
Note: We use Bootstrap in this tutorial for styling purposes, so this is where the CSS classes come from!
On the Items#new
page, we need a Trubo Frame with an id of "new_item"
that wraps the form so that Turbo can extract the content of this Frame and insert it on the Items#index
page:
<%# app/views/items/new.html.erb %>
<main class="container">
<%= link_to "Back to items", items_path %>
<h1 class="my-4">
New item
</h1>
<%= turbo_frame_tag "new_item" do %>
<%= render "items/form", item: @item %>
<% end %>
</main>
The form is a classic Rails form that also contains an alert in case there are some errors:
<%# app/views/items/_form.html.erb %>
<% if item.errors.any? %>
<div class="alert alert-danger">
<%= item.errors.full_messages.to_sentence %>
</div>
<% end %>
<%= form_with model: item, class: "row g-3 mb-3" do |f| %>
<div class="col-auto flex-grow-1">
<%= f.label :name, class: "visually-hidden" %>
<%= f.text_field :name, placeholder: "Todo item", class: "form-control" %>
</div>
<div class="col-auto">
<%= f.submit "Save", class: "btn btn-primary" %>
</div>
<% end %>
Last but not least, when the form submission is successful, we need a Turbo Stream view to prepend the created item to the list of items and clear the content of the Turbo Frame that contains the form on the Items#index
page:
<%# app/views/items/create.turbo_stream.erb %>
<%= turbo_stream.prepend "items", @item %>
<%= turbo_stream.update "new_item", "" %>
Our application should now work as described in the sketches above. It's now time to start thinking about how we will implement our modals with Hotwire.
Sketching our modal with Turbo Frames and Stimulus.js
Now that we have a working application, we would like to make the creation of new items happen in a modal. Let's rename our "new_item"
Turbo Frame to "modal"
on the Items#index
page and move it to the bottom of the page:
We should also update our Items#new
page to ensure the Turbo Frame around the form also has an id of "modal"
:
Now, when clicking on the "New item" button, the new item form is inserted on the Items#index
page in the "modal"
Turbo Frame.
The only thing left to do is to create a Stimulus controller that:
- Opens the modal when a Turbo Frame is inserted inside it.
- Closes the modal when a valid form is submitted.
Let's learn to do this in the next section.
Coding our modal with Turbo Frames and Stimulus.js
First, let's add the "modal"
Turbo Frame to the application's layout. While adding the modal to the layout is not strictly necessary, it's a good way to ensure there is always an empty modal on every page. That way, we ensure that our modal pattern will work on every page of our application.
Let's add our modal markup and the corresponding Turbo Frame in the layout:
<%# app/views/layouts/application.html.erb %>
<body>
<%= yield %>
<div class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<%= turbo_frame_tag "modal" %>
</div>
</div>
</div>
</body>
Let's now update the Items#index
and Items#new
pages markup to use the "modal"
id instead of the "new_item"
one:
<%# app/views/items/index.html.erb %>
<main class="container">
<div class="col-md-6 offset-md-3">
<div class="d-flex justify-content-between align-items-center my-4">
<h1 class="h3">
All items
</h1>
<%= link_to "New item",
new_item_path,
class: "btn btn-primary",
data: { turbo_frame: "modal" } %>
</div>
<div id="items" class="d-flex flex-column gap-3">
<%= render @items %>
</div>
</div>
</main>
<%# app/views/items/new.html.erb %>
<main class="container">
<%= link_to "Back to items", items_path %>
<%= turbo_frame_tag "modal" do %>
<h1 class="h3 modal-header">
New item
</h1>
<div class="modal-body">
<%= render "items/form", item: @item %>
</div>
<% end %>
</main>
When clicking on the "New item" button on the Items#index
page, the form is successfully inserted inside the modal, but the modal does not yet open.
Luckily, we can use Turbo's custom events to:
- Open the modal when a Turbo Frame is inserted inside it.
- Close the modal when a valid form is submitted.
According to the documentation:
- The
turbo:frame-load
event fires when a<turbo-frame>
element finishes loading. In our case, this means, when the form was successfully inserted inside our"modal"
Turbo Frame. - The
turbo:submit-end
event fires after the form submission-initiated network request completes. In our case, that means when the form submission for our new item ends.
We will use those two events to create a Stimulus controller that will open and close the modal. Let's add the markup in our layout to bind those two events to the corresponding JavaScript behavior inside the modal
Stimulus controller.
<%# app/views/layouts/application.html.erb %>
<body>
<%= yield %>
<div
class="modal"
tabindex="-1"
data-controller="modal"
data-action="turbo:frame-load->modal#open turbo:submit-end->modal#close"
>
<div class="modal-dialog">
<div class="modal-content">
<%= turbo_frame_tag "modal" %>
</div>
</div>
</div>
</body>
As we can see in the previous markup, we want to open the modal when the turbo:frame-load
event is fired and close the modal when the turbo:submit-end
event is fired.
Let's now create the corresponding Stimulus controller:
// app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"
import * as bootstrap from "bootstrap"
export default class extends Controller {
connect() {
this.modal = new bootstrap.Modal(this.element)
}
open() {
if (!this.modal.isOpened) {
this.modal.show()
}
}
close(event) {
if (event.detail.success) {
this.modal.hide()
}
}
}
Let's break this code down together.
First, when the Stimulus controller is connected to the DOM, we instantiate a modal object thanks to the Bootstrap JavaScript library and store it inside this.modal
. If you don't use Bootstrap, the code will be slightly different here, but the principle remains the same.
In the open
action, we can open the modal when it is not already opened.
In the close
action, we can check if the form submission was successfull with event.detail.success
. If that is the case, we can close the modal.
As the Stimulus controller closes the modal automatically, it is not necessary to manually remove the content inside it. We can safely delete the line that removes the form in the Turbo Stream create.turbo_stream.erb
view:
<%# app/views/items/create.turbo_stream.erb %>
<%= turbo_stream.prepend "items", @item %>
Our application should now work as expected if we test our modal in the browser!