Rails Wizards Pt. 5

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!

Routing and Controllers

So we’ve got most of the pieces in place at this point, we just need to set up our controllers to coordinate the whole process. As I noted in Part 1, one of the things I’d like to avoid is having a controller for each step of the wizard. I’ve worked with wizards set up this way in the past and there’s just a lot of code duplication. I think we can avoid that.

This is going to look slightly different depending on which data-persistence strategy and which routing/URL strategy you chose, but the high level works in the following way. For starters, we’ll have a ‘top-level’ resource controller that’ll be pretty standard-Rails. Continuing with the houses example, that would be our HousesController. The goal with the HousesController is to retain a mostly-default, very RESTful interface for both HTML and JSON clients — this is where a POST with all necessary attributes should continue to execute a successful create.

The one thing we will change regardless of strategy choice is the new method. We’ll be aiming to capture the spirit of the new method (which I explored in “‘New’ and ‘Edit’s RESTful-ness (Rails)” so feel free to give that a read) by giving users an interface to create a new record (the wizard), but the new method itself will instead create the data-persistence ‘space’ for our wizard then redirect the user to our wizard.

We should only need one other controller to facilitate the user’s movements through all of the steps of the wizard. You can name it anything that makes you happy, but I tend to call these ‘steps controllers’ and if my project has multiple wizards, tend to put them in a dedicated directory. In practice, I have ~/app/controllers/ which contains steps_controllers/ and within that directory have house_steps_controller.rb and car_steps_controllers.rb etc. You don’t need to nest inside of steps_controllers/ if you’d prefer not to — totally up to you. Our steps controllers are going to use the well-known Wicked gem to handle some of the stateless ‘move-through-the-wizard’ logic, so go ahead and add that to your Gemfile and bundle.

Before diving into actual controller code, we need to talk routes. I know we already did that, but I mean in the actual routes.rb file sense this time. We need to actually encode the strategy we chose in Part 3.

For In-URL Routing

The routing is actually pretty simple when you keep the data-persistence ID in the route! For the House model, it looks like this:

resources :houses do
  resources :steps, only: [:show, :update], controller: 'steps_controllers/house_steps'
end

#=> URLs like:
# example.com/houses/1                        (viewing existing house)
# example.com/houses/99/steps/address_info    (building a new House)
# example.com/houses/99/steps/house_info      (building a new House)

This routing setup allows us to get our standard resourceful routes like /houses, /houses/21, etc. but also get the wizard routes as /houses/19/steps/address_info and /houses/19/steps/house_info (Wicked takes care of converting the show path string into the form step name automatically). Neato!

For In-Session Routing

Since we’ll be managing the underlying partial-data-store record key via the user’s session, it actually doesn’t matter too much where we position the routing for the xyz_steps_controller. It can be nested within another resource (like above) or totally on its own. Just remember that the last chunk of the URL path will always be the wizard step name. Yay flexibility! Here’s a few examples showing both the main-object route and the steps-controller route:

# Outside of main resource route
resources :houses
resources :build_house, only: [:update, :show], controller: 'steps_controllers/house_steps'

#=> URLs like:
# example.com/houses/14                  (viewing existing house)
# example.com/build_house/address_info   (building a new House)
# example.com/build_house/house_info     (building a new House)

# Outside of main resource route but within an umbrella resource
resources :accounts do
  resources :houses
  resources :build_house, only: [:update, :show], controller: 'steps_controllers/house_steps'
end

#=> URLs like:
# example.com/accounts/abc_xyz/houses/14                   (viewing existing house)
# example.com/accounts/abc_xyz/build_house/address_info    (building a new House)
# example.com/accounts/abc_xyz/build_house/house_info      (building a new House)

As with all things routing, feel free to get creative here. You may be able to override namespaces and scopes along with the steps_controllers naming to get something a little cleaner, but I’ll leave that up to you depending on your other strategies!

Personally, I like the aesthetics of the in-session routing URLs here more than the in-URL routing. I think it gives users the sense that they’re not looking at a particular/existing house when they’re building one; it obscures that we’re persisting data and instead gives the user the idea that they haven’t actually made an object yet… which is true!

The Controller(s)

