How to create modals with form handling through a Turbo frame
23 Feb 2023With the new Turbo.js 7.2, and more specifically this PR, Turbo would automatically navigate to a new page on turbo missed frame. Let me show you how we can use this to our benefit.
This is an upgrade to the previous approach I demonstrated in How to create seamless modal forms with Turbo Drive.
As a reminder, goal is to not introduce any changes to the backend code (no Turbo Streams), but still be able to submit forms and see validation errors on a modal. This allows for a progressive upgrade, where a page could be renders both on its own, as well as in a modal.
Steps
On the JavaScript side
Any javascript that displays a modal would do.
I’ve used the one from Tailwind Stimulus Components as it’s nice, maintained and self-contained.
Make 2 modifications:
- Add
turboFrame
as a target - Modify the
open(e)
method to set the turbo frame src if it’s a remote modal
import { Controller } from '@hotwired/stimulus'
import { Modal } from 'tailwindcss-stimulus-components'
export default class extends Modal {
static targets = ['container', 'turboFrame']
open(event) {
if (this.hasTurboFrameTarget) {
this.turboFrameTarget.src = event.target.href
}
super.open(event)
}
}
Unlike our previous solution there won’t be any listening to events and manually checking and handling responses.
On the Rails side
A ModalComponent
to render both inline and async modals:
# app/components/modal_component.rb
# Usage
#
# = render ModalComponent.new(remote: true) do |modal|
# = modal.trigger do
# = link_to "Open modal",
# new_resource_path(article),
# class: "btn",
# data: modal.trigger_attributes
#
# = modal.body do
#
# in /my_resources/new.html.slim wrap the section that you'd like
# to go in the modla in {ModalBodyComponent} like so:
# = render ModalBodyComponent.new do
# / modal content
#
class ModalComponent < ViewComponent::Base
include Turbo::FramesHelper
attr_reader :remote, :title
renders_one :body
renders_one :trigger
def initialize(remote: false, title: nil)
@remote = remote
@title = title
super
end
def trigger_attributes
{ action: "click->modal#open" }
end
end
/ app/components/modal_component.html.slim
- random_identifier = SecureRandom.hex
= tag.div data: { controller: "modal" }
= trigger
/ The modal itself
= tag.div data: { action: "click->modal#closeBackground keyup@window->modal#closeWithKeyboard",
modal_target: "container" },
class: "hidden animated anim-scale-in faster fixed inset-0 overflow-y-auto flex items-center justify-center",
role: "dialog",
"aria-labelledby": "dialog-title",
"aria-describedby": "dialog-body-#{random_identifier}",
style: "z-index: 9999;" do
div class="max-h-screen w-full max-w-2xl relative"
div class="mx-auto align-middle bg-white rounded-lg shadow"
header id="dialog-title" class="border-b py-4 pl-8 pr-4 flex justify-between items-center"
- if title.present?
h4 class="mb-0 font-bold"
= title
button[
type="button"
class="btn text-gray-400 hover:text-gray-800"
data-action="click->modal#close"
]
span.sr-only Close
/ Heroicon name: outline/x-mark
<svg class="fill-current h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
div id="dialog-body-#{random_identifier}" class="p-8"
- if remote
= turbo_frame_tag "modal-#{random_identifier}", data: { modal_target: "turboFrame" }
- else
= body
And a ModalBodyComponent
to handle the rendering of the async content. Here, if we need to render a response with an error, we need to use the dom id of the turbo frame that made the request. We handily grab it from the Turbo-Frame
header. Nice!
# app/components/modal_body_component.rb
class ModalBodyComponent < ViewComponent::Base
include Turbo::FramesHelper
# If the response resulted in an error, we'd typically
# render from a controller. When rendering, it's important to render a turbo
# frame with the same dom_id as the request.
def turbo_frame_id
request.headers["Turbo-Frame"]
end
end
/ app/components/modal_body_component.html.slim
= turbo_frame_tag turbo_frame_id do
= content
Usage
Controller has no modifications. Example one:
class ArticlesController < BaseController
def new
article = Article.new
render locals: { article: article }
end
def create
result = Article.create(params)
if result
redirect_to articles_path
else
render :new, locals: { article: article }, status: :unprocessable_entity
end
end
end
Wrap the link or button which triggers the modal in ModalComponent
:
= render ModalComponent.new(remote: true) do |modal|
= modal.trigger do
= link_to "New",
new_article_path,
class: "btn",
data: modal.trigger_attributes
Wrap the rendered action (new
in this case) in a ModalBodyComponent
:
/ app/views/articles/new.html.slim
= render ModalBodyComponent.new do
h3 Add
= render "shared/errors", model: article
= form_with article do |f|
/ ...
Final result
Any validation errors correctly update the turbo frame within the modal. If no errors, a redirect is followed.
Hope it’s useful! Thanks Turbo! 🙌