Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Turbo-streams and turbo-frames with responders #230

Open
brendon opened this issue Oct 21, 2021 · 13 comments
Open

Turbo-streams and turbo-frames with responders #230

brendon opened this issue Oct 21, 2021 · 13 comments
Assignees

Comments

@brendon
Copy link

brendon commented Oct 21, 2021

I've come up against an interesting problem. This page has an interesting technique for creating a modal with turbo: https://www.viget.com/articles/fancy-form-modals-with-rails-turbo/

I'm trying to specifically reproduce this logic with responders:

def update
  if current_user.update(user_params)
    respond_to do |format|
      format.turbo_stream do
        render turbo_stream: [
          turbo_stream.update("flash", partial: "shared/flash", locals: { notice: "Profile updated" }),
          turbo_stream.update("user-about", partial: "users/about", locals: { user: current_user })
        ]
      end

      format.html do
        redirect_to current_user, notice: "Profile updated"
      end
    end
  else
    render :edit, status: :unprocessable_entity
  end
end

So to translate, the initial edit form is just a html view wrapped in a turbo-frame tag called 'modal'. Navigating to this updates the modal with the form. We then use stimulus to show the modal.

When submitting the form: if the model saves we want to render a turbo-stream response that updates various areas of the page. If it fails we want to render the original html edit page from before with the form field errors highlighted etc...

So far I can't seem to figure out how to make this work with responders.

Does anyone have any tips? :)

@brendon
Copy link
Author

brendon commented Oct 21, 2021

I managed to get there in the end. Is this the most efficient way to write it?

  def update
    respond_with online_newsletter,
      location: [:admin, online_newsletter] do |format|

      if online_newsletter.update(online_newsletter_params)
        format.turbo_stream {
          render turbo_stream: turbo_stream.update("header", partial: "header")
        }
      else
        format.html { render 'edit', status: :unprocessable_entity }
      end
    end
  end

@ClayShentrup
Copy link

ClayShentrup commented Oct 22, 2021

I'm using this guy's responder but I think it needs to be modified to play nice with the flash responder. He's only trying to deal with Devise issues whereas I'm using Responders generally (because why on Earth would people want to manually do this in all their controller actions?).

https://www.driftingruby.com/episodes/hotwire-turbo-replacing-rails-ujs

So far I'm getting some mileage out of this:

class ApplicationResponder < ActionController::Responder
  module TurboFlashResponder
    include(Responders::FlashResponder)

    def to_turbo_stream
      to_html
    end
  end
  include(TurboResponder)
  include(TurboFlashResponder)
  include(Responders::HttpCacheResponder)
end

# https://gorails.com/episodes/devise-hotwire-turbo
module TurboResponder
  def navigation_behavior(error)
    if get?
      raise error
    elsif has_errors? && default_action
      render(rendering_options.merge(formats: [:turbo_stream, :html], status: :unprocessable_entity))
    else
      redirect_to navigation_location
    end
  end

  def options
    super.merge(formats: [:turbo_stream, :html])
  end
end

@brendon
Copy link
Author

brendon commented Oct 25, 2021

Thanks @ClayShentrup, that looks like a great start. @carlosantoniodasilva, do you think there is appetite to make turbo responses a first class citizen in responders?

@schristm
Copy link

In case it's helpful for anyone else, this is what's working for me to make Turbo work with Responders.

Inside application_responder.rb:

def initialize(controller, resources, options = {})
    super

    if [:js, :turbo_stream].include?(format)
      options[:formats] ||= request.formats.map(&:symbol)
    end
end

alias :to_turbo_stream :to_html

@ClayShentrup
Copy link

@rafaelfranca what's the resolution here? is the plan not to offer a to_turbo_stream?

@brendon
Copy link
Author

brendon commented Sep 6, 2022

Not sure if this should have been closed. In my excursion into broadcast turbo streams I'm finding that I want to render :no_content for actions (like create) and then immediately broadcast the page changes to all users from the controller using something like:

article_html = render_to_string partial: 'online_newsletters/article', locals: { article: article }
Turbo::StreamsChannel.broadcast_before_to online_newsletter, target: article.subsequent, html: article_html

I'm not a fan of broadcasting from models. I also struggled with the built-in rendering functionality with Turbo::StreamsChannel as it hard-codes the use of ApplicationController and I use a couple of different FormBuilder's in different areas of my app. I know I can create my own stream channel and customise all this, but in the end render_to_string works well as it maintains the context of the request, and that's ok in my case as I want all users watching the editing interface to get the same updates and there's no per-user differences. That's a side-track anyway.

Responders treats the turbo_stream format as an api style format and wants to render a status: :created with a location by default. Ideally it should behave more like a hybrid to_html but if no template is found and if it's a post it just renders head :no_content. That's my opinion anyway. I think the main problem is that turbo-streams are so versatile that everyone will probably want to do something slightly different.

