Skip to content

Commit

Permalink
"Break out" of a frame from the server
Browse files Browse the repository at this point in the history
Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

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.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][], and marking
it with [data-turbo-cache="false"][]

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
[data-turbo-cache="false"]: https://turbo.hotwired.dev/reference/attributes#data-attributes
  • Loading branch information
seanpdoyle committed May 14, 2023
1 parent ea00f37 commit 0cf26aa
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 4 deletions.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ end

group :test do
gem 'capybara'
gem 'capybara_accessible_selectors', github: 'citizensadvice/capybara_accessible_selectors', branch: 'main'
gem 'rexml'
gem 'selenium-webdriver'
gem 'webdrivers'
Expand Down
25 changes: 25 additions & 0 deletions app/controllers/turbo/streams/redirect.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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)

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})

case Rack::Utils.status_code(response_options.fetch(:status, :created))
when 300..399 then response_options[:status] = :created
end

render "turbo/streams/redirect", **response_options.with_defaults(
locals: {location: location, turbo_frame: turbo_frame, turbo_action: turbo_action},
location: location,
)
else
super
end
end
end
11 changes: 11 additions & 0 deletions app/views/turbo/streams/redirect.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<%= turbo_stream.append_all "head" do %>
<%= javascript_tag nonce: true, data: {turbo_cache: false} do %>
window.Turbo.visit("<%= escape_javascript response.location %>", {
frame: "<%= escape_javascript turbo_frame %>",
<% if turbo_action.present? %>
action: "<%= escape_javascript turbo_action %>",
<% end %>
})
document.currentScript.remove()
<% end %>
<% end %>
4 changes: 3 additions & 1 deletion lib/turbo/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ class Engine < Rails::Engine

initializer "turbo.helpers", before: :load_config_initializers do
ActiveSupport.on_load(:action_controller_base) do
include Turbo::Streams::TurboStreamsTagBuilder, Turbo::Frames::FrameRequest, Turbo::Native::Navigation
include Turbo::Streams::TurboStreamsTagBuilder, Turbo::Frames::FrameRequest, Turbo::Native::Navigation,
Turbo::Streams::Redirect

helper Turbo::Engine.helpers
end
end
Expand Down
18 changes: 15 additions & 3 deletions test/dummy/app/controllers/articles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,20 @@ def update
redirect_to articles_url
end

def create
def new
@article = Article.new
end

@article.update! article_params
def create
@article = Article.new article_params

redirect_to articles_url
if @article.save
flash.notice = "Created!"

redirect_to articles_url, redirect_params.to_h.to_options
else
render :new, status: :unprocessable_entity
end
end

def destroy
Expand All @@ -39,6 +47,10 @@ 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)
end

def assert_param_method!
raise unless params[:_method].present?
end
Expand Down
17 changes: 17 additions & 0 deletions test/dummy/app/views/articles/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<details>
<summary>New Article</summary>

<%= turbo_frame_tag @article do %>
<%= form_with model: @article do |form| %>
<%= form.label :body %>
<%= form.text_area :body, aria: { describedby: (dom_id(@article, :errors) if @article.errors[:body].any?) } %>
<% if @article.errors[:body].any? %>
<p id="<%= dom_id(@article, :errors) %>">
<%= @article.errors[:body].to_sentence %>
</p>
<% end %>

<%= form.button name: :turbo_frame, value: "_top"%>
<% end %>
<% end %>
</details>
143 changes: 143 additions & 0 deletions test/streams/redirect_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
require "test_helper"

class Turbo::Streams::RedirectTest < ActionDispatch::IntegrationTest
test "html requests respond with a redirect HTTP status" do
post articles_path, params: {
turbo_frame: "_top", status: 303,
article: {body: "A valid value"}
}

assert_response :see_other
assert_redirected_to articles_url
assert_equal "Created!", flash[:notice]
end

test "html redirects write to the flash" do
post articles_path, params: {
turbo_frame: "_top", flash: {alert: "Wrote to alert:"},
article: {body: "A valid value"}
}

assert_equal "Wrote to alert:", flash[:alert]
end

