hotwired / turbo

The speed of a single-page web application without having to write any JavaScript
https://turbo.hotwired.dev
MIT License
6.65k stars 421 forks source link

Turbo frames conditional rendering #378

Closed james-reading closed 2 years ago

james-reading commented 3 years ago

I want to stick a form in a modal, using a link that will request the new action to render the form HTML which then gets injected into the modal and opened.

This is a pretty common pattern that I've implemented many times before with Rails UJS.

I am now trying to do the same thing with Turbo.

<%# index.html.erb %>

<%= link_to 'New Post', new_post_path, data: { 'turbo-frame': 'modal-body' } %>

<div class="modal" data-controller="modal">
  <%= turbo_frame_tag "modal-body", 'data-action': 'turbo:frame-render->modal#show' %>
</div>
<%# new.html.erb %>

<h1>New Post</h1>

<%= turbo_frame_tag "modal-body" do %>
  <%= form_with(model: @post) do |form| %>
    <div class="field">
      <%= form.label :title %>
      <%= form.text_field :title %>
    </div>

    <div class="actions">
      <%= form.submit %>
    </div>
  <% end %>
<% end %>

<%= link_to 'Show', @post %> |
<%= link_to 'Back', posts_path %>

So I have a pre-rendered empty modal with turbo_frame_tag in the modal body. When I click the link, the controller renders the new template which has the form wrapped in a matching turbo_frame_tag. The form gets injected into the modal and I have a stimulus controller which opens the modal on turbo:frame-render.

This works pretty well, but there seems to be a pretty big limitation - The form HTML has to be identical for both the modal, and for when I visit the new URL directly. For example, if I need a close button when in a modal, but a cancel link when visiting the page directly, this doesn't seem possible to handle server-side.

With Rails UJS, My new controller action could respond to format.js for the modal and format.html when visiting directly, making it possible to render different HTML. As new action is a GET, this rules out format.turbo_stream which is for non-GET requests only.

acetinick commented 3 years ago

Hi @james-reading , this might be a duplicate of this one https://github.com/hotwired/turbo/issues/257 ?

james-reading commented 3 years ago

Hi @james-reading , this might be a duplicate of this one #257 ?

@acetinick Hmm I think it might be slightly different. I am not talking about conditionally setting the response turbo frame. I mean conditionally rendering different HTML based on the request turbo frame.

What I'd like to do is something like

class PostsController < ApplicationController
  def new
    @post = Post.new

    case request.headers['Turbo-Frame']
    when 'modal-body'
      render partial: 'posts/modal_body'
    when nil
      render :new
    end
  end
end

This works pretty well, but I also need to have the same case statement in the create action when the save returns false, except with status: :unprocessable_entity for it to work properly.

Something like this seems like it would be a double win because not only does it mean I can render different HTML based on the requesting frame, but the server also doesn't have to spend time rendering HTML that ultimately won't be rendered in the browser.

tleish commented 3 years ago

Are you suggesting adding a turbo-frame mime type?:

respond_to do |format|
  format.turbo_frame
  format.html
end

In your scenario I prefer to use the same HTML (perhaps with CSS), but show/hide elements

<% if turbo_frame_request? %>
  <a href="#" title="Close">X</a>  
<% end %>

I'm not as worried about some of the extra HTML it might include.

If you want to get to that level, you can still use:

class PostsController < ApplicationController
  def new
    @post = Post.new
    if turbo_frame_request?
      render partial: 'posts/modal_body'
    else
      render :new
    end
  end

  def create
    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to ... }
    end
  end
end
tleish commented 3 years ago

Since turbo_frame response is really an HTML mime-type, adding a custom mime type just for turbo-frames wouldn't be a good solution (although, I guess you could make that same argument for turbo-streams being html). You could implement an html variant (search for variants in ActionController::MimeResponds):

Example:

class ApplicationController < ActionController::Base
  private

  def turbo_frame_request_variant
    request.variant = :turbo_frame if turbo_frame_request?
  end
end

class PostsController < ApplicationController
  before_action :turbo_frame_request_variant, only: %i[new create]

  def new
    @post = Post.new
    respond_to do |format|
      format.html do |variant|
        variant.turbo_frame # renders app/views/posts/new.html+turbo_frame.erb
        variant.none # renders app/views/posts/new.html.erb
      end
    end
  end

  def create
    respond_to do |format|
      format.turbo_stream do |variant|
        variant.turbo_frame { redirect_to ... }
        variant.none { redirect_to ... }
      end
      format.html { redirect_to ... }
    end
  end
end
james-reading commented 3 years ago

Thanks @tleish, variants work brilliants actually. It works without the boilerplate format.html do |variant| in the controller too, as long as the variant is set in the before action it renders the variant template if it exists.

Is there any harm to putting turbo_frame_request_variant in ApplicationController before all request?

tleish commented 3 years ago

I suggested this as a default in the turbo-rails gem

see: https://github.com/hotwired/turbo-rails/issues/229