Rails Wizards Pt. 6

Series Breakdown
  1. Part 1: What’s a Wizard and Why are We Here?
  2. Part 2: Choose Your Data Persistence
  3. Part 3: Choose Your URL Strategy
  4. Part 4: Model Validations
  5. Part 5: Routing and Controllers
  6. Part 6: Add Hotwire / Turbo!
  7. Part 7: Other Modifications / Options
  8. Part 8: Did We Do the Thing?
  9. 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


Header Photo / Banner by: @kellysikkema

Join the Conversation

Latest Blog Posts