@carlosantoniodasilva
Copy link
Member

I am going to reopen this to remind me to look into turbo_stream again. Now that Responders and Devise are working fine with Turbo for the most common usage (via HTML navigation and such), I want to make sure it works with turbo streams (i.e. responding as turbo stream) as expected too, because it does seem like it's may not be.

@brendon
Copy link
Author

brendon commented Feb 20, 2023

Thanks @carlosantoniodasilva, let me know if you need to test anything in the wild :) For now in my respond_with I just have this:

if article.save
  format.turbo_stream { }
  Turbo::StreamsChannel.broadcast_before_to online_newsletter, target: article.subsequent,
    html: render_to_string(partial: 'online_newsletters/article')
else
  format.html { render 'new', status: :unprocessable_entity }
end

Including format.turbo_stream { } forces the empty response.

@n-rodriguez
Copy link

I'm using this guy's responder but I think it needs to be modified to play nice with the flash responder. He's only trying to deal with Devise issues whereas I'm using Responders generally (because why on Earth would people want to manually do this in all their controller actions?).

https://www.driftingruby.com/episodes/hotwire-turbo-replacing-rails-ujs

So far I'm getting some mileage out of this:

class ApplicationResponder < ActionController::Responder
  module TurboFlashResponder
    include(Responders::FlashResponder)

    def to_turbo_stream
      to_html
    end
  end
  include(TurboResponder)
  include(TurboFlashResponder)
  include(Responders::HttpCacheResponder)
end

# https://gorails.com/episodes/devise-hotwire-turbo
module TurboResponder
  def navigation_behavior(error)
    if get?
      raise error
    elsif has_errors? && default_action
      render(rendering_options.merge(formats: [:turbo_stream, :html], status: :unprocessable_entity))
    else
      redirect_to navigation_location
    end
  end

  def options
    super.merge(formats: [:turbo_stream, :html])
  end
end

Reduced to :

# frozen_string_literal: true

class ApplicationResponder < ActionController::Responder
  include Responders::FlashResponder
  include Responders::HttpCacheResponder

  self.error_status    = :unprocessable_entity
  self.redirect_status = :see_other

  alias :to_turbo_stream :to_html
end

@carlosantoniodasilva
Copy link
Member

@n-rodriguez that is great :)

Can you clarify what's your use for the alias :to_turbo_stream :to_html? I assume you are adding specific turbo_stream responses to devise related actions/controllers? (or maybe others, which is why you might require it?)

I haven't circled back yet on that from responder's perspective, but I'm guessing I might have to add something along those lines to responders itself so it can handle those appropriately.

@a-nickol
Copy link

a-nickol commented Sep 27, 2023

@carlosantoniodasilva, I think alias :to_turbo_stream :to_html is used to make use e.g. of FlashResponder::to_html method which has to be called to set the flash message appropriately.

But for me it was not enough. Flash messages have to be displayed immediately and not on the next request.

Thus i had to change the set_flash_now? method.

# config/initializers/responders_override.rb
# frozen_string_literal: true

module Responders
  module FlashResponder
    def set_flash_now?
        @flash_now == true || format == :js || format == :turbo_stream ||
            (default_action && (has_errors? ? @flash_now == :on_failure : @flash_now == :on_success))
    end
  end
end

@brendon
Copy link
Author

brendon commented Oct 15, 2023

Without alias :to_turbo_stream :to_html I found that responders wouldn't look for a .turbo_stream.erb file in the error scenario if I called something like:

format.turbo_stream { render 'errors', status: :unprocessable_entity }

@manufaktor
Copy link

I also needed alias :to_turbo_stream :to_html in my responder.

When a request with a turbo stream format comes in:

Started POST "/2023-10-16/workdays" for ::1 at 2023-10-30 18:37:40 +0100
18:37:40 web.1  | Processing by WorkdaysController#create as TURBO_STREAM

it would respond with this:

2023-10-30 18:37:33 +0100 Read: #<NoMethodError: undefined method `bytesize' for #<ActiveModel::Error attribute=base, type=invalid, options={}>
18:37:33 web.1  | 
18:37:33 web.1  |             next if part.nil? || (byte_size = part.bytesize).zero?
18:37:33 web.1  |                                                   ^^^^^^^^^>

Now with this setup it will correctly render and return the new.turbo_stream.erb file on the POST (if there are errors on the model):

class ApplicationController < ActionController::Base
  self.responder = ApplicationResponder
  respond_to :turbo_stream, :html
end

# and 

class ApplicationResponder < ActionController::Responder
  include Responders::FlashResponder

  # Configure default status codes for responding to errors and redirects.
  self.error_status = :unprocessable_entity
  self.redirect_status = :see_other

  alias_method :to_turbo_stream, :to_html
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

8 participants