Skip to content

Commit

Permalink
Introduce turbo-stream[action=visit] and `#break_out_of_turbo_frame…
Browse files Browse the repository at this point in the history
…_and_redirect_to`

Introduces the `Turbo::Stream::Redirect` concern to introduce the
`#break_out_of_turbo_frame_and_redirect_to` and
`#turbo_stream_redirect_to` methods. The
`#break_out_of_turbo_frame_and_redirect_to` draws inspiration from the
methods provided by the [Turbo::Native::Navigation][] concern.

When handling requests made from outside a `<turbo-frame>` elements
(without the `Turbo-Frame` HTTP header), respond with a typical HTML
redirect response.

When handling request made from inside a `<turbo-frame>` element (with
the `Turbo-Frame` HTTP header), render a `<turbo-stream action="visit">`
element with the redirect's pathname or URL encoded into the
`[location]` attribute.

When Turbo Drive receives the response, it will call `Turbo.visit()` with
the value read from the `[location]` attribute.

```ruby
class ArticlesController < ApplicationController
  def show
    @Article = Article.find(params[:id])
  end

  def create
    @Article = Article.new(article_params)

    if @article.save
      break_out_of_turbo_frame_and_redirect_to @Article
    else
      render :new, status: :unprocessable_entity
    end
  end
end
```

Response options (like `:notice`, `:alert`, `:status`, etc.) are
forwarded to the underlying redirection mechanism (`#redirect_to` for
`Mime[:html]` requests and `#turbo_stream_redirect_to` for
`Mime[:turbo_stream]` requests).

This enables server-side actions to navigate the entire page with a,
regardless of the provenance of the request.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

To support this behavior, this commit introduces the first
`@hotwire/turbo-rails`-specific Turbo `StreamAction`: the
`turbo-stream[action="visit"]`.

[Turbo::Native::Navigation]: https://github.com/hotwired/turbo-rails/blob/v2.0.11/app/controllers/turbo/native/navigation.rb
  • Loading branch information
seanpdoyle committed Nov 23, 2024
1 parent aee95a8 commit 05f5928
Show file tree
Hide file tree
Showing 14 changed files with 199 additions and 156 deletions.
4 changes: 4 additions & 0 deletions app/assets/javascripts/turbo.js
Original file line number Diff line number Diff line change
Expand Up @@ -5437,6 +5437,10 @@ function isBodyInit(body) {
return body instanceof FormData || body instanceof URLSearchParams;
}

StreamActions.visit = function() {
visit(this.getAttribute("location"));
};

window.Turbo = Turbo$1;

addEventListener("turbo:before-fetch-request", encodeMethodIntoRequestBody);
Expand Down
2 changes: 1 addition & 1 deletion app/assets/javascripts/turbo.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/javascripts/turbo.min.js.map

Large diffs are not rendered by default.

83 changes: 68 additions & 15 deletions app/controllers/turbo/streams/redirect.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,78 @@
module Turbo::Streams::Redirect
extend ActiveSupport::Concern

def redirect_to(options = {}, response_options = {})
turbo_frame = response_options.delete(:turbo_frame)
turbo_action = response_options.delete(:turbo_action)
location = url_for(options)
private

if request.format.turbo_stream? && turbo_frame.present?
alert, notice, flash_override = response_options.values_at(:alert, :notice, :flash)
flash.merge!(flash_override || {alert: alert, notice: notice})
# Instruct Turbo Drive to redirect the page, regardless of whether or not the
# request was made from within a `<turbo-frame>` element.
#
# When handling requests made from outside a `<turbo-frame>` elements (without
# the `Turbo-Frame` HTTP header), respond with a typical HTML redirect
# response.
#
# When handling request made from inside a `<turbo-frame>` element (with the
# `Turbo-Frame` HTTP header), render a `<turbo-stream action="visit">` element
# with the redirect's pathname or URL encoded into the `[location]` attribute.
#
# When Turbo Drive receives the response, it will call `Turbo.visit()` with
# the value read from the `[location]` attribute.
#
# class ArticlesController < ApplicationController
# def show
# @article = Article.find(params[:id])
# end
#
# def create
# @article = Article.new(article_params)
#
# if @article.save
# break_out_of_turbo_frame_and_redirect_to @article
# else
# render :new, status: :unprocessable_entity
# end
# end
# end
#
# Response options (like `:notice`, `:alert`, `:status`, etc.) are forwarded
# to the underlying redirection mechanism (`#redirect_to` for `Mime[:html]`
# requests and `#turbo_stream_redirect_to` for `Mime[:turbo_stream]`
# requests).
def break_out_of_turbo_frame_and_redirect_to(options = {}, response_options_and_flash = {}) # :doc:
respond_to do |format|
format.html { redirect_to(options, response_options_and_flash) }

case Rack::Utils.status_code(response_options.fetch(:status, :created))
when 300..399 then response_options[:status] = :created
if turbo_frame_request?
format.turbo_stream { turbo_stream_redirect_to(options, response_options_and_flash) }
end
end
end

