RenderTurboStream
This gem has a second README Turbo::StreamsChannel. Starting point is here. And I recommend that you download my Quick-and-dirty test project, set it up, see all the tests succeed, and then read through this README.
As of v4.3, locals inside partials should work as expected. If you are working with turbo but without this gem, please read readme-locals to avoid tedious details.
Defining templates like (create|update).turbo_stream.haml
is a heavy mix of logic and view. This gem separates logic and view so that *.turbo_stream.*
templates are no longer necessary and the logic can stay on the ruby side.
For :replace
actions, responses must be wrapped inside a turbo_frame_tag
with a matching target_id
. The gem will regex the content and wrap it by a turbo_frame_tag
if necessary but not present. This way the matching target_id
is only defined in one place. This check only happens on the first call after restarting the application in production and on every call if precompile assets is set to true in configs.
There are many different ways to handle redirects since turbo. Some details are handled here.
Execute turbo_power commands such as adding a css class to an html element, can be sent directly from the controller.
Has a testing strategy.
An overview of how we design a rails-7 application with turbo is published on dev.to.
Chris
Installation
gem 'render_turbo_stream'
bundle install
Include the helpers
ApplicationController
include RenderTurboStream::ControllerHelpers
Create a Initializer
ActionView::Base.send :include, RenderTurboStream::ViewHelpers
spec/rails_helper.rb (for rspec)
RSpec.configure do |config|
#...
config.include RenderTurboStream::Test::Request::Helpers, type: :request
config.include RenderTurboStream::Test::Request::ChannelHelpers, type: :request
config.include RenderTurboStream::Test::System::Helpers, type: :system
end
Flash
Required Configurations for Flash Partial
config.x.render_turbo_stream.flash_partial = 'layouts/flash'
config.x.render_turbo_stream.flash_target_id = 'flash-box'
config.x.render_turbo_stream.flash_turbo_action = 'prepend'
# In case of redirection a flash cannot be sent by TurboStream
config.x.render_turbo_stream.allow_channel_to_me_for_turbo_stream_save = true
config.x.render_turbo_stream.store_last_saved_object = true # default: false
# saves the last successful saved record into session['last_saved_object'] as global_id
# this can be accessed by view_helper #last_saved_object
For the latter you have to setup channels, see below.
The corresponding partials for flashes could look like this
Translations
en:
activerecord:
render_turbo_stream_success:
create: '%<model_name>s successfully created'
update: '%<model_name>s successfully updated'
destroy: '%<model_name>s successfully destroyed'
render_turbo_stream_errors:
create: '%<model_name>s could not be created'
update: '%<model_name>s could not be updated'
destroy: '%<model_name>s could not be destroyed'
The last key (e.g. update
) marks the controller action.
Model name translations, see: Rails Docs.
Turbo power
To get redirection and many other options working, you need to follow the installation steps from turbo_power.
Turbo itself
A comprehensive tutorial on turbo and how to check that it is working properly can be found at hotrails.dev.
Turbo::StreamsChannel
The Rails team has integrated ActionCable
as Turbo::StreamsChannel
into Turbo Rails
. For installation along with this gem, see the README-channels.
Usage
render_turbo_stream
The basic idea for this gem is to have all the decisions/logic within the controller so that the views are just views. So in the end we have an array of turbo stream actions that are handled by the gem. In normal cases, *.turbo_stream.*
templates are not needed anymore.
render_turbo_stream(
[
{
partial: 'customers/customer_form' #=> The target id can be defined within the form and retrieved during rendering. The default :action is :replace
},
{
id: 'flash-wrapper',
partial: 'layouts/flashes',
action: :prepend,
locals: { success: true }
},
[
:push_state,
'/articles/my-special-path'
],
[
:add_css_class,
'#colored-element',
'red'
]
]
)
for just rendering a partial there is a helper:
stream_partial(
'form',
locals: {}
)
this enables:
- turbo-stream or custom javascript actions from turbo and all actions from turbo_power
- Testing helpers (request-testing), as mentioned later
- Helpers for handling the target id, as mentioned later
If element is an array instead of a hash, it does this under the hood: = turbo_stream.send args.first, *(args[1..-1])
. If element is a hash, it is interpreted as a partial or template to render.
turbo_stream_save
turbo_stream_save
is a special method for update
or create
functions with turbo_stream
. A controller action for update might look like this:
def create
turbo_stream_save(
@article.update(article_params),
if_success_redirect_to: articles_path,
partial: 'form'
)
end
- uses
render_turbo_stream
or render_to_me (if allowed by config) under the hood - Generates translated flash messages
- Handles redirects: Either classic redirect, which would never break out of a turbo-frame or the redirect from turbo_power which does a full-page-reload.
Note that if you want to redirect to show path, but the save action fails, article_path(@article)
would throw an error. For this, the arguments if_success_turbo_redirect_to
and if_success_redirect_to
accept procs that are evaluated only on success. So your line would look like this if_success_redirect_to: ->{article_path(@article)}
.
Config: allow_channel_to_me_for_turbo_stream_save
Turbo streams rely on the controller response, which can only be executed once for a request. Other than Turbo::StreamsChannel, they cannot run in parallel with the standard responses like redirect_to.
If this config is set to true, Turbo::StreamsChannel is installed and a current user is logged in:
If an if_success_redirect_to
argument is provided and the save action was successful, turbo_stream_save
would send the partials by channel.
turbo_target_tag
Assuming we want to build a form that works within a turbo-frame, including flash messages and redirects, we have to build all that inside a turbo-frame
, and, within that we need a target-id, that must not be a turbo-frame. For that, there is a helper:
= turbo_target_tag do
= simple_form ...
- Generates an element like
<turbo-target id="new-article-form">
- If no target-id is defined on
turbo_stream_save
orrender_turbo_stream
the renderer will grab inside the rendered content for a turbo-frame or turbo-target and get the target-id from there, so you can avoid having to define the same target-id on multiple places.
Target-ID
The target ID for turbo has to be unique for sure, and it has to be nice, because, at least during testing the developer has to deal with it. Since the default ID builder dom_id is too simple for this, there are some helpers. How it works is best shown by the request-test helper target_id
:
# target_id(virtual_view_path, object)
target_id('customers/_form', Customer.new) #=> 'new-customer-form'
target_id('form', Customer.new) #=> 'new-customer-form'
View-helper: Assuming we are inside customers/_my_form
:
#=> Assuming controller-name "customers" and variable @customer is present
target_id
#=> 'customer-1-my-form'
target_id(Customer.first) #=> 'customer-1-my-form'
target_id( [Customer.first, Article.last, 'hello'] ) #=> 'customer-1-article-7-hello-my-form'
target_id('hi-joe') #=> 'hi-joe'
target_id_css('hi-joe') #=> '#hi-joe'
#=> is available for system tests, example for capybara: «within target_id_css('articles/_form', @article) do ..
Why include the filename in a html-id? Because for turbo its likely to have multiple cases for the same object on the same page, for example: _form, _show, _password. These can all be the same customer.
Target-ID: Avoid the same definition in multiple places
Without this gem a turbo action would be wrapped within two frames, for example:
= turbo_stream.replace 'target-id' do
= render 'a partial'
and within a partial:
= turbo_frame_tag 'target-id' do
... content
The turbo_frame_tag
would stay inside a partial in most cases. The reason is: On the first load, the target-id must be delivered up front, so that if the tag needs to be replaced later, turbo knows which part to replace.
This means that the target id must be defined in several places: inside a partial and at the place where the turbo action is defined.
In order to avoid this kind of tedious coding, the gem has a kind of fallback built in: If the argument partial
is given, but the attribute target_id
is not, the gem will get the target_id from the partial. The process is:
- Render the partial with the provided locals
- Grabs into the partial by Nokogiri and looks for the first
turbo-frame
orturbo-target
element, get the id and uses this as target_id. - If all that not is found it raises a exception
- wraps the partial within the
turbo_stream.*
and sends this to the front.
Debugging
On :debug
level (rails-docs), if a save action fails, turbo_stream_save
and turbo_channel_save
are logging the output from Model.errors.full_messages
to the console.
Request Testing
To test if the whole system works together, including javascript actions, so that finally a part reaches the surface, there is Capybara system testing. But it is a good practice to break tests into smaller pieces. So, there are helpers for enabling the much faster request tests that are much easier to maintain.
If the request format is not turbo_stream
, which is the case on request specs, the method responds in a special html
that contains the medadata that is interesting for our tests and is parsed by included test helpers.
There is a helper for writing the test: In the debugger, within the test, check the output of all_turbo_responses
.
Response Status
The turbo_stream_save
method sets three statuses to the response: 200
if save_action
is true, otherwise 422
and 302
for redirect. If one of the declared partials does not exist or breaks, the server will respond with exception anyway.
Redirection
If you defined the attribute if_success_turbo_redirect_to
which uses the redirect_to function from turbo_power
if installed:
it 'update success' do
patch article_path(article, params: valid_params)
assert_turbo_redirect_to('/articles')
end
Test turbo stream response
expect(turbo_targets.length).to eq(2)
# Check the total number of targeted html-ids, in most cases it will be one form and one flash.
assert_stream_action('turbo_frame_set_src'){ |args| args == ["cars-box", "/cars"] }
# if the turbo_power gem is installed
assert_stream_response('form'){|e|e.css('.field_with_errors').inner_html.include?('title')}
# make sure that there is one response to the target '#form' and it checks the rendered content
# if cero or more responses are expected, add a attribute like 'count: 0'
Possible matchers for checking html content can be found at Nokogiri.
The assert_stream_response
checks by default that the same target is not affected multiple times by the :replace
action within the same response. This is defined in the underlying RenderTurboStream::Test::Request::Libs.select_responses
method, which is also used for the assert_channel_to_*
methods.
More Configs
On test helpers, the marker for a turbo-stream target is in most cases the id of the target element. This is true for the standard turbo-stream functions. On turbo_power
it is the same in most cases. RenderTurboStream::Test::Request::Libs.first_arg_is_html_id()
checks for which methods this is true. You can override this:
config.x.render_turbo_stream.first_argument_is_html_id = %[replace append prepend turbo_frame_set_src]
This setting is relevant for testing helpers.
Contributing
Contributors welcome.
License
The gem is available as open source under the terms of the MIT License.
Thanks
Thanks to Marco Roth for turbo_power and thanks to DHH and the rails team for taking a bold new step by creating turbo.