Rails Wizards Pt. 6
7 min read | April 19 2021
Series Breakdown
- Part 1: What’s a Wizard and Why are We Here?
- Part 2: Choose Your Data Persistence
- Part 3: Choose Your URL Strategy
- Part 4: Model Validations
- Part 5: Routing and Controllers
- Part 6: Add Hotwire / Turbo!
- Part 7: Other Modifications / Options
- Part 8: Did We Do the Thing?
- Part 9: See it in Action!
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/*
- address_info.html.erb
- house_info.html.erb
- 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 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 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 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!
5. 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
To see Hotwire/Turbo running yourself, check