Rails Wizards Pt. 5
Jon Sully
17 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 (This page)
- • Apr 2021: Rails Wizards Pt. 6
- • Apr 2021: Rails Wizards Pt. 7
- • Apr 2021: Rails Wizards Pt. 8
- • Apr 2021: Rails Wizards Pt. 9
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 ruby>HousesController
. The goal with the ruby>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 ruby>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 ruby>/houses/19/steps/address_info
and ruby>/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:
- 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 - When Rails receives the GET for the
new_house_path
it will generate a stub (empty)ruby>House
object (let’s sayid = 5
) then redirect the user to thehouse_step_path
passing in both the empty@house
and the first wizard step name (which we’re pulling from theform_steps
enum on the model itself) - The user follows the redirect and
GET
s/houses/5/steps/house_info
which goes to thehouse_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:
- The user follows the redirect and
GET
s/houses/5/steps/house_info
which goes to thehouse_steps_controller
‘sshow
action. Thehouse_id
is in the URL (5
) so Rails grabs it and exposes it asruby>params[:house_id]
and similarly, Wicked grabs the last chunk of the URL and exposes it asstep
- We setup the variable
ruby>@house
(to be passed to the view) then callrender_wizard
with no arguments, causing Wicked to render the view name matching thestep
(house_info.html.erb) - 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:
<%# 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:
- The user completes the form with their address and current family last-name and click ‘Next Step’
- The form submit triggers a
PATCH
back to thehouse_steps_controller
which routes to theupdate
method - We get the particular
@house
thanks to its ID being in the URL path (ruby>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
ruby>.permit(House.form_steps[step])
bit) - We specifically use
#assign_attributes
rather than#update
(more on that in a moment)
- 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
- We also assign the current form
step
to the object to fill theattr_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 - 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’srender_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 runrender_wizard
, so we just use#assign_attributes
instead of#update
.
- We previously used
- Repeat that process for the rest of your wizard steps until the final one
- On the final step Wicked uses the
finish_wizard_path
we defined and redirects the user there. In this case, theshow
method on the top-levelruby>HousesController
to see their completed wizard data in the fancyshow
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 ruby>House.last
somewhere else in my application may yield me an empty ruby>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 ruby>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!
Comments? Thoughts?
Ryan Lundie
In the case where you have nested attributes for your model, you will need to use .deep_merge in place of .merge. This will insure all attributes are merged, even when they are nested. https://medium.com/@christhesoul/merge-and-deep-merge-and-why-one-day-youll-find-the-latter-useful-fd43f94c1226 explains the difference.
Jon Sully (Author)
That’s a great call! Good add 👍🏻