Multistep Form (Wizard) — Rails Recipes
Anatomy of a Multistep Form
A multistep form is a variant of a wizard. A wizard is a usability tool. It represents a task as a set of steps arranged in a prescribed order. Users must complete each step in the given order to achieve its goal.
Multistep forms split complex forms into smaller parts, grouping their fields by different criteria. This results in a less daunting form-filling experience for end users. When combined with instructions, multistep forms significantly increase the ease of use of our applications.
Their main drawback is efficiency. Filling a multistep form usually takes more time than filling a single form (if you are familiar with the process). This is due to the navigation between steps, which does not exist in standard forms.
How you implement this navigation can significantly impact the user experience: will you allow users to navigate back and forth to any step in the wizard or just to the next and previous steps? is previous step information visible in the current step? Bad design choices can lead to slow and frustrating form-filling experiences that discourage users from filling out our form and even using our system.
This recipe will teach you to create reusable and customizable multistep forms in Rails.
The Challenge
In our application we manage Projects. A project is defined by a name and a description. A Project can have multiple Books associated to it. A Book is defined by a title and a description. We want to implement a multistep form to create Projects. The first step will require the project basic information, and the second, optional, information about a Book associated to the Project.
The Recipe
Prerequisites
(Required) Rails **ViewComponent **gem installed: ViewComponent wraps Rails views into objects. This makes it easier to test our views and provides additional methods for a better developer experience working with them.
(Optional) *TailwindCSS *PostCSS plugin installed: this CSS framework removes the need to write CSS in our code by providing a powerful set of CSS classes.
Model
In Rails, we use forms to create and update records. These operations are performed via POST, PUT, and PATCH HTTP requests submitted from a single form. Requests are processed following the same process:
Rails Controller layer parses form request data and asks the Model layer to perform the requested operation with the extracted information.
Rails Model layer processes request data, validates it, and performs the requested operation if it is valid. The result of the operation is sent back from the Model to the Controller.
Rails Controller layer requests the View layer to render a response based on the operation outcome. Successful operations render a view of the operation results. Unsuccessful operations render the submitted form with the form request data and error messages.
In a multistep form, operations only succeed when all steps are completed. Unsuccessful operations will re-render the multistep form, displaying the next step to complete. Once the last step is completed and submitted, the operation is then performed.
Our first task is to implement the logic to validate whether all steps in a multistep form are completed.
Rails ActiveRecord::Validations API, included in ApplicationRecord Model classes, defines the valid? method. This method is executed on every creation *and *update operation. It evaluates all Model validation conditions, returning true when all validations pass. Only valid? operations can persist information in the database.
The validation for our multistep form must check the current step index in the form request corresponds to its last step. We need two attributes to track these variables: current_step to store the current step index and total_steps to store the number of steps in a multistep form. Since both the validation and the attributes are the same for all models, we can encapsulate them in a concern that can be reused across our Model classes:
# app/models/concerns/multistep_form_model.rb
module MultistepFormModel
ERROR_ATTRIBUTE = :current_step
ERROR_DETAIL = :incompleted_multistep_form
extend ActiveSupport::Concern
included do
# Defines two new attributes in including Model class
attr_accessor :current_step, :total_steps
# Adds new validation rule in including Model class
validate :all_multistep_form_steps_completed
end
private
def all_multistep_form_steps_completed? = current_step == total_steps
def all_multistep_form_steps_completed
errors.add(ERROR_ATTRIBUTE, ERROR_DETAIL) unless all_multistep_form_steps_completed?
end
end
The all_multistep_form_steps_completed validation will be checked in all creation and update operations. Since there might be scenarios where you want to update records without using a multistep form, we need to ensure the validation is only checked when requests are submitted from a multistep form.
# app/models/concerns/multistep_form_model.rb
module MultistepFormModel
...
included do
# Adds new validation rule in including Model class
validate :all_multistep_form_steps_completed,
if: -> { current_step.present? && total_steps.present? }
end
...
end
Adding this if statement, we ensure the validation is executed only when both current_step and total_steps attributes have a value (which will happen when using multistep forms only).
Finally, we can update our models by including our multistep form concern so that we can use them with our multistep form:
# app/models/project.rb
class Project < ApplicationRecord
...
# Now our Project class has current_step and total_steps attributes, and the multistep validation rule
include MultistepFormModel
...
end
Controller
Controllers are responsible for parsing form request data and passing it down to the Model class. To work with multistep forms, we need to update the controller’s strong parameters to ensure the **curret_step **and **total_steps **parameters are parsed and passed to the Model class.
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
...
private
...
def project_params
params
.require(:project)
.permit(:name, :description, :total_steps, :current_step, books_attributes: [:title, :description])
end
...
end
View
Let’s decompose a multistep form in ViewComponents. A multistep form is a form split into multiple parts (steps). In terms of components, this can be modeled as a MultistepFormComponent whose content is composed of multiple StepComponents.
Each step contains a set of form fields. A step is completed when all step-required fields are filled with valid data. A **StepComponent **renders the fields corresponding to a multistep form step and provides a simple interface to know, given the form data, if the step it represents is completed.
We previously talked about the valid? method and how it determines whether form data is valid. In addition to this behavior, **valid? **also updates the **Model **object with a dictionary containing validation errors. It can be accessed via model.errors, which returns an ActiveModel::Errors object.
To determine if a StepComponent is completed, we can use the form.object.errors object. Given a step with a set of input fields, we can confirm the step is completed if the form.object.errors object does not include any error associated with the step fields.
# How to check whether a step is completed.
def completed?(form_object)
input_attributes.none? { |attr| form_object.errors.key?(attr) }
end
This method also reveals the information a step needs to be rendered:
An ActionView::Helpers::FormBuilder object to render the step fields and fetch the form data via the .object method.
A list of the input_attributes fields it renders.
Not revealed from the method but obvious, we also need to know the index of the step in the multistep form.
With this in mind, we can write down our StepComponent class:
# app/components/common/multistep_form_component/step_component.rb
module Common
class MultistepFormComponent::StepComponent < ApplicationComponent
class << self
def title = raise "StepComponent #{name} does not define title method"
def input_attributes = raise "StepComponent #{name} does not define list of input attributes"
def completed?(form_object)
input_attributes.none? { |attr| form_object.errors.key?(attr) }
end
end
attr_reader :multistep_component, :index, :form
def initialize(multistep_component:, index:, html_attributes: {})
@index = index
@form = multistep_component.form
@multistep_component = multistep_component
super(html_attributes:)
end
def call
raise "
#{self.class.name} is a StepComponent. StepComponents are abstract by default.
Components inheriting from it must define its own template or call method.
"
end
def current_step? = index == multistep_component.current_step
def completed? = self.class.completed?(multistep_component.form.object)
end
end
We slightly changed our previous specification and required a MultistepFormComponent instance instead of a FormBuilder object to initialize the component. This will allow our StepComponents to access additional information from the parent component.
The **StepComponent **is an abstract component. It is not designed to be rendered but extended by other StepComponent classes. For our Project form steps, we will define two new components, each one for each steps we want to render:
# app/components/common/projects/form_component/project_details_component.rb
module Common
class Projects::FormComponent::ProjectDetailsComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "Project Details"
def input_attributes = %w[name]
end
...
end
end
# app/components/common/projects/form_component/project_deliverables_component.rb
module Common
class Projects::FormComponent::ProjectDeliverablesComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "Project Deliverables"
# Example of a step component with nested attributes / nested form fields
def input_attributes = %w[books.title]
end
...
end
end
The **MultistepFormComponent **is also an abstract component. It defines the list of StepComponents which compose the multistep form and is responsible for rendering them in the given order. Internally the component renders a form in which steps are placed. To generate the form, the component requires:
The URL where the form data will be submitted.
And the form data itself. This data represents the record we want to create or update. In each iteration of the multistep form cycle, the data will represent the information provided by the user in the previous form submission.
The multistep form must also render hidden fields for the current_step and total_step attributes, ensuring these attributes are present in the submission and triggering the model validation logic:
# app/components/common/multistep_form_component.rb
module Common
class MultistepFormComponent < ApplicationComponent
class << self
def steps = raise "#{name} MultistepFormComponent does not define steps class model"
end
delegate :steps, to: :class
attr_reader :form_url, :back_url, :model, :form
def initialize(form_url:, back_url:, model:, html_attributes: {})
@form_url = form_url
@back_url = back_url
@model = model
super(html_attributes:)
end
def call
form_with(url:, model: model) do |form|
@form = form
concat(form.hidden_field(:total_steps, value: steps.count))
concat(form.hidden_field(:current_step, value: current_step_index))
steps.each_with_index do |step, index|
concat(render step.new(multistep_form: self, index:))
end
end
end
end
end
In the code above, the call method renders all steps StepComponents in the given order. When the form is rendered, we store a reference to the form builder object in the @form attribute. This will allow each step component instance to access the builder object to render their form fields.
We can now define our project FormComponent by extending the class and specifying the step of StepComponent classes that define its form:
# app/components/common/projects/form_component.rb
module Common
class Projects::FormComponent < Common::MultistepFormComponent
class << self
def steps = [ProjectDetailsComponent, ProjectDeliverablesComponent]
end
end
end
Let’s now define the logic for navigating between steps. Our current component displays all form steps at once. The goal is to show one step at a time and be able to show and hide others’ steps when navigating back and forth in the form.
There are multiple ways to navigate between steps: some only allow navigation forward until all steps are completed; in others, it is possible to navigate back and forth one step and a time, and others even allow displaying multiple previous steps at once. Our multistep form should be flexible enough to implement all possible navigation strategies. However, it should not implement any navigation strategy. We will delegate it to the components extending the MultistepFormComponent class.
We can hide and show steps using simple checkbox and radio HTML inputs. A checkbox is a simple box that can be checked. A radio is a group of checkboxes in which only one can be checked. CSS can target the checked status of these elements via the :checked selector. This allows styling HTML elements based on the status of a checkbox or radio.
We can assign a radio and checkbox input to each step in our form and style the step according to its state so it is only visible when the corresponding input is checked. We will define a new method to wrap each step instance within a tag containing a radio and checkbox to achieve it.
# app/components/common/multistep_form_component.rb
...
def call
...
steps.each_with_index do |step, index|
concat(step_wrapper(step, index) { render step.new(multistep_form: self, index:) })
end
...
end
def radio_id(step_index) = "#{object_id}_radio_#{step_index}"
def checkbox_id(step_index) = "#{object_id}_radio_#{step_index}"
def step_wrapper(step, index, html_attributes: {}, &content)
tag.div(**html_attributes) do
concat(
radio_button_tag(
radio_id(nil),
1,
current_step_index == index,
id: radio_id(index),
class: "hidden peer/radio"
)
)
concat(
check_box_tag(
checkbox_id(index),
1,
current_step_index == index,
id: checkbox_id(index),
class: "hidden peer/checkbox"
)
)
concat(capture(&content))
end
end
...
The step_wrapper method yields the steps in a div tag together with a checkbox and radio element. Via two new id methods, we assign an id and name to them. This allows targeting the inputs from buttons that can check and uncheck the input they point to when clicked. Finally, we use the tailwind hidden class to hide the inputs and the peer class to style the sibling step container based on the input checked status.
Component extending the MultistepFormComponent can update this method defining common elements present in all steps and using the peers to show and hide steps based on the checked status.
# app/components/common/projects/form_component.rb
...
def step_wrapper(step, index, html_attributes: {}, &content)
selected = index == current_step_index
options = (selected ? { "aria-current": "step" } : {})
super(step, index, html_attributes: options) do
concat(
tag.label(
for: index <= current_step_index ? radio_id(index) : "",
class: "
w-full inline-flex items-center px-4 py-2 gap-4 border-2 text-zinc-500 border-zinc-500
peer-checked/radio:bg-zinc-500 peer-checked/radio:text-white
"
) do
concat(tag.p(index + 1, class: "h-6 w-6 text-center rounded-full text-white bg-zinc-500"))
concat(tag.p(step.title))
end
)
concat(
tag.div(
class: "
hidden px-4 py-6
peer-checked/radio:block
"
) do
concat(capture(&content))
concat(
tag.div(class: "mt-5 w-full inline-flex gap-2 items-center justify-end") do
concat(link_to("cancel", back_url, class: "px-4 py-2 bg-zinc-500 text-white")) if index == 0
concat(tag.label("previous", for: radio_id(index - 1), class: "px-4 py-2 bg-zinc-500 text-white")) if index > 0
concat(form.submit("next", class: "cursor-pointer px-4 py-2 bg-zinc-500 text-white")) if index < (steps.count - 1)
concat(form.submit("create project", class: "cursor-pointer px-4 py-2 bg-zinc-500 text-white")) if index == (steps.count - 1)
end
)
end
)
end
end
...
The project FormComponent extends the step_wrapper method to wrap our steps with a header and a set of navigation buttons. In lines 13 and 24 we can see some peer-checked/radio prefixed CSS classes. They style the step wrapper when the radio checkboxes are checked, revealing it. Our steps are hidden by default and only revealed when the sibling radio checkbox is checked. We use the radio to display one step at a time. If we want to display multiple steps simultaneously, we should target the checkbox instead.
In line 31 there is the logic to navigate a step back in the form. We use the MultistepFormComponent radio_id method to map the label to the radio input from the previous step. When we click the label, the radio button will be checked. This will hide the current step and reveal the previous one.
To navigate forward, we always need to submit the form. If, after form submission, all previous steps are completed, the multistep will render the next step. Otherwise, the form will step back to the incompleted step.
# app/components/common/multistep_form_component.rb
...
def first_incompleted_step_index
steps.find_index { |s| !s.completed?(model) }
end
def current_step_index
previous_step = model.current_step&.to_i
@current_step_index =
if model.current_step.nil?
0
else
next_step = previous_step + (steps[previous_step].completed?(model) ? 1 : 0)
[next_step, first_incompleted_step_index].compact.min
end
end
...
Updating the MultistepFormComponent with these methods provides a way for steps to determine which is the current step in the form. If a specific multistep form defines a different, forward navigation logic, the current_step_index can be overwritten to implement that specific navigation logic. The MultistepFormComponent can use this method to set the status of the radio and checkboxes:
# app/components/common/multistep_form_component.rb
...
def step_wrapper(step, index, html_attributes: {}, &content)
tag.div(**html_attributes) do
concat(
radio_button_tag(
...
# If true radio is checked
current_step_index == index,
...
)
)
concat(
check_box_tag(
...
# If true checkbox is checked
current_step_index == index,
...
)
)
concat(capture(&content))
end
end
...
Finally, we need to handle errors. Every time the form is submitted, all validations are checked. This can lead to errors associated with form fields in steps the user did not visit, resulting in error messages displayed before filling out the form. On top of that, the current_step error is always registered. We use this error to trigger the rendering of new steps, but we must avoid displaying its associated error message (the user should be unaware of it).
We will add a new method responsible for removing all error messages related to steps the user did not visit and another to delete the current_step error. To achieve this, we will add a third attribute responsible for tracking the latest step in the form the user filled in so we can determine which steps remain unvisited.
# app/components/common/multistep_form_component.rb
...
def call
...
concat(form.hidden_field(:total_steps, value: steps.count))
concat(form.hidden_field(:latest_step, value: latest_step_index))
concat(form.hidden_field(:current_step, value: current_step_index))
...
end
end
...
def latest_step_index
@latest_step_index =
if model.latest_step.nil?
0
else
[current_step_index, model.latest_step].max
end
end
...
Tests
There are two key elements to test for our multistep form: our Model concern and the MultistepFormComponent. Additional unit and system tests for the controllers, project components, and the whole user flow of creating projects are also essential. Still, they do not involve any special configurations as these others do.
To test the MultistepFormModel concern, we might be tempted to test the current_step validations via the Project model. This is not a good practice since it could lead to flaky tests: we must not test concerns via Models that might not include or extend them in the future.
Instead, we can define an auxiliary Model class with the only purpose of testing the concern, and then run the tests with it. This approach has two benefits: testing the concern without relying on Model classes from our business logic and documenting how the concern can be used in our Model classes.
class MultistepFormModelTest < ActiveSupport::TestCase
class ValidMultistepFormMode
include ActiveModel::Model
include MultistepFormModel
end
def setup
@model = ValidMultistepFormMode.new
end
# Validations
# -----------
test "MultistepFormModel does not apply step validation if total and current attibutes are not specified" do
assert_nil @model.current_step
assert_nil @model.total_steps
assert @model.valid?
end
test "MultistepFormModel is valid only when total and current steps are the same" do
@model.current_step = 1
@model.total_steps = 2
refute @model.valid?
@model.current_step = 3
@model.total_steps = 3
assert @model.valid?
end
test "MultistepFormModel sets custom error attribute and detail when total and current step attributes are invalid" do
@model.current_step = 1
@model.total_steps = 2
refute @model.valid?
assert_equal @model.errors.first.attribute, MultistepFormModel::ERROR_ATTRIBUTE
assert_equal @model.errors.first.detail, MultistepFormModel::ERROR_DETAIL
end
end
To test the MultistepFormModel we can follow the same approach, defining a set of test components that inherit from the MultistepFormComponent and StepComponent classes just for testing. Here we will check the component sets the correct current_step according to our logic by passing different Model data. Since we do not need to interact with the form (click buttons, run javascript code, etc), we can use unit tests instead of slower system tests.
class Common::MultistepFormComponentTest < ViewComponent::TestCase
class SampleModel
include ActiveModel::Model
include MultistepFormModel
attr_accessor :attribute_1, :attribute_2, :attribute_3
validates_presence_of :attribute_1
validates_presence_of :attribute_2
validates_presence_of :attribute_3
end
class TestMultistepFormComponent < Common::MultistepFormComponent
class << self
def steps = [FirstTestStepComponent, SecondTestStepComponent, ThirdTestStepComponent]
end
end
class FirstTestStepComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "First Step"
def input_attributes = %i[attribute_1]
end
def call
tag.div(**wrapper_attributes)
end
end
class SecondTestStepComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "Second Step"
def input_attributes = %i[attribute_2]
end
def call
tag.div(**wrapper_attributes)
end
end
class ThirdTestStepComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "Third Step"
def input_attributes = %i[attribute_3]
end
def call
tag.div(**wrapper_attributes)
end
end
def setup
@component = TestMultistepFormComponent
end
test "Renders multistep form" do
render_inline(@component.new(form_url: "test", back_url: "test", model: SampleModel.new))
assert_selector(".#{@component.component_class}")
end
test "Renders multistep form steps" do
render_inline(@component.new(form_url: "test", back_url: "test", model: SampleModel.new))
assert_selector(".#{FirstTestStepComponent.component_class}", visible: :all)
assert_selector(".#{SecondTestStepComponent.component_class}", visible: :all)
assert_selector(".#{ThirdTestStepComponent.component_class}", visible: :all)
end
test "Selects first step as current step if current_step value is nil" do
model = SampleModel.new
model.valid?
result = @component.new(form_url: "test", back_url: "test", model:).current_step_index
assert_equal 0, result
end
...
end
Conclusions
Multisteps in Rails are tightly coupled to the controller and model layers. To keep our business logic as separate as possible from this component, we rely on concerns and abstract components. These tools work as a plug & play logic that makes it extremely easy to use multisteps in any part of our application.
The abstract nature of the MultistepFormComponent simplifies the task of implementing forms with different designs and navigation logics, by delegating the implementation of this logic to the components that extend it.
Finally, by writing some generic tests that rely on agnostic test Model and Component classes, we ensure the correct functioning of the multisteps across all our applications and provide a detailed and well-documented example of how developers can make use of the multistep logic in the future.
Code in Github
References
Any Questions?
If you find yourself lost at any point in the article or try to replicate my solution with no result, you can reach me in the comments section. I will be glad to provide additional information and try to clarify open questions. Soon I will implement additional channels for sharing feedback and questions.
If you have reached this point, thank you for your time and patience. I hope you found my tutorial helpful!
Top comments (0)