Rails Wizards Pt. 6
Jon Sully
6 Minutes
Routing and Controllers
-
Part of the
‘Rails Wizards’
Series:
- • Apr 2021: Rails Wizards Pt. 1
- • Apr 2021: Rails Wizards Pt. 2
- • Apr 2021: Rails Wizards Pt. 3
- • Apr 2021: Rails Wizards Pt. 4
- • Apr 2021: Rails Wizards Pt. 5
- • Apr 2021: Rails Wizards Pt. 6 (This page)
- • Apr 2021: Rails Wizards Pt. 7
- • Apr 2021: Rails Wizards Pt. 8
- • Apr 2021: Rails Wizards Pt. 9
Adding Hotwire & Turbo
Alright, we’ve done quite a bit at this point. We should actually have fully-setup and working wizards without any issue. Adding Hotwire/Turbo is exclusively an enhancement and not something that should be required for our wizards to function properly.
One of the reasons I started this series / exploration into wizards (aside from needing them at Agent Pronto) was to find out how Hotwire’s Turbo Frames could work with multi-step forms and give users that SPA-like experience without actually writing any Javascript or worrying about the complex nature of having back-end validations with a front-end wizard.
Well, I’m quite happy to say that they work great and really do give a super-smooth experience to users! It’s wild to see it in action and know that it’s 100% server-side rendered code (just like 2004!) powering a very modern feeling experience. As I’ve mentioned previously in this series, feel free to check out Part 9 to play around with that experience for yourself. Part 9 includes a demo site that has multiple turbo-framed wizards running live on it 😀.
The Code
For starters, the good news is that adding Turbo Frames is just about the same for any of the combinations of persistence + routing strategies discussed in the prior few parts. Since Turbo Frames is almost entirely a view concern and there was little if no difference between the views in the various persistence + routing strategies, this discussion should be fairly uniform across those strategies.
1. Install Turbo
In the case of a Rails app, Turbo comes pre-packaged in the gem turbo-rails
and includes a number of Rails-specific bindings to enabled Turbo almost effortlessly. Thanks, Basecamp! (Or really, thanks HEY!) Just install the gem and bundle
, then, per the installation instructions, run rails turbo:install
. If it’s a fresh app, that should work straight-away. If it’s an older app, be sure to consult the installation manual for what may change with your app. If you’ve previously used Turbolinks in your app (now redesigned and renamed “Turbo Drive” as a part of Hotwire), make sure you search your entire project for uses of “turbolinks_track
” and ensure they’re renamed to "turbo_track"
.
2. Frame the wizard views
We’re going to continue with the House
example here, so lets recall that we have three wizard views:
# ~/app/views/steps_controllers/house_steps/* 1 address_info.html.erb 2 house_info.html.erb 3 house_stats.html.erb
and each of these views contains a form_with
just like:
<%# app/views/steps_controllers/house_steps/address_info.html.erb %> <%= form_with model: @house, url: wizard_path do |f| %> <!-- ... error-displaying code ... --> <!-- ... form fields ... --> <% end %>
(*If you’re using cache-persistence, make sure you don’t remove the ruby>method: :patch
in your form_with
)
Adding Turbo Frames to the wizard so that only the wizard form gets replaced in the DOM when the user hits ‘next’ (and it happens very quickly), is as simple as wrapping the form_with
in a Turbo Frame tag!
<%# app/views/steps_controllers/house_steps/address_info.html.erb %> <%= turbo_frame_tag dom_id(@house) do %> <%= form_with model: @house, url: wizard_path do |f| %> <!-- ... error-displaying code ... --> <!-- ... form fields ... --> <% end %> <% end %>
Well, almost 😅. There are a couple of other small changes we’ll need to make. Simply adding a turbo_frame_tag
around the form_with
in all three views is 95% of the work — Turbo Frames have that ‘it just works’ Rails Magic✨… but they are still in beta and have a couple little kinks.
3. ‘_Top’ the last frame
For starters, we need to add a special data
attribute to whichever frame / view is the last in the wizard workflow. This is to inform Turbo that the response won’t contain a Turbo Form and should instead be a ‘full’ / top-level ‘Turbo Drive’ navigation. That makes sense — we’re not continuing the wizard with another form; upon submission, we’ve completed the wizard! We can add this data
attribute to the turbo_frame_tag
or the form_with
, but since the form is what’s actually initiating the request, I like to put it there. Feels a little more intentional.
<%# app/views/steps_controllers/house_steps/house_steps.html.erb # THE LAST WIZARD STEP %> <%= turbo_frame_tag dom_id(@house) do %> <%= form_with model: @house, url: wizard_path do |f| %> <%= form_with model: @house, url: wizard_path, data: { turbo_frame: :_top } do |f| %> <!-- ... error-displaying code ... --> <!-- ... form fields ... --> <% end %> <% end %>
(*If you’re using cache-persistence, make sure you don’t remove the ruby>method: :patch
in your form_with
)
4. Status Change
The last step to getting everything working just right is an update to our step controller — we need to make sure our steps controller explicitly sends back a status 422 Unprocessable Entity
. The Rails team recently changed the default behavior of Rails to match this paradigm:Â validation errors now send back the same form with a status 422 instead of a 200 as done previously. As a tangent, make sure that gets correctly back-ported to any existing controllers in your app(s). I scaffolded my app fresh so my top-level controllers already have that logic built it:
# app/controllers/houses_controller.rb class HousesController < ApplicationController # ... # PATCH/PUT /houses/1 or /houses/1.json def update respond_to do |format| if @house.update(house_params) format.html { redirect_to @house, notice: "House was successfully updated." } format.json { render :show, status: :ok, location: @house } else format.html { render :edit, status: :unprocessable_entity } # Thanks Rails team! format.json { render json: @house.errors, status: :unprocessable_entity } end end end # ... end
But our steps controller does not automatically have the ruby>status: :unprocessable_entity
logic in place and Wicked handles the rendering of the view itself, not us. Luckily Wicked exposes some controls for us and the code change is pretty straightforward.
If you’re using cache-persistence you just need to change the empty call to render_wizard
like so:
# app/controllers/steps_controllers/house_steps_controller.rb # cache-persistence + key-in-URL approach module StepsControllers class HouseStepsController < ApplicationController # ... def update house_attrs = Rails.cache.read(params[:build_house_id]).merge house_params @house = House.new house_attrs if @house.valid? Rails.cache.write params[:build_house_id], house_attrs redirect_to_next next_step else render_wizard render_wizard nil, status: :unprocessable_entity end end # .... end end
If you’re using database-persistence you need to add a validation check similar to the above:
# app/controllers/steps_controllers/house_steps_controller.rb # database-persistence + id-in-URL approach module StepsControllers class HouseStepsController < ApplicationController # ... def update @house = House.find(params[:house_id]) # Use #assign_attributes since render_wizard runs a #save for us @house.assign_attributes house_params render_wizard @house if @house.valid? render_wizard @house else render_wizard @house, status: :unprocessable_entity end end # .... end end
This change has to do with the last step of the form being a Turbo Drive navigation and the exclusive status codes that Turbo Drives respond to, but I’ll leave that for another post! There may also be changes to Wicked in the future that automatically respond with a 422 status for us, but until then, this does the trick!
Party 🎉
That’s it! You’ve now got yourself a Turbo Framed form experience! Boom! 💥 As always if you want to double check some working source code or play with an already-running version of a Turbo’d form, feel free to check out Part 9, where every variation of wizard discussed in this series is running live.
Onward!
Good news, friends! We’re just about done. Let’s take a look at some customizations and other options that might suit your business needs better, then we’ll wrap it up with a recap of goals. Ready? → Part 7. Mods & Options
Comments? Thoughts?
Anonymous
Since you are updating the turbo frame content using turbo stream you might consider using the data: { turbo_action: :advance} so the URL on the page also advances. This way if a user refreshes the page they will be on the correct step in the process.
<%= form_with model: @house,
url: wizard_path,
method: :patch,
class: "animate-form-in",
data: { turbo_action: :advance } do |form| %> <- the magic sauce
Eri
Hello Jon and thanks a lot for your tutorial.
Everything works great for me except the last step of the form. I am using cache persistence and In-Session Routing. When adding the “data: { turbo_frame: :_top }” in the view of the last step, if this last step validates, my record is saved and the redirection works fine. But if some validation fails, it renders.. a “naked” form (I mean the turbo-frame only and not what is supposed to surround it). I guess since I told him the next step woud not be a turbo frame, the wizard get confused and “exit” the turbo (sorry if my english is not great).
If I remove the “data: { turbo_frame: :_top }”, when failing to validate, the turbo renders fine, but if the validation pass, of course, it renders nothing, since it’s looking for the next turbo frame and do not find it.
Do you have any idea what could cause that ? (I am on ruby 3.1.2p20 and Rails 7.0.3)
Thank you !
Edit : I found out that since it re renders the whole page when validation fails in the last step, the “surroundings” (which are written in the first step page), it ‘s normal that it disappears. Indeed it exits the turbo. I found a way to deal with it and store the “surroundings” (what a weird way to call it haha) elsewhere so that it is now rendered in every case. But since the whole page re render, we lose the nice turbo “flow”. My poor users now have to scroll to see the form again :-(
Jon Sully (Author)
Greetings, Eri!
Yes, there is slightly odd workflow artifact that happens with Turbo Frames and form submissions similar to how you described. I’ve been hoping this gets resolved in Turbo for some time, but many PRs and discussions floating around the topic have consistently stagnated and/or used work-arounds: https://github.com/hotwired/turbo/pull/210
I hope that helps!
Jon
Eri
Thank you very much Jon, I’ll keep an eye on these discussions !