Form on index page with morph on success

Rails 7.1.3.2
turbo-rails 2.0.5 (i.e. Turbo 8)

I have an index page showing a list of records and I want it to have a form for adding new records which refreshes/morphs the page when records are created.

I have got it working but my implementation feels suboptimal, as if I have missed something. Any feedback would be much appreciated :smile:

My index page renders the records as normal:

<%= render @posts %>

I have an “Add post” link which shows the post form:

<%= link_to 'Add post', new_post_path %>

To make the form replace the “Add post” link, I wrap them both in the same turbo frame:

<!-- /posts/index.html.erb -->
<%= turbo_frame_tag :new_post do %>
  <%= link_to 'Add post', new_post_path %>
<% end %>

<!-- /posts/new.html.erb -->
<%= turbo_frame_tag :new_post do %>
  <%= form_with model: @post do |f| %>
    <%= f.text_field :title %>
    <%= f.submit %>
  <% end%>
<% end %>

Next I want an invalid form submission to render the form with validation errors inside the turbo frame:

# app/controllers/posts_controller.rb
def create
  @post = Post.new(post_params)
  if @post.save
    # TODO
  else
    render :new, status: :unprocessable_entity
  end
end

This works. Next I want a valid form submission to refresh the index page, showing the new post, removing the form, and adding the “Add post” button again.

I tried redirecting to the index page:

# app/controllers/posts_controller.rb
def create
  @post = Post.new(post_params)
  if @post.save
    redirect_to posts_path
  else
    render :new, status: :unprocessable_entity
  end
end

This of course simply updates the turbo frame: removing the form and adding the “Add post” button. It doesn’t refresh the list of posts.

Next I tried getting the form to target the whole page, so that a valid form submission would break out of the turbo frame when redirecting, allowing morphing to work:

<!-- /posts/new.html.erb -->
<%= turbo_frame_tag :new_post do %>
  <%= form_with model: @post, data: {turbo_frame: "_top"} do |f| %>
    <%= f.text_field :title %>
    <%= f.submit %>
  <% end%>
<% end %>

This works for valid form submissions – but breaks for invalid form submissions: the invalid form with validation errors is rendered on a new page. Somehow despite render :new, status: :unprocessable_entity, the response breaks out of the turbo frame. I didn’t think that was supposed to happen. Can anyone explain this to me please?

So I removed the data: {turbo_frame: "_top"} from the form and found a way to break out of the frame from the server:

# app/controllers/posts_controller.rb
def create
  @post = Post.new(post_params)
  if @post.save
    render turbo_stream: turbo_stream.action(:refresh, '_top')
  else
    render :new, status: :unprocessable_entity
  end
end

Now this does everything I want. But it feels wrong to have to resort to a turbo stream response to force the refresh. Can anyone show me a better way please?

Finally I should mention that my model is broadcasting refreshes, and the index page is listening to those broadcasts.

# app/models/post.rb
class Post < ApplicationRecord
  broadcasts
end

<!-- posts/index.html.erb -->
<%= turbo_stream_from @post %>

I can see this working when I update a post in the Rails console, and the page automatically refreshes.

However it doesn’t help my form situation. I think that’s because broadcasts caused by your own requests are discarded, as far as I understand?

Anyway, I would greatly appreciate any help!

1 Like

Sorry, after more testing, I didn’t manage to solve the issue you’re describing without using streams.

Here’s an interesting thread with alternative solutions: Redirect to new page on successful form submission, rerender otherwise · Issue #138 · hotwired/turbo · GitHub

This solution is the best I came up with, based on this advice:

  • Set up the form for whatever makes sense for the “happy” path, whether that’s inside a frame or not it doesn’t matter
  • On errors, replace the form using a in the response. This works regardless of whether or not the form is inside a frame.

The controller looks as follows:

  def create
    @post = Post.new(post_params)

    if @post.save
      redirect_to posts_path
    else
      respond_to do |format|
        format.html { render :new, status: :unprocessable_entity }
        format.turbo_stream { render turbo_stream: turbo_stream.replace(Post.new, partial: 'form', locals: { post: @post }) }
      end
    end
  end

And the form is:

<%= form_with model: post, data: {turbo_frame: '_top'} do |f| %>
  <div class="text-red-500">
    <%= post.errors.full_messages.join(', ') %>
  </div>

  <%= f.text_field :title %>
  <%= f.submit %>
<% end%>

To enable morph, I just added action: 'refresh' to the form:

<%= form_with model: post, data: {turbo_frame: '_top', action: 'refresh'} do |f| %>

So the difference is that I invert the condition in the controller (stream on failure), and target a specific element when streaming (turbo_stream.replace(Post.new, partial: 'form', locals: { post: @post }) }).