How to redirect from a form that is inside a turbo frame?

I’m using turbo-rails library with Rails 7.0. I have a page that shows a list of users in a group and a form for adding a new user by email. The form is inside a turbo frame. When form is submitted there are two choices:

  1. If the email is incorrect it will show the form with a validation error “User not found” by rendering the form partial only and replacing the turbo-frame’s content.

  2. Otherwise, if the user is found, it will add the user to the group and re-render the entire page using the Rails redirect_to function.

#1 works, but #2 does not. Rails responds with the redirect but the page is not updated (i.e. I don’t see a new user added to the list). How do I make both #1 and #2 work? Is there a better approach to achieve what I want? Is there a parameter for redirect_to method to break out of the turbo frame?

My implementation

The form for adding a new user is inside a Rails partial _add_user_form.html.erb:

<turbo-frame id='add_user_form'>
    <form action="/group/123/add_user" method="post">
         <!-- some validation -->
        <input type="email" name="email" />
        <input type="submit" name="Add user" />
    </form>
</turbo-frame>

The view groups/users.html.erb renders the partial and the list of users in the group:

<h1>Users</h1>

<!-- renders the form from _add_user_form.html.erb partial  -->

<p>User 1</p>
<p>User 2</p>
<p>User 3</p>

In the Rails controller groups_controler.rb I have

def add_user
  user = User.find_by(email: params[:email])

  if user.nil?
    return render(
      partial: 'add_user_form',
      locals: {
        email: params[:email],
        user_not_found: true
      }
    )
  end

  group = Group.find(params[:id])
  group.users << user  # Add the user to the group
  redirect_to users_group_path(group)
end

Solution that sucks

Of course, I can get rid of the turbo frame, and re-render the entire page when form is submitted (with validation errors or not). But I hoped to use the turbo frame functionality, so I only render the form on validation error and keep the rest of the page unchanged.

There was a similar question posted here that i forgot the link to it. But, basically you needed to use a Stimulus Controller to listen for the events.

When the form has validation errors, i.e status: :unprocessable_entity it will do nothing. However, if the response status is success, it visits the ur.

// form_redirect_controller.js

import { Controller } from "@hotwired/stimulus"
import * as Turbo from "@hotwired/turbo"

export default class extends Controller {
  next(event) {
    if (event.detail.success) {
      const fetchResponse = event.detail.fetchResponse

      history.pushState(
        { turbo_frame_history: true },
        "",
        fetchResponse.response.url
      )

      Turbo.visit(fetchResponse.response.url)
    }
  }
}

Wiring it up with your html

<turbo-frame id='add_user_form'>
    <form action="/group/123/add_user" method="post" data-controller='form-redirect' data-action='turbo:submit-end->form-redirect#next'>
         <!-- some validation -->
        <input type="email" name="email" />
        <input type="submit" name="Add user" />
    </form>
</turbo-frame>
1 Like

Thanks @rockwell! This is an excellent workaround, since I can write this Stimulus controller once and reuse it in my forms. Having said that, to me this solution feels a bit … hacky. I wish there was a built-in way to conditionally break out of the turbo frame programmatically, from a Rails controller for example.

2 Likes

If it were me, I’d drop the turbo-frame and use turbo-stream with http instead. No additional javascript needed.

def add_user
  @user = User.find_by(email: params[:email])
  @group = Group.find(params[:id])

  if user.nil?
    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to users_group_path(group) }
    end
  else
    group.users << user  # Add the user to the group
    redirect_to users_group_path(group)
  end
end
<!-- add_user.turbo_stream.erb -->
<%= turbo_stream.update 'add_user_form' %>
    <form action="/group/123/add_user" method="post">
         <!-- some validation -->
        <input type="email" name="email" />
        <input type="submit" name="Add user" />
    </form>
<% end %>
1 Like

Your solution looks very nice! When you say

I’d drop the turbo-frame

do you mean to NOT have <turbo-frame id='add_user_form'> in the html at all? If so, how will turbo_stream.update 'add_user_form' work? Doesn’t it reference the ID of the turbo frame to update?

Sorry, I should’ve added an id the form. The turbo-stream will look for the DOM element with the ID, and the perform the action (replace, update, delete, etc).

Original Form:

    <form action="/group/123/add_user" method="post" id="add_user_form">
         <!-- some validation -->
        <input type="email" name="email" />
        <input type="submit" name="Add user" />
    </form>

add_user.turbo_stream.erb using replace

<%= turbo_stream.replace 'add_user_form' %>
    <form action="/group/123/add_user" method="post" id="add_user_form">
         <!-- some validation -->
        <input type="email" name="email" />
        <input type="submit" name="Add user" />
    </form>
<% end %>

or using update

<%= turbo_stream.update 'add_user_form' %>
  <input type="email" name="email" />
  <input type="submit" name="Add user" />
<% end %>

I recommend reading the documentation on how turbo-streams work.

1 Like

The turbo-stream will look for the DOM element with the ID

Oh so the first argument of turbo_stream.replace method can be an ID of ANY element, not just limited to a turbo-frame. I did not know that, thanks!

I recommend reading the documentation on how turbo-streams work.

I will do that!