Alrighty, let’s take the high level summary from above and turn it into a real controller! There are basically four combinations here depending on your data-persistence strategy and routing-strategy, so I’m going to cover all four. I recommend reading at least the first one (Database Persistence + In-URL Routing) since that’s where I cover most of the ‘meat’ of the workflow. You should feel free to skip to Part 9 if you prefer to just see them in action / play with them yourself, but make sure to come back and read the ‘why’!

Database Persistence + In-URL Routing

We’re going to assume our routes.rb contains this for our House setup (same as above):

resources :houses do
  resources :steps, only: [:show, :update], controller: 'steps_controllers/house_steps'
end
#=> URLs like: /houses/14/steps/house_info

Let’s start with our ‘top-level’ controller as described above. Like I mentioned, the only method we’re overriding from the stock Rails controller that was generated when we scaffolded the model is the new method, so I’m only going to show the code for that. If you want to see the entire top-level controller code, the demo site in Part 9 shows all of its underlying code within the site (this would be the “Users” wizard).

Since we went with the database-persistence route, we will be creating a new record every time the wizard is started. That means we’re going to override the new method to create a new, empty record (and not validate it) then redirect to the beginning of the steps controller for that particular new record. Out of the ‘many flavors’ of database-persisted styles I mentioned in Part 2, this is the “all of the fields are NULL and we fill them in by chunks” setup. Here’s the code:

# app/controllers/houses_controller.rb
class HousesController < ApplicationController
  # ...
  # GET /houses/new
  def new
    @house = House.new
    @house.save! validate: false
    redirect_to house_step_path(@house, House.form_steps.keys.first)
  end
  # ...
end

The workflow will work something like this:

  1. The user navigates to whichever page has the “create new House” button on it (probably the House index), and the button will link to the new_house_path standard Rails path helpers
  2. When Rails receives the GET for the new_house_path it will generate a stub (empty) House object (let’s say id = 5) then redirect the user to the house_step_path passing in both the empty @house and the first wizard step name (which we’re pulling from the form_steps enum on the model itself)
  3. The user follows the redirect and GETs /houses/5/steps/house_info which goes to the house_steps_controller

Here’s the code behind the house_steps_controller:

# app/controllers/steps_controllers/house_steps_controller.rb
module StepsControllers
  class HouseStepsController < ApplicationController
    include Wicked::Wizard

    steps *House.form_steps.keys

    def show
      @house = House.find(params[:house_id])
      render_wizard
    end

    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
    end

    private

    # Only allow the params for specific attributes allowed in this step
    def house_params
      params.require(:house).permit(House.form_steps[step]).merge(form_step: step.to_sym)
    end

    def finish_wizard_path
      house_path(@house)
    end
  end
end

Pretty short, right? Let’s pick up the user workflow with #3 above:

  1. The user follows the redirect and GETs /houses/5/steps/house_info which goes to the house_steps_controller’s show action. The house_id is in the URL (5) so Rails grabs it and exposes it as params[:house_id] and similarly, Wicked grabs the last chunk of the URL and exposes it as step
  2. We setup the variable @house (to be passed to the view) then call render_wizard with no arguments, causing Wicked to render the view name matching the step (house_info.html.erb)
  3. Finally, app/ views/ steps_controllers/ house_steps/ house_info.html.erb gets rendered to the user

Let’s check out that view code real quick then continue our workflow:

Click to Open
<%# app/views/steps_controllers/house_steps/address_info.html.erb %>

<%= form_with model: @house, url: wizard_path do |f| %>
  <% if f.object.errors.any? %>
    <div class="error_messages">
      <% f.object.errors.full_messages.each do |error| %>
        <p><%= error %></p>
      <% end %>
    </div>
  <% end %>

  <fieldset>
    <legend>Address Info</legend>

    <div>
      <%= f.label :address %>
      <%= f.text_field :address %>
    </div>

    <div>
      <%= f.label :current_family_last_name %>
      <%= f.text_field :current_family_last_name %>
    </div>

    <div>
      <%= f.submit 'Next Step' %>
    </div>
  </fieldset>
<% end %>

