How to Ruby logo

How to create modals with form handling through a Turbo frame

23 Feb 2023

Html declarative turbo animations

With 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:

  1. Add turboFrame as a target
  2. 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! 🙌