test "html redirects write to alert" do
post articles_path, params: {
turbo_frame: "_top", alert: "Wrote to alert:",
article: {body: "A valid value"}
}

assert_equal "Wrote to alert:", flash[:alert]
end

test "html redirects write to notice" do
post articles_path, params: {
turbo_frame: "_top", notice: "Wrote to notice:",
article: {body: "A valid value"}
}

assert_equal "Wrote to notice:", flash[:notice]
end

test "turbo_stream requests with the turbo_frame: option responds with a redirect Turbo Stream" do
post articles_path, as: :turbo_stream, params: {
turbo_frame: "_top",
article: {body: "A valid value"}
}

assert_turbo_stream action: :append, targets: "head", status: :created do
assert_select "script[data-turbo-cache=false]", count: 1 do |script|
assert_includes script.text, %(frame: "_top",)
assert_not_includes script.text, %(action:)
end
end
assert_equal articles_url, response.location
end

test "turbo_stream requests with the turbo_frame: and turbo_action: options responds with a redirect Turbo Stream" do
post articles_path, as: :turbo_stream, params: {
turbo_frame: "_top",
turbo_action: "replace",
article: {body: "A valid value"}
}

assert_turbo_stream action: :append, targets: "head", status: :created do
assert_select "script[data-turbo-cache=false]", count: 1 do |script|
assert_includes script.text, %(frame: "_top",)
assert_includes script.text, %(action: "replace",)
end
end
assert_equal articles_url, response.location
end

test "turbo_stream requests with the turbo_frame: option preserves status: values in the 2xx range" do
post articles_path, as: :turbo_stream, params: {
turbo_frame: "_top", status: 200,
article: { body: "A valid value" }
}

assert_response 200
end

test "turbo_stream requests with the turbo_frame: option replaces status: values in the 3xx range with 201 Created" do
post articles_path, as: :turbo_stream, params: {
turbo_frame: "_top", status: 303,
article: { body: "A valid value" }
}

assert_response 201
end

test "turbo_stream requests with the turbo_frame: option preserves status: values in the 4xx range" do
post articles_path, as: :turbo_stream, params: {
turbo_frame: "_top", status: 403,
article: { body: "A valid value" }
}

assert_response 403
end

test "turbo_stream requests with the turbo_frame: option preserves status: values in the 5xx range" do
post articles_path, as: :turbo_stream, params: {
turbo_frame: "_top", status: 500,
article: { body: "A valid value" }
}

assert_response 500
end

test "turbo_stream requests without the turbo_frame: option respond with a redirect HTTP status" do
post articles_path, as: :turbo_stream, params: {
article: { body: "A valid value" }
}

assert_redirected_to articles_url
end

test "turbo_stream redirects write to the flash" do
post articles_path, as: :turbo_stream, params: {
turbo_frame: "_top", flash: {alert: "Wrote to alert:"},
article: {body: "A valid value"}
}

assert_equal "Wrote to alert:", flash[:alert]
end

test "turbo_stream redirects write to alert" do
post articles_path, as: :turbo_stream, params: {
turbo_frame: "_top", alert: "Wrote to alert:",
article: {body: "A valid value"}
}

assert_equal "Wrote to alert:", flash[:alert]
end

test "turbo_stream redirects write to notice" do
post articles_path, as: :turbo_stream, params: {
turbo_frame: "_top", notice: "Wrote to notice:",
article: {body: "A valid value"}
}

assert_equal "Wrote to notice:", flash[:notice]
end
end
26 changes: 26 additions & 0 deletions test/system/frames_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
require "application_system_test_case"

class FramesTest < ApplicationSystemTestCase
test "can render an invalid submission within a frame" do
visit new_article_path
toggle_disclosure "New Article" do
click_on "Create Article"
end

within_disclosure "New Article" do
assert_field "Body", described_by: "can't be blank"
end
end

test "can redirect the entire page after a valid submission within a frame" do
visit new_article_path
toggle_disclosure "New Article" do
fill_in "Body", with: "An article's body"
click_on "Create Article"
end

assert_no_selector :disclosure, "New Article"
assert_no_field "Body"
assert_text "An article's body"
end
end

0 comments on commit 0cf26aa

Please sign in to comment.