Pretty simple form_with that targets the wizard_path (another Wicked feature) and allows the user to fill out the house’s address and the current family last name then click ‘Next Step’. Continuing with the workflow:

  1. The user completes the form with their address and current family last-name and click ‘Next Step’
  2. The form submit triggers a PATCH back to the house_steps_controller which routes to the update method
  3. We get the particular @house thanks to its ID being in the URL path (params[:house_id]), then we assign the incoming params to the object
    • One neat trick here is that we only actually permit the attributes the model intends to be updated in this step so the user can’t inject rogue attributes not meant to be handled at this step in the wizard (the .permit(House.form_steps[step]) bit)
    • We specifically use #assign_attributes rather than #update (more on that in a moment)
  4. We also assign the current form step to the object to fill the attr_accessor :form_step we discussed back in Part 4 — this is how the conditional validations run. Wicked is actually telling the model which step the user is currently on as a means to know which validations to run; we don’t have to explicitly store step-state anywhere, the user working through the form is the state
  5. Finally we run render_wizard @house and the user either moves to the next wizard step or gets the same wizard step back if there were validation errors
    • We previously used #assign_attributes rather than #update because Wicked’s render_wizard @object method (1) saves the @object then (2) renders the next wizard step if the save was successful or renders the same wizard step if the object failed to save (so the user can see and fix the validation errors). There’s no need for us to save the object twice and Wicked will do it when we run render_wizard, so we just use #assign_attributes instead of #update.
  6. Repeat that process for the rest of your wizard steps until the final one
  7. On the final step Wicked uses the finish_wizard_path we defined and redirects the user there. In this case, the show method on the top-level HousesController to see their completed wizard data in the fancy show view (this is pretty standard)

And voilà! We have ourselves a fully functioning and working wizard with database persistence and in-URL ID-routing! This strategy should allow you to hook up model callbacks and all sorts of other functionality with ease based on the object’s form_step attribute — you are fully ready to use the mid-progress status of this wizard as part of your domain model since you can actively see which columns are hydrated or left empty on a per-step basis!

While testing this strategy out myself I found that I really wanted some means of filtering out incomplete data. As it stands, calling House.last somewhere else in my application may yield me an empty House record and that just doesn’t seem useful. There are a few strategies for accomplishing this task. I’ve added them in Part 7.

See Part 9 for the rest of the code for the other two views (it’s about the same) and to see this setup working in action.

Database + In-Session Routing

Let’s assume our routes.rb includes the following code for this setup:

resources :houses
#=> URLs like: /houses/14
resources :build_house, only: [:show, :update], controller: 'steps_controllers/house_steps'
#=> URLs like: /build_house/house_info

Now, recalling that the idea with database persistence and in-session routing is that the database record ID for the ‘new record’ created when a user starts a wizard will be stored in the user’s session, there aren’t actually many modifications we need to make to the code shown above in the Database + In-URL Routing section.

We will need to change the new method on the top-level Houses controller slightly. We’ll still generate a new record and save it without validation, but this time we’re going to store the record ID in the session object then redirect the user to the build_house_path without passing a particular record:

# app/controllers/houses_controller.rb
class HousesController < ApplicationController
  # ...
  # GET /houses/new
  def new
    # If they already started building one, use that
    unless house_id = session[:house_id]
      @house = House.new
      @house.save! validate: false
      session[:house_id] = @house.id
    end
    redirect_to build_house_path(House.form_steps.keys.first)
  end
  # ...
end

The user-workflow I laid out in the Database + In-URL Routing section still holds mostly true here, only the URLs change. The user’s initial request to new_house_path (based on the above code) will redirect them to /build_house/address_info. Let’s see how the steps controller code changes when using in-session routing rather than in-URL:

# app/controllers/steps_controllers/house_steps_controller.rb
module StepsControllers
  class HouseStepsController < ApplicationController
    include Wicked::Wizard

    steps *House.form_steps.keys

    def show
      @house = House.find session[:house_id]
      render_wizard
    end

    def update
      @house = House.find session[:house_id]
      # Use #assign_attributes since render_wizard runs a #save for us
      @house.assign_attributes house_params
      render_wizard @house
    end

    private

    # Only allow the params for specific attributes allowed in this step
    def house_params
      params.require(:house).permit(House.form_steps[step]).merge(form_step: step.to_sym)
    end

    def finish_wizard_path
      house_path(@house)
    end
  end
