Notes for updating the Hotrails.dev Turbo Rails tutorial to Ruby 3.4 / Rails 8.1
- I just did
rails new quote-editor --css=sass. I figure SQLite and javascript import maps are just fine and closer to the defaults. We'll see how this pans out :) - I'm using
gem "turbo-rails", "~> 2.0"
- I adjusted the controller to use
params.expect(quote: [:name]) - It was helpful for me to generate a scaffold controller and compare to the controller in the tutorial, just because I am used to scaffold controllers, to see what was different/simplified.
- I'm using
gem "simple_form", "~> 5.3.0" - Headless chrome was already the default so I didn't need to adjust anything
- The top-level CSS file is named
application.scssnotapplication.sass.scss @includeis deprecated in SASS. Everything worked, but I decided to fix the deprecation warnings. Here's what was needed:- Stop including
mixins/mediaat the top level. - Change all other top level
@includeto@use - In files that used the media mixin, put
@use "mixins/media";at the top of the file - change references to
mediatomedia.media
- Stop including
- No changes needed
- "the creation and the edition" is a typo, "edition" here is not a word that means editing. I would reformat this sentence to use "creating and updating" to match the language in the tests. There are several other instances of the word "edition" on this page that are incorrect as well.
- Add refresh to the list of stream types
NOTE: I experimented to see if I could use the new turbo morphing from turbo 2 as another route to solve the problems presented in this chapter. But I couldn't get it to play nicely with frames. I'm planning to play around with it some more. But it would be great for someone who groks it really well to present another version of this chapter that uses and discusses the trade-offs.
It does seems like there are some edge cases of morphing that are still getting worked out, or at least some "sharp knife" edges that would be helpful to explain. See hotwired/turbo#1083 and https://thoughtbot.com/blog/turbo-morphing-woes.
Note from the future: I found a good use case for exploring morphing and the refresh stream type in Chapter 8, and I feel like I understand it much better. Keep reading!
- Instead of setting up redis, I just used the web-console gem so that my
events were happening in the same process that the cable was connected to. To
do this I put
consoleon a new line inQuotesController#index, and then used the console window at the bottom of the browser on the /quotes page.
-
I opted to use the new
bin/rails generate authenticationfeature from the Security Guide instead of Devise. It was mostly a drop in replacement with just some minor tweaks to the tutorial code to account for different naming conventions. There was a bit of extra work to get the sign in page styled, I wrapped it in the container div and switched to simple_form, which was a little awkward because there isn't really a rails model to build the form on, but I ended up with:<main class="container"> <%= simple_form_for "session", url: session_path do |f| %> <%= f.input :email_address, input_html: { autofocus: true } %> <%= f.input :password %> <%= f.submit 'Sign in', class: "btn btn--secondary" %> <% end %> </main>
This also required a small tweak to the generated controller to account for the scoped params:
if user = User.authenticate_by(params.expect(session: [:email_address, :password]))
In system tests, I first needed to make an auth helper. In
test_helpers/session_test_helper.rbI added the following:module SessionApplicationTestHelper def sign_in_as(user) Current.session = user.sessions.create! ActionDispatch::Request.new(Rails.application.env_config).tap do |request| request.cookie_jar.signed[:session_id] = { value: Current.session.id, httponly: true, same_site: :lax } end end def sign_out Current.session&.destroy! ActionDispatch::Request.new(Rails.application.env_config).tap do |request| request.cookie_jar.delete(:session_id) end end end
Then in the system test, I needed to include the new module inside the class:
class QuotesTest < ApplicationSystemTestCase include SessionApplicationTestHelper # ... end
And then you can use the helper
sign_in_as users(:accountant). This did create some intermittent issues with the generated auth tests when both controller and system tests are run together (bin/rails test:all). I didn't dive deeply into why, but I might be contaminating the session cookie or something.
Turbo.session.drive = falsedidn't work, I had to adddata-turbo="false"to the body tag.- Stimulus controllers are auto-loaded now.
- Another interesting take on getting flash to work with turbo
- There is some discussion around using layouts with turbo streams as another alternative to accomplish this, but it's not well documented. After some chats with Claude and reading the source, I finally understood that, due to a quirk in how turbo frame's are recognized, turbo stream responses from inside a frame look for a layout named turbo_rails/frame while turbo stream responses from the top level look for the normally named application layout. So if you create both application.turbo_stream.erb and turbo_rails/frame.turbo_stream.erb, you can use the layout idea to inject flash messages without as much duplication.
-
I was a little frustrated that the chapter round-up did not point out the trade-off that the empty state does not disappear when the first new quote form appears, it only disappears after that first new quote is created. And it doesn't mention that in the version hosted with the tutorial, you actually use the first method, not the :only-child method. I do recognize that it's a useful pattern to teach and potentially more helpful for notifications than for our quotes.
-
Because of ^, I went down a rabbit hole of exploring other alternatives and their trade-offs. The first was to use the new morphing feature by adding
<meta name="turbo-refresh-method" content="morph"> <meta name="turbo-refresh-scroll" content="preserve">
to
application.html.erband replacing thebroadcasts_toline in the Quote model withbroadcasts_refreshes_to ->(quote) { [quote.company, "quotes"] }. This worked (and as a bonus, helped click for me that maybe refreshes are more helpful as a broadcast than as a direct turbo stream response). The trade-off here is that if the second user has an edit form open, it gets reset to the show partial when the refresh morph happens. But to me this is a better trade-off than what is presented in the chapter -
Then I decided to see what "While we could play with the callbacks and override the default options of the broadcasts_to method" actually entailed. What I ended up with was:
after_create_commit ->{ broadcast_render_later_to company, "quotes", template: "quotes/create" }
after_update_commit ->{ broadcast_render_later_to company, "quotes", template: "quotes/update" }
after_destroy_commit ->{ broadcast_render_to company, "quotes", template: "quotes/destroy" }This didn't quite work because the templates use @quote (instance variable)
and broadcast_render_to sends the local variable quote, so I had to
update each template to reference @quote || quote. The trade-off here is
things aren't quite as elegant and simple, but the advantage that both the
local and streaming user get the exact same updates (except for flash
updates, but that's probably desirable). In a larger codebase I'm not sure
whether tying our controller-rendered turbo stream templates with our
model-rendered cable stream updates would be an advantage or a liability.
-
"prevent a quote from having multiple times the same date" should be "prevent a quote from having the same date multiple times".
-
"uniqueness constraint for the couple quote_id and date" using "couple" here is technically fine, but not very commonly heard. Usually you'd say "tuple" or just "columns".
-
Since you already have an index on
[:date, :quote_id], you don't also need an index on:date. All multi-column indexes can be used with a subset (prefix only) of the columns. -
I had to apply https://8yd.no/article/date-input-height-in-safari to get the form looking right in Safari.
-
Can't add
only: [... :destroy]and load the page before the destroy method exists, this is now raising an error as of a recent rails version. -
Typo "turbo_fram_tag"
-
The chapter ends without talking about live updates. I went for the simple broadcast refreshes approach. I added the following to the line_item_dates model:
broadcasts_refreshes_to ->(lid) { [lid.quote, "line_item_dates"] }
and this to the quotes show view:
<%= turbo_stream_from @quote, "line_item_dates" %>
-
I also notice we don't add empty states. This seems like a perfect "exercise for the reader" since it's pretty much the same as the quotes empty state, except you need to pass the @quote through to scope to dates. It was a fun little exercise.
- "there is a small glitch in our application right." typo should be "right now." at the end.
- TODO for practice: add stream syncing.
- No notes! Nice tight little chapter to round things out.
I think this tutorial is incredibly valuable at helping someone gain a mental model of how turbo frames and streams work and work together. After going through it all, most of it is still very relevant to the most current rails version. I hope these contributions can help others to continue to get value out of it while being able to try it in the latest version.