Wizards are a common component in a lot of applications. Either for signing up new users, creating products, purchasing items and many more.
They can be tricky to manage once they get bigger and more complex. At Getaround, we have several wizards which don’t share their architecture. A common architecture cannot fit every use case with different needs, flow, and user experience.
To list new cars on our platform, hosts provide multiple pieces of information on the vehicle, themselves, and their needs. We may ask for more information or skip some steps. Such constraints lead to complexity and difficulty in handling and testing every variation.
After multiple iterations, we ended up with a modular architecture that was less strict than a decision tree and allowed us to design wizards with complex or simple logic.
In this article, I’ll try to guide you through building such a modular architecture. We’ll use a form object for each step and a Manager
to orchestrate everything.
ActiveModel
provides convenient modules to create custom form objects and manipulate attributes. We are going to use the following modules:
ActiveModel::Model
so that our form object behaves like a regular modelActiveModel::Attributes
to access submitted fields as attributesActiveModel::Validations
to benefit from convenient attribute validationsUsing these modules will also allow us to use Rails form helpers as if the manipulated object were an actual model. Many thanks to Intrepidd for sharing code that led to this base form.
Our BaseForm
would then look like this:
require "active_model"
class BaseForm
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations
attribute :car
def complete?
raise NotImplementedError
end
def next_step
raise NotImplementedError
end
def submit(params)
params.each { |key, value| public_send("#{key}=", value) }
perform if valid?
end
private
def perform
raise NotImplementedError
end
end
Let’s take the time to explain this code. First, we’re declaring attribute :car
because our form objects will be initialized with a Car
model. This object will be the source of truth, the one we will fill with new data and rely on to determine what’s missing from it.
complete?
will be the method called to know if a step has been successfully completed. In this method we can for example check if a particular attribute has been filled in on our car record.
next_step
handles the logic to compute what the next step will be. A step knows what the next one is because it will rely on what was submitted previously.
submit
is the method to submit our params from the associated form. It will only call perform
, supposed to be implemented on each form object, if all validations passed.
With this public interface, we can create as many steps as we want and they will create the flow by themselves using perform
to save data, and then complete?
and next_step
to handle going from one step to another.
Having steps handling themselves is great, but we still need some logic to initiate the wizard and determine which step the user is currently on.
Our Manager
object will handle this logic. It also can manage having available steps, and non-available steps. For instance, if we have steps A
, B
and C
, with step A
already being submitted. The next step to submit is B
, but I should be allowed to access step A
again if I want to correct what I submitted. Step C
is not accessible as long as step B
is not complete, it shouldn’t even be visible to the user.
Our Manager
could then look like this:
class Manager
FIRST_STEP = :country
STEP_FORMS = {
country: CountryForm,
insurance_provider: InsuranceProviderForm,
mileage: MileageForm,
}.freeze
def initialize(car:)
@car = car
@instantiated_forms = {}
@possible_steps = compute_possible_steps
end
def current_step
@possible_steps.find do |step|
return nil if step.nil?
form = find_or_instantiate_form_for(step)
!form.complete?
end
end
def form_for(step)
STEP_FORMS.fetch(step)
end
private
def compute_possible_steps
steps = []
steps_path(FIRST_STEP, steps)
steps
end
def steps_path(starting_step, steps_acc)
steps_acc.push(starting_step)
return if starting_step.nil?
form = find_or_instantiate_form_for(starting_step)
steps_path(form.next_step, steps_acc) if form.complete?
end
def find_or_instantiate_form_for(step)
@instantiated_forms.fetch(step) do
form_for(step)
.new(car: @car)
.tap { |form| @instantiated_forms[step] = form }
end
end
end
Every step is declared with its associated form object. At initialization, all possible steps are computed using the public complete?
, calculating one step after another with next_step
. The method form_for
will allow the controller to manipulate the right form object from the manager.
As we support multiple flows, the last step may not be the last one defined in the list. We then expect next_step
to return nil
when there’s no step left.
In the manager, I mentioned three steps, country
, insurance_provider
and mileage
. Let’s build them and see how with only 3 steps we can already have multiple flows.
This step will simply save the selected country on the car record. However, its next step will depend on what country was selected.
class CountryForm < BaseForm
ALLOWED_COUNTRIES = %w[CA ES PK JP].freeze
COUNTRY_REQUIRING_INSURANCE_PROVIDER = %w[ES].freeze
attribute :country, :string
validates_inclusion_of :country, in: ALLOWED_COUNTRIES
def perform
car.update!(country: country)
end
def complete?
!car.country.nil?
end
def next_step
if COUNTRY_REQUIRING_INSURANCE_PROVIDER.include?(car.country)
:insurance_provider
else
:mileage
end
end
end
First we can see how readable the attributes and validations are thanks to ActiveModel
. country
is a string attribute and we expect it to be one of the allowed countries, defined in a constant. The perform
method will only be called if the requirements are met, if valid?
returns true
.
complete?
only checks if country
has been successfully saved on the car record.
Finally, next_step
depends on the country selected. If an insurance provider is required in the country, then we’ll need the user to provide this data. If not, we decide to go directly to the next one, mileage
.
Of course we could improve things here, especially in perform
. We probably don’t want to use update!
which raises when it fails, but we still want to be sure what’s inside this method is properly executed. For the sake of simplicity I didn’t add such a logic here, but we can easily play with errors
available thanks to ActiveModel::Validations
.
Nothing particular to say about this one, except that it will be displayed only if the car’s country requires an insurance provider.
The next step is defined as mileage
, but from here we could imagine another branch in the decision tree, multiple flows, multiple possibilities.
class InsuranceProviderForm < BaseForm
attribute :insurance_provider, :string
def perform
car.update!(insurance_provider: insurance_provider)
end
def complete?
!car.insurance_provider.nil?
end
def next_step
:mileage
end
end
In our example, mileage
is the last step. Once a mileage integer is submitted, validated and saved, there’s no other step to go to. next_step
returns nil
to announce that the wizard is finished.
class MileageForm < BaseForm
attribute :mileage, :integer
validates :mileage, numericality: { greater_than: 0 }
def perform
car.update!(mileage: mileage)
end
def complete?
!car.mileage.nil?
end
def next_step
nil
end
end
We only need two routes to support this wizard: show
and update
. show
will display the step to the user while update
will handle the step submission.
# config/routes.rb
resources :car_wizards, only: %i[show update]
On the controller side, we’re supposed to let all the logic come from the manager and only handle rendering, form submission and redirections.
class CarWizardController < ApplicationController
before_action :initialize_variables, only: %i[show update]
def show
if @step == :current
redirect_to car_wizard_path(@car, @step_manager.current_step)
elsif @step_manager.possible_step?(@step)
set_form
render @step
else
render "errors/not_found", status: :not_found
end
end
def update
if !@step_manager.possible_step?(@step)
return render("errors/not_found", status: :not_found)
end
set_form
if @form.submit(params[:car])
redirect_to car_wizard_path(@car, @form.next_step)
else
render @step
end
end
private
def initialize_variables
@car = current_user.cars.incomplete.find(params[:car_id])
@step = params[:id].to_sym
@step_manager = Manager.new(car: @car)
rescue ActiveRecord::RecordNotFound
redirect_to root_path, error: "Car not found"
end
def set_form
@form ||= @step_manager.form_for(@step).new(car: @car)
end
end
Finally, each step has its associated view. A step view only needs a form helper instance, based on the form object, to display form fields. At Getaround, we also have an associated presenter for each step, which allows us to share information between web, web mobile and mobile apps.
simple_form_for @form, as: :car, url: car_wizard_path(@car, @step), method: :put do |f|
= f.select :mileage, car_mileage_options_for_select, label: t("car_wizard.steps.mileage.attributes.mileage.label")
With this architecture, we can build a complex wizard, with multiple flows. A user can stop and resume it any time, and it is possible to have many flows with many rules without having to write the entire logic in one single file, which would be much harder to understand and maintain.
Each step having its own logic allows us to test the flow step by step, independently. The simple public API helps us to test service perfoming logic and attribute validation separately.
It is easy to integrate into our MVC pattern with a very simple controller and basic views. The wizard manager itself is only a simple algorithm to compute possible steps.
However, having most logic inside the form objects forces us to read each step to understand how the flows work. If the whole wizards gets too complicated, computing each step could begin to take some time, so this is something to watch out on the long term.
Also, the step completion is based on saving things on database records. Having informational steps is a challenge to handle because we need to find other ways to store state to indicate that they have been seen, or rely on the state of adjacent steps.
For the simplicity of the article we haven’t show all the features we have based on the car wizard. For instance, we have a logic to handle tracking on each step automatically. Also, the mobile wizard is driven by the backend, based on a dedicated API. With this in mind, such an architecture allows us to reorder, add or remove any step without having to deploy a new version of our mobile apps. Well, to be honest, it’s a little bit more complicated than that depending on what the mobile app supports, but you get the idea.
This modular flow is one solution to the wizard problem. It won’t suit every need, but a similar architecture has its advantages if you seek to manage multiple flows with complex decision trees.
Feel free to comment and let us know what you think of this architecture and how we could improve it.
Cheers.