end

Mostly the same here other than pulling the House key from session rather than params. The URLs the user follows throughout the wizard will reflect that change too, requesting /build_house/address_info and /build_house/house_info rather than /build_house/5/address_info and /build_house/5/house_info. Neat! The rest of the workflow follows suit from the above example and the view code is the exact same. 🎉

Voilà (again)! We’ve got a fully functioning and working wizard with database-persistence and session-based routing/URLs. You’ll still be able to hook up mid-progress callbacks and other hooks like above, this variant mostly just makes for prettier URLs. With this strategy we’ll still be subject to having un-finished records pop up in the list of House.all, but check Part 7 for a strategy to avoid that.

See Part 9 for the completed code and full example of this setup in action.

Cache Persistence + In-URL Routing

A Note: If you’re about to build this locally, please remember that Rails Cache is disabled by default for local development. You can enable it by running rails dev:cache.

As with the former two setups, here’s the routes.rb contents we’ll use for this workflow:

resources :houses
#=> URLs like: /houses/14
resources :build_house, only: [] do
  resources :step, only: [:update, :show], controller: 'steps_controllers/house_steps'
end
#=> URLs like: /build_house/abc-xyz/steps/address_info

You may notice that the routing isn’t quite as simple when using cache-persistence with in-URL routing. The reasoning behind this is that our top-level routes (/houses, /houses/24, /houses/12/edit, etc.) can’t be broken by our wizard routes. Attempting to merge in other routes or top-level routes just won’t work with this approach — in order to leverage the cache-key-in-URL model, we need a top level resource to represent that cache-key but we still need a sub-level resource to represent the wizard steps. Since neither of those can be the House resource itself, we need the dummy build_house only: [] resource to act as the top-level resource.

Our top-level Houses controller will once again only require changes from the scaffolded version for the new method. Here’s what the new method will look like in this workflow:

# app/controllers/houses_controller.rb
class HousesController < ApplicationController
  # ...
  # GET /houses/new
  def new
    house_builder_key = Random.urlsafe_base64(6)            # 6 is probably fine
    Rails.cache.fetch(house_builder_key) { Hash.new }       # If they already started building one, use that
    redirect_to build_house_step_path(house_builder_key, House.form_steps.keys.first)
  end
  # ...
end

The primary note here is that we’re generating a random 6-character string to act as the unique key to represent the space in our Rails cache that we’re storing all of the user’s form submissions along the way. That random string / cache key gets passed to our redirect_to in order to push the user to the wizard with that specific cache-key in the URL. If the user happens to be coming back to a wizard URL from a prior entry, we pull that data from the cache, otherwise we start a new hash.

On the steps controller side of things, the code isn’t changed too much:

# app/controllers/steps_controllers/house_steps_controller.rb
module StepsControllers
  class HouseStepsController < ApplicationController
    include Wicked::Wizard

    steps *House.form_steps.keys

    def show
      house_attrs = Rails.cache.read params[:build_house_id]
      @house = House.new house_attrs
      render_wizard
    end

    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
      end
    end

    private

    # Only allow the params for specific attributes allowed in this step
    def house_params
      params.require(:house).permit(House.form_steps[step]).merge(form_step: step.to_sym)
    end

    def finish_wizard_path
      house_attrs = Rails.cache.read(params[:build_house_id])
      @house = House.new house_attrs
      @house.save!
      Rails.cache.delete params[:build_house_id]
      house_path(@house)
    end
  end
end

