Presenting forms in modals with Turbo

Hi,

I’m trying to figure out how to use Turbo to present a form in a modal.
For the sake of this example let’s say the model is a Group.

In the application.html.erb layout I’ve added the following line before the closing body tag:

<%= turbo_frame_tag "modal" %>

To present the modal, the user clicks the following link in index.html.erb:

<%= link_to 'Create new', new_group_path, data: { 'turbo-frame': 'modal' } %>

The new.html.erb view looks like this:

<%= turbo_frame_tag "modal" do %>
  <%= form_with model: @group, url: groups_path, local: true, data: { 'turbo-frame': '_top' } do |form| %>
    <%= form.submit _('Create') %>
  <% end %>
<% end %>

Finally, the relevant actions in GroupsController look like this:

def new
  @group = Group.new
end

def create
  @group = Group.new(group_params)

  respond_to do |format|
    if @group.save
      format.html { redirect_to group_path(@group) }
    else
      format.html { render :new, status: :unprocessable_entity }
    end
  end
end

This almost works. When the link is clicked, the form is fetched and inserted into the corresponding <turbo_frame id=“modal”> (I then use a Stimulus controller to present it).

When the form submission is successful, Turbo will navigate from /groups to /groups/1.

However, if a validation error occurs, Turbo will remain on /groups but the entire page is replaced with the contents of new.html.erb. This is because of the data: { 'turbo-frame': '_top' } attribute on the form.
If I remove it, the redirect on a successful form submission does not work.

Is there a best practice for handling this?

here is what I came up with

1 Like

Thanks for your reply, @dstull.

I ended up writing a couple of lines of JavaScript to work around the issue. It’s a hack, but it means I don’t have to bring Turbo Streams into play just to handle a simple form submission. I can share my solution if you’re interested.

@agis I’d be interested in what you came up with.

@agis Yup. Let’s take a look.

That would be great!

Recall that the issue is the following:

  1. If the form submission fails, we want to return the form with an error message. This should replace the form that is currently being shown.
  2. If the form submission succeeds, we want to cause a redirect for the entire page, even though the form is presented in a frame.

The client-side part of Turbo will delegate navigation to either a Navigator object or a FormController for replacing the entire page or just a frame, respectively. Which one is used is determined when the request is made, so you can’t decide based on the response.

The solution is pretty close to what I posted originally. The new.html.erb looks like this:

<%= turbo_frame_tag "modal" do %>
  <%= form_with model: @group, local: true, data: { 'controller': 'form' } do |form| %>
    <!-- Form fields here... -->
    <%= form.submit _('Create') %>
  <% end %>
<% end %>

I’m still using Rails 6.0 so I set local: true on the form but I believe this is the default on Rails 6.1, so you can omit it if you’ve upgraded.

You’ll notice a Stimulus controller on the form, which looks like this:

import { Controller } from "stimulus"

export default class extends Controller {
    static targets = ["closeButton"]

    connect() {
        this.element.addEventListener("turbo:submit-start", (event) => {
            const frameController = event.detail.formSubmission.delegate

            // Workaround to ensure that the entire page is replaced, when a form
            // submission succeeds, even if the form is displayed within a turbo-frame.
            frameController.formSubmissionSucceededWithResponse = (formSubmission, response) => {
                Turbo.navigator.formSubmission = formSubmission
                Turbo.navigator.formSubmissionSucceededWithResponse(formSubmission, response).catch(console.error)
            }
        })
    }

}

This hijacks successful form responses from the “frame controller” and forwards them to the “navigator”. The result is that successful form submissions are allowed to break out of the frame that contains the form and cause the entire page to redirect, while submissions that fail will cause just the frame, in which the form is presented, to be replaced. Note that these are internal library methods, so this solution might not work in future versions of Turbo.

2 Likes

I elaborated this variation on the interesting @dstull code: Daniele Tonon / turbo_modal · GitLab

It works with and without js, has a quite consistent routing pattern and even supports back/forward navigation.

I was asking about routing suggestions on this thread: https://discuss.hotwired.dev/t/routing-in-a-modal-interface

It’s a hack, but it means I don’t have to bring Turbo Streams into play just to handle a simple form submission.

I’m not sure I understand, what’s the motivation for avoiding turbo-streams?

@tleish
Not speaking for @agis but a turbo-frame seems like a proper fit (“decompose pages into independent contexts”) for a modal form since in many cases you are populating the modal with form content independent of the current view, which usually will be another “main” form.

For example, in an app I’m working on there is a shipping form. Within the shipping form I want the user to be able to create a new customer on the fly without leaving the shipping form, hence the need for a customer creating modal.

This scenario fits with what a turbo-frame should be able to accomplish based on its definition. I went with the turbo-streams approach but kudos to @agis (and @daniele) for detailing their work using turbo-frames.

I’ve noticed a repeated pattern that many misunderstand turbo-streams. They believe it requires websockets. While turbo-streams shines with websockets, turbo-streams are simple and powerful without websockets.

In the above original scenario, you could do something like:

def create
  @group = Group.new(group_params)

  respond_to do |format|
    if @group.save
      format.html { redirect_to group_path(@group) }
    else
+     format.turbo_stream
      format.html { render :new, status: :unprocessable_entity }
    end
  end
end

and then a stream file with something like

# create.turbo_stream.erb
<%= turbo_stream.replace "modal" do %>
  <%= render 'new' %>
<% end %>
1 Like

@tleish I totally agree regarding turbo-streams being thought of as websockets tech as that was my first impression. I also concur that it is fantastic pattern for regular “Ajax” request. Generally speaking, turbo-streams will have much more impact with the latter transport. Better explanation of streams in the turbo documentation would be helpful.

I have since had to update this to work with latest turbo updates - turbo-rails-0.5.8...turbo-rails-0.5.9 · Doug Stull / turbo_modal · GitLab

I agree, I find I’m using turbo streams more without websockets than with websockets!

Hi :wave: ,

A bit of an old topic, but I recently figured out a way to use turbo frames in modals, and not require any change from standard rails controller code.

The key is to render a turbo frame with the same turbo frame dom ID as the one on the form only on error. See full solution here:

Here it is: How to create modals with form handling through a Turbo frame | how to ruby

@tleish, I’m confused about the create.turbo_stream.erb using <%= render 'new' %>. Won’t this require a partial called _new.html.erb to work?

Does the turbo_stream helper in Turbo Rails work with a normal html.erb instead of a partial with different syntax?

Correct.

Yes. All the helper does is render a turbo-stream HTML tag.

<turbo-stream action="replace" target="modal">
  <template>
   (content of _new.html.erb partial)
  </template>
</turbo-stream>

So, the comment # create.turbo_stream.erb in the original example really means create.turbo_stream.erb HTML.

Thank you for clearing that up for me.