render "turbo/streams/redirect", **response_options.with_defaults(
locals: {location: location, turbo_frame: turbo_frame, turbo_action: turbo_action},
location: location,
)
else
super
# Respond with a `<turbo-stream action="visit">` with the `[location]`
# attribute set to the pathname or URL. Preserves `:alert`, `:notice`, and
# `:flash` options.
#
# When passed a `:status` HTTP status code option between `300` and `399`,
# replace it with a `201 Created` status and set the `Location` HTTP header to
# the pathname or URL.
def turbo_stream_redirect_to(options = {}, response_options_and_flash = {}) # :doc:
location = url_for(options)

self.class._flash_types.each do |flash_type|
if (type = response_options_and_flash.delete(flash_type))
flash[flash_type] = type
end
end

if (other_flashes = response_options_and_flash.delete(:flash))
flash.update(other_flashes)
end

case Rack::Utils.status_code(response_options_and_flash.fetch(:status, :created))
when 201, 300..399
response_options_and_flash[:status] = :created
response_options_and_flash[:location] = location
end

render turbo_stream: turbo_stream.visit(location), **response_options_and_flash
end
end
10 changes: 9 additions & 1 deletion app/helpers/turbo/streams/action_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def turbo_stream_action_tag(action, attributes = {})
target = attributes.delete(:target)
targets = attributes.delete(:targets)
template = attributes.delete(:template)
template = action.to_sym.in?(%i[ remove refresh ]) ? "" : tag.template(template.to_s.html_safe)
template = action.to_sym.in?(%i[ remove refresh visit ]) ? "" : tag.template(template.to_s.html_safe)

if target = convert_to_turbo_stream_dom_id(target)
tag.turbo_stream(template, **attributes, action: action, target: target)
Expand All @@ -47,6 +47,14 @@ def turbo_stream_refresh_tag(request_id: Turbo.current_request_id, **attributes)
turbo_stream_action_tag(:refresh, attributes.with_defaults({ "request-id": request_id }.compact))
end

# Creates a `turbo-stream` tag with an `action="visit"` attribute. Example:
#
# turbo_stream_visit_tag "/"
# # => <turbo-stream action="visit" location="/"></turbo-stream>
def turbo_stream_visit_tag(location)
turbo_stream_action_tag(:visit, location: location)
end

private
def convert_to_turbo_stream_dom_id(target, include_selector: false)
target_array = Array.wrap(target)
Expand Down
8 changes: 8 additions & 0 deletions app/javascript/turbo/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ export { cable }

import { encodeMethodIntoRequestBody } from "./fetch_requests"

/**
* Call `Turbo.visit(location)`, where `location` is read from the
* `<turbo-stream>` element's `[location]` attribute.
*/
Turbo.StreamActions.visit = function() {
Turbo.visit(this.getAttribute("location"))
}

window.Turbo = Turbo

addEventListener("turbo:before-fetch-request", encodeMethodIntoRequestBody)
9 changes: 9 additions & 0 deletions app/models/turbo/streams/tag_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,15 @@ def refresh(**options)
turbo_stream_refresh_tag(**options)
end

# Creates a `turbo-stream` tag with an `[action="visit"]` attribute and an
# `[location]` attribute
#
# turbo_stream.visit("/")
# # => <turbo-stream action="visit" location="/"></turbo-stream>
def visit(location)
turbo_stream_visit_tag(location)
end

# Send an action of the type <tt>name</tt> to <tt>target</tt>. Options described in the concrete methods.
def action(name, target, content = nil, method: nil, allow_inferred_rendering: true, **rendering, &block)
template = render_template(target, content, allow_inferred_rendering: allow_inferred_rendering, **rendering, &block)
Expand Down
11 changes: 0 additions & 11 deletions app/views/turbo/streams/redirect.turbo_stream.erb

This file was deleted.

3 changes: 2 additions & 1 deletion lib/turbo/test_assertions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ module TestAssertions
# assert_select "template p", text: "Hello!"
# end
#
def assert_turbo_stream(action:, target: nil, targets: nil, count: 1, &block)
def assert_turbo_stream(action:, target: nil, targets: nil, count: 1, **attributes, &block)
selector = %(turbo-stream[action="#{action}"])
selector << %([target="#{target.respond_to?(:to_key) ? dom_id(target) : target}"]) if target
selector << %([targets="#{targets}"]) if targets
attributes.each { |name, value| selector << %([#{name}="#{value}"]) }
assert_select selector, count: count, &block
end

Expand Down
6 changes: 3 additions & 3 deletions test/dummy/app/controllers/articles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def create
if @article.save
flash.notice = "Created!"

redirect_to articles_url, redirect_params.to_h.to_options
break_out_of_turbo_frame_and_redirect_to articles_url, redirect_to_options_params.to_h.to_options
else
render :new, status: :unprocessable_entity
end
Expand All @@ -52,8 +52,8 @@ def article_params
params.require(:article).permit(:body)
end

def redirect_params
params.permit(:alert, :notice, :status, :turbo_action, :turbo_frame, flash: {}).with_defaults(status: :found)
def redirect_to_options_params
params.fetch(:redirect_to_options, {}).permit!
end

def assert_param_method!
Expand Down
4 changes: 3 additions & 1 deletion test/dummy/app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
</head>

<body class="<%= "turbo-native" if turbo_native_app? %>">
<%= flash[:notice] %>
<% flash.each do |message| %>
<p role="alert"><%= message %></p>
<% end %>
<%= yield %>
</body>
</html>
Loading

0 comments on commit 05f5928

Please sign in to comment.