We’re notably reading the house_attrs from the Rails cache at the cache-key in the URL (params[:build_house_id]) then continuously hydrating a new House object with the attributes but never actually saving it. This is so we can determine if the attributes are valid (by calling #valid?) as to respond to the user with the proper validation faults, but we never save the record as a persisted object. Because of this, we do our own validation check and use redirect_to_next next_step (two handy methods provided by Wicked) to push the user forward if the data is valid, and plain-old render_wizard with no arguments if the data is not valid — rendering the same step with the @house object that will have validation errors attached to it.

Once the user has completed the wizard, we added logic to the finish_wizard_path method to pull the attributes hash from the Rails cache and finally create / persist a single full record with those attributes. Once we have that final record, we instruct Wicked to redirect the user to the house_path for that completed record by returning the house_path(@house).

The views are mostly unchanged from the Database + In-URL strategy setup here except for one important part. Because each view will be given a @house object that is not persisted, form_with model: @house will assume that the form is meant to create the object and will default to using the POST method. We don’t want that! So we just need to update our form_with across each view to include method: :patch:

  <%# app/views/steps_controllers/house_steps/address_info.html.erb %>

- <%= form_with model: @house, url: wizard_path do |f| %>
+ <%= form_with model: @house, url: wizard_path, method: :patch do |f| %>
    <!-- ... error-displaying code ... -->
    <!-- ... form fields ... -->
  <% end %>

And voilà! Cache-persisted wizard with in-URL routing! As with the other examples, this code is really the bones of how the system works so please visit Part 9 to see the full code and play around with it yourself.

For Cache Persistence + In-Session Routing

A Note: If you’re about to build this locally, please remember that Rails Cache is disabled by default for local development. You can enable it by running rails dev:cache.

Here are the routes.rb we’re going to use for this workflow:

resources :houses
#=> URLs like: /houses/14
resources :build_house, only: [:update, :show], controller: 'steps_controllers/house_steps'
#=> URLs like /build_house/address_info

And, as with the others, this workflow only has a few key differences from the original Database + In-URL Routing workflow. Here’s the top-level controller’s new method

# app/controllers/houses_controller.rb
class HousesController < ApplicationController
  # ...
  # GET /houses/new
  def new
    Rails.cache.fetch(session.id) { Hash.new }
    redirect_to build_house_path(House.form_steps.keys.first)
  end
  # ...
end

Pretty slick! Similarly to the cache-persistence + In-URL Routing strategy, we setup a chunk of Rails Cache space for the user to store their data as they go along, but since we’re basing their persistence on their session, we can just use session.id as the cache key! Neat!

On the steps controller side of things, the code isn’t too different either:

# app/controllers/steps_controllers/house_steps_controller.rb
module StepsControllers
  class HouseStepsController < ApplicationController
    include Wicked::Wizard

    steps *House.form_steps.keys

    def show
      house_attrs = Rails.cache.read session.id
      @house = House.new house_attrs
      render_wizard
    end

    def update
      house_attrs = Rails.cache.read(session.id).merge house_params
      @house = House.new house_attrs

      if @house.valid?
        Rails.cache.write session.id, house_attrs
        redirect_to_next next_step
      else
        render_wizard
      end
    end

    private

    # Only allow the params for specific attributes allowed in this step
    def house_params
      params.require(:house).permit(House.form_steps[step]).merge(form_step: step.to_sym)
    end

    def finish_wizard_path
      house_attrs = Rails.cache.fetch(session.id)
      @house = House.new house_attrs
      @house.save!
      Rails.cache.delete session.id
      house_path(@house)
    end
  end
end

This is mostly the same as the cache-persistence + in-session routing strategy (including the final attribute pull & #save!) but where we were previously using the cache key of params[:build_house_id], we’re now just using the session.id as the cache key.

Once again, since we’re passing an un-persisted object to form_with in the view layer, we’ll need to explicitly add method: :patch to the form_with so that updates aren’t sent back via POST requests:

  <%# app/views/steps_controllers/house_steps/address_info.html.erb %>

- <%= form_with model: @house, url: wizard_path do |f| %>
+ <%= form_with model: @house, url: wizard_path, method: :patch do |f| %>
    <!-- ... error-displaying code ... -->
    <!-- ... form fields ... -->
  <% end %>

And that’s it! I tend to like this particular strategy as I feel it’s very clean from a code standpoint. If you want to see the full code beyond just these critical parts (or just want to play with it yourself) check out Part 9!

Onward!

Okay. That’s all four variations and pretty much the entire premise of how to do Wizards in Rails. Now we get to add fun stuff! Let’s move on to Part 6 and install Hotwire to get some Single-Page-App-like features going in our forms → Part 6. Turbo-Frame It!

Header Photo / Banner by: @kellysikkema

Join the Conversation

Latest Blog Posts