Rails Wizards Pt. 6

Jon Sully

6 Minutes

Routing and Controllers

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 !

Reply

Please note: spam comments happen a lot. All submitted comments are run through OpenAI to detect and block spam.